test: 添加一大堆测试文件

This commit is contained in:
claude-code-best
2026-04-02 20:28:08 +08:00
parent 6f5623b26c
commit ce29527a67
33 changed files with 3502 additions and 329 deletions

View File

@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test";
import { groupMessagesByApiRound } from "../grouping";
function makeMsg(type: "user" | "assistant" | "system", id: string): any {
return {
type,
message: { id, content: `${type}-${id}` },
};
}
describe("groupMessagesByApiRound", () => {
// Boundary fires when: assistant msg with NEW id AND current group has items
test("splits before first assistant if user messages precede it", () => {
const messages = [makeMsg("user", "u1"), makeMsg("assistant", "a1")];
const groups = groupMessagesByApiRound(messages);
// user msgs form group 1, assistant starts group 2
expect(groups).toHaveLength(2);
expect(groups[0]).toHaveLength(1);
expect(groups[1]).toHaveLength(1);
});
test("single assistant message forms one group", () => {
const messages = [makeMsg("assistant", "a1")];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
});
test("splits at new assistant message ID", () => {
const messages = [
makeMsg("user", "u1"),
makeMsg("assistant", "a1"),
makeMsg("assistant", "a2"),
];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(3);
});
test("keeps same-ID assistant messages in same group (streaming chunks)", () => {
const messages = [
makeMsg("assistant", "a1"),
makeMsg("assistant", "a1"),
makeMsg("assistant", "a1"),
];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(3);
});
test("returns empty array for empty input", () => {
expect(groupMessagesByApiRound([])).toEqual([]);
});
test("handles all user messages (no assistant)", () => {
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
});
test("three API rounds produce correct groups", () => {
const messages = [
makeMsg("user", "u1"),
makeMsg("assistant", "a1"),
makeMsg("user", "u2"),
makeMsg("assistant", "a2"),
makeMsg("user", "u3"),
makeMsg("assistant", "a3"),
];
const groups = groupMessagesByApiRound(messages);
// [u1], [a1, u2], [a2, u3], [a3] = 4 groups
expect(groups).toHaveLength(4);
});
test("consecutive user messages stay in same group", () => {
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
expect(groupMessagesByApiRound(messages)).toHaveLength(1);
});
test("does not produce empty groups", () => {
const messages = [
makeMsg("assistant", "a1"),
makeMsg("assistant", "a2"),
];
const groups = groupMessagesByApiRound(messages);
for (const group of groups) {
expect(group.length).toBeGreaterThan(0);
}
});
test("handles single message", () => {
expect(groupMessagesByApiRound([makeMsg("user", "u1")])).toHaveLength(1);
});
test("preserves message order within groups", () => {
const messages = [makeMsg("assistant", "a1"), makeMsg("user", "u2")];
const groups = groupMessagesByApiRound(messages);
expect(groups[0][0].message.id).toBe("a1");
expect(groups[0][1].message.id).toBe("u2");
});
test("handles system messages", () => {
const messages = [
makeMsg("system", "s1"),
makeMsg("assistant", "a1"),
];
// system msg is non-assistant, goes to current. Then assistant a1 is new ID
// and current has items, so split.
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(2);
});
test("tool_result after assistant stays in same round", () => {
const messages = [
makeMsg("assistant", "a1"),
makeMsg("user", "tool_result_1"),
makeMsg("assistant", "a1"), // same ID = no new boundary
];
const groups = groupMessagesByApiRound(messages);
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(3);
});
});

View File

@@ -0,0 +1,77 @@
import { mock, describe, expect, test } from "bun:test";
mock.module("bun:bundle", () => ({ feature: () => false }));
const { formatCompactSummary } = await import("../prompt");
describe("formatCompactSummary", () => {
test("strips <analysis>...</analysis> block", () => {
const input = "<analysis>my thought process</analysis>\n<summary>the summary</summary>";
const result = formatCompactSummary(input);
expect(result).not.toContain("<analysis>");
expect(result).not.toContain("my thought process");
});
test("replaces <summary>...</summary> with 'Summary:\\n' prefix", () => {
const input = "<summary>key points here</summary>";
const result = formatCompactSummary(input);
expect(result).toContain("Summary:");
expect(result).toContain("key points here");
expect(result).not.toContain("<summary>");
});
test("handles analysis + summary together", () => {
const input = "<analysis>thinking</analysis><summary>result</summary>";
const result = formatCompactSummary(input);
expect(result).not.toContain("thinking");
expect(result).toContain("result");
});
test("handles summary without analysis", () => {
const input = "<summary>just the summary</summary>";
const result = formatCompactSummary(input);
expect(result).toContain("just the summary");
});
test("handles analysis without summary", () => {
const input = "<analysis>just analysis</analysis>and some text";
const result = formatCompactSummary(input);
expect(result).not.toContain("just analysis");
expect(result).toContain("and some text");
});
test("collapses multiple newlines to double", () => {
const input = "hello\n\n\n\nworld";
const result = formatCompactSummary(input);
expect(result).not.toMatch(/\n{3,}/);
});
test("trims leading/trailing whitespace", () => {
const input = " \n hello \n ";
const result = formatCompactSummary(input);
expect(result).toBe("hello");
});
test("handles empty string", () => {
expect(formatCompactSummary("")).toBe("");
});
test("handles plain text without tags", () => {
const input = "just plain text";
expect(formatCompactSummary(input)).toBe("just plain text");
});
test("handles multiline analysis content", () => {
const input = "<analysis>\nline1\nline2\nline3\n</analysis><summary>ok</summary>";
const result = formatCompactSummary(input);
expect(result).not.toContain("line1");
expect(result).toContain("ok");
});
test("preserves content between analysis and summary", () => {
const input = "<analysis>thoughts</analysis>middle text<summary>final</summary>";
const result = formatCompactSummary(input);
expect(result).toContain("middle text");
expect(result).toContain("final");
});
});

