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:
Peter Steinberger
2026-05-03 15:25:56 +01:00
committed by GitHub
parent 6aa4fb7a69
commit 0bf06e953f
11 changed files with 1509 additions and 1 deletions

View File

@@ -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",

View File

@@ -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")

View 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;
}
}

View 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,
});
});
}

View 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");
});
});

View 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,
};
}