mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 07:33:46 -06:00
aa33c63c5b
toSorted/flat in the labeler coverage test, drop no-op empty-object spread fallbacks in the telegram command-menu e2e config, and attach the caught cause when rewrapping the trusted-workflow parse error.
326 lines
13 KiB
TypeScript
326 lines
13 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { withServer, withTempDir } from "openclaw/plugin-sdk/test-env";
|
|
import { expect, test } from "vitest";
|
|
import {
|
|
startQaGatewayChild,
|
|
startQaMockOpenAiServer,
|
|
writeJson,
|
|
} from "../../../../extensions/qa-lab/api.js";
|
|
type BotCommand = { command: string; description: string };
|
|
type Body = { commands?: BotCommand[]; language_code?: string; scope?: { type?: string } };
|
|
const LOCALIZED_PLUGIN_ID = "telegram-menu-localization-e2e";
|
|
const LOCALIZED_PLUGIN_COMMANDS = Array.from({ length: 24 }, (_, index) => ({
|
|
name: `menu_locale_${String(index).padStart(3, "0")}`,
|
|
description: `Menu locale ${index}`,
|
|
descriptionLocalizations: {
|
|
fr: `Menu français ${index}`,
|
|
ko: "한".repeat(250),
|
|
},
|
|
}));
|
|
const TOKENS = ["A", "B", "C", "D", "E"].map(
|
|
(letter, index) => `${100001 + index}:${letter.repeat(35)}`,
|
|
) as [string, string, string, string, string];
|
|
const [COUNT_TOKEN, TEXT_TOKEN, RETRY_TOKEN, SKILL_TOKEN, LOCALIZED_TOKEN] = TOKENS;
|
|
function customCommands(prefix: string, count: number, description = "Configured command") {
|
|
return Array.from({ length: count }, (_, index) => ({
|
|
command: `${prefix}_${String(index).padStart(3, "0")}`,
|
|
description,
|
|
}));
|
|
}
|
|
const names = (commands: BotCommand[]) => commands.map(({ command }) => command);
|
|
async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
let text = "";
|
|
for await (const chunk of req) {
|
|
text += chunk;
|
|
}
|
|
return text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
}
|
|
const succeed = (res: ServerResponse, result: unknown = true) =>
|
|
writeJson(res, 200, { ok: true, result });
|
|
const LIMIT = { ok: false, error_code: 400, description: "Bad Request: BOT_COMMANDS_TOO_MUCH" };
|
|
async function writeLocalizedCommandPlugin(params: { repoRoot: string; workspace: string }) {
|
|
const pluginDir = path.join(params.workspace, "plugins", LOCALIZED_PLUGIN_ID);
|
|
await fs.mkdir(pluginDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(pluginDir, "package.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
name: `@openclaw/${LOCALIZED_PLUGIN_ID}`,
|
|
version: "0.0.0",
|
|
type: "module",
|
|
openclaw: { extensions: ["./index.js"] },
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
await fs.writeFile(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
id: LOCALIZED_PLUGIN_ID,
|
|
name: "Telegram Menu Localization E2E",
|
|
description: "Provides localized command-menu fixtures for Gateway proof.",
|
|
activation: { onStartup: true },
|
|
configSchema: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: {},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
const pluginEntryUrl = pathToFileURL(
|
|
path.join(params.repoRoot, "dist", "plugin-sdk", "plugin-entry.js"),
|
|
).href;
|
|
await fs.writeFile(
|
|
path.join(pluginDir, "index.js"),
|
|
[
|
|
`import { definePluginEntry } from ${JSON.stringify(pluginEntryUrl)};`,
|
|
`const commands = ${JSON.stringify(LOCALIZED_PLUGIN_COMMANDS, null, 2)};`,
|
|
"export default definePluginEntry({",
|
|
` id: ${JSON.stringify(LOCALIZED_PLUGIN_ID)},`,
|
|
" name: 'Telegram Menu Localization E2E',",
|
|
" register(api) {",
|
|
" for (const command of commands) {",
|
|
" api.registerCommand({",
|
|
" ...command,",
|
|
" channels: ['telegram'],",
|
|
" handler: async () => ({ text: 'ok' }),",
|
|
" });",
|
|
" }",
|
|
" },",
|
|
"});",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
return pluginDir;
|
|
}
|
|
async function settleCleanup(...cleanups: Array<() => Promise<void>>) {
|
|
const failures: unknown[] = [];
|
|
for (const cleanup of cleanups) {
|
|
await cleanup().catch((error: unknown) => failures.push(error));
|
|
}
|
|
if (failures.length > 0) {
|
|
throw new AggregateError(failures, "Telegram command-menu gateway cleanup failed");
|
|
}
|
|
}
|
|
test("registers pressure-prioritized Telegram menus through a real Gateway", async () => {
|
|
const calls: Array<{ token: string; body: Body }> = [];
|
|
const polled = new Set<string>();
|
|
let retryRejected = false;
|
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
const [, token = "", method = ""] =
|
|
new URL(req.url ?? "/", "http://127.0.0.1").pathname.match(/^\/bot([^/]+)\/([^/]+)$/) ?? [];
|
|
if (method === "getMe") {
|
|
succeed(res, { id: Number(token.split(":")[0]), is_bot: true, first_name: "QA" });
|
|
return;
|
|
}
|
|
const body = await readJson(req);
|
|
if (method === "getUpdates") {
|
|
if (!polled.has(token)) {
|
|
polled.add(token);
|
|
succeed(res, []);
|
|
}
|
|
return;
|
|
}
|
|
if (method === "setMyCommands") {
|
|
calls.push({ token, body: body as Body });
|
|
if (token === RETRY_TOKEN && !body.scope && !retryRejected) {
|
|
retryRejected = true;
|
|
writeJson(res, 400, LIMIT);
|
|
return;
|
|
}
|
|
succeed(res);
|
|
return;
|
|
}
|
|
if (method === "deleteWebhook" || method === "deleteMyCommands") {
|
|
succeed(res);
|
|
return;
|
|
}
|
|
writeJson(res, 404, { ok: false, error_code: 404, description: "Not Found" });
|
|
};
|
|
await withServer(
|
|
(req, res) => {
|
|
void handleRequest(req, res);
|
|
},
|
|
async (apiRoot) =>
|
|
await withTempDir("openclaw-telegram-menu-", async (workspace) => {
|
|
let mock: Awaited<ReturnType<typeof startQaMockOpenAiServer>> | undefined;
|
|
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
|
try {
|
|
const repoRoot = path.resolve(import.meta.dirname, "../../../..");
|
|
const localizedPluginDir = await writeLocalizedCommandPlugin({ repoRoot, workspace });
|
|
const skillDir = path.join(workspace, "skills", "menu-proof");
|
|
await fs.mkdir(skillDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(skillDir, "SKILL.md"),
|
|
"---\nname: menu-proof\ndescription: Proof\nuser-invocable: true\n---\n# Proof\n",
|
|
);
|
|
const countCustom = customCommands("count", 100);
|
|
const textCustom = customCommands("text", 24, "x".repeat(256));
|
|
const retryCustom = customCommands("retry", 2);
|
|
const skillCustom = customCommands("skillcustom", 100);
|
|
const localizedCustom = customCommands("localized", 1);
|
|
const baseAccount = {
|
|
enabled: true,
|
|
dmPolicy: "disabled" as const,
|
|
groupPolicy: "disabled" as const,
|
|
commands: { native: true, nativeSkills: false },
|
|
};
|
|
const account = (token: string, commands: BotCommand[], nativeSkills = false) => ({
|
|
enabled: true,
|
|
botToken: token,
|
|
apiRoot,
|
|
commands: { native: true, nativeSkills },
|
|
customCommands: commands,
|
|
});
|
|
mock = await startQaMockOpenAiServer();
|
|
gateway = await startQaGatewayChild({
|
|
repoRoot,
|
|
useRepoCli: true,
|
|
providerBaseUrl: `${mock.baseUrl}/v1`,
|
|
transportBaseUrl: apiRoot,
|
|
transport: {
|
|
requiredPluginIds: ["telegram"],
|
|
createGatewayConfig: () => ({
|
|
channels: {
|
|
telegram: {
|
|
...baseAccount,
|
|
defaultAccount: "count",
|
|
accounts: {
|
|
count: account(COUNT_TOKEN, countCustom),
|
|
text: account(TEXT_TOKEN, textCustom),
|
|
retry: account(RETRY_TOKEN, retryCustom),
|
|
skill: account(SKILL_TOKEN, skillCustom, true),
|
|
localized: account(LOCALIZED_TOKEN, localizedCustom),
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
controlUiEnabled: false,
|
|
runtimeEnvPatch: {
|
|
OPENCLAW_SKIP_CHANNELS: undefined,
|
|
OPENCLAW_SKIP_PROVIDERS: undefined,
|
|
OPENCLAW_TEST_MINIMAL_GATEWAY: undefined,
|
|
TELEGRAM_BOT_TOKEN: undefined,
|
|
},
|
|
mutateConfig: (cfg) => {
|
|
cfg.agents!.defaults!.workspace = workspace;
|
|
cfg.bindings = [
|
|
...(cfg.bindings ?? []),
|
|
{ agentId: "qa", match: { channel: "telegram", accountId: "skill" } },
|
|
];
|
|
cfg.plugins = {
|
|
...cfg.plugins,
|
|
allow: [...new Set([...(cfg.plugins?.allow ?? []), LOCALIZED_PLUGIN_ID])],
|
|
entries: {
|
|
...cfg.plugins?.entries,
|
|
[LOCALIZED_PLUGIN_ID]: { enabled: true },
|
|
},
|
|
load: {
|
|
...cfg.plugins?.load,
|
|
paths: [...new Set([...(cfg.plugins?.load?.paths ?? []), localizedPluginDir])],
|
|
},
|
|
};
|
|
return cfg;
|
|
},
|
|
});
|
|
const scoped = (token: string, group = false, languageCode?: string) =>
|
|
calls.filter((call) => {
|
|
const isGroup = call.body.scope?.type === "all_group_chats";
|
|
return (
|
|
call.token === token &&
|
|
isGroup === group &&
|
|
call.body.language_code === languageCode
|
|
);
|
|
});
|
|
const payload = (token: string, group = false, index = -1, languageCode?: string) =>
|
|
scoped(token, group, languageCode).at(index)?.body.commands ?? [];
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
TOKENS.every(
|
|
(token) => scoped(token).length >= 1 && scoped(token, true).length >= 1,
|
|
) &&
|
|
scoped(RETRY_TOKEN).length >= 2 &&
|
|
["fr", "ko"].every(
|
|
(languageCode) =>
|
|
scoped(LOCALIZED_TOKEN, false, languageCode).length >= 1 &&
|
|
scoped(LOCALIZED_TOKEN, true, languageCode).length >= 1,
|
|
),
|
|
{ interval: 50, timeout: 30_000 },
|
|
)
|
|
.toBe(true);
|
|
expect(payload(COUNT_TOKEN)).toEqual(countCustom);
|
|
const textPayload = payload(TEXT_TOKEN);
|
|
expect(textPayload.length).toBeLessThan(100);
|
|
expect(names(textPayload).slice(0, textCustom.length)).toEqual(names(textCustom));
|
|
expect(textPayload[0]?.description.length).toBeLessThan(256);
|
|
const retryPayload = payload(RETRY_TOKEN, false, 1);
|
|
expect(retryPayload).toHaveLength(
|
|
Math.floor(payload(RETRY_TOKEN, false, 0).length * 0.8),
|
|
);
|
|
expect(retryPayload.slice(0, retryCustom.length)).toEqual(retryCustom);
|
|
expect(retryPayload.some(({ command }) => command === "btw")).toBe(true);
|
|
expect(retryPayload.some(({ command }) => command === "side")).toBe(false);
|
|
expect(names(payload(SKILL_TOKEN))).toEqual([
|
|
"skill",
|
|
...names(skillCustom.slice(0, 99)),
|
|
]);
|
|
const localizedDefault = payload(LOCALIZED_TOKEN);
|
|
const localizedFrench = payload(LOCALIZED_TOKEN, false, -1, "fr");
|
|
const localizedKorean = payload(LOCALIZED_TOKEN, false, -1, "ko");
|
|
const localizedKoreanNames = names(localizedKorean);
|
|
expect(names(localizedFrench)).toEqual(names(localizedDefault));
|
|
expect(localizedKoreanNames[0]).toBe(localizedCustom[0]?.command);
|
|
expect(localizedKoreanNames.indexOf("help")).toBeGreaterThan(0);
|
|
expect(localizedKoreanNames.indexOf(LOCALIZED_PLUGIN_COMMANDS[0]!.name)).toBeGreaterThan(
|
|
localizedKoreanNames.indexOf("help"),
|
|
);
|
|
expect(localizedKoreanNames.indexOf("side")).toBeGreaterThan(
|
|
localizedKoreanNames.indexOf(LOCALIZED_PLUGIN_COMMANDS.at(-1)!.name),
|
|
);
|
|
expect(
|
|
localizedKorean.find(({ command }) => command === "menu_locale_000")?.description
|
|
.length,
|
|
).toBeLessThan(250);
|
|
for (const languageCode of ["fr", "ko"]) {
|
|
expect(payload(LOCALIZED_TOKEN, true, -1, languageCode)).toEqual(
|
|
payload(LOCALIZED_TOKEN, false, -1, languageCode),
|
|
);
|
|
}
|
|
for (const token of TOKENS) {
|
|
expect(payload(token, true)).toEqual(payload(token));
|
|
}
|
|
expect(
|
|
calls.every(
|
|
(call) =>
|
|
call.body.language_code === undefined ||
|
|
call.body.language_code === "fr" ||
|
|
call.body.language_code === "ko",
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
calls
|
|
.flatMap((call) => call.body.commands ?? [])
|
|
.every(
|
|
(command) => Object.keys(command).toSorted().join(",") === "command,description",
|
|
),
|
|
).toBe(true);
|
|
} finally {
|
|
await settleCleanup(
|
|
async () => await gateway?.stop(),
|
|
async () => await mock?.stop(),
|
|
);
|
|
}
|
|
}),
|
|
);
|
|
}, 120_000);
|