View File

@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test";
// findChannelEntry extracted from ../channelNotification.ts (line 161)
// Copied to avoid heavy import chain
type ChannelEntry = {
kind: "server" | "plugin"
name: string
}
function findChannelEntry(
serverName: string,
channels: readonly ChannelEntry[],
): ChannelEntry | undefined {
const parts = serverName.split(":")
return channels.find(c =>
c.kind === "server"
? serverName === c.name
: parts[0] === "plugin" && parts[1] === c.name,
)
}
describe("findChannelEntry", () => {
test("finds server entry by exact name match", () => {
const channels = [{ kind: "server" as const, name: "my-server" }]
expect(findChannelEntry("my-server", channels)).toBeDefined()
expect(findChannelEntry("my-server", channels)!.name).toBe("my-server")
})
test("finds plugin entry by matching second segment", () => {
const channels = [{ kind: "plugin" as const, name: "slack" }]
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
})
test("returns undefined for no match", () => {
const channels = [{ kind: "server" as const, name: "other" }]
expect(findChannelEntry("my-server", channels)).toBeUndefined()
})
test("handles empty channels array", () => {
expect(findChannelEntry("my-server", [])).toBeUndefined()
})
test("handles server name without colon", () => {
const channels = [{ kind: "server" as const, name: "simple" }]
expect(findChannelEntry("simple", channels)).toBeDefined()
})
test("handles 'plugin:name' format correctly", () => {
const channels = [{ kind: "plugin" as const, name: "slack" }]
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined()
})
test("prefers exact match (server kind) over partial match", () => {
const channels = [
{ kind: "server" as const, name: "plugin:slack" },
{ kind: "plugin" as const, name: "slack" },
]
const result = findChannelEntry("plugin:slack", channels)
expect(result).toBeDefined()
expect(result!.kind).toBe("server")
})
test("plugin kind does not match bare name", () => {
const channels = [{ kind: "plugin" as const, name: "slack" }]
expect(findChannelEntry("slack", channels)).toBeUndefined()
})
})

View File

