diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index d882b847933..67edd11a134 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -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[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); diff --git a/src/channels/plugins/account-helpers.test.ts b/src/channels/plugins/account-helpers.test.ts index 0bc823ad8d4..3e297312c7d 100644 --- a/src/channels/plugins/account-helpers.test.ts +++ b/src/channels/plugins/account-helpers.test.ts @@ -36,16 +36,21 @@ function cfg(accounts?: Record | 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>[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(input)).toEqual(expected); }); it("deep-merges selected nested object keys after resolving the account", () => { diff --git a/src/channels/plugins/helpers.test.ts b/src/channels/plugins/helpers.test.ts index 4e62600e136..3b3330316a2 100644 --- a/src/channels/plugins/helpers.test.ts +++ b/src/channels/plugins/helpers.test.ts @@ -15,29 +15,29 @@ function cfgWithChannel(channelKey: string, accounts?: Record): } 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 ", - }), - ).toEqual({ - policy: "allowlist", - allowFrom: ["user-1"], - policyPath: "channels.demo-default.dmPolicy", - allowFromPath: "channels.demo-default.", - approveHint: "openclaw pairing approve demo-default ", - normalizeEntry: undefined, - }); + }, + expected: { + policy: "allowlist", + allowFrom: ["user-1"], + policyPath: "channels.demo-default.dmPolicy", + allowFromPath: "channels.demo-default.", + approveHint: "openclaw pairing approve demo-default ", + normalizeEntry: undefined, + }, + }, + ])("$name", ({ input, expected }) => { + expect(buildAccountScopedDmSecurityPolicy(input)).toEqual(expected); }); }); diff --git a/src/channels/plugins/setup-wizard-helpers.test.ts b/src/channels/plugins/setup-wizard-helpers.test.ts index 1bfb5fe6250..4a2eb56b204 100644 --- a/src/channels/plugins/setup-wizard-helpers.test.ts +++ b/src/channels/plugins/setup-wizard-helpers.test.ts @@ -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; + 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", }); diff --git a/src/channels/plugins/threading-helpers.test.ts b/src/channels/plugins/threading-helpers.test.ts index 4cc0c6c8cf3..09f2446144f 100644 --- a/src/channels/plugins/threading-helpers.test.ts +++ b/src/channels/plugins/threading-helpers.test.ts @@ -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); }); }); diff --git a/src/commands/auth-choice.apply-helpers.test.ts b/src/commands/auth-choice.apply-helpers.test.ts index b500664ca38..495eb4ed173 100644 --- a/src/commands/auth-choice.apply-helpers.test.ts +++ b/src/commands/auth-choice.apply-helpers.test.ts @@ -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[0]["config"]; env?: Parameters[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().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, diff --git a/src/pairing/pairing-store.test.ts b/src/pairing/pairing-store.test.ts index 46bc152d9ea..af9a973ac33 100644 --- a/src/pairing/pairing-store.test.ts +++ b/src/pairing/pairing-store.test.ts @@ -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, }); }); }); diff --git a/src/plugin-sdk/channel-config-helpers.test.ts b/src/plugin-sdk/channel-config-helpers.test.ts index 9921b6354c4..a82604823aa 100644 --- a/src/plugin-sdk/channel-config-helpers.test.ts +++ b/src/plugin-sdk/channel-config-helpers.test.ts @@ -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" }, diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index ce393a9ecd3..e63387dfbf8 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -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); }); }); diff --git a/src/plugin-sdk/status-helpers.test.ts b/src/plugin-sdk/status-helpers.test.ts index 73c1f66d27c..60786ba5f2e 100644 --- a/src/plugin-sdk/status-helpers.test.ts +++ b/src/plugin-sdk/status-helpers.test.ts @@ -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; + +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 { + 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); }); }); diff --git a/src/plugins/source-display.test.ts b/src/plugins/source-display.test.ts index 7fb1c023cb1..8ad099fb558 100644 --- a/src/plugins/source-display.test.ts +++ b/src/plugins/source-display.test.ts @@ -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"); diff --git a/src/security/dm-policy-shared.test.ts b/src/security/dm-policy-shared.test.ts index 5c0a2669da6..0b21a2bbc21 100644 --- a/src/security/dm-policy-shared.test.ts +++ b/src/security/dm-policy-shared.test.ts @@ -298,6 +298,14 @@ describe("security/dm-policy-shared", () => { expectedReactionAllowed: boolean; }; + type DecisionCase = { + name: string; + input: Parameters[0]; + expected: + | ReturnType + | Pick, "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); }); - }); + } } }); diff --git a/src/sessions/send-policy.test.ts b/src/sessions/send-policy.test.ts index 6ce41093d7f..4967142a649 100644 --- a/src/sessions/send-policy.test.ts +++ b/src/sessions/send-policy.test.ts @@ -4,6 +4,18 @@ import type { SessionEntry } from "../config/sessions.js"; import { resolveSendPolicy } from "./send-policy.js"; describe("resolveSendPolicy", () => { + const cfgWithRules = ( + rules: NonNullable["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); }); });