test(e2e): stabilize suite
This commit is contained in:
@@ -56,6 +56,23 @@ describe("models-config", () => {
|
||||
const previousSynthetic = process.env.SYNTHETIC_API_KEY;
|
||||
const previousVenice = process.env.VENICE_API_KEY;
|
||||
const previousXiaomi = process.env.XIAOMI_API_KEY;
|
||||
const previousOllama = process.env.OLLAMA_API_KEY;
|
||||
const previousVllm = process.env.VLLM_API_KEY;
|
||||
const previousTogether = process.env.TOGETHER_API_KEY;
|
||||
const previousHuggingfaceHub = process.env.HUGGINGFACE_HUB_TOKEN;
|
||||
const previousHuggingfaceHf = process.env.HF_TOKEN;
|
||||
const previousQianfan = process.env.QIANFAN_API_KEY;
|
||||
const previousNvidia = process.env.NVIDIA_API_KEY;
|
||||
const previousAwsAccessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||
const previousAwsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
const previousAwsSessionToken = process.env.AWS_SESSION_TOKEN;
|
||||
const previousAwsProfile = process.env.AWS_PROFILE;
|
||||
const previousAwsRegion = process.env.AWS_REGION;
|
||||
const previousAwsDefaultRegion = process.env.AWS_DEFAULT_REGION;
|
||||
const previousAwsSharedCredentials = process.env.AWS_SHARED_CREDENTIALS_FILE;
|
||||
const previousAwsConfigFile = process.env.AWS_CONFIG_FILE;
|
||||
const previousAgentDir = process.env.OPENCLAW_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||
delete process.env.GH_TOKEN;
|
||||
delete process.env.GITHUB_TOKEN;
|
||||
@@ -65,9 +82,29 @@ describe("models-config", () => {
|
||||
delete process.env.SYNTHETIC_API_KEY;
|
||||
delete process.env.VENICE_API_KEY;
|
||||
delete process.env.XIAOMI_API_KEY;
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
delete process.env.VLLM_API_KEY;
|
||||
delete process.env.TOGETHER_API_KEY;
|
||||
delete process.env.HUGGINGFACE_HUB_TOKEN;
|
||||
delete process.env.HF_TOKEN;
|
||||
delete process.env.QIANFAN_API_KEY;
|
||||
delete process.env.NVIDIA_API_KEY;
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
delete process.env.AWS_SESSION_TOKEN;
|
||||
delete process.env.AWS_PROFILE;
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
||||
delete process.env.AWS_CONFIG_FILE;
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
try {
|
||||
const agentDir = path.join(home, "agent-empty");
|
||||
// Avoid merging in the user's real main auth store via OPENCLAW_AGENT_DIR.
|
||||
process.env.OPENCLAW_AGENT_DIR = agentDir;
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
const result = await ensureOpenClawModelsJson(
|
||||
{
|
||||
models: { providers: {} },
|
||||
@@ -123,6 +160,91 @@ describe("models-config", () => {
|
||||
} else {
|
||||
process.env.XIAOMI_API_KEY = previousXiaomi;
|
||||
}
|
||||
if (previousOllama === undefined) {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
} else {
|
||||
process.env.OLLAMA_API_KEY = previousOllama;
|
||||
}
|
||||
if (previousVllm === undefined) {
|
||||
delete process.env.VLLM_API_KEY;
|
||||
} else {
|
||||
process.env.VLLM_API_KEY = previousVllm;
|
||||
}
|
||||
if (previousTogether === undefined) {
|
||||
delete process.env.TOGETHER_API_KEY;
|
||||
} else {
|
||||
process.env.TOGETHER_API_KEY = previousTogether;
|
||||
}
|
||||
if (previousHuggingfaceHub === undefined) {
|
||||
delete process.env.HUGGINGFACE_HUB_TOKEN;
|
||||
} else {
|
||||
process.env.HUGGINGFACE_HUB_TOKEN = previousHuggingfaceHub;
|
||||
}
|
||||
if (previousHuggingfaceHf === undefined) {
|
||||
delete process.env.HF_TOKEN;
|
||||
} else {
|
||||
process.env.HF_TOKEN = previousHuggingfaceHf;
|
||||
}
|
||||
if (previousQianfan === undefined) {
|
||||
delete process.env.QIANFAN_API_KEY;
|
||||
} else {
|
||||
process.env.QIANFAN_API_KEY = previousQianfan;
|
||||
}
|
||||
if (previousNvidia === undefined) {
|
||||
delete process.env.NVIDIA_API_KEY;
|
||||
} else {
|
||||
process.env.NVIDIA_API_KEY = previousNvidia;
|
||||
}
|
||||
if (previousAwsAccessKeyId === undefined) {
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
} else {
|
||||
process.env.AWS_ACCESS_KEY_ID = previousAwsAccessKeyId;
|
||||
}
|
||||
if (previousAwsSecretAccessKey === undefined) {
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
} else {
|
||||
process.env.AWS_SECRET_ACCESS_KEY = previousAwsSecretAccessKey;
|
||||
}
|
||||
if (previousAwsSessionToken === undefined) {
|
||||
delete process.env.AWS_SESSION_TOKEN;
|
||||
} else {
|
||||
process.env.AWS_SESSION_TOKEN = previousAwsSessionToken;
|
||||
}
|
||||
if (previousAwsProfile === undefined) {
|
||||
delete process.env.AWS_PROFILE;
|
||||
} else {
|
||||
process.env.AWS_PROFILE = previousAwsProfile;
|
||||
}
|
||||
if (previousAwsRegion === undefined) {
|
||||
delete process.env.AWS_REGION;
|
||||
} else {
|
||||
process.env.AWS_REGION = previousAwsRegion;
|
||||
}
|
||||
if (previousAwsDefaultRegion === undefined) {
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
} else {
|
||||
process.env.AWS_DEFAULT_REGION = previousAwsDefaultRegion;
|
||||
}
|
||||
if (previousAwsSharedCredentials === undefined) {
|
||||
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
||||
} else {
|
||||
process.env.AWS_SHARED_CREDENTIALS_FILE = previousAwsSharedCredentials;
|
||||
}
|
||||
if (previousAwsConfigFile === undefined) {
|
||||
delete process.env.AWS_CONFIG_FILE;
|
||||
} else {
|
||||
process.env.AWS_CONFIG_FILE = previousAwsConfigFile;
|
||||
}
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.OPENCLAW_AGENT_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -158,7 +280,7 @@ describe("models-config", () => {
|
||||
}
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
|
||||
expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
|
||||
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
|
||||
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
|
||||
expect(ids).toContain("MiniMax-M2.1");
|
||||
|
||||
@@ -116,6 +116,7 @@ vi.mock("./logger.js", () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
isEnabled: vi.fn(() => false),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -586,7 +586,7 @@ describe("Agent-specific tool filtering", () => {
|
||||
const helperResult = await helperExecTool!.execute("call-helper", {
|
||||
command: "echo done",
|
||||
host: "sandbox",
|
||||
yieldMs: 10,
|
||||
yieldMs: 1000,
|
||||
});
|
||||
expect(helperResult?.details.status).toBe("completed");
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8");
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "skills", "prose", "SKILL.md"),
|
||||
`---\nname: prose\ndescription: test\n---\n`,
|
||||
@@ -99,6 +100,7 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(path.join(pluginRoot, "index.ts"), "export {};\n", "utf-8");
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, "skills", "prose", "SKILL.md"),
|
||||
`---\nname: prose\ndescription: test\n---\n`,
|
||||
|
||||
@@ -44,6 +44,18 @@ type AnnounceQueueState = {
|
||||
|
||||
const ANNOUNCE_QUEUES = new Map<string, AnnounceQueueState>();
|
||||
|
||||
export function resetAnnounceQueuesForTests() {
|
||||
// Test isolation: other suites may leave a draining queue behind in the worker.
|
||||
// Clearing the map alone isn't enough because drain loops capture `queue` by reference.
|
||||
for (const queue of ANNOUNCE_QUEUES.values()) {
|
||||
queue.items.length = 0;
|
||||
queue.summaryLines.length = 0;
|
||||
queue.droppedCount = 0;
|
||||
queue.lastEnqueuedAt = 0;
|
||||
}
|
||||
ANNOUNCE_QUEUES.clear();
|
||||
}
|
||||
|
||||
function getAnnounceQueue(
|
||||
key: string,
|
||||
settings: AnnounceQueueSettings,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loadConfig } from "../config/config.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
|
||||
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
|
||||
import {
|
||||
loadSubagentRegistryFromDisk,
|
||||
@@ -398,6 +399,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
|
||||
export function resetSubagentRegistryForTests(opts?: { persist?: boolean }) {
|
||||
subagentRuns.clear();
|
||||
resumedRuns.clear();
|
||||
resetAnnounceQueuesForTests();
|
||||
stopSweeper();
|
||||
restoreAttempted = false;
|
||||
if (listenerStop) {
|
||||
|
||||
@@ -299,7 +299,7 @@ describe("image tool MiniMax VLM routing", () => {
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
|
||||
expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(String((init?.headers as Record<string, string>)?.Authorization)).toBe(
|
||||
"Bearer minimax-test",
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("gateway-cli coverage", () => {
|
||||
expect(out).toContain("- Studio openclaw.internal.");
|
||||
expect(out).toContain(" tailnet: studio.tailnet.ts.net");
|
||||
expect(out).toContain(" host: studio.openclaw.internal");
|
||||
expect(out).toContain(" ws: ws://studio.tailnet.ts.net:18789");
|
||||
expect(out).toContain(" ws: ws://studio.openclaw.internal:18789");
|
||||
});
|
||||
|
||||
it("validates gateway discover timeout", async () => {
|
||||
|
||||
@@ -259,7 +259,7 @@ describe("applyMinimaxApiConfig", () => {
|
||||
expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key");
|
||||
expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([
|
||||
"old-model",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2.5",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -448,7 +448,7 @@ describe("gateway server agent", () => {
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.sessionKey).toBe("main");
|
||||
expect(call.sessionKey).toBe("agent:main:main");
|
||||
expectChannels(call, "webchat");
|
||||
expect(typeof call.message).toBe("string");
|
||||
expect(call.message).toContain("what is in the image?");
|
||||
|
||||
@@ -117,6 +117,18 @@ describe("gateway server auth/connect", () => {
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("ignores requested scopes when device identity is omitted", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, { device: null });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(false);
|
||||
expect(health.error?.message).toContain("missing scope");
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
test("does not grant admin when scopes are omitted", async () => {
|
||||
const ws = await openWs(port);
|
||||
const token =
|
||||
@@ -144,18 +156,6 @@ describe("gateway server auth/connect", () => {
|
||||
signedAtMs,
|
||||
token: token ?? null,
|
||||
});
|
||||
|
||||
test("ignores requested scopes when device identity is omitted", async () => {
|
||||
const ws = await openWs(port);
|
||||
const res = await connectReq(ws, { device: null });
|
||||
expect(res.ok).toBe(true);
|
||||
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(false);
|
||||
expect(health.error?.message).toContain("missing scope");
|
||||
|
||||
ws.close();
|
||||
});
|
||||
const device = {
|
||||
id: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
|
||||
@@ -403,8 +403,7 @@ describe("gateway server misc", () => {
|
||||
const plugins = updated.plugins as Record<string, unknown> | undefined;
|
||||
const entries = plugins?.entries as Record<string, unknown> | undefined;
|
||||
const discord = entries?.discord as Record<string, unknown> | undefined;
|
||||
// Auto-enable registers the plugin entry but keeps it disabled for explicit opt-in.
|
||||
expect(discord?.enabled).toBe(false);
|
||||
expect(discord?.enabled).toBe(true);
|
||||
expect((updated.channels as Record<string, unknown> | undefined)?.discord).toMatchObject({
|
||||
token: "token-123",
|
||||
});
|
||||
|
||||
@@ -170,12 +170,15 @@ installGatewayTestHooks({ scope: "suite" });
|
||||
describe("gateway hot reload", () => {
|
||||
let prevSkipChannels: string | undefined;
|
||||
let prevSkipGmail: string | undefined;
|
||||
let prevSkipProviders: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevSkipChannels = process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
prevSkipGmail = process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
prevSkipProviders = process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "0";
|
||||
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
delete process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -189,6 +192,11 @@ describe("gateway hot reload", () => {
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = prevSkipGmail;
|
||||
}
|
||||
if (prevSkipProviders === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_PROVIDERS;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_PROVIDERS = prevSkipProviders;
|
||||
}
|
||||
});
|
||||
|
||||
it("applies hot reload actions and emits restart signal", async () => {
|
||||
|
||||
@@ -207,6 +207,7 @@ describe("gateway update.run", () => {
|
||||
process.on("SIGUSR1", sigusr1);
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(CONFIG_PATH), { recursive: true });
|
||||
await fs.writeFile(CONFIG_PATH, JSON.stringify({ update: { channel: "beta" } }, null, 2));
|
||||
const updateMock = vi.mocked(runGatewayUpdate);
|
||||
updateMock.mockClear();
|
||||
|
||||
@@ -70,75 +70,82 @@ describe("web auto-reply", () => {
|
||||
});
|
||||
it("forces reconnect when watchdog closes without onClose", async () => {
|
||||
vi.useFakeTimers();
|
||||
const sleep = vi.fn(async () => {});
|
||||
const closeResolvers: Array<(reason: unknown) => void> = [];
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = vi.fn(
|
||||
async (opts: {
|
||||
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
let resolveClose: (reason: unknown) => void = () => {};
|
||||
const onClose = new Promise<unknown>((res) => {
|
||||
resolveClose = res;
|
||||
closeResolvers.push(res);
|
||||
});
|
||||
return {
|
||||
close: vi.fn(),
|
||||
onClose,
|
||||
signalClose: (reason?: unknown) => resolveClose(reason),
|
||||
};
|
||||
},
|
||||
);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebChannel(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const sleep = vi.fn(async () => {});
|
||||
const closeResolvers: Array<(reason: unknown) => void> = [];
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const listenerFactory = vi.fn(
|
||||
async (opts: {
|
||||
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
let resolveClose: (reason: unknown) => void = () => {};
|
||||
const onClose = new Promise<unknown>((res) => {
|
||||
resolveClose = res;
|
||||
closeResolvers.push(res);
|
||||
});
|
||||
return {
|
||||
close: vi.fn(),
|
||||
onClose,
|
||||
signalClose: (reason?: unknown) => resolveClose(reason),
|
||||
};
|
||||
},
|
||||
);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const run = monitorWebChannel(
|
||||
false,
|
||||
listenerFactory,
|
||||
true,
|
||||
async () => ({ text: "ok" }),
|
||||
runtime as never,
|
||||
controller.signal,
|
||||
{
|
||||
heartbeatSeconds: 1,
|
||||
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
|
||||
sleep,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(1);
|
||||
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const sendMedia = vi.fn();
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "m1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const sendComposing = vi.fn();
|
||||
const sendMedia = vi.fn();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(31 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
// The watchdog only needs `lastMessageAt` to be set. Don't await full message
|
||||
// processing here since it can schedule timers and become flaky under load.
|
||||
void capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1",
|
||||
to: "+2",
|
||||
id: "m1",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(31 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
controller.abort();
|
||||
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
|
||||
await Promise.resolve();
|
||||
await run;
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await Promise.resolve();
|
||||
expect(listenerFactory).toHaveBeenCalledTimes(2);
|
||||
|
||||
controller.abort();
|
||||
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
|
||||
await Promise.resolve();
|
||||
await run;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("stops after hitting max reconnect attempts", { timeout: 60_000 }, async () => {
|
||||
|
||||
Reference in New Issue
Block a user