mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
2bed8caf9e
* feat(gateway): proxy channel conversation avatars * feat(discord): capture conversation avatars * feat(slack): capture DM sender avatars * test(discord): bind guild avatar mock * feat(ui): render channel conversation avatars * fix(ui): align sidebar owner fixtures * fix(gateway): version channel-avatar routes by media revision A stable per-session URL let AuthenticatedAvatarRouteLoader's blob and sticky-404 caches pin a mounted row to a stale or blank avatar after the backing media changed. Append an opaque digest of the media reference so replacement and 404-recovery change the route identity. * test(ui): align sidebar owner facet * fix(ui): keep owner chip until channel avatar loads A session with a channelAvatarUrl suppressed its owner chip even while the blob was loading, auth was not ready, or the route 404ed, leaving an empty lead slot. The chip now rides as fallback content inside the avatar element and yields only to a usable image. Covers 404 and auth-pending states; avatar rows keep renderedOwnerId unset so an owner-viewer stays visible in the facepile. * perf(ui): keep channel avatar fallback within budget * perf(ui): lazy-load the channel avatar element The avatar element and its authenticated blob loader rode the startup bundle through session-leading-indicator, pushing startup JS 51 B over the CI gzip budget. Channel avatars are not startup-critical: register the element on the first avatar row; the owner-chip fallback covers the one-time upgrade window. Startup JS returns ~1 KiB under the ceiling. * build(ui): raise startup baseline for channel avatars CI-measured startup JS is 344379 B against a 343289 B baseline (+1090 B). The avatar element and blob loader are code-split out of startup (previous commit); the residual is the sidebar lead-slot render branch and row plumbing, which cannot be deferred. Baseline updated via check-control-ui-performance --update-baseline with CI bytes per the script's contract; well inside the 4096 B ratchet step and 358400 B ceiling.
1656 lines
51 KiB
TypeScript
1656 lines
51 KiB
TypeScript
/** Tests inbound auto-reply handling across channel message contexts. */
|
|
import path from "node:path";
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { GroupKeyResolution } from "../config/sessions.js";
|
|
import { channelRouteDedupeKey } from "../plugin-sdk/channel-route.js";
|
|
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
|
|
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
|
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
|
import { createInboundDebouncer, type InboundDebounceCreateParams } from "./inbound-debounce.js";
|
|
import { resolveGroupRequireMention } from "./reply/groups.js";
|
|
import { finalizeInboundContext } from "./reply/inbound-context.js";
|
|
import {
|
|
claimInboundDedupe,
|
|
commitInboundDedupe,
|
|
resetInboundDedupe,
|
|
} from "./reply/inbound-dedupe.js";
|
|
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
|
|
import {
|
|
buildMentionRegexes,
|
|
matchesMentionPatterns,
|
|
normalizeMentionText,
|
|
stripMentions,
|
|
} from "./reply/mentions.js";
|
|
import { initSessionState } from "./reply/session.js";
|
|
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
|
|
|
|
type TestChannelGroupContext = {
|
|
cfg: OpenClawConfig;
|
|
groupId?: string | null;
|
|
groupChannel?: string | null;
|
|
groupSpace?: string | null;
|
|
accountId?: string | null;
|
|
};
|
|
|
|
function commitInboundForTest(ctx: MsgContext): string {
|
|
const claim = claimInboundDedupe(ctx);
|
|
expect(claim.status).toBe("claimed");
|
|
if (claim.status !== "claimed") {
|
|
throw new Error(`expected inbound dedupe claim, got ${claim.status}`);
|
|
}
|
|
commitInboundDedupe(claim.key);
|
|
return claim.key;
|
|
}
|
|
|
|
function normalizeTestSlug(raw?: string | null): string {
|
|
return raw?.trim().replace(/^#/, "").toLowerCase() ?? "";
|
|
}
|
|
|
|
function resolveDiscordRequireMentionForTest(params: TestChannelGroupContext): boolean {
|
|
const discordCfg = params.cfg.channels?.discord as
|
|
| {
|
|
guilds?: Record<
|
|
string,
|
|
{
|
|
requireMention?: boolean;
|
|
slug?: string;
|
|
channels?: Record<string, { requireMention?: boolean }>;
|
|
}
|
|
>;
|
|
}
|
|
| undefined;
|
|
const guilds = discordCfg?.guilds;
|
|
if (!guilds) {
|
|
return true;
|
|
}
|
|
const space = params.groupSpace?.trim() ?? "";
|
|
const spaceSlug = normalizeTestSlug(space);
|
|
const guild =
|
|
(space ? guilds[space] : undefined) ??
|
|
(spaceSlug ? guilds[spaceSlug] : undefined) ??
|
|
Object.values(guilds).find((entry) => normalizeTestSlug(entry?.slug) === spaceSlug) ??
|
|
guilds["*"];
|
|
const channelSlug = normalizeTestSlug(params.groupChannel);
|
|
const channel =
|
|
(params.groupId ? guild?.channels?.[params.groupId] : undefined) ??
|
|
(channelSlug ? guild?.channels?.[channelSlug] : undefined) ??
|
|
(channelSlug ? guild?.channels?.[`#${channelSlug}`] : undefined);
|
|
return channel?.requireMention ?? guild?.requireMention ?? true;
|
|
}
|
|
|
|
function resolveSlackRequireMentionForTest(params: TestChannelGroupContext): boolean {
|
|
const slackCfg = params.cfg.channels?.slack as
|
|
| {
|
|
defaultAccount?: string;
|
|
channels?: Record<string, { requireMention?: boolean }>;
|
|
accounts?: Record<string, { channels?: Record<string, { requireMention?: boolean }> }>;
|
|
}
|
|
| undefined;
|
|
if (!slackCfg) {
|
|
return true;
|
|
}
|
|
const accountId = params.accountId ?? slackCfg.defaultAccount;
|
|
const channels =
|
|
(accountId ? slackCfg.accounts?.[accountId]?.channels : undefined) ?? slackCfg.channels;
|
|
if (!channels) {
|
|
return true;
|
|
}
|
|
const channelName = params.groupChannel?.trim().replace(/^#/, "");
|
|
const channelSlug = normalizeTestSlug(channelName);
|
|
const candidates = [
|
|
params.groupId?.trim(),
|
|
channelName ? `#${channelName}` : undefined,
|
|
channelName,
|
|
channelSlug,
|
|
"*",
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
const entry = channels[candidate];
|
|
if (typeof entry?.requireMention === "boolean") {
|
|
return entry.requireMention;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function installGroupRequireMentionTestPlugins() {
|
|
setActivePluginRegistry(
|
|
createTestRegistry([
|
|
{
|
|
pluginId: "discord",
|
|
plugin: {
|
|
...createChannelTestPluginBase({ id: "discord" }),
|
|
groups: { resolveRequireMention: resolveDiscordRequireMentionForTest },
|
|
},
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "slack",
|
|
plugin: {
|
|
...createChannelTestPluginBase({ id: "slack" }),
|
|
groups: { resolveRequireMention: resolveSlackRequireMentionForTest },
|
|
},
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "line",
|
|
plugin: createChannelTestPluginBase({ id: "line" }),
|
|
source: "test",
|
|
},
|
|
{
|
|
pluginId: "imessage",
|
|
plugin: createChannelTestPluginBase({ id: "imessage" }),
|
|
source: "test",
|
|
},
|
|
]),
|
|
);
|
|
}
|
|
|
|
describe("applyTemplate", () => {
|
|
it("renders primitive values", () => {
|
|
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
|
|
const overrides = ctx as Record<string, unknown>;
|
|
overrides.MessageSid = 42;
|
|
overrides.IsNewSession = true;
|
|
|
|
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
|
|
});
|
|
|
|
it("renders arrays of primitives", () => {
|
|
const ctx = { MediaPaths: ["a"] } as TemplateContext;
|
|
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
|
|
|
|
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
|
|
});
|
|
|
|
it("drops object values", () => {
|
|
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
|
|
|
|
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
|
|
});
|
|
|
|
it("renders missing placeholders as empty", () => {
|
|
const ctx: TemplateContext = {};
|
|
|
|
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
|
|
});
|
|
|
|
it("never renders channel-owned conversation image references", () => {
|
|
const ctx = {
|
|
ConversationAvatar: "/private/media/inbound/avatar.png",
|
|
} as unknown as TemplateContext;
|
|
|
|
expect(applyTemplate("avatar={{ConversationAvatar}}", ctx)).toBe("avatar=");
|
|
});
|
|
});
|
|
|
|
describe("normalizeInboundTextNewlines", () => {
|
|
it("keeps real newlines", () => {
|
|
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
|
|
});
|
|
|
|
it("normalizes CRLF/CR to LF", () => {
|
|
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
|
|
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
|
|
});
|
|
|
|
it("preserves literal backslash-n sequences (Windows paths)", () => {
|
|
// Windows paths like C:\Work\nxxx should NOT have \n converted to newlines
|
|
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\\nb");
|
|
expect(normalizeInboundTextNewlines("C:\\Work\\nxxx")).toBe("C:\\Work\\nxxx");
|
|
});
|
|
});
|
|
|
|
describe("finalizeInboundContext", () => {
|
|
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
|
|
const ctx: MsgContext = {
|
|
// Use actual CRLF for newline normalization test, not literal \n sequences
|
|
Body: "a\r\nb\r\nc",
|
|
RawBody: "raw\r\nline",
|
|
ChatType: "channel",
|
|
From: "whatsapp:group:123@g.us",
|
|
GroupSubject: "Test",
|
|
};
|
|
|
|
const out = finalizeInboundContext(ctx);
|
|
expect(out.Body).toBe("a\nb\nc");
|
|
expect(out.RawBody).toBe("raw\nline");
|
|
// Prefer clean text over legacy envelope-shaped Body when RawBody is present.
|
|
expect(out.BodyForAgent).toBe("raw\nline");
|
|
expect(out.BodyForCommands).toBe("raw\nline");
|
|
expect(out.CommandAuthorized).toBe(false);
|
|
expect(out.CommandTurn).toMatchObject({
|
|
kind: "normal",
|
|
source: "message",
|
|
authorized: false,
|
|
});
|
|
expect(out.ChatType).toBe("channel");
|
|
expect(out.ConversationLabel).toContain("Test");
|
|
});
|
|
|
|
it("normalizes structured command turn context and legacy command fields together", () => {
|
|
const out = finalizeInboundContext({
|
|
Body: "/status",
|
|
CommandBody: "/status",
|
|
CommandAuthorized: false,
|
|
CommandTurn: {
|
|
kind: "text-slash" as const,
|
|
source: "text" as const,
|
|
authorized: true,
|
|
},
|
|
});
|
|
|
|
expect(out.CommandTurn).toMatchObject({
|
|
kind: "text-slash",
|
|
source: "text",
|
|
authorized: true,
|
|
commandName: "status",
|
|
body: "/status",
|
|
});
|
|
expect(out.CommandSource).toBe("text");
|
|
expect(out.CommandAuthorized).toBe(true);
|
|
});
|
|
|
|
it("clears stale legacy command source without dropping normal-turn command auth", () => {
|
|
const out = finalizeInboundContext({
|
|
Body: "hello",
|
|
CommandSource: "native",
|
|
CommandAuthorized: true,
|
|
CommandTurn: {
|
|
kind: "normal" as const,
|
|
source: "message" as const,
|
|
authorized: false,
|
|
},
|
|
});
|
|
|
|
expect(out.CommandTurn).toMatchObject({
|
|
kind: "normal",
|
|
source: "message",
|
|
authorized: false,
|
|
});
|
|
expect(out.CommandSource).toBeUndefined();
|
|
expect(out.CommandAuthorized).toBe(true);
|
|
});
|
|
|
|
it("keeps normal command authorization stable across repeated finalization", () => {
|
|
const out = finalizeInboundContext({
|
|
Body: "please inspect `/tmp/foo`",
|
|
CommandAuthorized: true,
|
|
CommandTurn: {
|
|
kind: "normal" as const,
|
|
source: "message" as const,
|
|
authorized: false,
|
|
},
|
|
});
|
|
|
|
const refinalized = finalizeInboundContext(out);
|
|
|
|
expect(refinalized.CommandTurn).toMatchObject({
|
|
kind: "normal",
|
|
source: "message",
|
|
authorized: false,
|
|
});
|
|
expect(refinalized.CommandSource).toBeUndefined();
|
|
expect(refinalized.CommandAuthorized).toBe(true);
|
|
});
|
|
|
|
it("normalizes trusted group system prompt newlines without rewriting prompt markers", () => {
|
|
const out = finalizeInboundContext({
|
|
Body: "hello",
|
|
GroupSystemPrompt: "[Assistant] room guidance\r\nSystem: owner instruction",
|
|
});
|
|
|
|
expect(out.GroupSystemPrompt).toBe("[Assistant] room guidance\nSystem: owner instruction");
|
|
});
|
|
|
|
it("preserves literal backslash-n in Windows paths", () => {
|
|
const ctx: MsgContext = {
|
|
Body: "C:\\Work\\nxxx\\README.md",
|
|
RawBody: "C:\\Work\\nxxx\\README.md",
|
|
ChatType: "direct",
|
|
From: "web:user",
|
|
};
|
|
|
|
const out = finalizeInboundContext(ctx);
|
|
expect(out.Body).toBe("C:\\Work\\nxxx\\README.md");
|
|
expect(out.BodyForAgent).toBe("C:\\Work\\nxxx\\README.md");
|
|
expect(out.BodyForCommands).toBe("C:\\Work\\nxxx\\README.md");
|
|
});
|
|
|
|
it("can force BodyForCommands to follow updated CommandBody", () => {
|
|
const ctx: MsgContext = {
|
|
Body: "base",
|
|
BodyForCommands: "<media:audio>",
|
|
CommandBody: "say hi",
|
|
From: "signal:+15550001111",
|
|
ChatType: "direct",
|
|
};
|
|
|
|
finalizeInboundContext(ctx, { forceBodyForCommands: true });
|
|
expect(ctx.BodyForCommands).toBe("say hi");
|
|
});
|
|
|
|
it("fills a generic content type only when media exists", () => {
|
|
const withMedia: MsgContext = {
|
|
Body: "hi",
|
|
media: [{ path: "/tmp/file.bin" }],
|
|
};
|
|
const outWithMedia = finalizeInboundContext(withMedia);
|
|
expect(outWithMedia.media).toEqual([
|
|
expect.objectContaining({ path: "/tmp/file.bin", contentType: "application/octet-stream" }),
|
|
]);
|
|
|
|
const withoutMedia: MsgContext = { Body: "hi" };
|
|
const outWithoutMedia = finalizeInboundContext(withoutMedia);
|
|
expect(outWithoutMedia.media).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("inbound dedupe", () => {
|
|
it("builds a stable key when MessageSid is present", () => {
|
|
const ctx: MsgContext = {
|
|
Provider: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:123",
|
|
MessageSid: "42",
|
|
};
|
|
expect(claimInboundDedupe(ctx, { inFlight: new Set() })).toEqual({
|
|
status: "claimed",
|
|
key: JSON.stringify([
|
|
"",
|
|
channelRouteDedupeKey({
|
|
channel: "telegram",
|
|
to: "telegram:123",
|
|
}),
|
|
"42",
|
|
]),
|
|
});
|
|
});
|
|
|
|
it("skips duplicates with the same key", () => {
|
|
resetInboundDedupe();
|
|
const ctx: MsgContext = {
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+1555",
|
|
MessageSid: "msg-1",
|
|
};
|
|
const key = commitInboundForTest(ctx);
|
|
expect(claimInboundDedupe(ctx)).toEqual({ status: "duplicate", key });
|
|
});
|
|
|
|
it("does not dedupe when the peer changes", () => {
|
|
resetInboundDedupe();
|
|
const base: MsgContext = {
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
MessageSid: "msg-1",
|
|
};
|
|
commitInboundForTest({ ...base, OriginatingTo: "whatsapp:+1000" });
|
|
expect(claimInboundDedupe({ ...base, OriginatingTo: "whatsapp:+2000" }).status).toBe("claimed");
|
|
});
|
|
|
|
it("does not dedupe across agent ids", () => {
|
|
resetInboundDedupe();
|
|
const base: MsgContext = {
|
|
Provider: "whatsapp",
|
|
OriginatingChannel: "whatsapp",
|
|
OriginatingTo: "whatsapp:+1555",
|
|
MessageSid: "msg-1",
|
|
};
|
|
const alphaKey = commitInboundForTest({ ...base, SessionKey: "agent:alpha:main" });
|
|
expect(
|
|
claimInboundDedupe({ ...base, SessionKey: "agent:bravo:whatsapp:direct:+1555" }).status,
|
|
).toBe("claimed");
|
|
expect(claimInboundDedupe({ ...base, SessionKey: "agent:alpha:main" })).toEqual({
|
|
status: "duplicate",
|
|
key: alphaKey,
|
|
});
|
|
});
|
|
|
|
it("dedupes when the same agent sees the same inbound message under different session keys", () => {
|
|
resetInboundDedupe();
|
|
const base: MsgContext = {
|
|
Provider: "telegram",
|
|
OriginatingChannel: "telegram",
|
|
OriginatingTo: "telegram:7463849194",
|
|
MessageSid: "msg-1",
|
|
};
|
|
const key = commitInboundForTest({ ...base, SessionKey: "agent:main:main" });
|
|
expect(
|
|
claimInboundDedupe({ ...base, SessionKey: "agent:main:telegram:direct:7463849194" }),
|
|
).toEqual({ status: "duplicate", key });
|
|
});
|
|
});
|
|
|
|
describe("createInboundDebouncer", () => {
|
|
type TestInboundDebounceFlush = ReturnType<InboundDebounceCreateParams<unknown>["onFlush"]>;
|
|
const flushOnCompletion = (dispatch: () => void | Promise<void>): TestInboundDebounceFlush => {
|
|
const completion = Promise.resolve().then(dispatch);
|
|
return { admission: completion, completion };
|
|
};
|
|
|
|
it("debounces and combines items", async () => {
|
|
vi.useFakeTimers();
|
|
const calls: Array<string[]> = [];
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 10,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
await debouncer.enqueue({ key: "a", id: "2" });
|
|
|
|
expect(calls).toStrictEqual([]);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(calls).toEqual([["1", "2"]]);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("flushes sustained same-key messages in complete, ordered batches", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const calls: Array<string[]> = [];
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
for (let index = 0; index < 12; index += 1) {
|
|
await debouncer.enqueue({ key: "a", id: String(index) });
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
}
|
|
expect(calls).toStrictEqual([]);
|
|
|
|
await debouncer.enqueue({ key: "a", id: "12" });
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(calls).toEqual([Array.from({ length: 13 }, (_, index) => String(index))]);
|
|
|
|
for (let index = 13; index < 25; index += 1) {
|
|
await debouncer.enqueue({ key: "a", id: String(index) });
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
}
|
|
expect(calls).toHaveLength(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(calls).toEqual([
|
|
Array.from({ length: 13 }, (_, index) => String(index)),
|
|
Array.from({ length: 12 }, (_, index) => String(index + 13)),
|
|
]);
|
|
await debouncer.drain();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("anchors sustained-message deadlines to the first item's debounce window", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const calls: Array<string[]> = [];
|
|
const debouncer = createInboundDebouncer<{
|
|
key: string;
|
|
id: string;
|
|
windowMs: number;
|
|
}>({
|
|
debounceMs: 0,
|
|
buildKey: (item) => item.key,
|
|
resolveDebounceMs: (item) => item.windowMs,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "first", windowMs: 50 });
|
|
await vi.advanceTimersByTimeAsync(40);
|
|
await debouncer.enqueue({ key: "a", id: "later", windowMs: 1_000 });
|
|
await vi.advanceTimersByTimeAsync(209);
|
|
expect(calls).toStrictEqual([]);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(calls).toEqual([["first", "later"]]);
|
|
await debouncer.drain();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("flushes sustained messages even when the system clock moves backward", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const calls: Array<string[]> = [];
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "0" });
|
|
for (let index = 1; index < 13; index += 1) {
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
if (index === 6) {
|
|
vi.setSystemTime(Date.now() - 60_000);
|
|
}
|
|
await debouncer.enqueue({ key: "a", id: String(index) });
|
|
}
|
|
expect(calls).toStrictEqual([]);
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(calls).toEqual([Array.from({ length: 13 }, (_, index) => String(index))]);
|
|
await debouncer.drain();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("reports buffered items when cancelling a key", async () => {
|
|
vi.useFakeTimers();
|
|
const calls: Array<string[]> = [];
|
|
const canceled: Array<string[]> = [];
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 10,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
onCancel: (items) => {
|
|
canceled.push(items.map((entry) => entry.id));
|
|
},
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
await debouncer.enqueue({ key: "a", id: "2" });
|
|
expect(debouncer.cancelKey("a")).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
expect(canceled).toEqual([["1", "2"]]);
|
|
expect(calls).toEqual([]);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("cancels a released flush still waiting behind active same-key work", async () => {
|
|
const calls: Array<string[]> = [];
|
|
const canceled: Array<string[]> = [];
|
|
let releaseFirst!: () => void;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const ids = items.map((entry) => entry.id);
|
|
calls.push(ids);
|
|
if (ids[0] === "1") {
|
|
await firstGate;
|
|
}
|
|
}),
|
|
onCancel: (items) => {
|
|
canceled.push(items.map((entry) => entry.id));
|
|
},
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
const firstFlush = debouncer.flushKey("a");
|
|
await vi.waitFor(() => expect(calls).toEqual([["1"]]));
|
|
|
|
await debouncer.enqueue({ key: "a", id: "2" });
|
|
const secondFlush = debouncer.flushKey("a");
|
|
expect(debouncer.cancelKey("a")).toBe(true);
|
|
|
|
await debouncer.enqueue({ key: "a", id: "3" });
|
|
const thirdFlush = debouncer.flushKey("a");
|
|
releaseFirst();
|
|
await Promise.all([firstFlush, secondFlush, thirdFlush]);
|
|
|
|
expect(canceled).toEqual([["2"]]);
|
|
expect(calls).toEqual([["1"], ["3"]]);
|
|
});
|
|
|
|
it("flushes buffered items before non-debounced item", async () => {
|
|
vi.useFakeTimers();
|
|
const calls: Array<string[]> = [];
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
shouldDebounce: (item) => item.debounce,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
|
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
|
|
|
expect(calls).toEqual([["1"], ["2"]]);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("supports per-item debounce windows when default debounce is disabled", async () => {
|
|
vi.useFakeTimers();
|
|
const calls: Array<string[]> = [];
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string; windowMs: number }>({
|
|
debounceMs: 0,
|
|
buildKey: (item) => item.key,
|
|
resolveDebounceMs: (item) => item.windowMs,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "forward", id: "1", windowMs: 30 });
|
|
await debouncer.enqueue({ key: "forward", id: "2", windowMs: 30 });
|
|
|
|
expect(calls).toStrictEqual([]);
|
|
await vi.advanceTimersByTimeAsync(30);
|
|
expect(calls).toEqual([["1", "2"]]);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("keeps later same-key work behind a timer-backed flush that already started", async () => {
|
|
const started: string[] = [];
|
|
const finished: string[] = [];
|
|
let releaseFirst: (() => void) | undefined;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
shouldDebounce: (item) => item.debounce,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const ids = items.map((entry) => entry.id).join(",");
|
|
started.push(ids);
|
|
if (ids === "1") {
|
|
await firstGate;
|
|
}
|
|
finished.push(ids);
|
|
}),
|
|
});
|
|
|
|
try {
|
|
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
|
|
|
const timerIndex = setTimeoutSpy.mock.calls.findLastIndex((call) => call[1] === 50);
|
|
expect(timerIndex).toBeGreaterThanOrEqual(0);
|
|
clearTimeout(setTimeoutSpy.mock.results[timerIndex]?.value as ReturnType<typeof setTimeout>);
|
|
const flushTimer = setTimeoutSpy.mock.calls[timerIndex]?.[0] as
|
|
| (() => Promise<void>)
|
|
| undefined;
|
|
const firstFlush = flushTimer?.();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["1"]);
|
|
});
|
|
|
|
const secondEnqueue = debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
|
await Promise.resolve();
|
|
|
|
expect(started).toEqual(["1"]);
|
|
expect(finished).toStrictEqual([]);
|
|
|
|
if (!releaseFirst) {
|
|
throw new Error("Expected first inbound debounce release callback to be initialized");
|
|
}
|
|
releaseFirst();
|
|
await Promise.all([firstFlush, secondEnqueue]);
|
|
|
|
expect(started).toEqual(["1", "2"]);
|
|
expect(finished).toEqual(["1", "2"]);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("keeps fire-and-forget keyed work ahead of a later buffered item", async () => {
|
|
const started: string[] = [];
|
|
const finished: string[] = [];
|
|
let releaseFirst: (() => void) | undefined;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
shouldDebounce: (item) => item.debounce,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const ids = items.map((entry) => entry.id).join(",");
|
|
started.push(ids);
|
|
if (ids === "1") {
|
|
await firstGate;
|
|
}
|
|
finished.push(ids);
|
|
}),
|
|
});
|
|
|
|
try {
|
|
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
|
|
|
|
const firstTimerIndex = setTimeoutSpy.mock.calls.findLastIndex((call) => call[1] === 50);
|
|
expect(firstTimerIndex).toBeGreaterThanOrEqual(0);
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[firstTimerIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
(setTimeoutSpy.mock.calls[firstTimerIndex]?.[0] as (() => void) | undefined)?.();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["1"]);
|
|
});
|
|
|
|
const secondEnqueue = debouncer.enqueue({ key: "a", id: "2", debounce: false });
|
|
const thirdEnqueue = debouncer.enqueue({ key: "a", id: "3", debounce: true });
|
|
|
|
const thirdTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
|
(call, index) => index > firstTimerIndex && call[1] === 50,
|
|
);
|
|
expect(thirdTimerIndex).toBeGreaterThan(firstTimerIndex);
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[thirdTimerIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
(setTimeoutSpy.mock.calls[thirdTimerIndex]?.[0] as (() => void) | undefined)?.();
|
|
|
|
await Promise.resolve();
|
|
|
|
expect(started).toEqual(["1"]);
|
|
expect(finished).toStrictEqual([]);
|
|
|
|
if (!releaseFirst) {
|
|
throw new Error("Expected first inbound debounce release callback to be initialized");
|
|
}
|
|
releaseFirst();
|
|
await Promise.all([secondEnqueue, thirdEnqueue]);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["1", "2", "3"]);
|
|
expect(finished).toEqual(["1", "2", "3"]);
|
|
});
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("does not serialize keyed turns by default when debounce is disabled", async () => {
|
|
const started: string[] = [];
|
|
let releaseFirst: (() => void) | undefined;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 0,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const id = items[0]?.id ?? "";
|
|
started.push(id);
|
|
if (id === "1") {
|
|
await firstGate;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const first = debouncer.enqueue({ key: "a", id: "1" });
|
|
await Promise.resolve();
|
|
const second = debouncer.enqueue({ key: "a", id: "2" });
|
|
await Promise.resolve();
|
|
|
|
expect(started).toEqual(["1", "2"]);
|
|
|
|
if (!releaseFirst) {
|
|
throw new Error("Expected first inbound debounce release callback to be initialized");
|
|
}
|
|
releaseFirst();
|
|
await Promise.all([first, second]);
|
|
});
|
|
|
|
it("serializes keyed turns when immediate serialization is enabled", async () => {
|
|
const started: string[] = [];
|
|
let releaseFirst: (() => void) | undefined;
|
|
const firstGate = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 0,
|
|
serializeImmediate: true,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const id = items[0]?.id ?? "";
|
|
started.push(id);
|
|
if (id === "1") {
|
|
await firstGate;
|
|
}
|
|
}),
|
|
});
|
|
|
|
const first = debouncer.enqueue({ key: "a", id: "1" });
|
|
await Promise.resolve();
|
|
const second = debouncer.enqueue({ key: "a", id: "2" });
|
|
await Promise.resolve();
|
|
|
|
expect(started).toEqual(["1"]);
|
|
|
|
if (!releaseFirst) {
|
|
throw new Error("Expected first inbound debounce release callback to be initialized");
|
|
}
|
|
releaseFirst();
|
|
await Promise.all([first, second]);
|
|
expect(started).toEqual(["1", "2"]);
|
|
});
|
|
|
|
it("releases a keyed chain at admission and drains full flush completions", async () => {
|
|
const started: string[] = [];
|
|
const completed: string[] = [];
|
|
let releaseFirst!: () => void;
|
|
const firstCompletion = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items, createFlush) =>
|
|
createFlush({
|
|
dispatch: async (lifecycle) => {
|
|
const id = items[0]?.id ?? "";
|
|
started.push(id);
|
|
await lifecycle.onAdopted();
|
|
if (id === "1") {
|
|
await firstCompletion;
|
|
}
|
|
completed.push(id);
|
|
},
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
await debouncer.flushKey("a");
|
|
await debouncer.enqueue({ key: "a", id: "2" });
|
|
await debouncer.flushKey("a");
|
|
|
|
expect(started).toEqual(["1", "2"]);
|
|
expect(completed).toEqual(["2"]);
|
|
|
|
let drained = false;
|
|
const drain = debouncer.drain().then(() => {
|
|
drained = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(drained).toBe(false);
|
|
|
|
releaseFirst();
|
|
await drain;
|
|
expect(completed).toEqual(["2", "1"]);
|
|
});
|
|
|
|
it("hands pre-admission completion failures to the source lifecycle once", async () => {
|
|
const sessionError = new Error("Session changed while starting work. Retry.");
|
|
const onFailed = vi.fn(async () => {});
|
|
const onError = vi.fn();
|
|
let attempt = 0;
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 0,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (_items, createFlush) =>
|
|
createFlush({
|
|
lifecycle: { onFailed },
|
|
dispatch: async (lifecycle) => {
|
|
attempt += 1;
|
|
if (attempt === 1) {
|
|
throw sessionError;
|
|
}
|
|
await lifecycle.onAdopted();
|
|
throw new Error("post-adoption failure");
|
|
},
|
|
}),
|
|
onError,
|
|
});
|
|
|
|
await expect(debouncer.enqueue({ key: "a", id: "failed-before-admission" })).resolves.toBe(
|
|
undefined,
|
|
);
|
|
await expect(debouncer.enqueue({ key: "a", id: "failed-after-admission" })).resolves.toBe(
|
|
undefined,
|
|
);
|
|
await debouncer.drain();
|
|
|
|
expect(onFailed).toHaveBeenCalledOnce();
|
|
expect(onFailed).toHaveBeenCalledWith(sessionError);
|
|
expect(onError).toHaveBeenCalledTimes(2);
|
|
expect(onError.mock.calls[0]?.[0]).toBe(sessionError);
|
|
});
|
|
|
|
it("drains same-key flushes queued before their completion is tracked", async () => {
|
|
const started: string[] = [];
|
|
let releaseFirst!: () => void;
|
|
let releaseSecond!: () => void;
|
|
const firstCompletion = new Promise<void>((resolve) => {
|
|
releaseFirst = resolve;
|
|
});
|
|
const secondCompletion = new Promise<void>((resolve) => {
|
|
releaseSecond = resolve;
|
|
});
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items, createFlush) =>
|
|
createFlush({
|
|
dispatch: async () => {
|
|
const id = items[0]?.id ?? "";
|
|
started.push(id);
|
|
await (id === "1" ? firstCompletion : secondCompletion);
|
|
},
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
const firstFlush = debouncer.flushKey("a");
|
|
await vi.waitFor(() => expect(started).toEqual(["1"]));
|
|
await debouncer.enqueue({ key: "a", id: "2" });
|
|
const secondFlush = debouncer.flushKey("a");
|
|
|
|
let drained = false;
|
|
const drain = debouncer.drain().then(() => {
|
|
drained = true;
|
|
});
|
|
releaseFirst();
|
|
await vi.waitFor(() => expect(started).toEqual(["1", "2"]));
|
|
expect(drained).toBe(false);
|
|
|
|
releaseSecond();
|
|
await Promise.all([firstFlush, secondFlush, drain]);
|
|
expect(drained).toBe(true);
|
|
});
|
|
|
|
it("swallows onError failures so keyed chains still complete", async () => {
|
|
const calls: string[] = [];
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 0,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items[0]?.id ?? "");
|
|
throw new Error("flush failed");
|
|
}),
|
|
onError: () => {
|
|
throw new Error("handler failed");
|
|
},
|
|
});
|
|
|
|
await expect(debouncer.enqueue({ key: "a", id: "1" })).resolves.toBeUndefined();
|
|
await expect(debouncer.enqueue({ key: "a", id: "2" })).resolves.toBeUndefined();
|
|
|
|
expect(calls).toEqual(["1", "2"]);
|
|
});
|
|
|
|
it("releases serialized keys when custom completion rejects before admission", async () => {
|
|
const failure = new Error("custom flush failed");
|
|
const calls: string[] = [];
|
|
const reported: unknown[] = [];
|
|
const pendingAdmission = new Promise<void>(() => {});
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 0,
|
|
serializeImmediate: true,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) => {
|
|
const id = items[0]?.id ?? "";
|
|
calls.push(id);
|
|
if (id === "first") {
|
|
return { admission: pendingAdmission, completion: Promise.reject(failure) };
|
|
}
|
|
return flushOnCompletion(() => {});
|
|
},
|
|
onError: (error) => {
|
|
reported.push(error);
|
|
throw new Error("observer failed");
|
|
},
|
|
});
|
|
|
|
const first = debouncer.enqueue({ key: "a", id: "first" });
|
|
await vi.waitFor(() => expect(calls).toEqual(["first"]));
|
|
const second = debouncer.enqueue({ key: "a", id: "second" });
|
|
const secondOutcome = await Promise.race([
|
|
second.then(() => "completed" as const),
|
|
new Promise<"stalled">((resolve) => {
|
|
setTimeout(() => resolve("stalled"), 100);
|
|
}),
|
|
]);
|
|
|
|
expect(secondOutcome).toBe("completed");
|
|
await Promise.all([first, second, debouncer.drain()]);
|
|
expect(calls).toEqual(["first", "second"]);
|
|
expect(reported).toEqual([failure]);
|
|
});
|
|
|
|
it("does not leak unhandled rejections when a keyed flush failure is awaited", async () => {
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 0,
|
|
buildKey: (item) => item.key,
|
|
onFlush: () =>
|
|
flushOnCompletion(() => {
|
|
throw new Error("flush failed");
|
|
}),
|
|
});
|
|
const unhandled: unknown[] = [];
|
|
const onUnhandledRejection = (reason: unknown) => {
|
|
unhandled.push(reason);
|
|
};
|
|
process.on("unhandledRejection", onUnhandledRejection);
|
|
|
|
try {
|
|
await expect(debouncer.enqueue({ key: "a", id: "1" })).resolves.toBeUndefined();
|
|
await new Promise<void>((resolve) => {
|
|
setImmediate(resolve);
|
|
});
|
|
expect(unhandled).toStrictEqual([]);
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandledRejection);
|
|
}
|
|
});
|
|
|
|
it("bypasses debouncing for new keys once the tracked-key cap is reached", async () => {
|
|
vi.useFakeTimers();
|
|
const calls: Array<string[]> = [];
|
|
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
maxTrackedKeys: 1,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(() => {
|
|
calls.push(items.map((entry) => entry.id));
|
|
}),
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
await debouncer.enqueue({ key: "b", id: "2" });
|
|
|
|
expect(calls).toEqual([["2"]]);
|
|
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
expect(calls).toEqual([["2"], ["1"]]);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("keeps same-key overflow work ordered after falling back to immediate flushes", async () => {
|
|
const started: string[] = [];
|
|
const finished: string[] = [];
|
|
let releaseOverflow: (() => void) | undefined;
|
|
const overflowGate = new Promise<void>((resolve) => {
|
|
releaseOverflow = resolve;
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
maxTrackedKeys: 1,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const ids = items.map((entry) => entry.id).join(",");
|
|
started.push(ids);
|
|
if (ids === "2") {
|
|
await overflowGate;
|
|
}
|
|
finished.push(ids);
|
|
}),
|
|
});
|
|
|
|
try {
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
const callCountBeforeOverflow = setTimeoutSpy.mock.calls.length;
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[callCountBeforeOverflow - 1]?.value as ReturnType<
|
|
typeof setTimeout
|
|
>,
|
|
);
|
|
|
|
const overflowEnqueue = debouncer.enqueue({ key: "b", id: "2" });
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["2"]);
|
|
});
|
|
|
|
const bufferedEnqueue = debouncer.enqueue({ key: "b", id: "3" });
|
|
const bufferedTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
|
(call, index) => index >= callCountBeforeOverflow && call[1] === 50,
|
|
);
|
|
expect(bufferedTimerIndex).toBeGreaterThanOrEqual(callCountBeforeOverflow);
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[bufferedTimerIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
(setTimeoutSpy.mock.calls[bufferedTimerIndex]?.[0] as (() => void) | undefined)?.();
|
|
|
|
await Promise.resolve();
|
|
expect(started).toEqual(["2"]);
|
|
expect(finished).toStrictEqual([]);
|
|
|
|
if (!releaseOverflow) {
|
|
throw new Error("Expected inbound overflow release callback to be initialized");
|
|
}
|
|
releaseOverflow();
|
|
await Promise.all([overflowEnqueue, bufferedEnqueue]);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["2", "3"]);
|
|
expect(finished).toEqual(["2", "3"]);
|
|
});
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it("counts tracked debounce keys by union of buffers and active chains", async () => {
|
|
const started: string[] = [];
|
|
const finished: string[] = [];
|
|
let releaseChainOnly: (() => void) | undefined;
|
|
const chainOnlyGate = new Promise<void>((resolve) => {
|
|
releaseChainOnly = resolve;
|
|
});
|
|
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
|
|
debounceMs: 50,
|
|
maxTrackedKeys: 3,
|
|
buildKey: (item) => item.key,
|
|
onFlush: (items) =>
|
|
flushOnCompletion(async () => {
|
|
const ids = items.map((entry) => entry.id).join(",");
|
|
started.push(ids);
|
|
if (ids === "2") {
|
|
await chainOnlyGate;
|
|
}
|
|
finished.push(ids);
|
|
}),
|
|
});
|
|
|
|
try {
|
|
await debouncer.enqueue({ key: "a", id: "1" });
|
|
const firstTimerIndex = setTimeoutSpy.mock.calls.findLastIndex((call) => call[1] === 50);
|
|
expect(firstTimerIndex).toBeGreaterThanOrEqual(0);
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[firstTimerIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
|
|
await debouncer.enqueue({ key: "b", id: "2" });
|
|
const secondTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
|
(call, index) => index > firstTimerIndex && call[1] === 50,
|
|
);
|
|
expect(secondTimerIndex).toBeGreaterThan(firstTimerIndex);
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[secondTimerIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
const secondFlush = (
|
|
setTimeoutSpy.mock.calls[secondTimerIndex]?.[0] as (() => Promise<void>) | undefined
|
|
)?.();
|
|
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["2"]);
|
|
});
|
|
|
|
await debouncer.enqueue({ key: "c", id: "3" });
|
|
const timerCountBeforeOverflow = setTimeoutSpy.mock.calls.length;
|
|
const thirdTimerIndex = setTimeoutSpy.mock.calls.findLastIndex(
|
|
(call, index) => index > secondTimerIndex && call[1] === 50,
|
|
);
|
|
expect(thirdTimerIndex).toBeGreaterThan(secondTimerIndex);
|
|
clearTimeout(
|
|
setTimeoutSpy.mock.results[thirdTimerIndex]?.value as ReturnType<typeof setTimeout>,
|
|
);
|
|
|
|
const overflowEnqueue = debouncer.enqueue({ key: "d", id: "4" });
|
|
|
|
expect(setTimeoutSpy.mock.calls).toHaveLength(timerCountBeforeOverflow);
|
|
await vi.waitFor(() => {
|
|
expect(started).toEqual(["2", "4"]);
|
|
expect(finished).toEqual(["4"]);
|
|
});
|
|
|
|
if (!releaseChainOnly) {
|
|
throw new Error("Expected inbound chain-only release callback to be initialized");
|
|
}
|
|
releaseChainOnly();
|
|
await Promise.all([secondFlush, overflowEnqueue]);
|
|
expect(finished).toEqual(["4", "2"]);
|
|
} finally {
|
|
setTimeoutSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
const senderMetaTempDirs = createSuiteTempRootTracker({
|
|
prefix: "openclaw-sender-meta-",
|
|
});
|
|
|
|
describe("initSessionState BodyStripped", () => {
|
|
beforeAll(async () => {
|
|
await senderMetaTempDirs.setup();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await senderMetaTempDirs.cleanup();
|
|
});
|
|
|
|
it("prefers BodyForAgent over Body for group chats", async () => {
|
|
const root = await senderMetaTempDirs.make("group");
|
|
const storePath = path.join(root, "sessions.json");
|
|
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
|
|
|
const result = await initSessionState({
|
|
ctx: finalizeInboundContext({
|
|
Body: "[WhatsApp 123@g.us] ping",
|
|
BodyForAgent: "ping",
|
|
ChatType: "group",
|
|
SenderName: "Bob",
|
|
SenderE164: "+222",
|
|
SenderId: "222@s.whatsapp.net",
|
|
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
|
}),
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(result.sessionCtx.BodyStripped).toBe("ping");
|
|
});
|
|
|
|
it("prefers BodyForAgent over Body for direct chats", async () => {
|
|
const root = await senderMetaTempDirs.make("direct");
|
|
const storePath = path.join(root, "sessions.json");
|
|
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
|
|
|
const result = await initSessionState({
|
|
ctx: finalizeInboundContext({
|
|
Body: "[WhatsApp +1] ping",
|
|
BodyForAgent: "ping",
|
|
ChatType: "direct",
|
|
SenderName: "Bob",
|
|
SenderE164: "+222",
|
|
SessionKey: "agent:main:whatsapp:dm:+222",
|
|
}),
|
|
cfg,
|
|
commandAuthorized: true,
|
|
});
|
|
|
|
expect(result.sessionCtx.BodyStripped).toBe("ping");
|
|
});
|
|
});
|
|
|
|
describe("mention helpers", () => {
|
|
it("builds regexes and skips invalid or unsafe patterns", () => {
|
|
const regexes = buildMentionRegexes({
|
|
messages: {
|
|
groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(invalid", "(a+)+$"] },
|
|
},
|
|
});
|
|
expect(regexes).toHaveLength(1);
|
|
expect(regexes[0]?.test("openclaw")).toBe(true);
|
|
});
|
|
|
|
it("normalizes zero-width characters", () => {
|
|
expect(normalizeMentionText("open\u200bclaw")).toBe("openclaw");
|
|
});
|
|
|
|
it("matches patterns case-insensitively", () => {
|
|
const regexes = buildMentionRegexes({
|
|
messages: { groupChat: { mentionPatterns: ["\\bopenclaw\\b"] } },
|
|
});
|
|
expect(matchesMentionPatterns("OPENCLAW: hi", regexes)).toBe(true);
|
|
});
|
|
|
|
it("lets catch-all mention patterns match empty text", () => {
|
|
const catchAllRegexes = buildMentionRegexes({
|
|
messages: { groupChat: { mentionPatterns: [".*"] } },
|
|
});
|
|
const specificRegexes = buildMentionRegexes({
|
|
messages: { groupChat: { mentionPatterns: ["\\bopenclaw\\b"] } },
|
|
});
|
|
|
|
expect(matchesMentionPatterns("", catchAllRegexes)).toBe(true);
|
|
expect(matchesMentionPatterns("", specificRegexes)).toBe(false);
|
|
});
|
|
|
|
it("uses per-agent mention patterns when configured", () => {
|
|
const regexes = buildMentionRegexes(
|
|
{
|
|
messages: {
|
|
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
|
|
},
|
|
agents: {
|
|
list: [
|
|
{
|
|
id: "work",
|
|
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
"work",
|
|
);
|
|
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
|
|
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
|
|
});
|
|
|
|
it("scopes configured mention patterns by provider conversation policy", () => {
|
|
const cfg = {
|
|
messages: {
|
|
groupChat: {
|
|
mentionPatterns: ["\\bopenclaw\\b"],
|
|
},
|
|
},
|
|
channels: {
|
|
slack: {
|
|
mentionPatterns: {
|
|
mode: "deny",
|
|
allowIn: ["C123"],
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
const allowed = buildMentionRegexes(cfg, undefined, {
|
|
provider: "slack",
|
|
conversationId: "C123",
|
|
});
|
|
const denied = buildMentionRegexes(cfg, undefined, {
|
|
provider: "slack",
|
|
conversationId: "C999",
|
|
});
|
|
|
|
expect(matchesMentionPatterns("openclaw: hi", allowed)).toBe(true);
|
|
expect(matchesMentionPatterns("openclaw: hi", denied)).toBe(false);
|
|
});
|
|
|
|
it("preserves mention patterns for callers without scoped policy facts", () => {
|
|
const regexes = buildMentionRegexes({
|
|
messages: {
|
|
groupChat: {
|
|
mentionPatterns: ["\\bopenclaw\\b"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(matchesMentionPatterns("openclaw", regexes)).toBe(true);
|
|
});
|
|
|
|
it("lets provider deny lists override globally allowed mention patterns", () => {
|
|
const cfg = {
|
|
messages: {
|
|
groupChat: {
|
|
mentionPatterns: ["\\bopenclaw\\b"],
|
|
},
|
|
},
|
|
channels: {
|
|
telegram: {
|
|
mentionPatterns: {
|
|
denyIn: ["-100:topic:7"],
|
|
},
|
|
},
|
|
},
|
|
} satisfies OpenClawConfig;
|
|
|
|
expect(
|
|
buildMentionRegexes(cfg, undefined, {
|
|
provider: "telegram",
|
|
conversationId: "-100:topic:7",
|
|
}),
|
|
).toEqual([]);
|
|
expect(
|
|
matchesMentionPatterns(
|
|
"openclaw",
|
|
buildMentionRegexes(cfg, undefined, {
|
|
provider: "telegram",
|
|
conversationId: "-100:topic:8",
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("strips safe mention patterns and ignores unsafe ones", () => {
|
|
const stripped = stripMentions("openclaw " + "a".repeat(28) + "!", {} as MsgContext, {
|
|
messages: {
|
|
groupChat: { mentionPatterns: ["\\bopenclaw\\b", "(a+)+$"] },
|
|
},
|
|
});
|
|
expect(stripped).toBe(`${"a".repeat(28)}!`);
|
|
});
|
|
|
|
it("strips provider mention regexes without config compilation", () => {
|
|
const stripped = stripMentions("<@12345> hello", { Provider: "discord" } as MsgContext, {});
|
|
expect(stripped).toBe("< > hello");
|
|
});
|
|
});
|
|
|
|
describe("resolveGroupRequireMention", () => {
|
|
beforeEach(() => {
|
|
resetPluginRuntimeStateForTest();
|
|
installGroupRequireMentionTestPlugins();
|
|
});
|
|
|
|
it("respects Discord guild/channel requireMention settings", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
discord: {
|
|
guilds: {
|
|
"145": {
|
|
channels: {
|
|
"123": { requireMention: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "discord",
|
|
From: "discord:group:123",
|
|
GroupChannel: "#general",
|
|
GroupSpace: "145",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "discord:group:123",
|
|
channel: "discord",
|
|
id: "123",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
|
});
|
|
|
|
it("respects Slack channel requireMention settings", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
slack: {
|
|
channels: {
|
|
C123: { requireMention: false },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "slack",
|
|
From: "slack:channel:C123",
|
|
GroupSubject: "#general",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "slack:group:C123",
|
|
channel: "slack",
|
|
id: "C123",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
|
});
|
|
|
|
it("uses Slack fallback resolver semantics for default-account wildcard channels", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
slack: {
|
|
defaultAccount: "work",
|
|
accounts: {
|
|
work: {
|
|
channels: {
|
|
"*": { requireMention: false },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "slack",
|
|
From: "slack:channel:C123",
|
|
GroupSubject: "#alerts",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "slack:group:C123",
|
|
channel: "slack",
|
|
id: "C123",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
|
});
|
|
|
|
it("uses Discord fallback resolver semantics for guild slug matches", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
discord: {
|
|
guilds: {
|
|
"145": {
|
|
slug: "dev",
|
|
requireMention: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "discord",
|
|
From: "discord:group:123",
|
|
GroupChannel: "#general",
|
|
GroupSpace: "dev",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "discord:group:123",
|
|
channel: "discord",
|
|
id: "123",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
|
});
|
|
|
|
it("keeps core reply-stage resolution aligned for Discord slug + wildcard guild fallbacks", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
discord: {
|
|
guilds: {
|
|
"*": {
|
|
requireMention: false,
|
|
channels: {
|
|
help: { requireMention: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "discord",
|
|
From: "discord:group:999",
|
|
GroupChannel: "#help",
|
|
GroupSpace: "guild-slug",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "discord:group:999",
|
|
channel: "discord",
|
|
id: "999",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
|
|
});
|
|
|
|
it("respects LINE prefixed group keys in reply-stage requireMention resolution", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
line: {
|
|
groups: {
|
|
r123: { requireMention: false },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "line",
|
|
From: "line:room:r123",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "line:group:r123",
|
|
channel: "line",
|
|
id: "r123",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
|
});
|
|
|
|
it("preserves plugin-backed channel requireMention resolution", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
imessage: {
|
|
groups: {
|
|
"chat:primary": { requireMention: false },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const ctx: TemplateContext = {
|
|
Provider: "imessage",
|
|
From: "imessage:group:chat:primary",
|
|
};
|
|
const groupResolution: GroupKeyResolution = {
|
|
key: "imessage:group:chat:primary",
|
|
channel: "imessage",
|
|
id: "chat:primary",
|
|
chatType: "group",
|
|
};
|
|
|
|
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|