test: dedupe helper-heavy test suites

This commit is contained in:
Peter Steinberger
2026-03-27 22:35:03 +00:00
parent 0826fb4a00
commit c52f89bd60
13 changed files with 1048 additions and 882 deletions

View File

@@ -12,50 +12,54 @@ const baseAttempt = {
reason: "rate_limit" as const,
};
describe("fallback-state", () => {
it("treats fallback as active only when state matches selected and active refs", () => {
const state: FallbackNoticeState = {
fallbackNoticeSelectedModel: "demo-primary/model-a",
fallbackNoticeActiveModel: "demo-fallback/model-b",
fallbackNoticeReason: "rate limit",
};
const activeFallbackState: FallbackNoticeState = {
fallbackNoticeSelectedModel: "demo-primary/model-a",
fallbackNoticeActiveModel: "demo-fallback/model-b",
fallbackNoticeReason: "rate limit",
};
const resolved = resolveActiveFallbackState({
selectedModelRef: "demo-primary/model-a",
activeModelRef: "demo-fallback/model-b",
state,
});
expect(resolved.active).toBe(true);
expect(resolved.reason).toBe("rate limit");
function resolveDemoFallbackTransition(
overrides: Partial<Parameters<typeof resolveFallbackTransition>[0]> = {},
) {
return resolveFallbackTransition({
selectedProvider: "demo-primary",
selectedModel: "model-a",
activeProvider: "demo-fallback",
activeModel: "model-b",
attempts: [baseAttempt],
state: {},
...overrides,
});
}
it("does not treat runtime drift as fallback when persisted state does not match", () => {
const state: FallbackNoticeState = {
fallbackNoticeSelectedModel: "other-provider/other-model",
fallbackNoticeActiveModel: "demo-fallback/model-b",
fallbackNoticeReason: "rate limit",
};
describe("fallback-state", () => {
it.each([
{
name: "treats fallback as active only when state matches selected and active refs",
state: activeFallbackState,
expected: { active: true, reason: "rate limit" },
},
{
name: "does not treat runtime drift as fallback when persisted state does not match",
state: {
fallbackNoticeSelectedModel: "other-provider/other-model",
fallbackNoticeActiveModel: "demo-fallback/model-b",
fallbackNoticeReason: "rate limit",
} satisfies FallbackNoticeState,
expected: { active: false, reason: undefined },
},
])("$name", ({ state, expected }) => {
const resolved = resolveActiveFallbackState({
selectedModelRef: "demo-primary/model-a",
activeModelRef: "demo-fallback/model-b",
state,
});
expect(resolved.active).toBe(false);
expect(resolved.reason).toBeUndefined();
expect(resolved).toEqual(expected);
});
it("marks fallback transition when selected->active pair changes", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "demo-primary",
selectedModel: "model-a",
activeProvider: "demo-fallback",
activeModel: "model-b",
attempts: [baseAttempt],
state: {},
});
const resolved = resolveDemoFallbackTransition();
expect(resolved.fallbackActive).toBe(true);
expect(resolved.fallbackTransitioned).toBe(true);
@@ -67,30 +71,17 @@ describe("fallback-state", () => {
});
it("normalizes fallback reason whitespace for summaries", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "demo-primary",
selectedModel: "model-a",
activeProvider: "demo-fallback",
activeModel: "model-b",
const resolved = resolveDemoFallbackTransition({
attempts: [{ ...baseAttempt, reason: "rate_limit\n\tburst" }],
state: {},
});
expect(resolved.reasonSummary).toBe("rate limit burst");
});
it("refreshes reason when fallback remains active with same model pair", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "demo-primary",
selectedModel: "model-a",
activeProvider: "demo-fallback",
activeModel: "model-b",
const resolved = resolveDemoFallbackTransition({
attempts: [{ ...baseAttempt, reason: "timeout" }],
state: {
fallbackNoticeSelectedModel: "demo-primary/model-a",
fallbackNoticeActiveModel: "demo-fallback/model-b",
fallbackNoticeReason: "rate limit",
},
state: activeFallbackState,
});
expect(resolved.fallbackTransitioned).toBe(false);
@@ -99,17 +90,12 @@ describe("fallback-state", () => {
});
it("marks fallback as cleared when runtime returns to selected model", () => {
const resolved = resolveFallbackTransition({
selectedProvider: "demo-primary",
selectedModel: "model-a",
const resolved = resolveDemoFallbackTransition({
activeProvider: "demo-primary",
selectedModel: "model-a",
activeModel: "model-a",
attempts: [],
state: {
fallbackNoticeSelectedModel: "demo-primary/model-a",
fallbackNoticeActiveModel: "demo-fallback/model-b",
fallbackNoticeReason: "rate limit",
},
state: activeFallbackState,
});
expect(resolved.fallbackActive).toBe(false);

View File

@@ -36,16 +36,21 @@ function cfg(accounts?: Record<string, unknown> | null, defaultAccount?: string)
describe("createAccountListHelpers", () => {
describe("listConfiguredAccountIds", () => {
it("returns empty for missing config", () => {
expect(listConfiguredAccountIds({} as OpenClawConfig)).toEqual([]);
});
it("returns empty when no accounts key", () => {
expect(listConfiguredAccountIds(cfg(null))).toEqual([]);
});
it("returns empty for empty accounts object", () => {
expect(listConfiguredAccountIds(cfg({}))).toEqual([]);
it.each([
{
name: "returns empty for missing config",
input: {} as OpenClawConfig,
},
{
name: "returns empty when no accounts key",
input: cfg(null),
},
{
name: "returns empty for empty accounts object",
input: cfg({}),
},
])("$name", ({ input }) => {
expect(listConfiguredAccountIds(input)).toEqual([]);
});
it("filters out empty keys", () => {
@@ -77,42 +82,61 @@ describe("createAccountListHelpers", () => {
});
describe("listAccountIds", () => {
it('returns ["default"] for empty config', () => {
expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]);
});
it('returns ["default"] for empty accounts', () => {
expect(listAccountIds(cfg({}))).toEqual(["default"]);
});
it("returns sorted ids", () => {
expect(listAccountIds(cfg({ z: {}, a: {}, m: {} }))).toEqual(["a", "m", "z"]);
it.each([
{
name: 'returns ["default"] for empty config',
input: {} as OpenClawConfig,
expected: ["default"],
},
{
name: 'returns ["default"] for empty accounts',
input: cfg({}),
expected: ["default"],
},
{
name: "returns sorted ids",
input: cfg({ z: {}, a: {}, m: {} }),
expected: ["a", "m", "z"],
},
])("$name", ({ input, expected }) => {
expect(listAccountIds(input)).toEqual(expected);
});
});
describe("resolveDefaultAccountId", () => {
it("prefers configured defaultAccount when it matches a configured account id", () => {
expect(resolveDefaultAccountId(cfg({ alpha: {}, beta: {} }, "beta"))).toBe("beta");
});
it("normalizes configured defaultAccount before matching", () => {
expect(resolveDefaultAccountId(cfg({ "router-d": {} }, "Router D"))).toBe("router-d");
});
it("falls back when configured defaultAccount is missing", () => {
expect(resolveDefaultAccountId(cfg({ beta: {}, alpha: {} }, "missing"))).toBe("alpha");
});
it('returns "default" when present', () => {
expect(resolveDefaultAccountId(cfg({ default: {}, other: {} }))).toBe("default");
});
it("returns first sorted id when no default", () => {
expect(resolveDefaultAccountId(cfg({ beta: {}, alpha: {} }))).toBe("alpha");
});
it('returns "default" for empty config', () => {
expect(resolveDefaultAccountId({} as OpenClawConfig)).toBe("default");
it.each([
{
name: "prefers configured defaultAccount when it matches a configured account id",
input: cfg({ alpha: {}, beta: {} }, "beta"),
expected: "beta",
},
{
name: "normalizes configured defaultAccount before matching",
input: cfg({ "router-d": {} }, "Router D"),
expected: "router-d",
},
{
name: "falls back when configured defaultAccount is missing",
input: cfg({ beta: {}, alpha: {} }, "missing"),
expected: "alpha",
},
{
name: 'returns "default" when present',
input: cfg({ default: {}, other: {} }),
expected: "default",
},
{
name: "returns first sorted id when no default",
input: cfg({ beta: {}, alpha: {} }),
expected: "alpha",
},
{
name: 'returns "default" for empty config',
input: {} as OpenClawConfig,
expected: "default",
},
])("$name", ({ input, expected }) => {
expect(resolveDefaultAccountId(input)).toBe(expected);
});
it("can preserve configured defaults that are not present in accounts", () => {
@@ -149,49 +173,49 @@ describe("listCombinedAccountIds", () => {
});
describe("resolveListedDefaultAccountId", () => {
it("prefers the configured default when present in the listed ids", () => {
expect(
resolveListedDefaultAccountId({
it.each([
{
name: "prefers the configured default when present in the listed ids",
input: {
accountIds: ["alerts", "work"],
configuredDefaultAccountId: "work",
}),
).toBe("work");
});
it("matches configured defaults against normalized listed ids", () => {
expect(
resolveListedDefaultAccountId({
},
expected: "work",
},
{
name: "matches configured defaults against normalized listed ids",
input: {
accountIds: ["Router D"],
configuredDefaultAccountId: "router-d",
}),
).toBe("router-d");
});
it("prefers the default account id when listed", () => {
expect(
resolveListedDefaultAccountId({
},
expected: "router-d",
},
{
name: "prefers the default account id when listed",
input: {
accountIds: ["default", "work"],
}),
).toBe("default");
});
it("can preserve an unlisted configured default", () => {
expect(
resolveListedDefaultAccountId({
},
expected: "default",
},
{
name: "can preserve an unlisted configured default",
input: {
accountIds: ["default", "work"],
configuredDefaultAccountId: "ops",
allowUnlistedDefaultAccount: true,
}),
).toBe("ops");
});
it("supports an explicit fallback id for ambiguous multi-account setups", () => {
expect(
resolveListedDefaultAccountId({
},
expected: "ops",
},
{
name: "supports an explicit fallback id for ambiguous multi-account setups",
input: {
accountIds: ["alerts", "work"],
ambiguousFallbackAccountId: "default",
}),
).toBe("default");
},
expected: "default",
},
])("$name", ({ input, expected }) => {
expect(resolveListedDefaultAccountId(input)).toBe(expected);
});
});
@@ -305,49 +329,59 @@ describe("mergeAccountConfig", () => {
});
describe("resolveMergedAccountConfig", () => {
it("merges the matching account config into channel config", () => {
const merged = resolveMergedAccountConfig<{
enabled?: boolean;
name?: string;
}>({
channelConfig: {
enabled: true,
},
accounts: {
work: {
name: "Work",
type MergedChannelConfig = {
enabled?: boolean;
name?: string;
};
type ResolveMergedInput = Parameters<typeof resolveMergedAccountConfig<MergedChannelConfig>>[0];
const resolveMergedCases: Array<{
name: string;
input: ResolveMergedInput;
expected: MergedChannelConfig;
}> = [
{
name: "merges the matching account config into channel config",
input: {
channelConfig: {
enabled: true,
},
},
accountId: "work",
});
expect(merged).toEqual({
enabled: true,
name: "Work",
});
});
it("supports normalized account lookups", () => {
const merged = resolveMergedAccountConfig<{
enabled?: boolean;
name?: string;
}>({
channelConfig: {
enabled: true,
},
accounts: {
"Router D": {
name: "Router",
accounts: {
work: {
name: "Work",
},
},
accountId: "work",
},
accountId: "router-d",
normalizeAccountId,
});
expected: {
enabled: true,
name: "Work",
},
},
{
name: "supports normalized account lookups",
input: {
channelConfig: {
enabled: true,
},
accounts: {
"Router D": {
name: "Router",
},
},
accountId: "router-d",
normalizeAccountId,
},
expected: {
enabled: true,
name: "Router",
},
},
];
expect(merged).toEqual({
enabled: true,
name: "Router",
});
it.each(resolveMergedCases)("$name", ({ input, expected }) => {
expect(resolveMergedAccountConfig<MergedChannelConfig>(input)).toEqual(expected);
});
it("deep-merges selected nested object keys after resolving the account", () => {

View File

@@ -15,29 +15,29 @@ function cfgWithChannel(channelKey: string, accounts?: Record<string, unknown>):
}
describe("buildAccountScopedDmSecurityPolicy", () => {
it("builds top-level dm policy paths when no account config exists", () => {
expect(
buildAccountScopedDmSecurityPolicy({
it.each([
{
name: "builds top-level dm policy paths when no account config exists",
input: {
cfg: cfgWithChannel("demo-root"),
channelKey: "demo-root",
fallbackAccountId: "default",
policy: "pairing",
allowFrom: ["123"],
policyPathSuffix: "dmPolicy",
}),
).toEqual({
policy: "pairing",
allowFrom: ["123"],
policyPath: "channels.demo-root.dmPolicy",
allowFromPath: "channels.demo-root.",
approveHint: formatPairingApproveHint("demo-root"),
normalizeEntry: undefined,
});
});
it("uses account-scoped paths when account config exists", () => {
expect(
buildAccountScopedDmSecurityPolicy({
},
expected: {
policy: "pairing",
allowFrom: ["123"],
policyPath: "channels.demo-root.dmPolicy",
allowFromPath: "channels.demo-root.",
approveHint: formatPairingApproveHint("demo-root"),
normalizeEntry: undefined,
},
},
{
name: "uses account-scoped paths when account config exists",
input: {
cfg: cfgWithChannel("demo-account", { work: {} }),
channelKey: "demo-account",
accountId: "work",
@@ -45,40 +45,38 @@ describe("buildAccountScopedDmSecurityPolicy", () => {
policy: "allowlist",
allowFrom: ["+12125551212"],
policyPathSuffix: "dmPolicy",
}),
).toEqual({
policy: "allowlist",
allowFrom: ["+12125551212"],
policyPath: "channels.demo-account.accounts.work.dmPolicy",
allowFromPath: "channels.demo-account.accounts.work.",
approveHint: formatPairingApproveHint("demo-account"),
normalizeEntry: undefined,
});
});
it("supports nested dm paths without explicit policyPath", () => {
expect(
buildAccountScopedDmSecurityPolicy({
},
expected: {
policy: "allowlist",
allowFrom: ["+12125551212"],
policyPath: "channels.demo-account.accounts.work.dmPolicy",
allowFromPath: "channels.demo-account.accounts.work.",
approveHint: formatPairingApproveHint("demo-account"),
normalizeEntry: undefined,
},
},
{
name: "supports nested dm paths without explicit policyPath",
input: {
cfg: cfgWithChannel("demo-nested", { work: {} }),
channelKey: "demo-nested",
accountId: "work",
policy: "pairing",
allowFrom: [],
allowFromPathSuffix: "dm.",
}),
).toEqual({
policy: "pairing",
allowFrom: [],
policyPath: undefined,
allowFromPath: "channels.demo-nested.accounts.work.dm.",
approveHint: formatPairingApproveHint("demo-nested"),
normalizeEntry: undefined,
});
});
it("supports custom defaults and approve hints", () => {
expect(
buildAccountScopedDmSecurityPolicy({
},
expected: {
policy: "pairing",
allowFrom: [],
policyPath: undefined,
allowFromPath: "channels.demo-nested.accounts.work.dm.",
approveHint: formatPairingApproveHint("demo-nested"),
normalizeEntry: undefined,
},
},
{
name: "supports custom defaults and approve hints",
input: {
cfg: cfgWithChannel("demo-default"),
channelKey: "demo-default",
fallbackAccountId: "default",
@@ -86,15 +84,18 @@ describe("buildAccountScopedDmSecurityPolicy", () => {
defaultPolicy: "allowlist",
policyPathSuffix: "dmPolicy",
approveHint: "openclaw pairing approve demo-default <code>",
}),
).toEqual({
policy: "allowlist",
allowFrom: ["user-1"],
policyPath: "channels.demo-default.dmPolicy",
allowFromPath: "channels.demo-default.",
approveHint: "openclaw pairing approve demo-default <code>",
normalizeEntry: undefined,
});
},
expected: {
policy: "allowlist",
allowFrom: ["user-1"],
policyPath: "channels.demo-default.dmPolicy",
allowFromPath: "channels.demo-default.",
approveHint: "openclaw pairing approve demo-default <code>",
normalizeEntry: undefined,
},
},
])("$name", ({ input, expected }) => {
expect(buildAccountScopedDmSecurityPolicy(input)).toEqual(expected);
});
});

View File

@@ -128,35 +128,79 @@ async function runPromptSingleToken(params: {
});
}
function createSecretInputPrompter(params: {
selects: string[];
confirms?: boolean[];
texts?: string[];
}) {
const selects = [...params.selects];
const confirms = [...(params.confirms ?? [])];
const texts = [...(params.texts ?? [])];
return {
select: vi.fn(async () => selects.shift() ?? "plaintext"),
confirm: vi.fn(async () => confirms.shift() ?? false),
text: vi.fn(async () => texts.shift() ?? ""),
note: vi.fn(async () => undefined),
};
}
async function runPromptSingleChannelSecretInput(params: {
prompter: ReturnType<typeof createSecretInputPrompter>;
providerHint: string;
credentialLabel: string;
accountConfigured: boolean;
canUseEnv: boolean;
hasConfigToken: boolean;
preferredEnvVar: string;
}) {
return await promptSingleChannelSecretInput({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: params.prompter as any,
providerHint: params.providerHint,
credentialLabel: params.credentialLabel,
accountConfigured: params.accountConfigured,
canUseEnv: params.canUseEnv,
hasConfigToken: params.hasConfigToken,
envPrompt: "use env",
keepPrompt: "keep",
inputPrompt: "token",
preferredEnvVar: params.preferredEnvVar,
});
}
describe("buildSingleChannelSecretPromptState", () => {
it("enables env path only when env is present and no config token exists", () => {
expect(
buildSingleChannelSecretPromptState({
it.each([
{
name: "enables env path only when env is present and no config token exists",
input: {
accountConfigured: false,
hasConfigToken: false,
allowEnv: true,
envValue: "token-from-env",
}),
).toEqual({
accountConfigured: false,
hasConfigToken: false,
canUseEnv: true,
});
});
it("disables env path when config token already exists", () => {
expect(
buildSingleChannelSecretPromptState({
},
expected: {
accountConfigured: false,
hasConfigToken: false,
canUseEnv: true,
},
},
{
name: "disables env path when config token already exists",
input: {
accountConfigured: true,
hasConfigToken: true,
allowEnv: true,
envValue: "token-from-env",
}),
).toEqual({
accountConfigured: true,
hasConfigToken: true,
canUseEnv: false,
});
},
expected: {
accountConfigured: true,
hasConfigToken: true,
canUseEnv: false,
},
},
])("$name", ({ input, expected }) => {
expect(buildSingleChannelSecretPromptState(input)).toEqual(expected);
});
});
@@ -334,74 +378,80 @@ describe("promptLegacyChannelAllowFromForAccount", () => {
});
describe("promptSingleChannelToken", () => {
it("uses env tokens when confirmed", async () => {
const prompter = createTokenPrompter({ confirms: [true], texts: [] });
it.each([
{
name: "uses env tokens when confirmed",
confirms: [true],
texts: [],
state: {
accountConfigured: false,
canUseEnv: true,
hasConfigToken: false,
},
expected: { useEnv: true, token: null },
expectTextCalls: 0,
},
{
name: "prompts for token when env exists but user declines env",
confirms: [false],
texts: ["abc"],
state: {
accountConfigured: false,
canUseEnv: true,
hasConfigToken: false,
},
expected: { useEnv: false, token: "abc" },
expectTextCalls: 1,
},
{
name: "keeps existing configured token when confirmed",
confirms: [true],
texts: [],
state: {
accountConfigured: true,
canUseEnv: false,
hasConfigToken: true,
},
expected: { useEnv: false, token: null },
expectTextCalls: 0,
},
{
name: "prompts for token when no env/config token is used",
confirms: [false],
texts: ["xyz"],
state: {
accountConfigured: true,
canUseEnv: false,
hasConfigToken: false,
},
expected: { useEnv: false, token: "xyz" },
expectTextCalls: 1,
},
])("$name", async ({ confirms, texts, state, expected, expectTextCalls }) => {
const prompter = createTokenPrompter({ confirms, texts });
const result = await runPromptSingleToken({
prompter,
accountConfigured: false,
canUseEnv: true,
hasConfigToken: false,
...state,
});
expect(result).toEqual({ useEnv: true, token: null });
expect(prompter.text).not.toHaveBeenCalled();
});
it("prompts for token when env exists but user declines env", async () => {
const prompter = createTokenPrompter({ confirms: [false], texts: ["abc"] });
const result = await runPromptSingleToken({
prompter,
accountConfigured: false,
canUseEnv: true,
hasConfigToken: false,
});
expect(result).toEqual({ useEnv: false, token: "abc" });
});
it("keeps existing configured token when confirmed", async () => {
const prompter = createTokenPrompter({ confirms: [true], texts: [] });
const result = await runPromptSingleToken({
prompter,
accountConfigured: true,
canUseEnv: false,
hasConfigToken: true,
});
expect(result).toEqual({ useEnv: false, token: null });
expect(prompter.text).not.toHaveBeenCalled();
});
it("prompts for token when no env/config token is used", async () => {
const prompter = createTokenPrompter({ confirms: [false], texts: ["xyz"] });
const result = await runPromptSingleToken({
prompter,
accountConfigured: true,
canUseEnv: false,
hasConfigToken: false,
});
expect(result).toEqual({ useEnv: false, token: "xyz" });
expect(result).toEqual(expected);
expect(prompter.text).toHaveBeenCalledTimes(expectTextCalls);
});
});
describe("promptSingleChannelSecretInput", () => {
it("returns use-env action when plaintext mode selects env fallback", async () => {
const prompter = {
select: vi.fn(async () => "plaintext"),
confirm: vi.fn(async () => true),
text: vi.fn(async () => ""),
note: vi.fn(async () => undefined),
};
const prompter = createSecretInputPrompter({
selects: ["plaintext"],
confirms: [true],
});
const result = await promptSingleChannelSecretInput({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
const result = await runPromptSingleChannelSecretInput({
prompter,
providerHint: "telegram",
credentialLabel: "Telegram bot token",
accountConfigured: false,
canUseEnv: true,
hasConfigToken: false,
envPrompt: "use env",
keepPrompt: "keep",
inputPrompt: "token",
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
});
@@ -410,25 +460,18 @@ describe("promptSingleChannelSecretInput", () => {
it("returns ref + resolved value when external env ref is selected", async () => {
process.env.OPENCLAW_TEST_TOKEN = "secret-token";
const prompter = {
select: vi.fn().mockResolvedValueOnce("ref").mockResolvedValueOnce("env"),
confirm: vi.fn(async () => false),
text: vi.fn(async () => "OPENCLAW_TEST_TOKEN"),
note: vi.fn(async () => undefined),
};
const prompter = createSecretInputPrompter({
selects: ["ref", "env"],
texts: ["OPENCLAW_TEST_TOKEN"],
});
const result = await promptSingleChannelSecretInput({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
const result = await runPromptSingleChannelSecretInput({
prompter,
providerHint: "discord",
credentialLabel: "Discord bot token",
accountConfigured: false,
canUseEnv: false,
hasConfigToken: false,
envPrompt: "use env",
keepPrompt: "keep",
inputPrompt: "token",
preferredEnvVar: "OPENCLAW_TEST_TOKEN",
});
@@ -444,25 +487,18 @@ describe("promptSingleChannelSecretInput", () => {
});
it("returns keep action when ref mode keeps an existing configured ref", async () => {
const prompter = {
select: vi.fn(async () => "ref"),
confirm: vi.fn(async () => true),
text: vi.fn(async () => ""),
note: vi.fn(async () => undefined),
};
const prompter = createSecretInputPrompter({
selects: ["ref"],
confirms: [true],
});
const result = await promptSingleChannelSecretInput({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
const result = await runPromptSingleChannelSecretInput({
prompter,
providerHint: "telegram",
credentialLabel: "Telegram bot token",
accountConfigured: true,
canUseEnv: false,
hasConfigToken: true,
envPrompt: "use env",
keepPrompt: "keep",
inputPrompt: "token",
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
});

View File

@@ -7,25 +7,27 @@ import {
} from "./threading-helpers.js";
describe("createStaticReplyToModeResolver", () => {
it("always returns the configured mode", () => {
expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off");
expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all");
it.each(["off", "all"] as const)("always returns the configured mode %s", (mode) => {
expect(createStaticReplyToModeResolver(mode)({ cfg: {} as OpenClawConfig })).toBe(mode);
});
});
describe("createTopLevelChannelReplyToModeResolver", () => {
it("reads the top-level channel config", () => {
const resolver = createTopLevelChannelReplyToModeResolver("demo-top-level");
expect(
resolver({
cfg: { channels: { "demo-top-level": { replyToMode: "first" } } } as OpenClawConfig,
}),
).toBe("first");
});
const resolver = createTopLevelChannelReplyToModeResolver("demo-top-level");
it("falls back to off", () => {
const resolver = createTopLevelChannelReplyToModeResolver("demo-top-level");
expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off");
it.each([
{
name: "reads the top-level channel config",
cfg: { channels: { "demo-top-level": { replyToMode: "first" } } } as OpenClawConfig,
expected: "first",
},
{
name: "falls back to off",
cfg: {} as OpenClawConfig,
expected: "off",
},
])("$name", ({ cfg, expected }) => {
expect(resolver({ cfg })).toBe(expected);
});
});

View File

@@ -51,6 +51,19 @@ function createPromptAndCredentialSpies(params?: { confirmResult?: boolean; text
};
}
function setMinimaxEnv(params: { apiKey?: string; oauthToken?: string } = {}) {
if (params.apiKey === undefined) {
delete process.env.MINIMAX_API_KEY;
} else {
process.env.MINIMAX_API_KEY = params.apiKey; // pragma: allowlist secret
}
if (params.oauthToken === undefined) {
delete process.env.MINIMAX_OAUTH_TOKEN;
} else {
process.env.MINIMAX_OAUTH_TOKEN = params.oauthToken; // pragma: allowlist secret
}
}
async function ensureMinimaxApiKey(params: {
config?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["config"];
env?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["env"];
@@ -114,8 +127,7 @@ async function ensureMinimaxApiKeyWithEnvRefPrompter(params: {
}
async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; textResult: string }) {
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;
setMinimaxEnv({ apiKey: "env-key" });
const { confirm, text } = createPromptSpies({
confirmResult: params.confirmResult,
@@ -193,19 +205,15 @@ describe("normalizeTokenProviderInput", () => {
});
describe("maybeApplyApiKeyFromOption", () => {
it("stores normalized token when provider matches", async () => {
const { result, setCredential } = await runMaybeApplyDemoToken("demo-provider");
it.each(["demo-provider", " DeMo-PrOvIdEr "])(
"stores normalized token when provider %p matches",
async (tokenProvider) => {
const { result, setCredential } = await runMaybeApplyDemoToken(tokenProvider);
expect(result).toBe("opt-key");
expect(setCredential).toHaveBeenCalledWith("opt-key", undefined);
});
it("matches provider with whitespace/case normalization", async () => {
const { result, setCredential } = await runMaybeApplyDemoToken(" DeMo-PrOvIdEr ");
expect(result).toBe("opt-key");
expect(setCredential).toHaveBeenCalledWith("opt-key", undefined);
});
expect(result).toBe("opt-key");
expect(setCredential).toHaveBeenCalledWith("opt-key", undefined);
},
);
it("skips when provider does not match", async () => {
const setCredential = vi.fn(async () => undefined);
@@ -251,8 +259,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
});
it("uses explicit inline env ref when secret-input-mode=ref selects existing env key", async () => {
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;
setMinimaxEnv({ apiKey: "env-key" });
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
confirmResult: true,
@@ -272,8 +279,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
});
it("fails ref mode without select when fallback env var is missing", async () => {
delete process.env.MINIMAX_API_KEY;
delete process.env.MINIMAX_OAUTH_TOKEN;
setMinimaxEnv();
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
confirmResult: true,
@@ -294,8 +300,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
});
it("uses explicit env for ref fallback instead of host process env", async () => {
process.env.MINIMAX_API_KEY = "host-key"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;
setMinimaxEnv({ apiKey: "host-key" });
const env = { MINIMAX_API_KEY: "explicit-key" } as NodeJS.ProcessEnv;
const { confirm, text, setCredential } = createPromptAndCredentialSpies({
@@ -316,8 +321,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
});
it("re-prompts after provider ref validation failure and succeeds with env ref", async () => {
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;
setMinimaxEnv({ apiKey: "env-key" });
const selectValues: Array<"provider" | "env" | "filemain"> = ["provider", "filemain", "env"];
const select = vi.fn(async () => selectValues.shift() ?? "env") as WizardPrompter["select"];
@@ -355,8 +359,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
});
it("never includes resolved env secret values in reference validation notes", async () => {
process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value"; // pragma: allowlist secret
delete process.env.MINIMAX_OAUTH_TOKEN;
setMinimaxEnv({ apiKey: "sk-minimax-redacted-value" });
const select = vi.fn(async () => "env") as WizardPrompter["select"];
const text = vi.fn<WizardPrompter["text"]>().mockResolvedValue("MINIMAX_API_KEY");
@@ -407,8 +410,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => {
});
it("falls back to env flow and shows note when opts provider does not match", async () => {
delete process.env.MINIMAX_OAUTH_TOKEN;
process.env.MINIMAX_API_KEY = "env-key"; // pragma: allowlist secret
setMinimaxEnv({ apiKey: "env-key" });
const { confirm, note, text, setCredential } = createPromptAndCredentialSpies({
confirmResult: true,

View File

@@ -160,6 +160,14 @@ async function withAllowFromCacheReadSpy(params: {
readSpy.mockRestore();
}
async function seedDefaultAccountAllowFromFixture(stateDir: string) {
await seedTelegramAllowFromFixtures({
stateDir,
scopedAccountId: DEFAULT_ACCOUNT_ID,
scopedAllowFrom: ["1002"],
});
}
describe("pairing store", () => {
it("reuses pending code and reports created=false", async () => {
await withTempStateDir(async () => {
@@ -448,11 +456,7 @@ describe("pairing store", () => {
it("reads legacy channel-scoped allowFrom for default account", async () => {
await withTempStateDir(async (stateDir) => {
await seedTelegramAllowFromFixtures({
stateDir,
scopedAccountId: "default",
scopedAllowFrom: ["1002"],
});
await seedDefaultAccountAllowFromFixture(stateDir);
const scoped = await readChannelAllowFromStore("telegram", process.env, DEFAULT_ACCOUNT_ID);
expect(scoped).toEqual(["1002", "1001"]);
@@ -461,11 +465,7 @@ describe("pairing store", () => {
it("uses default-account allowFrom when account id is omitted", async () => {
await withTempStateDir(async (stateDir) => {
await seedTelegramAllowFromFixtures({
stateDir,
scopedAccountId: DEFAULT_ACCOUNT_ID,
scopedAllowFrom: ["1002"],
});
await seedDefaultAccountAllowFromFixture(stateDir);
const asyncScoped = await readChannelAllowFromStore("telegram", process.env);
const syncScoped = readChannelAllowFromStoreSync("telegram", process.env);
@@ -474,22 +474,23 @@ describe("pairing store", () => {
});
});
it("reuses cached async allowFrom reads and invalidates on file updates", async () => {
it.each([
{
label: "async",
createReadSpy: () => vi.spyOn(fs, "readFile"),
readAllowFrom: () => readChannelAllowFromStore("telegram", process.env, "yy"),
},
{
label: "sync",
createReadSpy: () => vi.spyOn(fsSync, "readFileSync"),
readAllowFrom: async () => readChannelAllowFromStoreSync("telegram", process.env, "yy"),
},
])("reuses cached $label allowFrom reads and invalidates on file updates", async (variant) => {
await withTempStateDir(async (stateDir) => {
await withAllowFromCacheReadSpy({
stateDir,
createReadSpy: () => vi.spyOn(fs, "readFile"),
readAllowFrom: () => readChannelAllowFromStore("telegram", process.env, "yy"),
});
});
});
it("reuses cached sync allowFrom reads and invalidates on file updates", async () => {
await withTempStateDir(async (stateDir) => {
await withAllowFromCacheReadSpy({
stateDir,
createReadSpy: () => vi.spyOn(fsSync, "readFileSync"),
readAllowFrom: async () => readChannelAllowFromStoreSync("telegram", process.env, "yy"),
createReadSpy: variant.createReadSpy,
readAllowFrom: variant.readAllowFrom,
});
});
});

View File

@@ -23,6 +23,36 @@ import {
const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID;
function createMergedReaderCfg(
channelId: "whatsapp" | "imessage",
accountConfig: { allowFrom: string[]; defaultTo: string },
) {
return {
channels: {
[channelId]: {
allowFrom: ["root"],
defaultTo: " root:chat ",
accounts: {
alt: accountConfig,
},
},
},
};
}
function createConfigWritesCfg() {
return {
channels: {
telegram: {
configWrites: true,
accounts: {
Work: { configWrites: false },
},
},
},
};
}
function expectAdapterAllowFromAndDefaultTo(adapter: unknown) {
const channelAdapter = adapter as {
resolveAllowFrom?: (params: { cfg: object; accountId: string }) => unknown;
@@ -46,108 +76,92 @@ function expectAdapterAllowFromAndDefaultTo(adapter: unknown) {
}
describe("mapAllowFromEntries", () => {
it("coerces allowFrom entries to strings", () => {
expect(mapAllowFromEntries(["user", 42])).toEqual(["user", "42"]);
});
it("returns empty list for missing input", () => {
expect(mapAllowFromEntries(undefined)).toEqual([]);
it.each([
{
name: "coerces allowFrom entries to strings",
input: ["user", 42],
expected: ["user", "42"],
},
{
name: "returns empty list for missing input",
input: undefined,
expected: [],
},
])("$name", ({ input, expected }) => {
expect(mapAllowFromEntries(input)).toEqual(expected);
});
});
describe("resolveOptionalConfigString", () => {
it("trims and returns string values", () => {
expect(resolveOptionalConfigString(" room:123 ")).toBe("room:123");
});
it("coerces numeric values", () => {
expect(resolveOptionalConfigString(123)).toBe("123");
});
it("returns undefined for empty values", () => {
expect(resolveOptionalConfigString(" ")).toBeUndefined();
expect(resolveOptionalConfigString(undefined)).toBeUndefined();
it.each([
{
name: "trims and returns string values",
input: " room:123 ",
expected: "room:123",
},
{
name: "coerces numeric values",
input: 123,
expected: "123",
},
{
name: "returns undefined for empty string values",
input: " ",
expected: undefined,
},
{
name: "returns undefined for missing values",
input: undefined,
expected: undefined,
},
])("$name", ({ input, expected }) => {
expect(resolveOptionalConfigString(input)).toBe(expected);
});
});
describe("provider config readers", () => {
it("reads merged WhatsApp allowFrom/defaultTo without the channel registry", () => {
const cfg = {
channels: {
whatsapp: {
allowFrom: ["root"],
defaultTo: " root:chat ",
accounts: {
alt: {
allowFrom: ["49123", "42"],
defaultTo: " alt:chat ",
},
},
},
},
};
expect(resolveWhatsAppConfigAllowFrom({ cfg, accountId: "alt" })).toEqual(["49123", "42"]);
expect(resolveWhatsAppConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
});
it("reads merged iMessage allowFrom/defaultTo without the channel registry", () => {
const cfg = {
channels: {
imessage: {
allowFrom: ["root"],
defaultTo: " root:chat ",
accounts: {
alt: {
allowFrom: ["chat_id:9", "user@example.com"],
defaultTo: " alt:chat ",
},
},
},
},
};
expect(resolveIMessageConfigAllowFrom({ cfg, accountId: "alt" })).toEqual([
"chat_id:9",
"user@example.com",
]);
expect(resolveIMessageConfigDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
it.each([
{
name: "reads merged WhatsApp allowFrom/defaultTo without the channel registry",
cfg: createMergedReaderCfg("whatsapp", {
allowFrom: ["49123", "42"],
defaultTo: " alt:chat ",
}),
resolveAllowFrom: resolveWhatsAppConfigAllowFrom,
resolveDefaultTo: resolveWhatsAppConfigDefaultTo,
expectedAllowFrom: ["49123", "42"],
},
{
name: "reads merged iMessage allowFrom/defaultTo without the channel registry",
cfg: createMergedReaderCfg("imessage", {
allowFrom: ["chat_id:9", "user@example.com"],
defaultTo: " alt:chat ",
}),
resolveAllowFrom: resolveIMessageConfigAllowFrom,
resolveDefaultTo: resolveIMessageConfigDefaultTo,
expectedAllowFrom: ["chat_id:9", "user@example.com"],
},
])("$name", ({ cfg, resolveAllowFrom, resolveDefaultTo, expectedAllowFrom }) => {
expect(resolveAllowFrom({ cfg, accountId: "alt" })).toEqual(expectedAllowFrom);
expect(resolveDefaultTo({ cfg, accountId: "alt" })).toBe("alt:chat");
});
});
describe("config write helpers", () => {
it("matches account ids case-insensitively", () => {
const cfg = {
channels: {
telegram: {
configWrites: true,
accounts: {
Work: { configWrites: false },
},
},
},
};
expect(resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId: "work" })).toBe(
false,
);
expect(
resolveChannelConfigWrites({
cfg: createConfigWritesCfg(),
channelId: "telegram",
accountId: "work",
}),
).toBe(false);
});
it("blocks account-scoped writes when the configured account key differs only by case", () => {
const cfg = {
channels: {
telegram: {
configWrites: true,
accounts: {
Work: { configWrites: false },
},
},
},
};
expect(
authorizeConfigWrite({
cfg,
cfg: createConfigWritesCfg(),
target: {
kind: "account",
scope: { channelId: "telegram", accountId: "work" },

View File

@@ -71,39 +71,45 @@ describe("sendPayloadWithChunkedTextAndMedia", () => {
});
describe("resolveOutboundMediaUrls", () => {
it("prefers mediaUrls over the legacy single-media field", () => {
expect(
resolveOutboundMediaUrls({
it.each([
{
name: "prefers mediaUrls over the legacy single-media field",
payload: {
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
mediaUrl: "https://example.com/legacy.png",
}),
).toEqual(["https://example.com/a.png", "https://example.com/b.png"]);
});
it("falls back to the legacy single-media field", () => {
expect(
resolveOutboundMediaUrls({
},
expected: ["https://example.com/a.png", "https://example.com/b.png"],
},
{
name: "falls back to the legacy single-media field",
payload: {
mediaUrl: "https://example.com/legacy.png",
}),
).toEqual(["https://example.com/legacy.png"]);
},
expected: ["https://example.com/legacy.png"],
},
])("$name", ({ payload, expected }) => {
expect(resolveOutboundMediaUrls(payload)).toEqual(expected);
});
});
describe("countOutboundMedia", () => {
it("counts normalized media entries", () => {
expect(
countOutboundMedia({
it.each([
{
name: "counts normalized media entries",
payload: {
mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"],
}),
).toBe(2);
});
it("counts legacy single-media payloads", () => {
expect(
countOutboundMedia({
},
expected: 2,
},
{
name: "counts legacy single-media payloads",
payload: {
mediaUrl: "https://example.com/legacy.png",
}),
).toBe(1);
},
expected: 1,
},
])("$name", ({ payload, expected }) => {
expect(countOutboundMedia(payload)).toBe(expected);
});
});
@@ -116,33 +122,76 @@ describe("hasOutboundMedia", () => {
});
describe("hasOutboundText", () => {
it("checks raw text presence by default", () => {
expect(hasOutboundText({ text: "hello" })).toBe(true);
expect(hasOutboundText({ text: " " })).toBe(true);
expect(hasOutboundText({})).toBe(false);
});
it("can trim whitespace-only text", () => {
expect(hasOutboundText({ text: " " }, { trim: true })).toBe(false);
expect(hasOutboundText({ text: " hi " }, { trim: true })).toBe(true);
it.each([
{
name: "checks raw text presence by default",
payload: { text: "hello" },
options: undefined,
expected: true,
},
{
name: "treats whitespace-only text as present by default",
payload: { text: " " },
options: undefined,
expected: true,
},
{
name: "returns false when text is missing",
payload: {},
options: undefined,
expected: false,
},
{
name: "can trim whitespace-only text",
payload: { text: " " },
options: { trim: true },
expected: false,
},
{
name: "keeps non-empty trimmed text",
payload: { text: " hi " },
options: { trim: true },
expected: true,
},
])("$name", ({ payload, options, expected }) => {
expect(hasOutboundText(payload, options)).toBe(expected);
});
});
describe("hasOutboundReplyContent", () => {
it("detects text or media content", () => {
expect(hasOutboundReplyContent({ text: "hello" })).toBe(true);
expect(hasOutboundReplyContent({ mediaUrl: "https://example.com/a.png" })).toBe(true);
expect(hasOutboundReplyContent({})).toBe(false);
});
it("can ignore whitespace-only text unless media exists", () => {
expect(hasOutboundReplyContent({ text: " " }, { trimText: true })).toBe(false);
expect(
hasOutboundReplyContent(
{ text: " ", mediaUrls: ["https://example.com/a.png"] },
{ trimText: true },
),
).toBe(true);
it.each([
{
name: "detects text content",
payload: { text: "hello" },
options: undefined,
expected: true,
},
{
name: "detects media content",
payload: { mediaUrl: "https://example.com/a.png" },
options: undefined,
expected: true,
},
{
name: "returns false when text and media are both missing",
payload: {},
options: undefined,
expected: false,
},
{
name: "can ignore whitespace-only text",
payload: { text: " " },
options: { trimText: true },
expected: false,
},
{
name: "still reports content when trimmed text is blank but media exists",
payload: { text: " ", mediaUrls: ["https://example.com/a.png"] },
options: { trimText: true },
expected: true,
},
])("$name", ({ payload, options, expected }) => {
expect(hasOutboundReplyContent(payload, options)).toBe(expected);
});
});
@@ -186,16 +235,27 @@ describe("resolveSendableOutboundReplyParts", () => {
});
describe("resolveTextChunksWithFallback", () => {
it("returns existing chunks unchanged", () => {
expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]);
});
it("falls back to the full text when chunkers return nothing", () => {
expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]);
});
it("returns empty for empty text with no chunks", () => {
expect(resolveTextChunksWithFallback("", [])).toEqual([]);
it.each([
{
name: "returns existing chunks unchanged",
text: "hello",
chunks: ["a", "b"],
expected: ["a", "b"],
},
{
name: "falls back to the full text when chunkers return nothing",
text: "hello",
chunks: [],
expected: ["hello"],
},
{
name: "returns empty for empty text with no chunks",
text: "",
chunks: [],
expected: [],
},
])("$name", ({ text, chunks, expected }) => {
expect(resolveTextChunksWithFallback(text, chunks)).toEqual(expected);
});
});

View File

@@ -11,44 +11,157 @@ import {
createDefaultChannelRuntimeState,
} from "./status-helpers.js";
describe("createDefaultChannelRuntimeState", () => {
it("builds default runtime state without extra fields", () => {
expect(createDefaultChannelRuntimeState("default")).toEqual({
accountId: "default",
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
});
});
const defaultRuntimeState = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
it("merges extra fields into the default runtime state", () => {
expect(
createDefaultChannelRuntimeState("alerts", {
type ExpectedAccountSnapshot = {
accountId: string;
name?: string;
enabled?: boolean;
configured?: boolean;
running: boolean;
lastStartAt: number | null;
lastStopAt: number | null;
lastError: string | null;
probe?: unknown;
lastInboundAt: number | null;
lastOutboundAt: number | null;
} & Record<string, unknown>;
const defaultChannelSummary = {
configured: false,
...defaultRuntimeState,
};
const defaultTokenChannelSummary = {
...defaultChannelSummary,
tokenSource: "none",
mode: null,
probe: undefined,
lastProbeAt: null,
};
const defaultAccountSnapshot: ExpectedAccountSnapshot = {
accountId: "default",
name: undefined,
enabled: undefined,
configured: false,
...defaultRuntimeState,
probe: undefined,
lastInboundAt: null,
lastOutboundAt: null,
};
function expectedAccountSnapshot(
overrides: Partial<ExpectedAccountSnapshot> = {},
): ExpectedAccountSnapshot {
return {
...defaultAccountSnapshot,
...overrides,
};
}
const adapterAccount = {
accountId: "default",
enabled: true,
profileUrl: "https://example.test",
};
const adapterRuntime = {
accountId: "default",
running: true,
};
const adapterProbe = { ok: true };
function expectedAdapterAccountSnapshot() {
return {
...expectedAccountSnapshot({
enabled: true,
configured: true,
running: true,
probe: adapterProbe,
}),
profileUrl: adapterAccount.profileUrl,
connected: true,
};
}
function createComputedStatusAdapter() {
return createComputedAccountStatusAdapter<
{ accountId: string; enabled: boolean; profileUrl: string },
{ ok: boolean }
>({
defaultRuntime: createDefaultChannelRuntimeState("default"),
resolveAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: true,
extra: {
profileUrl: account.profileUrl,
connected: runtime?.running ?? false,
probe,
},
}),
});
}
function createAsyncStatusAdapter() {
return createAsyncComputedAccountStatusAdapter<
{ accountId: string; enabled: boolean; profileUrl: string },
{ ok: boolean }
>({
defaultRuntime: createDefaultChannelRuntimeState("default"),
resolveAccountSnapshot: async ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: true,
extra: {
profileUrl: account.profileUrl,
connected: runtime?.running ?? false,
probe,
},
}),
});
}
describe("createDefaultChannelRuntimeState", () => {
it.each([
{
name: "builds default runtime state without extra fields",
accountId: "default",
extra: undefined,
expected: {
accountId: "default",
...defaultRuntimeState,
},
},
{
name: "merges extra fields into the default runtime state",
accountId: "alerts",
extra: {
probeAt: 123,
healthy: true,
}),
).toEqual({
accountId: "alerts",
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probeAt: 123,
healthy: true,
});
},
expected: {
accountId: "alerts",
...defaultRuntimeState,
probeAt: 123,
healthy: true,
},
},
])("$name", ({ accountId, extra, expected }) => {
expect(createDefaultChannelRuntimeState(accountId, extra)).toEqual(expected);
});
});
describe("buildBaseChannelStatusSummary", () => {
it("defaults missing values", () => {
expect(buildBaseChannelStatusSummary({})).toEqual({
configured: false,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
});
expect(buildBaseChannelStatusSummary({})).toEqual(defaultChannelSummary);
});
it("keeps explicit values", () => {
@@ -61,6 +174,7 @@ describe("buildBaseChannelStatusSummary", () => {
lastError: "boom",
}),
).toEqual({
...defaultChannelSummary,
configured: true,
running: true,
lastStartAt: 1,
@@ -81,13 +195,10 @@ describe("buildBaseChannelStatusSummary", () => {
},
),
).toEqual({
...defaultChannelSummary,
configured: true,
mode: "webhook",
secretSource: "env",
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
});
});
});
@@ -98,19 +209,7 @@ describe("buildBaseAccountStatusSnapshot", () => {
buildBaseAccountStatusSnapshot({
account: { accountId: "default", enabled: true, configured: true },
}),
).toEqual({
accountId: "default",
name: undefined,
enabled: true,
configured: true,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
lastInboundAt: null,
lastOutboundAt: null,
});
).toEqual(expectedAccountSnapshot({ enabled: true, configured: true }));
});
it("merges extra snapshot fields after the shared account shape", () => {
@@ -125,17 +224,7 @@ describe("buildBaseAccountStatusSnapshot", () => {
},
),
).toEqual({
accountId: "default",
name: undefined,
enabled: undefined,
configured: true,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
lastInboundAt: null,
lastOutboundAt: null,
...expectedAccountSnapshot({ configured: true }),
connected: true,
mode: "polling",
});
@@ -150,19 +239,7 @@ describe("buildComputedAccountStatusSnapshot", () => {
enabled: true,
configured: false,
}),
).toEqual({
accountId: "default",
name: undefined,
enabled: true,
configured: false,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
lastInboundAt: null,
lastOutboundAt: null,
});
).toEqual(expectedAccountSnapshot({ enabled: true }));
});
it("merges computed extras after the shared fields", () => {
@@ -177,173 +254,100 @@ describe("buildComputedAccountStatusSnapshot", () => {
},
),
).toEqual({
accountId: "default",
name: undefined,
enabled: undefined,
configured: true,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
lastInboundAt: null,
lastOutboundAt: null,
...expectedAccountSnapshot({ configured: true }),
connected: true,
});
});
});
describe("createComputedAccountStatusAdapter", () => {
it("builds account snapshots from computed account metadata and extras", () => {
const status = createComputedAccountStatusAdapter<
{ accountId: string; enabled: boolean; profileUrl: string },
{ ok: boolean }
>({
defaultRuntime: createDefaultChannelRuntimeState("default"),
resolveAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: true,
extra: {
profileUrl: account.profileUrl,
connected: runtime?.running ?? false,
probe,
},
}),
});
expect(
status.buildAccountSnapshot?.({
account: { accountId: "default", enabled: true, profileUrl: "https://example.test" },
cfg: {} as never,
runtime: { accountId: "default", running: true },
probe: { ok: true },
}),
).toEqual({
accountId: "default",
name: undefined,
enabled: true,
configured: true,
running: true,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: { ok: true },
lastInboundAt: null,
lastOutboundAt: null,
profileUrl: "https://example.test",
connected: true,
});
});
});
describe("createAsyncComputedAccountStatusAdapter", () => {
it("builds account snapshots from async computed account metadata and extras", async () => {
const status = createAsyncComputedAccountStatusAdapter<
{ accountId: string; enabled: boolean; profileUrl: string },
{ ok: boolean }
>({
defaultRuntime: createDefaultChannelRuntimeState("default"),
resolveAccountSnapshot: async ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: true,
extra: {
profileUrl: account.profileUrl,
connected: runtime?.running ?? false,
probe,
},
}),
});
await expect(
status.buildAccountSnapshot?.({
account: { accountId: "default", enabled: true, profileUrl: "https://example.test" },
cfg: {} as never,
runtime: { accountId: "default", running: true },
probe: { ok: true },
}),
).resolves.toEqual({
accountId: "default",
name: undefined,
enabled: true,
configured: true,
running: true,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: { ok: true },
lastInboundAt: null,
lastOutboundAt: null,
profileUrl: "https://example.test",
connected: true,
});
});
describe("computed account status adapters", () => {
it.each([
{
name: "sync",
createStatus: createComputedStatusAdapter,
},
{
name: "async",
createStatus: createAsyncStatusAdapter,
},
])(
"builds account snapshots from $name computed account metadata and extras",
async ({ createStatus }) => {
const status = createStatus();
await expect(
Promise.resolve(
status.buildAccountSnapshot?.({
account: adapterAccount,
cfg: {} as never,
runtime: adapterRuntime,
probe: adapterProbe,
}),
),
).resolves.toEqual(expectedAdapterAccountSnapshot());
},
);
});
describe("buildRuntimeAccountStatusSnapshot", () => {
it("builds runtime lifecycle fields with defaults", () => {
expect(buildRuntimeAccountStatusSnapshot({})).toEqual({
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
});
});
it("merges extra fields into runtime snapshots", () => {
expect(buildRuntimeAccountStatusSnapshot({}, { port: 3978 })).toEqual({
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
port: 3978,
});
it.each([
{
name: "builds runtime lifecycle fields with defaults",
input: {},
extra: undefined,
expected: {
...defaultRuntimeState,
probe: undefined,
},
},
{
name: "merges extra fields into runtime snapshots",
input: {},
extra: { port: 3978 },
expected: {
...defaultRuntimeState,
probe: undefined,
port: 3978,
},
},
])("$name", ({ input, extra, expected }) => {
expect(buildRuntimeAccountStatusSnapshot(input, extra)).toEqual(expected);
});
});
describe("buildTokenChannelStatusSummary", () => {
it("includes token/probe fields with mode by default", () => {
expect(buildTokenChannelStatusSummary({})).toEqual({
configured: false,
tokenSource: "none",
running: false,
mode: null,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
lastProbeAt: null,
});
});
it("can omit mode for channels without a mode state", () => {
expect(
buildTokenChannelStatusSummary(
{
configured: true,
tokenSource: "env",
running: true,
lastStartAt: 1,
lastStopAt: 2,
lastError: "boom",
probe: { ok: true },
lastProbeAt: 3,
},
{ includeMode: false },
),
).toEqual({
configured: true,
tokenSource: "env",
running: true,
lastStartAt: 1,
lastStopAt: 2,
lastError: "boom",
probe: { ok: true },
lastProbeAt: 3,
});
it.each([
{
name: "includes token/probe fields with mode by default",
input: {},
options: undefined,
expected: defaultTokenChannelSummary,
},
{
name: "can omit mode for channels without a mode state",
input: {
configured: true,
tokenSource: "env",
running: true,
lastStartAt: 1,
lastStopAt: 2,
lastError: "boom",
probe: { ok: true },
lastProbeAt: 3,
},
options: { includeMode: false },
expected: {
configured: true,
tokenSource: "env",
running: true,
lastStartAt: 1,
lastStopAt: 2,
lastError: "boom",
probe: { ok: true },
lastProbeAt: 3,
},
},
])("$name", ({ input, options, expected }) => {
expect(buildTokenChannelStatusSummary(input, options)).toEqual(expected);
});
});

View File

@@ -23,44 +23,49 @@ function createPluginSourceRoots() {
}
describe("formatPluginSourceForTable", () => {
it("shortens bundled plugin sources under the stock root", () => {
const roots = createPluginSourceRoots();
const out = formatPluginSourceForTable(
{
origin: "bundled",
source: path.join(roots.stock, "demo-stock", "index.ts"),
},
roots,
);
expect(out.value).toBe("stock:demo-stock/index.ts");
expect(out.rootKey).toBe("stock");
});
it("shortens workspace plugin sources under the workspace root", () => {
const roots = createPluginSourceRoots();
const out = formatPluginSourceForTable(
{
origin: "workspace",
source: path.join(roots.workspace, "demo-workspace", "index.ts"),
},
roots,
);
expect(out.value).toBe("workspace:demo-workspace/index.ts");
expect(out.rootKey).toBe("workspace");
});
it("shortens global plugin sources under the global root", () => {
const roots = createPluginSourceRoots();
const out = formatPluginSourceForTable(
{
origin: "global",
source: path.join(roots.global, "demo-global", "index.js"),
},
roots,
);
expect(out.value).toBe("global:demo-global/index.js");
expect(out.rootKey).toBe("global");
});
it.each([
{
name: "bundled plugin sources under the stock root",
origin: "bundled" as const,
sourceKey: "stock" as const,
dirName: "demo-stock",
fileName: "index.ts",
expectedValue: "stock:demo-stock/index.ts",
expectedRootKey: "stock" as const,
},
{
name: "workspace plugin sources under the workspace root",
origin: "workspace" as const,
sourceKey: "workspace" as const,
dirName: "demo-workspace",
fileName: "index.ts",
expectedValue: "workspace:demo-workspace/index.ts",
expectedRootKey: "workspace" as const,
},
{
name: "global plugin sources under the global root",
origin: "global" as const,
sourceKey: "global" as const,
dirName: "demo-global",
fileName: "index.js",
expectedValue: "global:demo-global/index.js",
expectedRootKey: "global" as const,
},
])(
"shortens $name",
({ origin, sourceKey, dirName, fileName, expectedValue, expectedRootKey }) => {
const roots = createPluginSourceRoots();
const out = formatPluginSourceForTable(
{
origin,
source: path.join(roots[sourceKey], dirName, fileName),
},
roots,
);
expect(out.value).toBe(expectedValue);
expect(out.rootKey).toBe(expectedRootKey);
},
);
it("resolves source roots from an explicit env override", () => {
const homeDir = path.resolve(path.sep, "tmp", "openclaw-home");

View File

@@ -298,6 +298,14 @@ describe("security/dm-policy-shared", () => {
expectedReactionAllowed: boolean;
};
type DecisionCase = {
name: string;
input: Parameters<typeof resolveDmGroupAccessDecision>[0];
expected:
| ReturnType<typeof resolveDmGroupAccessDecision>
| Pick<ReturnType<typeof resolveDmGroupAccessDecision>, "decision">;
};
function createParityCase({
name,
...overrides
@@ -387,97 +395,113 @@ describe("security/dm-policy-shared", () => {
}
});
for (const channel of channels) {
it(`[${channel}] blocks groups when group allowlist is empty`, () => {
const decision = resolveDmGroupAccessDecision({
const decisionCases: DecisionCase[] = [
{
name: "blocks groups when group allowlist is empty",
input: {
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
},
expected: {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST,
reason: "groupPolicy=allowlist (empty allowlist)",
});
});
it(`[${channel}] allows groups when group policy is open`, () => {
const decision = resolveDmGroupAccessDecision({
},
},
{
name: "allows groups when group policy is open",
input: {
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "open",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
},
expected: {
decision: "allow",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_ALLOWED,
reason: "groupPolicy=open",
});
});
it(`[${channel}] blocks DM allowlist mode when allowlist is empty`, () => {
const decision = resolveDmGroupAccessDecision({
},
},
{
name: "blocks DM allowlist mode when allowlist is empty",
input: {
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
},
expected: {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED,
reason: "dmPolicy=allowlist (not allowlisted)",
});
});
it(`[${channel}] uses pairing flow when DM sender is not allowlisted`, () => {
const decision = resolveDmGroupAccessDecision({
},
},
{
name: "uses pairing flow when DM sender is not allowlisted",
input: {
isGroup: false,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
},
expected: {
decision: "pairing",
reasonCode: DM_GROUP_ACCESS_REASON.DM_POLICY_PAIRING_REQUIRED,
reason: "dmPolicy=pairing (not allowlisted)",
});
});
it(`[${channel}] allows DM sender when allowlisted`, () => {
const decision = resolveDmGroupAccessDecision({
},
},
{
name: "allows DM sender when allowlisted",
input: {
isGroup: false,
dmPolicy: "allowlist",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: [],
isSenderAllowed: () => true,
});
expect(decision.decision).toBe("allow");
});
it(`[${channel}] blocks group allowlist mode when sender/group is not allowlisted`, () => {
const decision = resolveDmGroupAccessDecision({
},
expected: {
decision: "allow",
},
},
{
name: "blocks group allowlist mode when sender/group is not allowlisted",
input: {
isGroup: true,
dmPolicy: "pairing",
groupPolicy: "allowlist",
effectiveAllowFrom: ["owner"],
effectiveGroupAllowFrom: ["group:abc"],
isSenderAllowed: () => false,
});
expect(decision).toEqual({
},
expected: {
decision: "block",
reasonCode: DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED,
reason: "groupPolicy=allowlist (not allowlisted)",
},
},
];
for (const channel of channels) {
for (const testCase of decisionCases) {
it(`[${channel}] ${testCase.name}`, () => {
const decision = resolveDmGroupAccessDecision(testCase.input);
if ("reasonCode" in testCase.expected && "reason" in testCase.expected) {
expect(decision).toEqual(testCase.expected);
return;
}
expect(decision).toMatchObject(testCase.expected);
});
});
}
}
});

View File

@@ -4,6 +4,18 @@ import type { SessionEntry } from "../config/sessions.js";
import { resolveSendPolicy } from "./send-policy.js";
describe("resolveSendPolicy", () => {
const cfgWithRules = (
rules: NonNullable<NonNullable<OpenClawConfig["session"]>["sendPolicy"]>["rules"],
) =>
({
session: {
sendPolicy: {
default: "allow",
rules,
},
},
}) as OpenClawConfig;
it("defaults to allow", () => {
const cfg = {} as OpenClawConfig;
expect(resolveSendPolicy({ cfg })).toBe("allow");
@@ -21,55 +33,40 @@ describe("resolveSendPolicy", () => {
expect(resolveSendPolicy({ cfg, entry })).toBe("deny");
});
it("rule match by channel + chatType", () => {
const cfg = {
session: {
sendPolicy: {
default: "allow",
rules: [
{
action: "deny",
match: { channel: "demo-channel", chatType: "group" },
},
],
},
},
} as OpenClawConfig;
const entry: SessionEntry = {
sessionId: "s",
updatedAt: 0,
channel: "demo-channel",
chatType: "group",
};
expect(resolveSendPolicy({ cfg, entry, sessionKey: "demo-channel:group:dev" })).toBe("deny");
});
it("rule match by keyPrefix", () => {
const cfg = {
session: {
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
},
},
} as OpenClawConfig;
expect(resolveSendPolicy({ cfg, sessionKey: "cron:job-1" })).toBe("deny");
});
it("rule match by rawKeyPrefix", () => {
const cfg = {
session: {
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { rawKeyPrefix: "agent:main:demo-channel:" } }],
},
},
} as OpenClawConfig;
expect(resolveSendPolicy({ cfg, sessionKey: "agent:main:demo-channel:group:dev" })).toBe(
"deny",
);
expect(resolveSendPolicy({ cfg, sessionKey: "agent:main:other-channel:group:dev" })).toBe(
"allow",
);
it.each([
{
name: "rule match by channel + chatType",
cfg: cfgWithRules([
{ action: "deny", match: { channel: "demo-channel", chatType: "group" } },
]),
entry: {
sessionId: "s",
updatedAt: 0,
channel: "demo-channel",
chatType: "group",
} as SessionEntry,
sessionKey: "demo-channel:group:dev",
expected: "deny",
},
{
name: "rule match by keyPrefix",
cfg: cfgWithRules([{ action: "deny", match: { keyPrefix: "cron:" } }]),
sessionKey: "cron:job-1",
expected: "deny",
},
{
name: "rule match by rawKeyPrefix",
cfg: cfgWithRules([{ action: "deny", match: { rawKeyPrefix: "agent:main:demo-channel:" } }]),
sessionKey: "agent:main:demo-channel:group:dev",
expected: "deny",
},
{
name: "rawKeyPrefix does not match other channels",
cfg: cfgWithRules([{ action: "deny", match: { rawKeyPrefix: "agent:main:demo-channel:" } }]),
sessionKey: "agent:main:other-channel:group:dev",
expected: "allow",
},
])("$name", ({ cfg, entry, sessionKey, expected }) => {
expect(resolveSendPolicy({ cfg, entry, sessionKey })).toBe(expected);
});
});