test: streamline config, audit, and qmd coverage
This commit is contained in:
@@ -5,78 +5,104 @@ function getLegacyRouting(config: unknown) {
|
||||
return (config as { routing?: Record<string, unknown> } | undefined)?.routing;
|
||||
}
|
||||
|
||||
function getChannelConfig(config: unknown, provider: string) {
|
||||
const channels = (config as { channels?: Record<string, Record<string, unknown>> } | undefined)
|
||||
?.channels;
|
||||
return channels?.[provider];
|
||||
}
|
||||
|
||||
describe("legacy config detection", () => {
|
||||
it("rejects routing.allowFrom", async () => {
|
||||
const res = validateConfigObject({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.allowFrom");
|
||||
it("rejects legacy routing keys", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "routing.allowFrom",
|
||||
input: { routing: { allowFrom: ["+15555550123"] } },
|
||||
expectedPath: "routing.allowFrom",
|
||||
},
|
||||
{
|
||||
name: "routing.groupChat.requireMention",
|
||||
input: { routing: { groupChat: { requireMention: false } } },
|
||||
expectedPath: "routing.groupChat.requireMention",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = validateConfigObject(testCase.input);
|
||||
expect(res.ok, testCase.name).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path, testCase.name).toBe(testCase.expectedPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
it("rejects routing.groupChat.requireMention", async () => {
|
||||
const res = validateConfigObject({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
||||
|
||||
it("migrates or drops routing.allowFrom based on whatsapp configuration", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "whatsapp configured",
|
||||
input: { routing: { allowFrom: ["+15555550123"] }, channels: { whatsapp: {} } },
|
||||
expectedChange: "Moved routing.allowFrom → channels.whatsapp.allowFrom.",
|
||||
expectWhatsappAllowFrom: true,
|
||||
},
|
||||
{
|
||||
name: "whatsapp missing",
|
||||
input: { routing: { allowFrom: ["+15555550123"] } },
|
||||
expectedChange: "Removed routing.allowFrom (channels.whatsapp not configured).",
|
||||
expectWhatsappAllowFrom: false,
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = migrateLegacyConfig(testCase.input);
|
||||
expect(res.changes, testCase.name).toContain(testCase.expectedChange);
|
||||
if (testCase.expectWhatsappAllowFrom) {
|
||||
expect(res.config?.channels?.whatsapp?.allowFrom, testCase.name).toEqual(["+15555550123"]);
|
||||
} else {
|
||||
expect(res.config?.channels?.whatsapp, testCase.name).toBeUndefined();
|
||||
}
|
||||
expect(getLegacyRouting(res.config)?.allowFrom, testCase.name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
|
||||
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||
expect(getLegacyRouting(res.config)?.allowFrom).toBeUndefined();
|
||||
});
|
||||
it("drops routing.allowFrom when whatsapp missing", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { allowFrom: ["+15555550123"] },
|
||||
});
|
||||
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
|
||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
||||
expect(getLegacyRouting(res.config)?.allowFrom).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
channels: { whatsapp: {} },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(getLegacyRouting(res.config)?.groupChat).toBeUndefined();
|
||||
});
|
||||
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
routing: { groupChat: { requireMention: false } },
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes).not.toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||
expect(getLegacyRouting(res.config)?.groupChat).toBeUndefined();
|
||||
|
||||
it("migrates routing.groupChat.requireMention to provider group defaults", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "whatsapp configured",
|
||||
input: { routing: { groupChat: { requireMention: false } }, channels: { whatsapp: {} } },
|
||||
expectWhatsapp: true,
|
||||
},
|
||||
{
|
||||
name: "whatsapp missing",
|
||||
input: { routing: { groupChat: { requireMention: false } } },
|
||||
expectWhatsapp: false,
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = migrateLegacyConfig(testCase.input);
|
||||
expect(res.changes, testCase.name).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.changes, testCase.name).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
|
||||
);
|
||||
if (testCase.expectWhatsapp) {
|
||||
expect(res.changes, testCase.name).toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp?.groups?.["*"]?.requireMention, testCase.name).toBe(
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
expect(res.changes, testCase.name).not.toContain(
|
||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||
);
|
||||
expect(res.config?.channels?.whatsapp, testCase.name).toBeUndefined();
|
||||
}
|
||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention, testCase.name).toBe(
|
||||
false,
|
||||
);
|
||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention, testCase.name).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getLegacyRouting(res.config)?.groupChat, testCase.name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
@@ -346,247 +372,238 @@ describe("legacy config detection", () => {
|
||||
expect(validated.config.gateway?.bind).toBe("tailnet");
|
||||
}
|
||||
});
|
||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["123456789"] } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.telegram.allowFrom");
|
||||
it('enforces dmPolicy="open" allowFrom wildcard for supported providers', async () => {
|
||||
const cases = [
|
||||
{
|
||||
provider: "telegram",
|
||||
allowFrom: ["123456789"],
|
||||
expectedIssuePath: "channels.telegram.allowFrom",
|
||||
},
|
||||
{
|
||||
provider: "whatsapp",
|
||||
allowFrom: ["+15555550123"],
|
||||
expectedIssuePath: "channels.whatsapp.allowFrom",
|
||||
},
|
||||
{
|
||||
provider: "signal",
|
||||
allowFrom: ["+15555550123"],
|
||||
expectedIssuePath: "channels.signal.allowFrom",
|
||||
},
|
||||
{
|
||||
provider: "imessage",
|
||||
allowFrom: ["+15555550123"],
|
||||
expectedIssuePath: "channels.imessage.allowFrom",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
[testCase.provider]: { dmPolicy: "open", allowFrom: testCase.allowFrom },
|
||||
},
|
||||
});
|
||||
expect(res.ok, testCase.provider).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path, testCase.provider).toBe(testCase.expectedIssuePath);
|
||||
}
|
||||
}
|
||||
});
|
||||
it('accepts telegram.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.dmPolicy).toBe("open");
|
||||
|
||||
it('accepts dmPolicy="open" when allowFrom includes wildcard', async () => {
|
||||
const providers = ["telegram", "whatsapp", "signal"] as const;
|
||||
for (const provider of providers) {
|
||||
const res = validateConfigObject({
|
||||
channels: { [provider]: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok, provider).toBe(true);
|
||||
if (res.ok) {
|
||||
const channel = getChannelConfig(res.config, provider);
|
||||
expect(channel?.dmPolicy, provider).toBe("open");
|
||||
}
|
||||
}
|
||||
});
|
||||
it("defaults telegram.dmPolicy to pairing when telegram section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { telegram: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.dmPolicy).toBe("pairing");
|
||||
|
||||
it("defaults dm/group policy for configured providers", async () => {
|
||||
const providers = ["telegram", "whatsapp", "signal"] as const;
|
||||
for (const provider of providers) {
|
||||
const res = validateConfigObject({ channels: { [provider]: {} } });
|
||||
expect(res.ok, provider).toBe(true);
|
||||
if (res.ok) {
|
||||
const channel = getChannelConfig(res.config, provider);
|
||||
expect(channel?.dmPolicy, provider).toBe("pairing");
|
||||
expect(channel?.groupPolicy, provider).toBe("allowlist");
|
||||
if (provider === "telegram") {
|
||||
expect(channel?.streaming, provider).toBe("off");
|
||||
expect(channel?.streamMode, provider).toBeUndefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it("defaults telegram.groupPolicy to allowlist when telegram section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { telegram: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.groupPolicy).toBe("allowlist");
|
||||
}
|
||||
});
|
||||
it("defaults telegram.streaming to off when telegram section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { telegram: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.streaming).toBe("off");
|
||||
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates legacy telegram.streamMode=off to streaming=off", async () => {
|
||||
const res = validateConfigObject({ channels: { telegram: { streamMode: "off" } } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.streaming).toBe("off");
|
||||
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates legacy telegram.streamMode=block to streaming=block", async () => {
|
||||
const res = validateConfigObject({ channels: { telegram: { streamMode: "block" } } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.streaming).toBe("block");
|
||||
expect(res.config.channels?.telegram?.streamMode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates legacy telegram.accounts.*.streamMode to streaming", async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
streamMode: "off",
|
||||
it("normalizes telegram legacy streamMode aliases", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "top-level off",
|
||||
input: { channels: { telegram: { streamMode: "off" } } },
|
||||
expectedTopLevel: "off",
|
||||
},
|
||||
{
|
||||
name: "top-level block",
|
||||
input: { channels: { telegram: { streamMode: "block" } } },
|
||||
expectedTopLevel: "block",
|
||||
},
|
||||
{
|
||||
name: "per-account off",
|
||||
input: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
streamMode: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedAccountStreaming: "off",
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.telegram?.accounts?.ops?.streaming).toBe("off");
|
||||
expect(res.config.channels?.telegram?.accounts?.ops?.streamMode).toBeUndefined();
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = validateConfigObject(testCase.input);
|
||||
expect(res.ok, testCase.name).toBe(true);
|
||||
if (res.ok) {
|
||||
if (testCase.expectedTopLevel !== undefined) {
|
||||
expect(res.config.channels?.telegram?.streaming, testCase.name).toBe(
|
||||
testCase.expectedTopLevel,
|
||||
);
|
||||
expect(res.config.channels?.telegram?.streamMode, testCase.name).toBeUndefined();
|
||||
}
|
||||
if (testCase.expectedAccountStreaming !== undefined) {
|
||||
expect(res.config.channels?.telegram?.accounts?.ops?.streaming, testCase.name).toBe(
|
||||
testCase.expectedAccountStreaming,
|
||||
);
|
||||
expect(
|
||||
res.config.channels?.telegram?.accounts?.ops?.streamMode,
|
||||
testCase.name,
|
||||
).toBeUndefined();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
it("normalizes channels.discord.streaming booleans in legacy migration", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
channels: {
|
||||
discord: {
|
||||
streaming: true,
|
||||
},
|
||||
|
||||
it("normalizes discord streaming fields during legacy migration", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "boolean streaming=true",
|
||||
input: { channels: { discord: { streaming: true } } },
|
||||
expectedChanges: ["Normalized channels.discord.streaming boolean → enum (partial)."],
|
||||
expectedStreaming: "partial",
|
||||
},
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Normalized channels.discord.streaming boolean → enum (partial).",
|
||||
);
|
||||
expect(res.config?.channels?.discord?.streaming).toBe("partial");
|
||||
expect(res.config?.channels?.discord?.streamMode).toBeUndefined();
|
||||
});
|
||||
it("migrates channels.discord.streamMode to channels.discord.streaming in legacy migration", async () => {
|
||||
const res = migrateLegacyConfig({
|
||||
channels: {
|
||||
discord: {
|
||||
streaming: false,
|
||||
streamMode: "block",
|
||||
},
|
||||
{
|
||||
name: "streamMode with streaming boolean",
|
||||
input: { channels: { discord: { streaming: false, streamMode: "block" } } },
|
||||
expectedChanges: [
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming (block).",
|
||||
"Normalized channels.discord.streaming boolean → enum (block).",
|
||||
],
|
||||
expectedStreaming: "block",
|
||||
},
|
||||
});
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming (block).",
|
||||
);
|
||||
expect(res.changes).toContain("Normalized channels.discord.streaming boolean → enum (block).");
|
||||
expect(res.config?.channels?.discord?.streaming).toBe("block");
|
||||
expect(res.config?.channels?.discord?.streamMode).toBeUndefined();
|
||||
});
|
||||
it("migrates discord.streaming=true to streaming=partial", async () => {
|
||||
const res = validateConfigObject({ channels: { discord: { streaming: true } } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.discord?.streaming).toBe("partial");
|
||||
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = migrateLegacyConfig(testCase.input);
|
||||
for (const expectedChange of testCase.expectedChanges) {
|
||||
expect(res.changes, testCase.name).toContain(expectedChange);
|
||||
}
|
||||
expect(res.config?.channels?.discord?.streaming, testCase.name).toBe(
|
||||
testCase.expectedStreaming,
|
||||
);
|
||||
expect(res.config?.channels?.discord?.streamMode, testCase.name).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates discord.streaming=false to streaming=off", async () => {
|
||||
const res = validateConfigObject({ channels: { discord: { streaming: false } } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.discord?.streaming).toBe("off");
|
||||
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
|
||||
|
||||
it("normalizes discord streaming fields during validation", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "streaming=true",
|
||||
input: { channels: { discord: { streaming: true } } },
|
||||
expectedStreaming: "partial",
|
||||
},
|
||||
{
|
||||
name: "streaming=false",
|
||||
input: { channels: { discord: { streaming: false } } },
|
||||
expectedStreaming: "off",
|
||||
},
|
||||
{
|
||||
name: "streamMode overrides streaming boolean",
|
||||
input: { channels: { discord: { streamMode: "block", streaming: false } } },
|
||||
expectedStreaming: "block",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = validateConfigObject(testCase.input);
|
||||
expect(res.ok, testCase.name).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.discord?.streaming, testCase.name).toBe(
|
||||
testCase.expectedStreaming,
|
||||
);
|
||||
expect(res.config.channels?.discord?.streamMode, testCase.name).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
it("keeps explicit discord.streamMode and normalizes to streaming", async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: { discord: { streamMode: "block", streaming: false } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.discord?.streaming).toBe("block");
|
||||
expect(res.config.channels?.discord?.streamMode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates discord.accounts.*.streaming alias to streaming enum", async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: true,
|
||||
it("normalizes account-level discord and slack streaming aliases", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "discord account streaming boolean",
|
||||
input: {
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("partial");
|
||||
expect(res.config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
|
||||
}
|
||||
});
|
||||
it("migrates slack.streamMode values to slack.streaming enum", async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
streamMode: "status_final",
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.discord?.accounts?.work?.streaming).toBe("partial");
|
||||
expect(config.channels?.discord?.accounts?.work?.streamMode).toBeUndefined();
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.slack?.streaming).toBe("progress");
|
||||
expect(res.config.channels?.slack?.streamMode).toBeUndefined();
|
||||
expect(res.config.channels?.slack?.nativeStreaming).toBe(true);
|
||||
}
|
||||
});
|
||||
it("migrates legacy slack.streaming boolean to nativeStreaming", async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
slack: {
|
||||
streaming: false,
|
||||
{
|
||||
name: "slack streamMode alias",
|
||||
input: {
|
||||
channels: {
|
||||
slack: {
|
||||
streamMode: "status_final",
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.slack?.streaming).toBe("progress");
|
||||
expect(config.channels?.slack?.streamMode).toBeUndefined();
|
||||
expect(config.channels?.slack?.nativeStreaming).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.slack?.streaming).toBe("partial");
|
||||
expect(res.config.channels?.slack?.nativeStreaming).toBe(false);
|
||||
}
|
||||
});
|
||||
it('rejects whatsapp.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
whatsapp: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
||||
{
|
||||
name: "slack streaming boolean legacy",
|
||||
input: {
|
||||
channels: {
|
||||
slack: {
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.slack?.streaming).toBe("partial");
|
||||
expect(config.channels?.slack?.nativeStreaming).toBe(false);
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.whatsapp.allowFrom");
|
||||
}
|
||||
});
|
||||
it('accepts whatsapp.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: { whatsapp: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("open");
|
||||
}
|
||||
});
|
||||
it("defaults whatsapp.dmPolicy to pairing when whatsapp section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { whatsapp: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.whatsapp?.dmPolicy).toBe("pairing");
|
||||
}
|
||||
});
|
||||
it("defaults whatsapp.groupPolicy to allowlist when whatsapp section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { whatsapp: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.whatsapp?.groupPolicy).toBe("allowlist");
|
||||
}
|
||||
});
|
||||
it('rejects signal.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["+15555550123"] } },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.signal.allowFrom");
|
||||
}
|
||||
});
|
||||
it('accepts signal.dmPolicy="open" with allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.signal?.dmPolicy).toBe("open");
|
||||
}
|
||||
});
|
||||
it("defaults signal.dmPolicy to pairing when signal section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { signal: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.signal?.dmPolicy).toBe("pairing");
|
||||
}
|
||||
});
|
||||
it("defaults signal.groupPolicy to allowlist when signal section exists", async () => {
|
||||
const res = validateConfigObject({ channels: { signal: {} } });
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.channels?.signal?.groupPolicy).toBe("allowlist");
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const res = validateConfigObject(testCase.input);
|
||||
expect(res.ok, testCase.name).toBe(true);
|
||||
if (res.ok) {
|
||||
testCase.assert(res.config);
|
||||
}
|
||||
}
|
||||
});
|
||||
it("accepts historyLimit overrides per provider and account", async () => {
|
||||
@@ -616,15 +633,4 @@ describe("legacy config detection", () => {
|
||||
expect(res.config.channels?.discord?.historyLimit).toBe(3);
|
||||
}
|
||||
});
|
||||
it('rejects imessage.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
const res = validateConfigObject({
|
||||
channels: {
|
||||
imessage: { dmPolicy: "open", allowFrom: ["+15555550123"] },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("channels.imessage.allowFrom");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1212,40 +1212,52 @@ describe("QmdMemoryManager", () => {
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns empty text when a qmd workspace file does not exist", async () => {
|
||||
const { manager } = await createManager();
|
||||
const result = await manager.readFile({ relPath: "ghost.md" });
|
||||
expect(result).toEqual({ text: "", path: "ghost.md" });
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("returns empty text when a qmd file disappears before partial read", async () => {
|
||||
it("returns empty text when qmd files are missing before or during read", async () => {
|
||||
const relPath = "qmd-window.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8");
|
||||
|
||||
const { manager } = await createManager();
|
||||
const cases = [
|
||||
{
|
||||
name: "missing before read",
|
||||
request: { relPath: "ghost.md" },
|
||||
expectedPath: "ghost.md",
|
||||
},
|
||||
{
|
||||
name: "disappears before partial read",
|
||||
request: { relPath, from: 2, lines: 1 },
|
||||
expectedPath: relPath,
|
||||
installOpenSpy: () => {
|
||||
const realOpen = fs.open;
|
||||
let injected = false;
|
||||
const openSpy = vi
|
||||
.spyOn(fs, "open")
|
||||
.mockImplementation(async (...args: Parameters<typeof realOpen>) => {
|
||||
const [target, options] = args;
|
||||
if (!injected && typeof target === "string" && path.resolve(target) === absPath) {
|
||||
injected = true;
|
||||
const err = new Error("gone") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return realOpen(target, options);
|
||||
});
|
||||
return () => openSpy.mockRestore();
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
const realOpen = fs.open;
|
||||
let injected = false;
|
||||
const openSpy = vi
|
||||
.spyOn(fs, "open")
|
||||
.mockImplementation(async (...args: Parameters<typeof realOpen>) => {
|
||||
const [target, options] = args;
|
||||
if (!injected && typeof target === "string" && path.resolve(target) === absPath) {
|
||||
injected = true;
|
||||
const err = new Error("gone") as NodeJS.ErrnoException;
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return realOpen(target, options);
|
||||
});
|
||||
|
||||
const result = await manager.readFile({ relPath, from: 2, lines: 1 });
|
||||
expect(result).toEqual({ text: "", path: relPath });
|
||||
|
||||
openSpy.mockRestore();
|
||||
await manager.close();
|
||||
for (const testCase of cases) {
|
||||
const { manager } = await createManager();
|
||||
const restoreOpen = testCase.installOpenSpy?.();
|
||||
try {
|
||||
const result = await manager.readFile(testCase.request);
|
||||
expect(result, testCase.name).toEqual({ text: "", path: testCase.expectedPath });
|
||||
} finally {
|
||||
restoreOpen?.();
|
||||
await manager.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses exported session markdown files when inputs are unchanged", async () => {
|
||||
@@ -1295,67 +1307,86 @@ describe("QmdMemoryManager", () => {
|
||||
writeFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("throws when sqlite index is busy", async () => {
|
||||
const { manager } = await createManager();
|
||||
const inner = manager as unknown as {
|
||||
db: {
|
||||
prepare: () => {
|
||||
all: () => never;
|
||||
get: () => never;
|
||||
};
|
||||
close: () => void;
|
||||
} | null;
|
||||
resolveDocLocation: (docid?: string) => Promise<unknown>;
|
||||
};
|
||||
const busyStmt: { all: () => never; get: () => never } = {
|
||||
all: () => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
},
|
||||
get: () => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
},
|
||||
};
|
||||
|
||||
inner.db = {
|
||||
prepare: () => busyStmt,
|
||||
close: () => {},
|
||||
};
|
||||
await expect(inner.resolveDocLocation("abc123")).rejects.toThrow(
|
||||
"qmd index busy while reading results",
|
||||
);
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("fails search when sqlite index is busy so caller can fallback", async () => {
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([{ docid: "abc123", score: 1, snippet: "@@ -1,1\nremember this" }]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
|
||||
const { manager } = await createManager();
|
||||
const inner = manager as unknown as {
|
||||
db: { prepare: () => { all: () => never }; close: () => void } | null;
|
||||
};
|
||||
inner.db = {
|
||||
prepare: () => ({
|
||||
all: () => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
it("fails closed when sqlite index is busy during doc lookup or search", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "resolveDocLocation",
|
||||
run: async (manager: QmdMemoryManager) => {
|
||||
const inner = manager as unknown as {
|
||||
db: {
|
||||
prepare: () => {
|
||||
all: () => never;
|
||||
get: () => never;
|
||||
};
|
||||
close: () => void;
|
||||
} | null;
|
||||
resolveDocLocation: (docid?: string) => Promise<unknown>;
|
||||
};
|
||||
const busyStmt: { all: () => never; get: () => never } = {
|
||||
all: () => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
},
|
||||
get: () => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
},
|
||||
};
|
||||
inner.db = {
|
||||
prepare: () => busyStmt,
|
||||
close: () => {},
|
||||
};
|
||||
await expect(inner.resolveDocLocation("abc123")).rejects.toThrow(
|
||||
"qmd index busy while reading results",
|
||||
);
|
||||
},
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
await expect(
|
||||
manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }),
|
||||
).rejects.toThrow("qmd index busy while reading results");
|
||||
await manager.close();
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
run: async (manager: QmdMemoryManager) => {
|
||||
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
|
||||
if (args[0] === "search") {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
emitAndClose(
|
||||
child,
|
||||
"stdout",
|
||||
JSON.stringify([{ docid: "abc123", score: 1, snippet: "@@ -1,1\nremember this" }]),
|
||||
);
|
||||
return child;
|
||||
}
|
||||
return createMockChild();
|
||||
});
|
||||
const inner = manager as unknown as {
|
||||
db: { prepare: () => { all: () => never }; close: () => void } | null;
|
||||
};
|
||||
inner.db = {
|
||||
prepare: () => ({
|
||||
all: () => {
|
||||
throw new Error("SQLITE_BUSY: database is locked");
|
||||
},
|
||||
}),
|
||||
close: () => {},
|
||||
};
|
||||
await expect(
|
||||
manager.search("busy lookup", { sessionKey: "agent:main:slack:dm:u123" }),
|
||||
).rejects.toThrow("qmd index busy while reading results");
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
spawnMock.mockReset();
|
||||
spawnMock.mockImplementation(() => createMockChild());
|
||||
const { manager } = await createManager();
|
||||
try {
|
||||
await testCase.run(manager);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`${testCase.name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
} finally {
|
||||
await manager.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers exact docid match before prefix fallback for qmd document lookups", async () => {
|
||||
@@ -1581,56 +1612,68 @@ describe("QmdMemoryManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("symlinks default model cache into custom XDG_CACHE_HOME on first run", async () => {
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
expect(manager).toBeTruthy();
|
||||
it("handles first-run symlink, existing dir preservation, and missing default cache", async () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
setup?: () => Promise<void>;
|
||||
assert: () => Promise<void>;
|
||||
}> = [
|
||||
{
|
||||
name: "symlinks default cache on first run",
|
||||
assert: async () => {
|
||||
const stat = await fs.lstat(customModelsDir);
|
||||
expect(stat.isSymbolicLink()).toBe(true);
|
||||
const target = await fs.readlink(customModelsDir);
|
||||
expect(target).toBe(defaultModelsDir);
|
||||
const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8");
|
||||
expect(content).toBe("fake-model");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not overwrite existing models directory",
|
||||
setup: async () => {
|
||||
await fs.mkdir(customModelsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom");
|
||||
},
|
||||
assert: async () => {
|
||||
const stat = await fs.lstat(customModelsDir);
|
||||
expect(stat.isSymbolicLink()).toBe(false);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
const content = await fs.readFile(
|
||||
path.join(customModelsDir, "custom-model.bin"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(content).toBe("custom");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "skips symlink when default models are absent",
|
||||
setup: async () => {
|
||||
await fs.rm(defaultModelsDir, { recursive: true, force: true });
|
||||
},
|
||||
assert: async () => {
|
||||
await expect(fs.lstat(customModelsDir)).rejects.toThrow();
|
||||
expect(logWarnMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed to symlink qmd models directory"),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const stat = await fs.lstat(customModelsDir);
|
||||
expect(stat.isSymbolicLink()).toBe(true);
|
||||
const target = await fs.readlink(customModelsDir);
|
||||
expect(target).toBe(defaultModelsDir);
|
||||
|
||||
// Models are accessible through the symlink.
|
||||
const content = await fs.readFile(path.join(customModelsDir, "model.bin"), "utf-8");
|
||||
expect(content).toBe("fake-model");
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("does not overwrite existing models directory", async () => {
|
||||
// Pre-create the custom models dir with different content.
|
||||
await fs.mkdir(customModelsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(customModelsDir, "custom-model.bin"), "custom");
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
expect(manager).toBeTruthy();
|
||||
|
||||
// Should still be a real directory, not a symlink.
|
||||
const stat = await fs.lstat(customModelsDir);
|
||||
expect(stat.isSymbolicLink()).toBe(false);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
|
||||
// Custom content should be preserved.
|
||||
const content = await fs.readFile(path.join(customModelsDir, "custom-model.bin"), "utf-8");
|
||||
expect(content).toBe("custom");
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("skips symlink when no default models exist", async () => {
|
||||
// Remove the default models dir.
|
||||
await fs.rm(defaultModelsDir, { recursive: true, force: true });
|
||||
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
expect(manager).toBeTruthy();
|
||||
|
||||
// Custom models dir should not exist (no symlink created).
|
||||
await expect(fs.lstat(customModelsDir)).rejects.toThrow();
|
||||
expect(logWarnMock).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("failed to symlink qmd models directory"),
|
||||
);
|
||||
|
||||
await manager.close();
|
||||
for (const testCase of cases) {
|
||||
await fs.rm(customModelsDir, { recursive: true, force: true });
|
||||
await fs.mkdir(defaultModelsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(defaultModelsDir, "model.bin"), "fake-model");
|
||||
logWarnMock.mockReset();
|
||||
await testCase.setup?.();
|
||||
const { manager } = await createManager({ mode: "full" });
|
||||
expect(manager, testCase.name).toBeTruthy();
|
||||
try {
|
||||
await testCase.assert();
|
||||
} finally {
|
||||
await manager.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user