test: 添加一大堆测试文件
This commit is contained in:
parent
6f5623b26c
commit
ce29527a67
@ -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<string, unknown> = {}) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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]')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
147
src/commands/plugin/__tests__/parseArgs.test.ts
Normal file
147
src/commands/plugin/__tests__/parseArgs.test.ts
Normal file
@ -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" });
|
||||
});
|
||||
});
|
||||
121
src/services/compact/__tests__/grouping.test.ts
Normal file
121
src/services/compact/__tests__/grouping.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { groupMessagesByApiRound } from "../grouping";
|
||||
|
||||
function makeMsg(type: "user" | "assistant" | "system", id: string): any {
|
||||
return {
|
||||
type,
|
||||
message: { id, content: `${type}-${id}` },
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupMessagesByApiRound", () => {
|
||||
// Boundary fires when: assistant msg with NEW id AND current group has items
|
||||
test("splits before first assistant if user messages precede it", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("assistant", "a1")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
// user msgs form group 1, assistant starts group 2
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toHaveLength(1);
|
||||
expect(groups[1]).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("single assistant message forms one group", () => {
|
||||
const messages = [makeMsg("assistant", "a1")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("splits at new assistant message ID", () => {
|
||||
const messages = [
|
||||
makeMsg("user", "u1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a2"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("keeps same-ID assistant messages in same group (streaming chunks)", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(groupMessagesByApiRound([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles all user messages (no assistant)", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("three API rounds produce correct groups", () => {
|
||||
const messages = [
|
||||
makeMsg("user", "u1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("user", "u2"),
|
||||
makeMsg("assistant", "a2"),
|
||||
makeMsg("user", "u3"),
|
||||
makeMsg("assistant", "a3"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
// [u1], [a1, u2], [a2, u3], [a3] = 4 groups
|
||||
expect(groups).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("consecutive user messages stay in same group", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
|
||||
expect(groupMessagesByApiRound(messages)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("does not produce empty groups", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a2"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
for (const group of groups) {
|
||||
expect(group.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("handles single message", () => {
|
||||
expect(groupMessagesByApiRound([makeMsg("user", "u1")])).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves message order within groups", () => {
|
||||
const messages = [makeMsg("assistant", "a1"), makeMsg("user", "u2")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups[0][0].message.id).toBe("a1");
|
||||
expect(groups[0][1].message.id).toBe("u2");
|
||||
});
|
||||
|
||||
test("handles system messages", () => {
|
||||
const messages = [
|
||||
makeMsg("system", "s1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
];
|
||||
// system msg is non-assistant, goes to current. Then assistant a1 is new ID
|
||||
// and current has items, so split.
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("tool_result after assistant stays in same round", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("user", "tool_result_1"),
|
||||
makeMsg("assistant", "a1"), // same ID = no new boundary
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
77
src/services/compact/__tests__/prompt.test.ts
Normal file
77
src/services/compact/__tests__/prompt.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("bun:bundle", () => ({ feature: () => false }));
|
||||
|
||||
const { formatCompactSummary } = await import("../prompt");
|
||||
|
||||
describe("formatCompactSummary", () => {
|
||||
test("strips <analysis>...</analysis> block", () => {
|
||||
const input = "<analysis>my thought process</analysis>\n<summary>the summary</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("<analysis>");
|
||||
expect(result).not.toContain("my thought process");
|
||||
});
|
||||
|
||||
test("replaces <summary>...</summary> with 'Summary:\\n' prefix", () => {
|
||||
const input = "<summary>key points here</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("Summary:");
|
||||
expect(result).toContain("key points here");
|
||||
expect(result).not.toContain("<summary>");
|
||||
});
|
||||
|
||||
test("handles analysis + summary together", () => {
|
||||
const input = "<analysis>thinking</analysis><summary>result</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("thinking");
|
||||
expect(result).toContain("result");
|
||||
});
|
||||
|
||||
test("handles summary without analysis", () => {
|
||||
const input = "<summary>just the summary</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("just the summary");
|
||||
});
|
||||
|
||||
test("handles analysis without summary", () => {
|
||||
const input = "<analysis>just analysis</analysis>and some text";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("just analysis");
|
||||
expect(result).toContain("and some text");
|
||||
});
|
||||
|
||||
test("collapses multiple newlines to double", () => {
|
||||
const input = "hello\n\n\n\nworld";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
test("trims leading/trailing whitespace", () => {
|
||||
const input = " \n hello \n ";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(formatCompactSummary("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles plain text without tags", () => {
|
||||
const input = "just plain text";
|
||||
expect(formatCompactSummary(input)).toBe("just plain text");
|
||||
});
|
||||
|
||||
test("handles multiline analysis content", () => {
|
||||
const input = "<analysis>\nline1\nline2\nline3\n</analysis><summary>ok</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("line1");
|
||||
expect(result).toContain("ok");
|
||||
});
|
||||
|
||||
test("preserves content between analysis and summary", () => {
|
||||
const input = "<analysis>thoughts</analysis>middle text<summary>final</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("middle text");
|
||||
expect(result).toContain("final");
|
||||
});
|
||||
});
|
||||
69
src/services/mcp/__tests__/channelNotification.test.ts
Normal file
69
src/services/mcp/__tests__/channelNotification.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// findChannelEntry extracted from ../channelNotification.ts (line 161)
|
||||
// Copied to avoid heavy import chain
|
||||
|
||||
type ChannelEntry = {
|
||||
kind: "server" | "plugin"
|
||||
name: string
|
||||
}
|
||||
|
||||
function findChannelEntry(
|
||||
serverName: string,
|
||||
channels: readonly ChannelEntry[],
|
||||
): ChannelEntry | undefined {
|
||||
const parts = serverName.split(":")
|
||||
return channels.find(c =>
|
||||
c.kind === "server"
|
||||
? serverName === c.name
|
||||
: parts[0] === "plugin" && parts[1] === c.name,
|
||||
)
|
||||
}
|
||||
|
||||
describe("findChannelEntry", () => {
|
||||
test("finds server entry by exact name match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "my-server" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeDefined()
|
||||
expect(findChannelEntry("my-server", channels)!.name).toBe("my-server")
|
||||
})
|
||||
|
||||
test("finds plugin entry by matching second segment", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns undefined for no match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "other" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles empty channels array", () => {
|
||||
expect(findChannelEntry("my-server", [])).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles server name without colon", () => {
|
||||
const channels = [{ kind: "server" as const, name: "simple" }]
|
||||
expect(findChannelEntry("simple", channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles 'plugin:name' format correctly", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers exact match (server kind) over partial match", () => {
|
||||
const channels = [
|
||||
{ kind: "server" as const, name: "plugin:slack" },
|
||||
{ kind: "plugin" as const, name: "slack" },
|
||||
]
|
||||
const result = findChannelEntry("plugin:slack", channels)
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.kind).toBe("server")
|
||||
})
|
||||
|
||||
test("plugin kind does not match bare name", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("slack", channels)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
165
src/services/mcp/__tests__/channelPermissions.test.ts
Normal file
165
src/services/mcp/__tests__/channelPermissions.test.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
mock.module("src/utils/slowOperations.js", () => ({
|
||||
jsonStringify: (v: unknown) => JSON.stringify(v),
|
||||
}));
|
||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
}));
|
||||
|
||||
const {
|
||||
shortRequestId,
|
||||
truncateForPreview,
|
||||
PERMISSION_REPLY_RE,
|
||||
createChannelPermissionCallbacks,
|
||||
} = await import("../channelPermissions");
|
||||
|
||||
describe("shortRequestId", () => {
|
||||
test("returns 5-char string from tool use ID", () => {
|
||||
const result = shortRequestId("toolu_abc123");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("is deterministic (same input = same output)", () => {
|
||||
const a = shortRequestId("toolu_abc123");
|
||||
const b = shortRequestId("toolu_abc123");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test("different inputs produce different outputs", () => {
|
||||
const a = shortRequestId("toolu_aaa");
|
||||
const b = shortRequestId("toolu_bbb");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
test("result contains only valid letters (no 'l')", () => {
|
||||
const validChars = new Set("abcdefghijkmnopqrstuvwxyz");
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = shortRequestId(`toolu_${i}`);
|
||||
for (const ch of result) {
|
||||
expect(validChars.has(ch)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = shortRequestId("");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateForPreview", () => {
|
||||
test("returns JSON string for object input", () => {
|
||||
const result = truncateForPreview({ key: "value" });
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
|
||||
test("truncates to <=200 chars with ellipsis when input is long", () => {
|
||||
const longObj = { data: "x".repeat(300) };
|
||||
const result = truncateForPreview(longObj);
|
||||
expect(result.length).toBeLessThanOrEqual(203); // 200 + '…'
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns short input unchanged", () => {
|
||||
const result = truncateForPreview({ a: 1 });
|
||||
expect(result).toBe('{"a":1}');
|
||||
expect(result.endsWith("…")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles string input", () => {
|
||||
const result = truncateForPreview("hello");
|
||||
expect(result).toBe('"hello"');
|
||||
});
|
||||
|
||||
test("handles null input", () => {
|
||||
const result = truncateForPreview(null);
|
||||
expect(result).toBe("null");
|
||||
});
|
||||
|
||||
test("handles undefined input", () => {
|
||||
const result = truncateForPreview(undefined);
|
||||
// JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)'
|
||||
expect(result).toBe("(unserializable)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PERMISSION_REPLY_RE", () => {
|
||||
test("matches 'y abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'yes abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'n abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches 'no abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true);
|
||||
expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match without ID", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes")).toBe(false);
|
||||
});
|
||||
|
||||
test("captures the ID from reply", () => {
|
||||
const match = "y abcde".match(PERMISSION_REPLY_RE);
|
||||
expect(match?.[2]).toBe("abcde");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChannelPermissionCallbacks", () => {
|
||||
test("resolve returns false for unknown request ID", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
expect(cb.resolve("unknown-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
|
||||
test("onResponse + resolve triggers handler", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("test-id", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("test-id", "allow", "test-server")).toBe(true);
|
||||
expect(received).toEqual({
|
||||
behavior: "allow",
|
||||
fromServer: "test-server",
|
||||
});
|
||||
});
|
||||
|
||||
test("onResponse unsubscribe prevents resolve", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let called = false;
|
||||
const unsub = cb.onResponse("test-id", () => {
|
||||
called = true;
|
||||
});
|
||||
unsub();
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test("duplicate resolve returns false (already consumed)", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
cb.onResponse("test-id", () => {});
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(true);
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
|
||||
test("is case-insensitive for request IDs", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("ABC", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("abc", "deny", "server")).toBe(true);
|
||||
expect(received?.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
65
src/services/mcp/__tests__/filterUtils.test.ts
Normal file
65
src/services/mcp/__tests__/filterUtils.test.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
// parseHeaders is a pure function from ../utils.ts (line 325)
|
||||
// Copied here to avoid triggering the heavy import chain of utils.ts
|
||||
function parseHeaders(headerArray: string[]): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
for (const header of headerArray) {
|
||||
const colonIndex = header.indexOf(":")
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid header format: "${header}". Expected format: "Header-Name: value"`,
|
||||
)
|
||||
}
|
||||
const key = header.substring(0, colonIndex).trim()
|
||||
const value = header.substring(colonIndex + 1).trim()
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
`Invalid header: "${header}". Header name cannot be empty.`,
|
||||
)
|
||||
}
|
||||
headers[key] = value
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
describe("parseHeaders", () => {
|
||||
test("parses 'Key: Value' format", () => {
|
||||
expect(parseHeaders(["Content-Type: application/json"])).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses multiple headers", () => {
|
||||
expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({
|
||||
Key1: "val1",
|
||||
Key2: "val2",
|
||||
});
|
||||
});
|
||||
|
||||
test("trims whitespace around key and value", () => {
|
||||
expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" });
|
||||
});
|
||||
|
||||
test("throws on missing colon", () => {
|
||||
expect(() => parseHeaders(["no colon here"])).toThrow();
|
||||
});
|
||||
|
||||
test("throws on empty key", () => {
|
||||
expect(() => parseHeaders([": value"])).toThrow();
|
||||
});
|
||||
|
||||
test("handles value with colons (like URLs)", () => {
|
||||
expect(parseHeaders(["url: http://example.com:8080"])).toEqual({
|
||||
url: "http://example.com:8080",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
expect(parseHeaders([])).toEqual({});
|
||||
});
|
||||
|
||||
test("handles duplicate keys (last wins)", () => {
|
||||
expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" });
|
||||
});
|
||||
});
|
||||
45
src/services/mcp/__tests__/officialRegistry.test.ts
Normal file
45
src/services/mcp/__tests__/officialRegistry.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { mock, describe, expect, test, afterEach } from "bun:test";
|
||||
|
||||
mock.module("axios", () => ({
|
||||
default: { get: async () => ({ data: { servers: [] } }) },
|
||||
}));
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
logForDebugging: () => {},
|
||||
}));
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
errorMessage: (e: any) => String(e),
|
||||
}));
|
||||
|
||||
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
|
||||
"../officialRegistry"
|
||||
);
|
||||
|
||||
describe("isOfficialMcpUrl", () => {
|
||||
afterEach(() => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
});
|
||||
|
||||
test("returns false when registry not loaded (initial state)", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for non-registered URL", () => {
|
||||
expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isOfficialMcpUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetOfficialMcpUrlsForTesting", () => {
|
||||
test("can be called without error", () => {
|
||||
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow();
|
||||
});
|
||||
|
||||
test("clears state so subsequent lookups return false", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://anything.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
112
src/state/__tests__/store.test.ts
Normal file
112
src/state/__tests__/store.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
136
src/tools/AgentTool/__tests__/agentDisplay.test.ts
Normal file
136
src/tools/AgentTool/__tests__/agentDisplay.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
314
src/tools/AgentTool/__tests__/agentToolUtils.test.ts
Normal file
314
src/tools/AgentTool/__tests__/agentToolUtils.test.ts
Normal file
@ -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<string>();
|
||||
|
||||
// Utility: create a mock module factory that returns an object with arbitrary named exports
|
||||
function stubModule(exportNames: string[]) {
|
||||
const obj: Record<string, any> = {};
|
||||
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<string, string> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
146
src/tools/MCPTool/__tests__/classifyForCollapse.test.ts
Normal file
146
src/tools/MCPTool/__tests__/classifyForCollapse.test.ts
Normal file
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
136
src/utils/__tests__/collapseHookSummaries.test.ts
Normal file
136
src/utils/__tests__/collapseHookSummaries.test.ts
Normal file
@ -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([]);
|
||||
});
|
||||
});
|
||||
94
src/utils/__tests__/collapseTeammateShutdowns.test.ts
Normal file
94
src/utils/__tests__/collapseTeammateShutdowns.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
70
src/utils/__tests__/configConstants.test.ts
Normal file
70
src/utils/__tests__/configConstants.test.ts
Normal file
@ -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"]);
|
||||
});
|
||||
});
|
||||
108
src/utils/__tests__/detectRepository.test.ts
Normal file
108
src/utils/__tests__/detectRepository.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
110
src/utils/__tests__/directMemberMessage.test.ts
Normal file
110
src/utils/__tests__/directMemberMessage.test.ts
Normal file
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
122
src/utils/__tests__/fingerprint.test.ts
Normal file
122
src/utils/__tests__/fingerprint.test.ts
Normal file
@ -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}$/);
|
||||
});
|
||||
});
|
||||
114
src/utils/__tests__/generators.test.ts
Normal file
114
src/utils/__tests__/generators.test.ts
Normal file
@ -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<number, void> {
|
||||
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<number, string> {
|
||||
yield 1;
|
||||
return "done";
|
||||
}
|
||||
const result = await returnValue(gen());
|
||||
expect(result).toBe("done");
|
||||
});
|
||||
|
||||
test("returns undefined for void return", async () => {
|
||||
async function* gen(): AsyncGenerator<number, void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
163
src/utils/__tests__/horizontalScroll.test.ts
Normal file
163
src/utils/__tests__/horizontalScroll.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
54
src/utils/__tests__/lazySchema.test.ts
Normal file
54
src/utils/__tests__/lazySchema.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
58
src/utils/__tests__/markdown.test.ts
Normal file
58
src/utils/__tests__/markdown.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
80
src/utils/__tests__/modelCost.test.ts
Normal file
80
src/utils/__tests__/modelCost.test.ts
Normal file
@ -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")
|
||||
})
|
||||
})
|
||||
110
src/utils/__tests__/privacyLevel.test.ts
Normal file
110
src/utils/__tests__/privacyLevel.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
48
src/utils/__tests__/semanticBoolean.test.ts
Normal file
48
src/utils/__tests__/semanticBoolean.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
52
src/utils/__tests__/semanticNumber.test.ts
Normal file
52
src/utils/__tests__/semanticNumber.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
99
src/utils/__tests__/sequential.test.ts
Normal file
99
src/utils/__tests__/sequential.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
131
src/utils/__tests__/textHighlighting.test.ts
Normal file
131
src/utils/__tests__/textHighlighting.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
76
src/utils/__tests__/userPromptKeywords.test.ts
Normal file
76
src/utils/__tests__/userPromptKeywords.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
58
src/utils/__tests__/withResolvers.test.ts
Normal file
58
src/utils/__tests__/withResolvers.test.ts
Normal file
@ -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<string>();
|
||||
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<string>();
|
||||
resolve("hello");
|
||||
const result = await promise;
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("promise rejects when reject is called", async () => {
|
||||
const { promise, reject } = withResolvers<string>();
|
||||
reject(new Error("fail"));
|
||||
await expect(promise).rejects.toThrow("fail");
|
||||
});
|
||||
|
||||
test("resolve passes value through", async () => {
|
||||
const { promise, resolve } = withResolvers<number>();
|
||||
resolve(42);
|
||||
expect(await promise).toBe(42);
|
||||
});
|
||||
|
||||
test("reject passes error through", async () => {
|
||||
const { promise, reject } = withResolvers<void>();
|
||||
const err = new Error("custom error");
|
||||
reject(err);
|
||||
await expect(promise).rejects.toBe(err);
|
||||
});
|
||||
|
||||
test("promise is instanceof Promise", () => {
|
||||
const { promise } = withResolvers<void>();
|
||||
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<number>();
|
||||
setTimeout(() => resolve(99), 10);
|
||||
const result = await promise;
|
||||
expect(result).toBe(99);
|
||||
});
|
||||
});
|
||||
84
src/utils/__tests__/xdg.test.ts
Normal file
84
src/utils/__tests__/xdg.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user