mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:20:43 +00:00
feat: add Mantis Discord smoke runner (#76696)
* docs: add Mantis QA system design * feat: add Mantis Discord smoke runner * fix: harden Mantis Discord smoke * fix: redact Mantis Discord artifacts * fix: satisfy Mantis redaction lint * fix: redact Mantis mismatch failures * test: avoid promise assertions in Mantis tests
This commit is contained in:
committed by
GitHub
parent
6aa4fb7a69
commit
0bf06e953f
@@ -48,6 +48,7 @@ const {
|
||||
runQaProviderServerCommand,
|
||||
runQaSuiteCommand,
|
||||
runQaTelegramCommand,
|
||||
runMantisDiscordSmokeCommand,
|
||||
} = vi.hoisted(() => ({
|
||||
runQaCredentialsAddCommand: vi.fn(),
|
||||
runQaCredentialsListCommand: vi.fn(),
|
||||
@@ -56,6 +57,7 @@ const {
|
||||
runQaProviderServerCommand: vi.fn(),
|
||||
runQaSuiteCommand: vi.fn(),
|
||||
runQaTelegramCommand: vi.fn(),
|
||||
runMantisDiscordSmokeCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
const { listQaRunnerCliContributions } = vi.hoisted(() => ({
|
||||
@@ -72,6 +74,10 @@ vi.mock("./live-transports/telegram/cli.runtime.js", () => ({
|
||||
runQaTelegramCommand,
|
||||
}));
|
||||
|
||||
vi.mock("./mantis/cli.runtime.js", () => ({
|
||||
runMantisDiscordSmokeCommand,
|
||||
}));
|
||||
|
||||
vi.mock("./cli.runtime.js", () => ({
|
||||
runQaCredentialsAddCommand,
|
||||
runQaCredentialsListCommand,
|
||||
@@ -95,6 +101,7 @@ describe("qa cli registration", () => {
|
||||
runQaProviderServerCommand.mockReset();
|
||||
runQaSuiteCommand.mockReset();
|
||||
runQaTelegramCommand.mockReset();
|
||||
runMantisDiscordSmokeCommand.mockReset();
|
||||
listQaRunnerCliContributions
|
||||
.mockReset()
|
||||
.mockReturnValue([createAvailableQaRunnerContribution()]);
|
||||
@@ -109,10 +116,51 @@ describe("qa cli registration", () => {
|
||||
const qa = program.commands.find((command) => command.name() === "qa");
|
||||
expect(qa).toBeDefined();
|
||||
expect(qa?.commands.map((command) => command.name())).toEqual(
|
||||
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials", "coverage"]),
|
||||
expect.arrayContaining([
|
||||
TEST_QA_RUNNER.commandName,
|
||||
"telegram",
|
||||
"mantis",
|
||||
"credentials",
|
||||
"coverage",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes mantis discord-smoke flags into the mantis runtime command", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
"openclaw",
|
||||
"qa",
|
||||
"mantis",
|
||||
"discord-smoke",
|
||||
"--repo-root",
|
||||
"/tmp/openclaw-repo",
|
||||
"--output-dir",
|
||||
".artifacts/qa-e2e/mantis/discord-smoke",
|
||||
"--guild-id",
|
||||
"123456789012345678",
|
||||
"--channel-id",
|
||||
"223456789012345678",
|
||||
"--token-file",
|
||||
"/tmp/mantis-token",
|
||||
"--message",
|
||||
"hello from mantis",
|
||||
"--skip-post",
|
||||
]);
|
||||
|
||||
expect(runMantisDiscordSmokeCommand).toHaveBeenCalledWith({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
outputDir: ".artifacts/qa-e2e/mantis/discord-smoke",
|
||||
guildId: "123456789012345678",
|
||||
channelId: "223456789012345678",
|
||||
tokenEnv: undefined,
|
||||
tokenFile: "/tmp/mantis-token",
|
||||
tokenFileEnv: undefined,
|
||||
message: "hello from mantis",
|
||||
skipPost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes coverage report flags into the qa runtime command", async () => {
|
||||
await program.parseAsync([
|
||||
"node",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { collectString } from "./cli-options.js";
|
||||
import { listLiveTransportQaCliRegistrations } from "./live-transports/cli.js";
|
||||
import { registerMantisCli } from "./mantis/cli.js";
|
||||
import {
|
||||
DEFAULT_QA_LIVE_PROVIDER_MODE,
|
||||
formatQaProviderModeHelp,
|
||||
@@ -225,6 +226,7 @@ export function registerQaLabCli(program: Command) {
|
||||
const qa = program
|
||||
.command("qa")
|
||||
.description("Run private QA automation flows and launch the QA debugger");
|
||||
registerMantisCli(qa);
|
||||
|
||||
qa.command("run")
|
||||
.description("Run the bundled QA self-check and write a Markdown report")
|
||||
|
||||
10
extensions/qa-lab/src/mantis/cli.runtime.ts
Normal file
10
extensions/qa-lab/src/mantis/cli.runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { runMantisDiscordSmoke, type MantisDiscordSmokeOptions } from "./discord-smoke.runtime.js";
|
||||
|
||||
export async function runMantisDiscordSmokeCommand(opts: MantisDiscordSmokeOptions) {
|
||||
const result = await runMantisDiscordSmoke(opts);
|
||||
process.stdout.write(`Mantis Discord smoke report: ${result.reportPath}\n`);
|
||||
process.stdout.write(`Mantis Discord smoke summary: ${result.summaryPath}\n`);
|
||||
if (result.status === "fail") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
58
extensions/qa-lab/src/mantis/cli.ts
Normal file
58
extensions/qa-lab/src/mantis/cli.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Command } from "commander";
|
||||
import { createLazyCliRuntimeLoader } from "../live-transports/shared/live-transport-cli.js";
|
||||
import type { MantisDiscordSmokeOptions } from "./discord-smoke.runtime.js";
|
||||
|
||||
type MantisCliRuntime = typeof import("./cli.runtime.js");
|
||||
|
||||
const loadMantisCliRuntime = createLazyCliRuntimeLoader<MantisCliRuntime>(
|
||||
() => import("./cli.runtime.js"),
|
||||
);
|
||||
|
||||
async function runDiscordSmoke(opts: MantisDiscordSmokeOptions) {
|
||||
const runtime = await loadMantisCliRuntime();
|
||||
await runtime.runMantisDiscordSmokeCommand(opts);
|
||||
}
|
||||
|
||||
type MantisDiscordSmokeCommanderOptions = {
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
message?: string;
|
||||
outputDir?: string;
|
||||
repoRoot?: string;
|
||||
skipPost?: boolean;
|
||||
tokenFile?: string;
|
||||
tokenFileEnv?: string;
|
||||
tokenEnv?: string;
|
||||
};
|
||||
|
||||
export function registerMantisCli(qa: Command) {
|
||||
const mantis = qa
|
||||
.command("mantis")
|
||||
.description("Run Mantis before/after and live-smoke verification flows");
|
||||
|
||||
mantis
|
||||
.command("discord-smoke")
|
||||
.description("Verify the Mantis Discord bot can see the guild/channel, post, and react")
|
||||
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
|
||||
.option("--output-dir <path>", "Mantis Discord smoke artifact directory")
|
||||
.option("--guild-id <id>", "Override OPENCLAW_QA_DISCORD_GUILD_ID")
|
||||
.option("--channel-id <id>", "Override OPENCLAW_QA_DISCORD_CHANNEL_ID")
|
||||
.option("--token-env <name>", "Env var containing the Mantis Discord bot token")
|
||||
.option("--token-file <path>", "File containing the Mantis Discord bot token")
|
||||
.option("--token-file-env <name>", "Env var containing the Mantis Discord bot token file path")
|
||||
.option("--message <text>", "Smoke message to post")
|
||||
.option("--skip-post", "Only check Discord API visibility; do not post or react", false)
|
||||
.action(async (opts: MantisDiscordSmokeCommanderOptions) => {
|
||||
await runDiscordSmoke({
|
||||
channelId: opts.channelId,
|
||||
guildId: opts.guildId,
|
||||
message: opts.message,
|
||||
outputDir: opts.outputDir,
|
||||
repoRoot: opts.repoRoot,
|
||||
skipPost: opts.skipPost,
|
||||
tokenFile: opts.tokenFile,
|
||||
tokenFileEnv: opts.tokenFileEnv,
|
||||
tokenEnv: opts.tokenEnv,
|
||||
});
|
||||
});
|
||||
}
|
||||
310
extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts
Normal file
310
extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuard } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
import { runMantisDiscordSmoke } from "./discord-smoke.runtime.js";
|
||||
|
||||
function jsonResponse(payload: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyResponse(status = 204) {
|
||||
return new Response(null, { status });
|
||||
}
|
||||
|
||||
describe("mantis discord smoke runtime", () => {
|
||||
let repoRoot: string;
|
||||
let tokenFile: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mantis-discord-smoke-"));
|
||||
tokenFile = path.join(repoRoot, "mantis-token");
|
||||
await fs.writeFile(tokenFile, "test-token", "utf8");
|
||||
fetchWithSsrFGuard.mockReset();
|
||||
const reactionPaths = new Set([
|
||||
"/api/v10/channels/1456744319972282449/messages/1500000000000000001/reactions/%F0%9F%91%80/@me",
|
||||
"/api/v10/channels/1456744319972282449/messages/1500000000000000001/reactions/👀/@me",
|
||||
]);
|
||||
fetchWithSsrFGuard.mockImplementation(
|
||||
async ({ url, init }: { url: string; init?: RequestInit }) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
const method = init?.method ?? "GET";
|
||||
if (pathname === "/api/v10/users/@me") {
|
||||
return {
|
||||
response: jsonResponse({ id: "1489650053747314748", username: "Mantis" }),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/guilds/1456350064065904867") {
|
||||
return {
|
||||
response: jsonResponse({ id: "1456350064065904867", name: "Friends" }),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/guilds/1456350064065904867/channels") {
|
||||
return { response: jsonResponse([{ id: "1456744319972282449" }]), release: vi.fn() };
|
||||
}
|
||||
if (pathname === "/api/v10/channels/1456744319972282449" && method === "GET") {
|
||||
return {
|
||||
response: jsonResponse({
|
||||
guild_id: "1456350064065904867",
|
||||
id: "1456744319972282449",
|
||||
name: "maintainers",
|
||||
type: 0,
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/channels/1456744319972282449/messages" && method === "POST") {
|
||||
return {
|
||||
response: jsonResponse({
|
||||
id: "1500000000000000001",
|
||||
channel_id: "1456744319972282449",
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (reactionPaths.has(pathname) && method === "PUT") {
|
||||
return { response: emptyResponse(), release: vi.fn() };
|
||||
}
|
||||
return {
|
||||
response: jsonResponse({ message: `unexpected ${method} ${pathname}` }, 404),
|
||||
release: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes pass artifacts without leaking the bot token", async () => {
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/test",
|
||||
tokenFile,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
now: () => new Date("2026-05-03T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {
|
||||
status: string;
|
||||
tokenSource: string;
|
||||
message: { id: string; posted: boolean; reactionAdded: boolean };
|
||||
};
|
||||
expect(summary).toMatchObject({
|
||||
status: "pass",
|
||||
tokenSource: "file",
|
||||
message: {
|
||||
id: "1500000000000000001",
|
||||
posted: true,
|
||||
reactionAdded: true,
|
||||
},
|
||||
});
|
||||
expect(await fs.readFile(result.summaryPath, "utf8")).not.toContain("test-token");
|
||||
expect(await fs.readFile(result.reportPath, "utf8")).not.toContain("test-token");
|
||||
});
|
||||
|
||||
it("supports visibility-only smoke runs", async () => {
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/visibility",
|
||||
tokenFile,
|
||||
skipPost: true,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
expect(fetchWithSsrFGuard).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
init: expect.objectContaining({ method: "POST" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts Discord target metadata in public artifacts", async () => {
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/redacted",
|
||||
tokenFile,
|
||||
redactPublicMetadata: true,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("pass");
|
||||
const summaryText = await fs.readFile(result.summaryPath, "utf8");
|
||||
const reportText = await fs.readFile(result.reportPath, "utf8");
|
||||
expect(reportText).toContain("# Mantis Discord Smoke");
|
||||
expect(reportText).toContain("- Bot: <redacted> (<redacted>)");
|
||||
expect(reportText).toContain("- Guild: <redacted> (<redacted>)");
|
||||
expect(reportText).toContain("- Channel: #<redacted> (<redacted>)");
|
||||
for (const text of [summaryText, reportText]) {
|
||||
expect(text).toContain("<redacted>");
|
||||
expect(text).not.toContain("1489650053747314748");
|
||||
expect(text).not.toContain("1456350064065904867");
|
||||
expect(text).not.toContain("Friends");
|
||||
expect(text).not.toContain("1456744319972282449");
|
||||
expect(text).not.toContain("maintainers");
|
||||
expect(text).not.toContain("1500000000000000001");
|
||||
}
|
||||
expect(summaryText).not.toContain("Mantis");
|
||||
expect(JSON.parse(summaryText)).toMatchObject({
|
||||
metadataRedaction: true,
|
||||
bot: { id: "<redacted>", username: "<redacted>" },
|
||||
guild: { id: "<redacted>", name: "<redacted>" },
|
||||
channel: { id: "<redacted>", name: "<redacted>" },
|
||||
message: { id: "<redacted>" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails before calling Discord when required ids are missing", async () => {
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/missing",
|
||||
tokenFile,
|
||||
env: {},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
const errorText = await fs.readFile(path.join(result.outputDir, "error.txt"), "utf8");
|
||||
expect(errorText).toContain("Missing OPENCLAW_QA_DISCORD_GUILD_ID");
|
||||
});
|
||||
|
||||
it("fails when the channel is not in the configured guild", async () => {
|
||||
fetchWithSsrFGuard.mockImplementation(
|
||||
async ({ url, init }: { url: string; init?: RequestInit }) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
const method = init?.method ?? "GET";
|
||||
if (pathname === "/api/v10/users/@me") {
|
||||
return {
|
||||
response: jsonResponse({ id: "1489650053747314748", username: "Mantis" }),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/guilds/1456350064065904867") {
|
||||
return {
|
||||
response: jsonResponse({ id: "1456350064065904867", name: "Friends" }),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/guilds/1456350064065904867/channels") {
|
||||
return { response: jsonResponse([{ id: "1999999999999999999" }]), release: vi.fn() };
|
||||
}
|
||||
if (pathname === "/api/v10/channels/1456744319972282449" && method === "GET") {
|
||||
return {
|
||||
response: jsonResponse({
|
||||
guild_id: "1999999999999999999",
|
||||
id: "1456744319972282449",
|
||||
name: "wrong-guild-channel",
|
||||
type: 0,
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: jsonResponse({ message: `unexpected ${method} ${pathname}` }, 404),
|
||||
release: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/wrong-guild",
|
||||
tokenFile,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
const errorText = await fs.readFile(path.join(result.outputDir, "error.txt"), "utf8");
|
||||
expect(errorText).toContain("is not in guild");
|
||||
expect(fetchWithSsrFGuard).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
init: expect.objectContaining({ method: "POST" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts response guild ids in mismatch failure artifacts", async () => {
|
||||
fetchWithSsrFGuard.mockImplementation(
|
||||
async ({ url, init }: { url: string; init?: RequestInit }) => {
|
||||
const pathname = new URL(url).pathname;
|
||||
const method = init?.method ?? "GET";
|
||||
if (pathname === "/api/v10/users/@me") {
|
||||
return {
|
||||
response: jsonResponse({ id: "1489650053747314748", username: "Mantis" }),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/guilds/1456350064065904867") {
|
||||
return {
|
||||
response: jsonResponse({ id: "1456350064065904867", name: "Friends" }),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
if (pathname === "/api/v10/guilds/1456350064065904867/channels") {
|
||||
return { response: jsonResponse([{ id: "1456744319972282449" }]), release: vi.fn() };
|
||||
}
|
||||
if (pathname === "/api/v10/channels/1456744319972282449" && method === "GET") {
|
||||
return {
|
||||
response: jsonResponse({
|
||||
guild_id: "1999999999999999999",
|
||||
id: "1456744319972282449",
|
||||
name: "wrong-guild-channel",
|
||||
type: 0,
|
||||
}),
|
||||
release: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: jsonResponse({ message: `unexpected ${method} ${pathname}` }, 404),
|
||||
release: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runMantisDiscordSmoke({
|
||||
repoRoot,
|
||||
outputDir: ".artifacts/qa-e2e/mantis/wrong-guild-redacted",
|
||||
tokenFile,
|
||||
redactPublicMetadata: true,
|
||||
env: {
|
||||
OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867",
|
||||
OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("fail");
|
||||
const errorText = await fs.readFile(path.join(result.outputDir, "error.txt"), "utf8");
|
||||
expect(errorText).toContain("<redacted>");
|
||||
expect(errorText).not.toContain("1999999999999999999");
|
||||
expect(errorText).not.toContain("1456350064065904867");
|
||||
expect(errorText).not.toContain("1456744319972282449");
|
||||
});
|
||||
});
|
||||
491
extensions/qa-lab/src/mantis/discord-smoke.runtime.ts
Normal file
491
extensions/qa-lab/src/mantis/discord-smoke.runtime.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
|
||||
|
||||
export type MantisDiscordSmokeOptions = {
|
||||
channelId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
guildId?: string;
|
||||
message?: string;
|
||||
now?: () => Date;
|
||||
outputDir?: string;
|
||||
redactPublicMetadata?: boolean;
|
||||
repoRoot?: string;
|
||||
skipPost?: boolean;
|
||||
token?: string;
|
||||
tokenEnv?: string;
|
||||
tokenFile?: string;
|
||||
tokenFileEnv?: string;
|
||||
};
|
||||
|
||||
export type MantisDiscordSmokeResult = {
|
||||
outputDir: string;
|
||||
reportPath: string;
|
||||
summaryPath: string;
|
||||
status: "pass" | "fail";
|
||||
};
|
||||
|
||||
type DiscordUser = {
|
||||
id: string;
|
||||
username?: string;
|
||||
};
|
||||
|
||||
type DiscordGuild = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type DiscordChannel = {
|
||||
guild_id?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: number;
|
||||
};
|
||||
|
||||
type DiscordMessage = {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
};
|
||||
|
||||
type DiscordApiCall = {
|
||||
label: string;
|
||||
method: string;
|
||||
ok: boolean;
|
||||
path: string;
|
||||
status: number;
|
||||
};
|
||||
|
||||
type MantisDiscordSmokeSummary = {
|
||||
apiCalls: DiscordApiCall[];
|
||||
artifacts: {
|
||||
reportPath: string;
|
||||
summaryPath: string;
|
||||
};
|
||||
bot?: {
|
||||
id: string;
|
||||
username?: string;
|
||||
};
|
||||
channel?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: number;
|
||||
};
|
||||
finishedAt: string;
|
||||
guild?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
message?: {
|
||||
id: string;
|
||||
posted: boolean;
|
||||
reactionAdded: boolean;
|
||||
};
|
||||
metadataRedaction: boolean;
|
||||
outputDir: string;
|
||||
reportPath: string;
|
||||
startedAt: string;
|
||||
status: "pass" | "fail";
|
||||
summaryPath: string;
|
||||
tokenSource: "env" | "file" | "option";
|
||||
};
|
||||
|
||||
const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
|
||||
const DEFAULT_MANTIS_TOKEN_ENV = "OPENCLAW_QA_DISCORD_MANTIS_BOT_TOKEN";
|
||||
const DEFAULT_MANTIS_TOKEN_FILE_ENV = "OPENCLAW_QA_DISCORD_MANTIS_BOT_TOKEN_FILE";
|
||||
const DEFAULT_GUILD_ID_ENV = "OPENCLAW_QA_DISCORD_GUILD_ID";
|
||||
const DEFAULT_CHANNEL_ID_ENV = "OPENCLAW_QA_DISCORD_CHANNEL_ID";
|
||||
const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
|
||||
|
||||
function trimToValue(value: string | undefined) {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isTruthyOptIn(value: string | undefined) {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function assertDiscordSnowflake(value: string, label: string) {
|
||||
if (!/^\d{17,20}$/u.test(value)) {
|
||||
throw new Error(`${label} must be a Discord snowflake.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTokenFile(filePath: string) {
|
||||
const token = trimToValue(await fs.readFile(filePath, "utf8"));
|
||||
if (!token) {
|
||||
throw new Error(`Mantis Discord token file is empty: ${filePath}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
async function resolveMantisDiscordToken(opts: MantisDiscordSmokeOptions) {
|
||||
const env = opts.env ?? process.env;
|
||||
const tokenEnv = trimToValue(opts.tokenEnv) ?? DEFAULT_MANTIS_TOKEN_ENV;
|
||||
const tokenFileEnv = trimToValue(opts.tokenFileEnv) ?? DEFAULT_MANTIS_TOKEN_FILE_ENV;
|
||||
const optionToken = trimToValue(opts.token);
|
||||
if (optionToken) {
|
||||
return { source: "option" as const, token: optionToken };
|
||||
}
|
||||
const envToken = trimToValue(env[tokenEnv]);
|
||||
if (envToken) {
|
||||
return { source: "env" as const, token: envToken };
|
||||
}
|
||||
const tokenFile = trimToValue(opts.tokenFile) ?? trimToValue(env[tokenFileEnv]);
|
||||
if (tokenFile) {
|
||||
return { source: "file" as const, token: await readTokenFile(tokenFile) };
|
||||
}
|
||||
throw new Error(
|
||||
`Missing Mantis Discord bot token. Set ${tokenEnv}, ${tokenFileEnv}, or pass --token-file.`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRequiredSnowflake(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
envKey: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
}) {
|
||||
const resolved = trimToValue(params.value) ?? trimToValue(params.env[params.envKey]);
|
||||
if (!resolved) {
|
||||
throw new Error(`Missing ${params.envKey}.`);
|
||||
}
|
||||
assertDiscordSnowflake(resolved, params.label);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function assertMantisDiscordChannelInGuild(params: {
|
||||
channel: DiscordChannel;
|
||||
guildChannels: readonly DiscordChannel[];
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
}) {
|
||||
if (!params.guildChannels.some((channel) => channel.id === params.channelId)) {
|
||||
throw new Error(
|
||||
`OPENCLAW_QA_DISCORD_CHANNEL_ID ${params.channelId} is not in guild ${params.guildId}.`,
|
||||
);
|
||||
}
|
||||
if (params.channel.guild_id && params.channel.guild_id !== params.guildId) {
|
||||
throw new Error(
|
||||
`OPENCLAW_QA_DISCORD_CHANNEL_ID ${params.channelId} belongs to guild ${params.channel.guild_id}, not ${params.guildId}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultMantisDiscordSmokeOutputDir(repoRoot: string, startedAt: Date) {
|
||||
const stamp = startedAt.toISOString().replace(/[:.]/gu, "-");
|
||||
return path.join(repoRoot, ".artifacts", "qa-e2e", "mantis", `discord-smoke-${stamp}`);
|
||||
}
|
||||
|
||||
async function callDiscordApi<T>(params: {
|
||||
apiCalls: DiscordApiCall[];
|
||||
body?: unknown;
|
||||
label: string;
|
||||
method?: string;
|
||||
path: string;
|
||||
token: string;
|
||||
}) {
|
||||
const method = params.method ?? "GET";
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bot ${params.token}`);
|
||||
let body: string | undefined;
|
||||
if (params.body !== undefined) {
|
||||
headers.set("content-type", "application/json");
|
||||
body = JSON.stringify(params.body);
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${DISCORD_API_BASE_URL}${params.path}`,
|
||||
init: {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
policy: { hostnameAllowlist: ["discord.com"] },
|
||||
auditContext: "qa-lab-mantis-discord-smoke",
|
||||
});
|
||||
try {
|
||||
const text = await response.text();
|
||||
const payload = text.trim() ? (JSON.parse(text) as unknown) : undefined;
|
||||
params.apiCalls.push({
|
||||
label: params.label,
|
||||
method,
|
||||
ok: response.ok,
|
||||
path: params.path,
|
||||
status: response.status,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
typeof (payload as { message?: unknown }).message === "string"
|
||||
? (payload as { message: string }).message
|
||||
: text.trim();
|
||||
throw new Error(
|
||||
message || `Discord API ${params.path} failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
return payload as T;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function renderMantisDiscordSmokeReport(summary: MantisDiscordSmokeSummary) {
|
||||
const lines = [
|
||||
"# Mantis Discord Smoke",
|
||||
"",
|
||||
`Status: ${summary.status}`,
|
||||
`Metadata redaction: ${summary.metadataRedaction ? "enabled" : "disabled"}`,
|
||||
`Started: ${summary.startedAt}`,
|
||||
`Finished: ${summary.finishedAt}`,
|
||||
`Output: ${summary.outputDir}`,
|
||||
"",
|
||||
"## Target",
|
||||
"",
|
||||
`- Bot: ${summary.bot?.username ?? "unknown"} (${summary.bot?.id ?? "unknown"})`,
|
||||
`- Guild: ${summary.guild?.name ?? "unknown"} (${summary.guild?.id ?? "unknown"})`,
|
||||
`- Channel: #${summary.channel?.name ?? "unknown"} (${summary.channel?.id ?? "unknown"})`,
|
||||
"",
|
||||
"## Message",
|
||||
"",
|
||||
summary.message?.posted
|
||||
? `- Posted message: ${summary.message.id}`
|
||||
: "- Posted message: skipped",
|
||||
summary.message?.reactionAdded ? "- Added reaction: yes" : "- Added reaction: no",
|
||||
"",
|
||||
"## Discord API Calls",
|
||||
"",
|
||||
"| Label | Method | Status |",
|
||||
"| --- | --- | --- |",
|
||||
...summary.apiCalls.map((call) => `| ${call.label} | ${call.method} | ${call.status} |`),
|
||||
"",
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function addSensitiveValue(values: Set<string>, value: string | undefined) {
|
||||
const resolved = trimToValue(value);
|
||||
if (resolved && resolved !== "<redacted>") {
|
||||
values.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function redactMantisDiscordMetadata(text: string, sensitiveValues: ReadonlySet<string>) {
|
||||
let redacted = text;
|
||||
const sortedValues = [...sensitiveValues].toSorted((a, b) => b.length - a.length);
|
||||
for (const value of sortedValues) {
|
||||
redacted = redacted.replaceAll(value, "<redacted>");
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
function buildPublishedMantisDiscordSmokeSummary(
|
||||
summary: MantisDiscordSmokeSummary,
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
): MantisDiscordSmokeSummary {
|
||||
if (!summary.metadataRedaction) {
|
||||
return summary;
|
||||
}
|
||||
return {
|
||||
...summary,
|
||||
apiCalls: summary.apiCalls.map((call) => ({
|
||||
...call,
|
||||
path: redactMantisDiscordMetadata(call.path, sensitiveValues),
|
||||
})),
|
||||
bot: summary.bot
|
||||
? {
|
||||
id: "<redacted>",
|
||||
username: summary.bot.username ? "<redacted>" : undefined,
|
||||
}
|
||||
: undefined,
|
||||
channel: summary.channel
|
||||
? {
|
||||
id: "<redacted>",
|
||||
name: summary.channel.name ? "<redacted>" : undefined,
|
||||
type: summary.channel.type,
|
||||
}
|
||||
: undefined,
|
||||
guild: summary.guild
|
||||
? {
|
||||
id: "<redacted>",
|
||||
name: summary.guild.name ? "<redacted>" : undefined,
|
||||
}
|
||||
: undefined,
|
||||
message: summary.message
|
||||
? {
|
||||
...summary.message,
|
||||
id: summary.message.id ? "<redacted>" : "",
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeMantisDiscordSmokeArtifacts(
|
||||
summary: MantisDiscordSmokeSummary,
|
||||
sensitiveValues: ReadonlySet<string>,
|
||||
) {
|
||||
await fs.mkdir(summary.outputDir, { recursive: true });
|
||||
const publishedSummary = buildPublishedMantisDiscordSmokeSummary(summary, sensitiveValues);
|
||||
const report = renderMantisDiscordSmokeReport(publishedSummary);
|
||||
const summaryJson = `${JSON.stringify(publishedSummary, null, 2)}\n`;
|
||||
await fs.writeFile(summary.reportPath, report, "utf8");
|
||||
await fs.writeFile(summary.summaryPath, summaryJson, "utf8");
|
||||
}
|
||||
|
||||
export async function runMantisDiscordSmoke(
|
||||
opts: MantisDiscordSmokeOptions = {},
|
||||
): Promise<MantisDiscordSmokeResult> {
|
||||
const env = opts.env ?? process.env;
|
||||
const startedAt = (opts.now ?? (() => new Date()))();
|
||||
const redactPublicMetadata =
|
||||
opts.redactPublicMetadata ?? isTruthyOptIn(env[QA_REDACT_PUBLIC_METADATA_ENV]);
|
||||
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
|
||||
const outputDir = await ensureRepoBoundDirectory(
|
||||
repoRoot,
|
||||
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
|
||||
defaultMantisDiscordSmokeOutputDir(repoRoot, startedAt),
|
||||
"Mantis Discord smoke output directory",
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const summaryPath = path.join(outputDir, "mantis-discord-smoke-summary.json");
|
||||
const reportPath = path.join(outputDir, "mantis-discord-smoke-report.md");
|
||||
const apiCalls: DiscordApiCall[] = [];
|
||||
const sensitiveValues = new Set<string>();
|
||||
const summary: MantisDiscordSmokeSummary = {
|
||||
apiCalls,
|
||||
artifacts: {
|
||||
reportPath,
|
||||
summaryPath,
|
||||
},
|
||||
finishedAt: startedAt.toISOString(),
|
||||
metadataRedaction: redactPublicMetadata,
|
||||
outputDir,
|
||||
reportPath,
|
||||
startedAt: startedAt.toISOString(),
|
||||
status: "fail",
|
||||
summaryPath,
|
||||
tokenSource: "env",
|
||||
};
|
||||
|
||||
try {
|
||||
const { source, token } = await resolveMantisDiscordToken(opts);
|
||||
summary.tokenSource = source;
|
||||
const guildId = resolveRequiredSnowflake({
|
||||
env,
|
||||
envKey: DEFAULT_GUILD_ID_ENV,
|
||||
label: DEFAULT_GUILD_ID_ENV,
|
||||
value: opts.guildId,
|
||||
});
|
||||
const channelId = resolveRequiredSnowflake({
|
||||
env,
|
||||
envKey: DEFAULT_CHANNEL_ID_ENV,
|
||||
label: DEFAULT_CHANNEL_ID_ENV,
|
||||
value: opts.channelId,
|
||||
});
|
||||
addSensitiveValue(sensitiveValues, guildId);
|
||||
addSensitiveValue(sensitiveValues, channelId);
|
||||
const bot = await callDiscordApi<DiscordUser>({
|
||||
apiCalls,
|
||||
label: "current-user",
|
||||
path: "/users/@me",
|
||||
token,
|
||||
});
|
||||
addSensitiveValue(sensitiveValues, bot.id);
|
||||
addSensitiveValue(sensitiveValues, bot.username);
|
||||
const guild = await callDiscordApi<DiscordGuild>({
|
||||
apiCalls,
|
||||
label: "guild",
|
||||
path: `/guilds/${guildId}`,
|
||||
token,
|
||||
});
|
||||
addSensitiveValue(sensitiveValues, guild.id);
|
||||
addSensitiveValue(sensitiveValues, guild.name);
|
||||
const guildChannels = await callDiscordApi<DiscordChannel[]>({
|
||||
apiCalls,
|
||||
label: "guild-channels",
|
||||
path: `/guilds/${guildId}/channels`,
|
||||
token,
|
||||
});
|
||||
for (const guildChannel of guildChannels) {
|
||||
addSensitiveValue(sensitiveValues, guildChannel.id);
|
||||
addSensitiveValue(sensitiveValues, guildChannel.guild_id);
|
||||
addSensitiveValue(sensitiveValues, guildChannel.name);
|
||||
}
|
||||
const channel = await callDiscordApi<DiscordChannel>({
|
||||
apiCalls,
|
||||
label: "channel",
|
||||
path: `/channels/${channelId}`,
|
||||
token,
|
||||
});
|
||||
addSensitiveValue(sensitiveValues, channel.id);
|
||||
addSensitiveValue(sensitiveValues, channel.guild_id);
|
||||
addSensitiveValue(sensitiveValues, channel.name);
|
||||
assertMantisDiscordChannelInGuild({
|
||||
channel,
|
||||
guildChannels,
|
||||
guildId,
|
||||
channelId,
|
||||
});
|
||||
summary.bot = { id: bot.id, username: bot.username };
|
||||
summary.guild = { id: guild.id, name: guild.name };
|
||||
summary.channel = { id: channel.id, name: channel.name, type: channel.type };
|
||||
|
||||
if (opts.skipPost) {
|
||||
summary.message = { id: "", posted: false, reactionAdded: false };
|
||||
} else {
|
||||
const message = await callDiscordApi<DiscordMessage>({
|
||||
apiCalls,
|
||||
body: {
|
||||
content:
|
||||
trimToValue(opts.message) ?? `Mantis Discord smoke: OK (${startedAt.toISOString()})`,
|
||||
},
|
||||
label: "post-message",
|
||||
method: "POST",
|
||||
path: `/channels/${channelId}/messages`,
|
||||
token,
|
||||
});
|
||||
addSensitiveValue(sensitiveValues, message.id);
|
||||
await callDiscordApi<void>({
|
||||
apiCalls,
|
||||
label: "add-reaction",
|
||||
method: "PUT",
|
||||
path: `/channels/${channelId}/messages/${message.id}/reactions/%F0%9F%91%80/@me`,
|
||||
token,
|
||||
});
|
||||
summary.message = { id: message.id, posted: true, reactionAdded: true };
|
||||
}
|
||||
|
||||
summary.status = "pass";
|
||||
} catch (error) {
|
||||
summary.status = "fail";
|
||||
summary.message = summary.message ?? {
|
||||
id: "",
|
||||
posted: false,
|
||||
reactionAdded: false,
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(outputDir, "error.txt"),
|
||||
`${
|
||||
redactPublicMetadata
|
||||
? redactMantisDiscordMetadata(formatErrorMessage(error), sensitiveValues)
|
||||
: formatErrorMessage(error)
|
||||
}${os.EOL}`,
|
||||
"utf8",
|
||||
);
|
||||
} finally {
|
||||
summary.finishedAt = new Date().toISOString();
|
||||
await writeMantisDiscordSmokeArtifacts(summary, sensitiveValues);
|
||||
}
|
||||
|
||||
return {
|
||||
outputDir,
|
||||
reportPath,
|
||||
summaryPath,
|
||||
status: summary.status,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user