mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 15:17:15 +00:00
Co-authored-by: Shawn <shenghuikevin@shenghuideMac-mini.local> Co-authored-by: 不做了睡大觉 <user@example.com> Co-authored-by: Marcus Widing <widing.marcus@gmail.com>
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Automation/Subagent/Cron reliability: honor `ANNOUNCE_SKIP` in `sessions_spawn` completion/direct announce flows (no user-visible token leaks), add transient direct-announce retries for channel unavailability (for example WhatsApp listener reconnect windows), and include `cron` in the `coding` tool profile so `/tools/invoke` can execute cron actions when explicitly allowed by gateway policy. (#25800, #25656, #25842, #25813, #25822, #25821) Thanks @astra-fer, @aaajiao, @dwight11232-coder, @kevinWangSheng, @widingmarcus-cyber, and @stakeswky.
|
||||||
- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin.
|
- Discord/Block streaming: restore block-streamed reply delivery by suppressing only reasoning payloads (instead of all `block` payloads), fixing missing Discord replies in `channels.discord.streaming=block` mode. (#25839, #25836, #25792) Thanks @pewallin.
|
||||||
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
|
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
|
||||||
- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
|
- Sandbox/FS bridge: build canonical-path shell scripts with newline separators (not `; ` joins) to avoid POSIX `sh` `do;` syntax errors that broke sandbox file/image read-write operations. (#25737, #25824, #25868) Thanks @DennisGoldfinger and @peteragility.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { resolvePreferredOpenClawTmpDir } from "../../../src/infra/tmp-openclaw-dir.js";
|
||||||
|
|
||||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||||
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -42,7 +42,7 @@ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
|||||||
expect(pathValue).not.toContain(key);
|
expect(pathValue).not.toContain(key);
|
||||||
expect(pathValue).not.toContain("..");
|
expect(pathValue).not.toContain("..");
|
||||||
|
|
||||||
const tmpRoot = path.resolve(os.tmpdir());
|
const tmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
|
||||||
const resolved = path.resolve(pathValue);
|
const resolved = path.resolve(pathValue);
|
||||||
const rel = path.relative(tmpRoot, resolved);
|
const rel = path.relative(tmpRoot, resolved);
|
||||||
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
|
||||||
|
|||||||
@@ -401,6 +401,102 @@ describe("subagent announce formatting", () => {
|
|||||||
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
|
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
|
||||||
|
const didAnnounce = await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: "agent:main:subagent:test",
|
||||||
|
childRunId: "run-direct-completion-skip",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
|
||||||
|
...defaultOutcomeAnnounce,
|
||||||
|
expectsCompletionMessage: true,
|
||||||
|
roundOneReply: "ANNOUNCE_SKIP",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(didAnnounce).toBe(true);
|
||||||
|
expect(sendSpy).not.toHaveBeenCalled();
|
||||||
|
expect(agentSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses announce flow for whitespace-padded ANNOUNCE_SKIP and still runs cleanup", async () => {
|
||||||
|
const didAnnounce = await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: "agent:main:subagent:test",
|
||||||
|
childRunId: "run-direct-skip-whitespace",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
...defaultOutcomeAnnounce,
|
||||||
|
cleanup: "delete",
|
||||||
|
roundOneReply: " ANNOUNCE_SKIP ",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(didAnnounce).toBe(true);
|
||||||
|
expect(sendSpy).not.toHaveBeenCalled();
|
||||||
|
expect(agentSpy).not.toHaveBeenCalled();
|
||||||
|
expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries completion direct send on transient channel-unavailable errors", async () => {
|
||||||
|
sendSpy
|
||||||
|
.mockRejectedValueOnce(new Error("Error: No active WhatsApp Web listener (account: default)"))
|
||||||
|
.mockRejectedValueOnce(new Error("UNAVAILABLE: listener reconnecting"))
|
||||||
|
.mockResolvedValueOnce({ runId: "send-main", status: "ok" });
|
||||||
|
|
||||||
|
const didAnnounce = await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: "agent:main:subagent:test",
|
||||||
|
childRunId: "run-direct-completion-retry",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
requesterOrigin: { channel: "whatsapp", to: "+15550000000", accountId: "default" },
|
||||||
|
...defaultOutcomeAnnounce,
|
||||||
|
expectsCompletionMessage: true,
|
||||||
|
roundOneReply: "final answer",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(didAnnounce).toBe(true);
|
||||||
|
expect(sendSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(agentSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not retry completion direct send on permanent channel errors", async () => {
|
||||||
|
sendSpy.mockRejectedValueOnce(new Error("unsupported channel: telegram"));
|
||||||
|
|
||||||
|
const didAnnounce = await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: "agent:main:subagent:test",
|
||||||
|
childRunId: "run-direct-completion-no-retry",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
requesterOrigin: { channel: "telegram", to: "telegram:1234" },
|
||||||
|
...defaultOutcomeAnnounce,
|
||||||
|
expectsCompletionMessage: true,
|
||||||
|
roundOneReply: "final answer",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(didAnnounce).toBe(false);
|
||||||
|
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(agentSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries direct agent announce on transient channel-unavailable errors", async () => {
|
||||||
|
agentSpy
|
||||||
|
.mockRejectedValueOnce(new Error("No active WhatsApp Web listener (account: default)"))
|
||||||
|
.mockRejectedValueOnce(new Error("UNAVAILABLE: delivery temporarily unavailable"))
|
||||||
|
.mockResolvedValueOnce({ runId: "run-main", status: "ok" });
|
||||||
|
|
||||||
|
const didAnnounce = await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: "agent:main:subagent:test",
|
||||||
|
childRunId: "run-direct-agent-retry",
|
||||||
|
requesterSessionKey: "agent:main:main",
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
requesterOrigin: { channel: "whatsapp", to: "+15551112222", accountId: "default" },
|
||||||
|
...defaultOutcomeAnnounce,
|
||||||
|
roundOneReply: "worker result",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(didAnnounce).toBe(true);
|
||||||
|
expect(agentSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(sendSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => {
|
it("keeps completion-mode delivery coordinated when sibling runs are still active", async () => {
|
||||||
sessionStore = {
|
sessionStore = {
|
||||||
"agent:main:subagent:test": {
|
"agent:main:subagent:test": {
|
||||||
|
|||||||
@@ -37,12 +37,16 @@ import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
|
|||||||
import type { SpawnSubagentMode } from "./subagent-spawn.js";
|
import type { SpawnSubagentMode } from "./subagent-spawn.js";
|
||||||
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
import { readLatestAssistantReply } from "./tools/agent-step.js";
|
||||||
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
|
import { sanitizeTextContent, extractAssistantText } from "./tools/sessions-helpers.js";
|
||||||
|
import { isAnnounceSkip } from "./tools/sessions-send-helpers.js";
|
||||||
|
|
||||||
const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1";
|
const FAST_TEST_MODE = process.env.OPENCLAW_TEST_FAST === "1";
|
||||||
const FAST_TEST_RETRY_INTERVAL_MS = 8;
|
const FAST_TEST_RETRY_INTERVAL_MS = 8;
|
||||||
const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20;
|
const FAST_TEST_REPLY_CHANGE_WAIT_MS = 20;
|
||||||
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000;
|
const DEFAULT_SUBAGENT_ANNOUNCE_TIMEOUT_MS = 60_000;
|
||||||
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
|
const MAX_TIMER_SAFE_TIMEOUT_MS = 2_147_000_000;
|
||||||
|
const DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS = FAST_TEST_MODE
|
||||||
|
? ([8, 16, 32] as const)
|
||||||
|
: ([5_000, 10_000, 20_000] as const);
|
||||||
|
|
||||||
type ToolResultMessage = {
|
type ToolResultMessage = {
|
||||||
role?: unknown;
|
role?: unknown;
|
||||||
@@ -72,6 +76,9 @@ function buildCompletionDeliveryMessage(params: {
|
|||||||
outcome?: SubagentRunOutcome;
|
outcome?: SubagentRunOutcome;
|
||||||
}): string {
|
}): string {
|
||||||
const findingsText = params.findings.trim();
|
const findingsText = params.findings.trim();
|
||||||
|
if (isAnnounceSkip(findingsText)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
const hasFindings = findingsText.length > 0 && findingsText !== "(no output)";
|
const hasFindings = findingsText.length > 0 && findingsText !== "(no output)";
|
||||||
const header = (() => {
|
const header = (() => {
|
||||||
if (params.outcome?.status === "error") {
|
if (params.outcome?.status === "error") {
|
||||||
@@ -111,6 +118,92 @@ function summarizeDeliveryError(error: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
|
||||||
|
/\berrorcode=unavailable\b/i,
|
||||||
|
/\bstatus\s*[:=]\s*"?unavailable\b/i,
|
||||||
|
/\bUNAVAILABLE\b/,
|
||||||
|
/no active .* listener/i,
|
||||||
|
/gateway not connected/i,
|
||||||
|
/gateway closed \(1006/i,
|
||||||
|
/gateway timeout/i,
|
||||||
|
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
|
||||||
|
/unsupported channel/i,
|
||||||
|
/unknown channel/i,
|
||||||
|
/chat not found/i,
|
||||||
|
/user not found/i,
|
||||||
|
/bot was blocked by the user/i,
|
||||||
|
/forbidden: bot was kicked/i,
|
||||||
|
/recipient is not a valid/i,
|
||||||
|
/outbound not configured for channel/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTransientAnnounceDeliveryError(error: unknown): boolean {
|
||||||
|
const message = summarizeDeliveryError(error);
|
||||||
|
if (!message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAnnounceRetryDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
|
if (ms <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!signal) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
}, ms);
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnnounceDeliveryWithRetry<T>(params: {
|
||||||
|
operation: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
run: () => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
let retryIndex = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (params.signal?.aborted) {
|
||||||
|
throw new Error("announce delivery aborted");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await params.run();
|
||||||
|
} catch (err) {
|
||||||
|
const delayMs = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS[retryIndex];
|
||||||
|
if (delayMs == null || !isTransientAnnounceDeliveryError(err) || params.signal?.aborted) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const nextAttempt = retryIndex + 2;
|
||||||
|
const maxAttempts = DIRECT_ANNOUNCE_TRANSIENT_RETRY_DELAYS_MS.length + 1;
|
||||||
|
defaultRuntime.log(
|
||||||
|
`[warn] Subagent announce ${params.operation} transient failure, retrying ${nextAttempt}/${maxAttempts} in ${Math.round(delayMs / 1000)}s: ${summarizeDeliveryError(err)}`,
|
||||||
|
);
|
||||||
|
retryIndex += 1;
|
||||||
|
await waitForAnnounceRetryDelay(delayMs, params.signal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractToolResultText(content: unknown): string {
|
function extractToolResultText(content: unknown): string {
|
||||||
if (typeof content === "string") {
|
if (typeof content === "string") {
|
||||||
return sanitizeTextContent(content);
|
return sanitizeTextContent(content);
|
||||||
@@ -712,18 +805,23 @@ async function sendSubagentAnnounceDirectly(params: {
|
|||||||
path: "none",
|
path: "none",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await callGateway({
|
await runAnnounceDeliveryWithRetry({
|
||||||
method: "send",
|
operation: "completion direct send",
|
||||||
params: {
|
signal: params.signal,
|
||||||
channel: completionChannel,
|
run: async () =>
|
||||||
to: completionTo,
|
await callGateway({
|
||||||
accountId: completionDirectOrigin?.accountId,
|
method: "send",
|
||||||
threadId: completionThreadId,
|
params: {
|
||||||
sessionKey: canonicalRequesterSessionKey,
|
channel: completionChannel,
|
||||||
message: params.completionMessage,
|
to: completionTo,
|
||||||
idempotencyKey: params.directIdempotencyKey,
|
accountId: completionDirectOrigin?.accountId,
|
||||||
},
|
threadId: completionThreadId,
|
||||||
timeoutMs: announceTimeoutMs,
|
sessionKey: canonicalRequesterSessionKey,
|
||||||
|
message: params.completionMessage,
|
||||||
|
idempotencyKey: params.directIdempotencyKey,
|
||||||
|
},
|
||||||
|
timeoutMs: announceTimeoutMs,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -754,21 +852,26 @@ async function sendSubagentAnnounceDirectly(params: {
|
|||||||
path: "none",
|
path: "none",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await callGateway({
|
await runAnnounceDeliveryWithRetry({
|
||||||
method: "agent",
|
operation: "direct announce agent call",
|
||||||
params: {
|
signal: params.signal,
|
||||||
sessionKey: canonicalRequesterSessionKey,
|
run: async () =>
|
||||||
message: params.triggerMessage,
|
await callGateway({
|
||||||
deliver: shouldDeliverExternally,
|
method: "agent",
|
||||||
bestEffortDeliver: params.bestEffortDeliver,
|
params: {
|
||||||
channel: shouldDeliverExternally ? directChannel : undefined,
|
sessionKey: canonicalRequesterSessionKey,
|
||||||
accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined,
|
message: params.triggerMessage,
|
||||||
to: shouldDeliverExternally ? directTo : undefined,
|
deliver: shouldDeliverExternally,
|
||||||
threadId: shouldDeliverExternally ? threadId : undefined,
|
bestEffortDeliver: params.bestEffortDeliver,
|
||||||
idempotencyKey: params.directIdempotencyKey,
|
channel: shouldDeliverExternally ? directChannel : undefined,
|
||||||
},
|
accountId: shouldDeliverExternally ? directOrigin?.accountId : undefined,
|
||||||
expectFinal: true,
|
to: shouldDeliverExternally ? directTo : undefined,
|
||||||
timeoutMs: announceTimeoutMs,
|
threadId: shouldDeliverExternally ? threadId : undefined,
|
||||||
|
idempotencyKey: params.directIdempotencyKey,
|
||||||
|
},
|
||||||
|
expectFinal: true,
|
||||||
|
timeoutMs: announceTimeoutMs,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1096,6 +1199,10 @@ export async function runSubagentAnnounceFlow(params: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAnnounceSkip(reply)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!outcome) {
|
if (!outcome) {
|
||||||
outcome = { status: "unknown" };
|
outcome = { status: "unknown" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
|||||||
label: "cron",
|
label: "cron",
|
||||||
description: "Schedule tasks",
|
description: "Schedule tasks",
|
||||||
sectionId: "automation",
|
sectionId: "automation",
|
||||||
profiles: [],
|
profiles: ["coding"],
|
||||||
includeInOpenClawGroup: true,
|
includeInOpenClawGroup: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ describe("tool-policy", () => {
|
|||||||
it("resolves known profiles and ignores unknown ones", () => {
|
it("resolves known profiles and ignores unknown ones", () => {
|
||||||
const coding = resolveToolProfilePolicy("coding");
|
const coding = resolveToolProfilePolicy("coding");
|
||||||
expect(coding?.allow).toContain("read");
|
expect(coding?.allow).toContain("read");
|
||||||
|
expect(coding?.allow).toContain("cron");
|
||||||
|
expect(coding?.allow).not.toContain("gateway");
|
||||||
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
|
expect(resolveToolProfilePolicy("nope")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -120,4 +120,23 @@ describe("tools invoke HTTP denylist", () => {
|
|||||||
|
|
||||||
expect(cronRes.status).toBe(200);
|
expect(cronRes.status).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps cron available under coding profile without exposing gateway", async () => {
|
||||||
|
cfg = {
|
||||||
|
tools: {
|
||||||
|
profile: "coding",
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
tools: {
|
||||||
|
allow: ["cron"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cronRes = await invoke("cron");
|
||||||
|
const gatewayRes = await invoke("gateway");
|
||||||
|
|
||||||
|
expect(cronRes.status).toBe(200);
|
||||||
|
expect(gatewayRes.status).toBe(404);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user