mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 21:05:51 +00:00
test: Phase 5 — 添加 12 个测试文件 (+209 tests, 1177 total)
新增覆盖: effort, tokenBudget, displayTags, taggedId, controlMessageCompat, MCP normalization/envExpansion, gitConfigParser, formatBriefTimestamp, hyperlink, windowsPaths, notebook Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
139
src/services/mcp/__tests__/envExpansion.test.ts
Normal file
139
src/services/mcp/__tests__/envExpansion.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { expandEnvVarsInString } from "../envExpansion";
|
||||
|
||||
describe("expandEnvVarsInString", () => {
|
||||
// Save and restore env vars touched by tests
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const trackedKeys = [
|
||||
"TEST_HOME",
|
||||
"MISSING",
|
||||
"TEST_A",
|
||||
"TEST_B",
|
||||
"TEST_EMPTY",
|
||||
"TEST_X",
|
||||
"VAR",
|
||||
"TEST_FOUND",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("expands a single env var that exists", () => {
|
||||
process.env.TEST_HOME = "/home/user";
|
||||
const result = expandEnvVarsInString("${TEST_HOME}");
|
||||
expect(result.expanded).toBe("/home/user");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns original placeholder and tracks missing var when not found", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING}");
|
||||
expect(result.expanded).toBe("${MISSING}");
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
|
||||
test("uses default value when var is missing and default is provided", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING:-fallback}");
|
||||
expect(result.expanded).toBe("fallback");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("expands multiple vars", () => {
|
||||
process.env.TEST_A = "hello";
|
||||
process.env.TEST_B = "world";
|
||||
const result = expandEnvVarsInString("${TEST_A}/${TEST_B}");
|
||||
expect(result.expanded).toBe("hello/world");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles mix of found and missing vars", () => {
|
||||
process.env.TEST_FOUND = "yes";
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}");
|
||||
expect(result.expanded).toBe("yes-${MISSING}");
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
|
||||
test("returns plain string unchanged with empty missingVars", () => {
|
||||
const result = expandEnvVarsInString("plain string");
|
||||
expect(result.expanded).toBe("plain string");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("expands empty env var value", () => {
|
||||
process.env.TEST_EMPTY = "";
|
||||
const result = expandEnvVarsInString("${TEST_EMPTY}");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("prefers env var value over default when var exists", () => {
|
||||
process.env.TEST_X = "real";
|
||||
const result = expandEnvVarsInString("${TEST_X:-default}");
|
||||
expect(result.expanded).toBe("real");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles default value containing colons", () => {
|
||||
// split(':-', 2) means only the first :- is the delimiter
|
||||
delete process.env.TEST_X;
|
||||
const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}");
|
||||
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
|
||||
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
|
||||
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
|
||||
expect(result.expanded).toBe("value");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles nested-looking syntax as literal (not supported)", () => {
|
||||
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
|
||||
// so varName would be "${VAR" which won't be found in env
|
||||
delete process.env.VAR;
|
||||
const result = expandEnvVarsInString("${${VAR}}");
|
||||
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
|
||||
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
|
||||
expect(result.missingVars).toEqual(["${VAR"]);
|
||||
expect(result.expanded).toBe("${${VAR}}");
|
||||
});
|
||||
|
||||
test("handles empty string input", () => {
|
||||
const result = expandEnvVarsInString("");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles var surrounded by text", () => {
|
||||
process.env.TEST_A = "middle";
|
||||
const result = expandEnvVarsInString("before-${TEST_A}-after");
|
||||
expect(result.expanded).toBe("before-middle-after");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles default value that is empty string", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString("${MISSING:-}");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not expand $VAR without braces", () => {
|
||||
process.env.TEST_A = "value";
|
||||
const result = expandEnvVarsInString("$TEST_A");
|
||||
expect(result.expanded).toBe("$TEST_A");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
});
|
||||
59
src/services/mcp/__tests__/normalization.test.ts
Normal file
59
src/services/mcp/__tests__/normalization.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeNameForMCP } from "../normalization";
|
||||
|
||||
describe("normalizeNameForMCP", () => {
|
||||
test("returns simple valid name unchanged", () => {
|
||||
expect(normalizeNameForMCP("my-server")).toBe("my-server");
|
||||
});
|
||||
|
||||
test("replaces dots with underscores", () => {
|
||||
expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name");
|
||||
});
|
||||
|
||||
test("replaces spaces with underscores", () => {
|
||||
expect(normalizeNameForMCP("my server")).toBe("my_server");
|
||||
});
|
||||
|
||||
test("replaces special characters with underscores", () => {
|
||||
expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_");
|
||||
});
|
||||
|
||||
test("returns already valid name unchanged", () => {
|
||||
expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(normalizeNameForMCP("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => {
|
||||
// "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server"
|
||||
// starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server"
|
||||
expect(normalizeNameForMCP("claude.ai My Server")).toBe(
|
||||
"claude_ai_My_Server"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles claude.ai prefix with consecutive invalid chars", () => {
|
||||
// "claude.ai ...test..." -> replace invalid -> "claude_ai____test___"
|
||||
// collapse consecutive _ -> "claude_ai_test_"
|
||||
// strip leading/trailing _ -> "claude_ai_test"
|
||||
expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test");
|
||||
});
|
||||
|
||||
test("non-claude.ai name preserves consecutive underscores", () => {
|
||||
// "a..b" -> "a__b", no claude.ai prefix so no collapse
|
||||
expect(normalizeNameForMCP("a..b")).toBe("a__b");
|
||||
});
|
||||
|
||||
test("non-claude.ai name preserves trailing underscores", () => {
|
||||
expect(normalizeNameForMCP("name!")).toBe("name_");
|
||||
});
|
||||
|
||||
test("handles claude.ai prefix that results in only underscores", () => {
|
||||
// "claude.ai ..." -> replace invalid -> "claude_ai____"
|
||||
// collapse -> "claude_ai_"
|
||||
// strip trailing -> "claude_ai"
|
||||
expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai");
|
||||
});
|
||||
});
|
||||
103
src/utils/__tests__/controlMessageCompat.test.ts
Normal file
103
src/utils/__tests__/controlMessageCompat.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeControlMessageKeys } from "../controlMessageCompat";
|
||||
|
||||
describe("normalizeControlMessageKeys", () => {
|
||||
// --- basic camelCase to snake_case ---
|
||||
test("converts requestId to request_id", () => {
|
||||
const obj = { requestId: "123" };
|
||||
const result = normalizeControlMessageKeys(obj);
|
||||
expect(result).toEqual({ request_id: "123" });
|
||||
expect((result as any).requestId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("leaves request_id unchanged", () => {
|
||||
const obj = { request_id: "123" };
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect(obj).toEqual({ request_id: "123" });
|
||||
});
|
||||
|
||||
// --- both present: snake_case wins ---
|
||||
test("keeps snake_case when both requestId and request_id exist", () => {
|
||||
const obj = { requestId: "camel", request_id: "snake" };
|
||||
const result = normalizeControlMessageKeys(obj) as any;
|
||||
expect(result.request_id).toBe("snake");
|
||||
// requestId is NOT deleted when request_id already exists
|
||||
// because the condition `!('request_id' in record)` prevents the branch
|
||||
expect(result.requestId).toBe("camel");
|
||||
});
|
||||
|
||||
// --- nested response ---
|
||||
test("normalizes nested response.requestId", () => {
|
||||
const obj = { response: { requestId: "456" } };
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect((obj as any).response.request_id).toBe("456");
|
||||
expect((obj as any).response.requestId).toBeUndefined();
|
||||
});
|
||||
|
||||
test("leaves nested response.request_id unchanged", () => {
|
||||
const obj = { response: { request_id: "789" } };
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect((obj as any).response.request_id).toBe("789");
|
||||
});
|
||||
|
||||
test("nested response: snake_case wins when both present", () => {
|
||||
const obj = {
|
||||
response: { requestId: "camel", request_id: "snake" },
|
||||
};
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect((obj as any).response.request_id).toBe("snake");
|
||||
expect((obj as any).response.requestId).toBe("camel");
|
||||
});
|
||||
|
||||
// --- non-object inputs ---
|
||||
test("returns null as-is", () => {
|
||||
expect(normalizeControlMessageKeys(null)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns undefined as-is", () => {
|
||||
expect(normalizeControlMessageKeys(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns string as-is", () => {
|
||||
expect(normalizeControlMessageKeys("hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns number as-is", () => {
|
||||
expect(normalizeControlMessageKeys(42)).toBe(42);
|
||||
});
|
||||
|
||||
// --- empty and edge cases ---
|
||||
test("empty object is unchanged", () => {
|
||||
const obj = {};
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect(obj).toEqual({});
|
||||
});
|
||||
|
||||
test("mutates the original object in place", () => {
|
||||
const obj = { requestId: "abc", other: "data" };
|
||||
const result = normalizeControlMessageKeys(obj);
|
||||
expect(result).toBe(obj); // same reference
|
||||
expect(obj).toEqual({ request_id: "abc", other: "data" });
|
||||
});
|
||||
|
||||
test("does not affect other keys on the object", () => {
|
||||
const obj = { requestId: "123", type: "control_request", payload: {} };
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect((obj as any).type).toBe("control_request");
|
||||
expect((obj as any).payload).toEqual({});
|
||||
expect((obj as any).request_id).toBe("123");
|
||||
});
|
||||
|
||||
test("handles response being null", () => {
|
||||
const obj = { response: null, requestId: "x" };
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect((obj as any).request_id).toBe("x");
|
||||
expect((obj as any).response).toBeNull();
|
||||
});
|
||||
|
||||
test("handles response being a non-object (string)", () => {
|
||||
const obj = { response: "not-an-object" };
|
||||
normalizeControlMessageKeys(obj);
|
||||
expect((obj as any).response).toBe("not-an-object");
|
||||
});
|
||||
});
|
||||
134
src/utils/__tests__/displayTags.test.ts
Normal file
134
src/utils/__tests__/displayTags.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
stripDisplayTags,
|
||||
stripDisplayTagsAllowEmpty,
|
||||
stripIdeContextTags,
|
||||
} from "../displayTags";
|
||||
|
||||
describe("stripDisplayTags", () => {
|
||||
test("strips a single system tag and returns remaining text", () => {
|
||||
expect(
|
||||
stripDisplayTags("<system-reminder>secret stuff</system-reminder>text")
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
test("strips multiple tags and preserves text between them", () => {
|
||||
const input =
|
||||
"<hook-output>data</hook-output>hello <task-info>info</task-info>world";
|
||||
expect(stripDisplayTags(input)).toBe("hello world");
|
||||
});
|
||||
|
||||
test("preserves uppercase JSX component names", () => {
|
||||
expect(stripDisplayTags("fix the <Button> layout")).toBe(
|
||||
"fix the <Button> layout"
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves angle brackets in prose (when x < y)", () => {
|
||||
expect(stripDisplayTags("when x < y")).toBe("when x < y");
|
||||
});
|
||||
|
||||
test("preserves DOCTYPE declarations", () => {
|
||||
expect(stripDisplayTags("<!DOCTYPE html>")).toBe("<!DOCTYPE html>");
|
||||
});
|
||||
|
||||
test("returns original text when stripping would result in empty", () => {
|
||||
const input = "<system-reminder>all tags</system-reminder>";
|
||||
expect(stripDisplayTags(input)).toBe(input);
|
||||
});
|
||||
|
||||
test("strips tags with attributes", () => {
|
||||
expect(
|
||||
stripDisplayTags('<context type="ide">data</context>hello')
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles multi-line tag content", () => {
|
||||
const input = "<info>\nline1\nline2\n</info>remaining";
|
||||
expect(stripDisplayTags(input)).toBe("remaining");
|
||||
});
|
||||
|
||||
test("returns trimmed result", () => {
|
||||
expect(
|
||||
stripDisplayTags(" <tag>content</tag> hello ")
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("handles empty string input", () => {
|
||||
// Empty string is falsy, so stripDisplayTags returns original
|
||||
expect(stripDisplayTags("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles whitespace-only input", () => {
|
||||
// After trim, result is empty string which is falsy, returns original
|
||||
expect(stripDisplayTags(" ")).toBe(" ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripDisplayTagsAllowEmpty", () => {
|
||||
test("returns empty string when all content is tags", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<system-reminder>stuff</system-reminder>")
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("strips tags and returns remaining text", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<tag>content</tag>hello")
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(stripDisplayTagsAllowEmpty("")).toBe("");
|
||||
});
|
||||
|
||||
test("returns empty string for whitespace-only content after strip", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<tag>content</tag> ")
|
||||
).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripIdeContextTags", () => {
|
||||
test("strips ide_opened_file tags", () => {
|
||||
expect(
|
||||
stripIdeContextTags(
|
||||
"<ide_opened_file>path/to/file.ts</ide_opened_file>hello"
|
||||
)
|
||||
).toBe("hello");
|
||||
});
|
||||
|
||||
test("strips ide_selection tags", () => {
|
||||
expect(
|
||||
stripIdeContextTags("<ide_selection>selected code</ide_selection>world")
|
||||
).toBe("world");
|
||||
});
|
||||
|
||||
test("strips ide tags with attributes", () => {
|
||||
expect(
|
||||
stripIdeContextTags(
|
||||
'<ide_opened_file path="foo.ts">content</ide_opened_file>text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
|
||||
test("preserves other lowercase tags", () => {
|
||||
expect(
|
||||
stripIdeContextTags("<system-reminder>data</system-reminder>hello")
|
||||
).toBe("<system-reminder>data</system-reminder>hello");
|
||||
});
|
||||
|
||||
test("preserves user-typed HTML like <code>", () => {
|
||||
expect(stripIdeContextTags("use <code>foo</code> here")).toBe(
|
||||
"use <code>foo</code> here"
|
||||
);
|
||||
});
|
||||
|
||||
test("strips only IDE tags while preserving other tags and text", () => {
|
||||
const input =
|
||||
"<ide_opened_file>f.ts</ide_opened_file><system-reminder>x</system-reminder>text";
|
||||
expect(stripIdeContextTags(input)).toBe(
|
||||
"<system-reminder>x</system-reminder>text"
|
||||
);
|
||||
});
|
||||
});
|
||||
255
src/utils/__tests__/effort.test.ts
Normal file
255
src/utils/__tests__/effort.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test";
|
||||
|
||||
// Mock heavy dependencies to avoid import chain issues
|
||||
mock.module("src/utils/thinking.js", () => ({
|
||||
isUltrathinkEnabled: () => false,
|
||||
}));
|
||||
mock.module("src/utils/settings/settings.js", () => ({
|
||||
getInitialSettings: () => ({}),
|
||||
}));
|
||||
mock.module("src/utils/auth.js", () => ({
|
||||
isProSubscriber: () => false,
|
||||
isMaxSubscriber: () => false,
|
||||
isTeamSubscriber: () => false,
|
||||
}));
|
||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => null,
|
||||
}));
|
||||
mock.module("src/utils/model/modelSupportOverrides.js", () => ({
|
||||
get3PModelCapabilityOverride: () => undefined,
|
||||
}));
|
||||
|
||||
const {
|
||||
isEffortLevel,
|
||||
parseEffortValue,
|
||||
isValidNumericEffort,
|
||||
convertEffortValueToLevel,
|
||||
getEffortLevelDescription,
|
||||
resolvePickerEffortPersistence,
|
||||
EFFORT_LEVELS,
|
||||
} = await import("src/utils/effort.js");
|
||||
|
||||
// ─── EFFORT_LEVELS constant ────────────────────────────────────────────
|
||||
|
||||
describe("EFFORT_LEVELS", () => {
|
||||
test("contains the four canonical levels", () => {
|
||||
expect(EFFORT_LEVELS).toEqual(["low", "medium", "high", "max"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isEffortLevel ─────────────────────────────────────────────────────
|
||||
|
||||
describe("isEffortLevel", () => {
|
||||
test("returns true for 'low'", () => {
|
||||
expect(isEffortLevel("low")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'medium'", () => {
|
||||
expect(isEffortLevel("medium")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'high'", () => {
|
||||
expect(isEffortLevel("high")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for 'max'", () => {
|
||||
expect(isEffortLevel("max")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for 'invalid'", () => {
|
||||
expect(isEffortLevel("invalid")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isEffortLevel("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseEffortValue ──────────────────────────────────────────────────
|
||||
|
||||
describe("parseEffortValue", () => {
|
||||
test("returns undefined for undefined", () => {
|
||||
expect(parseEffortValue(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for null", () => {
|
||||
expect(parseEffortValue(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseEffortValue("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns number for integer input", () => {
|
||||
expect(parseEffortValue(42)).toBe(42);
|
||||
});
|
||||
|
||||
test("returns string for valid effort level string", () => {
|
||||
expect(parseEffortValue("low")).toBe("low");
|
||||
expect(parseEffortValue("medium")).toBe("medium");
|
||||
expect(parseEffortValue("high")).toBe("high");
|
||||
expect(parseEffortValue("max")).toBe("max");
|
||||
});
|
||||
|
||||
test("parses numeric string to number", () => {
|
||||
expect(parseEffortValue("42")).toBe(42);
|
||||
});
|
||||
|
||||
test("returns undefined for invalid string", () => {
|
||||
expect(parseEffortValue("invalid")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("non-integer number falls through to string parsing (parseInt truncates)", () => {
|
||||
// 3.14 fails isValidNumericEffort, then String(3.14) -> "3.14" -> parseInt = 3
|
||||
expect(parseEffortValue(3.14)).toBe(3);
|
||||
});
|
||||
|
||||
test("handles case-insensitive effort level strings", () => {
|
||||
expect(parseEffortValue("LOW")).toBe("low");
|
||||
expect(parseEffortValue("HIGH")).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isValidNumericEffort ──────────────────────────────────────────────
|
||||
|
||||
describe("isValidNumericEffort", () => {
|
||||
test("returns true for integer", () => {
|
||||
expect(isValidNumericEffort(50)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for zero", () => {
|
||||
expect(isValidNumericEffort(0)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for negative integer", () => {
|
||||
expect(isValidNumericEffort(-1)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for float", () => {
|
||||
expect(isValidNumericEffort(3.14)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for NaN", () => {
|
||||
expect(isValidNumericEffort(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for Infinity", () => {
|
||||
expect(isValidNumericEffort(Infinity)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── convertEffortValueToLevel ─────────────────────────────────────────
|
||||
|
||||
describe("convertEffortValueToLevel", () => {
|
||||
test("returns valid effort level string as-is", () => {
|
||||
expect(convertEffortValueToLevel("low")).toBe("low");
|
||||
expect(convertEffortValueToLevel("medium")).toBe("medium");
|
||||
expect(convertEffortValueToLevel("high")).toBe("high");
|
||||
expect(convertEffortValueToLevel("max")).toBe("max");
|
||||
});
|
||||
|
||||
test("returns 'high' for unknown string", () => {
|
||||
expect(convertEffortValueToLevel("unknown" as any)).toBe("high");
|
||||
});
|
||||
|
||||
test("non-ant numeric value returns 'high'", () => {
|
||||
const saved = process.env.USER_TYPE;
|
||||
delete process.env.USER_TYPE;
|
||||
|
||||
expect(convertEffortValueToLevel(50)).toBe("high");
|
||||
expect(convertEffortValueToLevel(100)).toBe("high");
|
||||
|
||||
process.env.USER_TYPE = saved;
|
||||
});
|
||||
|
||||
describe("ant numeric mapping", () => {
|
||||
let savedUserType: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
savedUserType = process.env.USER_TYPE;
|
||||
process.env.USER_TYPE = "ant";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedUserType === undefined) {
|
||||
delete process.env.USER_TYPE;
|
||||
} else {
|
||||
process.env.USER_TYPE = savedUserType;
|
||||
}
|
||||
});
|
||||
|
||||
test("value <= 50 maps to 'low'", () => {
|
||||
expect(convertEffortValueToLevel(50)).toBe("low");
|
||||
expect(convertEffortValueToLevel(0)).toBe("low");
|
||||
expect(convertEffortValueToLevel(-10)).toBe("low");
|
||||
});
|
||||
|
||||
test("value 51-85 maps to 'medium'", () => {
|
||||
expect(convertEffortValueToLevel(51)).toBe("medium");
|
||||
expect(convertEffortValueToLevel(85)).toBe("medium");
|
||||
});
|
||||
|
||||
test("value 86-100 maps to 'high'", () => {
|
||||
expect(convertEffortValueToLevel(86)).toBe("high");
|
||||
expect(convertEffortValueToLevel(100)).toBe("high");
|
||||
});
|
||||
|
||||
test("value > 100 maps to 'max'", () => {
|
||||
expect(convertEffortValueToLevel(101)).toBe("max");
|
||||
expect(convertEffortValueToLevel(200)).toBe("max");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getEffortLevelDescription ─────────────────────────────────────────
|
||||
|
||||
describe("getEffortLevelDescription", () => {
|
||||
test("returns description for 'low'", () => {
|
||||
const desc = getEffortLevelDescription("low");
|
||||
expect(desc).toContain("Quick");
|
||||
});
|
||||
|
||||
test("returns description for 'medium'", () => {
|
||||
const desc = getEffortLevelDescription("medium");
|
||||
expect(desc).toContain("Balanced");
|
||||
});
|
||||
|
||||
test("returns description for 'high'", () => {
|
||||
const desc = getEffortLevelDescription("high");
|
||||
expect(desc).toContain("Comprehensive");
|
||||
});
|
||||
|
||||
test("returns description for 'max'", () => {
|
||||
const desc = getEffortLevelDescription("max");
|
||||
expect(desc).toContain("Maximum");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolvePickerEffortPersistence ────────────────────────────────────
|
||||
|
||||
describe("resolvePickerEffortPersistence", () => {
|
||||
test("returns undefined when picked matches model default and no prior persistence", () => {
|
||||
const result = resolvePickerEffortPersistence("high", "high", undefined, false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns picked when it differs from model default", () => {
|
||||
const result = resolvePickerEffortPersistence("low", "high", undefined, false);
|
||||
expect(result).toBe("low");
|
||||
});
|
||||
|
||||
test("returns picked when priorPersisted is set (even if same as default)", () => {
|
||||
const result = resolvePickerEffortPersistence("high", "high", "high", false);
|
||||
expect(result).toBe("high");
|
||||
});
|
||||
|
||||
test("returns picked when toggledInPicker is true (even if same as default)", () => {
|
||||
const result = resolvePickerEffortPersistence("high", "high", undefined, true);
|
||||
expect(result).toBe("high");
|
||||
});
|
||||
|
||||
test("returns undefined picked value when no explicit and matches default", () => {
|
||||
const result = resolvePickerEffortPersistence(undefined, "high" as any, undefined, false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
76
src/utils/__tests__/formatBriefTimestamp.test.ts
Normal file
76
src/utils/__tests__/formatBriefTimestamp.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatBriefTimestamp } from "../formatBriefTimestamp";
|
||||
|
||||
describe("formatBriefTimestamp", () => {
|
||||
// Fixed "now" for deterministic tests: 2026-04-02T14:00:00Z (Thursday)
|
||||
const now = new Date("2026-04-02T14:00:00Z");
|
||||
|
||||
test("same day timestamp returns time only (contains colon)", () => {
|
||||
const result = formatBriefTimestamp("2026-04-02T10:30:00Z", now);
|
||||
expect(result).toContain(":");
|
||||
// Should NOT contain a weekday name since it's the same day
|
||||
expect(result).not.toMatch(
|
||||
/Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday/
|
||||
);
|
||||
});
|
||||
|
||||
test("yesterday returns weekday and time", () => {
|
||||
// 2026-04-01 is Wednesday
|
||||
const result = formatBriefTimestamp("2026-04-01T16:15:00Z", now);
|
||||
expect(result).toContain("Wednesday");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
|
||||
test("3 days ago returns weekday and time", () => {
|
||||
// 2026-03-30 is Monday
|
||||
const result = formatBriefTimestamp("2026-03-30T09:00:00Z", now);
|
||||
expect(result).toContain("Monday");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
|
||||
test("6 days ago returns weekday and time (still within 6-day window)", () => {
|
||||
// 2026-03-27 is Friday
|
||||
const result = formatBriefTimestamp("2026-03-27T12:00:00Z", now);
|
||||
expect(result).toContain("Friday");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
|
||||
test("7+ days ago returns weekday, month, day, and time", () => {
|
||||
// 2026-03-20 is Friday, 13 days ago
|
||||
const result = formatBriefTimestamp("2026-03-20T14:30:00Z", now);
|
||||
expect(result).toContain("Friday");
|
||||
expect(result).toContain(":");
|
||||
// Should contain month abbreviation (Mar)
|
||||
expect(result).toMatch(/Mar/);
|
||||
});
|
||||
|
||||
test("much older date returns full format with month", () => {
|
||||
const result = formatBriefTimestamp("2025-12-25T08:00:00Z", now);
|
||||
expect(result).toContain(":");
|
||||
expect(result).toMatch(/Dec/);
|
||||
});
|
||||
|
||||
test("invalid ISO string returns empty string", () => {
|
||||
expect(formatBriefTimestamp("not-a-date", now)).toBe("");
|
||||
});
|
||||
|
||||
test("empty string returns empty string", () => {
|
||||
expect(formatBriefTimestamp("", now)).toBe("");
|
||||
});
|
||||
|
||||
test("same day early morning returns time format", () => {
|
||||
const result = formatBriefTimestamp("2026-04-02T01:05:00Z", now);
|
||||
expect(result).toContain(":");
|
||||
// Should be time-only format
|
||||
expect(result.length).toBeLessThan(20);
|
||||
});
|
||||
|
||||
test("uses current time as default when now is not provided", () => {
|
||||
// Just verify it returns a non-empty string for a recent timestamp
|
||||
const recent = new Date();
|
||||
recent.setMinutes(recent.getMinutes() - 5);
|
||||
const result = formatBriefTimestamp(recent.toISOString());
|
||||
expect(result).not.toBe("");
|
||||
expect(result).toContain(":");
|
||||
});
|
||||
});
|
||||
99
src/utils/__tests__/hyperlink.test.ts
Normal file
99
src/utils/__tests__/hyperlink.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createHyperlink, OSC8_START, OSC8_END } from "../hyperlink";
|
||||
|
||||
// ─── OSC8 constants ────────────────────────────────────────────────────
|
||||
|
||||
describe("OSC8 constants", () => {
|
||||
test("OSC8_START is the correct escape sequence", () => {
|
||||
expect(OSC8_START).toBe("\x1b]8;;");
|
||||
});
|
||||
|
||||
test("OSC8_END is the BEL character", () => {
|
||||
expect(OSC8_END).toBe("\x07");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createHyperlink ───────────────────────────────────────────────────
|
||||
|
||||
describe("createHyperlink", () => {
|
||||
test("supported + no content: wraps URL in OSC 8 with URL as display text", () => {
|
||||
const url = "https://example.com";
|
||||
const result = createHyperlink(url, undefined, { supportsHyperlinks: true });
|
||||
|
||||
expect(result).toContain(OSC8_START);
|
||||
expect(result).toContain(OSC8_END);
|
||||
// Structure: OSC8_START + url + OSC8_END + coloredText + OSC8_START + OSC8_END
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
|
||||
test("supported + content: shows content as link text", () => {
|
||||
const url = "https://example.com";
|
||||
const content = "click here";
|
||||
const result = createHyperlink(url, content, { supportsHyperlinks: true });
|
||||
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toContain("click here");
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
|
||||
test("not supported: returns plain URL regardless of content", () => {
|
||||
const url = "https://example.com";
|
||||
const result = createHyperlink(url, "some content", {
|
||||
supportsHyperlinks: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
test("not supported + no content: returns plain URL", () => {
|
||||
const url = "https://example.com/path?q=1";
|
||||
const result = createHyperlink(url, undefined, {
|
||||
supportsHyperlinks: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
test("URL with special characters works when supported", () => {
|
||||
const url = "https://example.com/path?a=1&b=2#section";
|
||||
const result = createHyperlink(url, undefined, { supportsHyperlinks: true });
|
||||
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
|
||||
test("URL with special characters works when not supported", () => {
|
||||
const url = "https://example.com/path?a=1&b=2#section";
|
||||
const result = createHyperlink(url, undefined, {
|
||||
supportsHyperlinks: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(url);
|
||||
});
|
||||
|
||||
test("supported link text contains the display content", () => {
|
||||
const result = createHyperlink("https://example.com", "text", {
|
||||
supportsHyperlinks: true,
|
||||
});
|
||||
|
||||
// The colored text portion is between the two OSC8 sequences
|
||||
const inner = result.slice(
|
||||
`${OSC8_START}https://example.com${OSC8_END}`.length,
|
||||
result.length - `${OSC8_START}${OSC8_END}`.length
|
||||
);
|
||||
// chalk.blue may or may not emit ANSI depending on environment,
|
||||
// but the display text must always be present
|
||||
expect(inner).toContain("text");
|
||||
});
|
||||
|
||||
test("empty content string is treated as display text when supported", () => {
|
||||
const url = "https://example.com";
|
||||
const result = createHyperlink(url, "", { supportsHyperlinks: true });
|
||||
|
||||
// Empty string is falsy, so displayText falls back to url
|
||||
// Actually: content ?? url — "" is not null/undefined, so "" is used
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`);
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`);
|
||||
});
|
||||
});
|
||||
162
src/utils/__tests__/notebook.test.ts
Normal file
162
src/utils/__tests__/notebook.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCellId, mapNotebookCellsToToolResult } from "../notebook";
|
||||
|
||||
// ─── parseCellId ───────────────────────────────────────────────────────
|
||||
|
||||
describe("parseCellId", () => {
|
||||
test("parses cell-0 to 0", () => {
|
||||
expect(parseCellId("cell-0")).toBe(0);
|
||||
});
|
||||
|
||||
test("parses cell-5 to 5", () => {
|
||||
expect(parseCellId("cell-5")).toBe(5);
|
||||
});
|
||||
|
||||
test("parses cell-100 to 100", () => {
|
||||
expect(parseCellId("cell-100")).toBe(100);
|
||||
});
|
||||
|
||||
test("returns undefined for cell- (no number)", () => {
|
||||
expect(parseCellId("cell-")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for cell-abc (non-numeric)", () => {
|
||||
expect(parseCellId("cell-abc")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for other-format", () => {
|
||||
expect(parseCellId("other-format")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseCellId("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for prefix-only match like cell-0-extra", () => {
|
||||
// regex is /^cell-(\d+)$/ so trailing text should fail
|
||||
expect(parseCellId("cell-0-extra")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mapNotebookCellsToToolResult ──────────────────────────────────────
|
||||
|
||||
describe("mapNotebookCellsToToolResult", () => {
|
||||
test("returns tool result with correct tool_use_id", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: 'print("hello")',
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-123");
|
||||
expect(result.tool_use_id).toBe("tool-123");
|
||||
expect(result.type).toBe("tool_result");
|
||||
});
|
||||
|
||||
test("content array contains text blocks for cell content", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: 'x = 1',
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-1");
|
||||
expect(result.content).toBeInstanceOf(Array);
|
||||
expect(result.content!.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstBlock = result.content![0] as { type: string; text: string };
|
||||
expect(firstBlock.type).toBe("text");
|
||||
expect(firstBlock.text).toContain("cell-0");
|
||||
expect(firstBlock.text).toContain("x = 1");
|
||||
});
|
||||
|
||||
test("merges adjacent text blocks from multiple cells", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "a = 1",
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
},
|
||||
{
|
||||
cellType: "code",
|
||||
source: "b = 2",
|
||||
cell_id: "cell-1",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-2");
|
||||
// Two adjacent text blocks should be merged into one
|
||||
const textBlocks = result.content!.filter(
|
||||
(b: any) => b.type === "text"
|
||||
);
|
||||
expect(textBlocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves image blocks without merging", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "plot()",
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
outputs: [
|
||||
{
|
||||
output_type: "display_data",
|
||||
text: "",
|
||||
image: {
|
||||
image_data: "iVBORw0KGgo=",
|
||||
media_type: "image/png" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cellType: "code",
|
||||
source: "print(1)",
|
||||
cell_id: "cell-1",
|
||||
language: "python",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-3");
|
||||
const types = result.content!.map((b: any) => b.type);
|
||||
expect(types).toContain("image");
|
||||
});
|
||||
|
||||
test("markdown cell includes cell_type metadata", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "markdown",
|
||||
source: "# Title",
|
||||
cell_id: "cell-0",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-4");
|
||||
const textBlock = result.content![0] as { type: string; text: string };
|
||||
expect(textBlock.text).toContain("<cell_type>markdown</cell_type>");
|
||||
});
|
||||
|
||||
test("non-python code cell includes language metadata", () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "val x = 1",
|
||||
cell_id: "cell-0",
|
||||
language: "scala",
|
||||
},
|
||||
];
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-5");
|
||||
const textBlock = result.content![0] as { type: string; text: string };
|
||||
expect(textBlock.text).toContain("<language>scala</language>");
|
||||
});
|
||||
});
|
||||
104
src/utils/__tests__/taggedId.test.ts
Normal file
104
src/utils/__tests__/taggedId.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { toTaggedId } from "../taggedId";
|
||||
|
||||
const BASE_58_CHARS =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
|
||||
describe("toTaggedId", () => {
|
||||
test("zero UUID produces all base58 '1's (first char)", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
);
|
||||
// base58 of 0 is all '1's (the first base58 character)
|
||||
expect(result).toBe("user_01" + "1".repeat(22));
|
||||
});
|
||||
|
||||
test("format is tag_01 + 22 base58 chars", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
expect(result).toMatch(
|
||||
new RegExp(`^user_01[${BASE_58_CHARS.replace(/[-]/g, "\\-")}]{22}$`)
|
||||
);
|
||||
});
|
||||
|
||||
test("output starts with the provided tag", () => {
|
||||
const result = toTaggedId("org", "550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result.startsWith("org_01")).toBe(true);
|
||||
});
|
||||
|
||||
test("UUID with hyphens equals UUID without hyphens", () => {
|
||||
const withHyphens = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
const withoutHyphens = toTaggedId(
|
||||
"user",
|
||||
"550e8400e29b41d4a716446655440000"
|
||||
);
|
||||
expect(withHyphens).toBe(withoutHyphens);
|
||||
});
|
||||
|
||||
test("different tags produce different prefixes", () => {
|
||||
const uuid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const userResult = toTaggedId("user", uuid);
|
||||
const orgResult = toTaggedId("org", uuid);
|
||||
const msgResult = toTaggedId("msg", uuid);
|
||||
// They share the same base58 suffix but different prefixes
|
||||
expect(userResult.slice(userResult.indexOf("_01") + 3)).toBe(
|
||||
orgResult.slice(orgResult.indexOf("_01") + 3)
|
||||
);
|
||||
expect(userResult).not.toBe(orgResult);
|
||||
expect(orgResult).not.toBe(msgResult);
|
||||
});
|
||||
|
||||
test("different UUIDs produce different encoded parts", () => {
|
||||
const result1 = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
const result2 = toTaggedId(
|
||||
"user",
|
||||
"661f9500-f3ac-52e5-b827-557766550111"
|
||||
);
|
||||
expect(result1).not.toBe(result2);
|
||||
});
|
||||
|
||||
test("encoded part is always exactly 22 characters", () => {
|
||||
const uuids = [
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
];
|
||||
for (const uuid of uuids) {
|
||||
const result = toTaggedId("test", uuid);
|
||||
const encoded = result.slice("test_01".length);
|
||||
expect(encoded).toHaveLength(22);
|
||||
}
|
||||
});
|
||||
|
||||
test("throws on invalid UUID (too short)", () => {
|
||||
expect(() => toTaggedId("user", "abcdef")).toThrow("Invalid UUID hex length");
|
||||
});
|
||||
|
||||
test("throws on invalid UUID (too long)", () => {
|
||||
expect(() =>
|
||||
toTaggedId("user", "550e8400e29b41d4a716446655440000ff")
|
||||
).toThrow("Invalid UUID hex length");
|
||||
});
|
||||
|
||||
test("max UUID (all f's) produces valid base58 output", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff"
|
||||
);
|
||||
expect(result.startsWith("user_01")).toBe(true);
|
||||
const encoded = result.slice("user_01".length);
|
||||
for (const ch of encoded) {
|
||||
expect(BASE_58_CHARS).toContain(ch);
|
||||
}
|
||||
});
|
||||
});
|
||||
150
src/utils/__tests__/tokenBudget.test.ts
Normal file
150
src/utils/__tests__/tokenBudget.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseTokenBudget,
|
||||
findTokenBudgetPositions,
|
||||
getBudgetContinuationMessage,
|
||||
} from "../tokenBudget";
|
||||
|
||||
describe("parseTokenBudget", () => {
|
||||
// --- shorthand at start ---
|
||||
test("parses +500k at start", () => {
|
||||
expect(parseTokenBudget("+500k")).toBe(500_000);
|
||||
});
|
||||
|
||||
test("parses +2.5M at start", () => {
|
||||
expect(parseTokenBudget("+2.5M")).toBe(2_500_000);
|
||||
});
|
||||
|
||||
test("parses +1b at start", () => {
|
||||
expect(parseTokenBudget("+1b")).toBe(1_000_000_000);
|
||||
});
|
||||
|
||||
test("parses shorthand with leading whitespace", () => {
|
||||
expect(parseTokenBudget(" +500k")).toBe(500_000);
|
||||
});
|
||||
|
||||
// --- shorthand at end ---
|
||||
test("parses +1.5m at end of sentence", () => {
|
||||
expect(parseTokenBudget("do this +1.5m")).toBe(1_500_000);
|
||||
});
|
||||
|
||||
test("parses shorthand at end with trailing period", () => {
|
||||
expect(parseTokenBudget("please continue +100k.")).toBe(100_000);
|
||||
});
|
||||
|
||||
test("parses shorthand at end with trailing whitespace", () => {
|
||||
expect(parseTokenBudget("keep going +250k ")).toBe(250_000);
|
||||
});
|
||||
|
||||
// --- verbose ---
|
||||
test("parses 'use 2M tokens'", () => {
|
||||
expect(parseTokenBudget("use 2M tokens")).toBe(2_000_000);
|
||||
});
|
||||
|
||||
test("parses 'spend 500k tokens'", () => {
|
||||
expect(parseTokenBudget("spend 500k tokens")).toBe(500_000);
|
||||
});
|
||||
|
||||
test("parses verbose with singular 'token'", () => {
|
||||
expect(parseTokenBudget("use 1k token")).toBe(1_000);
|
||||
});
|
||||
|
||||
test("parses verbose embedded in sentence", () => {
|
||||
expect(parseTokenBudget("please use 3.5m tokens for this task")).toBe(
|
||||
3_500_000
|
||||
);
|
||||
});
|
||||
|
||||
// --- no match (returns null) ---
|
||||
test("returns null for plain text", () => {
|
||||
expect(parseTokenBudget("hello world")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for bare number without +", () => {
|
||||
expect(parseTokenBudget("500k")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for number without suffix", () => {
|
||||
expect(parseTokenBudget("+500")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseTokenBudget("")).toBeNull();
|
||||
});
|
||||
|
||||
// --- case insensitivity ---
|
||||
test("is case insensitive for suffix", () => {
|
||||
expect(parseTokenBudget("+500K")).toBe(500_000);
|
||||
expect(parseTokenBudget("+2m")).toBe(2_000_000);
|
||||
expect(parseTokenBudget("+1B")).toBe(1_000_000_000);
|
||||
});
|
||||
|
||||
// --- priority: start shorthand wins over end/verbose ---
|
||||
test("start shorthand takes priority over verbose in same text", () => {
|
||||
expect(parseTokenBudget("+100k use 2M tokens")).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findTokenBudgetPositions", () => {
|
||||
test("returns single position for +500k at start", () => {
|
||||
const positions = findTokenBudgetPositions("+500k");
|
||||
expect(positions).toHaveLength(1);
|
||||
expect(positions[0]!.start).toBe(0);
|
||||
expect(positions[0]!.end).toBe(5);
|
||||
});
|
||||
|
||||
test("returns position for shorthand at end", () => {
|
||||
const text = "do this +100k";
|
||||
const positions = findTokenBudgetPositions(text);
|
||||
expect(positions).toHaveLength(1);
|
||||
expect(positions[0]!.start).toBe(8);
|
||||
expect(text.slice(positions[0]!.start, positions[0]!.end)).toBe("+100k");
|
||||
});
|
||||
|
||||
test("returns position for verbose match", () => {
|
||||
const text = "please use 2M tokens here";
|
||||
const positions = findTokenBudgetPositions(text);
|
||||
expect(positions).toHaveLength(1);
|
||||
expect(text.slice(positions[0]!.start, positions[0]!.end)).toBe(
|
||||
"use 2M tokens"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns multiple positions for combined shorthand + verbose", () => {
|
||||
const text = "use 2M tokens and then +500k";
|
||||
const positions = findTokenBudgetPositions(text);
|
||||
expect(positions.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("returns empty array for no match", () => {
|
||||
expect(findTokenBudgetPositions("hello world")).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not double-count when +500k matches both start and end", () => {
|
||||
const positions = findTokenBudgetPositions("+500k");
|
||||
expect(positions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBudgetContinuationMessage", () => {
|
||||
test("formats a continuation message with correct values", () => {
|
||||
const msg = getBudgetContinuationMessage(50, 250_000, 500_000);
|
||||
expect(msg).toContain("50%");
|
||||
expect(msg).toContain("250,000");
|
||||
expect(msg).toContain("500,000");
|
||||
expect(msg).toContain("Keep working");
|
||||
expect(msg).toContain("do not summarize");
|
||||
});
|
||||
|
||||
test("formats zero values", () => {
|
||||
const msg = getBudgetContinuationMessage(0, 0, 100_000);
|
||||
expect(msg).toContain("0%");
|
||||
expect(msg).toContain("0 / 100,000");
|
||||
});
|
||||
|
||||
test("formats large numbers with commas", () => {
|
||||
const msg = getBudgetContinuationMessage(75, 7_500_000, 10_000_000);
|
||||
expect(msg).toContain("7,500,000");
|
||||
expect(msg).toContain("10,000,000");
|
||||
});
|
||||
});
|
||||
116
src/utils/__tests__/windowsPaths.test.ts
Normal file
116
src/utils/__tests__/windowsPaths.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
windowsPathToPosixPath,
|
||||
posixPathToWindowsPath,
|
||||
} from "../windowsPaths";
|
||||
|
||||
// ─── windowsPathToPosixPath ────────────────────────────────────────────
|
||||
|
||||
describe("windowsPathToPosixPath", () => {
|
||||
test("converts drive letter path to posix", () => {
|
||||
expect(windowsPathToPosixPath("C:\\Users\\foo")).toBe("/c/Users/foo");
|
||||
});
|
||||
|
||||
test("lowercases the drive letter", () => {
|
||||
expect(windowsPathToPosixPath("D:\\Work\\project")).toBe(
|
||||
"/d/Work/project"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles lowercase drive letter input", () => {
|
||||
expect(windowsPathToPosixPath("e:\\data")).toBe("/e/data");
|
||||
});
|
||||
|
||||
test("converts UNC path", () => {
|
||||
expect(windowsPathToPosixPath("\\\\server\\share\\dir")).toBe(
|
||||
"//server/share/dir"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts root drive path", () => {
|
||||
expect(windowsPathToPosixPath("D:\\")).toBe("/d/");
|
||||
});
|
||||
|
||||
test("converts relative path by flipping backslashes", () => {
|
||||
expect(windowsPathToPosixPath("src\\main.ts")).toBe("src/main.ts");
|
||||
});
|
||||
|
||||
test("handles forward slashes in windows drive path", () => {
|
||||
// The regex matches both / and \\ after drive letter
|
||||
expect(windowsPathToPosixPath("C:/Users/foo")).toBe("/c/Users/foo");
|
||||
});
|
||||
|
||||
test("already-posix relative path passes through", () => {
|
||||
expect(windowsPathToPosixPath("src/main.ts")).toBe("src/main.ts");
|
||||
});
|
||||
|
||||
test("handles deeply nested path", () => {
|
||||
expect(
|
||||
windowsPathToPosixPath("C:\\Users\\me\\Documents\\project\\src\\index.ts")
|
||||
).toBe("/c/Users/me/Documents/project/src/index.ts");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── posixPathToWindowsPath ────────────────────────────────────────────
|
||||
|
||||
describe("posixPathToWindowsPath", () => {
|
||||
test("converts MSYS2/Git Bash drive path to windows", () => {
|
||||
expect(posixPathToWindowsPath("/c/Users/foo")).toBe("C:\\Users\\foo");
|
||||
});
|
||||
|
||||
test("uppercases the drive letter", () => {
|
||||
expect(posixPathToWindowsPath("/d/Work/project")).toBe(
|
||||
"D:\\Work\\project"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts cygdrive path", () => {
|
||||
expect(posixPathToWindowsPath("/cygdrive/d/work")).toBe("D:\\work");
|
||||
});
|
||||
|
||||
test("converts cygdrive root path", () => {
|
||||
expect(posixPathToWindowsPath("/cygdrive/c/")).toBe("C:\\");
|
||||
});
|
||||
|
||||
test("converts UNC posix path to windows UNC", () => {
|
||||
expect(posixPathToWindowsPath("//server/share/dir")).toBe(
|
||||
"\\\\server\\share\\dir"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts root drive posix path", () => {
|
||||
expect(posixPathToWindowsPath("/d/")).toBe("D:\\");
|
||||
});
|
||||
|
||||
test("converts bare drive mount (no trailing slash)", () => {
|
||||
// /d matches the regex ^\/([A-Za-z])(\/|$) where $2 is empty
|
||||
expect(posixPathToWindowsPath("/d")).toBe("D:\\");
|
||||
});
|
||||
|
||||
test("converts relative path by flipping forward slashes", () => {
|
||||
expect(posixPathToWindowsPath("src/main.ts")).toBe("src\\main.ts");
|
||||
});
|
||||
|
||||
test("handles already-windows relative path", () => {
|
||||
// No leading / or //, just flips / to backslash
|
||||
expect(posixPathToWindowsPath("foo\\bar")).toBe("foo\\bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── round-trip conversions ────────────────────────────────────────────
|
||||
|
||||
describe("round-trip conversions", () => {
|
||||
test("drive path round-trips windows -> posix -> windows", () => {
|
||||
const original = "C:\\Users\\foo\\bar";
|
||||
const posix = windowsPathToPosixPath(original);
|
||||
const back = posixPathToWindowsPath(posix);
|
||||
expect(back).toBe(original);
|
||||
});
|
||||
|
||||
test("drive path round-trips posix -> windows -> posix", () => {
|
||||
const original = "/c/Users/foo/bar";
|
||||
const win = posixPathToWindowsPath(original);
|
||||
const back = windowsPathToPosixPath(win);
|
||||
expect(back).toBe(original);
|
||||
});
|
||||
});
|
||||
138
src/utils/git/__tests__/gitConfigParser.test.ts
Normal file
138
src/utils/git/__tests__/gitConfigParser.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseConfigString } from "../gitConfigParser";
|
||||
|
||||
describe("parseConfigString", () => {
|
||||
test("parses simple remote url", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://github.com/user/repo.git';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://github.com/user/repo.git"
|
||||
);
|
||||
});
|
||||
|
||||
test("section matching is case-insensitive", () => {
|
||||
const config = '[REMOTE "origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("subsection matching is case-sensitive", () => {
|
||||
const config = '[remote "Origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBeNull();
|
||||
});
|
||||
|
||||
test("subsection matching is case-sensitive (positive)", () => {
|
||||
const config = '[remote "Origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "Origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("key matching is case-insensitive", () => {
|
||||
const config = '[remote "origin"]\n\tURL = https://example.com';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("parses quoted value with spaces", () => {
|
||||
const config = '[user]\n\tname = "John Doe"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe("John Doe");
|
||||
});
|
||||
|
||||
test("handles escape sequence \\n inside quotes", () => {
|
||||
const config = '[user]\n\tname = "line1\\nline2"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe(
|
||||
"line1\nline2"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles escape sequence \\t inside quotes", () => {
|
||||
const config = '[user]\n\tname = "col1\\tcol2"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe(
|
||||
"col1\tcol2"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles escape sequence \\\\ inside quotes", () => {
|
||||
const config = '[user]\n\tname = "back\\\\slash"';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe("back\\slash");
|
||||
});
|
||||
|
||||
test("handles escape sequence \\\" inside quotes", () => {
|
||||
const config = '[user]\n\tname = "say \\"hello\\""';
|
||||
expect(parseConfigString(config, "user", null, "name")).toBe('say "hello"');
|
||||
});
|
||||
|
||||
test("strips inline comment with #", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com # comment';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("strips inline comment with ;", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com ; comment';
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("finds value in correct section among multiple sections", () => {
|
||||
const config = [
|
||||
'[remote "origin"]',
|
||||
"\turl = https://origin.example.com",
|
||||
'[remote "upstream"]',
|
||||
"\turl = https://upstream.example.com",
|
||||
].join("\n");
|
||||
expect(parseConfigString(config, "remote", "upstream", "url")).toBe(
|
||||
"https://upstream.example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for missing key", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com';
|
||||
expect(
|
||||
parseConfigString(config, "remote", "origin", "pushurl")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for missing section", () => {
|
||||
const config = '[remote "origin"]\n\turl = https://example.com';
|
||||
expect(parseConfigString(config, "branch", "main", "merge")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for boolean key (no = sign)", () => {
|
||||
const config = "[core]\n\tbare";
|
||||
expect(parseConfigString(config, "core", null, "bare")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty config string", () => {
|
||||
expect(parseConfigString("", "remote", "origin", "url")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles section without subsection", () => {
|
||||
const config = "[core]\n\trepositoryformatversion = 0";
|
||||
expect(
|
||||
parseConfigString(config, "core", null, "repositoryformatversion")
|
||||
).toBe("0");
|
||||
});
|
||||
|
||||
test("does not match section without subsection when subsection is requested", () => {
|
||||
const config = "[core]\n\tbare = false";
|
||||
// Looking for [core "something"] but config has [core]
|
||||
expect(parseConfigString(config, "core", "something", "bare")).toBeNull();
|
||||
});
|
||||
|
||||
test("skips comment-only lines", () => {
|
||||
const config = [
|
||||
"# This is a comment",
|
||||
"; This is also a comment",
|
||||
'[remote "origin"]',
|
||||
"\turl = https://example.com",
|
||||
].join("\n");
|
||||
expect(parseConfigString(config, "remote", "origin", "url")).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user