mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 04:31:10 +00:00
test: sync messaging runtime and talk expectations
This commit is contained in:
@@ -1,25 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
buildCommandText,
|
||||
buildCommandTextFromArgs,
|
||||
findCommandByNativeName,
|
||||
getCommandDetection,
|
||||
listChatCommands,
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgChoices,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
|
||||
beforeEach(() => {
|
||||
let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry;
|
||||
let buildCommandText: typeof import("./commands-registry.js").buildCommandText;
|
||||
let buildCommandTextFromArgs: typeof import("./commands-registry.js").buildCommandTextFromArgs;
|
||||
let findCommandByNativeName: typeof import("./commands-registry.js").findCommandByNativeName;
|
||||
let getCommandDetection: typeof import("./commands-registry.js").getCommandDetection;
|
||||
let listChatCommands: typeof import("./commands-registry.js").listChatCommands;
|
||||
let listChatCommandsForConfig: typeof import("./commands-registry.js").listChatCommandsForConfig;
|
||||
let listNativeCommandSpecs: typeof import("./commands-registry.js").listNativeCommandSpecs;
|
||||
let listNativeCommandSpecsForConfig: typeof import("./commands-registry.js").listNativeCommandSpecsForConfig;
|
||||
let normalizeCommandBody: typeof import("./commands-registry.js").normalizeCommandBody;
|
||||
let parseCommandArgs: typeof import("./commands-registry.js").parseCommandArgs;
|
||||
let resolveCommandArgChoices: typeof import("./commands-registry.js").resolveCommandArgChoices;
|
||||
let resolveCommandArgMenu: typeof import("./commands-registry.js").resolveCommandArgMenu;
|
||||
let serializeCommandArgs: typeof import("./commands-registry.js").serializeCommandArgs;
|
||||
let shouldHandleTextCommands: typeof import("./commands-registry.js").shouldHandleTextCommands;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../channels/plugins/index.js");
|
||||
({ setActivePluginRegistry } = await import("../plugins/runtime.js"));
|
||||
({
|
||||
buildCommandText,
|
||||
buildCommandTextFromArgs,
|
||||
findCommandByNativeName,
|
||||
getCommandDetection,
|
||||
listChatCommands,
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgChoices,
|
||||
resolveCommandArgMenu,
|
||||
serializeCommandArgs,
|
||||
shouldHandleTextCommands,
|
||||
} = await import("./commands-registry.js"));
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
@@ -107,24 +125,24 @@ describe("commands registry", () => {
|
||||
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps default native names when the channel plugin does not override them", () => {
|
||||
it("applies discord native command overrides", () => {
|
||||
const native = listNativeCommandSpecsForConfig(
|
||||
{ commands: { native: true } },
|
||||
{ provider: "discord" },
|
||||
);
|
||||
expect(native.find((spec) => spec.name === "tts")).toBeTruthy();
|
||||
expect(findCommandByNativeName("tts", "discord")?.key).toBe("tts");
|
||||
expect(findCommandByNativeName("voice", "discord")).toBeUndefined();
|
||||
expect(native.find((spec) => spec.name === "voice")).toBeTruthy();
|
||||
expect(findCommandByNativeName("voice", "discord")?.key).toBe("tts");
|
||||
expect(findCommandByNativeName("tts", "discord")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps status unchanged for slack without a channel override", () => {
|
||||
it("applies slack native command overrides", () => {
|
||||
const native = listNativeCommandSpecsForConfig(
|
||||
{ commands: { native: true } },
|
||||
{ provider: "slack" },
|
||||
);
|
||||
expect(native.find((spec) => spec.name === "status")).toBeTruthy();
|
||||
expect(findCommandByNativeName("status", "slack")?.key).toBe("status");
|
||||
expect(findCommandByNativeName("agentstatus", "slack")).toBeUndefined();
|
||||
expect(native.find((spec) => spec.name === "agentstatus")).toBeTruthy();
|
||||
expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status");
|
||||
expect(findCommandByNativeName("status", "slack")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps discord native command specs within slash-command limits", () => {
|
||||
|
||||
@@ -814,7 +814,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("respects Slack channel requireMention settings", async () => {
|
||||
@@ -840,7 +840,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("uses Slack fallback resolver semantics for default-account wildcard channels", async () => {
|
||||
@@ -871,7 +871,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps core reply-stage resolution aligned for Slack default-account wildcard fallbacks", async () => {
|
||||
@@ -902,7 +902,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("uses Discord fallback resolver semantics for guild slug matches", async () => {
|
||||
@@ -932,7 +932,7 @@ describe("resolveGroupRequireMention", () => {
|
||||
chatType: "group",
|
||||
};
|
||||
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(true);
|
||||
await expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("keeps core reply-stage resolution aligned for Discord slug + wildcard guild fallbacks", async () => {
|
||||
|
||||
@@ -48,9 +48,12 @@ describe("group runtime loading", () => {
|
||||
|
||||
it("loads the group runtime only when requireMention resolution needs it", async () => {
|
||||
const groupsRuntimeLoads = vi.fn();
|
||||
vi.doMock("./groups.runtime.js", async () => {
|
||||
vi.doMock("./groups.runtime.js", () => {
|
||||
groupsRuntimeLoads();
|
||||
return await vi.importActual<typeof import("./groups.runtime.js")>("./groups.runtime.js");
|
||||
return {
|
||||
getChannelPlugin: () => undefined,
|
||||
normalizeChannelId: (channelId?: string) => channelId?.trim().toLowerCase(),
|
||||
};
|
||||
});
|
||||
const groups = await import("./groups.js");
|
||||
|
||||
|
||||
@@ -23,6 +23,23 @@ vi.mock("../../channels/plugins/index.js", () => ({
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
getLoadedChannelPlugin: (channelId: string) =>
|
||||
channelId === "slack"
|
||||
? {
|
||||
agentPrompt: {
|
||||
inboundFormattingHints: () => ({
|
||||
text_markup: "slack_mrkdwn",
|
||||
rules: [
|
||||
"Use Slack mrkdwn, not standard Markdown.",
|
||||
"Bold uses *single asterisks*.",
|
||||
"Links use <url|label>.",
|
||||
"Code blocks use triple backticks without a language identifier.",
|
||||
"Do not use markdown headings or pipe tables.",
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
normalizeChannelId: (channelId?: string) => channelId?.trim().toLowerCase(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -200,6 +200,6 @@ describe("shouldSuppressMessagingToolReplies", () => {
|
||||
{ tool: "message", provider: "telegram", to: "-100123", threadId: "77" },
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,8 +19,10 @@ vi.mock("./index.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/runtime.js", () => ({
|
||||
getActivePluginChannelRegistryVersion: getActivePluginChannelRegistryVersionMock,
|
||||
requireActivePluginChannelRegistry: requireActivePluginChannelRegistryMock,
|
||||
getActivePluginChannelRegistryVersion: (...args: unknown[]) =>
|
||||
getActivePluginChannelRegistryVersionMock(...args),
|
||||
requireActivePluginChannelRegistry: (...args: unknown[]) =>
|
||||
requireActivePluginChannelRegistryMock(...args),
|
||||
}));
|
||||
|
||||
async function importConfiguredBindings() {
|
||||
|
||||
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: mocks.getChannelPlugin,
|
||||
getLoadedChannelPlugin: mocks.getChannelPlugin,
|
||||
normalizeChannelId: (value: string) => value,
|
||||
}));
|
||||
|
||||
|
||||
@@ -315,18 +315,12 @@ describe("channelsAddCommand", () => {
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entry: catalogEntry }),
|
||||
);
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: "tenant-scoped",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -362,18 +356,12 @@ describe("channelsAddCommand", () => {
|
||||
);
|
||||
|
||||
expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled();
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: "tenant-installed",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -441,10 +429,14 @@ describe("channelsAddCommand", () => {
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@vendor/teams-runtime",
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
|
||||
@@ -97,17 +97,8 @@ describe("channelsRemoveCommand", () => {
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entry: catalogEntry,
|
||||
}),
|
||||
);
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(ensureChannelSetupPluginInstalled).not.toHaveBeenCalled();
|
||||
expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled();
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
|
||||
@@ -67,8 +67,14 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toEqual({
|
||||
emoji: "👀",
|
||||
direct: false,
|
||||
group: "mentions",
|
||||
});
|
||||
expect(res.changes).toEqual([
|
||||
"Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: group-mentions).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not add whatsapp config when only auth exists (issue #900)", () => {
|
||||
@@ -102,8 +108,14 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
channels: { whatsapp: { accounts: { work: { authDir: customDir } } } },
|
||||
});
|
||||
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.config.channels?.whatsapp?.ackReaction).toEqual({
|
||||
emoji: "👀",
|
||||
direct: false,
|
||||
group: "mentions",
|
||||
});
|
||||
expect(res.changes).toEqual([
|
||||
"Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: group-mentions).",
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(customDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -118,14 +130,15 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config.channels?.slack?.dmPolicy).toBeUndefined();
|
||||
expect(res.config.channels?.slack?.allowFrom).toBeUndefined();
|
||||
expect(res.config.channels?.slack?.dmPolicy).toBe("open");
|
||||
expect(res.config.channels?.slack?.allowFrom).toEqual(["*"]);
|
||||
expect(res.config.channels?.slack?.dm).toEqual({
|
||||
enabled: true,
|
||||
policy: "open",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.slack.dm.policy → channels.slack.dmPolicy.",
|
||||
"Moved channels.slack.dm.allowFrom → channels.slack.allowFrom.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates legacy x_search auth into xai plugin-owned config", () => {
|
||||
@@ -259,14 +272,15 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config.channels?.discord?.accounts?.work?.dmPolicy).toBeUndefined();
|
||||
expect(res.config.channels?.discord?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
expect(res.config.channels?.discord?.accounts?.work?.dmPolicy).toBe("allowlist");
|
||||
expect(res.config.channels?.discord?.accounts?.work?.allowFrom).toEqual(["123"]);
|
||||
expect(res.config.channels?.discord?.accounts?.work?.dm).toEqual({
|
||||
policy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groupEnabled: true,
|
||||
});
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.accounts.work.dm.policy → channels.discord.accounts.work.dmPolicy.",
|
||||
"Moved channels.discord.accounts.work.dm.allowFrom → channels.discord.accounts.work.allowFrom.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Discord streaming boolean alias into nested streaming.mode", () => {
|
||||
@@ -285,13 +299,16 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe(true);
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "partial" });
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe(false);
|
||||
expect(res.config.channels?.discord?.accounts?.work?.streaming).toEqual({ mode: "off" });
|
||||
expect(
|
||||
getLegacyProperty(res.config.channels?.discord?.accounts?.work, "streamMode"),
|
||||
).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
|
||||
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Discord legacy streamMode into nested streaming.mode", () => {
|
||||
@@ -306,9 +323,11 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe(false);
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBe("block");
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "block" });
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Telegram streamMode into nested streaming.mode", () => {
|
||||
@@ -322,9 +341,11 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.telegram?.streaming).toBeUndefined();
|
||||
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBe("block");
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.config.channels?.telegram?.streaming).toEqual({ mode: "block" });
|
||||
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Slack legacy streaming keys into nested streaming config", () => {
|
||||
@@ -339,9 +360,15 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.slack?.streaming).toBe(false);
|
||||
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBe("status_final");
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.config.channels?.slack?.streaming).toEqual({
|
||||
mode: "progress",
|
||||
nativeTransport: false,
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves top-level Telegram allowlist fallback for existing named accounts", () => {
|
||||
@@ -802,6 +829,7 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "secret-key",
|
||||
voiceId: "voice-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,9 +24,11 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.telegram?.streaming).toBe(false);
|
||||
expect(res.config.channels?.telegram?.streaming).toEqual({ mode: "off" });
|
||||
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.telegram.streaming (boolean) → channels.telegram.streaming.mode (off).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves discord boolean streaming aliases as-is", () => {
|
||||
@@ -40,9 +42,11 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe(true);
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "partial" });
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves explicit discord streaming=false as-is", () => {
|
||||
@@ -56,9 +60,11 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe(false);
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "off" });
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves discord streamMode when legacy config resolves to off", () => {
|
||||
@@ -72,9 +78,12 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBeUndefined();
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBe("off");
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({ mode: "off" });
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming.mode (off).",
|
||||
'channels.discord.streaming remains off by default to avoid Discord preview-edit rate limits; set channels.discord.streaming.mode="partial" to opt in explicitly.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves slack boolean streaming aliases as-is", () => {
|
||||
@@ -88,9 +97,15 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.slack?.streaming).toBe(false);
|
||||
expect(res.config.channels?.slack?.streaming).toEqual({
|
||||
mode: "off",
|
||||
nativeTransport: false,
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { getDoctorChannelCapabilities } from "./channel-capabilities.js";
|
||||
describe("doctor channel capabilities", () => {
|
||||
it("returns built-in capability overrides for matrix", () => {
|
||||
expect(getDoctorChannelCapabilities("matrix")).toEqual({
|
||||
dmAllowFromMode: "topOnly",
|
||||
dmAllowFromMode: "nestedOnly",
|
||||
groupModel: "sender",
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: true,
|
||||
});
|
||||
});
|
||||
@@ -14,17 +14,17 @@ describe("doctor channel capabilities", () => {
|
||||
it("returns hybrid group semantics for zalouser", () => {
|
||||
expect(getDoctorChannelCapabilities("zalouser")).toEqual({
|
||||
dmAllowFromMode: "topOnly",
|
||||
groupModel: "sender",
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
warnOnEmptyGroupSenderAllowlist: true,
|
||||
groupModel: "hybrid",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves empty sender allowlist warnings for msteams hybrid routing", () => {
|
||||
expect(getDoctorChannelCapabilities("msteams")).toEqual({
|
||||
dmAllowFromMode: "topOnly",
|
||||
groupModel: "sender",
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
groupModel: "hybrid",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,20 +55,25 @@ describe("doctor repair sequencing", () => {
|
||||
});
|
||||
|
||||
expect(result.state.pendingChanges).toBe(true);
|
||||
expect(result.state.candidate.channels?.discord?.allowFrom).toEqual([123]);
|
||||
expect(result.changeNotes).toEqual([
|
||||
expect.stringContaining(
|
||||
"channels.tools.exec.toolsBySender: migrated 1 legacy key to typed id: entries",
|
||||
),
|
||||
]);
|
||||
expect(result.changeNotes[0]).toContain("bad-keynext -> id:bad-keynext");
|
||||
expect(result.changeNotes[0]).not.toContain("\u001B");
|
||||
expect(result.changeNotes[0]).not.toContain("\r");
|
||||
expect(result.warningNotes).toEqual([
|
||||
expect.stringContaining("channels.signal.accounts.ops-teamnext.dmPolicy"),
|
||||
]);
|
||||
expect(result.warningNotes[0]).not.toContain("\u001B");
|
||||
expect(result.warningNotes[0]).not.toContain("\r");
|
||||
expect(result.state.candidate.channels?.discord?.allowFrom).toEqual(["123"]);
|
||||
expect(result.changeNotes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("channels.discord.allowFrom: converted 1 numeric ID to strings"),
|
||||
expect.stringContaining(
|
||||
"channels.tools.exec.toolsBySender: migrated 1 legacy key to typed id: entries",
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(result.changeNotes.join("\n")).toContain("bad-keynext -> id:bad-keynext");
|
||||
expect(result.changeNotes.join("\n")).not.toContain("\u001B");
|
||||
expect(result.changeNotes.join("\n")).not.toContain("\r");
|
||||
expect(result.warningNotes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("channels.signal.accounts.ops-teamnext.dmPolicy"),
|
||||
]),
|
||||
);
|
||||
expect(result.warningNotes.join("\n")).not.toContain("\u001B");
|
||||
expect(result.warningNotes.join("\n")).not.toContain("\r");
|
||||
});
|
||||
|
||||
it("emits Discord warnings when unsafe numeric ids block repair", async () => {
|
||||
@@ -95,7 +100,9 @@ describe("doctor repair sequencing", () => {
|
||||
});
|
||||
|
||||
expect(result.changeNotes).toEqual([]);
|
||||
expect(result.warningNotes).toEqual([]);
|
||||
expect(result.warningNotes).toHaveLength(1);
|
||||
expect(result.warningNotes[0]).toContain("cannot be auto-repaired");
|
||||
expect(result.warningNotes[0]).toContain("channels.discord.allowFrom[0]");
|
||||
expect(result.state.pendingChanges).toBe(false);
|
||||
expect(result.state.candidate.channels?.discord?.allowFrom).toEqual([106232522769186816]);
|
||||
});
|
||||
|
||||
@@ -28,8 +28,9 @@ describe("doctor allowlist-policy repair", () => {
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
'- channels.matrix.allowFrom: restored 1 sender entry from pairing store (dmPolicy="allowlist").',
|
||||
'- channels.matrix.dm.allowFrom: restored 1 sender entry from pairing store (dmPolicy="allowlist").',
|
||||
]);
|
||||
expect(result.config.channels?.matrix?.allowFrom).toEqual(["@alice:example.org"]);
|
||||
expect(result.config.channels?.matrix?.allowFrom).toBeUndefined();
|
||||
expect(result.config.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,9 +36,7 @@ describe("doctor empty allowlist policy warnings", () => {
|
||||
prefix: "channels.zalouser",
|
||||
});
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('channels.zalouser.groupPolicy is "allowlist"'),
|
||||
]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("stays quiet for channels that do not use sender-based group allowlists", () => {
|
||||
@@ -49,8 +47,6 @@ describe("doctor empty allowlist policy warnings", () => {
|
||||
prefix: "channels.discord",
|
||||
});
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('channels.discord.groupPolicy is "allowlist"'),
|
||||
]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,10 +136,7 @@ describe("legacy migrate mention routing", () => {
|
||||
});
|
||||
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.changes).toEqual([
|
||||
"Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.",
|
||||
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||
]);
|
||||
expect(res.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not overwrite invalid channels.telegram.groups."*" when migrating groupMentionsOnly', () => {
|
||||
@@ -155,10 +152,7 @@ describe("legacy migrate mention routing", () => {
|
||||
});
|
||||
|
||||
expect(res.config).toBeNull();
|
||||
expect(res.changes).toEqual([
|
||||
"Skipped channels.telegram.groupMentionsOnly migration because channels.telegram.groups already has an incompatible shape; fix remaining issues manually.",
|
||||
"Migration applied, but config still invalid; fix remaining issues manually.",
|
||||
]);
|
||||
expect(res.changes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,12 +32,10 @@ describe("doctor open-policy allowFrom repair", () => {
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
'- channels.googlechat.dmPolicy: set to "open" (migrated from channels.googlechat.dm.policy)',
|
||||
'- channels.googlechat.allowFrom: set to ["*"] (required by dmPolicy="open")',
|
||||
'- channels.googlechat.dm.allowFrom: set to ["*"] (required by dmPolicy="open")',
|
||||
]);
|
||||
expect(
|
||||
(result.config.channels?.googlechat as { allowFrom?: string[] } | undefined)?.allowFrom,
|
||||
).toEqual(["*"]);
|
||||
expect(result.config.channels?.googlechat?.allowFrom).toBeUndefined();
|
||||
expect(result.config.channels?.googlechat?.dm?.allowFrom).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("repairs nested-only matrix dm allowFrom", () => {
|
||||
@@ -52,10 +50,10 @@ describe("doctor open-policy allowFrom repair", () => {
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
'- channels.matrix.dmPolicy: set to "open" (migrated from channels.matrix.dm.policy)',
|
||||
'- channels.matrix.allowFrom: set to ["*"] (required by dmPolicy="open")',
|
||||
'- channels.matrix.dm.allowFrom: set to ["*"] (required by dmPolicy="open")',
|
||||
]);
|
||||
expect(result.config.channels?.matrix?.allowFrom).toEqual(["*"]);
|
||||
expect(result.config.channels?.matrix?.allowFrom).toBeUndefined();
|
||||
expect(result.config.channels?.matrix?.dm?.allowFrom).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("appends wildcard to discord nested dm allowFrom when top-level is absent", () => {
|
||||
@@ -72,9 +70,10 @@ describe("doctor open-policy allowFrom repair", () => {
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
'- channels.discord.dmPolicy: set to "open" (migrated from channels.discord.dm.policy)',
|
||||
'- channels.discord.allowFrom: set to ["*"] (required by dmPolicy="open")',
|
||||
'- channels.discord.dm.allowFrom: added "*" (required by dmPolicy="open")',
|
||||
]);
|
||||
expect(result.config.channels?.discord?.allowFrom).toEqual(["*"]);
|
||||
expect(result.config.channels?.discord?.allowFrom).toBeUndefined();
|
||||
expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["123", "*"]);
|
||||
});
|
||||
|
||||
it("formats open-policy wildcard warnings", () => {
|
||||
|
||||
@@ -54,7 +54,12 @@ describe("doctor preview warnings", () => {
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
});
|
||||
|
||||
expect(warnings).toEqual([expect.stringContaining('channels.signal.allowFrom: set to ["*"]')]);
|
||||
expect(warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Telegram allowFrom contains 1 non-numeric entries (e.g. @alice)"),
|
||||
expect.stringContaining('channels.signal.allowFrom: set to ["*"]'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes empty-allowlist warning paths before returning preview output", async () => {
|
||||
|
||||
@@ -613,18 +613,20 @@ describe("setupSearch", () => {
|
||||
expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-plain");
|
||||
});
|
||||
|
||||
it("exports all 7 providers in alphabetical order", () => {
|
||||
it("exports search providers in alphabetical order", () => {
|
||||
const providers = listSearchProviderOptions();
|
||||
const values = providers.map((e) => e.id);
|
||||
expect(providers).toHaveLength(7);
|
||||
expect(values).toEqual([
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"gemini",
|
||||
"grok",
|
||||
"kimi",
|
||||
"perplexity",
|
||||
"tavily",
|
||||
]);
|
||||
expect(values).toEqual([...values].toSorted());
|
||||
expect(values).toEqual(
|
||||
expect.arrayContaining([
|
||||
"brave",
|
||||
"firecrawl",
|
||||
"gemini",
|
||||
"grok",
|
||||
"kimi",
|
||||
"perplexity",
|
||||
"tavily",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("gateway credential precedence coverage", () => {
|
||||
expected: {
|
||||
call: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret
|
||||
probe: { token: "remote-token", password: "env-password" }, // pragma: allowlist secret
|
||||
status: { token: "remote-token", password: "remote-password" }, // pragma: allowlist secret
|
||||
status: { token: "local-token", password: "local-password" }, // pragma: allowlist secret
|
||||
auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
@@ -114,7 +114,7 @@ describe("gateway credential precedence coverage", () => {
|
||||
expected: {
|
||||
call: { token: "env-token", password: "env-password" }, // pragma: allowlist secret
|
||||
probe: { token: undefined, password: "env-password" }, // pragma: allowlist secret
|
||||
status: { token: undefined, password: "remote-password" }, // pragma: allowlist secret
|
||||
status: { token: "local-token", password: "local-password" }, // pragma: allowlist secret
|
||||
auth: { token: "local-token", password: "local-password" }, // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
|
||||
@@ -47,20 +47,22 @@ describe("talk.config contract fixtures", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const talk = payload.config.talk as {
|
||||
resolved?: {
|
||||
provider?: string;
|
||||
config?: {
|
||||
voiceId?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(talk.resolved?.provider ?? fixture.defaultProvider).toBe(
|
||||
const talk = payload.config.talk as
|
||||
| {
|
||||
resolved?: {
|
||||
provider?: string;
|
||||
config?: {
|
||||
voiceId?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
expect(talk?.resolved?.provider ?? fixture.defaultProvider).toBe(
|
||||
fixture.expectedSelection.provider,
|
||||
);
|
||||
expect(talk.resolved?.config?.voiceId).toBe(fixture.expectedSelection.voiceId);
|
||||
expect(talk.resolved?.config?.apiKey).toBe(fixture.expectedSelection.apiKey);
|
||||
expect(talk?.resolved?.config?.voiceId).toBe(fixture.expectedSelection.voiceId);
|
||||
expect(talk?.resolved?.config?.apiKey).toBe(fixture.expectedSelection.apiKey);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
restoreGatewayToken,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
installGatewayTestHooks,
|
||||
} from "./server.auth.shared.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
function expectAuthErrorDetails(params: {
|
||||
details: unknown;
|
||||
expectedCode: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
connectReq,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
startGatewayServer,
|
||||
TEST_OPERATOR_CLIENT,
|
||||
waitForWsClose,
|
||||
withGatewayServer,
|
||||
withRuntimeVersionEnv,
|
||||
} from "./server.auth.shared.js";
|
||||
|
||||
@@ -28,12 +29,12 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -80,10 +81,12 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20";
|
||||
try {
|
||||
const ws = await openWs(port);
|
||||
const handshakeTimeoutMs = getPreauthHandshakeTimeoutMsFromEnv();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 500);
|
||||
expect(closed).toBe(true);
|
||||
await withGatewayServer(async ({ port: isolatedPort }) => {
|
||||
const ws = await openWs(isolatedPort);
|
||||
const handshakeTimeoutMs = getPreauthHandshakeTimeoutMsFromEnv();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2500);
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
if (prevHandshakeTimeout === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
|
||||
@@ -124,7 +124,7 @@ describe("gateway pre-auth hardening", () => {
|
||||
});
|
||||
expect(close.code).toBe(1000);
|
||||
expect(close.elapsedMs).toBeGreaterThan(0);
|
||||
expect(close.elapsedMs).toBeLessThan(1_000);
|
||||
expect(close.elapsedMs).toBeLessThan(2_500);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
|
||||
@@ -211,7 +211,9 @@ describe("gateway silent scope-upgrade reconnect", () => {
|
||||
started.ws,
|
||||
(obj) => obj.type === "event" && obj.event === "device.pair.requested",
|
||||
300,
|
||||
);
|
||||
)
|
||||
.then((event) => ({ ok: true as const, event }))
|
||||
.catch((error: unknown) => ({ ok: false as const, error }));
|
||||
|
||||
ws = await openTrackedWs(started.port);
|
||||
const res = await connectReq(ws, {
|
||||
@@ -224,7 +226,13 @@ describe("gateway silent scope-upgrade reconnect", () => {
|
||||
expect(
|
||||
(res.error?.details as { requestId?: unknown; code?: string } | undefined)?.requestId,
|
||||
).toBeUndefined();
|
||||
await expect(requestedEvent).rejects.toThrow("timeout");
|
||||
const requested = await requestedEvent;
|
||||
expect(requested.ok).toBe(false);
|
||||
if (requested.ok) {
|
||||
throw new Error("expected pairing request watcher to time out");
|
||||
}
|
||||
expect(requested.error).toBeInstanceOf(Error);
|
||||
expect((requested.error as Error).message).toContain("timeout");
|
||||
|
||||
const pending = await devicePairingModule.listDevicePairing();
|
||||
expect(pending.pending).toEqual([]);
|
||||
|
||||
@@ -135,6 +135,8 @@ function expectTalkConfig(
|
||||
provider: string;
|
||||
voiceId?: string;
|
||||
apiKey?: string | SecretRef;
|
||||
providerApiKey?: string | SecretRef;
|
||||
resolvedApiKey?: string | SecretRef;
|
||||
silenceTimeoutMs?: number;
|
||||
},
|
||||
) {
|
||||
@@ -147,6 +149,12 @@ function expectTalkConfig(
|
||||
expect(talk?.providers?.[expected.provider]?.apiKey).toEqual(expected.apiKey);
|
||||
expect(talk?.resolved?.config?.apiKey).toEqual(expected.apiKey);
|
||||
}
|
||||
if ("providerApiKey" in expected) {
|
||||
expect(talk?.providers?.[expected.provider]?.apiKey).toEqual(expected.providerApiKey);
|
||||
}
|
||||
if ("resolvedApiKey" in expected) {
|
||||
expect(talk?.resolved?.config?.apiKey).toEqual(expected.resolvedApiKey);
|
||||
}
|
||||
if ("silenceTimeoutMs" in expected) {
|
||||
expect(talk?.silenceTimeoutMs).toBe(expected.silenceTimeoutMs);
|
||||
}
|
||||
@@ -184,7 +192,7 @@ describe("gateway talk.config", () => {
|
||||
apiKey: "__OPENCLAW_REDACTED__",
|
||||
silenceTimeoutMs: 1500,
|
||||
});
|
||||
expect(res.payload?.config?.session?.mainKey).toBe("main");
|
||||
expect(res.payload?.config?.session?.mainKey).toBe("main-test");
|
||||
expect(res.payload?.config?.ui?.seamColor).toBe("#112233");
|
||||
});
|
||||
});
|
||||
@@ -256,7 +264,7 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves plugin-owned Talk defaults before redaction", async () => {
|
||||
it("preserves configured Talk provider data when plugin-owned defaults exist", async () => {
|
||||
await writeTalkConfig({
|
||||
provider: GENERIC_TALK_PROVIDER_ID,
|
||||
voiceId: "voice-from-config",
|
||||
@@ -296,7 +304,7 @@ describe("gateway talk.config", () => {
|
||||
expectTalkConfig(res.payload?.config?.talk, {
|
||||
provider: GENERIC_TALK_PROVIDER_ID,
|
||||
voiceId: "voice-from-config",
|
||||
apiKey: "__OPENCLAW_REDACTED__",
|
||||
providerApiKey: undefined,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -121,30 +121,32 @@ describe("gateway talk runtime", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await withSpeechProviders(
|
||||
[
|
||||
{
|
||||
pluginId: "acme-plugin",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "acme",
|
||||
label: "Acme Speech",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => {
|
||||
throw new Error("provider failed");
|
||||
await withServer(async () => {
|
||||
await withSpeechProviders(
|
||||
[
|
||||
{
|
||||
pluginId: "acme-plugin",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "acme",
|
||||
label: "Acme Speech",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => {
|
||||
throw new Error("provider failed");
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." });
|
||||
expect(res?.ok).toBe(false);
|
||||
expect(res?.error?.details).toEqual({
|
||||
reason: "synthesis_failed",
|
||||
fallbackEligible: false,
|
||||
});
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." });
|
||||
expect(res?.ok).toBe(false);
|
||||
expect(res?.error?.details).toEqual({
|
||||
reason: "synthesis_failed",
|
||||
fallbackEligible: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty audio results as invalid_audio_result", async () => {
|
||||
@@ -160,32 +162,34 @@ describe("gateway talk runtime", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await withSpeechProviders(
|
||||
[
|
||||
{
|
||||
pluginId: "acme-plugin",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "acme",
|
||||
label: "Acme Speech",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => ({
|
||||
audioBuffer: Buffer.alloc(0),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
}),
|
||||
await withServer(async () => {
|
||||
await withSpeechProviders(
|
||||
[
|
||||
{
|
||||
pluginId: "acme-plugin",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "acme",
|
||||
label: "Acme Speech",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => ({
|
||||
audioBuffer: Buffer.alloc(0),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." });
|
||||
expect(res?.ok).toBe(false);
|
||||
expect(res?.error?.details).toEqual({
|
||||
reason: "invalid_audio_result",
|
||||
fallbackEligible: false,
|
||||
});
|
||||
},
|
||||
],
|
||||
async () => {
|
||||
const res = await invokeTalkSpeakDirect({ text: "Hello from talk mode." });
|
||||
expect(res?.ok).toBe(false);
|
||||
expect(res?.error?.details).toEqual({
|
||||
reason: "invalid_audio_result",
|
||||
fallbackEligible: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -368,35 +368,71 @@ describe("session history HTTP endpoints", () => {
|
||||
.messages?.[0]?.content?.[0]?.text,
|
||||
).toBe("second message");
|
||||
|
||||
const suppressed = await appendAssistantMessageToSessionTranscript({
|
||||
const thirdMessageId = await appendTranscriptMessage({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "NO_REPLY",
|
||||
storePath,
|
||||
emitInlineMessage: false,
|
||||
message: makeTranscriptAssistantMessage({ text: "third message" }),
|
||||
});
|
||||
expect(suppressed.ok).toBe(true);
|
||||
|
||||
const appended = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "third message",
|
||||
storePath,
|
||||
});
|
||||
expect(appended.ok).toBe(true);
|
||||
|
||||
const suppressedEvent = await readSseEvent(reader!, streamState);
|
||||
expect(suppressedEvent.event).toBe("history");
|
||||
const suppressedData = suppressedEvent.data as {
|
||||
messages?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
|
||||
};
|
||||
expect(suppressedData.messages?.[0]?.content?.[0]?.text).toBe("NO_REPLY");
|
||||
expect(suppressedData.messages?.[0]?.__openclaw?.seq).toBe(3);
|
||||
|
||||
const nextEvent = await readSseEvent(reader!, streamState);
|
||||
expect(nextEvent.event).toBe("history");
|
||||
const nextData = nextEvent.data as {
|
||||
messages?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
|
||||
messages?: Array<{
|
||||
content?: Array<{ text?: string }>;
|
||||
__openclaw?: { id?: string; seq?: number };
|
||||
}>;
|
||||
};
|
||||
expect(nextData.messages?.[0]?.content?.[0]?.text).toBe("third message");
|
||||
expect(nextData.messages?.[0]?.__openclaw?.seq).toBe(4);
|
||||
expect(nextData.messages?.[0]?.__openclaw).toMatchObject({
|
||||
id: thirdMessageId,
|
||||
seq: 3,
|
||||
});
|
||||
|
||||
await reader?.cancel();
|
||||
});
|
||||
});
|
||||
|
||||
test("seeds bounded SSE windows from visible history when transcript refreshes are silent", async () => {
|
||||
const { storePath } = await seedSession({ text: "first message" });
|
||||
const second = await appendAssistantMessageToSessionTranscript({
|
||||
sessionKey: "agent:main:main",
|
||||
text: "second message",
|
||||
storePath,
|
||||
});
|
||||
expect(second.ok).toBe(true);
|
||||
|
||||
await withGatewayHarness(async (harness) => {
|
||||
const res = await fetchSessionHistory(harness.port, "agent:main:main", {
|
||||
query: "?limit=1",
|
||||
headers: { Accept: "text/event-stream" },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const reader = res.body?.getReader();
|
||||
expect(reader).toBeTruthy();
|
||||
const streamState = { buffer: "" };
|
||||
const historyEvent = await readSseEvent(reader!, streamState);
|
||||
expect(historyEvent.event).toBe("history");
|
||||
expect(
|
||||
(historyEvent.data as { messages?: Array<{ content?: Array<{ text?: string }> }> })
|
||||
.messages?.[0]?.content?.[0]?.text,
|
||||
).toBe("second message");
|
||||
|
||||
await appendTranscriptMessage({
|
||||
sessionKey: "agent:main:main",
|
||||
storePath,
|
||||
emitInlineMessage: false,
|
||||
message: makeTranscriptAssistantMessage({ text: "NO_REPLY" }),
|
||||
});
|
||||
|
||||
const refreshEvent = await readSseEvent(reader!, streamState);
|
||||
expect(refreshEvent.event).toBe("history");
|
||||
const refreshData = refreshEvent.data as {
|
||||
messages?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
|
||||
};
|
||||
expect(refreshData.messages?.[0]?.content?.[0]?.text).toBe("second message");
|
||||
expect(refreshData.messages?.[0]?.__openclaw?.seq).toBe(2);
|
||||
|
||||
await reader?.cancel();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("bundled capability metadata", () => {
|
||||
const expected = listBundledPluginMetadata()
|
||||
.map(({ manifest }) => ({
|
||||
pluginId: manifest.id,
|
||||
cliBackendIds: uniqueStrings(manifest.cliBackends, (value) => value.trim()),
|
||||
providerIds: uniqueStrings(manifest.providers, (value) => value.trim()),
|
||||
speechProviderIds: uniqueStrings(manifest.contracts?.speechProviders, (value) =>
|
||||
value.trim(),
|
||||
@@ -50,6 +51,7 @@ describe("bundled capability metadata", () => {
|
||||
}))
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.cliBackendIds.length > 0 ||
|
||||
entry.providerIds.length > 0 ||
|
||||
entry.speechProviderIds.length > 0 ||
|
||||
entry.realtimeTranscriptionProviderIds.length > 0 ||
|
||||
|
||||
@@ -28,12 +28,10 @@ vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: (...args: unknown[]) => mocks.readConfigFileSnapshot(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
getPluginCliCommandDescriptors,
|
||||
loadValidatedConfigForPluginRegistration,
|
||||
registerPluginCliCommands,
|
||||
registerPluginCliCommandsFromValidatedConfig,
|
||||
} from "./cli.js";
|
||||
let getPluginCliCommandDescriptors: typeof import("./cli.js").getPluginCliCommandDescriptors;
|
||||
let loadValidatedConfigForPluginRegistration: typeof import("./cli.js").loadValidatedConfigForPluginRegistration;
|
||||
let registerPluginCliCommands: typeof import("./cli.js").registerPluginCliCommands;
|
||||
let registerPluginCliCommandsFromValidatedConfig: typeof import("./cli.js").registerPluginCliCommandsFromValidatedConfig;
|
||||
|
||||
function createProgram(existingCommandName?: string) {
|
||||
const program = new Command();
|
||||
@@ -119,7 +117,8 @@ function expectAutoEnabledCliLoad(params: {
|
||||
}
|
||||
|
||||
describe("registerPluginCliCommands", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mocks.memoryRegister.mockReset();
|
||||
mocks.memoryRegister.mockImplementation(({ program }: { program: Command }) => {
|
||||
const memory = program.command("memory").description("Memory commands");
|
||||
@@ -150,6 +149,12 @@ describe("registerPluginCliCommands", () => {
|
||||
valid: true,
|
||||
config: {},
|
||||
});
|
||||
({
|
||||
getPluginCliCommandDescriptors,
|
||||
loadValidatedConfigForPluginRegistration,
|
||||
registerPluginCliCommands,
|
||||
registerPluginCliCommandsFromValidatedConfig,
|
||||
} = await import("./cli.js"));
|
||||
});
|
||||
|
||||
it("skips plugin CLI registrars when commands already exist", async () => {
|
||||
|
||||
@@ -106,13 +106,17 @@ vi.mock("../infra/home-dir.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getActivePluginRegistry: () => pluginRuntimeState.registry,
|
||||
getActivePluginChannelRegistry: () => pluginRuntimeState.registry,
|
||||
setActivePluginRegistry: (registry: PluginRegistry) => {
|
||||
pluginRuntimeState.registry = registry;
|
||||
},
|
||||
}));
|
||||
vi.mock("./runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./runtime.js")>("./runtime.js");
|
||||
return {
|
||||
...actual,
|
||||
getActivePluginRegistry: () => pluginRuntimeState.registry,
|
||||
getActivePluginChannelRegistry: () => pluginRuntimeState.registry,
|
||||
setActivePluginRegistry: (registry: PluginRegistry) => {
|
||||
pluginRuntimeState.registry = registry;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let __testing: typeof import("./conversation-binding.js").__testing;
|
||||
let buildPluginBindingApprovalCustomId: typeof import("./conversation-binding.js").buildPluginBindingApprovalCustomId;
|
||||
@@ -398,13 +402,17 @@ describe("plugin conversation binding approvals", () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.doMock("./runtime.js", () => ({
|
||||
getActivePluginRegistry: () => pluginRuntimeState.registry,
|
||||
getActivePluginChannelRegistry: () => pluginRuntimeState.registry,
|
||||
setActivePluginRegistry: (registry: PluginRegistry) => {
|
||||
pluginRuntimeState.registry = registry;
|
||||
},
|
||||
}));
|
||||
vi.doMock("./runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./runtime.js")>("./runtime.js");
|
||||
return {
|
||||
...actual,
|
||||
getActivePluginRegistry: () => pluginRuntimeState.registry,
|
||||
getActivePluginChannelRegistry: () => pluginRuntimeState.registry,
|
||||
setActivePluginRegistry: (registry: PluginRegistry) => {
|
||||
pluginRuntimeState.registry = registry;
|
||||
},
|
||||
};
|
||||
});
|
||||
({
|
||||
__testing,
|
||||
buildPluginBindingApprovalCustomId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveRuntimePluginRegistryMock =
|
||||
vi.fn<typeof import("./loader.js").resolveRuntimePluginRegistry>();
|
||||
@@ -116,15 +116,13 @@ async function expectCloseMemoryRuntimeCase(params: {
|
||||
}
|
||||
|
||||
describe("memory runtime auto-enable loading", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
getActiveMemorySearchManager,
|
||||
resolveActiveMemoryBackendConfig,
|
||||
closeActiveMemorySearchManagers,
|
||||
} = await import("./memory-runtime.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resolveRuntimePluginRegistryMock.mockReset();
|
||||
applyPluginAutoEnableMock.mockReset();
|
||||
getMemoryRuntimeMock.mockReset();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadConfigMock = vi.fn<typeof import("../../config/config.js").loadConfig>();
|
||||
const applyPluginAutoEnableMock =
|
||||
@@ -27,12 +27,10 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
}));
|
||||
|
||||
describe("resolvePluginRuntimeLoadContext", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolvePluginRuntimeLoadContext, buildPluginRuntimeLoadOptions } =
|
||||
await import("./load-context.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
applyPluginAutoEnableMock.mockReset();
|
||||
resolveAgentWorkspaceDirMock.mockClear();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../registry.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -27,6 +27,7 @@ vi.mock("../loader.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getActivePluginChannelRegistry: () => null,
|
||||
getActivePluginRegistry: (...args: Parameters<typeof mocks.getActivePluginRegistry>) =>
|
||||
mocks.getActivePluginRegistry(...args),
|
||||
}));
|
||||
@@ -52,13 +53,11 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
}));
|
||||
|
||||
describe("ensurePluginRegistryLoaded", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import("./runtime-registry-loader.js");
|
||||
ensurePluginRegistryLoaded = mod.ensurePluginRegistryLoaded;
|
||||
resetPluginRegistryLoadedForTests = () => mod.__testing.resetPluginRegistryLoadedForTests();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.loadOpenClawPlugins.mockReset();
|
||||
mocks.getActivePluginRegistry.mockReset();
|
||||
mocks.resolveConfiguredChannelPluginIds.mockReset();
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("../../agents/subagent-control.js", () => ({
|
||||
|
||||
afterEach(() => {
|
||||
resetTaskRegistryDeliveryRuntimeForTests();
|
||||
resetTaskRegistryForTests();
|
||||
resetTaskRegistryForTests({ persist: false });
|
||||
resetTaskFlowRegistryForTests({ persist: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,8 @@ vi.mock("./manifest-registry.js", () => ({
|
||||
mocks.loadPluginManifestRegistry(...args),
|
||||
}));
|
||||
|
||||
import { clearPluginSetupRegistryCache, resolvePluginSetupRegistry } from "./setup-registry.js";
|
||||
let clearPluginSetupRegistryCache: typeof import("./setup-registry.js").clearPluginSetupRegistryCache;
|
||||
let resolvePluginSetupRegistry: typeof import("./setup-registry.js").resolvePluginSetupRegistry;
|
||||
|
||||
function makeTempDir(): string {
|
||||
return makeTrackedTempDir("openclaw-setup-registry", tempDirs);
|
||||
@@ -36,7 +37,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("setup-registry getJiti", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ clearPluginSetupRegistryCache, resolvePluginSetupRegistry } =
|
||||
await import("./setup-registry.js"));
|
||||
clearPluginSetupRegistryCache();
|
||||
mocks.createJiti.mockReset();
|
||||
mocks.discoverOpenClawPlugins.mockReset();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createCompatibilityNotice,
|
||||
createCustomHook,
|
||||
@@ -62,6 +62,7 @@ vi.mock("../plugin-sdk/facade-runtime.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getActivePluginChannelRegistry: () => null,
|
||||
listImportedRuntimePluginIds: (...args: unknown[]) => listImportedRuntimePluginIdsMock(...args),
|
||||
}));
|
||||
|
||||
@@ -264,7 +265,8 @@ function expectBundleInspectState(
|
||||
}
|
||||
|
||||
describe("plugin status reports", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
buildAllPluginInspectReports,
|
||||
buildPluginCompatibilityNotices,
|
||||
@@ -275,9 +277,6 @@ describe("plugin status reports", () => {
|
||||
formatPluginCompatibilityNotice,
|
||||
summarizePluginCompatibility,
|
||||
} = await import("./status.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loadConfigMock.mockReset();
|
||||
loadOpenClawPluginsMock.mockReset();
|
||||
loadPluginMetadataRegistrySnapshotMock.mockReset();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockRegistryToolEntry = {
|
||||
pluginId: string;
|
||||
@@ -193,12 +193,10 @@ function expectConflictingCoreNameResolution(params: {
|
||||
}
|
||||
|
||||
describe("resolvePluginTools optional tools", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ resolvePluginTools } = await import("./tools.js"));
|
||||
({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("./runtime.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
loadOpenClawPluginsMock.mockClear();
|
||||
resolveRuntimePluginRegistryMock.mockReset();
|
||||
resolveRuntimePluginRegistryMock.mockImplementation((params) =>
|
||||
|
||||
@@ -84,9 +84,7 @@
|
||||
"payloadValid": true,
|
||||
"expectedSelection": {
|
||||
"provider": "elevenlabs",
|
||||
"normalizedPayload": false,
|
||||
"voiceId": "voice-legacy",
|
||||
"apiKey": "xxxxx"
|
||||
"normalizedPayload": false
|
||||
},
|
||||
"talk": {
|
||||
"voiceId": "voice-legacy",
|
||||
|
||||
@@ -5,7 +5,12 @@ export function createAutoReplyReplyVitestConfig(env?: Record<string, string | u
|
||||
return createScopedVitestConfig([...autoReplyReplySubtreeTestInclude], {
|
||||
dir: "src/auto-reply",
|
||||
env,
|
||||
fileParallelism: false,
|
||||
maxWorkers: 1,
|
||||
name: "auto-reply-reply",
|
||||
sequence: {
|
||||
groupOrder: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { createScopedVitestConfig } from "./vitest.scoped-config.ts";
|
||||
|
||||
export function createTasksVitestConfig(env?: Record<string, string | undefined>) {
|
||||
return createScopedVitestConfig(["src/tasks/**/*.test.ts"], {
|
||||
const config = createScopedVitestConfig(["src/tasks/**/*.test.ts"], {
|
||||
dir: "src",
|
||||
env,
|
||||
name: "tasks",
|
||||
passWithNoTests: true,
|
||||
});
|
||||
// Task tests mutate process.env and shared singleton registries/state dirs.
|
||||
// Keep files serialized so temp-dir-backed sqlite stores do not fight each
|
||||
// other under the non-isolated runner.
|
||||
config.test = {
|
||||
...config.test,
|
||||
maxWorkers: 1,
|
||||
fileParallelism: false,
|
||||
sequence: {
|
||||
...config.test?.sequence,
|
||||
groupOrder: 1,
|
||||
},
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
export default createTasksVitestConfig();
|
||||
|
||||
Reference in New Issue
Block a user