refactor(test): stabilize case tables and readonly helper inputs

This commit is contained in:
Peter Steinberger
2026-02-22 00:00:53 +01:00
parent 03586e3d00
commit 8752203f59
11 changed files with 106 additions and 52 deletions

View File

@@ -408,15 +408,15 @@ describe("redactConfigSnapshot", () => {
custom2: [{ mySecret: "this-is-a-custom-secret-value" }],
}),
assert: ({ redacted, restored }) => {
const cfg = redacted as Record<string, Record<string, unknown>>;
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<string, unknown>).anykey as Record<string, unknown>).mySecret,
).toBe(REDACTED_SENTINEL);
expect((cfgCustom2[0] as Record<string, unknown>).mySecret).toBe(REDACTED_SENTINEL);
const out = restored as Record<string, Record<string, unknown>>;
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<string, unknown>).anykey as Record<string, unknown>).mySecret,
@@ -437,15 +437,15 @@ describe("redactConfigSnapshot", () => {
custom2: [{ mySecret: "this-is-a-custom-secret-value" }],
}),
assert: ({ redacted, restored }) => {
const cfg = redacted as Record<string, Record<string, unknown>>;
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<string, unknown>).anykey as Record<string, unknown>).mySecret,
).toBe(REDACTED_SENTINEL);
expect((cfgCustom2[0] as Record<string, unknown>).mySecret).toBe(REDACTED_SENTINEL);
const out = restored as Record<string, Record<string, unknown>>;
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<string, unknown>).anykey as Record<string, unknown>).mySecret,

View File

@@ -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<typeof shouldEmitDiscordReactionNotification>[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();

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<typeof buildOutboundResultEnvelope>[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<typeof buildOutboundResultEnvelope>[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<typeof normalizeOutboundPayloadsForJson>[0];
expected: ReturnType<typeof normalizeOutboundPayloadsForJson>;
}>([
{
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<typeof formatOutboundPayloadLog>[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(

View File

@@ -15,7 +15,7 @@ export type OutboundPayloadJson = {
channelData?: Record<string, unknown>;
};
function mergeMediaUrls(...lists: Array<Array<string | undefined> | undefined>): string[] {
function mergeMediaUrls(...lists: Array<ReadonlyArray<string | undefined> | undefined>): string[] {
const seen = new Set<string>();
const merged: string[] = [];
for (const list of lists) {
@@ -37,7 +37,9 @@ function mergeMediaUrls(...lists: Array<Array<string | undefined> | 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<NormalizedOutboundPayload, "text" | "channelData"> & {
mediaUrls: readonly string[];
},
): string {
const lines: string[] = [];
if (payload.text) {
lines.push(payload.text.trimEnd());

View File

@@ -6,4 +6,4 @@ export type TelegramInlineButton = {
style?: TelegramButtonStyle;
};
export type TelegramInlineButtons = TelegramInlineButton[][];
export type TelegramInlineButtons = ReadonlyArray<ReadonlyArray<TelegramInlineButton>>;

View File

@@ -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);
}

View File

@@ -23,7 +23,7 @@ export type ProviderInfo = {
export type ModelsKeyboardParams = {
provider: string;
models: string[];
models: readonly string[];
currentModel?: string;
currentPage: number;
totalPages: number;

View File

@@ -596,22 +596,22 @@ describe("sendMessageTelegram", () => {
fileName: "video.mp4",
});
const opts: Parameters<typeof sendMessageTelegram>[2] = {
const sendOptions: NonNullable<Parameters<typeof sendMessageTelegram>[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

View File

@@ -0,0 +1,3 @@
export function typedCases<T>(cases: T[]): T[] {
return cases;
}