diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 96e98bf3b9d..84b03c2e76b 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -408,15 +408,15 @@ describe("redactConfigSnapshot", () => { custom2: [{ mySecret: "this-is-a-custom-secret-value" }], }), assert: ({ redacted, restored }) => { - const cfg = redacted as Record>; - const cfgCustom2 = cfg.custom2 as unknown as unknown[]; + const cfg = redacted; + const cfgCustom2 = Array.isArray(cfg.custom2) ? cfg.custom2 : []; expect(cfgCustom2.length).toBeGreaterThan(0); expect( ((cfg.custom1 as Record).anykey as Record).mySecret, ).toBe(REDACTED_SENTINEL); expect((cfgCustom2[0] as Record).mySecret).toBe(REDACTED_SENTINEL); - const out = restored as Record>; - const outCustom2 = out.custom2 as unknown as unknown[]; + const out = restored; + const outCustom2 = Array.isArray(out.custom2) ? out.custom2 : []; expect(outCustom2.length).toBeGreaterThan(0); expect( ((out.custom1 as Record).anykey as Record).mySecret, @@ -437,15 +437,15 @@ describe("redactConfigSnapshot", () => { custom2: [{ mySecret: "this-is-a-custom-secret-value" }], }), assert: ({ redacted, restored }) => { - const cfg = redacted as Record>; - const cfgCustom2 = cfg.custom2 as unknown as unknown[]; + const cfg = redacted; + const cfgCustom2 = Array.isArray(cfg.custom2) ? cfg.custom2 : []; expect(cfgCustom2.length).toBeGreaterThan(0); expect( ((cfg.custom1 as Record).anykey as Record).mySecret, ).toBe(REDACTED_SENTINEL); expect((cfgCustom2[0] as Record).mySecret).toBe(REDACTED_SENTINEL); - const out = restored as Record>; - const outCustom2 = out.custom2 as unknown as unknown[]; + const out = restored; + const outCustom2 = Array.isArray(out.custom2) ? out.custom2 : []; expect(outCustom2.length).toBeGreaterThan(0); expect( ((out.custom1 as Record).anykey as Record).mySecret, diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index 29d9690f423..4a0e95e5cd8 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -1,5 +1,6 @@ import { ChannelType, type Guild } from "@buape/carbon"; import { describe, expect, it, vi } from "vitest"; +import { typedCases } from "../test-utils/typed-cases.js"; import { allowListMatches, buildDiscordMediaPayload, @@ -637,7 +638,11 @@ describe("discord autoThread name sanitization", () => { describe("discord reaction notification gating", () => { it("applies mode-specific reaction notification rules", () => { - const cases = [ + const cases = typedCases<{ + name: string; + input: Parameters[0]; + expected: boolean; + }>([ { name: "unset defaults to own (author is bot)", input: { @@ -721,7 +726,7 @@ describe("discord reaction notification gating", () => { }, expected: true, }, - ] as const; + ]); for (const testCase of cases) { expect( @@ -963,7 +968,18 @@ describe("discord reaction notification modes", () => { const guild = fakeGuild(guildId, "Mode Guild"); it("applies message-fetch behavior across notification modes and channel types", async () => { - const cases = [ + const cases = typedCases<{ + name: string; + reactionNotifications: "off" | "all" | "allowlist" | "own"; + users: string[] | undefined; + userId: string | undefined; + channelType: ChannelType; + channelId: string | undefined; + parentId: string | undefined; + messageAuthorId: string; + expectedMessageFetchCalls: number; + expectedEnqueueCalls: number; + }>([ { name: "off mode", reactionNotifications: "off" as const, @@ -1024,7 +1040,7 @@ describe("discord reaction notification modes", () => { expectedMessageFetchCalls: 0, expectedEnqueueCalls: 1, }, - ] as const; + ]); for (const testCase of cases) { enqueueSystemEventSpy.mockClear(); diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 9dd7a025b8d..fcc8fae9678 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -15,9 +15,10 @@ import { import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js"; import { buildAgentPeerSessionKey } from "../routing/session-key.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { typedCases } from "../test-utils/typed-cases.js"; import { - isHeartbeatEnabledForAgent, type HeartbeatDeps, + isHeartbeatEnabledForAgent, resolveHeartbeatIntervalMs, resolveHeartbeatPrompt, runHeartbeatOnce, @@ -680,7 +681,15 @@ describe("runHeartbeatOnce", () => { it("resolves configured and forced session key overrides", async () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { - const cases = [ + const cases = typedCases<{ + name: string; + caseDir: string; + peerKind: "group" | "direct"; + peerId: string; + message: string; + applyOverride: (params: { cfg: OpenClawConfig; sessionKey: string }) => void; + runOptions: (params: { sessionKey: string }) => { sessionKey?: string }; + }>([ { name: "heartbeat.session", caseDir: "hb-explicit-session", @@ -705,7 +714,7 @@ describe("runHeartbeatOnce", () => { applyOverride: () => {}, runOptions: ({ sessionKey }: { sessionKey: string }) => ({ sessionKey }), }, - ] as const; + ]); for (const testCase of cases) { const tmpDir = await createCaseDir(testCase.caseDir); @@ -835,12 +844,12 @@ describe("runHeartbeatOnce", () => { it("handles reasoning payload delivery variants", async () => { const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); try { - const cases: Array<{ + const cases = typedCases<{ name: string; caseDir: string; replies: Array<{ text: string }>; expectedTexts: string[]; - }> = [ + }>([ { name: "reasoning + final payload", caseDir: "hb-reasoning", @@ -853,7 +862,7 @@ describe("runHeartbeatOnce", () => { replies: [{ text: "Reasoning:\n_Because it helps_" }, { text: "HEARTBEAT_OK" }], expectedTexts: ["Reasoning:\n_Because it helps_"], }, - ]; + ]); for (const testCase of cases) { const tmpDir = await createCaseDir(testCase.caseDir); diff --git a/src/infra/outbound/envelope.ts b/src/infra/outbound/envelope.ts index cea05f56685..9cd9f84aba3 100644 --- a/src/infra/outbound/envelope.ts +++ b/src/infra/outbound/envelope.ts @@ -9,7 +9,7 @@ export type OutboundResultEnvelope = { }; type BuildEnvelopeParams = { - payloads?: ReplyPayload[] | OutboundPayloadJson[]; + payloads?: readonly ReplyPayload[] | readonly OutboundPayloadJson[]; meta?: unknown; delivery?: OutboundDeliveryJson; flattenDelivery?: boolean; @@ -29,8 +29,8 @@ export function buildOutboundResultEnvelope( : params.payloads.length === 0 ? [] : isOutboundPayloadJson(params.payloads[0]) - ? (params.payloads as OutboundPayloadJson[]) - : normalizeOutboundPayloadsForJson(params.payloads as ReplyPayload[]); + ? [...(params.payloads as readonly OutboundPayloadJson[])] + : normalizeOutboundPayloadsForJson(params.payloads as readonly ReplyPayload[]); if (params.flattenDelivery !== false && params.delivery && !params.meta && !hasPayloads) { return params.delivery; diff --git a/src/infra/outbound/outbound.test.ts b/src/infra/outbound/outbound.test.ts index e273bc51441..f07aff99054 100644 --- a/src/infra/outbound/outbound.test.ts +++ b/src/infra/outbound/outbound.test.ts @@ -8,6 +8,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { typedCases } from "../../test-utils/typed-cases.js"; import { ackDelivery, computeBackoffMs, @@ -447,7 +448,11 @@ describe("buildOutboundResultEnvelope", () => { mediaUrl: null, channelId: "C1", }; - const cases = [ + const cases = typedCases<{ + name: string; + input: Parameters[0]; + expected: unknown; + }>([ { name: "flatten delivery by default", input: { delivery: whatsappDelivery }, @@ -478,7 +483,7 @@ describe("buildOutboundResultEnvelope", () => { input: { delivery: discordDelivery, flattenDelivery: false }, expected: { delivery: discordDelivery }, }, - ]; + ]); for (const testCase of cases) { const input: Parameters[0] = "payloads" in testCase.input @@ -814,7 +819,10 @@ describe("resolveOutboundSessionRoute", () => { describe("normalizeOutboundPayloadsForJson", () => { it("normalizes payloads for JSON output", () => { - const cases = [ + const cases = typedCases<{ + input: Parameters[0]; + expected: ReturnType; + }>([ { input: [ { text: "hi" }, @@ -852,7 +860,7 @@ describe("normalizeOutboundPayloadsForJson", () => { }, ], }, - ]; + ]); for (const testCase of cases) { const input: ReplyPayload[] = testCase.input.map((payload) => @@ -878,7 +886,11 @@ describe("normalizeOutboundPayloads", () => { describe("formatOutboundPayloadLog", () => { it("formats text+media and media-only logs", () => { - const cases = [ + const cases = typedCases<{ + name: string; + input: Parameters[0]; + expected: string; + }>([ { name: "text with media lines", input: { @@ -895,7 +907,7 @@ describe("formatOutboundPayloadLog", () => { }, expected: "MEDIA:https://x.test/a.png", }, - ]; + ]); for (const testCase of cases) { expect( diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 888f3624e1c..f61261939c1 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -15,7 +15,7 @@ export type OutboundPayloadJson = { channelData?: Record; }; -function mergeMediaUrls(...lists: Array | undefined>): string[] { +function mergeMediaUrls(...lists: Array | undefined>): string[] { const seen = new Set(); const merged: string[] = []; for (const list of lists) { @@ -37,7 +37,9 @@ function mergeMediaUrls(...lists: Array | undefined>): return merged; } -export function normalizeReplyPayloadsForDelivery(payloads: ReplyPayload[]): ReplyPayload[] { +export function normalizeReplyPayloadsForDelivery( + payloads: readonly ReplyPayload[], +): ReplyPayload[] { return payloads.flatMap((payload) => { const parsed = parseReplyDirectives(payload.text ?? ""); const explicitMediaUrls = payload.mediaUrls ?? parsed.mediaUrls; @@ -68,7 +70,9 @@ export function normalizeReplyPayloadsForDelivery(payloads: ReplyPayload[]): Rep }); } -export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedOutboundPayload[] { +export function normalizeOutboundPayloads( + payloads: readonly ReplyPayload[], +): NormalizedOutboundPayload[] { return normalizeReplyPayloadsForDelivery(payloads) .map((payload) => { const channelData = payload.channelData; @@ -89,7 +93,9 @@ export function normalizeOutboundPayloads(payloads: ReplyPayload[]): NormalizedO ); } -export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): OutboundPayloadJson[] { +export function normalizeOutboundPayloadsForJson( + payloads: readonly ReplyPayload[], +): OutboundPayloadJson[] { return normalizeReplyPayloadsForDelivery(payloads).map((payload) => ({ text: payload.text ?? "", mediaUrl: payload.mediaUrl ?? null, @@ -98,7 +104,11 @@ export function normalizeOutboundPayloadsForJson(payloads: ReplyPayload[]): Outb })); } -export function formatOutboundPayloadLog(payload: NormalizedOutboundPayload): string { +export function formatOutboundPayloadLog( + payload: Pick & { + mediaUrls: readonly string[]; + }, +): string { const lines: string[] = []; if (payload.text) { lines.push(payload.text.trimEnd()); diff --git a/src/telegram/button-types.ts b/src/telegram/button-types.ts index 09c687b3320..922b72acd9f 100644 --- a/src/telegram/button-types.ts +++ b/src/telegram/button-types.ts @@ -6,4 +6,4 @@ export type TelegramInlineButton = { style?: TelegramButtonStyle; }; -export type TelegramInlineButtons = TelegramInlineButton[][]; +export type TelegramInlineButtons = ReadonlyArray>; diff --git a/src/telegram/format.wrap-md.test.ts b/src/telegram/format.wrap-md.test.ts index 37ef4e80916..d059f950cae 100644 --- a/src/telegram/format.wrap-md.test.ts +++ b/src/telegram/format.wrap-md.test.ts @@ -237,12 +237,12 @@ describe("edge cases", () => { ] as const; for (const testCase of cases) { const result = markdownToTelegramHtml(testCase.input); - if ("contains" in testCase) { + if ("contains" in testCase && testCase.contains) { for (const expected of testCase.contains) { expect(result, testCase.name).toContain(expected); } } - if ("notContains" in testCase) { + if ("notContains" in testCase && testCase.notContains) { for (const unexpected of testCase.notContains) { expect(result, testCase.name).not.toContain(unexpected); } @@ -301,7 +301,7 @@ describe("edge cases", () => { if ("expectedExact" in testCase) { expect(result, testCase.name).toBe(testCase.expectedExact); } - if ("contains" in testCase) { + if ("contains" in testCase && testCase.contains) { for (const expected of testCase.contains) { expect(result, testCase.name).toContain(expected); } diff --git a/src/telegram/model-buttons.ts b/src/telegram/model-buttons.ts index 03f74dae918..86e54a07524 100644 --- a/src/telegram/model-buttons.ts +++ b/src/telegram/model-buttons.ts @@ -23,7 +23,7 @@ export type ProviderInfo = { export type ModelsKeyboardParams = { provider: string; - models: string[]; + models: readonly string[]; currentModel?: string; currentPage: number; totalPages: number; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 4abccf8290d..6eb633e5445 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -596,22 +596,22 @@ describe("sendMessageTelegram", () => { fileName: "video.mp4", }); - const opts: Parameters[2] = { + const sendOptions: NonNullable[2]> = { token: "tok", api, mediaUrl: "https://example.com/video.mp4", asVideoNote: true, - ...("replyToMessageId" in testCase.options - ? { replyToMessageId: testCase.options.replyToMessageId } - : {}), - ...(Array.isArray(testCase.options.buttons) - ? { - buttons: testCase.options.buttons.map((row) => row.map((button) => ({ ...button }))), - } - : {}), }; - - await sendMessageTelegram(chatId, testCase.text, opts); + if ( + "replyToMessageId" in testCase.options && + testCase.options.replyToMessageId !== undefined + ) { + sendOptions.replyToMessageId = testCase.options.replyToMessageId; + } + if ("buttons" in testCase.options && testCase.options.buttons) { + sendOptions.buttons = testCase.options.buttons; + } + await sendMessageTelegram(chatId, testCase.text, sendOptions); expect(sendVideoNote).toHaveBeenCalledWith( chatId, @@ -790,8 +790,12 @@ describe("sendMessageTelegram", () => { api, mediaUrl: testCase.mediaUrl, ...("asVoice" in testCase && testCase.asVoice ? { asVoice: true } : {}), - ...("messageThreadId" in testCase ? { messageThreadId: testCase.messageThreadId } : {}), - ...("replyToMessageId" in testCase ? { replyToMessageId: testCase.replyToMessageId } : {}), + ...("messageThreadId" in testCase && testCase.messageThreadId !== undefined + ? { messageThreadId: testCase.messageThreadId } + : {}), + ...("replyToMessageId" in testCase && testCase.replyToMessageId !== undefined + ? { replyToMessageId: testCase.replyToMessageId } + : {}), }); const called = testCase.expectedMethod === "sendVoice" ? sendVoice : sendAudio; @@ -1321,13 +1325,13 @@ describe("editMessageTelegram", () => { if ("firstExpectNoReplyMarkup" in testCase && testCase.firstExpectNoReplyMarkup) { expect(firstParams, testCase.name).not.toHaveProperty("reply_markup"); } - if ("firstExpectReplyMarkup" in testCase) { + if ("firstExpectReplyMarkup" in testCase && testCase.firstExpectReplyMarkup) { expect(firstParams, testCase.name).toEqual( expect.objectContaining({ reply_markup: testCase.firstExpectReplyMarkup }), ); } - if ("secondExpectReplyMarkup" in testCase) { + if ("secondExpectReplyMarkup" in testCase && testCase.secondExpectReplyMarkup) { const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record< string, unknown diff --git a/src/test-utils/typed-cases.ts b/src/test-utils/typed-cases.ts new file mode 100644 index 00000000000..41fb0b47b2a --- /dev/null +++ b/src/test-utils/typed-cases.ts @@ -0,0 +1,3 @@ +export function typedCases(cases: T[]): T[] { + return cases; +}