mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 12:10:45 +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
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;
|
||||
}
|
||||
54
openclaw/test/helpers/node-builtin-mocks.test.ts
Normal file
54
openclaw/test/helpers/node-builtin-mocks.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mockNodeBuiltinModule } from "./node-builtin-mocks.js";
|
||||
|
||||
describe("mockNodeBuiltinModule", () => {
|
||||
it("merges partial overrides into the original module", async () => {
|
||||
const actual = { readFileSync: () => "actual", watch: () => "watch" };
|
||||
const readFileSync = () => "mock";
|
||||
|
||||
const mocked = await mockNodeBuiltinModule(async () => actual, {
|
||||
readFileSync,
|
||||
});
|
||||
|
||||
expect(mocked.readFileSync).toBe(readFileSync);
|
||||
expect(mocked.watch).toBe(actual.watch);
|
||||
expect("default" in mocked).toBe(false);
|
||||
});
|
||||
|
||||
it("mirrors overrides into the default export when requested", async () => {
|
||||
const homedir = () => "/tmp/home";
|
||||
|
||||
const mocked = await mockNodeBuiltinModule(
|
||||
async () => ({ tmpdir: () => "/tmp" }),
|
||||
{ homedir },
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
|
||||
expect(mocked.default).toMatchObject({
|
||||
homedir,
|
||||
tmpdir: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves existing default exports while overriding members", async () => {
|
||||
const actual = {
|
||||
readFileSync: () => "actual",
|
||||
default: {
|
||||
readFileSync: () => "actual",
|
||||
statSync: () => "stat",
|
||||
},
|
||||
};
|
||||
const readFileSync = () => "mock";
|
||||
|
||||
const mocked = await mockNodeBuiltinModule(
|
||||
async () => actual,
|
||||
{ readFileSync },
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
|
||||
expect(mocked.default).toMatchObject({
|
||||
readFileSync,
|
||||
statSync: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
43
openclaw/test/helpers/node-builtin-mocks.ts
Normal file
43
openclaw/test/helpers/node-builtin-mocks.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
type MockFactory<TModule extends object> =
|
||||
| Partial<TModule>
|
||||
| ((actual: TModule) => Partial<TModule>);
|
||||
|
||||
function resolveMockOverrides<TModule extends object>(
|
||||
actual: TModule,
|
||||
factory: MockFactory<TModule>,
|
||||
): Partial<TModule> {
|
||||
return typeof factory === "function" ? factory(actual) : factory;
|
||||
}
|
||||
|
||||
function resolveDefaultBase(actual: object): Record<string, unknown> {
|
||||
const defaultExport = (actual as { default?: unknown }).default;
|
||||
if (defaultExport && typeof defaultExport === "object") {
|
||||
return defaultExport as Record<string, unknown>;
|
||||
}
|
||||
return actual as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function mockNodeBuiltinModule<TModule extends object>(
|
||||
loadActual: () => Promise<TModule>,
|
||||
factory: MockFactory<TModule>,
|
||||
options?: { mirrorToDefault?: boolean },
|
||||
): Promise<TModule> {
|
||||
const actual = await loadActual();
|
||||
const overrides = resolveMockOverrides(actual, factory);
|
||||
const mocked = {
|
||||
...actual,
|
||||
...overrides,
|
||||
} as TModule & { default?: Record<string, unknown> };
|
||||
|
||||
if (!options?.mirrorToDefault) {
|
||||
return mocked;
|
||||
}
|
||||
|
||||
return {
|
||||
...mocked,
|
||||
default: {
|
||||
...resolveDefaultBase(actual),
|
||||
...overrides,
|
||||
},
|
||||
} as TModule;
|
||||
}
|
||||
9
openclaw/test/helpers/normalize-text.ts
Normal file
9
openclaw/test/helpers/normalize-text.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { stripAnsi } from "../../src/terminal/ansi.js";
|
||||
|
||||
export function normalizeTestText(input: string): string {
|
||||
return stripAnsi(input)
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("…", "...")
|
||||
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?")
|
||||
.replace(/[\uD800-\uDFFF]/g, "?");
|
||||
}
|
||||
24
openclaw/test/helpers/pairing-reply.ts
Normal file
24
openclaw/test/helpers/pairing-reply.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { expect } from "vitest";
|
||||
|
||||
export function extractPairingCode(text: string): string {
|
||||
const code = text.match(/Pairing code:\s*```[\r\n]+([A-Z2-9]{6,})/)?.[1];
|
||||
expect(code).toBeDefined();
|
||||
return code ?? "";
|
||||
}
|
||||
|
||||
export function expectPairingReplyText(
|
||||
text: string,
|
||||
params: {
|
||||
channel: string;
|
||||
idLine: string;
|
||||
code?: string;
|
||||
},
|
||||
): string {
|
||||
const code = params.code ?? extractPairingCode(text);
|
||||
expect(text).toContain("OpenClaw: access not configured.");
|
||||
expect(text).toContain(params.idLine);
|
||||
expect(text).toContain("Pairing code:");
|
||||
expect(text).toContain(`\n\`\`\`\n${code}\n\`\`\`\n`);
|
||||
expect(text).toContain(`pairing approve ${params.channel} ${code}`);
|
||||
return code;
|
||||
}
|
||||
16
openclaw/test/helpers/paths.ts
Normal file
16
openclaw/test/helpers/paths.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import path from "node:path";
|
||||
|
||||
export function isPathWithinBase(base: string, target: string): boolean {
|
||||
if (process.platform === "win32") {
|
||||
const normalizedBase = path.win32.normalize(path.win32.resolve(base));
|
||||
const normalizedTarget = path.win32.normalize(path.win32.resolve(target));
|
||||
|
||||
const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase());
|
||||
return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel));
|
||||
}
|
||||
|
||||
const normalizedBase = path.resolve(base);
|
||||
const normalizedTarget = path.resolve(target);
|
||||
const rel = path.relative(normalizedBase, normalizedTarget);
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
}
|
||||
23
openclaw/test/helpers/pattern-file.ts
Normal file
23
openclaw/test/helpers/pattern-file.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function createPatternFileHelper(prefix: string) {
|
||||
const tempDirs = new Set<string>();
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
for (const dir of tempDirs) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.clear();
|
||||
},
|
||||
writePatternFile(basename: string, value: unknown) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.add(dir);
|
||||
const filePath = path.join(dir, basename);
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
||||
return filePath;
|
||||
},
|
||||
};
|
||||
}
|
||||
107
openclaw/test/helpers/plugins/contracts-testkit.ts
Normal file
107
openclaw/test/helpers/plugins/contracts-testkit.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { createPluginRegistry, type PluginRecord } from "../../../src/plugins/registry.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createPluginRecord } from "../../../src/plugins/status.test-helpers.js";
|
||||
import type { OpenClawPluginApi } from "../../../src/plugins/types.js";
|
||||
|
||||
export {
|
||||
registerProviderPlugins as registerProviders,
|
||||
requireRegisteredProvider as requireProvider,
|
||||
} from "../../../src/test-utils/plugin-registration.js";
|
||||
|
||||
export function uniqueSortedStrings(values: readonly string[]) {
|
||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function formatImportSideEffectCall(args: readonly unknown[]): string {
|
||||
if (args.length === 0) {
|
||||
return "(no args)";
|
||||
}
|
||||
return args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function assertNoImportTimeSideEffects(params: {
|
||||
moduleId: string;
|
||||
forbiddenSeam: string;
|
||||
calls: readonly (readonly unknown[])[];
|
||||
why: string;
|
||||
fixHint: string;
|
||||
}) {
|
||||
if (params.calls.length === 0) {
|
||||
return;
|
||||
}
|
||||
const observedCalls = params.calls
|
||||
.slice(0, 3)
|
||||
.map((call, index) => ` ${index + 1}. ${formatImportSideEffectCall(call)}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
[
|
||||
`[runtime contract] ${params.moduleId} touched ${params.forbiddenSeam} during module import.`,
|
||||
`why this is banned: ${params.why}`,
|
||||
`expected fix: ${params.fixHint}`,
|
||||
`observed calls (${params.calls.length}):`,
|
||||
observedCalls,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginRegistryFixture(config = {} as OpenClawConfig) {
|
||||
return {
|
||||
config,
|
||||
registry: createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
record: PluginRecord;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
params.registry.registry.plugins.push(params.record);
|
||||
params.register(
|
||||
params.registry.createApi(params.record, {
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerVirtualTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
id: string;
|
||||
name: string;
|
||||
source?: string;
|
||||
kind?: PluginRecord["kind"];
|
||||
contracts?: PluginRecord["contracts"];
|
||||
register(this: void, api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
registerTestPlugin({
|
||||
registry: params.registry,
|
||||
config: params.config,
|
||||
record: createPluginRecord({
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
source: params.source ?? `/virtual/${params.id}/index.ts`,
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
...(params.contracts ? { contracts: params.contracts } : {}),
|
||||
}),
|
||||
register: params.register,
|
||||
});
|
||||
}
|
||||
25
openclaw/test/helpers/plugins/direct-smoke.ts
Normal file
25
openclaw/test/helpers/plugins/direct-smoke.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
|
||||
const SHARED_IMPORT_ENV = {
|
||||
HOME: process.env.HOME,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
PATH: process.env.PATH,
|
||||
TERM: process.env.TERM,
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
export async function runDirectImportSmoke(code: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync(process.execPath, ["--import", "tsx", "-e", code], {
|
||||
cwd: repoRoot,
|
||||
env: SHARED_IMPORT_ENV,
|
||||
timeout: 40_000,
|
||||
});
|
||||
|
||||
return stdout;
|
||||
}
|
||||
33
openclaw/test/helpers/plugins/directory.ts
Normal file
33
openclaw/test/helpers/plugins/directory.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
type DirectorySurface = {
|
||||
listPeers: NonNullable<ChannelDirectoryAdapter["listPeers"]>;
|
||||
listGroups: NonNullable<ChannelDirectoryAdapter["listGroups"]>;
|
||||
};
|
||||
|
||||
export function createDirectoryTestRuntime() {
|
||||
return {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function expectDirectorySurface(directory: unknown): DirectorySurface {
|
||||
if (!directory || typeof directory !== "object") {
|
||||
throw new Error("expected directory");
|
||||
}
|
||||
const { listPeers, listGroups } = directory as ChannelDirectoryAdapter;
|
||||
if (!listPeers) {
|
||||
throw new Error("expected listPeers");
|
||||
}
|
||||
if (!listGroups) {
|
||||
throw new Error("expected listGroups");
|
||||
}
|
||||
return {
|
||||
listPeers,
|
||||
listGroups,
|
||||
};
|
||||
}
|
||||
1
openclaw/test/helpers/plugins/frozen-time.ts
Normal file
1
openclaw/test/helpers/plugins/frozen-time.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useFrozenTime, useRealTime } from "../../../src/test-utils/frozen-time.js";
|
||||
238
openclaw/test/helpers/plugins/jiti-runtime-api.ts
Normal file
238
openclaw/test/helpers/plugins/jiti-runtime-api.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const JITI_EXTENSIONS = [
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".mtsx",
|
||||
".ctsx",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
] as const;
|
||||
|
||||
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
|
||||
const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
|
||||
|
||||
type SourceModuleRef = {
|
||||
specifier: string;
|
||||
typeOnly: boolean;
|
||||
};
|
||||
|
||||
function listPluginSdkExportedSubpaths(root: string): string[] {
|
||||
const packageJsonPath = path.join(root, "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
return Object.keys(packageJson.exports ?? {})
|
||||
.filter((key) => key.startsWith("./plugin-sdk/"))
|
||||
.map((key) => key.slice("./plugin-sdk/".length));
|
||||
}
|
||||
|
||||
function resolvePluginSdkAliasTarget(root: string, subpath: string): string | null {
|
||||
const distCandidate = path.join(root, "dist", "plugin-sdk", `${subpath}.js`);
|
||||
if (existsSync(distCandidate)) {
|
||||
return distCandidate;
|
||||
}
|
||||
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
const srcCandidate = path.join(root, "src", "plugin-sdk", `${subpath}${ext}`);
|
||||
if (existsSync(srcCandidate)) {
|
||||
return srcCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalModulePath(filePath: string, specifier: string): string | null {
|
||||
const basePath = path.resolve(path.dirname(filePath), specifier);
|
||||
const candidates = new Set<string>([basePath]);
|
||||
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
candidates.add(`${basePath}${ext}`);
|
||||
}
|
||||
|
||||
if (/\.[cm]?[jt]sx?$/u.test(basePath)) {
|
||||
const withoutExt = basePath.replace(/\.[cm]?[jt]sx?$/u, "");
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
candidates.add(`${withoutExt}${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
candidates.add(path.join(basePath, `index${ext}`));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectSourceModuleRefs(filePath: string): SourceModuleRef[] {
|
||||
const sourceText = readFileSync(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const refs: SourceModuleRef[] = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(statement)) {
|
||||
const specifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (specifier) {
|
||||
refs.push({
|
||||
specifier,
|
||||
typeOnly: Boolean(statement.importClause?.isTypeOnly),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const specifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!specifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const typeOnly = Boolean(
|
||||
statement.isTypeOnly ||
|
||||
(statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause) &&
|
||||
statement.exportClause.elements.length > 0 &&
|
||||
statement.exportClause.elements.every((element) => element.isTypeOnly)),
|
||||
);
|
||||
|
||||
refs.push({ specifier, typeOnly });
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectPluginSdkAliases(params: {
|
||||
modulePath: string;
|
||||
root: string;
|
||||
realPluginSdkSpecifiers?: readonly string[];
|
||||
}): Record<string, string> {
|
||||
const realSpecifiers = new Set<string>();
|
||||
const stubSpecifiers = new Set<string>();
|
||||
const visitedFiles = new Set<string>();
|
||||
const stubPath = path.join(params.root, "test", "helpers", "plugins", "plugin-sdk-stub.cjs");
|
||||
const explicitRealSpecifiers = new Set(params.realPluginSdkSpecifiers ?? []);
|
||||
|
||||
function visitModule(filePath: string, rootModule: boolean): void {
|
||||
if (visitedFiles.has(filePath)) {
|
||||
return;
|
||||
}
|
||||
visitedFiles.add(filePath);
|
||||
|
||||
for (const ref of collectSourceModuleRefs(filePath)) {
|
||||
if (ref.specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
|
||||
const shouldKeepReal =
|
||||
rootModule &&
|
||||
!ref.typeOnly &&
|
||||
(explicitRealSpecifiers.size === 0 || explicitRealSpecifiers.has(ref.specifier));
|
||||
if (shouldKeepReal) {
|
||||
realSpecifiers.add(ref.specifier);
|
||||
const subpath = ref.specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
|
||||
const target = resolvePluginSdkAliasTarget(params.root, subpath);
|
||||
if (target?.endsWith(".ts")) {
|
||||
visitModule(target, false);
|
||||
}
|
||||
} else {
|
||||
stubSpecifiers.add(ref.specifier);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ref.specifier.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolveLocalModulePath(filePath, ref.specifier);
|
||||
if (resolved) {
|
||||
visitModule(resolved, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitModule(params.modulePath, true);
|
||||
|
||||
const aliasEntries = new Map<string, string>();
|
||||
for (const specifier of listPluginSdkExportedSubpaths(params.root).map(
|
||||
(subpath) => `${PLUGIN_SDK_SPECIFIER_PREFIX}${subpath}`,
|
||||
)) {
|
||||
if (realSpecifiers.has(specifier)) {
|
||||
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
|
||||
aliasEntries.set(specifier, resolvePluginSdkAliasTarget(params.root, subpath) ?? stubPath);
|
||||
continue;
|
||||
}
|
||||
if (stubSpecifiers.has(specifier)) {
|
||||
aliasEntries.set(specifier, stubPath);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(aliasEntries);
|
||||
}
|
||||
|
||||
export function loadRuntimeApiExportTypesViaJiti(params: {
|
||||
modulePath: string;
|
||||
exportNames: readonly string[];
|
||||
additionalAliases?: Record<string, string>;
|
||||
realPluginSdkSpecifiers?: readonly string[];
|
||||
}): Record<string, string> {
|
||||
const root = process.cwd();
|
||||
const alias = {
|
||||
...collectPluginSdkAliases({
|
||||
modulePath: params.modulePath,
|
||||
root,
|
||||
realPluginSdkSpecifiers: params.realPluginSdkSpecifiers,
|
||||
}),
|
||||
...params.additionalAliases,
|
||||
};
|
||||
|
||||
const script = `
|
||||
import path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const modulePath = ${JSON.stringify(params.modulePath)};
|
||||
const exportNames = ${JSON.stringify(params.exportNames)};
|
||||
const alias = ${JSON.stringify(alias)};
|
||||
const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), {
|
||||
interopDefault: true,
|
||||
tryNative: false,
|
||||
fsCache: false,
|
||||
moduleCache: false,
|
||||
extensions: ${JSON.stringify(JITI_EXTENSIONS)},
|
||||
alias,
|
||||
});
|
||||
const mod = jiti(modulePath);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])),
|
||||
),
|
||||
);
|
||||
`;
|
||||
|
||||
const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], {
|
||||
cwd: root,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
return JSON.parse(raw) as Record<string, string>;
|
||||
}
|
||||
1
openclaw/test/helpers/plugins/media-understanding.ts
Normal file
1
openclaw/test/helpers/plugins/media-understanding.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createRequestCaptureJsonFetch } from "../../../src/media-understanding/audio.test-helpers.js";
|
||||
1
openclaw/test/helpers/plugins/mock-http-response.ts
Normal file
1
openclaw/test/helpers/plugins/mock-http-response.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
|
||||
46
openclaw/test/helpers/plugins/onboard-config.ts
Normal file
46
openclaw/test/helpers/plugins/onboard-config.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ModelApi } from "../../../src/config/types.models.js";
|
||||
|
||||
export const EXPECTED_FALLBACKS = ["anthropic/claude-opus-4-5"] as const;
|
||||
|
||||
export function createLegacyProviderConfig(params: {
|
||||
providerId: string;
|
||||
api: ModelApi;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
[params.providerId]: {
|
||||
baseUrl: params.baseUrl ?? "https://old.example.com",
|
||||
apiKey: params.apiKey ?? "old-key",
|
||||
api: params.api,
|
||||
models: [
|
||||
{
|
||||
id: params.modelId ?? "old-model",
|
||||
name: params.modelName ?? "Old",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function createConfigWithFallbacks(): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { fallbacks: [...EXPECTED_FALLBACKS] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
106
openclaw/test/helpers/plugins/package-manifest-contract.ts
Normal file
106
openclaw/test/helpers/plugins/package-manifest-contract.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAtLeast, parseSemver } from "../../../src/infra/runtime-guard.js";
|
||||
import { parseMinHostVersionRequirement } from "../../../src/plugins/min-host-version.js";
|
||||
import { bundledPluginFile } from "../bundled-plugin-paths.js";
|
||||
|
||||
type PackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
install?: {
|
||||
minHostVersion?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PackageManifestContractParams = {
|
||||
pluginId: string;
|
||||
pluginLocalRuntimeDeps?: string[];
|
||||
mirroredRootRuntimeDeps?: string[];
|
||||
minHostVersionBaseline?: string;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe package manifest shape.
|
||||
function readJson<T>(relativePath: string): T {
|
||||
const absolutePath = path.resolve(process.cwd(), relativePath);
|
||||
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
export function describePackageManifestContract(params: PackageManifestContractParams) {
|
||||
const packagePath = bundledPluginFile(params.pluginId, "package.json");
|
||||
|
||||
describe(`${params.pluginId} package manifest contract`, () => {
|
||||
if (params.pluginLocalRuntimeDeps?.length) {
|
||||
for (const dependencyName of params.pluginLocalRuntimeDeps) {
|
||||
it(`keeps ${dependencyName} plugin-local`, () => {
|
||||
const rootManifest = readJson("package.json") as PackageManifest;
|
||||
const pluginManifest = readJson(packagePath) as PackageManifest;
|
||||
const pluginSpec =
|
||||
pluginManifest.dependencies?.[dependencyName] ??
|
||||
pluginManifest.optionalDependencies?.[dependencyName];
|
||||
const rootSpec =
|
||||
rootManifest.dependencies?.[dependencyName] ??
|
||||
rootManifest.optionalDependencies?.[dependencyName];
|
||||
|
||||
expect(pluginSpec).toBeTruthy();
|
||||
expect(rootSpec).toBeUndefined();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (params.mirroredRootRuntimeDeps?.length) {
|
||||
for (const dependencyName of params.mirroredRootRuntimeDeps) {
|
||||
it(`mirrors ${dependencyName} at the root package`, () => {
|
||||
const rootManifest = readJson<PackageManifest>("package.json");
|
||||
const pluginManifest = readJson<PackageManifest>(packagePath);
|
||||
const pluginSpec =
|
||||
pluginManifest.dependencies?.[dependencyName] ??
|
||||
pluginManifest.optionalDependencies?.[dependencyName];
|
||||
const rootSpec =
|
||||
rootManifest.dependencies?.[dependencyName] ??
|
||||
rootManifest.optionalDependencies?.[dependencyName];
|
||||
|
||||
expect(pluginSpec).toBeTruthy();
|
||||
expect(rootSpec).toBe(pluginSpec);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const minHostVersionBaseline = params.minHostVersionBaseline;
|
||||
if (minHostVersionBaseline) {
|
||||
it("declares a parseable minHostVersion floor at or above the baseline", () => {
|
||||
const baseline = parseSemver(minHostVersionBaseline);
|
||||
expect(baseline).not.toBeNull();
|
||||
if (!baseline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = readJson<PackageManifest>(packagePath);
|
||||
const requirement = parseMinHostVersionRequirement(
|
||||
manifest.openclaw?.install?.minHostVersion ?? null,
|
||||
);
|
||||
|
||||
expect(
|
||||
requirement,
|
||||
`${packagePath} should declare openclaw.install.minHostVersion`,
|
||||
).not.toBeNull();
|
||||
if (!requirement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minimum = parseSemver(requirement.minimumLabel);
|
||||
expect(minimum, `${packagePath} should use a parseable semver floor`).not.toBeNull();
|
||||
if (!minimum) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
isAtLeast(minimum, baseline),
|
||||
`${packagePath} should require at least OpenClaw ${minHostVersionBaseline}`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
58
openclaw/test/helpers/plugins/plugin-api.ts
Normal file
58
openclaw/test/helpers/plugins/plugin-api.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
|
||||
type TestPluginApiInput = Partial<OpenClawPluginApi>;
|
||||
|
||||
export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPluginApi {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
name: "test-plugin",
|
||||
source: "test",
|
||||
registrationMode: "full",
|
||||
config: {},
|
||||
runtime: {} as OpenClawPluginApi["runtime"],
|
||||
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerCliBackend() {},
|
||||
registerTextTransforms() {},
|
||||
registerService() {},
|
||||
registerReload() {},
|
||||
registerNodeHostCommand() {},
|
||||
registerSecurityAuditCollector() {},
|
||||
registerConfigMigration() {},
|
||||
registerAutoEnableProbe() {},
|
||||
registerProvider() {},
|
||||
registerSpeechProvider() {},
|
||||
registerRealtimeTranscriptionProvider() {},
|
||||
registerRealtimeVoiceProvider() {},
|
||||
registerMediaUnderstandingProvider() {},
|
||||
registerImageGenerationProvider() {},
|
||||
registerMusicGenerationProvider() {},
|
||||
registerVideoGenerationProvider() {},
|
||||
registerWebFetchProvider() {},
|
||||
registerWebSearchProvider() {},
|
||||
registerInteractiveHandler() {},
|
||||
onConversationBindingResolved() {},
|
||||
registerCommand() {},
|
||||
registerContextEngine() {},
|
||||
registerCompactionProvider() {},
|
||||
registerAgentHarness() {},
|
||||
registerDetachedTaskRuntime() {},
|
||||
registerMemoryCapability() {},
|
||||
registerMemoryPromptSection() {},
|
||||
registerMemoryPromptSupplement() {},
|
||||
registerMemoryCorpusSupplement() {},
|
||||
registerMemoryFlushPlan() {},
|
||||
registerMemoryRuntime() {},
|
||||
registerMemoryEmbeddingProvider() {},
|
||||
resolvePath(input: string) {
|
||||
return input;
|
||||
},
|
||||
on() {},
|
||||
...api,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { describePluginRegistrationContract } from "./plugin-registration-contract.js";
|
||||
|
||||
type PluginRegistrationContractParams = Parameters<typeof describePluginRegistrationContract>[0];
|
||||
|
||||
export const pluginRegistrationContractCases = {
|
||||
anthropic: {
|
||||
pluginId: "anthropic",
|
||||
providerIds: ["anthropic"],
|
||||
mediaUnderstandingProviderIds: ["anthropic"],
|
||||
cliBackendIds: ["claude-cli"],
|
||||
requireDescribeImages: true,
|
||||
},
|
||||
brave: {
|
||||
pluginId: "brave",
|
||||
webSearchProviderIds: ["brave"],
|
||||
},
|
||||
comfy: {
|
||||
pluginId: "comfy",
|
||||
providerIds: ["comfy"],
|
||||
imageGenerationProviderIds: ["comfy"],
|
||||
musicGenerationProviderIds: ["comfy"],
|
||||
videoGenerationProviderIds: ["comfy"],
|
||||
requireGenerateImage: true,
|
||||
requireGenerateVideo: true,
|
||||
},
|
||||
deepgram: {
|
||||
pluginId: "deepgram",
|
||||
mediaUnderstandingProviderIds: ["deepgram"],
|
||||
},
|
||||
duckduckgo: {
|
||||
pluginId: "duckduckgo",
|
||||
webSearchProviderIds: ["duckduckgo"],
|
||||
},
|
||||
elevenlabs: {
|
||||
pluginId: "elevenlabs",
|
||||
speechProviderIds: ["elevenlabs"],
|
||||
requireSpeechVoices: true,
|
||||
},
|
||||
exa: {
|
||||
pluginId: "exa",
|
||||
webSearchProviderIds: ["exa"],
|
||||
},
|
||||
fal: {
|
||||
pluginId: "fal",
|
||||
providerIds: ["fal"],
|
||||
imageGenerationProviderIds: ["fal"],
|
||||
},
|
||||
firecrawl: {
|
||||
pluginId: "firecrawl",
|
||||
webFetchProviderIds: ["firecrawl"],
|
||||
webSearchProviderIds: ["firecrawl"],
|
||||
toolNames: ["firecrawl_search", "firecrawl_scrape"],
|
||||
},
|
||||
google: {
|
||||
pluginId: "google",
|
||||
providerIds: ["google", "google-gemini-cli"],
|
||||
webSearchProviderIds: ["gemini"],
|
||||
speechProviderIds: ["google"],
|
||||
mediaUnderstandingProviderIds: ["google"],
|
||||
imageGenerationProviderIds: ["google"],
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
groq: {
|
||||
pluginId: "groq",
|
||||
mediaUnderstandingProviderIds: ["groq"],
|
||||
},
|
||||
microsoft: {
|
||||
pluginId: "microsoft",
|
||||
speechProviderIds: ["microsoft"],
|
||||
requireSpeechVoices: true,
|
||||
},
|
||||
minimax: {
|
||||
pluginId: "minimax",
|
||||
providerIds: ["minimax", "minimax-portal"],
|
||||
mediaUnderstandingProviderIds: ["minimax", "minimax-portal"],
|
||||
imageGenerationProviderIds: ["minimax", "minimax-portal"],
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
mistral: {
|
||||
pluginId: "mistral",
|
||||
mediaUnderstandingProviderIds: ["mistral"],
|
||||
},
|
||||
moonshot: {
|
||||
pluginId: "moonshot",
|
||||
providerIds: ["moonshot"],
|
||||
webSearchProviderIds: ["kimi"],
|
||||
mediaUnderstandingProviderIds: ["moonshot"],
|
||||
requireDescribeImages: true,
|
||||
manifestAuthChoice: {
|
||||
pluginId: "kimi",
|
||||
choiceId: "kimi-code-api-key",
|
||||
choiceLabel: "Kimi Code API key (subscription)",
|
||||
groupId: "moonshot",
|
||||
groupLabel: "Moonshot AI (Kimi K2.5)",
|
||||
groupHint: "Kimi K2.5",
|
||||
},
|
||||
},
|
||||
openai: {
|
||||
pluginId: "openai",
|
||||
providerIds: ["openai", "openai-codex"],
|
||||
speechProviderIds: ["openai"],
|
||||
realtimeTranscriptionProviderIds: ["openai"],
|
||||
realtimeVoiceProviderIds: ["openai"],
|
||||
mediaUnderstandingProviderIds: ["openai", "openai-codex"],
|
||||
imageGenerationProviderIds: ["openai"],
|
||||
requireSpeechVoices: true,
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
openrouter: {
|
||||
pluginId: "openrouter",
|
||||
providerIds: ["openrouter"],
|
||||
mediaUnderstandingProviderIds: ["openrouter"],
|
||||
requireDescribeImages: true,
|
||||
},
|
||||
perplexity: {
|
||||
pluginId: "perplexity",
|
||||
webSearchProviderIds: ["perplexity"],
|
||||
},
|
||||
tavily: {
|
||||
pluginId: "tavily",
|
||||
webSearchProviderIds: ["tavily"],
|
||||
toolNames: ["tavily_search", "tavily_extract"],
|
||||
},
|
||||
xai: {
|
||||
pluginId: "xai",
|
||||
providerIds: ["xai"],
|
||||
webSearchProviderIds: ["grok"],
|
||||
},
|
||||
zai: {
|
||||
pluginId: "zai",
|
||||
mediaUnderstandingProviderIds: ["zai"],
|
||||
requireDescribeImages: true,
|
||||
},
|
||||
} satisfies Record<string, PluginRegistrationContractParams>;
|
||||
156
openclaw/test/helpers/plugins/plugin-registration-contract.ts
Normal file
156
openclaw/test/helpers/plugins/plugin-registration-contract.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { pluginRegistrationContractRegistry } from "../../../src/plugins/contracts/registry.js";
|
||||
import { loadPluginManifestRegistry } from "../../../src/plugins/manifest-registry.js";
|
||||
|
||||
type PluginRegistrationContractParams = {
|
||||
pluginId: string;
|
||||
cliBackendIds?: string[];
|
||||
providerIds?: string[];
|
||||
webFetchProviderIds?: string[];
|
||||
webSearchProviderIds?: string[];
|
||||
speechProviderIds?: string[];
|
||||
realtimeTranscriptionProviderIds?: string[];
|
||||
realtimeVoiceProviderIds?: string[];
|
||||
mediaUnderstandingProviderIds?: string[];
|
||||
imageGenerationProviderIds?: string[];
|
||||
videoGenerationProviderIds?: string[];
|
||||
musicGenerationProviderIds?: string[];
|
||||
toolNames?: string[];
|
||||
requireSpeechVoices?: boolean;
|
||||
requireDescribeImages?: boolean;
|
||||
requireGenerateImage?: boolean;
|
||||
requireGenerateVideo?: boolean;
|
||||
manifestAuthChoice?: {
|
||||
pluginId: string;
|
||||
choiceId: string;
|
||||
choiceLabel: string;
|
||||
groupId: string;
|
||||
groupLabel: string;
|
||||
groupHint: string;
|
||||
};
|
||||
};
|
||||
|
||||
function findRegistration(pluginId: string) {
|
||||
const entry = pluginRegistrationContractRegistry.find(
|
||||
(candidate) => candidate.pluginId === pluginId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`plugin registration contract missing for ${pluginId}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function describePluginRegistrationContract(params: PluginRegistrationContractParams) {
|
||||
describe(`${params.pluginId} plugin registration contract`, () => {
|
||||
if (params.cliBackendIds) {
|
||||
it("keeps bundled cli-backend ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).cliBackendIds).toEqual(params.cliBackendIds);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.providerIds) {
|
||||
it("keeps bundled provider ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).providerIds).toEqual(params.providerIds);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.webSearchProviderIds) {
|
||||
it("keeps bundled web search ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).webSearchProviderIds).toEqual(
|
||||
params.webSearchProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.webFetchProviderIds) {
|
||||
it("keeps bundled web fetch ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).webFetchProviderIds).toEqual(
|
||||
params.webFetchProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.speechProviderIds) {
|
||||
it("keeps bundled speech ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).speechProviderIds).toEqual(
|
||||
params.speechProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.realtimeTranscriptionProviderIds) {
|
||||
it("keeps bundled realtime-transcription ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).realtimeTranscriptionProviderIds).toEqual(
|
||||
params.realtimeTranscriptionProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.realtimeVoiceProviderIds) {
|
||||
it("keeps bundled realtime-voice ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).realtimeVoiceProviderIds).toEqual(
|
||||
params.realtimeVoiceProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.mediaUnderstandingProviderIds) {
|
||||
it("keeps bundled media-understanding ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).mediaUnderstandingProviderIds).toEqual(
|
||||
params.mediaUnderstandingProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.imageGenerationProviderIds) {
|
||||
it("keeps bundled image-generation ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).imageGenerationProviderIds).toEqual(
|
||||
params.imageGenerationProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.videoGenerationProviderIds) {
|
||||
it("keeps bundled video-generation ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).videoGenerationProviderIds).toEqual(
|
||||
params.videoGenerationProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.musicGenerationProviderIds) {
|
||||
it("keeps bundled music-generation ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).musicGenerationProviderIds).toEqual(
|
||||
params.musicGenerationProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.toolNames) {
|
||||
it("keeps bundled tool ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).toolNames).toEqual(params.toolNames);
|
||||
});
|
||||
}
|
||||
|
||||
const manifestAuthChoice = params.manifestAuthChoice;
|
||||
if (manifestAuthChoice) {
|
||||
it("keeps onboarding auth grouping explicit", () => {
|
||||
const plugin = loadPluginManifestRegistry({}).plugins.find(
|
||||
(entry) => entry.origin === "bundled" && entry.id === manifestAuthChoice.pluginId,
|
||||
);
|
||||
|
||||
expect(plugin?.providerAuthChoices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
choiceId: manifestAuthChoice.choiceId,
|
||||
choiceLabel: manifestAuthChoice.choiceLabel,
|
||||
groupId: manifestAuthChoice.groupId,
|
||||
groupLabel: manifestAuthChoice.groupLabel,
|
||||
groupHint: manifestAuthChoice.groupHint,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
1
openclaw/test/helpers/plugins/plugin-registration.ts
Normal file
1
openclaw/test/helpers/plugins/plugin-registration.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { registerSingleProviderPlugin } from "../../../src/test-utils/plugin-registration.js";
|
||||
3
openclaw/test/helpers/plugins/plugin-registry.ts
Normal file
3
openclaw/test/helpers/plugins/plugin-registry.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
export { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js";
|
||||
412
openclaw/test/helpers/plugins/plugin-runtime-mock.ts
Normal file
412
openclaw/test/helpers/plugins/plugin-runtime-mock.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { vi } from "vitest";
|
||||
import {
|
||||
removeAckReactionAfterReply,
|
||||
shouldAckReaction,
|
||||
} from "../../../src/channels/ack-reactions.js";
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
resolveInboundMentionDecision,
|
||||
} from "../../../src/channels/mention-gating.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
|
||||
const DEFAULT_PROVIDER = "openai";
|
||||
const DEFAULT_MODEL = "gpt-5.4";
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
|
||||
? T[K]
|
||||
: T[K] extends ReadonlyArray<unknown>
|
||||
? T[K]
|
||||
: T[K] extends object
|
||||
? DeepPartial<T[K]>
|
||||
: T[K];
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
for (const [key, overrideValue] of Object.entries(overrides as Record<string, unknown>)) {
|
||||
if (overrideValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const baseValue = result[key];
|
||||
if (isObject(baseValue) && isObject(overrideValue)) {
|
||||
result[key] = mergeDeep(baseValue, overrideValue);
|
||||
continue;
|
||||
}
|
||||
result[key] = overrideValue;
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function createTaskFlowSessionMock() {
|
||||
return {
|
||||
sessionKey: "agent:main:main",
|
||||
createManaged: vi.fn(),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(() => []),
|
||||
findLatest: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
getTaskSummary: vi.fn(),
|
||||
setWaiting: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
requestCancel: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
||||
const taskFlow = {
|
||||
bindSession: vi.fn(
|
||||
createTaskFlowSessionMock,
|
||||
) as unknown as PluginRuntime["taskFlow"]["bindSession"],
|
||||
fromToolContext: vi.fn(
|
||||
createTaskFlowSessionMock,
|
||||
) as unknown as PluginRuntime["taskFlow"]["fromToolContext"],
|
||||
};
|
||||
const base: PluginRuntime = {
|
||||
version: "1.0.0-test",
|
||||
config: {
|
||||
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
|
||||
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
model: DEFAULT_MODEL,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
},
|
||||
resolveAgentDir: vi.fn(
|
||||
() => "/tmp/agent",
|
||||
) as unknown as PluginRuntime["agent"]["resolveAgentDir"],
|
||||
resolveAgentWorkspaceDir: vi.fn(
|
||||
() => "/tmp/workspace",
|
||||
) as unknown as PluginRuntime["agent"]["resolveAgentWorkspaceDir"],
|
||||
resolveAgentIdentity: vi.fn(() => ({
|
||||
name: "test-agent",
|
||||
})) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"],
|
||||
resolveThinkingDefault: vi.fn(
|
||||
() => "off",
|
||||
) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"],
|
||||
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
|
||||
payloads: [],
|
||||
meta: {},
|
||||
}) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"],
|
||||
runEmbeddedAgent: vi.fn().mockResolvedValue({
|
||||
payloads: [],
|
||||
meta: {},
|
||||
}) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"],
|
||||
resolveAgentTimeoutMs: vi.fn(
|
||||
() => 30_000,
|
||||
) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"],
|
||||
ensureAgentWorkspace: vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined) as unknown as PluginRuntime["agent"]["ensureAgentWorkspace"],
|
||||
session: {
|
||||
resolveStorePath: vi.fn(
|
||||
() => "/tmp/agent-sessions.json",
|
||||
) as unknown as PluginRuntime["agent"]["session"]["resolveStorePath"],
|
||||
loadSessionStore: vi.fn(
|
||||
() => ({}),
|
||||
) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"],
|
||||
saveSessionStore: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
undefined,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["saveSessionStore"],
|
||||
resolveSessionFilePath: vi.fn(
|
||||
(sessionId: string) => `/tmp/${sessionId}.json`,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["resolveSessionFilePath"],
|
||||
},
|
||||
},
|
||||
system: {
|
||||
enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
||||
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
|
||||
runHeartbeatOnce: vi.fn(async () => ({
|
||||
status: "ran" as const,
|
||||
durationMs: 0,
|
||||
})) as unknown as PluginRuntime["system"]["runHeartbeatOnce"],
|
||||
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
||||
formatNativeDependencyHint: vi.fn(
|
||||
() => "",
|
||||
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
|
||||
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
|
||||
isVoiceCompatibleAudio:
|
||||
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
|
||||
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
|
||||
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
||||
},
|
||||
tts: {
|
||||
textToSpeech: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeech"],
|
||||
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
||||
listVoices: vi.fn() as unknown as PluginRuntime["tts"]["listVoices"],
|
||||
},
|
||||
mediaUnderstanding: {
|
||||
runFile: vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["runFile"],
|
||||
describeImageFile:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFile"],
|
||||
describeImageFileWithModel:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFileWithModel"],
|
||||
describeVideoFile:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeVideoFile"],
|
||||
transcribeAudioFile:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"],
|
||||
},
|
||||
imageGeneration: {
|
||||
generate: vi.fn() as unknown as PluginRuntime["imageGeneration"]["generate"],
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["imageGeneration"]["listProviders"],
|
||||
},
|
||||
musicGeneration: {
|
||||
generate: vi.fn() as unknown as PluginRuntime["musicGeneration"]["generate"],
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["musicGeneration"]["listProviders"],
|
||||
},
|
||||
videoGeneration: {
|
||||
generate: vi.fn() as unknown as PluginRuntime["videoGeneration"]["generate"],
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["videoGeneration"]["listProviders"],
|
||||
},
|
||||
webSearch: {
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["webSearch"]["listProviders"],
|
||||
search: vi.fn() as unknown as PluginRuntime["webSearch"]["search"],
|
||||
},
|
||||
stt: {
|
||||
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
chunkByNewline: vi.fn((text: string) => (text ? [text] : [])),
|
||||
chunkMarkdownText: vi.fn((text: string) => [text]),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
|
||||
chunkText: vi.fn((text: string) => (text ? [text] : [])),
|
||||
chunkTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
|
||||
resolveChunkMode: vi.fn(
|
||||
() => "length",
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
resolveTextChunkLimit: vi.fn(() => 4000),
|
||||
hasControlCommand: vi.fn(() => false),
|
||||
resolveMarkdownTableMode: vi.fn(
|
||||
() => "code",
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
||||
async () => undefined,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
createReplyDispatcherWithTyping:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
|
||||
resolveEffectiveMessagesConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
|
||||
resolveHumanDelayConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
||||
dispatchReplyFromConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
||||
withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
}) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
finalizeInboundContext: vi.fn(
|
||||
(ctx: Record<string, unknown>) => ctx,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
formatAgentEnvelope: vi.fn(
|
||||
(opts: { body: string }) => opts.body,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
||||
formatInboundEnvelope: vi.fn(
|
||||
(opts: { body: string }) => opts.body,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => ({
|
||||
template: "channel+name+time",
|
||||
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
routing: {
|
||||
buildAgentSessionKey: vi.fn(
|
||||
({
|
||||
agentId,
|
||||
channel,
|
||||
peer,
|
||||
}: {
|
||||
agentId: string;
|
||||
channel: string;
|
||||
peer?: { kind?: string; id?: string };
|
||||
}) => `agent:${agentId}:${channel}:${peer?.kind ?? "direct"}:${peer?.id ?? "peer"}`,
|
||||
) as unknown as PluginRuntime["channel"]["routing"]["buildAgentSessionKey"],
|
||||
resolveAgentRoute: vi.fn(() => ({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:test:dm:peer",
|
||||
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: vi.fn(
|
||||
() => "Pairing code: TESTCODE",
|
||||
) as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
|
||||
readAllowFromStore: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
[],
|
||||
) as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
|
||||
upsertPairingRequest: vi.fn().mockResolvedValue({
|
||||
code: "TESTCODE",
|
||||
created: true,
|
||||
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
||||
},
|
||||
media: {
|
||||
fetchRemoteMedia:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
path: "/tmp/test-media.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(
|
||||
() => "/tmp/sessions.json",
|
||||
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
readSessionUpdatedAt: vi.fn(
|
||||
() => undefined,
|
||||
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
recordSessionMetaFromInbound:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
|
||||
recordInboundSession:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
||||
updateLastRoute:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: vi.fn(() => [
|
||||
/\bbert\b/i,
|
||||
]) as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
||||
matchesMentionPatterns: vi.fn((text: string, regexes: RegExp[]) =>
|
||||
regexes.some((regex) => regex.test(text)),
|
||||
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
||||
matchesMentionWithExplicit: vi.fn(
|
||||
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) =>
|
||||
params.explicitWasMentioned === true
|
||||
? true
|
||||
: params.mentionRegexes.some((regex) => regex.test(params.text)),
|
||||
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
|
||||
implicitMentionKindWhen,
|
||||
resolveInboundMentionDecision,
|
||||
},
|
||||
reactions: {
|
||||
shouldAckReaction,
|
||||
removeAckReactionAfterReply,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy: vi.fn(
|
||||
() => "open",
|
||||
) as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
|
||||
},
|
||||
debounce: {
|
||||
createInboundDebouncer: vi.fn(
|
||||
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
|
||||
enqueue: async (item: unknown) => {
|
||||
await params.onFlush([item]);
|
||||
},
|
||||
flushKey: vi.fn(),
|
||||
}),
|
||||
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
|
||||
resolveInboundDebounceMs: vi.fn(
|
||||
() => 0,
|
||||
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
|
||||
},
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
|
||||
isControlCommandMessage:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
|
||||
shouldComputeCommandAuthorized:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
|
||||
shouldHandleTextCommands:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
|
||||
},
|
||||
outbound: {
|
||||
loadAdapter: vi.fn() as unknown as PluginRuntime["channel"]["outbound"]["loadAdapter"],
|
||||
},
|
||||
threadBindings: {
|
||||
setIdleTimeoutBySessionKey:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setIdleTimeoutBySessionKey"],
|
||||
setMaxAgeBySessionKey:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setMaxAgeBySessionKey"],
|
||||
},
|
||||
runtimeContexts: {
|
||||
register: vi.fn(({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
const lease = { dispose: vi.fn() };
|
||||
abortSignal?.addEventListener("abort", lease.dispose, { once: true });
|
||||
return lease;
|
||||
}) as unknown as PluginRuntime["channel"]["runtimeContexts"]["register"],
|
||||
get: vi.fn() as unknown as PluginRuntime["channel"]["runtimeContexts"]["get"],
|
||||
watch: vi.fn(() =>
|
||||
vi.fn(),
|
||||
) as unknown as PluginRuntime["channel"]["runtimeContexts"]["watch"],
|
||||
},
|
||||
activity: {} as PluginRuntime["channel"]["activity"],
|
||||
},
|
||||
events: {
|
||||
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
|
||||
onSessionTranscriptUpdate: vi.fn(
|
||||
() => () => {},
|
||||
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(() => false),
|
||||
getChildLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
|
||||
},
|
||||
tasks: {
|
||||
runs: {
|
||||
bindSession: vi.fn(),
|
||||
fromToolContext: vi.fn(),
|
||||
} as PluginRuntime["tasks"]["runs"],
|
||||
flows: {
|
||||
bindSession: vi.fn(),
|
||||
fromToolContext: vi.fn(),
|
||||
} as PluginRuntime["tasks"]["flows"],
|
||||
flow: taskFlow,
|
||||
},
|
||||
taskFlow,
|
||||
modelAuth: {
|
||||
getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"],
|
||||
getRuntimeAuthForModel:
|
||||
vi.fn() as unknown as PluginRuntime["modelAuth"]["getRuntimeAuthForModel"],
|
||||
resolveApiKeyForProvider:
|
||||
vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"],
|
||||
},
|
||||
subagent: {
|
||||
run: vi.fn(),
|
||||
waitForRun: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return mergeDeep(base, overrides);
|
||||
}
|
||||
65
openclaw/test/helpers/plugins/plugin-sdk-stub.cjs
Normal file
65
openclaw/test/helpers/plugins/plugin-sdk-stub.cjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use strict";
|
||||
|
||||
let stub;
|
||||
|
||||
stub = new Proxy(
|
||||
function pluginSdkStub() {
|
||||
return stub;
|
||||
},
|
||||
{
|
||||
apply() {
|
||||
return stub;
|
||||
},
|
||||
construct() {
|
||||
return stub;
|
||||
},
|
||||
get(_target, prop) {
|
||||
if (prop === "__esModule") {
|
||||
return true;
|
||||
}
|
||||
if (prop === "default") {
|
||||
return stub;
|
||||
}
|
||||
if (prop === "then") {
|
||||
return undefined;
|
||||
}
|
||||
if (prop === Symbol.toPrimitive) {
|
||||
return () => "";
|
||||
}
|
||||
if (prop === "toJSON") {
|
||||
return () => undefined;
|
||||
}
|
||||
if (prop === "toString") {
|
||||
return () => "";
|
||||
}
|
||||
if (prop === "valueOf") {
|
||||
return () => 0;
|
||||
}
|
||||
return stub;
|
||||
},
|
||||
ownKeys(target) {
|
||||
return [...new Set([...Reflect.ownKeys(target), "__esModule", "default"])];
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop === "__esModule") {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: true,
|
||||
writable: false,
|
||||
};
|
||||
}
|
||||
if (prop === "default") {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: stub,
|
||||
writable: false,
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = stub;
|
||||
405
openclaw/test/helpers/plugins/provider-auth-contract.ts
Normal file
405
openclaw/test/helpers/plugins/provider-auth-contract.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js";
|
||||
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
|
||||
import { createNonExitingRuntime } from "../../../src/runtime.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
import type {
|
||||
WizardMultiSelectParams,
|
||||
WizardPrompter,
|
||||
WizardProgress,
|
||||
WizardSelectParams,
|
||||
} from "../../../src/wizard/prompts.js";
|
||||
import { registerProviders, requireProvider } from "./contracts-testkit.js";
|
||||
|
||||
type LoginOpenAICodexOAuth =
|
||||
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
|
||||
type GithubCopilotLoginCommand =
|
||||
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
|
||||
type CreateVpsAwareHandlers =
|
||||
(typeof import("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
|
||||
type EnsureAuthProfileStore =
|
||||
typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore;
|
||||
type ListProfilesForProvider =
|
||||
typeof import("openclaw/plugin-sdk/provider-auth").listProfilesForProvider;
|
||||
|
||||
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
|
||||
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
|
||||
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
|
||||
const providerAuthContractModules = {
|
||||
githubCopilotIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "github-copilot",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
openAIIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "openai",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-login", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth-login")>(
|
||||
"openclaw/plugin-sdk/provider-auth-login",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
|
||||
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth")>(
|
||||
"openclaw/plugin-sdk/provider-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
|
||||
async function importBundledProviderPlugin<T>(moduleUrl: string): Promise<T> {
|
||||
return (await import(`${moduleUrl}?t=${Date.now()}`)) as T;
|
||||
}
|
||||
|
||||
function buildPrompter(): WizardPrompter {
|
||||
const progress: WizardProgress = {
|
||||
update() {},
|
||||
stop() {},
|
||||
};
|
||||
return {
|
||||
intro: async () => {},
|
||||
outro: async () => {},
|
||||
note: async () => {},
|
||||
select: async <T>(params: WizardSelectParams<T>) => {
|
||||
const option = params.options[0];
|
||||
if (!option) {
|
||||
throw new Error("missing select option");
|
||||
}
|
||||
return option.value;
|
||||
},
|
||||
multiselect: async <T>(params: WizardMultiSelectParams<T>) => params.initialValues ?? [],
|
||||
text: async () => "",
|
||||
confirm: async () => false,
|
||||
progress: () => progress,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthContext() {
|
||||
return {
|
||||
config: {},
|
||||
prompter: buildPrompter(),
|
||||
runtime: createNonExitingRuntime(),
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: vi.fn<CreateVpsAwareHandlers>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
function buildOpenAICodexOAuthResult(params: {
|
||||
profileId: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
}) {
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: params.profileId,
|
||||
credential: {
|
||||
type: "oauth" as const,
|
||||
provider: "openai-codex",
|
||||
access: params.access,
|
||||
refresh: params.refresh,
|
||||
expires: params.expires,
|
||||
...(params.email ? { email: params.email } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai-codex/gpt-5.4": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: "openai-codex/gpt-5.4",
|
||||
notes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) {
|
||||
beforeEach(() => {
|
||||
state.authStore = { version: 1, profiles: {} };
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockImplementation(() => state.authStore);
|
||||
listProfilesForProviderMock.mockReset();
|
||||
listProfilesForProviderMock.mockImplementation((store, providerId) =>
|
||||
Object.entries(store.profiles)
|
||||
.filter(([, credential]) => credential?.provider === providerId)
|
||||
.map(([profileId]) => profileId),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loginOpenAICodexOAuthMock.mockReset();
|
||||
githubCopilotLoginCommandMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
listProfilesForProviderMock.mockReset();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenAICodexProviderAuthContract() {
|
||||
const state = {
|
||||
authStore: { version: 1, profiles: {} } as AuthProfileStore,
|
||||
};
|
||||
|
||||
describe("openai-codex provider auth contract", () => {
|
||||
installSharedAuthProfileStoreHooks(state);
|
||||
|
||||
async function expectStableFallbackProfile(params: { access: string; profileId: string }) {
|
||||
const { default: openAIPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(providerAuthContractModules.openAIIndexModuleUrl);
|
||||
const provider = requireProvider(await registerProviders(openAIPlugin), "openai-codex");
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
refresh: "refresh-token",
|
||||
access: params.access,
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: params.profileId,
|
||||
access: params.access,
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getProvider() {
|
||||
const { default: openAIPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(providerAuthContractModules.openAIIndexModuleUrl);
|
||||
return requireProvider(await registerProviders(openAIPlugin), "openai-codex");
|
||||
}
|
||||
|
||||
it("keeps OAuth auth results provider-owned", async () => {
|
||||
const provider = await getProvider();
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
email: "user@example.com",
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: "openai-codex:user@example.com",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
email: "user@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("backfills OAuth email from the JWT profile claim", async () => {
|
||||
const provider = await getProvider();
|
||||
const access = createJwt({
|
||||
"https://api.openai.com/profile": {
|
||||
email: "jwt-user@example.com",
|
||||
},
|
||||
});
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
refresh: "refresh-token",
|
||||
access,
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: "openai-codex:jwt-user@example.com",
|
||||
access,
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
email: "jwt-user@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a stable fallback id when JWT email is missing", async () => {
|
||||
const access = createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_user_id: "user-123__acct-456",
|
||||
},
|
||||
});
|
||||
const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url");
|
||||
await expectStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => {
|
||||
const access = createJwt({
|
||||
iss: "https://accounts.openai.com",
|
||||
sub: "user-abc",
|
||||
});
|
||||
const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString(
|
||||
"base64url",
|
||||
);
|
||||
await expectStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses sub alone to build a stable fallback id when iss is missing", async () => {
|
||||
const access = createJwt({
|
||||
sub: "user-abc",
|
||||
});
|
||||
const expectedStableId = Buffer.from("user-abc").toString("base64url");
|
||||
await expectStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default profile when JWT parsing yields no identity", async () => {
|
||||
const provider = await getProvider();
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
refresh: "refresh-token",
|
||||
access: "not-a-jwt-token",
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: "openai-codex:default",
|
||||
access: "not-a-jwt-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OAuth failures non-fatal at the provider layer", async () => {
|
||||
const provider = await getProvider();
|
||||
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
|
||||
|
||||
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGithubCopilotProviderAuthContract() {
|
||||
const state = {
|
||||
authStore: { version: 1, profiles: {} } as AuthProfileStore,
|
||||
};
|
||||
|
||||
describe("github-copilot provider auth contract", () => {
|
||||
installSharedAuthProfileStoreHooks(state);
|
||||
|
||||
async function getProvider() {
|
||||
const { default: githubCopilotPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(providerAuthContractModules.githubCopilotIndexModuleUrl);
|
||||
return requireProvider(await registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
}
|
||||
|
||||
it("keeps device auth results provider-owned", async () => {
|
||||
const provider = await getProvider();
|
||||
state.authStore.profiles["github-copilot:github"] = {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "github-device-token",
|
||||
};
|
||||
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
|
||||
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
|
||||
Object.defineProperty(stdin, "isTTY", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith(
|
||||
{ yes: true, profileId: "github-copilot:github" },
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "github-copilot:github",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "github-device-token",
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "github-copilot/claude-opus-4.6",
|
||||
});
|
||||
} finally {
|
||||
if (previousIsTTYDescriptor) {
|
||||
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
||||
} else if (!hadOwnIsTTY) {
|
||||
delete (stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps auth gated on interactive TTYs", async () => {
|
||||
const provider = await getProvider();
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
|
||||
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
|
||||
Object.defineProperty(stdin, "isTTY", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => false,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
||||
profiles: [],
|
||||
});
|
||||
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousIsTTYDescriptor) {
|
||||
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
||||
} else if (!hadOwnIsTTY) {
|
||||
delete (stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
27
openclaw/test/helpers/plugins/provider-catalog.ts
Normal file
27
openclaw/test/helpers/plugins/provider-catalog.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
} from "../../../src/plugins/provider-runtime.test-support.js";
|
||||
export type { ProviderPlugin } from "../../../src/plugins/types.js";
|
||||
export { loadBundledPluginPublicSurfaceSync } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
|
||||
type ProviderRuntimeCatalogModule = Pick<
|
||||
typeof import("../../../src/plugins/provider-runtime.js"),
|
||||
| "augmentModelCatalogWithProviderPlugins"
|
||||
| "resetProviderRuntimeHookCacheForTest"
|
||||
| "resolveProviderBuiltInModelSuppression"
|
||||
>;
|
||||
|
||||
export async function importProviderRuntimeCatalogModule(): Promise<ProviderRuntimeCatalogModule> {
|
||||
const {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await import("../../../src/plugins/provider-runtime.js");
|
||||
return {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
};
|
||||
}
|
||||
179
openclaw/test/helpers/plugins/provider-contract-suites.ts
Normal file
179
openclaw/test/helpers/plugins/provider-contract-suites.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type {
|
||||
ProviderPlugin,
|
||||
WebFetchProviderPlugin,
|
||||
WebSearchProviderPlugin,
|
||||
} from "../../../src/plugins/types.js";
|
||||
|
||||
type Lazy<T> = T | (() => T);
|
||||
|
||||
type WebProviderCredentialContract = Pick<
|
||||
WebSearchProviderPlugin,
|
||||
| "createTool"
|
||||
| "docsUrl"
|
||||
| "envVars"
|
||||
| "getCredentialValue"
|
||||
| "hint"
|
||||
| "id"
|
||||
| "label"
|
||||
| "placeholder"
|
||||
| "setCredentialValue"
|
||||
| "signupUrl"
|
||||
>;
|
||||
|
||||
function resolveLazy<T>(value: Lazy<T>): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
function expectWebProviderCredentialContract(
|
||||
provider: WebProviderCredentialContract,
|
||||
credentialValue: unknown,
|
||||
) {
|
||||
expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
||||
expect(provider.label.trim()).not.toBe("");
|
||||
expect(provider.hint.trim()).not.toBe("");
|
||||
expect(provider.placeholder.trim()).not.toBe("");
|
||||
expect(provider.signupUrl.startsWith("https://")).toBe(true);
|
||||
if (provider.docsUrl) {
|
||||
expect(provider.docsUrl.startsWith("http")).toBe(true);
|
||||
}
|
||||
|
||||
expect(provider.envVars).toEqual([...new Set(provider.envVars)]);
|
||||
expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true);
|
||||
|
||||
const configTarget: Record<string, unknown> = {};
|
||||
provider.setCredentialValue(configTarget, credentialValue);
|
||||
expect(provider.getCredentialValue(configTarget)).toEqual(credentialValue);
|
||||
|
||||
expect(typeof provider.createTool).toBe("function");
|
||||
return configTarget;
|
||||
}
|
||||
|
||||
export function installProviderPluginContractSuite(params: { provider: Lazy<ProviderPlugin> }) {
|
||||
it("satisfies the base provider plugin contract", () => {
|
||||
const provider = resolveLazy(params.provider);
|
||||
const authIds = provider.auth.map((method) => method.id);
|
||||
const wizardChoiceIds = new Set<string>();
|
||||
|
||||
expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
||||
expect(provider.label.trim()).not.toBe("");
|
||||
|
||||
if (provider.docsPath) {
|
||||
expect(provider.docsPath.startsWith("/")).toBe(true);
|
||||
}
|
||||
if (provider.aliases) {
|
||||
expect(provider.aliases).toEqual([...new Set(provider.aliases)]);
|
||||
}
|
||||
if (provider.envVars) {
|
||||
expect(provider.envVars).toEqual([...new Set(provider.envVars)]);
|
||||
expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true);
|
||||
}
|
||||
|
||||
expect(Array.isArray(provider.auth)).toBe(true);
|
||||
expect(authIds).toEqual([...new Set(authIds)]);
|
||||
for (const method of provider.auth) {
|
||||
expect(method.id.trim()).not.toBe("");
|
||||
expect(method.label.trim()).not.toBe("");
|
||||
if (method.hint !== undefined) {
|
||||
expect(method.hint.trim()).not.toBe("");
|
||||
}
|
||||
if (method.wizard) {
|
||||
if (method.wizard.choiceId) {
|
||||
expect(method.wizard.choiceId.trim()).not.toBe("");
|
||||
expect(wizardChoiceIds.has(method.wizard.choiceId)).toBe(false);
|
||||
wizardChoiceIds.add(method.wizard.choiceId);
|
||||
}
|
||||
if (method.wizard.methodId) {
|
||||
expect(authIds).toContain(method.wizard.methodId);
|
||||
}
|
||||
if (method.wizard.modelAllowlist?.allowedKeys) {
|
||||
expect(method.wizard.modelAllowlist.allowedKeys).toEqual([
|
||||
...new Set(method.wizard.modelAllowlist.allowedKeys),
|
||||
]);
|
||||
}
|
||||
if (method.wizard.modelAllowlist?.initialSelections) {
|
||||
expect(method.wizard.modelAllowlist.initialSelections).toEqual([
|
||||
...new Set(method.wizard.modelAllowlist.initialSelections),
|
||||
]);
|
||||
}
|
||||
}
|
||||
expect(typeof method.run).toBe("function");
|
||||
}
|
||||
|
||||
if (provider.wizard?.setup || provider.wizard?.modelPicker) {
|
||||
expect(provider.auth.length).toBeGreaterThan(0);
|
||||
}
|
||||
if (provider.wizard?.setup) {
|
||||
if (provider.wizard.setup.choiceId) {
|
||||
expect(provider.wizard.setup.choiceId.trim()).not.toBe("");
|
||||
expect(wizardChoiceIds.has(provider.wizard.setup.choiceId)).toBe(false);
|
||||
}
|
||||
if (provider.wizard.setup.methodId) {
|
||||
expect(authIds).toContain(provider.wizard.setup.methodId);
|
||||
}
|
||||
if (provider.wizard.setup.modelAllowlist?.allowedKeys) {
|
||||
expect(provider.wizard.setup.modelAllowlist.allowedKeys).toEqual([
|
||||
...new Set(provider.wizard.setup.modelAllowlist.allowedKeys),
|
||||
]);
|
||||
}
|
||||
if (provider.wizard.setup.modelAllowlist?.initialSelections) {
|
||||
expect(provider.wizard.setup.modelAllowlist.initialSelections).toEqual([
|
||||
...new Set(provider.wizard.setup.modelAllowlist.initialSelections),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (provider.wizard?.modelPicker?.methodId) {
|
||||
expect(authIds).toContain(provider.wizard.modelPicker.methodId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installWebSearchProviderContractSuite(params: {
|
||||
provider: Lazy<WebSearchProviderPlugin>;
|
||||
credentialValue: Lazy<unknown>;
|
||||
}) {
|
||||
it("satisfies the base web search provider contract", () => {
|
||||
const provider = resolveLazy(params.provider);
|
||||
const credentialValue = resolveLazy(params.credentialValue);
|
||||
|
||||
const searchConfigTarget = expectWebProviderCredentialContract(provider, credentialValue);
|
||||
expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue);
|
||||
if (provider.runSetup) {
|
||||
expect(typeof provider.runSetup).toBe("function");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installWebFetchProviderContractSuite(params: {
|
||||
provider: Lazy<WebFetchProviderPlugin>;
|
||||
credentialValue: Lazy<unknown>;
|
||||
pluginId?: string;
|
||||
}) {
|
||||
it("satisfies the base web fetch provider contract", () => {
|
||||
const provider = resolveLazy(params.provider);
|
||||
const credentialValue = resolveLazy(params.credentialValue);
|
||||
|
||||
expectWebProviderCredentialContract(provider, credentialValue);
|
||||
expect(provider.credentialPath.trim()).not.toBe("");
|
||||
if (provider.inactiveSecretPaths) {
|
||||
expect(provider.inactiveSecretPaths).toEqual([...new Set(provider.inactiveSecretPaths)]);
|
||||
expect(provider.inactiveSecretPaths).toContain(provider.credentialPath);
|
||||
}
|
||||
|
||||
const fetchConfigTarget: Record<string, unknown> = {};
|
||||
provider.setCredentialValue(fetchConfigTarget, credentialValue);
|
||||
expect(provider.getCredentialValue(fetchConfigTarget)).toEqual(credentialValue);
|
||||
|
||||
if (provider.setConfiguredCredentialValue && provider.getConfiguredCredentialValue) {
|
||||
const configTarget = {} as OpenClawConfig;
|
||||
provider.setConfiguredCredentialValue(configTarget, credentialValue);
|
||||
expect(provider.getConfiguredCredentialValue(configTarget)).toEqual(credentialValue);
|
||||
}
|
||||
|
||||
if (provider.applySelectionConfig && params.pluginId) {
|
||||
const applied = provider.applySelectionConfig({} as OpenClawConfig);
|
||||
expect(applied.plugins?.entries?.[params.pluginId]?.enabled).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
60
openclaw/test/helpers/plugins/provider-contract.ts
Normal file
60
openclaw/test/helpers/plugins/provider-contract.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
pluginRegistrationContractRegistry,
|
||||
providerContractLoadError,
|
||||
resolveProviderContractProvidersForPluginIds,
|
||||
} from "../../../src/plugins/contracts/registry.js";
|
||||
import { resolveBundledExplicitProviderContractsFromPublicArtifacts } from "../../../src/plugins/provider-contract-public-artifacts.js";
|
||||
import type { ProviderPlugin } from "../../../src/plugins/types.js";
|
||||
import { installProviderPluginContractSuite } from "./provider-contract-suites.js";
|
||||
|
||||
type ProviderContractEntry = {
|
||||
pluginId: string;
|
||||
provider: ProviderPlugin;
|
||||
};
|
||||
|
||||
function resolveProviderContractProvidersFromPublicArtifact(
|
||||
pluginId: string,
|
||||
): ProviderContractEntry[] | null {
|
||||
return resolveBundledExplicitProviderContractsFromPublicArtifacts({ onlyPluginIds: [pluginId] });
|
||||
}
|
||||
|
||||
export function describeProviderContracts(pluginId: string) {
|
||||
const providerIds =
|
||||
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ??
|
||||
[];
|
||||
const resolveProviderEntries = (): ProviderContractEntry[] => {
|
||||
const publicArtifactProviders = resolveProviderContractProvidersFromPublicArtifact(pluginId);
|
||||
if (publicArtifactProviders) {
|
||||
return publicArtifactProviders;
|
||||
}
|
||||
return resolveProviderContractProvidersForPluginIds([pluginId]).map((provider) => ({
|
||||
pluginId,
|
||||
provider,
|
||||
}));
|
||||
};
|
||||
|
||||
describe(`${pluginId} provider contract registry load`, () => {
|
||||
it("loads bundled providers without import-time registry failure", () => {
|
||||
const providers = resolveProviderEntries();
|
||||
expect(providerContractLoadError).toBeUndefined();
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
describe(`${pluginId}:${providerId} provider contract`, () => {
|
||||
// Resolve provider entries lazily so the non-isolated extension runner
|
||||
// does not race provider contract collection against other file imports.
|
||||
installProviderPluginContractSuite({
|
||||
provider: () => {
|
||||
const entry = resolveProviderEntries().find((entry) => entry.provider.id === providerId);
|
||||
if (!entry) {
|
||||
throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`);
|
||||
}
|
||||
return entry.provider;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
660
openclaw/test/helpers/plugins/provider-discovery-contract.ts
Normal file
660
openclaw/test/helpers/plugins/provider-discovery-contract.ts
Normal file
|
|
@ -0,0 +1,660 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
resolveBundledPluginPublicModulePath,
|
||||
resolveRelativeBundledPluginPublicModuleId,
|
||||
} from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
import { registerProviders, requireProvider } from "./contracts-testkit.js";
|
||||
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
const buildVllmProviderMock = vi.hoisted(() => vi.fn());
|
||||
const buildSglangProviderMock = vi.hoisted(() => vi.fn());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
|
||||
const bundledProviderModules = {
|
||||
cloudflareAiGatewayIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "cloudflare-ai-gateway",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
githubCopilotIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "github-copilot",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
githubCopilotRegisterRuntimeModuleId: resolveBundledPluginPublicModulePath({
|
||||
pluginId: "github-copilot",
|
||||
artifactBasename: "register.runtime.js",
|
||||
}),
|
||||
minimaxIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "minimax",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
qwenIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "qwen",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
sglangApiModuleId: resolveBundledPluginPublicModulePath({
|
||||
pluginId: "sglang",
|
||||
artifactBasename: "api.js",
|
||||
}),
|
||||
sglangIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "sglang",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
vllmApiModuleId: resolveBundledPluginPublicModulePath({
|
||||
pluginId: "vllm",
|
||||
artifactBasename: "api.js",
|
||||
}),
|
||||
vllmIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "vllm",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
};
|
||||
|
||||
type ProviderHandle = Awaited<ReturnType<typeof requireProvider>>;
|
||||
|
||||
type DiscoveryState = {
|
||||
runProviderCatalog: typeof import("../../../src/plugins/provider-discovery.js").runProviderCatalog;
|
||||
githubCopilotProvider?: ProviderHandle;
|
||||
vllmProvider?: ProviderHandle;
|
||||
sglangProvider?: ProviderHandle;
|
||||
minimaxProvider?: ProviderHandle;
|
||||
minimaxPortalProvider?: ProviderHandle;
|
||||
modelStudioProvider?: ProviderHandle;
|
||||
cloudflareAiGatewayProvider?: ProviderHandle;
|
||||
};
|
||||
|
||||
type BundledProviderUnderTest =
|
||||
| "github-copilot"
|
||||
| "vllm"
|
||||
| "sglang"
|
||||
| "minimax"
|
||||
| "modelstudio"
|
||||
| "cloudflare-ai-gateway";
|
||||
|
||||
function setRuntimeAuthStore(store?: AuthProfileStore) {
|
||||
const resolvedStore = store ?? {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
ensureAuthProfileStoreMock.mockReturnValue(resolvedStore);
|
||||
listProfilesForProviderMock.mockImplementation(
|
||||
(authStore: AuthProfileStore, providerId: string) =>
|
||||
Object.entries(authStore.profiles)
|
||||
.filter(([, credential]) => credential.provider === providerId)
|
||||
.map(([profileId]) => profileId),
|
||||
);
|
||||
}
|
||||
|
||||
function setGithubCopilotProfileSnapshot() {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "profile-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runCatalog(
|
||||
state: DiscoveryState,
|
||||
params: {
|
||||
provider: ProviderHandle;
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
resolveProviderApiKey?: () => { apiKey: string | undefined };
|
||||
resolveProviderAuth?: (
|
||||
providerId?: string,
|
||||
options?: { oauthMarker?: string },
|
||||
) => {
|
||||
apiKey: string | undefined;
|
||||
discoveryApiKey?: string;
|
||||
mode: "api_key" | "oauth" | "token" | "none";
|
||||
source: "env" | "profile" | "none";
|
||||
profileId?: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
return state.runProviderCatalog({
|
||||
provider: params.provider,
|
||||
config: params.config ?? {},
|
||||
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||
resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })),
|
||||
resolveProviderAuth:
|
||||
params.resolveProviderAuth ??
|
||||
((_, options) => ({
|
||||
apiKey: options?.oauthMarker,
|
||||
discoveryApiKey: undefined,
|
||||
mode: options?.oauthMarker ? "oauth" : "none",
|
||||
source: options?.oauthMarker ? "profile" : "none",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function importBundledProviderPlugin<T>(moduleUrl: string): Promise<T> {
|
||||
return (await import(moduleUrl)) as T;
|
||||
}
|
||||
|
||||
function installDiscoveryHooks(
|
||||
state: DiscoveryState,
|
||||
providerIds: readonly BundledProviderUnderTest[],
|
||||
) {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("openclaw/plugin-sdk/agent-runtime", () => {
|
||||
return {
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
vi.doMock("openclaw/plugin-sdk/provider-auth", () => {
|
||||
return {
|
||||
MINIMAX_OAUTH_MARKER: "minimax-oauth",
|
||||
applyAuthProfileConfig: (config: OpenClawConfig) => config,
|
||||
buildApiKeyCredential: (
|
||||
provider: string,
|
||||
key: unknown,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => ({
|
||||
type: "api_key",
|
||||
provider,
|
||||
...(typeof key === "string" ? { key } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
}),
|
||||
buildOauthProviderAuthResult: vi.fn(),
|
||||
coerceSecretRef: (value: unknown) =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null,
|
||||
ensureApiKeyFromOptionEnvOrPrompt: vi.fn(),
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
normalizeApiKeyInput: (value: unknown) => (typeof value === "string" ? value.trim() : ""),
|
||||
normalizeOptionalSecretInput: (value: unknown) =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : undefined,
|
||||
resolveNonEnvSecretRefApiKeyMarker: (source: unknown) =>
|
||||
typeof source === "string" ? source : "",
|
||||
upsertAuthProfile: vi.fn(),
|
||||
validateApiKeyInput: () => undefined,
|
||||
};
|
||||
});
|
||||
vi.doMock(bundledProviderModules.githubCopilotRegisterRuntimeModuleId, async () => {
|
||||
const actual = await vi.importActual<object>(
|
||||
bundledProviderModules.githubCopilotRegisterRuntimeModuleId,
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
};
|
||||
});
|
||||
vi.doMock(bundledProviderModules.vllmApiModuleId, async () => {
|
||||
return {
|
||||
VLLM_DEFAULT_API_KEY_ENV_VAR: "VLLM_API_KEY",
|
||||
VLLM_DEFAULT_BASE_URL: "http://127.0.0.1:8000/v1",
|
||||
VLLM_MODEL_PLACEHOLDER: "meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
VLLM_PROVIDER_LABEL: "vLLM",
|
||||
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
|
||||
};
|
||||
});
|
||||
vi.doMock(bundledProviderModules.sglangApiModuleId, async () => {
|
||||
return {
|
||||
SGLANG_DEFAULT_API_KEY_ENV_VAR: "SGLANG_API_KEY",
|
||||
SGLANG_DEFAULT_BASE_URL: "http://127.0.0.1:30000/v1",
|
||||
SGLANG_MODEL_PLACEHOLDER: "Qwen/Qwen3-8B",
|
||||
SGLANG_PROVIDER_LABEL: "SGLang",
|
||||
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
|
||||
};
|
||||
});
|
||||
({ runProviderCatalog: state.runProviderCatalog } =
|
||||
await import("../../../src/plugins/provider-discovery.js"));
|
||||
|
||||
if (providerIds.includes("github-copilot")) {
|
||||
const { default: githubCopilotPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.githubCopilotIndexModuleUrl);
|
||||
state.githubCopilotProvider = requireProvider(
|
||||
await registerProviders(githubCopilotPlugin),
|
||||
"github-copilot",
|
||||
);
|
||||
}
|
||||
|
||||
if (providerIds.includes("vllm")) {
|
||||
const { default: vllmPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.vllmIndexModuleUrl);
|
||||
state.vllmProvider = requireProvider(await registerProviders(vllmPlugin), "vllm");
|
||||
}
|
||||
|
||||
if (providerIds.includes("sglang")) {
|
||||
const { default: sglangPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.sglangIndexModuleUrl);
|
||||
state.sglangProvider = requireProvider(await registerProviders(sglangPlugin), "sglang");
|
||||
}
|
||||
|
||||
if (providerIds.includes("minimax")) {
|
||||
const { default: minimaxPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.minimaxIndexModuleUrl);
|
||||
const registeredProviders = await registerProviders(minimaxPlugin);
|
||||
state.minimaxProvider = requireProvider(registeredProviders, "minimax");
|
||||
state.minimaxPortalProvider = requireProvider(registeredProviders, "minimax-portal");
|
||||
}
|
||||
|
||||
if (providerIds.includes("modelstudio")) {
|
||||
const { default: qwenPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.qwenIndexModuleUrl);
|
||||
state.modelStudioProvider = requireProvider(await registerProviders(qwenPlugin), "qwen");
|
||||
}
|
||||
|
||||
if (providerIds.includes("cloudflare-ai-gateway")) {
|
||||
const { default: cloudflareAiGatewayPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.cloudflareAiGatewayIndexModuleUrl);
|
||||
state.cloudflareAiGatewayProvider = requireProvider(
|
||||
await registerProviders(cloudflareAiGatewayPlugin),
|
||||
"cloudflare-ai-gateway",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setRuntimeAuthStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
buildVllmProviderMock.mockReset();
|
||||
buildSglangProviderMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
listProfilesForProviderMock.mockReset();
|
||||
setRuntimeAuthStore();
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGithubCopilotProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("github-copilot provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["github-copilot"]);
|
||||
|
||||
it("keeps catalog disabled without env tokens or profiles", async () => {
|
||||
await expect(
|
||||
runCatalog(state, { provider: state.githubCopilotProvider! }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps profile-only catalog fallback provider-owned", async () => {
|
||||
setGithubCopilotProfileSnapshot();
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.githubCopilotProvider!,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps env-token base URL resolution provider-owned", async () => {
|
||||
resolveCopilotApiTokenMock.mockResolvedValueOnce({
|
||||
token: "copilot-api-token",
|
||||
baseUrl: "https://copilot-proxy.example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.githubCopilotProvider!,
|
||||
env: {
|
||||
GITHUB_TOKEN: "github-env-token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://copilot-proxy.example.com",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({
|
||||
githubToken: "github-env-token",
|
||||
env: expect.objectContaining({
|
||||
GITHUB_TOKEN: "github-env-token",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeVllmProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("vllm provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["vllm"]);
|
||||
|
||||
it("keeps self-hosted discovery provider-owned", async () => {
|
||||
buildVllmProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.vllmProvider!,
|
||||
config: {},
|
||||
env: {
|
||||
VLLM_API_KEY: "env-vllm-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "VLLM_API_KEY",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
},
|
||||
});
|
||||
expect(buildVllmProviderMock).toHaveBeenCalledWith({
|
||||
apiKey: "env-vllm-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeSglangProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("sglang provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["sglang"]);
|
||||
|
||||
it("keeps self-hosted discovery provider-owned", async () => {
|
||||
buildSglangProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.sglangProvider!,
|
||||
config: {},
|
||||
env: {
|
||||
SGLANG_API_KEY: "env-sglang-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
},
|
||||
});
|
||||
expect(buildSglangProviderMock).toHaveBeenCalledWith({
|
||||
apiKey: "env-sglang-key",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeMinimaxProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("minimax provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["minimax"]);
|
||||
|
||||
it("keeps API catalog provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.minimaxProvider!,
|
||||
config: {},
|
||||
env: {
|
||||
MINIMAX_API_KEY: "minimax-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: "minimax-key" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "minimax-key",
|
||||
discoveryApiKey: undefined,
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
apiKey: "minimax-key",
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "MiniMax-M2.7" }),
|
||||
expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps portal oauth marker fallback provider-owned", async () => {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax-portal:default": {
|
||||
type: "oauth",
|
||||
provider: "minimax-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.minimaxPortalProvider!,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "minimax-oauth",
|
||||
discoveryApiKey: "access-token",
|
||||
mode: "oauth",
|
||||
source: "profile",
|
||||
profileId: "minimax-portal:default",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
apiKey: "minimax-oauth",
|
||||
models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps portal explicit base URL override provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.minimaxPortalProvider!,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
"minimax-portal": {
|
||||
baseUrl: "https://portal-proxy.example.com/anthropic",
|
||||
apiKey: "explicit-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://portal-proxy.example.com/anthropic",
|
||||
apiKey: "explicit-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeModelStudioProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("modelstudio provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["modelstudio"]);
|
||||
|
||||
it("keeps catalog provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.modelStudioProvider!,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
modelstudio: {
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
MODELSTUDIO_API_KEY: "modelstudio-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "modelstudio-key",
|
||||
discoveryApiKey: undefined,
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "modelstudio-key",
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "qwen3.5-plus" }),
|
||||
expect.objectContaining({ id: "qwen3-max-2026-01-23" }),
|
||||
expect.objectContaining({ id: "MiniMax-M2.5" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeCloudflareAiGatewayProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("cloudflare-ai-gateway provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["cloudflare-ai-gateway"]);
|
||||
|
||||
it("keeps catalog disabled without stored metadata", async () => {
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.cloudflareAiGatewayProvider!,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps env-managed catalog provider-owned", async () => {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"cloudflare-ai-gateway:default": {
|
||||
type: "api_key",
|
||||
provider: "cloudflare-ai-gateway",
|
||||
keyRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
},
|
||||
metadata: {
|
||||
accountId: "acc-123",
|
||||
gatewayId: "gw-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.cloudflareAiGatewayProvider!,
|
||||
config: {},
|
||||
env: {
|
||||
CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
models: [expect.objectContaining({ id: "claude-sonnet-4-5" })],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
91
openclaw/test/helpers/plugins/provider-onboard.ts
Normal file
91
openclaw/test/helpers/plugins/provider-onboard.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import type { ModelApi } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { expect } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import {
|
||||
createConfigWithFallbacks,
|
||||
createLegacyProviderConfig,
|
||||
EXPECTED_FALLBACKS,
|
||||
} from "./onboard-config.js";
|
||||
|
||||
export function expectProviderOnboardAllowlistAlias(params: {
|
||||
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
modelRef: string;
|
||||
alias: string;
|
||||
}) {
|
||||
const withDefault = params.applyProviderConfig({});
|
||||
expect(Object.keys(withDefault.agents?.defaults?.models ?? {})).toContain(params.modelRef);
|
||||
|
||||
const withAlias = params.applyProviderConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[params.modelRef]: { alias: params.alias },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(withAlias.agents?.defaults?.models?.[params.modelRef]?.alias).toBe(params.alias);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardPrimaryAndFallbacks(params: {
|
||||
applyConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
modelRef: string;
|
||||
}) {
|
||||
expectProviderOnboardPrimaryModel(params);
|
||||
|
||||
const cfgWithFallbacks = params.applyConfig(createConfigWithFallbacks());
|
||||
expect(resolveAgentModelFallbackValues(cfgWithFallbacks.agents?.defaults?.model)).toEqual([
|
||||
...EXPECTED_FALLBACKS,
|
||||
]);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardPrimaryModel(params: {
|
||||
applyConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
modelRef: string;
|
||||
}) {
|
||||
const cfg = params.applyConfig({});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(params.modelRef);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardPreservesPrimary(params: {
|
||||
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
primaryModelRef: string;
|
||||
}) {
|
||||
const cfg = params.applyProviderConfig({
|
||||
agents: { defaults: { model: { primary: params.primaryModelRef } } },
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(params.primaryModelRef);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardMergedLegacyConfig(params: {
|
||||
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
providerId: string;
|
||||
providerApi: ModelApi;
|
||||
baseUrl: string;
|
||||
legacyApi: Parameters<typeof createLegacyProviderConfig>[0]["api"];
|
||||
legacyModelId?: string;
|
||||
legacyModelName?: string;
|
||||
legacyBaseUrl?: string;
|
||||
legacyApiKey?: string;
|
||||
}) {
|
||||
const cfg = params.applyProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: params.providerId,
|
||||
api: params.legacyApi,
|
||||
modelId: params.legacyModelId,
|
||||
modelName: params.legacyModelName,
|
||||
baseUrl: params.legacyBaseUrl,
|
||||
apiKey: params.legacyApiKey,
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = cfg.models?.providers?.[params.providerId];
|
||||
expect(provider?.baseUrl).toBe(params.baseUrl);
|
||||
expect(provider?.api).toBe(params.providerApi);
|
||||
expect(provider?.apiKey).toBe((params.legacyApiKey ?? "old-key").trim());
|
||||
return provider;
|
||||
}
|
||||
84
openclaw/test/helpers/plugins/provider-registration.ts
Normal file
84
openclaw/test/helpers/plugins/provider-registration.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type {
|
||||
ImageGenerationProviderPlugin,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
MusicGenerationProviderPlugin,
|
||||
ProviderPlugin,
|
||||
SpeechProviderPlugin,
|
||||
VideoGenerationProviderPlugin,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { createTestPluginApi } from "./plugin-api.js";
|
||||
|
||||
type RegisteredProviderCollections = {
|
||||
providers: ProviderPlugin[];
|
||||
speechProviders: SpeechProviderPlugin[];
|
||||
mediaProviders: MediaUnderstandingProviderPlugin[];
|
||||
imageProviders: ImageGenerationProviderPlugin[];
|
||||
musicProviders: MusicGenerationProviderPlugin[];
|
||||
videoProviders: VideoGenerationProviderPlugin[];
|
||||
};
|
||||
|
||||
type ProviderPluginModule = {
|
||||
register(api: ReturnType<typeof createTestPluginApi>): void;
|
||||
};
|
||||
|
||||
export async function registerProviderPlugin(params: {
|
||||
plugin: ProviderPluginModule;
|
||||
id: string;
|
||||
name: string;
|
||||
}): Promise<RegisteredProviderCollections> {
|
||||
const providers: ProviderPlugin[] = [];
|
||||
const speechProviders: SpeechProviderPlugin[] = [];
|
||||
const mediaProviders: MediaUnderstandingProviderPlugin[] = [];
|
||||
const imageProviders: ImageGenerationProviderPlugin[] = [];
|
||||
const musicProviders: MusicGenerationProviderPlugin[] = [];
|
||||
const videoProviders: VideoGenerationProviderPlugin[] = [];
|
||||
|
||||
params.plugin.register(
|
||||
createTestPluginApi({
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerProvider: (provider) => {
|
||||
providers.push(provider);
|
||||
},
|
||||
registerSpeechProvider: (provider) => {
|
||||
speechProviders.push(provider);
|
||||
},
|
||||
registerMediaUnderstandingProvider: (provider) => {
|
||||
mediaProviders.push(provider);
|
||||
},
|
||||
registerImageGenerationProvider: (provider) => {
|
||||
imageProviders.push(provider);
|
||||
},
|
||||
registerMusicGenerationProvider: (provider) => {
|
||||
musicProviders.push(provider);
|
||||
},
|
||||
registerVideoGenerationProvider: (provider) => {
|
||||
videoProviders.push(provider);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
providers,
|
||||
speechProviders,
|
||||
mediaProviders,
|
||||
imageProviders,
|
||||
musicProviders,
|
||||
videoProviders,
|
||||
};
|
||||
}
|
||||
|
||||
export function requireRegisteredProvider<T extends { id: string }>(
|
||||
entries: T[],
|
||||
id: string,
|
||||
label = "provider",
|
||||
): T {
|
||||
const entry = entries.find((candidate) => candidate.id === id);
|
||||
if (!entry) {
|
||||
throw new Error(`${label} ${id} was not registered`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
876
openclaw/test/helpers/plugins/provider-runtime-contract.ts
Normal file
876
openclaw/test/helpers/plugins/provider-runtime-contract.ts
Normal file
|
|
@ -0,0 +1,876 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../../src/test-utils/provider-usage-fetch.js";
|
||||
import { registerProviderPlugin, requireRegisteredProvider } from "./provider-registration.js";
|
||||
|
||||
const CONTRACT_SETUP_TIMEOUT_MS = 300_000;
|
||||
|
||||
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
|
||||
const getOAuthProvidersMock = vi.hoisted(() =>
|
||||
vi.fn(() => [
|
||||
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" },
|
||||
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" },
|
||||
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" },
|
||||
]),
|
||||
);
|
||||
const providerRuntimeContractModules = {
|
||||
anthropicIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "anthropic",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
githubCopilotIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "github-copilot",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
googleIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "google",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
openAIIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "openai",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
openRouterIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "openrouter",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
veniceIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "venice",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
zaiIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "zai",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
|
||||
getOAuthProviders: getOAuthProvidersMock,
|
||||
};
|
||||
});
|
||||
|
||||
async function importBundledProviderPlugin<T>(moduleUrl: string): Promise<T> {
|
||||
return (await import(moduleUrl)) as T;
|
||||
}
|
||||
|
||||
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.name ?? overrides.id,
|
||||
api: overrides.api ?? "openai-responses",
|
||||
provider: overrides.provider ?? "demo",
|
||||
baseUrl: overrides.baseUrl ?? "https://api.example.com/v1",
|
||||
reasoning: overrides.reasoning ?? true,
|
||||
input: overrides.input ?? ["text"],
|
||||
cost: overrides.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: overrides.contextWindow ?? 200_000,
|
||||
maxTokens: overrides.maxTokens ?? 8_192,
|
||||
} satisfies ProviderRuntimeModel;
|
||||
}
|
||||
|
||||
type ProviderRuntimeContractFixture = {
|
||||
providerIds: string[];
|
||||
pluginId: string;
|
||||
name: string;
|
||||
load: () => Promise<{ default: Parameters<typeof registerProviderPlugin>[0]["plugin"] }>;
|
||||
};
|
||||
|
||||
const PROVIDER_RUNTIME_CONTRACT_FIXTURES: readonly ProviderRuntimeContractFixture[] = [
|
||||
{
|
||||
providerIds: ["anthropic"],
|
||||
pluginId: "anthropic",
|
||||
name: "Anthropic",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.anthropicIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["github-copilot"],
|
||||
pluginId: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.githubCopilotIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["google", "google-gemini-cli"],
|
||||
pluginId: "google",
|
||||
name: "Google",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.googleIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["openai", "openai-codex"],
|
||||
pluginId: "openai",
|
||||
name: "OpenAI",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.openAIIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["openrouter"],
|
||||
pluginId: "openrouter",
|
||||
name: "OpenRouter",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.openRouterIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["venice"],
|
||||
pluginId: "venice",
|
||||
name: "Venice",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.veniceIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["zai"],
|
||||
pluginId: "zai",
|
||||
name: "Z.AI",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.zaiIndexModuleId),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const providerRuntimeContractProviders = new Map<string, ProviderPlugin>();
|
||||
let providerRuntimeContractLoadPromise: Promise<void> | null = null;
|
||||
|
||||
function requireProviderContractProvider(providerId: string): ProviderPlugin {
|
||||
const provider = providerRuntimeContractProviders.get(providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`provider runtime contract fixture missing for ${providerId}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
async function ensureProviderRuntimeContractProvidersLoaded() {
|
||||
if (!providerRuntimeContractLoadPromise) {
|
||||
providerRuntimeContractLoadPromise = (async () => {
|
||||
providerRuntimeContractProviders.clear();
|
||||
const registeredFixtures = await Promise.all(
|
||||
PROVIDER_RUNTIME_CONTRACT_FIXTURES.map(async (fixture) => {
|
||||
const plugin = await fixture.load();
|
||||
return {
|
||||
fixture,
|
||||
providers: (
|
||||
await registerProviderPlugin({
|
||||
plugin: plugin.default,
|
||||
id: fixture.pluginId,
|
||||
name: fixture.name,
|
||||
})
|
||||
).providers,
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const { fixture, providers } of registeredFixtures) {
|
||||
for (const providerId of fixture.providerIds) {
|
||||
providerRuntimeContractProviders.set(
|
||||
providerId,
|
||||
requireRegisteredProvider(providers, providerId, "provider"),
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await providerRuntimeContractLoadPromise;
|
||||
}
|
||||
|
||||
function installRuntimeHooks() {
|
||||
beforeAll(async () => {
|
||||
await ensureProviderRuntimeContractProvidersLoaded();
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
|
||||
beforeEach(() => {
|
||||
refreshOpenAICodexTokenMock.mockReset();
|
||||
getOAuthProvidersMock.mockClear();
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
export function describeAnthropicProviderRuntimeContract() {
|
||||
describe("anthropic provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns anthropic 4.6 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.6-20260219",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "claude-sonnet-4.5-20260219"
|
||||
? createModel({
|
||||
id,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "claude-sonnet-4.6-20260219",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage auth resolution", async () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "anthropic",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => ({
|
||||
token: "anthropic-oauth-token",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "anthropic-oauth-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns auth doctor hint generation", () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const hint = provider.buildAuthDoctorHint?.({
|
||||
provider: "anthropic",
|
||||
profileId: "anthropic:default",
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:oauth-user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "oauth-access",
|
||||
refresh: "oauth-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hint).toContain("suggested profile: anthropic:oauth-user@example.com");
|
||||
expect(hint).toContain("openclaw doctor --yes");
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
seven_day: { utilization: 35, resets_at: "2026-01-09T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "anthropic",
|
||||
token: "anthropic-oauth-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 20, resetAt: Date.parse("2026-01-07T01:00:00Z") },
|
||||
{ label: "Week", usedPercent: 35, resetAt: Date.parse("2026-01-09T01:00:00Z") },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGithubCopilotProviderRuntimeContract() {
|
||||
describe(
|
||||
"github-copilot provider runtime contract",
|
||||
{ timeout: CONTRACT_SETUP_TIMEOUT_MS },
|
||||
() => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns Copilot-specific forward-compat fallbacks", () => {
|
||||
const provider = requireProviderContractProvider("github-copilot");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-5.4",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.2-codex"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-codex-responses",
|
||||
provider: "github-copilot",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4",
|
||||
provider: "github-copilot",
|
||||
api: "openai-codex-responses",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function describeGoogleProviderRuntimeContract() {
|
||||
describe("google provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns google direct gemini 3.1 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("google");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "google",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gemini-3-pro-preview"
|
||||
? createModel({
|
||||
id,
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: false,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gemini-3.1-pro-preview",
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns gemini cli 3.1 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "google-gemini-cli",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gemini-3-pro-preview"
|
||||
? createModel({
|
||||
id,
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-gemini-cli",
|
||||
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||
reasoning: false,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gemini-3.1-pro-preview",
|
||||
provider: "google-gemini-cli",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage-token parsing", async () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "google-gemini-cli",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => ({
|
||||
token: '{"token":"google-oauth-token"}',
|
||||
accountId: "google-account",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "google-oauth-token",
|
||||
accountId: "google-account",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns OAuth auth-profile formatting", () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
|
||||
expect(
|
||||
provider.formatApiKey?.({
|
||||
type: "oauth",
|
||||
provider: "google-gemini-cli",
|
||||
access: "google-oauth-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
projectId: "proj-123",
|
||||
}),
|
||||
).toBe('{"token":"google-oauth-token","projectId":"proj-123"}');
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
|
||||
return makeResponse(200, {
|
||||
buckets: [
|
||||
{ modelId: "gemini-3.1-pro-preview", remainingFraction: 0.4 },
|
||||
{ modelId: "gemini-3.1-flash-preview", remainingFraction: 0.8 },
|
||||
],
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "google-gemini-cli",
|
||||
token: "google-oauth-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
provider: "google-gemini-cli",
|
||||
displayName: "Gemini",
|
||||
});
|
||||
expect(snapshot?.windows[0]).toEqual({ label: "Pro", usedPercent: 60 });
|
||||
expect(snapshot?.windows[1]?.label).toBe("Flash");
|
||||
expect(snapshot?.windows[1]?.usedPercent).toBeCloseTo(20);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenAIProviderRuntimeContract() {
|
||||
describe("openai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns openai gpt-5.4 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4-pro",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.2-pro"
|
||||
? createModel({
|
||||
id,
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-pro",
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns openai gpt-5.4 mini forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4-mini",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5-mini"
|
||||
? createModel({
|
||||
id,
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-mini",
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns direct openai transport normalization", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
model: createModel({
|
||||
id: "gpt-5.4",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
api: "openai-responses",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns refresh fallback for accountId extraction failures", async () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const credential = {
|
||||
type: "oauth" as const,
|
||||
provider: "openai-codex",
|
||||
access: "cached-access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
};
|
||||
|
||||
refreshOpenAICodexTokenMock.mockRejectedValueOnce(
|
||||
new Error("Failed to extract accountId from token"),
|
||||
);
|
||||
|
||||
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential);
|
||||
});
|
||||
|
||||
it("owns forward-compat codex models", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.2-codex"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns forward-compat codex mini models", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4-mini",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.1-codex-mini"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-mini",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
contextWindow: 272_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns codex transport defaults", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
extraParams: { temperature: 0.2 },
|
||||
}),
|
||||
).toEqual({
|
||||
temperature: 0.2,
|
||||
transport: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("chatgpt.com/backend-api/wham/usage")) {
|
||||
return makeResponse(200, {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 12,
|
||||
limit_window_seconds: 10800,
|
||||
reset_at: 1_705_000,
|
||||
},
|
||||
},
|
||||
plan_type: "Plus",
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "openai-codex",
|
||||
token: "codex-token",
|
||||
accountId: "acc-1",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "openai-codex",
|
||||
displayName: "Codex",
|
||||
windows: [{ label: "3h", usedPercent: 12, resetAt: 1_705_000_000 }],
|
||||
plan: "Plus",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenRouterProviderRuntimeContract() {
|
||||
describe("openrouter provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns dynamic OpenRouter model defaults", () => {
|
||||
const provider = requireProviderContractProvider("openrouter");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openrouter",
|
||||
modelId: "x-ai/grok-4-1-fast",
|
||||
modelRegistry: {
|
||||
find: () => null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "x-ai/grok-4-1-fast",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
maxTokens: 8192,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeVeniceProviderRuntimeContract() {
|
||||
describe("venice provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns xai downstream compat flags for grok-backed Venice models", () => {
|
||||
const provider = requireProviderContractProvider("venice");
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "venice",
|
||||
modelId: "grok-41-fast",
|
||||
model: createModel({
|
||||
id: "grok-41-fast",
|
||||
provider: "venice",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.venice.ai/api/v1",
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
compat: {
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeZAIProviderRuntimeContract() {
|
||||
describe("zai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns glm-5 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "glm-4.7"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: false,
|
||||
contextWindow: 202_752,
|
||||
maxTokens: 16_384,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "glm-5",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage auth resolution", async () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {
|
||||
ZAI_API_KEY: "env-zai-token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
provider: "zai",
|
||||
resolveApiKeyFromConfigAndStore: () => "env-zai-token",
|
||||
resolveOAuthToken: async () => null,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "env-zai-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy pi auth tokens for usage auth", async () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-contract-"));
|
||||
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(home, ".pi", "agent", "auth.json"),
|
||||
`${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: { HOME: home } as NodeJS.ProcessEnv,
|
||||
provider: "zai",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => null,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "legacy-zai-token",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) {
|
||||
return makeResponse(200, {
|
||||
success: true,
|
||||
code: 200,
|
||||
data: {
|
||||
planName: "Pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
percentage: 25,
|
||||
unit: 3,
|
||||
number: 6,
|
||||
nextResetTime: "2026-01-07T06:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "zai",
|
||||
token: "env-zai-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "zai",
|
||||
displayName: "z.ai",
|
||||
windows: [{ label: "Tokens (6h)", usedPercent: 25, resetAt: 1_767_765_600_000 }],
|
||||
plan: "Pro",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
4
openclaw/test/helpers/plugins/provider-usage-fetch.ts
Normal file
4
openclaw/test/helpers/plugins/provider-usage-fetch.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../../src/test-utils/provider-usage-fetch.js";
|
||||
239
openclaw/test/helpers/plugins/provider-wizard-contract-suites.ts
Normal file
239
openclaw/test/helpers/plugins/provider-wizard-contract-suites.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildProviderPluginMethodChoice,
|
||||
resolveProviderModelPickerEntries,
|
||||
resolveProviderPluginChoice,
|
||||
resolveProviderWizardOptions,
|
||||
} from "../../../src/plugins/provider-wizard.js";
|
||||
import type { ProviderAuthMethod, ProviderPlugin } from "../../../src/plugins/types.js";
|
||||
|
||||
const resolvePluginProvidersMock = vi.fn();
|
||||
|
||||
vi.mock("../../../src/plugins/providers.runtime.js", () => ({
|
||||
isPluginProvidersLoadInFlight: () => false,
|
||||
resolvePluginProviders: (...args: unknown[]) => resolvePluginProvidersMock(...args),
|
||||
}));
|
||||
|
||||
function createAuthMethod(
|
||||
params: Pick<ProviderAuthMethod, "id" | "label"> &
|
||||
Partial<Pick<ProviderAuthMethod, "hint" | "wizard">>,
|
||||
): ProviderAuthMethod {
|
||||
return {
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
...(params.hint ? { hint: params.hint } : {}),
|
||||
...(params.wizard ? { wizard: params.wizard } : {}),
|
||||
kind: "api_key",
|
||||
run: async () => ({ profiles: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_PROVIDERS: ProviderPlugin[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
label: "Alpha",
|
||||
auth: [
|
||||
createAuthMethod({
|
||||
id: "api-key",
|
||||
label: "API key",
|
||||
wizard: {
|
||||
choiceLabel: "Alpha key",
|
||||
choiceHint: "Use an API key",
|
||||
groupId: "alpha",
|
||||
groupLabel: "Alpha",
|
||||
onboardingScopes: ["text-inference"],
|
||||
},
|
||||
}),
|
||||
createAuthMethod({
|
||||
id: "oauth",
|
||||
label: "OAuth",
|
||||
wizard: {
|
||||
choiceId: "alpha-oauth",
|
||||
choiceLabel: "Alpha OAuth",
|
||||
groupId: "alpha",
|
||||
groupLabel: "Alpha",
|
||||
groupHint: "Recommended",
|
||||
},
|
||||
}),
|
||||
],
|
||||
wizard: {
|
||||
modelPicker: {
|
||||
label: "Alpha custom",
|
||||
hint: "Pick Alpha models",
|
||||
methodId: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
label: "Beta",
|
||||
auth: [createAuthMethod({ id: "token", label: "Token" })],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceLabel: "Beta setup",
|
||||
groupId: "beta",
|
||||
groupLabel: "Beta",
|
||||
},
|
||||
modelPicker: {
|
||||
label: "Beta custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gamma",
|
||||
label: "Gamma",
|
||||
auth: [
|
||||
createAuthMethod({ id: "default", label: "Default auth" }),
|
||||
createAuthMethod({ id: "alt", label: "Alt auth" }),
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
methodId: "alt",
|
||||
choiceId: "gamma-alt",
|
||||
choiceLabel: "Gamma alt",
|
||||
groupId: "gamma",
|
||||
groupLabel: "Gamma",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_PROVIDER_IDS = TEST_PROVIDERS.map((provider) => provider.id).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
|
||||
function sortedValues(values: readonly string[]) {
|
||||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function expectUniqueValues(values: readonly string[]) {
|
||||
expect(values).toEqual([...new Set(values)]);
|
||||
}
|
||||
|
||||
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
|
||||
return sortedValues(
|
||||
providers.flatMap((provider) => {
|
||||
const methodSetups = provider.auth.filter((method) => method.wizard);
|
||||
if (methodSetups.length > 0) {
|
||||
return methodSetups.map(
|
||||
(method) =>
|
||||
method.wizard?.choiceId?.trim() ||
|
||||
buildProviderPluginMethodChoice(provider.id, method.id),
|
||||
);
|
||||
}
|
||||
|
||||
const setup = provider.wizard?.setup;
|
||||
if (!setup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const explicitMethodId = setup.methodId?.trim();
|
||||
if (explicitMethodId && provider.auth.some((method) => method.id === explicitMethodId)) {
|
||||
return [
|
||||
setup.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, explicitMethodId),
|
||||
];
|
||||
}
|
||||
|
||||
if (provider.auth.length === 1) {
|
||||
return [setup.choiceId?.trim() || provider.id];
|
||||
}
|
||||
|
||||
return provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
|
||||
return sortedValues(
|
||||
providers.flatMap((provider) => {
|
||||
const modelPicker = provider.wizard?.modelPicker;
|
||||
if (!modelPicker) {
|
||||
return [];
|
||||
}
|
||||
const explicitMethodId = modelPicker.methodId?.trim();
|
||||
if (explicitMethodId) {
|
||||
return [buildProviderPluginMethodChoice(provider.id, explicitMethodId)];
|
||||
}
|
||||
if (provider.auth.length === 1) {
|
||||
return [provider.id];
|
||||
}
|
||||
return [buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default")];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectAllChoicesResolve(
|
||||
values: readonly string[],
|
||||
resolver: (choice: string) => ReturnType<typeof resolveProviderPluginChoice>,
|
||||
) {
|
||||
expect(
|
||||
values.every((value) => Boolean(resolver(value))),
|
||||
values.join(", "),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
resolvePluginProvidersMock.mockReturnValue(TEST_PROVIDERS);
|
||||
});
|
||||
|
||||
export function describeProviderWizardSetupOptionsContract() {
|
||||
describe("provider wizard setup options contract", () => {
|
||||
it("exposes every wizard setup choice through the shared wizard layer", () => {
|
||||
const options = resolveProviderWizardOptions({
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: TEST_PROVIDER_IDS,
|
||||
slots: {
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(sortedValues(options.map((option) => option.value))).toEqual(
|
||||
resolveExpectedWizardChoiceValues(TEST_PROVIDERS),
|
||||
);
|
||||
expectUniqueValues(options.map((option) => option.value));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeProviderWizardChoiceResolutionContract() {
|
||||
describe("provider wizard choice resolution contract", () => {
|
||||
it("round-trips every shared wizard choice back to its provider and auth method", () => {
|
||||
const options = resolveProviderWizardOptions({ config: {}, env: process.env });
|
||||
|
||||
expectAllChoicesResolve(
|
||||
options.map((option) => option.value),
|
||||
(choice) =>
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeProviderWizardModelPickerContract() {
|
||||
describe("provider wizard model picker contract", () => {
|
||||
it("exposes every model-picker entry through the shared wizard layer", () => {
|
||||
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
|
||||
|
||||
expect(sortedValues(entries.map((entry) => entry.value))).toEqual(
|
||||
resolveExpectedModelPickerValues(TEST_PROVIDERS),
|
||||
);
|
||||
expectAllChoicesResolve(
|
||||
entries.map((entry) => entry.value),
|
||||
(choice) =>
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
53
openclaw/test/helpers/plugins/public-artifacts.ts
Normal file
53
openclaw/test/helpers/plugins/public-artifacts.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../../../src/plugins/runtime-sidecar-paths.js";
|
||||
|
||||
function assertUniqueValues<T extends string>(values: readonly T[], label: string): readonly T[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = new Set<string>();
|
||||
for (const value of values) {
|
||||
if (seen.has(value)) {
|
||||
duplicates.add(value);
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
}
|
||||
if (duplicates.size > 0) {
|
||||
throw new Error(`Duplicate ${label}: ${Array.from(duplicates).join(", ")}`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function getPublicArtifactBasename(relativePath: string): string {
|
||||
return relativePath.split("/").at(-1) ?? relativePath;
|
||||
}
|
||||
|
||||
const EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues(
|
||||
[
|
||||
"action-runtime.runtime.js",
|
||||
"action-runtime-api.js",
|
||||
"allow-from.js",
|
||||
"api.js",
|
||||
"auth-presence.js",
|
||||
"channel-config-api.js",
|
||||
"index.js",
|
||||
"login-qr-api.js",
|
||||
"onboard.js",
|
||||
"openai-codex-catalog.js",
|
||||
"provider-catalog.js",
|
||||
"session-key-api.js",
|
||||
"setup-api.js",
|
||||
"setup-entry.js",
|
||||
"timeouts.js",
|
||||
"x-search.js",
|
||||
] as const,
|
||||
"extra guarded extension public surface basename",
|
||||
);
|
||||
|
||||
export const BUNDLED_RUNTIME_SIDECAR_BASENAMES = assertUniqueValues(
|
||||
[...new Set(BUNDLED_RUNTIME_SIDECAR_PATHS.map(getPublicArtifactBasename))],
|
||||
"bundled runtime sidecar basename",
|
||||
);
|
||||
|
||||
export const GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues(
|
||||
[...BUNDLED_RUNTIME_SIDECAR_BASENAMES, ...EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES],
|
||||
"guarded extension public surface basename",
|
||||
);
|
||||
31
openclaw/test/helpers/plugins/runtime-env.ts
Normal file
31
openclaw/test/helpers/plugins/runtime-env.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { OutputRuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createRuntimeEnv(options?: { throwOnExit?: boolean }): OutputRuntimeEnv {
|
||||
const throwOnExit = options?.throwOnExit ?? true;
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
writeStdout: vi.fn(),
|
||||
writeJson: vi.fn(),
|
||||
exit: throwOnExit
|
||||
? vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
})
|
||||
: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets plugin suites ascribe runtime extension shape.
|
||||
export function createTypedRuntimeEnv<TRuntime>(options?: { throwOnExit?: boolean }): TRuntime {
|
||||
return createRuntimeEnv(options) as TRuntime;
|
||||
}
|
||||
|
||||
export function createNonExitingRuntimeEnv(): OutputRuntimeEnv {
|
||||
return createRuntimeEnv({ throwOnExit: false });
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets plugin suites ascribe runtime extension shape.
|
||||
export function createNonExitingTypedRuntimeEnv<TRuntime>(): TRuntime {
|
||||
return createTypedRuntimeEnv<TRuntime>({ throwOnExit: false });
|
||||
}
|
||||
1
openclaw/test/helpers/plugins/runtime-taskflow.ts
Normal file
1
openclaw/test/helpers/plugins/runtime-taskflow.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createRuntimeTaskFlow } from "../../../src/plugins/runtime/runtime-taskflow.js";
|
||||
65
openclaw/test/helpers/plugins/send-config.ts
Normal file
65
openclaw/test/helpers/plugins/send-config.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { expect } from "vitest";
|
||||
|
||||
type MockFn = (...args: never[]) => unknown;
|
||||
|
||||
type CfgThreadingAssertion<TCfg> = {
|
||||
loadConfig: MockFn;
|
||||
resolveAccount: MockFn;
|
||||
cfg: TCfg;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type SendRuntimeState = {
|
||||
loadConfig: MockFn;
|
||||
resolveMarkdownTableMode: MockFn;
|
||||
convertMarkdownTables: MockFn;
|
||||
record: MockFn;
|
||||
};
|
||||
|
||||
export function expectProvidedCfgSkipsRuntimeLoad<TCfg>({
|
||||
loadConfig,
|
||||
resolveAccount,
|
||||
cfg,
|
||||
accountId,
|
||||
}: CfgThreadingAssertion<TCfg>): void {
|
||||
expect(loadConfig).not.toHaveBeenCalled();
|
||||
expect(resolveAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function expectRuntimeCfgFallback<TCfg>({
|
||||
loadConfig,
|
||||
resolveAccount,
|
||||
cfg,
|
||||
accountId,
|
||||
}: CfgThreadingAssertion<TCfg>): void {
|
||||
expect(loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(resolveAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSendCfgThreadingRuntime({
|
||||
loadConfig,
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
record,
|
||||
}: SendRuntimeState) {
|
||||
return {
|
||||
config: {
|
||||
loadConfig,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
},
|
||||
activity: {
|
||||
record,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
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