@@ -0,0 +1,165 @@
import { mock, describe, expect, test } from "bun:test";
mock.module("src/utils/slowOperations.js", () => ({
jsonStringify: (v: unknown) => JSON.stringify(v),
}));
mock.module("src/services/analytics/growthbook.js", () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
}));
const {
shortRequestId,
truncateForPreview,
PERMISSION_REPLY_RE,
createChannelPermissionCallbacks,
} = await import("../channelPermissions");
describe("shortRequestId", () => {
test("returns 5-char string from tool use ID", () => {
const result = shortRequestId("toolu_abc123");
expect(result).toHaveLength(5);
});
test("is deterministic (same input = same output)", () => {
const a = shortRequestId("toolu_abc123");
const b = shortRequestId("toolu_abc123");
expect(a).toBe(b);
});
test("different inputs produce different outputs", () => {
const a = shortRequestId("toolu_aaa");
const b = shortRequestId("toolu_bbb");
expect(a).not.toBe(b);
});
test("result contains only valid letters (no 'l')", () => {
const validChars = new Set("abcdefghijkmnopqrstuvwxyz");
for (let i = 0; i < 50; i++) {
const result = shortRequestId(`toolu_${i}`);
for (const ch of result) {
expect(validChars.has(ch)).toBe(true);
}
}
});
test("handles empty string", () => {
const result = shortRequestId("");
expect(result).toHaveLength(5);
});
});
describe("truncateForPreview", () => {
test("returns JSON string for object input", () => {
const result = truncateForPreview({ key: "value" });
expect(result).toBe('{"key":"value"}');
});
test("truncates to <=200 chars with ellipsis when input is long", () => {
const longObj = { data: "x".repeat(300) };
const result = truncateForPreview(longObj);
expect(result.length).toBeLessThanOrEqual(203); // 200 + '…'
expect(result.endsWith("…")).toBe(true);
});
test("returns short input unchanged", () => {
const result = truncateForPreview({ a: 1 });
expect(result).toBe('{"a":1}');
expect(result.endsWith("…")).toBe(false);
});
test("handles string input", () => {
const result = truncateForPreview("hello");
expect(result).toBe('"hello"');
});
test("handles null input", () => {
const result = truncateForPreview(null);
expect(result).toBe("null");
});
test("handles undefined input", () => {
const result = truncateForPreview(undefined);
// JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)'
expect(result).toBe("(unserializable)");
});
});
describe("PERMISSION_REPLY_RE", () => {
test("matches 'y abcde'", () => {
expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true);
});
test("matches 'yes abcde'", () => {
expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true);
});
test("matches 'n abcde'", () => {
expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true);
});
test("matches 'no abcde'", () => {
expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true);
});
test("is case-insensitive", () => {
expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true);
expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true);
});
test("does not match without ID", () => {
expect(PERMISSION_REPLY_RE.test("yes")).toBe(false);
});
test("captures the ID from reply", () => {
const match = "y abcde".match(PERMISSION_REPLY_RE);
expect(match?.[2]).toBe("abcde");
});
});
describe("createChannelPermissionCallbacks", () => {
test("resolve returns false for unknown request ID", () => {
const cb = createChannelPermissionCallbacks();
expect(cb.resolve("unknown-id", "allow", "server")).toBe(false);
});
test("onResponse + resolve triggers handler", () => {
const cb = createChannelPermissionCallbacks();
let received: any = null;
cb.onResponse("test-id", (response) => {
received = response;
});
expect(cb.resolve("test-id", "allow", "test-server")).toBe(true);
expect(received).toEqual({
behavior: "allow",
fromServer: "test-server",
});
});
test("onResponse unsubscribe prevents resolve", () => {
const cb = createChannelPermissionCallbacks();
let called = false;
const unsub = cb.onResponse("test-id", () => {
called = true;
});
unsub();
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
expect(called).toBe(false);
});
test("duplicate resolve returns false (already consumed)", () => {
const cb = createChannelPermissionCallbacks();
cb.onResponse("test-id", () => {});
expect(cb.resolve("test-id", "allow", "server")).toBe(true);
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
});
test("is case-insensitive for request IDs", () => {
const cb = createChannelPermissionCallbacks();
let received: any = null;
cb.onResponse("ABC", (response) => {
received = response;
});
expect(cb.resolve("abc", "deny", "server")).toBe(true);
expect(received?.behavior).toBe("deny");
});
});

View File

@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
// parseHeaders is a pure function from ../utils.ts (line 325)
// Copied here to avoid triggering the heavy import chain of utils.ts
function parseHeaders(headerArray: string[]): Record<string, string> {
const headers: Record<string, string> = {}
for (const header of headerArray) {
const colonIndex = header.indexOf(":")
if (colonIndex === -1) {
throw new Error(
`Invalid header format: "${header}". Expected format: "Header-Name: value"`,
)
}
const key = header.substring(0, colonIndex).trim()
const value = header.substring(colonIndex + 1).trim()
if (!key) {
throw new Error(
`Invalid header: "${header}". Header name cannot be empty.`,
)
}
headers[key] = value
}
return headers
}
describe("parseHeaders", () => {
test("parses 'Key: Value' format", () => {
expect(parseHeaders(["Content-Type: application/json"])).toEqual({
"Content-Type": "application/json",
});
});
test("parses multiple headers", () => {
expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({
Key1: "val1",
Key2: "val2",
});
});
test("trims whitespace around key and value", () => {
expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" });
});
test("throws on missing colon", () => {
expect(() => parseHeaders(["no colon here"])).toThrow();
});
test("throws on empty key", () => {
expect(() => parseHeaders([": value"])).toThrow();
});
test("handles value with colons (like URLs)", () => {
expect(parseHeaders(["url: http://example.com:8080"])).toEqual({
url: "http://example.com:8080",
});
});
test("returns empty object for empty array", () => {
expect(parseHeaders([])).toEqual({});
});
test("handles duplicate keys (last wins)", () => {
expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" });
});
});

View File

@@ -0,0 +1,45 @@
import { mock, describe, expect, test, afterEach } from "bun:test";
mock.module("axios", () => ({
default: { get: async () => ({ data: { servers: [] } }) },
}));
mock.module("src/utils/debug.js", () => ({
logForDebugging: () => {},
}));
mock.module("src/utils/errors.js", () => ({
errorMessage: (e: any) => String(e),
}));
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
"../officialRegistry"
);
describe("isOfficialMcpUrl", () => {
afterEach(() => {
resetOfficialMcpUrlsForTesting();
});
test("returns false when registry not loaded (initial state)", () => {
resetOfficialMcpUrlsForTesting();
expect(isOfficialMcpUrl("https://example.com")).toBe(false);
});
test("returns false for non-registered URL", () => {
expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false);
});
test("returns false for empty string", () => {
expect(isOfficialMcpUrl("")).toBe(false);
});
});
describe("resetOfficialMcpUrlsForTesting", () => {
test("can be called without error", () => {
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow();
});
test("clears state so subsequent lookups return false", () => {
resetOfficialMcpUrlsForTesting();
expect(isOfficialMcpUrl("https://anything.com")).toBe(false);
});
});