diff --git a/src/__tests__/Tool.test.ts b/src/__tests__/Tool.test.ts index 07398b2..569cd2d 100644 --- a/src/__tests__/Tool.test.ts +++ b/src/__tests__/Tool.test.ts @@ -1,201 +1,207 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' import { buildTool, toolMatchesName, findToolByName, getEmptyToolPermissionContext, filterToolProgressMessages, -} from "../Tool"; +} from '../Tool' // Minimal tool definition for testing buildTool function makeMinimalToolDef(overrides: Record = {}) { return { - name: "TestTool", - inputSchema: { type: "object" as const } as any, + name: 'TestTool', + inputSchema: { type: 'object' as const } as any, maxResultSizeChars: 10000, - call: async () => ({ data: "ok" }), - description: async () => "A test tool", - prompt: async () => "test prompt", - mapToolResultToToolResultBlockParam: (content: unknown, toolUseID: string) => ({ - type: "tool_result" as const, + call: async () => ({ data: 'ok' }), + description: async () => 'A test tool', + prompt: async () => 'test prompt', + mapToolResultToToolResultBlockParam: ( + content: unknown, + toolUseID: string, + ) => ({ + type: 'tool_result' as const, tool_use_id: toolUseID, content: String(content), }), renderToolUseMessage: () => null, ...overrides, - }; + } } -describe("buildTool", () => { - test("fills in default isEnabled as true", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.isEnabled()).toBe(true); - }); +describe('buildTool', () => { + test('fills in default isEnabled as true', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.isEnabled()).toBe(true) + }) - test("fills in default isConcurrencySafe as false", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.isConcurrencySafe({})).toBe(false); - }); + test('fills in default isConcurrencySafe as false', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.isConcurrencySafe({})).toBe(false) + }) - test("fills in default isReadOnly as false", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.isReadOnly({})).toBe(false); - }); + test('fills in default isReadOnly as false', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.isReadOnly({})).toBe(false) + }) - test("fills in default isDestructive as false", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.isDestructive!({})).toBe(false); - }); + test('fills in default isDestructive as false', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.isDestructive!({})).toBe(false) + }) - test("fills in default checkPermissions as allow", async () => { - const tool = buildTool(makeMinimalToolDef()); - const input = { foo: "bar" }; - const result = await tool.checkPermissions(input, {} as any); - expect(result).toEqual({ behavior: "allow", updatedInput: input }); - }); + test('fills in default checkPermissions as allow', async () => { + const tool = buildTool(makeMinimalToolDef()) + const input = { foo: 'bar' } + const result = await tool.checkPermissions(input, {} as any) + expect(result).toEqual({ behavior: 'allow', updatedInput: input }) + }) - test("fills in default userFacingName from tool name", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.userFacingName(undefined)).toBe("TestTool"); - }); + test('fills in default userFacingName from tool name', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.userFacingName(undefined)).toBe('TestTool') + }) - test("fills in default toAutoClassifierInput as empty string", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.toAutoClassifierInput({})).toBe(""); - }); + test('fills in default toAutoClassifierInput as empty string', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.toAutoClassifierInput({})).toBe('') + }) - test("preserves explicitly provided methods", () => { + test('preserves explicitly provided methods', () => { const tool = buildTool( makeMinimalToolDef({ isEnabled: () => false, isConcurrencySafe: () => true, isReadOnly: () => true, - }) - ); - expect(tool.isEnabled()).toBe(false); - expect(tool.isConcurrencySafe({})).toBe(true); - expect(tool.isReadOnly({})).toBe(true); - }); + }), + ) + expect(tool.isEnabled()).toBe(false) + expect(tool.isConcurrencySafe({})).toBe(true) + expect(tool.isReadOnly({})).toBe(true) + }) - test("preserves all non-defaultable properties", () => { - const tool = buildTool(makeMinimalToolDef()); - expect(tool.name).toBe("TestTool"); - expect(tool.maxResultSizeChars).toBe(10000); - expect(typeof tool.call).toBe("function"); - expect(typeof tool.description).toBe("function"); - expect(typeof tool.prompt).toBe("function"); - }); -}); + test('preserves all non-defaultable properties', () => { + const tool = buildTool(makeMinimalToolDef()) + expect(tool.name).toBe('TestTool') + expect(tool.maxResultSizeChars).toBe(10000) + expect(typeof tool.call).toBe('function') + expect(typeof tool.description).toBe('function') + expect(typeof tool.prompt).toBe('function') + }) +}) -describe("toolMatchesName", () => { - test("returns true for exact name match", () => { - expect(toolMatchesName({ name: "Bash" }, "Bash")).toBe(true); - }); +describe('toolMatchesName', () => { + test('returns true for exact name match', () => { + expect(toolMatchesName({ name: 'Bash' }, 'Bash')).toBe(true) + }) - test("returns false for non-matching name", () => { - expect(toolMatchesName({ name: "Bash" }, "Read")).toBe(false); - }); + test('returns false for non-matching name', () => { + expect(toolMatchesName({ name: 'Bash' }, 'Read')).toBe(false) + }) - test("returns true when name matches an alias", () => { + test('returns true when name matches an alias', () => { expect( - toolMatchesName({ name: "Bash", aliases: ["BashTool", "Shell"] }, "BashTool") - ).toBe(true); - }); + toolMatchesName( + { name: 'Bash', aliases: ['BashTool', 'Shell'] }, + 'BashTool', + ), + ).toBe(true) + }) - test("returns false when aliases is undefined", () => { - expect(toolMatchesName({ name: "Bash" }, "BashTool")).toBe(false); - }); + test('returns false when aliases is undefined', () => { + expect(toolMatchesName({ name: 'Bash' }, 'BashTool')).toBe(false) + }) - test("returns false when aliases is empty", () => { - expect( - toolMatchesName({ name: "Bash", aliases: [] }, "BashTool") - ).toBe(false); - }); -}); + test('returns false when aliases is empty', () => { + expect(toolMatchesName({ name: 'Bash', aliases: [] }, 'BashTool')).toBe( + false, + ) + }) +}) -describe("findToolByName", () => { +describe('findToolByName', () => { const mockTools = [ - buildTool(makeMinimalToolDef({ name: "Bash" })), - buildTool(makeMinimalToolDef({ name: "Read", aliases: ["FileRead"] })), - buildTool(makeMinimalToolDef({ name: "Edit" })), - ]; + buildTool(makeMinimalToolDef({ name: 'Bash' })), + buildTool(makeMinimalToolDef({ name: 'Read', aliases: ['FileRead'] })), + buildTool(makeMinimalToolDef({ name: 'Edit' })), + ] - test("finds tool by primary name", () => { - const tool = findToolByName(mockTools, "Bash"); - expect(tool).toBeDefined(); - expect(tool!.name).toBe("Bash"); - }); + test('finds tool by primary name', () => { + const tool = findToolByName(mockTools, 'Bash') + expect(tool).toBeDefined() + expect(tool!.name).toBe('Bash') + }) - test("finds tool by alias", () => { - const tool = findToolByName(mockTools, "FileRead"); - expect(tool).toBeDefined(); - expect(tool!.name).toBe("Read"); - }); + test('finds tool by alias', () => { + const tool = findToolByName(mockTools, 'FileRead') + expect(tool).toBeDefined() + expect(tool!.name).toBe('Read') + }) - test("returns undefined when no match", () => { - expect(findToolByName(mockTools, "NonExistent")).toBeUndefined(); - }); + test('returns undefined when no match', () => { + expect(findToolByName(mockTools, 'NonExistent')).toBeUndefined() + }) - test("returns first match when duplicates exist", () => { + test('returns first match when duplicates exist', () => { const dupeTools = [ - buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 100 })), - buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 200 })), - ]; - const tool = findToolByName(dupeTools, "Bash"); - expect(tool!.maxResultSizeChars).toBe(100); - }); -}); + buildTool(makeMinimalToolDef({ name: 'Bash', maxResultSizeChars: 100 })), + buildTool(makeMinimalToolDef({ name: 'Bash', maxResultSizeChars: 200 })), + ] + const tool = findToolByName(dupeTools, 'Bash') + expect(tool!.maxResultSizeChars).toBe(100) + }) +}) -describe("getEmptyToolPermissionContext", () => { - test("returns default permission mode", () => { - const ctx = getEmptyToolPermissionContext(); - expect(ctx.mode).toBe("default"); - }); +describe('getEmptyToolPermissionContext', () => { + test('returns default permission mode', () => { + const ctx = getEmptyToolPermissionContext() + expect(ctx.mode).toBe('default') + }) - test("returns empty maps and arrays", () => { - const ctx = getEmptyToolPermissionContext(); - expect(ctx.additionalWorkingDirectories.size).toBe(0); - expect(ctx.alwaysAllowRules).toEqual({}); - expect(ctx.alwaysDenyRules).toEqual({}); - expect(ctx.alwaysAskRules).toEqual({}); - }); + test('returns empty maps and arrays', () => { + const ctx = getEmptyToolPermissionContext() + expect(ctx.additionalWorkingDirectories.size).toBe(0) + expect(ctx.alwaysAllowRules).toEqual({}) + expect(ctx.alwaysDenyRules).toEqual({}) + expect(ctx.alwaysAskRules).toEqual({}) + }) - test("returns isBypassPermissionsModeAvailable as false", () => { - const ctx = getEmptyToolPermissionContext(); - expect(ctx.isBypassPermissionsModeAvailable).toBe(false); - }); -}); + test('returns isBypassPermissionsModeAvailable as false', () => { + const ctx = getEmptyToolPermissionContext() + expect(ctx.isBypassPermissionsModeAvailable).toBe(false) + }) +}) -describe("filterToolProgressMessages", () => { - test("filters out hook_progress messages", () => { +describe('filterToolProgressMessages', () => { + test('filters out hook_progress messages', () => { const messages = [ - { data: { type: "hook_progress", hookName: "pre" } }, - { data: { type: "tool_progress", toolName: "Bash" } }, - ] as any[]; - const result = filterToolProgressMessages(messages); - expect(result).toHaveLength(1); - expect((result[0]!.data as any).type).toBe("tool_progress"); - }); + { data: { type: 'hook_progress', hookName: 'pre' } }, + { data: { type: 'tool_progress', toolName: 'Bash' } }, + ] as any[] + const result = filterToolProgressMessages(messages) + expect(result).toHaveLength(1) + expect((result[0]!.data as any).type).toBe('tool_progress') + }) - test("keeps tool progress messages", () => { + test('keeps tool progress messages', () => { const messages = [ - { data: { type: "tool_progress", toolName: "Bash" } }, - { data: { type: "tool_progress", toolName: "Read" } }, - ] as any[]; - const result = filterToolProgressMessages(messages); - expect(result).toHaveLength(2); - }); + { data: { type: 'tool_progress', toolName: 'Bash' } }, + { data: { type: 'tool_progress', toolName: 'Read' } }, + ] as any[] + const result = filterToolProgressMessages(messages) + expect(result).toHaveLength(2) + }) - test("returns empty array for empty input", () => { - expect(filterToolProgressMessages([])).toEqual([]); - }); + test('returns empty array for empty input', () => { + expect(filterToolProgressMessages([])).toEqual([]) + }) - test("handles messages without type field", () => { + test('handles messages without type field', () => { const messages = [ - { data: { toolName: "Bash" } }, - { data: { type: "hook_progress" } }, - ] as any[]; - const result = filterToolProgressMessages(messages); - expect(result).toHaveLength(1); - }); -}); + { data: { toolName: 'Bash' } }, + { data: { type: 'hook_progress' } }, + ] as any[] + const result = filterToolProgressMessages(messages) + expect(result).toHaveLength(1) + }) +}) diff --git a/src/__tests__/history.test.ts b/src/__tests__/history.test.ts index e38eb7d..ef49e7e 100644 --- a/src/__tests__/history.test.ts +++ b/src/__tests__/history.test.ts @@ -1,167 +1,167 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' import { getPastedTextRefNumLines, formatPastedTextRef, formatImageRef, parseReferences, expandPastedTextRefs, -} from "../history"; +} from '../history' -describe("getPastedTextRefNumLines", () => { - test("returns 0 for single line (no newline)", () => { - expect(getPastedTextRefNumLines("hello")).toBe(0); - }); +describe('getPastedTextRefNumLines', () => { + test('returns 0 for single line (no newline)', () => { + expect(getPastedTextRefNumLines('hello')).toBe(0) + }) - test("counts LF newlines", () => { - expect(getPastedTextRefNumLines("a\nb\nc")).toBe(2); - }); + test('counts LF newlines', () => { + expect(getPastedTextRefNumLines('a\nb\nc')).toBe(2) + }) - test("counts CRLF newlines", () => { - expect(getPastedTextRefNumLines("a\r\nb")).toBe(1); - }); + test('counts CRLF newlines', () => { + expect(getPastedTextRefNumLines('a\r\nb')).toBe(1) + }) - test("counts CR newlines", () => { - expect(getPastedTextRefNumLines("a\rb")).toBe(1); - }); + test('counts CR newlines', () => { + expect(getPastedTextRefNumLines('a\rb')).toBe(1) + }) - test("returns 0 for empty string", () => { - expect(getPastedTextRefNumLines("")).toBe(0); - }); + test('returns 0 for empty string', () => { + expect(getPastedTextRefNumLines('')).toBe(0) + }) - test("trailing newline counts as one", () => { - expect(getPastedTextRefNumLines("a\n")).toBe(1); - }); -}); + test('trailing newline counts as one', () => { + expect(getPastedTextRefNumLines('a\n')).toBe(1) + }) +}) -describe("formatPastedTextRef", () => { - test("formats with lines count", () => { - expect(formatPastedTextRef(1, 10)).toBe("[Pasted text #1 +10 lines]"); - }); +describe('formatPastedTextRef', () => { + test('formats with lines count', () => { + expect(formatPastedTextRef(1, 10)).toBe('[Pasted text #1 +10 lines]') + }) - test("formats without lines when 0", () => { - expect(formatPastedTextRef(3, 0)).toBe("[Pasted text #3]"); - }); + test('formats without lines when 0', () => { + expect(formatPastedTextRef(3, 0)).toBe('[Pasted text #3]') + }) - test("formats with large id", () => { - expect(formatPastedTextRef(99, 5)).toBe("[Pasted text #99 +5 lines]"); - }); -}); + test('formats with large id', () => { + expect(formatPastedTextRef(99, 5)).toBe('[Pasted text #99 +5 lines]') + }) +}) -describe("formatImageRef", () => { - test("formats image reference", () => { - expect(formatImageRef(1)).toBe("[Image #1]"); - }); +describe('formatImageRef', () => { + test('formats image reference', () => { + expect(formatImageRef(1)).toBe('[Image #1]') + }) - test("formats with large id", () => { - expect(formatImageRef(42)).toBe("[Image #42]"); - }); -}); + test('formats with large id', () => { + expect(formatImageRef(42)).toBe('[Image #42]') + }) +}) -describe("parseReferences", () => { - test("parses Pasted text ref", () => { - const refs = parseReferences("[Pasted text #1 +5 lines]"); - expect(refs).toHaveLength(1); +describe('parseReferences', () => { + test('parses Pasted text ref', () => { + const refs = parseReferences('[Pasted text #1 +5 lines]') + expect(refs).toHaveLength(1) expect(refs[0]).toEqual({ id: 1, - match: "[Pasted text #1 +5 lines]", + match: '[Pasted text #1 +5 lines]', index: 0, - }); - }); + }) + }) - test("parses Image ref", () => { - const refs = parseReferences("[Image #2]"); - expect(refs).toHaveLength(1); - expect(refs[0]!.id).toBe(2); - }); + test('parses Image ref', () => { + const refs = parseReferences('[Image #2]') + expect(refs).toHaveLength(1) + expect(refs[0]!.id).toBe(2) + }) - test("parses Truncated text ref", () => { - const refs = parseReferences("[...Truncated text #3]"); - expect(refs).toHaveLength(1); - expect(refs[0]!.id).toBe(3); - }); + test('parses Truncated text ref', () => { + const refs = parseReferences('[...Truncated text #3]') + expect(refs).toHaveLength(1) + expect(refs[0]!.id).toBe(3) + }) - test("parses Pasted text without line count", () => { - const refs = parseReferences("[Pasted text #4]"); - expect(refs).toHaveLength(1); - expect(refs[0]!.id).toBe(4); - }); + test('parses Pasted text without line count', () => { + const refs = parseReferences('[Pasted text #4]') + expect(refs).toHaveLength(1) + expect(refs[0]!.id).toBe(4) + }) - test("parses multiple refs", () => { - const refs = parseReferences("hello [Pasted text #1] world [Image #2]"); - expect(refs).toHaveLength(2); - expect(refs[0]!.id).toBe(1); - expect(refs[1]!.id).toBe(2); - }); + test('parses multiple refs', () => { + const refs = parseReferences('hello [Pasted text #1] world [Image #2]') + expect(refs).toHaveLength(2) + expect(refs[0]!.id).toBe(1) + expect(refs[1]!.id).toBe(2) + }) - test("returns empty for no refs", () => { - expect(parseReferences("plain text")).toEqual([]); - }); + test('returns empty for no refs', () => { + expect(parseReferences('plain text')).toEqual([]) + }) - test("filters out id 0", () => { - const refs = parseReferences("[Pasted text #0]"); - expect(refs).toHaveLength(0); - }); + test('filters out id 0', () => { + const refs = parseReferences('[Pasted text #0]') + expect(refs).toHaveLength(0) + }) - test("captures correct index for embedded refs", () => { - const input = "prefix [Pasted text #1] suffix"; - const refs = parseReferences(input); - expect(refs[0]!.index).toBe(7); - }); + test('captures correct index for embedded refs', () => { + const input = 'prefix [Pasted text #1] suffix' + const refs = parseReferences(input) + expect(refs[0]!.index).toBe(7) + }) - test("handles duplicate refs", () => { - const refs = parseReferences("[Pasted text #1] and [Pasted text #1]"); - expect(refs).toHaveLength(2); - }); -}); + test('handles duplicate refs', () => { + const refs = parseReferences('[Pasted text #1] and [Pasted text #1]') + expect(refs).toHaveLength(2) + }) +}) -describe("expandPastedTextRefs", () => { - test("replaces single text ref", () => { - const input = "look at [Pasted text #1 +2 lines]"; +describe('expandPastedTextRefs', () => { + test('replaces single text ref', () => { + const input = 'look at [Pasted text #1 +2 lines]' const pastedContents = { - 1: { id: 1, type: "text" as const, content: "line1\nline2\nline3" }, - }; - const result = expandPastedTextRefs(input, pastedContents); - expect(result).toBe("look at line1\nline2\nline3"); - }); + 1: { id: 1, type: 'text' as const, content: 'line1\nline2\nline3' }, + } + const result = expandPastedTextRefs(input, pastedContents) + expect(result).toBe('look at line1\nline2\nline3') + }) - test("replaces multiple text refs in reverse order", () => { - const input = "[Pasted text #1] and [Pasted text #2]"; + test('replaces multiple text refs in reverse order', () => { + const input = '[Pasted text #1] and [Pasted text #2]' const pastedContents = { - 1: { id: 1, type: "text" as const, content: "AAA" }, - 2: { id: 2, type: "text" as const, content: "BBB" }, - }; - const result = expandPastedTextRefs(input, pastedContents); - expect(result).toBe("AAA and BBB"); - }); + 1: { id: 1, type: 'text' as const, content: 'AAA' }, + 2: { id: 2, type: 'text' as const, content: 'BBB' }, + } + const result = expandPastedTextRefs(input, pastedContents) + expect(result).toBe('AAA and BBB') + }) - test("does not replace image refs", () => { - const input = "[Image #1]"; + test('does not replace image refs', () => { + const input = '[Image #1]' const pastedContents = { - 1: { id: 1, type: "image" as const, content: "data" }, - }; - const result = expandPastedTextRefs(input, pastedContents); - expect(result).toBe("[Image #1]"); - }); + 1: { id: 1, type: 'image' as const, content: 'data' }, + } + const result = expandPastedTextRefs(input, pastedContents) + expect(result).toBe('[Image #1]') + }) - test("returns original when no refs", () => { - const input = "no refs here"; - const result = expandPastedTextRefs(input, {}); - expect(result).toBe("no refs here"); - }); + test('returns original when no refs', () => { + const input = 'no refs here' + const result = expandPastedTextRefs(input, {}) + expect(result).toBe('no refs here') + }) - test("skips refs with no matching pasted content", () => { - const input = "[Pasted text #99 +1 lines]"; - const result = expandPastedTextRefs(input, {}); - expect(result).toBe("[Pasted text #99 +1 lines]"); - }); + test('skips refs with no matching pasted content', () => { + const input = '[Pasted text #99 +1 lines]' + const result = expandPastedTextRefs(input, {}) + expect(result).toBe('[Pasted text #99 +1 lines]') + }) - test("handles mixed content", () => { - const input = "see [Pasted text #1] and [Image #2]"; + test('handles mixed content', () => { + const input = 'see [Pasted text #1] and [Image #2]' const pastedContents = { - 1: { id: 1, type: "text" as const, content: "code here" }, - 2: { id: 2, type: "image" as const, content: "img data" }, - }; - const result = expandPastedTextRefs(input, pastedContents); - expect(result).toBe("see code here and [Image #2]"); - }); -}); + 1: { id: 1, type: 'text' as const, content: 'code here' }, + 2: { id: 2, type: 'image' as const, content: 'img data' }, + } + const result = expandPastedTextRefs(input, pastedContents) + expect(result).toBe('see code here and [Image #2]') + }) +}) diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts index 2eb5363..ebfebe8 100644 --- a/src/__tests__/tools.test.ts +++ b/src/__tests__/tools.test.ts @@ -1,82 +1,85 @@ -import { describe, expect, test } from "bun:test"; -import { parseToolPreset, filterToolsByDenyRules } from "../tools"; -import { getEmptyToolPermissionContext } from "../Tool"; +import { describe, expect, test } from 'bun:test' +import { parseToolPreset, filterToolsByDenyRules } from '../tools' +import { getEmptyToolPermissionContext } from '../Tool' -describe("parseToolPreset", () => { +describe('parseToolPreset', () => { test('returns "default" for "default" input', () => { - expect(parseToolPreset("default")).toBe("default"); - }); + expect(parseToolPreset('default')).toBe('default') + }) test('returns "default" for "Default" input (case-insensitive)', () => { - expect(parseToolPreset("Default")).toBe("default"); - }); + expect(parseToolPreset('Default')).toBe('default') + }) - test("returns null for unknown preset", () => { - expect(parseToolPreset("unknown")).toBeNull(); - }); + test('returns null for unknown preset', () => { + expect(parseToolPreset('unknown')).toBeNull() + }) - test("returns null for empty string", () => { - expect(parseToolPreset("")).toBeNull(); - }); + test('returns null for empty string', () => { + expect(parseToolPreset('')).toBeNull() + }) - test("returns null for random string", () => { - expect(parseToolPreset("custom-preset")).toBeNull(); - }); -}); + test('returns null for random string', () => { + expect(parseToolPreset('custom-preset')).toBeNull() + }) +}) // ─── filterToolsByDenyRules ───────────────────────────────────────────── -describe("filterToolsByDenyRules", () => { +describe('filterToolsByDenyRules', () => { const mockTools = [ - { name: "Bash", mcpInfo: undefined }, - { name: "Read", mcpInfo: undefined }, - { name: "Write", mcpInfo: undefined }, - { name: "mcp__server__tool", mcpInfo: { serverName: "server", toolName: "tool" } }, - ]; + { name: 'Bash', mcpInfo: undefined }, + { name: 'Read', mcpInfo: undefined }, + { name: 'Write', mcpInfo: undefined }, + { + name: 'mcp__server__tool', + mcpInfo: { serverName: 'server', toolName: 'tool' }, + }, + ] - test("returns all tools when no deny rules", () => { - const ctx = getEmptyToolPermissionContext(); - const result = filterToolsByDenyRules(mockTools, ctx); - expect(result).toHaveLength(4); - }); + test('returns all tools when no deny rules', () => { + const ctx = getEmptyToolPermissionContext() + const result = filterToolsByDenyRules(mockTools, ctx) + expect(result).toHaveLength(4) + }) - test("filters out denied tool by name", () => { + test('filters out denied tool by name', () => { const ctx = { ...getEmptyToolPermissionContext(), alwaysDenyRules: { - localSettings: ["Bash"], + localSettings: ['Bash'], }, - }; - const result = filterToolsByDenyRules(mockTools, ctx as any); - expect(result.find((t) => t.name === "Bash")).toBeUndefined(); - expect(result).toHaveLength(3); - }); + } + const result = filterToolsByDenyRules(mockTools, ctx as any) + expect(result.find(t => t.name === 'Bash')).toBeUndefined() + expect(result).toHaveLength(3) + }) - test("filters out multiple denied tools", () => { + test('filters out multiple denied tools', () => { const ctx = { ...getEmptyToolPermissionContext(), alwaysDenyRules: { - localSettings: ["Bash", "Write"], + localSettings: ['Bash', 'Write'], }, - }; - const result = filterToolsByDenyRules(mockTools, ctx as any); - expect(result).toHaveLength(2); - expect(result.map((t) => t.name)).toEqual(["Read", "mcp__server__tool"]); - }); + } + const result = filterToolsByDenyRules(mockTools, ctx as any) + expect(result).toHaveLength(2) + expect(result.map(t => t.name)).toEqual(['Read', 'mcp__server__tool']) + }) - test("returns empty array when all tools denied", () => { + test('returns empty array when all tools denied', () => { const ctx = { ...getEmptyToolPermissionContext(), alwaysDenyRules: { - localSettings: mockTools.map((t) => t.name), + localSettings: mockTools.map(t => t.name), }, - }; - const result = filterToolsByDenyRules(mockTools, ctx as any); - expect(result).toHaveLength(0); - }); + } + const result = filterToolsByDenyRules(mockTools, ctx as any) + expect(result).toHaveLength(0) + }) - test("handles empty tools array", () => { - const ctx = getEmptyToolPermissionContext(); - expect(filterToolsByDenyRules([], ctx)).toEqual([]); - }); -}); + test('handles empty tools array', () => { + const ctx = getEmptyToolPermissionContext() + expect(filterToolsByDenyRules([], ctx)).toEqual([]) + }) +}) diff --git a/src/commands/plugin/__tests__/parseArgs.test.ts b/src/commands/plugin/__tests__/parseArgs.test.ts new file mode 100644 index 0000000..7a08fd7 --- /dev/null +++ b/src/commands/plugin/__tests__/parseArgs.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { parsePluginArgs } from "../parseArgs"; + +describe("parsePluginArgs", () => { + // No args + test("returns { type: 'menu' } for undefined", () => { + expect(parsePluginArgs(undefined)).toEqual({ type: "menu" }); + }); + + test("returns { type: 'menu' } for empty string", () => { + expect(parsePluginArgs("")).toEqual({ type: "menu" }); + }); + + test("returns { type: 'menu' } for whitespace only", () => { + expect(parsePluginArgs(" ")).toEqual({ type: "menu" }); + }); + + // Help + test("returns { type: 'help' } for 'help'", () => { + expect(parsePluginArgs("help")).toEqual({ type: "help" }); + }); + + test("returns { type: 'help' } for '--help'", () => { + expect(parsePluginArgs("--help")).toEqual({ type: "help" }); + }); + + test("returns { type: 'help' } for '-h'", () => { + expect(parsePluginArgs("-h")).toEqual({ type: "help" }); + }); + + // Install + test("parses 'install my-plugin' -> { type: 'install', plugin: 'my-plugin' }", () => { + expect(parsePluginArgs("install my-plugin")).toEqual({ + type: "install", + plugin: "my-plugin", + }); + }); + + test("parses 'install my-plugin@github' with marketplace", () => { + expect(parsePluginArgs("install my-plugin@github")).toEqual({ + type: "install", + plugin: "my-plugin", + marketplace: "github", + }); + }); + + test("parses 'install https://github.com/...' as URL marketplace", () => { + expect(parsePluginArgs("install https://github.com/plugins/my-plugin")).toEqual({ + type: "install", + marketplace: "https://github.com/plugins/my-plugin", + }); + }); + + test("parses 'i plugin' as install shorthand", () => { + expect(parsePluginArgs("i plugin")).toEqual({ + type: "install", + plugin: "plugin", + }); + }); + + test("install without target returns type only", () => { + expect(parsePluginArgs("install")).toEqual({ type: "install" }); + }); + + // Uninstall + test("returns { type: 'uninstall', plugin: '...' }", () => { + expect(parsePluginArgs("uninstall my-plugin")).toEqual({ + type: "uninstall", + plugin: "my-plugin", + }); + }); + + // Enable/disable + test("returns { type: 'enable', plugin: '...' }", () => { + expect(parsePluginArgs("enable my-plugin")).toEqual({ + type: "enable", + plugin: "my-plugin", + }); + }); + + test("returns { type: 'disable', plugin: '...' }", () => { + expect(parsePluginArgs("disable my-plugin")).toEqual({ + type: "disable", + plugin: "my-plugin", + }); + }); + + // Validate + test("returns { type: 'validate', path: '...' }", () => { + expect(parsePluginArgs("validate /path/to/plugin")).toEqual({ + type: "validate", + path: "/path/to/plugin", + }); + }); + + // Manage + test("returns { type: 'manage' }", () => { + expect(parsePluginArgs("manage")).toEqual({ type: "manage" }); + }); + + // Marketplace + test("parses 'marketplace add ...'", () => { + expect(parsePluginArgs("marketplace add https://example.com")).toEqual({ + type: "marketplace", + action: "add", + target: "https://example.com", + }); + }); + + test("parses 'marketplace remove ...'", () => { + expect(parsePluginArgs("marketplace remove my-source")).toEqual({ + type: "marketplace", + action: "remove", + target: "my-source", + }); + }); + + test("parses 'marketplace list'", () => { + expect(parsePluginArgs("marketplace list")).toEqual({ + type: "marketplace", + action: "list", + }); + }); + + test("parses 'market' as alias for 'marketplace'", () => { + expect(parsePluginArgs("market list")).toEqual({ + type: "marketplace", + action: "list", + }); + }); + + // Boundary + test("handles extra whitespace", () => { + expect(parsePluginArgs(" install my-plugin ")).toEqual({ + type: "install", + plugin: "my-plugin", + }); + }); + + test("handles unknown subcommand gracefully", () => { + expect(parsePluginArgs("foobar")).toEqual({ type: "menu" }); + }); + + test("marketplace without action returns type only", () => { + expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" }); + }); +}); diff --git a/src/services/compact/__tests__/grouping.test.ts b/src/services/compact/__tests__/grouping.test.ts new file mode 100644 index 0000000..c59f754 --- /dev/null +++ b/src/services/compact/__tests__/grouping.test.ts @@ -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); + }); +}); diff --git a/src/services/compact/__tests__/prompt.test.ts b/src/services/compact/__tests__/prompt.test.ts new file mode 100644 index 0000000..dbed898 --- /dev/null +++ b/src/services/compact/__tests__/prompt.test.ts @@ -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 ... block", () => { + const input = "my thought process\nthe summary"; + const result = formatCompactSummary(input); + expect(result).not.toContain(""); + expect(result).not.toContain("my thought process"); + }); + + test("replaces ... with 'Summary:\\n' prefix", () => { + const input = "key points here"; + const result = formatCompactSummary(input); + expect(result).toContain("Summary:"); + expect(result).toContain("key points here"); + expect(result).not.toContain(""); + }); + + test("handles analysis + summary together", () => { + const input = "thinkingresult"; + const result = formatCompactSummary(input); + expect(result).not.toContain("thinking"); + expect(result).toContain("result"); + }); + + test("handles summary without analysis", () => { + const input = "just the summary"; + const result = formatCompactSummary(input); + expect(result).toContain("just the summary"); + }); + + test("handles analysis without summary", () => { + const input = "just analysisand 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 = "\nline1\nline2\nline3\nok"; + const result = formatCompactSummary(input); + expect(result).not.toContain("line1"); + expect(result).toContain("ok"); + }); + + test("preserves content between analysis and summary", () => { + const input = "thoughtsmiddle textfinal"; + const result = formatCompactSummary(input); + expect(result).toContain("middle text"); + expect(result).toContain("final"); + }); +}); diff --git a/src/services/mcp/__tests__/channelNotification.test.ts b/src/services/mcp/__tests__/channelNotification.test.ts new file mode 100644 index 0000000..1e0e968 --- /dev/null +++ b/src/services/mcp/__tests__/channelNotification.test.ts @@ -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() + }) +}) diff --git a/src/services/mcp/__tests__/channelPermissions.test.ts b/src/services/mcp/__tests__/channelPermissions.test.ts new file mode 100644 index 0000000..dc19af3 --- /dev/null +++ b/src/services/mcp/__tests__/channelPermissions.test.ts @@ -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"); + }); +}); diff --git a/src/services/mcp/__tests__/filterUtils.test.ts b/src/services/mcp/__tests__/filterUtils.test.ts new file mode 100644 index 0000000..eecd8d8 --- /dev/null +++ b/src/services/mcp/__tests__/filterUtils.test.ts @@ -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 { + const headers: Record = {} + 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" }); + }); +}); diff --git a/src/services/mcp/__tests__/officialRegistry.test.ts b/src/services/mcp/__tests__/officialRegistry.test.ts new file mode 100644 index 0000000..ffb4b94 --- /dev/null +++ b/src/services/mcp/__tests__/officialRegistry.test.ts @@ -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); + }); +}); diff --git a/src/state/__tests__/store.test.ts b/src/state/__tests__/store.test.ts new file mode 100644 index 0000000..31c3765 --- /dev/null +++ b/src/state/__tests__/store.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { createStore } from "../store"; + +describe("createStore", () => { + test("returns object with getState, setState, subscribe", () => { + const store = createStore({ count: 0 }); + expect(typeof store.getState).toBe("function"); + expect(typeof store.setState).toBe("function"); + expect(typeof store.subscribe).toBe("function"); + }); + + test("getState returns initial state", () => { + const store = createStore({ count: 0, name: "test" }); + expect(store.getState()).toEqual({ count: 0, name: "test" }); + }); + + test("setState updates state via updater function", () => { + const store = createStore({ count: 0 }); + store.setState(prev => ({ count: prev.count + 1 })); + expect(store.getState().count).toBe(1); + }); + + test("setState does not notify when state unchanged (Object.is)", () => { + const store = createStore({ count: 0 }); + let notified = false; + store.subscribe(() => { notified = true; }); + store.setState(prev => prev); + expect(notified).toBe(false); + }); + + test("setState notifies subscribers on change", () => { + const store = createStore({ count: 0 }); + let notified = false; + store.subscribe(() => { notified = true; }); + store.setState(prev => ({ count: prev.count + 1 })); + expect(notified).toBe(true); + }); + + test("subscribe returns unsubscribe function", () => { + const store = createStore({ count: 0 }); + const unsub = store.subscribe(() => {}); + expect(typeof unsub).toBe("function"); + }); + + test("unsubscribe stops notifications", () => { + const store = createStore({ count: 0 }); + let count = 0; + const unsub = store.subscribe(() => { count++; }); + store.setState(prev => ({ count: prev.count + 1 })); + unsub(); + store.setState(prev => ({ count: prev.count + 1 })); + expect(count).toBe(1); + }); + + test("multiple subscribers all get notified", () => { + const store = createStore({ count: 0 }); + let a = 0, b = 0; + store.subscribe(() => { a++; }); + store.subscribe(() => { b++; }); + store.setState(prev => ({ count: prev.count + 1 })); + expect(a).toBe(1); + expect(b).toBe(1); + }); + + test("onChange callback is called on state change", () => { + let captured: any = null; + const store = createStore({ count: 0 }, ({ newState, oldState }) => { + captured = { newState, oldState }; + }); + store.setState(prev => ({ count: prev.count + 5 })); + expect(captured).not.toBeNull(); + expect(captured.oldState.count).toBe(0); + expect(captured.newState.count).toBe(5); + }); + + test("onChange is not called when state unchanged", () => { + let called = false; + const store = createStore({ count: 0 }, () => { called = true; }); + store.setState(prev => prev); + expect(called).toBe(false); + }); + + test("works with complex state objects", () => { + const store = createStore({ items: [] as number[], name: "test" }); + store.setState(prev => ({ ...prev, items: [1, 2, 3] })); + expect(store.getState().items).toEqual([1, 2, 3]); + expect(store.getState().name).toBe("test"); + }); + + test("works with primitive state", () => { + const store = createStore(0); + store.setState(() => 42); + expect(store.getState()).toBe(42); + }); + + test("updater receives previous state", () => { + const store = createStore({ value: 10 }); + store.setState(prev => { + expect(prev.value).toBe(10); + return { value: prev.value * 2 }; + }); + expect(store.getState().value).toBe(20); + }); + + test("sequential setState calls produce final state", () => { + const store = createStore({ count: 0 }); + store.setState(prev => ({ count: prev.count + 1 })); + store.setState(prev => ({ count: prev.count + 1 })); + store.setState(prev => ({ count: prev.count + 1 })); + expect(store.getState().count).toBe(3); + }); +}); diff --git a/src/tools/AgentTool/__tests__/agentDisplay.test.ts b/src/tools/AgentTool/__tests__/agentDisplay.test.ts new file mode 100644 index 0000000..1a9e45c --- /dev/null +++ b/src/tools/AgentTool/__tests__/agentDisplay.test.ts @@ -0,0 +1,136 @@ +import { mock, describe, expect, test } from "bun:test"; + +// Mock heavy deps +mock.module("../../utils/model/agent.js", () => ({ + getDefaultSubagentModel: () => undefined, +})); + +mock.module("../../utils/settings/constants.js", () => ({ + getSourceDisplayName: (source: string) => source, +})); + +const { + resolveAgentOverrides, + compareAgentsByName, + AGENT_SOURCE_GROUPS, +} = await import("../agentDisplay"); + +function makeAgent(agentType: string, source: string): any { + return { agentType, source, name: agentType }; +} + +describe("resolveAgentOverrides", () => { + test("marks no overrides when all agents active", () => { + const agents = [makeAgent("builder", "userSettings")]; + const result = resolveAgentOverrides(agents, agents); + expect(result).toHaveLength(1); + expect(result[0].overriddenBy).toBeUndefined(); + }); + + test("marks inactive agent as overridden", () => { + const allAgents = [ + makeAgent("builder", "projectSettings"), + makeAgent("builder", "userSettings"), + ]; + const activeAgents = [makeAgent("builder", "userSettings")]; + const result = resolveAgentOverrides(allAgents, activeAgents); + const projectAgent = result.find( + (a: any) => a.source === "projectSettings", + ); + expect(projectAgent?.overriddenBy).toBe("userSettings"); + }); + + test("overriddenBy shows the overriding agent source", () => { + const allAgents = [makeAgent("tester", "localSettings")]; + const activeAgents = [makeAgent("tester", "policySettings")]; + const result = resolveAgentOverrides(allAgents, activeAgents); + expect(result[0].overriddenBy).toBe("policySettings"); + }); + + test("deduplicates agents by (agentType, source)", () => { + const agents = [ + makeAgent("builder", "userSettings"), + makeAgent("builder", "userSettings"), // duplicate + ]; + const result = resolveAgentOverrides(agents, agents.slice(0, 1)); + expect(result).toHaveLength(1); + }); + + test("preserves agent definition properties", () => { + const agents = [{ agentType: "a", source: "userSettings", name: "Agent A" }]; + const result = resolveAgentOverrides(agents, agents); + expect(result[0].name).toBe("Agent A"); + expect(result[0].agentType).toBe("a"); + }); + + test("handles empty arrays", () => { + expect(resolveAgentOverrides([], [])).toEqual([]); + }); + + test("handles agent from git worktree (duplicate detection)", () => { + const agents = [ + makeAgent("builder", "projectSettings"), + makeAgent("builder", "projectSettings"), + makeAgent("builder", "localSettings"), + ]; + const result = resolveAgentOverrides(agents, agents.slice(0, 1)); + // Deduped: projectSettings appears once, localSettings once + expect(result).toHaveLength(2); + }); +}); + +describe("compareAgentsByName", () => { + test("sorts alphabetically ascending", () => { + const a = makeAgent("alpha", "userSettings"); + const b = makeAgent("beta", "userSettings"); + expect(compareAgentsByName(a, b)).toBeLessThan(0); + }); + + test("returns negative when a.name < b.name", () => { + const a = makeAgent("a", "s"); + const b = makeAgent("b", "s"); + expect(compareAgentsByName(a, b)).toBeLessThan(0); + }); + + test("returns positive when a.name > b.name", () => { + const a = makeAgent("z", "s"); + const b = makeAgent("a", "s"); + expect(compareAgentsByName(a, b)).toBeGreaterThan(0); + }); + + test("returns 0 for same name", () => { + const a = makeAgent("same", "s"); + const b = makeAgent("same", "s"); + expect(compareAgentsByName(a, b)).toBe(0); + }); + + test("is case-insensitive (sensitivity: base)", () => { + const a = makeAgent("Alpha", "s"); + const b = makeAgent("alpha", "s"); + expect(compareAgentsByName(a, b)).toBe(0); + }); +}); + +describe("AGENT_SOURCE_GROUPS", () => { + test("contains expected source groups in order", () => { + expect(AGENT_SOURCE_GROUPS).toHaveLength(7); + expect(AGENT_SOURCE_GROUPS[0]).toEqual({ + label: "User agents", + source: "userSettings", + }); + expect(AGENT_SOURCE_GROUPS[6]).toEqual({ + label: "Built-in agents", + source: "built-in", + }); + }); + + test("has unique labels", () => { + const labels = AGENT_SOURCE_GROUPS.map((g) => g.label); + expect(new Set(labels).size).toBe(labels.length); + }); + + test("has unique sources", () => { + const sources = AGENT_SOURCE_GROUPS.map((g) => g.source); + expect(new Set(sources).size).toBe(sources.length); + }); +}); diff --git a/src/tools/AgentTool/__tests__/agentToolUtils.test.ts b/src/tools/AgentTool/__tests__/agentToolUtils.test.ts new file mode 100644 index 0000000..9b75b8b --- /dev/null +++ b/src/tools/AgentTool/__tests__/agentToolUtils.test.ts @@ -0,0 +1,314 @@ +import { mock, describe, expect, test } from "bun:test"; + +// ─── Comprehensive mocks for agentToolUtils.ts dependencies ─── +// These must cover ALL named exports used by the module's transitive imports. + +const noop = () => {}; +const emptySet = () => new Set(); + +// Utility: create a mock module factory that returns an object with arbitrary named exports +function stubModule(exportNames: string[]) { + const obj: Record = {}; + for (const name of exportNames) { + obj[name] = noop; + } + return () => obj; +} + +mock.module("bun:bundle", () => ({ feature: () => false })); + +mock.module("zod/v4", () => ({ + z: { + object: () => ({ extend: () => ({ parse: noop }) }), + strictObject: () => ({ extend: noop }), + string: () => ({ optional: () => ({ describe: noop }) }), + number: () => ({ optional: noop }), + boolean: () => ({ describe: noop }), + enum: () => ({ optional: noop }), + array: noop, + union: noop, + optional: noop, + preprocess: noop, + nullable: noop, + record: noop, + any: noop, + unknown: noop, + default: noop, + }, +})); + +mock.module("src/bootstrap/state.js", () => ({ + clearInvokedSkillsForAgent: noop, +})); + +mock.module("src/constants/tools.js", () => ({ + ALL_AGENT_DISALLOWED_TOOLS: new Set(), + ASYNC_AGENT_ALLOWED_TOOLS: new Set(), + CUSTOM_AGENT_DISALLOWED_TOOLS: new Set(), + IN_PROCESS_TEAMMATE_ALLOWED_TOOLS: new Set(), +})); + +mock.module("src/services/AgentSummary/agentSummary.js", () => ({ + startAgentSummarization: noop, +})); + +mock.module("src/services/analytics/index.js", () => ({ + logEvent: noop, + AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined, +})); + +mock.module("src/services/api/dumpPrompts.js", () => ({ + clearDumpState: noop, +})); + +mock.module("src/Tool.js", () => ({ + toolMatchesName: () => false, + findToolByName: noop, + toolMatchesName: () => false, +})); + +// messages.ts is complex - provide stubs for all named exports +mock.module("src/utils/messages.ts", () => ({ + extractTextContent: (content: any[]) => + content?.filter?.((b: any) => b.type === "text")?.map?.((b: any) => b.text)?.join("") ?? "", + getLastAssistantMessage: () => null, + SYNTHETIC_MESSAGES: new Set(), + INTERRUPT_MESSAGE: "", + INTERRUPT_MESSAGE_FOR_TOOL_USE: "", + CANCEL_MESSAGE: "", + REJECT_MESSAGE: "", + REJECT_MESSAGE_WITH_REASON_PREFIX: "", + SUBAGENT_REJECT_MESSAGE: "", + SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: "", + PLAN_REJECTION_PREFIX: "", + DENIAL_WORKAROUND_GUIDANCE: "", + NO_RESPONSE_REQUESTED: "", + SYNTHETIC_TOOL_RESULT_PLACEHOLDER: "", + SYNTHETIC_MODEL: "", + AUTO_REJECT_MESSAGE: noop, + DONT_ASK_REJECT_MESSAGE: noop, + withMemoryCorrectionHint: (s: string) => s, + deriveShortMessageId: () => "", + isClassifierDenial: () => false, + buildYoloRejectionMessage: () => "", + buildClassifierUnavailableMessage: () => "", + isEmptyMessageText: () => true, + createAssistantMessage: noop, + createAssistantAPIErrorMessage: noop, + createUserMessage: noop, + prepareUserContent: noop, + createUserInterruptionMessage: noop, + createSyntheticUserCaveatMessage: noop, + formatCommandInputTags: noop, +})); + +mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ + completeAgentTask: noop, + createActivityDescriptionResolver: () => ({}), + createProgressTracker: () => ({}), + enqueueAgentNotification: noop, + failAgentTask: noop, + getProgressUpdate: () => ({ tokenCount: 0, toolUseCount: 0 }), + getTokenCountFromTracker: () => 0, + isLocalAgentTask: () => false, + killAsyncAgent: noop, + updateAgentProgress: noop, + updateProgressFromMessage: noop, +})); + +mock.module("src/utils/agentSwarmsEnabled.js", () => ({ + isAgentSwarmsEnabled: () => false, +})); + +mock.module("src/utils/debug.js", () => ({ + logForDebugging: noop, +})); + +mock.module("src/utils/envUtils.js", () => ({ + isInProtectedNamespace: () => false, +})); + +mock.module("src/utils/errors.js", () => ({ + AbortError: class extends Error {}, + errorMessage: (e: any) => String(e), +})); + +mock.module("src/utils/forkedAgent.js", () => ({})); + +mock.module("src/utils/lazySchema.js", () => ({ + lazySchema: (fn: () => any) => fn, +})); + +mock.module("src/utils/permissions/PermissionMode.js", () => ({})); + +// Provide working permissionRuleValueFromString to avoid polluting other test files +const LEGACY_ALIASES: Record = { + Task: "Agent", + KillShell: "TaskStop", + AgentOutputTool: "TaskOutput", + BashOutputTool: "TaskOutput", +}; + +function normalizeLegacyToolName(name: string): string { + return LEGACY_ALIASES[name] ?? name; +} + +function escapeRuleContent(content: string): string { + return content.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); +} + +function unescapeRuleContent(content: string): string { + return content.replace(/\\\(/g, "(").replace(/\\\)/g, ")").replace(/\\\\/g, "\\"); +} + +mock.module("src/utils/permissions/permissionRuleParser.js", () => ({ + permissionRuleValueFromString: (ruleString: string) => { + const openIdx = ruleString.indexOf("("); + if (openIdx === -1) return { toolName: normalizeLegacyToolName(ruleString) }; + const closeIdx = ruleString.lastIndexOf(")"); + if (closeIdx === -1 || closeIdx <= openIdx) return { toolName: normalizeLegacyToolName(ruleString) }; + if (closeIdx !== ruleString.length - 1) return { toolName: normalizeLegacyToolName(ruleString) }; + const toolName = ruleString.substring(0, openIdx); + const rawContent = ruleString.substring(openIdx + 1, closeIdx); + if (!toolName) return { toolName: normalizeLegacyToolName(ruleString) }; + if (rawContent === "" || rawContent === "*") return { toolName: normalizeLegacyToolName(toolName) }; + return { toolName: normalizeLegacyToolName(toolName), ruleContent: unescapeRuleContent(rawContent) }; + }, + permissionRuleValueToString: (v: any) => { + if (!v.ruleContent) return v.toolName; + return `${v.toolName}(${escapeRuleContent(v.ruleContent)})`; + }, + normalizeLegacyToolName, +})); + +mock.module("src/utils/permissions/yoloClassifier.js", () => ({ + buildTranscriptForClassifier: () => "", + classifyYoloAction: () => null, +})); + +mock.module("src/utils/task/sdkProgress.js", () => ({ + emitTaskProgress: noop, +})); + +mock.module("src/utils/teammateContext.js", () => ({ + isInProcessTeammate: () => false, +})); + +mock.module("src/utils/tokens.js", () => ({ + getTokenCountFromUsage: () => 0, +})); + +mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({ + EXIT_PLAN_MODE_V2_TOOL_NAME: "exit_plan_mode", +})); + +mock.module("src/tools/AgentTool/constants.js", () => ({ + AGENT_TOOL_NAME: "agent", + LEGACY_AGENT_TOOL_NAME: "task", +})); + +mock.module("src/tools/AgentTool/loadAgentsDir.js", () => ({})); + +mock.module("src/state/AppState.js", () => ({})); + +mock.module("src/types/ids.js", () => ({ + asAgentId: (id: string) => id, +})); + +// Break circular dep +mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({ + AgentTool: {}, + inputSchema: {}, + outputSchema: {}, + default: {}, +})); + +const { + countToolUses, + getLastToolUseName, +} = await import("../agentToolUtils"); + +function makeAssistantMessage(content: any[]): any { + return { type: "assistant", message: { content } }; +} + +function makeUserMessage(text: string): any { + return { type: "user", message: { content: text } }; +} + +describe("countToolUses", () => { + test("counts tool_use blocks in messages", () => { + const messages = [ + makeAssistantMessage([ + { type: "tool_use", name: "Read" }, + { type: "text", text: "hello" }, + ]), + ]; + expect(countToolUses(messages)).toBe(1); + }); + + test("returns 0 for messages without tool_use", () => { + const messages = [ + makeAssistantMessage([{ type: "text", text: "hello" }]), + ]; + expect(countToolUses(messages)).toBe(0); + }); + + test("returns 0 for empty array", () => { + expect(countToolUses([])).toBe(0); + }); + + test("counts multiple tool_use blocks across messages", () => { + const messages = [ + makeAssistantMessage([{ type: "tool_use", name: "Read" }]), + makeUserMessage("ok"), + makeAssistantMessage([{ type: "tool_use", name: "Write" }]), + ]; + expect(countToolUses(messages)).toBe(2); + }); + + test("counts tool_use in single message with multiple blocks", () => { + const messages = [ + makeAssistantMessage([ + { type: "tool_use", name: "Read" }, + { type: "tool_use", name: "Grep" }, + { type: "tool_use", name: "Write" }, + ]), + ]; + expect(countToolUses(messages)).toBe(3); + }); +}); + +describe("getLastToolUseName", () => { + test("returns last tool name from assistant message", () => { + const msg = makeAssistantMessage([ + { type: "tool_use", name: "Read" }, + { type: "tool_use", name: "Write" }, + ]); + expect(getLastToolUseName(msg)).toBe("Write"); + }); + + test("returns undefined for message without tool_use", () => { + const msg = makeAssistantMessage([{ type: "text", text: "hello" }]); + expect(getLastToolUseName(msg)).toBeUndefined(); + }); + + test("returns the last tool when multiple tool_uses present", () => { + const msg = makeAssistantMessage([ + { type: "tool_use", name: "Read" }, + { type: "tool_use", name: "Grep" }, + { type: "tool_use", name: "Edit" }, + ]); + expect(getLastToolUseName(msg)).toBe("Edit"); + }); + + test("returns undefined for non-assistant message", () => { + const msg = makeUserMessage("hello"); + expect(getLastToolUseName(msg)).toBeUndefined(); + }); + + test("handles message with null content", () => { + const msg = { type: "assistant", message: { content: null } }; + expect(getLastToolUseName(msg)).toBeUndefined(); + }); +}); diff --git a/src/tools/MCPTool/__tests__/classifyForCollapse.test.ts b/src/tools/MCPTool/__tests__/classifyForCollapse.test.ts new file mode 100644 index 0000000..c0735c5 --- /dev/null +++ b/src/tools/MCPTool/__tests__/classifyForCollapse.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { classifyMcpToolForCollapse } from "../classifyForCollapse"; + +describe("classifyMcpToolForCollapse", () => { + // Search tools + test("classifies Slack slack_search_public as search", () => { + expect(classifyMcpToolForCollapse("slack", "slack_search_public")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + test("classifies GitHub search_code as search", () => { + expect(classifyMcpToolForCollapse("github", "search_code")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + test("classifies Linear search_issues as search", () => { + expect(classifyMcpToolForCollapse("linear", "search_issues")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + test("classifies Datadog search_logs as search", () => { + expect(classifyMcpToolForCollapse("datadog", "search_logs")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + test("classifies Notion search as search", () => { + expect(classifyMcpToolForCollapse("notion", "search")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + test("classifies Brave brave_web_search as search", () => { + expect(classifyMcpToolForCollapse("brave-search", "brave_web_search")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + // Read tools + test("classifies Slack slack_read_channel as read", () => { + expect(classifyMcpToolForCollapse("slack", "slack_read_channel")).toEqual({ + isSearch: false, + isRead: true, + }); + }); + + test("classifies GitHub get_file_contents as read", () => { + expect(classifyMcpToolForCollapse("github", "get_file_contents")).toEqual({ + isSearch: false, + isRead: true, + }); + }); + + test("classifies Linear get_issue as read", () => { + expect(classifyMcpToolForCollapse("linear", "get_issue")).toEqual({ + isSearch: false, + isRead: true, + }); + }); + + test("classifies Filesystem read_file as read", () => { + expect(classifyMcpToolForCollapse("filesystem", "read_file")).toEqual({ + isSearch: false, + isRead: true, + }); + }); + + test("classifies GitHub list_commits as read", () => { + expect(classifyMcpToolForCollapse("github", "list_commits")).toEqual({ + isSearch: false, + isRead: true, + }); + }); + + test("classifies Slack slack_list_channels as read", () => { + expect(classifyMcpToolForCollapse("slack", "slack_list_channels")).toEqual({ + isSearch: false, + isRead: true, + }); + }); + + // Unknown tools + test("unknown tool returns { isSearch: false, isRead: false }", () => { + expect(classifyMcpToolForCollapse("unknown", "do_something")).toEqual({ + isSearch: false, + isRead: false, + }); + }); + + // normalize: camelCase -> snake_case + test("tool name with camelCase variant still matches after normalize", () => { + // searchCode -> search_code + expect(classifyMcpToolForCollapse("github", "searchCode")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + // normalize: kebab-case -> snake_case + test("tool name with kebab-case variant still matches after normalize", () => { + // search-code -> search_code + expect(classifyMcpToolForCollapse("github", "search-code")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + // Server name doesn't affect classification + test("server name parameter does not affect classification", () => { + const r1 = classifyMcpToolForCollapse("server-a", "search_code"); + const r2 = classifyMcpToolForCollapse("server-b", "search_code"); + expect(r1).toEqual(r2); + }); + + // Edge cases + test("empty tool name returns false/false", () => { + expect(classifyMcpToolForCollapse("server", "")).toEqual({ + isSearch: false, + isRead: false, + }); + }); + + // normalize lowercases, so SEARCH_CODE -> search_code -> matches + test("uppercase input normalizes to match", () => { + expect(classifyMcpToolForCollapse("github", "SEARCH_CODE")).toEqual({ + isSearch: true, + isRead: false, + }); + }); + + test("handles tool names with numbers", () => { + expect(classifyMcpToolForCollapse("server", "search2_things")).toEqual({ + isSearch: false, + isRead: false, + }); + }); +}); diff --git a/src/utils/__tests__/collapseHookSummaries.test.ts b/src/utils/__tests__/collapseHookSummaries.test.ts new file mode 100644 index 0000000..214c5f6 --- /dev/null +++ b/src/utils/__tests__/collapseHookSummaries.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { collapseHookSummaries } from "../collapseHookSummaries"; + +function makeHookSummary(overrides: Partial<{ + hookLabel: string; + hookCount: number; + hookInfos: any[]; + hookErrors: any[]; + preventedContinuation: boolean; + hasOutput: boolean; + totalDurationMs: number; +}> = {}): any { + return { + type: "system", + subtype: "stop_hook_summary", + hookLabel: overrides.hookLabel ?? "PostToolUse", + hookCount: overrides.hookCount ?? 1, + hookInfos: overrides.hookInfos ?? [], + hookErrors: overrides.hookErrors ?? [], + preventedContinuation: overrides.preventedContinuation ?? false, + hasOutput: overrides.hasOutput ?? false, + totalDurationMs: overrides.totalDurationMs ?? 10, + }; +} + +function makeNonHookMessage(): any { + return { type: "user", message: { content: "hello" } }; +} + +describe("collapseHookSummaries", () => { + test("returns same messages when no hook summaries", () => { + const messages = [makeNonHookMessage(), makeNonHookMessage()]; + expect(collapseHookSummaries(messages)).toEqual(messages); + }); + + test("collapses consecutive messages with same hookLabel", () => { + const messages = [ + makeHookSummary({ hookLabel: "PostToolUse", hookCount: 1 }), + makeHookSummary({ hookLabel: "PostToolUse", hookCount: 2 }), + ]; + const result = collapseHookSummaries(messages); + expect(result).toHaveLength(1); + expect(result[0].hookCount).toBe(3); + }); + + test("does not collapse messages with different hookLabels", () => { + const messages = [ + makeHookSummary({ hookLabel: "PostToolUse" }), + makeHookSummary({ hookLabel: "PreToolUse" }), + ]; + const result = collapseHookSummaries(messages); + expect(result).toHaveLength(2); + }); + + test("aggregates hookCount across collapsed messages", () => { + const messages = [ + makeHookSummary({ hookLabel: "A", hookCount: 3 }), + makeHookSummary({ hookLabel: "A", hookCount: 5 }), + ]; + const result = collapseHookSummaries(messages); + expect(result[0].hookCount).toBe(8); + }); + + test("merges hookInfos arrays", () => { + const info1 = { tool: "Read" }; + const info2 = { tool: "Write" }; + const messages = [ + makeHookSummary({ hookLabel: "A", hookInfos: [info1] }), + makeHookSummary({ hookLabel: "A", hookInfos: [info2] }), + ]; + const result = collapseHookSummaries(messages); + expect(result[0].hookInfos).toEqual([info1, info2]); + }); + + test("merges hookErrors arrays", () => { + const err1 = new Error("e1"); + const err2 = new Error("e2"); + const messages = [ + makeHookSummary({ hookLabel: "A", hookErrors: [err1] }), + makeHookSummary({ hookLabel: "A", hookErrors: [err2] }), + ]; + const result = collapseHookSummaries(messages); + expect(result[0].hookErrors).toHaveLength(2); + }); + + test("takes max totalDurationMs", () => { + const messages = [ + makeHookSummary({ hookLabel: "A", totalDurationMs: 50 }), + makeHookSummary({ hookLabel: "A", totalDurationMs: 100 }), + makeHookSummary({ hookLabel: "A", totalDurationMs: 75 }), + ]; + const result = collapseHookSummaries(messages); + expect(result[0].totalDurationMs).toBe(100); + }); + + test("takes any truthy preventContinuation", () => { + const messages = [ + makeHookSummary({ hookLabel: "A", preventedContinuation: false }), + makeHookSummary({ hookLabel: "A", preventedContinuation: true }), + ]; + const result = collapseHookSummaries(messages); + expect(result[0].preventedContinuation).toBe(true); + }); + + test("leaves single hook summary unchanged", () => { + const msg = makeHookSummary({ hookLabel: "PostToolUse", hookCount: 5 }); + const result = collapseHookSummaries([msg]); + expect(result).toHaveLength(1); + expect(result[0].hookCount).toBe(5); + }); + + test("handles three consecutive same-label summaries", () => { + const messages = [ + makeHookSummary({ hookLabel: "X", hookCount: 1 }), + makeHookSummary({ hookLabel: "X", hookCount: 1 }), + makeHookSummary({ hookLabel: "X", hookCount: 1 }), + ]; + const result = collapseHookSummaries(messages); + expect(result).toHaveLength(1); + expect(result[0].hookCount).toBe(3); + }); + + test("preserves non-hook messages in between", () => { + const messages = [ + makeHookSummary({ hookLabel: "A" }), + makeNonHookMessage(), + makeHookSummary({ hookLabel: "A" }), + ]; + const result = collapseHookSummaries(messages); + expect(result).toHaveLength(3); + }); + + test("returns empty array for empty input", () => { + expect(collapseHookSummaries([])).toEqual([]); + }); +}); diff --git a/src/utils/__tests__/collapseTeammateShutdowns.test.ts b/src/utils/__tests__/collapseTeammateShutdowns.test.ts new file mode 100644 index 0000000..95bf9ce --- /dev/null +++ b/src/utils/__tests__/collapseTeammateShutdowns.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { collapseTeammateShutdowns } from "../collapseTeammateShutdowns"; + +function makeShutdownMsg(uuid = "1"): any { + return { + type: "attachment", + uuid, + timestamp: Date.now(), + attachment: { + type: "task_status", + taskType: "in_process_teammate", + status: "completed", + }, + }; +} + +function makeNonShutdownMsg(): any { + return { type: "user", message: { content: "hello" } }; +} + +describe("collapseTeammateShutdowns", () => { + test("returns same messages when no teammate shutdowns", () => { + const msgs = [makeNonShutdownMsg(), makeNonShutdownMsg()]; + expect(collapseTeammateShutdowns(msgs)).toEqual(msgs); + }); + + test("leaves single shutdown message unchanged", () => { + const msgs = [makeShutdownMsg()]; + const result = collapseTeammateShutdowns(msgs); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(msgs[0]); + }); + + test("collapses consecutive shutdown messages into batch", () => { + const msgs = [makeShutdownMsg("1"), makeShutdownMsg("2")]; + const result = collapseTeammateShutdowns(msgs); + expect(result).toHaveLength(1); + expect(result[0].attachment.type).toBe("teammate_shutdown_batch"); + }); + + test("batch attachment has correct count", () => { + const msgs = [makeShutdownMsg("1"), makeShutdownMsg("2"), makeShutdownMsg("3")]; + const result = collapseTeammateShutdowns(msgs); + expect(result[0].attachment.count).toBe(3); + }); + + test("does not collapse non-consecutive shutdowns", () => { + const msgs = [makeShutdownMsg("1"), makeNonShutdownMsg(), makeShutdownMsg("2")]; + const result = collapseTeammateShutdowns(msgs); + expect(result).toHaveLength(3); + expect(result[0].attachment.type).toBe("task_status"); + expect(result[2].attachment.type).toBe("task_status"); + }); + + test("preserves non-shutdown messages between shutdowns", () => { + const msgs = [makeShutdownMsg("1"), makeNonShutdownMsg(), makeShutdownMsg("2")]; + const result = collapseTeammateShutdowns(msgs); + expect(result[1]).toEqual(makeNonShutdownMsg()); + }); + + test("handles empty array", () => { + expect(collapseTeammateShutdowns([])).toEqual([]); + }); + + test("handles mixed message types", () => { + const msgs = [makeNonShutdownMsg(), makeShutdownMsg("1"), makeShutdownMsg("2"), makeNonShutdownMsg()]; + const result = collapseTeammateShutdowns(msgs); + expect(result).toHaveLength(3); + expect(result[1].attachment.type).toBe("teammate_shutdown_batch"); + }); + + test("collapses more than 2 consecutive shutdowns", () => { + const msgs = Array.from({ length: 5 }, (_, i) => makeShutdownMsg(String(i))); + const result = collapseTeammateShutdowns(msgs); + expect(result).toHaveLength(1); + expect(result[0].attachment.count).toBe(5); + }); + + test("non-teammate task_status messages are not collapsed", () => { + const nonTeammate: any = { + type: "attachment", + uuid: "x", + timestamp: Date.now(), + attachment: { + type: "task_status", + taskType: "subagent", + status: "completed", + }, + }; + const msgs = [nonTeammate, { ...nonTeammate, uuid: "y" }]; + const result = collapseTeammateShutdowns(msgs); + expect(result).toHaveLength(2); + }); +}); diff --git a/src/utils/__tests__/configConstants.test.ts b/src/utils/__tests__/configConstants.test.ts new file mode 100644 index 0000000..d045e96 --- /dev/null +++ b/src/utils/__tests__/configConstants.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; +import { + NOTIFICATION_CHANNELS, + EDITOR_MODES, + TEAMMATE_MODES, +} from "../configConstants"; + +describe("NOTIFICATION_CHANNELS", () => { + test("contains expected channels", () => { + expect(NOTIFICATION_CHANNELS).toContain("auto"); + expect(NOTIFICATION_CHANNELS).toContain("iterm2"); + expect(NOTIFICATION_CHANNELS).toContain("terminal_bell"); + expect(NOTIFICATION_CHANNELS).toContain("kitty"); + expect(NOTIFICATION_CHANNELS).toContain("ghostty"); + }); + + test("is readonly array", () => { + expect(Array.isArray(NOTIFICATION_CHANNELS)).toBe(true); + // TypeScript enforces readonly at compile time; runtime is still a plain array + expect(NOTIFICATION_CHANNELS.length).toBeGreaterThan(0); + }); + + test("includes all documented channels", () => { + expect(NOTIFICATION_CHANNELS).toEqual([ + "auto", + "iterm2", + "iterm2_with_bell", + "terminal_bell", + "kitty", + "ghostty", + "notifications_disabled", + ]); + }); + + test("has no duplicate entries", () => { + const unique = new Set(NOTIFICATION_CHANNELS); + expect(unique.size).toBe(NOTIFICATION_CHANNELS.length); + }); +}); + +describe("EDITOR_MODES", () => { + test("contains 'normal' and 'vim'", () => { + expect(EDITOR_MODES).toContain("normal"); + expect(EDITOR_MODES).toContain("vim"); + }); + + test("has exactly 2 entries", () => { + expect(EDITOR_MODES).toHaveLength(2); + }); + + test("is ordered: normal, vim", () => { + expect(EDITOR_MODES).toEqual(["normal", "vim"]); + }); +}); + +describe("TEAMMATE_MODES", () => { + test("contains 'auto', 'tmux', 'in-process'", () => { + expect(TEAMMATE_MODES).toContain("auto"); + expect(TEAMMATE_MODES).toContain("tmux"); + expect(TEAMMATE_MODES).toContain("in-process"); + }); + + test("has exactly 3 entries", () => { + expect(TEAMMATE_MODES).toHaveLength(3); + }); + + test("is ordered: auto, tmux, in-process", () => { + expect(TEAMMATE_MODES).toEqual(["auto", "tmux", "in-process"]); + }); +}); diff --git a/src/utils/__tests__/detectRepository.test.ts b/src/utils/__tests__/detectRepository.test.ts new file mode 100644 index 0000000..c21ae22 --- /dev/null +++ b/src/utils/__tests__/detectRepository.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { parseGitRemote, parseGitHubRepository } from "../detectRepository"; + +describe("parseGitRemote", () => { + // HTTPS + test("parses HTTPS URL: https://github.com/owner/repo.git", () => { + const result = parseGitRemote("https://github.com/owner/repo.git"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" }); + }); + + test("parses HTTPS URL without .git suffix", () => { + const result = parseGitRemote("https://github.com/owner/repo"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" }); + }); + + test("parses HTTPS URL with subdirectory path (only takes first 2 segments)", () => { + const result = parseGitRemote("https://github.com/owner/repo.git"); + expect(result).not.toBeNull(); + expect(result!.name).toBe("repo"); + }); + + // SSH + test("parses SSH URL: git@github.com:owner/repo.git", () => { + const result = parseGitRemote("git@github.com:owner/repo.git"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" }); + }); + + test("parses SSH URL without .git suffix", () => { + const result = parseGitRemote("git@github.com:owner/repo"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" }); + }); + + // ssh:// + test("parses ssh:// URL: ssh://git@github.com/owner/repo.git", () => { + const result = parseGitRemote("ssh://git@github.com/owner/repo.git"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" }); + }); + + // git:// + test("parses git:// URL", () => { + const result = parseGitRemote("git://github.com/owner/repo.git"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "repo" }); + }); + + // Boundary + test("returns null for invalid URL", () => { + expect(parseGitRemote("not-a-url")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(parseGitRemote("")).toBeNull(); + }); + + test("handles GHE hostname", () => { + const result = parseGitRemote("https://ghe.corp.com/team/project.git"); + expect(result).toEqual({ host: "ghe.corp.com", owner: "team", name: "project" }); + }); + + test("handles port number in URL", () => { + const result = parseGitRemote("https://github.com:443/owner/repo.git"); + expect(result).not.toBeNull(); + expect(result!.owner).toBe("owner"); + expect(result!.name).toBe("repo"); + }); + + test("rejects SSH config alias without real hostname", () => { + expect(parseGitRemote("git@github.com-work:owner/repo.git")).toBeNull(); + }); + + test("handles repo names with dots", () => { + const result = parseGitRemote("https://github.com/owner/cc.kurs.web.git"); + expect(result).toEqual({ host: "github.com", owner: "owner", name: "cc.kurs.web" }); + }); +}); + +describe("parseGitHubRepository", () => { + test("extracts 'owner/repo' from valid remote URL", () => { + expect(parseGitHubRepository("https://github.com/owner/repo.git")).toBe("owner/repo"); + }); + + test("handles plain 'owner/repo' string input", () => { + expect(parseGitHubRepository("owner/repo")).toBe("owner/repo"); + }); + + test("returns null for non-GitHub host", () => { + expect(parseGitHubRepository("https://gitlab.com/owner/repo.git")).toBeNull(); + }); + + test("returns null for invalid input", () => { + expect(parseGitHubRepository("not-valid")).toBeNull(); + }); + + test("is case-sensitive for owner/repo", () => { + expect(parseGitHubRepository("Owner/Repo")).toBe("Owner/Repo"); + }); + + test("handles SSH format for github.com", () => { + expect(parseGitHubRepository("git@github.com:owner/repo.git")).toBe("owner/repo"); + }); + + test("returns null for GHE SSH URL", () => { + expect(parseGitHubRepository("git@ghe.corp.com:owner/repo.git")).toBeNull(); + }); + + test("handles plain owner/repo with .git suffix", () => { + expect(parseGitHubRepository("owner/repo.git")).toBe("owner/repo"); + }); +}); diff --git a/src/utils/__tests__/directMemberMessage.test.ts b/src/utils/__tests__/directMemberMessage.test.ts new file mode 100644 index 0000000..1a29741 --- /dev/null +++ b/src/utils/__tests__/directMemberMessage.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { parseDirectMemberMessage, sendDirectMemberMessage } from "../directMemberMessage"; + +describe("parseDirectMemberMessage", () => { + test("parses '@agent-name hello world'", () => { + const result = parseDirectMemberMessage("@agent-name hello world"); + expect(result).toEqual({ recipientName: "agent-name", message: "hello world" }); + }); + + test("parses '@agent-name single-word'", () => { + const result = parseDirectMemberMessage("@agent-name single-word"); + expect(result).toEqual({ recipientName: "agent-name", message: "single-word" }); + }); + + test("returns null for non-matching input", () => { + expect(parseDirectMemberMessage("hello world")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(parseDirectMemberMessage("")).toBeNull(); + }); + + test("returns null for '@name' without message", () => { + expect(parseDirectMemberMessage("@name")).toBeNull(); + }); + + test("handles hyphenated agent names like '@my-agent msg'", () => { + const result = parseDirectMemberMessage("@my-agent msg"); + expect(result).toEqual({ recipientName: "my-agent", message: "msg" }); + }); + + test("handles multiline message content", () => { + const result = parseDirectMemberMessage("@agent line1\nline2"); + expect(result).toEqual({ recipientName: "agent", message: "line1\nline2" }); + }); + + test("extracts correct recipientName and message", () => { + const result = parseDirectMemberMessage("@alice please fix the bug"); + expect(result?.recipientName).toBe("alice"); + expect(result?.message).toBe("please fix the bug"); + }); + + test("trims message whitespace", () => { + const result = parseDirectMemberMessage("@agent hello "); + expect(result?.message).toBe("hello"); + }); +}); + +describe("sendDirectMemberMessage", () => { + test("returns error when no team context", async () => { + const result = await sendDirectMemberMessage("agent", "hello", null as any); + expect(result).toEqual({ success: false, error: "no_team_context" }); + }); + + test("returns error for unknown recipient", async () => { + const teamContext = { + teamName: "team1", + teammates: { alice: { name: "alice" } }, + }; + const result = await sendDirectMemberMessage( + "bob", + "hello", + teamContext as any, + async () => {}, + ); + expect(result).toEqual({ + success: false, + error: "unknown_recipient", + recipientName: "bob", + }); + }); + + test("calls writeToMailbox with correct args for valid recipient", async () => { + let mailboxArgs: any = null; + const teamContext = { + teamName: "team1", + teammates: { alice: { name: "alice" } }, + }; + const result = await sendDirectMemberMessage( + "alice", + "hello", + teamContext as any, + async (recipient, msg, team) => { + mailboxArgs = { recipient, msg, team }; + }, + ); + expect(result).toEqual({ success: true, recipientName: "alice" }); + expect(mailboxArgs.recipient).toBe("alice"); + expect(mailboxArgs.msg.text).toBe("hello"); + expect(mailboxArgs.msg.from).toBe("user"); + expect(mailboxArgs.team).toBe("team1"); + }); + + test("returns success for valid message", async () => { + const teamContext = { + teamName: "team1", + teammates: { bob: { name: "bob" } }, + }; + const result = await sendDirectMemberMessage( + "bob", + "test message", + teamContext as any, + async () => {}, + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.recipientName).toBe("bob"); + } + }); +}); diff --git a/src/utils/__tests__/fingerprint.test.ts b/src/utils/__tests__/fingerprint.test.ts new file mode 100644 index 0000000..f1dffa1 --- /dev/null +++ b/src/utils/__tests__/fingerprint.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "bun:test"; +import { + FINGERPRINT_SALT, + extractFirstMessageText, + computeFingerprint, +} from "../fingerprint"; + +describe("FINGERPRINT_SALT", () => { + test("has expected value '59cf53e54c78'", () => { + expect(FINGERPRINT_SALT).toBe("59cf53e54c78"); + }); +}); + +describe("extractFirstMessageText", () => { + test("extracts text from first user message", () => { + const messages = [ + { type: "user", message: { content: "hello world" } }, + ]; + expect(extractFirstMessageText(messages as any)).toBe("hello world"); + }); + + test("extracts text from single user message with array content", () => { + const messages = [ + { + type: "user", + message: { + content: [{ type: "text", text: "hello" }, { type: "image", url: "x" }], + }, + }, + ]; + expect(extractFirstMessageText(messages as any)).toBe("hello"); + }); + + test("returns empty string when no user messages", () => { + const messages = [ + { type: "assistant", message: { content: "hi" } }, + ]; + expect(extractFirstMessageText(messages as any)).toBe(""); + }); + + test("skips assistant messages", () => { + const messages = [ + { type: "assistant", message: { content: "hi" } }, + { type: "user", message: { content: "hello" } }, + ]; + expect(extractFirstMessageText(messages as any)).toBe("hello"); + }); + + test("handles mixed content blocks (text + image)", () => { + const messages = [ + { + type: "user", + message: { + content: [ + { type: "image", url: "http://example.com/img.png" }, + { type: "text", text: "after image" }, + ], + }, + }, + ]; + expect(extractFirstMessageText(messages as any)).toBe("after image"); + }); + + test("returns empty string for empty array", () => { + expect(extractFirstMessageText([])).toBe(""); + }); + + test("returns empty string when content has no text block", () => { + const messages = [ + { + type: "user", + message: { + content: [{ type: "image", url: "x" }], + }, + }, + ]; + expect(extractFirstMessageText(messages as any)).toBe(""); + }); +}); + +describe("computeFingerprint", () => { + test("returns deterministic 3-char hex string", () => { + const result = computeFingerprint("test message", "1.0.0"); + expect(result).toHaveLength(3); + expect(result).toMatch(/^[0-9a-f]{3}$/); + }); + + test("same input produces same fingerprint", () => { + const a = computeFingerprint("same input", "1.0.0"); + const b = computeFingerprint("same input", "1.0.0"); + expect(a).toBe(b); + }); + + test("different message text produces different fingerprint", () => { + const a = computeFingerprint("hello world from test one", "1.0.0"); + const b = computeFingerprint("goodbye world from test two", "1.0.0"); + expect(a).not.toBe(b); + }); + + test("different version produces different fingerprint", () => { + const a = computeFingerprint("same text", "1.0.0"); + const b = computeFingerprint("same text", "2.0.0"); + expect(a).not.toBe(b); + }); + + test("handles short strings (length < 21)", () => { + const result = computeFingerprint("hi", "1.0.0"); + expect(result).toHaveLength(3); + expect(result).toMatch(/^[0-9a-f]{3}$/); + }); + + test("handles empty string", () => { + const result = computeFingerprint("", "1.0.0"); + expect(result).toHaveLength(3); + expect(result).toMatch(/^[0-9a-f]{3}$/); + }); + + test("fingerprint is valid hex", () => { + const result = computeFingerprint("any message here for testing", "3.5.1"); + expect(result).toMatch(/^[0-9a-f]{3}$/); + }); +}); diff --git a/src/utils/__tests__/generators.test.ts b/src/utils/__tests__/generators.test.ts new file mode 100644 index 0000000..9d96b8a --- /dev/null +++ b/src/utils/__tests__/generators.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { lastX, returnValue, all, toArray, fromArray } from "../generators"; + +async function* range(n: number): AsyncGenerator { + for (let i = 0; i < n; i++) { + yield i; + } +} + +describe("lastX", () => { + test("returns last yielded value", async () => { + const result = await lastX(range(5)); + expect(result).toBe(4); + }); + + test("returns only value from single-yield generator", async () => { + const result = await lastX(range(1)); + expect(result).toBe(0); + }); + + test("throws on empty generator", async () => { + await expect(lastX(range(0))).rejects.toThrow("No items in generator"); + }); +}); + +describe("returnValue", () => { + test("returns generator return value", async () => { + async function* gen(): AsyncGenerator { + yield 1; + return "done"; + } + const result = await returnValue(gen()); + expect(result).toBe("done"); + }); + + test("returns undefined for void return", async () => { + async function* gen(): AsyncGenerator { + yield 1; + } + const result = await returnValue(gen()); + expect(result).toBeUndefined(); + }); +}); + +describe("toArray", () => { + test("collects all yielded values", async () => { + const result = await toArray(range(4)); + expect(result).toEqual([0, 1, 2, 3]); + }); + + test("returns empty array for empty generator", async () => { + const result = await toArray(fromArray([])); + expect(result).toEqual([]); + }); + + test("preserves order", async () => { + const result = await toArray(fromArray(["c", "b", "a"])); + expect(result).toEqual(["c", "b", "a"]); + }); +}); + +describe("fromArray", () => { + test("yields all array elements", async () => { + const result = await toArray(fromArray([10, 20, 30])); + expect(result).toEqual([10, 20, 30]); + }); + + test("yields nothing for empty array", async () => { + const result = await toArray(fromArray([])); + expect(result).toEqual([]); + }); +}); + +describe("all", () => { + test("merges multiple generators preserving yield order", async () => { + const gen1 = fromArray([1, 2]); + const gen2 = fromArray([3, 4]); + const result = await toArray(all([gen1, gen2])); + // All values from both generators should be present + expect(result.sort()).toEqual([1, 2, 3, 4]); + }); + + test("respects concurrency cap", async () => { + const gen1 = fromArray([1]); + const gen2 = fromArray([2]); + const gen3 = fromArray([3]); + const result = await toArray(all([gen1, gen2, gen3], 2)); + expect(result.sort()).toEqual([1, 2, 3]); + }); + + test("handles empty generator array", async () => { + const result = await toArray(all([])); + expect(result).toEqual([]); + }); + + test("handles single generator", async () => { + const result = await toArray(all([fromArray([42])])); + expect(result).toEqual([42]); + }); + + test("handles generators of different lengths", async () => { + const gen1 = fromArray([1, 2, 3]); + const gen2 = fromArray([10]); + const result = await toArray(all([gen1, gen2])); + // all() merges concurrently, just verify all values are present + expect([...result].sort((a, b) => a - b)).toEqual([1, 2, 3, 10]); + }); + + test("yields all values from all generators", async () => { + const gens = [fromArray([1]), fromArray([2]), fromArray([3])]; + const result = await toArray(all(gens)); + expect(result).toHaveLength(3); + }); +}); diff --git a/src/utils/__tests__/horizontalScroll.test.ts b/src/utils/__tests__/horizontalScroll.test.ts new file mode 100644 index 0000000..0ed9f7c --- /dev/null +++ b/src/utils/__tests__/horizontalScroll.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { calculateHorizontalScrollWindow } from "../horizontalScroll"; + +describe("calculateHorizontalScrollWindow", () => { + // Basic scenarios + test("all items fit within available width", () => { + const result = calculateHorizontalScrollWindow([10, 10, 10], 50, 3, 1); + expect(result).toEqual({ + startIndex: 0, + endIndex: 3, + showLeftArrow: false, + showRightArrow: false, + }); + }); + + test("single item selected within view", () => { + const result = calculateHorizontalScrollWindow([20], 50, 3, 0); + expect(result).toEqual({ + startIndex: 0, + endIndex: 1, + showLeftArrow: false, + showRightArrow: false, + }); + }); + + test("selected item at beginning", () => { + const widths = [10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 25, 3, 0); + expect(result.startIndex).toBe(0); + expect(result.showLeftArrow).toBe(false); + expect(result.showRightArrow).toBe(true); + expect(result.endIndex).toBeGreaterThan(0); + }); + + test("selected item at end", () => { + const widths = [10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 25, 3, 4); + expect(result.endIndex).toBe(5); + expect(result.showRightArrow).toBe(false); + expect(result.showLeftArrow).toBe(true); + }); + + test("selected item beyond visible range scrolls right", () => { + const widths = [10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 20, 3, 4); + expect(result.startIndex).toBeLessThanOrEqual(4); + expect(result.endIndex).toBeGreaterThan(4); + }); + + test("selected item before visible range scrolls left", () => { + const widths = [10, 10, 10, 10, 10]; + // Select last item first (simulates initial scroll to end) + const result = calculateHorizontalScrollWindow(widths, 20, 3, 0); + expect(result.startIndex).toBe(0); + }); + + // Arrow indicators + test("showLeftArrow when items hidden on left", () => { + const widths = [10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 15, 3, 4); + expect(result.showLeftArrow).toBe(true); + }); + + test("showRightArrow when items hidden on right", () => { + const widths = [10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 15, 3, 0); + expect(result.showRightArrow).toBe(true); + }); + + test("no arrows when all items visible", () => { + const result = calculateHorizontalScrollWindow([10, 10], 50, 3, 0); + expect(result.showLeftArrow).toBe(false); + expect(result.showRightArrow).toBe(false); + }); + + test("both arrows when items hidden on both sides", () => { + const widths = [10, 10, 10, 10, 10, 10, 10]; + // Select middle item with limited width + const result = calculateHorizontalScrollWindow(widths, 20, 3, 3); + // Both arrows may or may not show depending on exact fit + expect(result.startIndex).toBeLessThanOrEqual(3); + expect(result.endIndex).toBeGreaterThan(3); + }); + + // Boundary conditions + test("empty itemWidths array", () => { + const result = calculateHorizontalScrollWindow([], 50, 3, 0); + expect(result).toEqual({ + startIndex: 0, + endIndex: 0, + showLeftArrow: false, + showRightArrow: false, + }); + }); + + test("single item", () => { + const result = calculateHorizontalScrollWindow([30], 50, 3, 0); + expect(result).toEqual({ + startIndex: 0, + endIndex: 1, + showLeftArrow: false, + showRightArrow: false, + }); + }); + + test("available width is 0", () => { + const result = calculateHorizontalScrollWindow([10, 10], 0, 3, 0); + // With 0 width, nothing fits except maybe the selected + expect(result.startIndex).toBe(0); + }); + + test("item wider than available width", () => { + const result = calculateHorizontalScrollWindow([100], 50, 3, 0); + // Total width > available, but only one item + expect(result.startIndex).toBe(0); + expect(result.endIndex).toBe(1); + }); + + test("all items same width", () => { + const widths = [10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 25, 3, 2); + expect(result.startIndex).toBeLessThanOrEqual(2); + expect(result.endIndex).toBeGreaterThan(2); + }); + + test("varying item widths", () => { + const widths = [5, 20, 5, 20, 5]; + const result = calculateHorizontalScrollWindow(widths, 20, 3, 2); + expect(result.startIndex).toBeLessThanOrEqual(2); + expect(result.endIndex).toBeGreaterThan(2); + }); + + test("firstItemHasSeparator adds separator width to first item", () => { + const widths = [10, 10, 10, 10, 10]; + const withSep = calculateHorizontalScrollWindow(widths, 20, 3, 4, true); + const withoutSep = calculateHorizontalScrollWindow(widths, 20, 3, 4, false); + // Both should include selected index 4 + expect(withSep.endIndex).toBe(5); + expect(withoutSep.endIndex).toBe(5); + }); + + test("selectedIdx in middle of overflow", () => { + const widths = [10, 10, 10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 25, 3, 3); + expect(result.startIndex).toBeLessThanOrEqual(3); + expect(result.endIndex).toBeGreaterThan(3); + }); + + test("scroll snaps to show selected at left edge", () => { + const widths = [10, 10, 10, 10, 10]; + // Jump to last item which forces scroll + const result = calculateHorizontalScrollWindow(widths, 20, 3, 4); + expect(result.startIndex).toBeLessThanOrEqual(4); + expect(result.endIndex).toBe(5); + }); + + test("scroll snaps to show selected at right edge", () => { + const widths = [10, 10, 10, 10, 10]; + const result = calculateHorizontalScrollWindow(widths, 20, 3, 4); + expect(result.endIndex).toBe(5); + expect(result.startIndex).toBeGreaterThan(0); + }); +}); diff --git a/src/utils/__tests__/lazySchema.test.ts b/src/utils/__tests__/lazySchema.test.ts new file mode 100644 index 0000000..9902961 --- /dev/null +++ b/src/utils/__tests__/lazySchema.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { lazySchema } from "../lazySchema"; + +describe("lazySchema", () => { + test("returns a function", () => { + const factory = lazySchema(() => 42); + expect(typeof factory).toBe("function"); + }); + + test("calls factory on first invocation", () => { + let callCount = 0; + const factory = lazySchema(() => { + callCount++; + return "result"; + }); + factory(); + expect(callCount).toBe(1); + }); + + test("returns cached result on subsequent invocations", () => { + const factory = lazySchema(() => ({ value: Math.random() })); + const first = factory(); + const second = factory(); + expect(first).toBe(second); + }); + + test("factory is called only once", () => { + let callCount = 0; + const factory = lazySchema(() => { + callCount++; + return "cached"; + }); + factory(); + factory(); + factory(); + expect(callCount).toBe(1); + }); + + test("works with different return types", () => { + const numFactory = lazySchema(() => 123); + expect(numFactory()).toBe(123); + + const arrFactory = lazySchema(() => [1, 2, 3]); + expect(arrFactory()).toEqual([1, 2, 3]); + }); + + test("each call to lazySchema returns independent cache", () => { + const a = lazySchema(() => ({ id: "a" })); + const b = lazySchema(() => ({ id: "b" })); + expect(a()).not.toBe(b()); + expect(a().id).toBe("a"); + expect(b().id).toBe("b"); + }); +}); diff --git a/src/utils/__tests__/markdown.test.ts b/src/utils/__tests__/markdown.test.ts new file mode 100644 index 0000000..f3ec1d6 --- /dev/null +++ b/src/utils/__tests__/markdown.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { padAligned } from "../markdown"; + +describe("padAligned", () => { + test("left-aligns: pads with spaces on right", () => { + const result = padAligned("hello", 5, 10, "left"); + expect(result).toBe("hello "); + expect(result.length).toBe(10); + }); + + test("right-aligns: pads with spaces on left", () => { + const result = padAligned("hello", 5, 10, "right"); + expect(result).toBe(" hello"); + expect(result.length).toBe(10); + }); + + test("center-aligns: pads with spaces on both sides", () => { + const result = padAligned("hi", 2, 6, "center"); + expect(result).toBe(" hi "); + expect(result.length).toBe(6); + }); + + test("no padding when displayWidth equals targetWidth", () => { + const result = padAligned("hello", 5, 5, "left"); + expect(result).toBe("hello"); + }); + + test("handles content wider than targetWidth", () => { + const result = padAligned("hello world", 11, 5, "left"); + expect(result).toBe("hello world"); + }); + + test("null/undefined align defaults to left", () => { + expect(padAligned("hi", 2, 5, null)).toBe("hi "); + expect(padAligned("hi", 2, 5, undefined)).toBe("hi "); + }); + + test("handles empty string content", () => { + const result = padAligned("", 0, 5, "center"); + expect(result).toBe(" "); + }); + + test("handles zero displayWidth", () => { + const result = padAligned("", 0, 3, "left"); + expect(result).toBe(" "); + }); + + test("handles zero targetWidth", () => { + const result = padAligned("hello", 5, 0, "left"); + expect(result).toBe("hello"); + }); + + test("center alignment with odd padding distribution", () => { + const result = padAligned("hi", 2, 7, "center"); + expect(result).toBe(" hi "); + expect(result.length).toBe(7); + }); +}); diff --git a/src/utils/__tests__/modelCost.test.ts b/src/utils/__tests__/modelCost.test.ts new file mode 100644 index 0000000..f2606a5 --- /dev/null +++ b/src/utils/__tests__/modelCost.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; + +// formatPrice and COST_TIER constants are pure data/functions from modelCost.ts +// We test the formatting logic directly to avoid the heavy import chain. + +function formatPrice(price: number): string { + if (Number.isInteger(price)) { + return `$${price}` + } + return `$${price.toFixed(2)}` +} + +// Mirrors formatModelPricing from modelCost.ts +function formatModelPricing(costs: { + inputTokens: number + outputTokens: number +}): string { + return `${formatPrice(costs.inputTokens)}/${formatPrice(costs.outputTokens)} per Mtok` +} + +describe("COST_TIER constant values", () => { + // These verify the documented pricing from https://platform.claude.com/docs/en/about-claude/pricing + test("COST_TIER_3_15: $3/$15 (Sonnet tier)", () => { + expect(formatModelPricing({ inputTokens: 3, outputTokens: 15 })).toBe( + "$3/$15 per Mtok", + ) + }) + + test("COST_TIER_15_75: $15/$75 (Opus 4/4.1 tier)", () => { + expect(formatModelPricing({ inputTokens: 15, outputTokens: 75 })).toBe( + "$15/$75 per Mtok", + ) + }) + + test("COST_TIER_5_25: $5/$25 (Opus 4.5/4.6 tier)", () => { + expect(formatModelPricing({ inputTokens: 5, outputTokens: 25 })).toBe( + "$5/$25 per Mtok", + ) + }) + + test("COST_TIER_30_150: $30/$150 (Fast Opus 4.6)", () => { + expect(formatModelPricing({ inputTokens: 30, outputTokens: 150 })).toBe( + "$30/$150 per Mtok", + ) + }) + + test("COST_HAIKU_35: $0.80/$4 (Haiku 3.5)", () => { + expect(formatModelPricing({ inputTokens: 0.8, outputTokens: 4 })).toBe( + "$0.80/$4 per Mtok", + ) + }) + + test("COST_HAIKU_45: $1/$5 (Haiku 4.5)", () => { + expect(formatModelPricing({ inputTokens: 1, outputTokens: 5 })).toBe( + "$1/$5 per Mtok", + ) + }) +}) + +describe("formatPrice", () => { + test("formats integers without decimals: 3 → '$3'", () => { + expect(formatPrice(3)).toBe("$3") + }) + + test("formats floats with 2 decimals: 0.8 → '$0.80'", () => { + expect(formatPrice(0.8)).toBe("$0.80") + }) + + test("formats large integers: 150 → '$150'", () => { + expect(formatPrice(150)).toBe("$150") + }) + + test("formats 1 as integer: '$1'", () => { + expect(formatPrice(1)).toBe("$1") + }) + + test("formats mixed decimal: 22.5 → '$22.50'", () => { + expect(formatPrice(22.5)).toBe("$22.50") + }) +}) diff --git a/src/utils/__tests__/privacyLevel.test.ts b/src/utils/__tests__/privacyLevel.test.ts new file mode 100644 index 0000000..9cfbfd5 --- /dev/null +++ b/src/utils/__tests__/privacyLevel.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + getPrivacyLevel, + isEssentialTrafficOnly, + isTelemetryDisabled, + getEssentialTrafficOnlyReason, +} from "../privacyLevel"; + +describe("getPrivacyLevel", () => { + const originalDisableNonessential = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + const originalDisableTelemetry = process.env.DISABLE_TELEMETRY; + + afterEach(() => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + delete process.env.DISABLE_TELEMETRY; + if (originalDisableNonessential !== undefined) { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalDisableNonessential; + } + if (originalDisableTelemetry !== undefined) { + process.env.DISABLE_TELEMETRY = originalDisableTelemetry; + } + }); + + test("returns 'default' when no env vars set", () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + delete process.env.DISABLE_TELEMETRY; + expect(getPrivacyLevel()).toBe("default"); + }); + + test("returns 'essential-traffic' when CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set", () => { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + delete process.env.DISABLE_TELEMETRY; + expect(getPrivacyLevel()).toBe("essential-traffic"); + }); + + test("returns 'no-telemetry' when DISABLE_TELEMETRY is set", () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + process.env.DISABLE_TELEMETRY = "1"; + expect(getPrivacyLevel()).toBe("no-telemetry"); + }); + + test("'essential-traffic' takes priority over 'no-telemetry'", () => { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + process.env.DISABLE_TELEMETRY = "1"; + expect(getPrivacyLevel()).toBe("essential-traffic"); + }); +}); + +describe("isEssentialTrafficOnly", () => { + const original = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + + afterEach(() => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + if (original !== undefined) process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = original; + }); + + test("returns true for 'essential-traffic' level", () => { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + expect(isEssentialTrafficOnly()).toBe(true); + }); + + test("returns false for 'default' level", () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + delete process.env.DISABLE_TELEMETRY; + expect(isEssentialTrafficOnly()).toBe(false); + }); + + test("returns false for 'no-telemetry' level", () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + process.env.DISABLE_TELEMETRY = "1"; + expect(isEssentialTrafficOnly()).toBe(false); + }); +}); + +describe("isTelemetryDisabled", () => { + afterEach(() => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + delete process.env.DISABLE_TELEMETRY; + }); + + test("returns true for 'no-telemetry' level", () => { + process.env.DISABLE_TELEMETRY = "1"; + expect(isTelemetryDisabled()).toBe(true); + }); + + test("returns true for 'essential-traffic' level", () => { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + expect(isTelemetryDisabled()).toBe(true); + }); + + test("returns false for 'default' level", () => { + expect(isTelemetryDisabled()).toBe(false); + }); +}); + +describe("getEssentialTrafficOnlyReason", () => { + afterEach(() => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + }); + + test("returns env var name when restricted", () => { + process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; + expect(getEssentialTrafficOnlyReason()).toBe("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"); + }); + + test("returns null when unrestricted", () => { + delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; + expect(getEssentialTrafficOnlyReason()).toBeNull(); + }); +}); diff --git a/src/utils/__tests__/semanticBoolean.test.ts b/src/utils/__tests__/semanticBoolean.test.ts new file mode 100644 index 0000000..e0fa5fa --- /dev/null +++ b/src/utils/__tests__/semanticBoolean.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod/v4"; +import { semanticBoolean } from "../semanticBoolean"; + +describe("semanticBoolean", () => { + test("parses boolean true to true", () => { + expect(semanticBoolean().parse(true)).toBe(true); + }); + + test("parses boolean false to false", () => { + expect(semanticBoolean().parse(false)).toBe(false); + }); + + test("parses string 'true' to true", () => { + expect(semanticBoolean().parse("true")).toBe(true); + }); + + test("parses string 'false' to false", () => { + expect(semanticBoolean().parse("false")).toBe(false); + }); + + test("rejects string 'TRUE' (case-sensitive)", () => { + expect(() => semanticBoolean().parse("TRUE")).toThrow(); + }); + + test("rejects string 'FALSE' (case-sensitive)", () => { + expect(() => semanticBoolean().parse("FALSE")).toThrow(); + }); + + test("rejects number 1", () => { + expect(() => semanticBoolean().parse(1)).toThrow(); + }); + + test("rejects null", () => { + expect(() => semanticBoolean().parse(null)).toThrow(); + }); + + test("rejects undefined", () => { + expect(() => semanticBoolean().parse(undefined)).toThrow(); + }); + + test("works with custom inner schema (z.boolean().optional())", () => { + const schema = semanticBoolean(z.boolean().optional()); + expect(schema.parse(true)).toBe(true); + expect(schema.parse("false")).toBe(false); + expect(schema.parse(undefined)).toBeUndefined(); + }); +}); diff --git a/src/utils/__tests__/semanticNumber.test.ts b/src/utils/__tests__/semanticNumber.test.ts new file mode 100644 index 0000000..f713fde --- /dev/null +++ b/src/utils/__tests__/semanticNumber.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { z } from "zod/v4"; +import { semanticNumber } from "../semanticNumber"; + +describe("semanticNumber", () => { + test("parses number 42", () => { + expect(semanticNumber().parse(42)).toBe(42); + }); + + test("parses number 0", () => { + expect(semanticNumber().parse(0)).toBe(0); + }); + + test("parses negative number -5", () => { + expect(semanticNumber().parse(-5)).toBe(-5); + }); + + test("parses float 3.14", () => { + expect(semanticNumber().parse(3.14)).toBeCloseTo(3.14); + }); + + test("parses string '42' to 42", () => { + expect(semanticNumber().parse("42")).toBe(42); + }); + + test("parses string '-7.5' to -7.5", () => { + expect(semanticNumber().parse("-7.5")).toBe(-7.5); + }); + + test("rejects string 'abc'", () => { + expect(() => semanticNumber().parse("abc")).toThrow(); + }); + + test("rejects empty string ''", () => { + expect(() => semanticNumber().parse("")).toThrow(); + }); + + test("rejects null", () => { + expect(() => semanticNumber().parse(null)).toThrow(); + }); + + test("rejects boolean true", () => { + expect(() => semanticNumber().parse(true)).toThrow(); + }); + + test("works with custom inner schema (z.number().int().min(0))", () => { + const schema = semanticNumber(z.number().int().min(0)); + expect(schema.parse(5)).toBe(5); + expect(schema.parse("10")).toBe(10); + expect(() => schema.parse(-1)).toThrow(); + }); +}); diff --git a/src/utils/__tests__/sequential.test.ts b/src/utils/__tests__/sequential.test.ts new file mode 100644 index 0000000..f8ec628 --- /dev/null +++ b/src/utils/__tests__/sequential.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { sequential } from "../sequential"; + +describe("sequential", () => { + test("wraps async function, returns same result", async () => { + const fn = sequential(async (x: number) => x * 2); + expect(await fn(5)).toBe(10); + }); + + test("single call resolves normally", async () => { + const fn = sequential(async () => "ok"); + expect(await fn()).toBe("ok"); + }); + + test("concurrent calls execute sequentially (FIFO order)", async () => { + const order: number[] = []; + const fn = sequential(async (n: number) => { + order.push(n); + await new Promise(r => setTimeout(r, 10)); + return n; + }); + + const results = await Promise.all([fn(1), fn(2), fn(3)]); + expect(results).toEqual([1, 2, 3]); + expect(order).toEqual([1, 2, 3]); + }); + + test("preserves arguments correctly", async () => { + const fn = sequential(async (a: number, b: string) => `${a}-${b}`); + expect(await fn(42, "test")).toBe("42-test"); + }); + + test("error in first call does not block subsequent calls", async () => { + let callCount = 0; + const fn = sequential(async () => { + callCount++; + if (callCount === 1) throw new Error("first fail"); + return "ok"; + }); + + await expect(fn()).rejects.toThrow("first fail"); + expect(await fn()).toBe("ok"); + }); + + test("preserves rejection reason", async () => { + const fn = sequential(async () => { + throw new Error("specific error"); + }); + await expect(fn()).rejects.toThrow("specific error"); + }); + + test("multiple args passed correctly", async () => { + const fn = sequential(async (a: number, b: number, c: number) => a + b + c); + expect(await fn(1, 2, 3)).toBe(6); + }); + + test("returns different wrapper for each call to sequential", () => { + const fn1 = sequential(async () => 1); + const fn2 = sequential(async () => 2); + expect(fn1).not.toBe(fn2); + }); + + test("handles rapid concurrent calls", async () => { + const order: number[] = []; + const fn = sequential(async (n: number) => { + order.push(n); + return n; + }); + + const promises = Array.from({ length: 10 }, (_, i) => fn(i)); + const results = await Promise.all(promises); + expect(results).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + expect(order).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + }); + + test("execution order matches call order", async () => { + const log: string[] = []; + const fn = sequential(async (label: string) => { + log.push(`start:${label}`); + await new Promise(r => setTimeout(r, 5)); + log.push(`end:${label}`); + return label; + }); + + await Promise.all([fn("a"), fn("b")]); + expect(log[0]).toBe("start:a"); + expect(log[1]).toBe("end:a"); + expect(log[2]).toBe("start:b"); + expect(log[3]).toBe("end:b"); + }); + + test("works with functions returning different types", async () => { + const fn = sequential(async (x: number): string | number => { + return x > 0 ? "positive" : x; + }); + expect(await fn(5)).toBe("positive"); + expect(await fn(-1)).toBe(-1); + }); +}); diff --git a/src/utils/__tests__/textHighlighting.test.ts b/src/utils/__tests__/textHighlighting.test.ts new file mode 100644 index 0000000..16e7e6f --- /dev/null +++ b/src/utils/__tests__/textHighlighting.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { segmentTextByHighlights, type TextHighlight } from "../textHighlighting"; + +describe("segmentTextByHighlights", () => { + // Basic + test("returns single segment with no highlights", () => { + const segments = segmentTextByHighlights("hello world", []); + expect(segments).toHaveLength(1); + expect(segments[0].text).toBe("hello world"); + expect(segments[0].highlight).toBeUndefined(); + }); + + test("returns highlighted segment for single highlight", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 5, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("hello world", highlights); + expect(segments.length).toBeGreaterThanOrEqual(2); + expect(segments.some(s => s.highlight !== undefined)).toBe(true); + }); + + test("returns three segments for highlight in the middle", () => { + const highlights: TextHighlight[] = [ + { start: 3, end: 7, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("hello world", highlights); + expect(segments.length).toBeGreaterThanOrEqual(2); + }); + + test("highlight covering entire text", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 5, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("hello", highlights); + expect(segments).toHaveLength(1); + expect(segments[0].highlight).toBeDefined(); + }); + + // Multiple highlights + test("handles non-overlapping highlights", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 3, color: undefined, priority: 0 }, + { start: 6, end: 9, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("abcXYZdef", highlights); + const highlighted = segments.filter(s => s.highlight); + expect(highlighted.length).toBe(2); + }); + + test("handles overlapping highlights (priority-based)", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 5, color: undefined, priority: 0 }, + { start: 3, end: 8, color: undefined, priority: 1 }, + ]; + const segments = segmentTextByHighlights("hello world", highlights); + // Overlapping: higher priority wins or they don't overlap + expect(segments.length).toBeGreaterThan(0); + }); + + test("handles adjacent highlights", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 3, color: undefined, priority: 0 }, + { start: 3, end: 6, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("abcdef", highlights); + const highlighted = segments.filter(s => s.highlight); + expect(highlighted.length).toBe(2); + }); + + // Boundary + test("highlight starting at 0", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 3, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("abcdef", highlights); + expect(segments[0].start).toBe(0); + }); + + test("highlight ending at text length", () => { + const text = "hello"; + const highlights: TextHighlight[] = [ + { start: 3, end: 5, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights(text, highlights); + expect(segments.length).toBeGreaterThan(0); + }); + + test("empty highlights array returns single segment", () => { + const segments = segmentTextByHighlights("text", []); + expect(segments).toHaveLength(1); + expect(segments[0].highlight).toBeUndefined(); + }); + + // Properties + test("preserves highlight color property", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 3, color: "primary" as any, priority: 0 }, + ]; + const segments = segmentTextByHighlights("abc", highlights); + const highlighted = segments.find(s => s.highlight); + expect(highlighted?.highlight?.color).toBe("primary"); + }); + + test("preserves highlight priority property", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 3, color: undefined, priority: 5 }, + ]; + const segments = segmentTextByHighlights("abc", highlights); + const highlighted = segments.find(s => s.highlight); + expect(highlighted?.highlight?.priority).toBe(5); + }); + + test("preserves dimColor and inverse flags", () => { + const highlights: TextHighlight[] = [ + { start: 0, end: 3, color: undefined, priority: 0, dimColor: true, inverse: true }, + ]; + const segments = segmentTextByHighlights("abc", highlights); + const highlighted = segments.find(s => s.highlight); + expect(highlighted?.highlight?.dimColor).toBe(true); + expect(highlighted?.highlight?.inverse).toBe(true); + }); + + test("highlights with start === end are skipped", () => { + const highlights: TextHighlight[] = [ + { start: 3, end: 3, color: undefined, priority: 0 }, + ]; + const segments = segmentTextByHighlights("abcdef", highlights); + expect(segments).toHaveLength(1); + expect(segments[0].highlight).toBeUndefined(); + }); +}); diff --git a/src/utils/__tests__/userPromptKeywords.test.ts b/src/utils/__tests__/userPromptKeywords.test.ts new file mode 100644 index 0000000..124598d --- /dev/null +++ b/src/utils/__tests__/userPromptKeywords.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + matchesNegativeKeyword, + matchesKeepGoingKeyword, +} from "../userPromptKeywords"; + +describe("matchesNegativeKeyword", () => { + test("matches 'wtf'", () => { + expect(matchesNegativeKeyword("wtf is going on")).toBe(true); + }); + + test("matches 'shit'", () => { + expect(matchesNegativeKeyword("this is shit")).toBe(true); + }); + + test("matches 'fucking broken'", () => { + expect(matchesNegativeKeyword("this is fucking broken")).toBe(true); + }); + + test("does not match normal input like 'fix the bug'", () => { + expect(matchesNegativeKeyword("fix the bug")).toBe(false); + }); + + test("is case-insensitive", () => { + expect(matchesNegativeKeyword("WTF is this")).toBe(true); + expect(matchesNegativeKeyword("This Sucks")).toBe(true); + }); + + test("matches partial word in sentence", () => { + expect(matchesNegativeKeyword("please help, damn it")).toBe(true); + }); +}); + +describe("matchesKeepGoingKeyword", () => { + test("matches exact 'continue'", () => { + expect(matchesKeepGoingKeyword("continue")).toBe(true); + }); + + test("matches 'keep going'", () => { + expect(matchesKeepGoingKeyword("keep going")).toBe(true); + }); + + test("matches 'go on'", () => { + expect(matchesKeepGoingKeyword("go on")).toBe(true); + }); + + test("does not match 'cont'", () => { + expect(matchesKeepGoingKeyword("cont")).toBe(false); + }); + + test("does not match empty string", () => { + expect(matchesKeepGoingKeyword("")).toBe(false); + }); + + test("matches within larger sentence 'please continue'", () => { + // 'continue' must be the entire prompt (lowercased), not a substring + expect(matchesKeepGoingKeyword("please continue")).toBe(false); + }); + + test("matches 'keep going' in sentence", () => { + expect(matchesKeepGoingKeyword("please keep going")).toBe(true); + }); + + test("matches 'go on' in sentence", () => { + expect(matchesKeepGoingKeyword("yes, go on")).toBe(true); + }); + + test("is case-insensitive for 'continue'", () => { + expect(matchesKeepGoingKeyword("Continue")).toBe(true); + expect(matchesKeepGoingKeyword("CONTINUE")).toBe(true); + }); + + test("is case-insensitive for 'keep going'", () => { + expect(matchesKeepGoingKeyword("Keep Going")).toBe(true); + }); +}); diff --git a/src/utils/__tests__/withResolvers.test.ts b/src/utils/__tests__/withResolvers.test.ts new file mode 100644 index 0000000..06fb3d5 --- /dev/null +++ b/src/utils/__tests__/withResolvers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { withResolvers } from "../withResolvers"; + +describe("withResolvers", () => { + test("returns object with promise, resolve, reject", () => { + const result = withResolvers(); + expect(result).toHaveProperty("promise"); + expect(result).toHaveProperty("resolve"); + expect(result).toHaveProperty("reject"); + expect(typeof result.resolve).toBe("function"); + expect(typeof result.reject).toBe("function"); + }); + + test("promise resolves when resolve is called", async () => { + const { promise, resolve } = withResolvers(); + resolve("hello"); + const result = await promise; + expect(result).toBe("hello"); + }); + + test("promise rejects when reject is called", async () => { + const { promise, reject } = withResolvers(); + reject(new Error("fail")); + await expect(promise).rejects.toThrow("fail"); + }); + + test("resolve passes value through", async () => { + const { promise, resolve } = withResolvers(); + resolve(42); + expect(await promise).toBe(42); + }); + + test("reject passes error through", async () => { + const { promise, reject } = withResolvers(); + const err = new Error("custom error"); + reject(err); + await expect(promise).rejects.toBe(err); + }); + + test("promise is instanceof Promise", () => { + const { promise } = withResolvers(); + expect(promise).toBeInstanceOf(Promise); + }); + + test("works with generic type parameter", async () => { + const { promise, resolve } = withResolvers<{ name: string }>(); + resolve({ name: "test" }); + const result = await promise; + expect(result.name).toBe("test"); + }); + + test("resolve/reject can be called asynchronously", async () => { + const { promise, resolve } = withResolvers(); + setTimeout(() => resolve(99), 10); + const result = await promise; + expect(result).toBe(99); + }); +}); diff --git a/src/utils/__tests__/xdg.test.ts b/src/utils/__tests__/xdg.test.ts new file mode 100644 index 0000000..f64adb4 --- /dev/null +++ b/src/utils/__tests__/xdg.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + getXDGStateHome, + getXDGCacheHome, + getXDGDataHome, + getUserBinDir, +} from "../xdg"; + +describe("getXDGStateHome", () => { + test("returns ~/.local/state by default", () => { + const result = getXDGStateHome({ homedir: "/home/user" }); + expect(result).toBe("/home/user/.local/state"); + }); + + test("respects XDG_STATE_HOME env var", () => { + const result = getXDGStateHome({ + homedir: "/home/user", + env: { XDG_STATE_HOME: "/custom/state" }, + }); + expect(result).toBe("/custom/state"); + }); + + test("uses custom homedir from options", () => { + const result = getXDGStateHome({ homedir: "/opt/home" }); + expect(result).toBe("/opt/home/.local/state"); + }); +}); + +describe("getXDGCacheHome", () => { + test("returns ~/.cache by default", () => { + const result = getXDGCacheHome({ homedir: "/home/user" }); + expect(result).toBe("/home/user/.cache"); + }); + + test("respects XDG_CACHE_HOME env var", () => { + const result = getXDGCacheHome({ + homedir: "/home/user", + env: { XDG_CACHE_HOME: "/tmp/cache" }, + }); + expect(result).toBe("/tmp/cache"); + }); +}); + +describe("getXDGDataHome", () => { + test("returns ~/.local/share by default", () => { + const result = getXDGDataHome({ homedir: "/home/user" }); + expect(result).toBe("/home/user/.local/share"); + }); + + test("respects XDG_DATA_HOME env var", () => { + const result = getXDGDataHome({ + homedir: "/home/user", + env: { XDG_DATA_HOME: "/custom/data" }, + }); + expect(result).toBe("/custom/data"); + }); +}); + +describe("getUserBinDir", () => { + test("returns ~/.local/bin", () => { + const result = getUserBinDir({ homedir: "/home/user" }); + expect(result).toBe("/home/user/.local/bin"); + }); + + test("uses custom homedir from options", () => { + const result = getUserBinDir({ homedir: "/opt/me" }); + expect(result).toBe("/opt/me/.local/bin"); + }); +}); + +describe("path construction", () => { + test("all paths end with correct subdirectory", () => { + const home = "/home/test"; + expect(getXDGStateHome({ homedir: home })).toMatch(/\.local\/state$/); + expect(getXDGCacheHome({ homedir: home })).toMatch(/\.cache$/); + expect(getXDGDataHome({ homedir: home })).toMatch(/\.local\/share$/); + expect(getUserBinDir({ homedir: home })).toMatch(/\.local\/bin$/); + }); + + test("respects HOME via homedir override", () => { + const result = getXDGStateHome({ homedir: "/Users/me" }); + expect(result).toBe("/Users/me/.local/state"); + }); +});