mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -1,102 +1,102 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CircularBuffer } from "../CircularBuffer";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { CircularBuffer } from '../CircularBuffer'
|
||||
|
||||
describe("CircularBuffer", () => {
|
||||
test("starts empty", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
expect(buf.length()).toBe(0);
|
||||
expect(buf.toArray()).toEqual([]);
|
||||
});
|
||||
describe('CircularBuffer', () => {
|
||||
test('starts empty', () => {
|
||||
const buf = new CircularBuffer<number>(5)
|
||||
expect(buf.length()).toBe(0)
|
||||
expect(buf.toArray()).toEqual([])
|
||||
})
|
||||
|
||||
test("adds items up to capacity", () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.add(1);
|
||||
buf.add(2);
|
||||
buf.add(3);
|
||||
expect(buf.length()).toBe(3);
|
||||
expect(buf.toArray()).toEqual([1, 2, 3]);
|
||||
});
|
||||
test('adds items up to capacity', () => {
|
||||
const buf = new CircularBuffer<number>(3)
|
||||
buf.add(1)
|
||||
buf.add(2)
|
||||
buf.add(3)
|
||||
expect(buf.length()).toBe(3)
|
||||
expect(buf.toArray()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("evicts oldest when full", () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.add(1);
|
||||
buf.add(2);
|
||||
buf.add(3);
|
||||
buf.add(4);
|
||||
expect(buf.length()).toBe(3);
|
||||
expect(buf.toArray()).toEqual([2, 3, 4]);
|
||||
});
|
||||
test('evicts oldest when full', () => {
|
||||
const buf = new CircularBuffer<number>(3)
|
||||
buf.add(1)
|
||||
buf.add(2)
|
||||
buf.add(3)
|
||||
buf.add(4)
|
||||
expect(buf.length()).toBe(3)
|
||||
expect(buf.toArray()).toEqual([2, 3, 4])
|
||||
})
|
||||
|
||||
test("evicts multiple oldest items", () => {
|
||||
const buf = new CircularBuffer<number>(2);
|
||||
buf.add(1);
|
||||
buf.add(2);
|
||||
buf.add(3);
|
||||
buf.add(4);
|
||||
buf.add(5);
|
||||
expect(buf.toArray()).toEqual([4, 5]);
|
||||
});
|
||||
test('evicts multiple oldest items', () => {
|
||||
const buf = new CircularBuffer<number>(2)
|
||||
buf.add(1)
|
||||
buf.add(2)
|
||||
buf.add(3)
|
||||
buf.add(4)
|
||||
buf.add(5)
|
||||
expect(buf.toArray()).toEqual([4, 5])
|
||||
})
|
||||
|
||||
test("addAll adds multiple items", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
buf.addAll([1, 2, 3]);
|
||||
expect(buf.toArray()).toEqual([1, 2, 3]);
|
||||
});
|
||||
test('addAll adds multiple items', () => {
|
||||
const buf = new CircularBuffer<number>(5)
|
||||
buf.addAll([1, 2, 3])
|
||||
expect(buf.toArray()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("addAll with overflow", () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.addAll([1, 2, 3, 4, 5]);
|
||||
expect(buf.toArray()).toEqual([3, 4, 5]);
|
||||
});
|
||||
test('addAll with overflow', () => {
|
||||
const buf = new CircularBuffer<number>(3)
|
||||
buf.addAll([1, 2, 3, 4, 5])
|
||||
expect(buf.toArray()).toEqual([3, 4, 5])
|
||||
})
|
||||
|
||||
test("getRecent returns last N items", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
buf.addAll([1, 2, 3, 4, 5]);
|
||||
expect(buf.getRecent(3)).toEqual([3, 4, 5]);
|
||||
});
|
||||
test('getRecent returns last N items', () => {
|
||||
const buf = new CircularBuffer<number>(5)
|
||||
buf.addAll([1, 2, 3, 4, 5])
|
||||
expect(buf.getRecent(3)).toEqual([3, 4, 5])
|
||||
})
|
||||
|
||||
test("getRecent returns fewer when not enough items", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
buf.add(1);
|
||||
buf.add(2);
|
||||
expect(buf.getRecent(5)).toEqual([1, 2]);
|
||||
});
|
||||
test('getRecent returns fewer when not enough items', () => {
|
||||
const buf = new CircularBuffer<number>(5)
|
||||
buf.add(1)
|
||||
buf.add(2)
|
||||
expect(buf.getRecent(5)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("getRecent works after wraparound", () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.addAll([1, 2, 3, 4, 5]);
|
||||
expect(buf.getRecent(2)).toEqual([4, 5]);
|
||||
});
|
||||
test('getRecent works after wraparound', () => {
|
||||
const buf = new CircularBuffer<number>(3)
|
||||
buf.addAll([1, 2, 3, 4, 5])
|
||||
expect(buf.getRecent(2)).toEqual([4, 5])
|
||||
})
|
||||
|
||||
test("clear resets buffer", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
buf.addAll([1, 2, 3]);
|
||||
buf.clear();
|
||||
expect(buf.length()).toBe(0);
|
||||
expect(buf.toArray()).toEqual([]);
|
||||
});
|
||||
test('clear resets buffer', () => {
|
||||
const buf = new CircularBuffer<number>(5)
|
||||
buf.addAll([1, 2, 3])
|
||||
buf.clear()
|
||||
expect(buf.length()).toBe(0)
|
||||
expect(buf.toArray()).toEqual([])
|
||||
})
|
||||
|
||||
test("works with string type", () => {
|
||||
const buf = new CircularBuffer<string>(2);
|
||||
buf.add("a");
|
||||
buf.add("b");
|
||||
buf.add("c");
|
||||
expect(buf.toArray()).toEqual(["b", "c"]);
|
||||
});
|
||||
test('works with string type', () => {
|
||||
const buf = new CircularBuffer<string>(2)
|
||||
buf.add('a')
|
||||
buf.add('b')
|
||||
buf.add('c')
|
||||
expect(buf.toArray()).toEqual(['b', 'c'])
|
||||
})
|
||||
|
||||
test("capacity=1 keeps only the most recent item", () => {
|
||||
const buf = new CircularBuffer<number>(1);
|
||||
buf.add(10);
|
||||
expect(buf.toArray()).toEqual([10]);
|
||||
buf.add(20);
|
||||
expect(buf.toArray()).toEqual([20]);
|
||||
buf.add(30);
|
||||
expect(buf.toArray()).toEqual([30]);
|
||||
expect(buf.getRecent(1)).toEqual([30]);
|
||||
});
|
||||
test('capacity=1 keeps only the most recent item', () => {
|
||||
const buf = new CircularBuffer<number>(1)
|
||||
buf.add(10)
|
||||
expect(buf.toArray()).toEqual([10])
|
||||
buf.add(20)
|
||||
expect(buf.toArray()).toEqual([20])
|
||||
buf.add(30)
|
||||
expect(buf.toArray()).toEqual([30])
|
||||
expect(buf.getRecent(1)).toEqual([30])
|
||||
})
|
||||
|
||||
test("getRecent on empty buffer returns empty array", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
expect(buf.getRecent(3)).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('getRecent on empty buffer returns empty array', () => {
|
||||
const buf = new CircularBuffer<number>(5)
|
||||
expect(buf.getRecent(3)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
createAbortController,
|
||||
createChildAbortController,
|
||||
} from "../abortController";
|
||||
} from '../abortController'
|
||||
|
||||
describe("createAbortController", () => {
|
||||
test("returns an AbortController that is not aborted", () => {
|
||||
const controller = createAbortController();
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
describe('createAbortController', () => {
|
||||
test('returns an AbortController that is not aborted', () => {
|
||||
const controller = createAbortController()
|
||||
expect(controller.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
test("aborting the controller sets signal.aborted", () => {
|
||||
const controller = createAbortController();
|
||||
controller.abort();
|
||||
expect(controller.signal.aborted).toBe(true);
|
||||
});
|
||||
test('aborting the controller sets signal.aborted', () => {
|
||||
const controller = createAbortController()
|
||||
controller.abort()
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("abort reason is propagated", () => {
|
||||
const controller = createAbortController();
|
||||
controller.abort("custom reason");
|
||||
expect(controller.signal.reason).toBe("custom reason");
|
||||
});
|
||||
test('abort reason is propagated', () => {
|
||||
const controller = createAbortController()
|
||||
controller.abort('custom reason')
|
||||
expect(controller.signal.reason).toBe('custom reason')
|
||||
})
|
||||
|
||||
test("accepts custom maxListeners without error", () => {
|
||||
const controller = createAbortController(100);
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
test('accepts custom maxListeners without error', () => {
|
||||
const controller = createAbortController(100)
|
||||
expect(controller.signal.aborted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createChildAbortController", () => {
|
||||
test("child is not aborted initially", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
describe('createChildAbortController', () => {
|
||||
test('child is not aborted initially', () => {
|
||||
const parent = createAbortController()
|
||||
const child = createChildAbortController(parent)
|
||||
expect(child.signal.aborted).toBe(false)
|
||||
expect(parent.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
test("parent abort propagates to child", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
parent.abort("parent reason");
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(child.signal.reason).toBe("parent reason");
|
||||
});
|
||||
test('parent abort propagates to child', () => {
|
||||
const parent = createAbortController()
|
||||
const child = createChildAbortController(parent)
|
||||
parent.abort('parent reason')
|
||||
expect(child.signal.aborted).toBe(true)
|
||||
expect(child.signal.reason).toBe('parent reason')
|
||||
})
|
||||
|
||||
test("child abort does NOT propagate to parent", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
child.abort("child reason");
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
test('child abort does NOT propagate to parent', () => {
|
||||
const parent = createAbortController()
|
||||
const child = createChildAbortController(parent)
|
||||
child.abort('child reason')
|
||||
expect(child.signal.aborted).toBe(true)
|
||||
expect(parent.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
test("already-aborted parent immediately aborts child", () => {
|
||||
const parent = createAbortController();
|
||||
parent.abort("pre-abort");
|
||||
const child = createChildAbortController(parent);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
expect(child.signal.reason).toBe("pre-abort");
|
||||
});
|
||||
test('already-aborted parent immediately aborts child', () => {
|
||||
const parent = createAbortController()
|
||||
parent.abort('pre-abort')
|
||||
const child = createChildAbortController(parent)
|
||||
expect(child.signal.aborted).toBe(true)
|
||||
expect(child.signal.reason).toBe('pre-abort')
|
||||
})
|
||||
|
||||
test("multiple children are independent", () => {
|
||||
const parent = createAbortController();
|
||||
const child1 = createChildAbortController(parent);
|
||||
const child2 = createChildAbortController(parent);
|
||||
child1.abort("child1");
|
||||
expect(child1.signal.aborted).toBe(true);
|
||||
expect(child2.signal.aborted).toBe(false);
|
||||
test('multiple children are independent', () => {
|
||||
const parent = createAbortController()
|
||||
const child1 = createChildAbortController(parent)
|
||||
const child2 = createChildAbortController(parent)
|
||||
child1.abort('child1')
|
||||
expect(child1.signal.aborted).toBe(true)
|
||||
expect(child2.signal.aborted).toBe(false)
|
||||
// Aborting child1 did not affect child2 or parent
|
||||
expect(parent.signal.aborted).toBe(false);
|
||||
});
|
||||
expect(parent.signal.aborted).toBe(false)
|
||||
})
|
||||
|
||||
test("parent abort propagates to all children", () => {
|
||||
const parent = createAbortController();
|
||||
const child1 = createChildAbortController(parent);
|
||||
const child2 = createChildAbortController(parent);
|
||||
parent.abort("all go down");
|
||||
expect(child1.signal.aborted).toBe(true);
|
||||
expect(child2.signal.aborted).toBe(true);
|
||||
});
|
||||
test('parent abort propagates to all children', () => {
|
||||
const parent = createAbortController()
|
||||
const child1 = createChildAbortController(parent)
|
||||
const child2 = createChildAbortController(parent)
|
||||
parent.abort('all go down')
|
||||
expect(child1.signal.aborted).toBe(true)
|
||||
expect(child2.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("grandchild abort propagation", () => {
|
||||
const grandparent = createAbortController();
|
||||
const parent = createChildAbortController(grandparent);
|
||||
const child = createChildAbortController(parent);
|
||||
grandparent.abort("chain");
|
||||
expect(parent.signal.aborted).toBe(true);
|
||||
expect(child.signal.aborted).toBe(true);
|
||||
});
|
||||
test('grandchild abort propagation', () => {
|
||||
const grandparent = createAbortController()
|
||||
const parent = createChildAbortController(grandparent)
|
||||
const child = createChildAbortController(parent)
|
||||
grandparent.abort('chain')
|
||||
expect(parent.signal.aborted).toBe(true)
|
||||
expect(child.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
test("child abort then parent abort — child stays aborted with original reason", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent);
|
||||
child.abort("child first");
|
||||
parent.abort("parent later");
|
||||
expect(child.signal.reason).toBe("child first");
|
||||
expect(parent.signal.reason).toBe("parent later");
|
||||
});
|
||||
test('child abort then parent abort — child stays aborted with original reason', () => {
|
||||
const parent = createAbortController()
|
||||
const child = createChildAbortController(parent)
|
||||
child.abort('child first')
|
||||
parent.abort('parent later')
|
||||
expect(child.signal.reason).toBe('child first')
|
||||
expect(parent.signal.reason).toBe('parent later')
|
||||
})
|
||||
|
||||
test("accepts custom maxListeners for child", () => {
|
||||
const parent = createAbortController();
|
||||
const child = createChildAbortController(parent, 200);
|
||||
expect(child.signal.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
test('accepts custom maxListeners for child', () => {
|
||||
const parent = createAbortController()
|
||||
const child = createChildAbortController(parent, 200)
|
||||
expect(child.signal.aborted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,145 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
parseArguments,
|
||||
parseArgumentNames,
|
||||
generateProgressiveArgumentHint,
|
||||
substituteArguments,
|
||||
} from "../argumentSubstitution";
|
||||
} from '../argumentSubstitution'
|
||||
|
||||
// ─── parseArguments ─────────────────────────────────────────────────────
|
||||
|
||||
describe("parseArguments", () => {
|
||||
test("splits simple arguments", () => {
|
||||
expect(parseArguments("foo bar baz")).toEqual(["foo", "bar", "baz"]);
|
||||
});
|
||||
describe('parseArguments', () => {
|
||||
test('splits simple arguments', () => {
|
||||
expect(parseArguments('foo bar baz')).toEqual(['foo', 'bar', 'baz'])
|
||||
})
|
||||
|
||||
test("handles quoted strings", () => {
|
||||
test('handles quoted strings', () => {
|
||||
expect(parseArguments('foo "hello world" baz')).toEqual([
|
||||
"foo",
|
||||
"hello world",
|
||||
"baz",
|
||||
]);
|
||||
});
|
||||
'foo',
|
||||
'hello world',
|
||||
'baz',
|
||||
])
|
||||
})
|
||||
|
||||
test("handles single-quoted strings", () => {
|
||||
test('handles single-quoted strings', () => {
|
||||
expect(parseArguments("foo 'hello world' baz")).toEqual([
|
||||
"foo",
|
||||
"hello world",
|
||||
"baz",
|
||||
]);
|
||||
});
|
||||
'foo',
|
||||
'hello world',
|
||||
'baz',
|
||||
])
|
||||
})
|
||||
|
||||
test("handles escaped quotes inside quoted strings", () => {
|
||||
test('handles escaped quotes inside quoted strings', () => {
|
||||
expect(parseArguments('foo "hello \\"world\\"" baz')).toEqual([
|
||||
"foo",
|
||||
'foo',
|
||||
'hello "world"',
|
||||
"baz",
|
||||
]);
|
||||
});
|
||||
'baz',
|
||||
])
|
||||
})
|
||||
|
||||
test("returns empty for empty string", () => {
|
||||
expect(parseArguments("")).toEqual([]);
|
||||
});
|
||||
test('returns empty for empty string', () => {
|
||||
expect(parseArguments('')).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty for whitespace only", () => {
|
||||
expect(parseArguments(" ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('returns empty for whitespace only', () => {
|
||||
expect(parseArguments(' ')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseArgumentNames ─────────────────────────────────────────────────
|
||||
|
||||
describe("parseArgumentNames", () => {
|
||||
test("parses space-separated string", () => {
|
||||
expect(parseArgumentNames("foo bar baz")).toEqual(["foo", "bar", "baz"]);
|
||||
});
|
||||
describe('parseArgumentNames', () => {
|
||||
test('parses space-separated string', () => {
|
||||
expect(parseArgumentNames('foo bar baz')).toEqual(['foo', 'bar', 'baz'])
|
||||
})
|
||||
|
||||
test("accepts array input", () => {
|
||||
expect(parseArgumentNames(["foo", "bar"])).toEqual(["foo", "bar"]);
|
||||
});
|
||||
test('accepts array input', () => {
|
||||
expect(parseArgumentNames(['foo', 'bar'])).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
test("filters out numeric-only names", () => {
|
||||
expect(parseArgumentNames("foo 123 bar")).toEqual(["foo", "bar"]);
|
||||
});
|
||||
test('filters out numeric-only names', () => {
|
||||
expect(parseArgumentNames('foo 123 bar')).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
test("filters out empty strings", () => {
|
||||
expect(parseArgumentNames(["foo", "", "bar"])).toEqual(["foo", "bar"]);
|
||||
});
|
||||
test('filters out empty strings', () => {
|
||||
expect(parseArgumentNames(['foo', '', 'bar'])).toEqual(['foo', 'bar'])
|
||||
})
|
||||
|
||||
test("returns empty for undefined", () => {
|
||||
expect(parseArgumentNames(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('returns empty for undefined', () => {
|
||||
expect(parseArgumentNames(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── generateProgressiveArgumentHint ────────────────────────────────────
|
||||
|
||||
describe("generateProgressiveArgumentHint", () => {
|
||||
test("shows remaining arguments", () => {
|
||||
expect(generateProgressiveArgumentHint(["a", "b", "c"], ["x"])).toBe(
|
||||
"[b] [c]"
|
||||
);
|
||||
});
|
||||
describe('generateProgressiveArgumentHint', () => {
|
||||
test('shows remaining arguments', () => {
|
||||
expect(generateProgressiveArgumentHint(['a', 'b', 'c'], ['x'])).toBe(
|
||||
'[b] [c]',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns undefined when all filled", () => {
|
||||
expect(
|
||||
generateProgressiveArgumentHint(["a"], ["x"])
|
||||
).toBeUndefined();
|
||||
});
|
||||
test('returns undefined when all filled', () => {
|
||||
expect(generateProgressiveArgumentHint(['a'], ['x'])).toBeUndefined()
|
||||
})
|
||||
|
||||
test("shows all when none typed", () => {
|
||||
expect(generateProgressiveArgumentHint(["a", "b"], [])).toBe("[a] [b]");
|
||||
});
|
||||
});
|
||||
test('shows all when none typed', () => {
|
||||
expect(generateProgressiveArgumentHint(['a', 'b'], [])).toBe('[a] [b]')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── substituteArguments ────────────────────────────────────────────────
|
||||
|
||||
describe("substituteArguments", () => {
|
||||
test("replaces $ARGUMENTS with full args", () => {
|
||||
expect(substituteArguments("run $ARGUMENTS", "foo bar")).toBe(
|
||||
"run foo bar"
|
||||
);
|
||||
});
|
||||
describe('substituteArguments', () => {
|
||||
test('replaces $ARGUMENTS with full args', () => {
|
||||
expect(substituteArguments('run $ARGUMENTS', 'foo bar')).toBe('run foo bar')
|
||||
})
|
||||
|
||||
test("replaces indexed $ARGUMENTS[0]", () => {
|
||||
expect(substituteArguments("run $ARGUMENTS[0]", "foo bar")).toBe("run foo");
|
||||
});
|
||||
test('replaces indexed $ARGUMENTS[0]', () => {
|
||||
expect(substituteArguments('run $ARGUMENTS[0]', 'foo bar')).toBe('run foo')
|
||||
})
|
||||
|
||||
test("replaces shorthand $0, $1", () => {
|
||||
expect(substituteArguments("$0 and $1", "hello world")).toBe(
|
||||
"hello and world"
|
||||
);
|
||||
});
|
||||
test('replaces shorthand $0, $1', () => {
|
||||
expect(substituteArguments('$0 and $1', 'hello world')).toBe(
|
||||
'hello and world',
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces out-of-range index with empty string", () => {
|
||||
expect(substituteArguments("$5", "hello world")).toBe("");
|
||||
});
|
||||
test('replaces out-of-range index with empty string', () => {
|
||||
expect(substituteArguments('$5', 'hello world')).toBe('')
|
||||
})
|
||||
|
||||
test("reuses same placeholder multiple times", () => {
|
||||
expect(substituteArguments("cmd $0 $1 $0", "alpha beta")).toBe(
|
||||
"cmd alpha beta alpha"
|
||||
);
|
||||
});
|
||||
test('reuses same placeholder multiple times', () => {
|
||||
expect(substituteArguments('cmd $0 $1 $0', 'alpha beta')).toBe(
|
||||
'cmd alpha beta alpha',
|
||||
)
|
||||
})
|
||||
|
||||
test("replaces named arguments", () => {
|
||||
expect(
|
||||
substituteArguments("file: $name", "test.txt", true, ["name"])
|
||||
).toBe("file: test.txt");
|
||||
});
|
||||
test('replaces named arguments', () => {
|
||||
expect(substituteArguments('file: $name', 'test.txt', true, ['name'])).toBe(
|
||||
'file: test.txt',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns content unchanged for undefined args", () => {
|
||||
expect(substituteArguments("hello", undefined)).toBe("hello");
|
||||
});
|
||||
test('returns content unchanged for undefined args', () => {
|
||||
expect(substituteArguments('hello', undefined)).toBe('hello')
|
||||
})
|
||||
|
||||
test("appends ARGUMENTS when no placeholder found", () => {
|
||||
expect(substituteArguments("run this", "extra")).toBe(
|
||||
"run this\n\nARGUMENTS: extra"
|
||||
);
|
||||
});
|
||||
test('appends ARGUMENTS when no placeholder found', () => {
|
||||
expect(substituteArguments('run this', 'extra')).toBe(
|
||||
'run this\n\nARGUMENTS: extra',
|
||||
)
|
||||
})
|
||||
|
||||
test("does not append when appendIfNoPlaceholder is false", () => {
|
||||
expect(substituteArguments("run this", "extra", false)).toBe("run this");
|
||||
});
|
||||
test('does not append when appendIfNoPlaceholder is false', () => {
|
||||
expect(substituteArguments('run this', 'extra', false)).toBe('run this')
|
||||
})
|
||||
|
||||
test("does not append for empty args string", () => {
|
||||
expect(substituteArguments("run this", "")).toBe("run this");
|
||||
});
|
||||
});
|
||||
test('does not append for empty args string', () => {
|
||||
expect(substituteArguments('run this', '')).toBe('run this')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { count, intersperse, uniq } from "../array";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { count, intersperse, uniq } from '../array'
|
||||
|
||||
describe("intersperse", () => {
|
||||
test("inserts separator between elements", () => {
|
||||
const result = intersperse([1, 2, 3], () => 0);
|
||||
expect(result).toEqual([1, 0, 2, 0, 3]);
|
||||
});
|
||||
describe('intersperse', () => {
|
||||
test('inserts separator between elements', () => {
|
||||
const result = intersperse([1, 2, 3], () => 0)
|
||||
expect(result).toEqual([1, 0, 2, 0, 3])
|
||||
})
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(intersperse([], () => 0)).toEqual([]);
|
||||
});
|
||||
test('returns empty array for empty input', () => {
|
||||
expect(intersperse([], () => 0)).toEqual([])
|
||||
})
|
||||
|
||||
test("returns single element without separator", () => {
|
||||
expect(intersperse([1], () => 0)).toEqual([1]);
|
||||
});
|
||||
test('returns single element without separator', () => {
|
||||
expect(intersperse([1], () => 0)).toEqual([1])
|
||||
})
|
||||
|
||||
test("passes index to separator function", () => {
|
||||
const result = intersperse(["a", "b", "c"], (i) => `sep-${i}`);
|
||||
expect(result).toEqual(["a", "sep-1", "b", "sep-2", "c"]);
|
||||
});
|
||||
});
|
||||
test('passes index to separator function', () => {
|
||||
const result = intersperse(['a', 'b', 'c'], i => `sep-${i}`)
|
||||
expect(result).toEqual(['a', 'sep-1', 'b', 'sep-2', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe("count", () => {
|
||||
test("counts matching elements", () => {
|
||||
expect(count([1, 2, 3, 4, 5], (x) => x > 3)).toBe(2);
|
||||
});
|
||||
describe('count', () => {
|
||||
test('counts matching elements', () => {
|
||||
expect(count([1, 2, 3, 4, 5], x => x > 3)).toBe(2)
|
||||
})
|
||||
|
||||
test("returns 0 for empty array", () => {
|
||||
expect(count([], () => true)).toBe(0);
|
||||
});
|
||||
test('returns 0 for empty array', () => {
|
||||
expect(count([], () => true)).toBe(0)
|
||||
})
|
||||
|
||||
test("returns 0 when nothing matches", () => {
|
||||
expect(count([1, 2, 3], (x) => x > 10)).toBe(0);
|
||||
});
|
||||
test('returns 0 when nothing matches', () => {
|
||||
expect(count([1, 2, 3], x => x > 10)).toBe(0)
|
||||
})
|
||||
|
||||
test("counts all when everything matches", () => {
|
||||
expect(count([1, 2, 3], () => true)).toBe(3);
|
||||
});
|
||||
});
|
||||
test('counts all when everything matches', () => {
|
||||
expect(count([1, 2, 3], () => true)).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe("uniq", () => {
|
||||
test("removes duplicates", () => {
|
||||
expect(uniq([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]);
|
||||
});
|
||||
describe('uniq', () => {
|
||||
test('removes duplicates', () => {
|
||||
expect(uniq([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("preserves order of first occurrence", () => {
|
||||
expect(uniq([3, 1, 2, 1, 3])).toEqual([3, 1, 2]);
|
||||
});
|
||||
test('preserves order of first occurrence', () => {
|
||||
expect(uniq([3, 1, 2, 1, 3])).toEqual([3, 1, 2])
|
||||
})
|
||||
|
||||
test("handles empty array", () => {
|
||||
expect(uniq([])).toEqual([]);
|
||||
});
|
||||
test('handles empty array', () => {
|
||||
expect(uniq([])).toEqual([])
|
||||
})
|
||||
|
||||
test("works with strings", () => {
|
||||
expect(uniq(["a", "b", "a"])).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
test('works with strings', () => {
|
||||
expect(uniq(['a', 'b', 'a'])).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -486,9 +486,7 @@ describe('autonomyRuns', () => {
|
||||
runs: Array<Record<string, unknown>>
|
||||
}
|
||||
file.runs = file.runs.map(run =>
|
||||
run.runId === runId
|
||||
? { ...run, ownerProcessId: 2_147_483_647 }
|
||||
: run,
|
||||
run.runId === runId ? { ...run, ownerProcessId: 2_147_483_647 } : run,
|
||||
)
|
||||
await writeTempFile(tempDir, RUNS_REL, `${JSON.stringify(file, null, 2)}\n`)
|
||||
|
||||
|
||||
@@ -1,117 +1,117 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createBufferedWriter } from "../bufferedWriter";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createBufferedWriter } from '../bufferedWriter'
|
||||
|
||||
describe("createBufferedWriter", () => {
|
||||
test("immediateMode calls writeFn directly", () => {
|
||||
const written: string[] = [];
|
||||
describe('createBufferedWriter', () => {
|
||||
test('immediateMode calls writeFn directly', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
writeFn: c => written.push(c),
|
||||
immediateMode: true,
|
||||
});
|
||||
writer.write("a");
|
||||
writer.write("b");
|
||||
expect(written).toEqual(["a", "b"]);
|
||||
});
|
||||
})
|
||||
writer.write('a')
|
||||
writer.write('b')
|
||||
expect(written).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
test("buffered mode accumulates until flush", () => {
|
||||
const written: string[] = [];
|
||||
test('buffered mode accumulates until flush', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("hello ");
|
||||
writer.write("world");
|
||||
expect(written).toEqual([]);
|
||||
writer.flush();
|
||||
expect(written).toEqual(["hello world"]);
|
||||
});
|
||||
writeFn: c => written.push(c),
|
||||
})
|
||||
writer.write('hello ')
|
||||
writer.write('world')
|
||||
expect(written).toEqual([])
|
||||
writer.flush()
|
||||
expect(written).toEqual(['hello world'])
|
||||
})
|
||||
|
||||
test("flush with empty buffer does not call writeFn", () => {
|
||||
const written: string[] = [];
|
||||
test('flush with empty buffer does not call writeFn', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.flush();
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
writeFn: c => written.push(c),
|
||||
})
|
||||
writer.flush()
|
||||
expect(written).toEqual([])
|
||||
})
|
||||
|
||||
test("flush clears the buffer", () => {
|
||||
const written: string[] = [];
|
||||
test('flush clears the buffer', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("data");
|
||||
writer.flush();
|
||||
writer.flush(); // second flush should be no-op
|
||||
expect(written).toEqual(["data"]);
|
||||
});
|
||||
writeFn: c => written.push(c),
|
||||
})
|
||||
writer.write('data')
|
||||
writer.flush()
|
||||
writer.flush() // second flush should be no-op
|
||||
expect(written).toEqual(['data'])
|
||||
})
|
||||
|
||||
test("overflow triggers deferred flush when maxBufferSize reached", () => {
|
||||
const written: string[] = [];
|
||||
test('overflow triggers deferred flush when maxBufferSize reached', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
writeFn: c => written.push(c),
|
||||
maxBufferSize: 2,
|
||||
});
|
||||
writer.write("a");
|
||||
writer.write("b");
|
||||
})
|
||||
writer.write('a')
|
||||
writer.write('b')
|
||||
// 2 writes = maxBufferSize, triggers flushDeferred via setImmediate
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
expect(written).toEqual([])
|
||||
})
|
||||
|
||||
test("overflow triggers deferred flush when maxBufferBytes reached", () => {
|
||||
const written: string[] = [];
|
||||
test('overflow triggers deferred flush when maxBufferBytes reached', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
writeFn: c => written.push(c),
|
||||
maxBufferBytes: 5,
|
||||
});
|
||||
writer.write("abc");
|
||||
writer.write("def");
|
||||
})
|
||||
writer.write('abc')
|
||||
writer.write('def')
|
||||
// total 6 bytes > 5, triggers flushDeferred
|
||||
expect(written).toEqual([]);
|
||||
});
|
||||
expect(written).toEqual([])
|
||||
})
|
||||
|
||||
test("dispose flushes remaining buffer", () => {
|
||||
const written: string[] = [];
|
||||
test('dispose flushes remaining buffer', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("final");
|
||||
writer.dispose();
|
||||
expect(written).toEqual(["final"]);
|
||||
});
|
||||
writeFn: c => written.push(c),
|
||||
})
|
||||
writer.write('final')
|
||||
writer.dispose()
|
||||
expect(written).toEqual(['final'])
|
||||
})
|
||||
|
||||
test("dispose flushes pending overflow", () => {
|
||||
const written: string[] = [];
|
||||
test('dispose flushes pending overflow', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
writeFn: c => written.push(c),
|
||||
maxBufferSize: 1,
|
||||
});
|
||||
writer.write("overflow-data");
|
||||
})
|
||||
writer.write('overflow-data')
|
||||
// overflow triggered but deferred; dispose should flush it synchronously
|
||||
writer.dispose();
|
||||
expect(written).toEqual(["overflow-data"]);
|
||||
});
|
||||
writer.dispose()
|
||||
expect(written).toEqual(['overflow-data'])
|
||||
})
|
||||
|
||||
test("coalesced overflow — multiple overflows merge before write", () => {
|
||||
const written: string[] = [];
|
||||
test('coalesced overflow — multiple overflows merge before write', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
writeFn: c => written.push(c),
|
||||
maxBufferSize: 1,
|
||||
});
|
||||
writer.write("a"); // triggers first overflow (deferred)
|
||||
writer.write("b"); // pendingOverflow exists, coalesces
|
||||
writer.dispose(); // flushes coalesced overflow
|
||||
expect(written).toEqual(["ab"]);
|
||||
});
|
||||
})
|
||||
writer.write('a') // triggers first overflow (deferred)
|
||||
writer.write('b') // pendingOverflow exists, coalesces
|
||||
writer.dispose() // flushes coalesced overflow
|
||||
expect(written).toEqual(['ab'])
|
||||
})
|
||||
|
||||
test("multiple flushes produce concatenated writes", () => {
|
||||
const written: string[] = [];
|
||||
test('multiple flushes produce concatenated writes', () => {
|
||||
const written: string[] = []
|
||||
const writer = createBufferedWriter({
|
||||
writeFn: (c) => written.push(c),
|
||||
});
|
||||
writer.write("batch1");
|
||||
writer.flush();
|
||||
writer.write("batch2");
|
||||
writer.flush();
|
||||
expect(written).toEqual(["batch1", "batch2"]);
|
||||
});
|
||||
});
|
||||
writeFn: c => written.push(c),
|
||||
})
|
||||
writer.write('batch1')
|
||||
writer.flush()
|
||||
writer.write('batch2')
|
||||
writer.flush()
|
||||
expect(written).toEqual(['batch1', 'batch2'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,9 @@ describe('Bun.hash Node.js polyfill (FNV-1a)', () => {
|
||||
// unsigned int. They use different widths so direct equality is not expected.
|
||||
// This test just verifies the native API exists and returns a numeric type.
|
||||
if (typeof globalThis.Bun?.hash === 'function') {
|
||||
const result = (globalThis.Bun.hash as (s: string) => bigint | number)('hello')
|
||||
const result = (globalThis.Bun.hash as (s: string) => bigint | number)(
|
||||
'hello',
|
||||
)
|
||||
expect(['number', 'bigint']).toContain(typeof result)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
stripHtmlComments,
|
||||
isMemoryFilePath,
|
||||
getLargeMemoryFiles,
|
||||
MAX_MEMORY_CHARACTER_COUNT,
|
||||
type MemoryFileInfo,
|
||||
} from "../claudemd";
|
||||
} from '../claudemd'
|
||||
|
||||
function mockMemoryFile(overrides: Partial<MemoryFileInfo> = {}): MemoryFileInfo {
|
||||
function mockMemoryFile(
|
||||
overrides: Partial<MemoryFileInfo> = {},
|
||||
): MemoryFileInfo {
|
||||
return {
|
||||
path: "/project/CLAUDE.md",
|
||||
type: "Project",
|
||||
content: "test content",
|
||||
path: '/project/CLAUDE.md',
|
||||
type: 'Project',
|
||||
content: 'test content',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("stripHtmlComments", () => {
|
||||
test("strips block-level HTML comments (own line)", () => {
|
||||
describe('stripHtmlComments', () => {
|
||||
test('strips block-level HTML comments (own line)', () => {
|
||||
// CommonMark type-2 HTML blocks: comment must start at beginning of line
|
||||
const result = stripHtmlComments("text\n<!-- block comment -->\nmore");
|
||||
expect(result.content).not.toContain("block comment");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
const result = stripHtmlComments('text\n<!-- block comment -->\nmore')
|
||||
expect(result.content).not.toContain('block comment')
|
||||
expect(result.stripped).toBe(true)
|
||||
})
|
||||
|
||||
test("returns stripped: false when no comments", () => {
|
||||
const result = stripHtmlComments("no comments here");
|
||||
expect(result.stripped).toBe(false);
|
||||
expect(result.content).toBe("no comments here");
|
||||
});
|
||||
test('returns stripped: false when no comments', () => {
|
||||
const result = stripHtmlComments('no comments here')
|
||||
expect(result.stripped).toBe(false)
|
||||
expect(result.content).toBe('no comments here')
|
||||
})
|
||||
|
||||
test("returns stripped: true when block comments exist", () => {
|
||||
const result = stripHtmlComments("hello\n<!-- world -->\nend");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
test('returns stripped: true when block comments exist', () => {
|
||||
const result = stripHtmlComments('hello\n<!-- world -->\nend')
|
||||
expect(result.stripped).toBe(true)
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = stripHtmlComments("");
|
||||
expect(result.content).toBe("");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
const result = stripHtmlComments('')
|
||||
expect(result.content).toBe('')
|
||||
expect(result.stripped).toBe(false)
|
||||
})
|
||||
|
||||
test("handles multiple block comments", () => {
|
||||
const result = stripHtmlComments(
|
||||
"a\n<!-- c1 -->\nb\n<!-- c2 -->\nc"
|
||||
);
|
||||
expect(result.content).not.toContain("c1");
|
||||
expect(result.content).not.toContain("c2");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
test('handles multiple block comments', () => {
|
||||
const result = stripHtmlComments('a\n<!-- c1 -->\nb\n<!-- c2 -->\nc')
|
||||
expect(result.content).not.toContain('c1')
|
||||
expect(result.content).not.toContain('c2')
|
||||
expect(result.stripped).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves code block content", () => {
|
||||
const input = "text\n```html\n<!-- not stripped -->\n```\nmore";
|
||||
const result = stripHtmlComments(input);
|
||||
expect(result.content).toContain("<!-- not stripped -->");
|
||||
});
|
||||
test('preserves code block content', () => {
|
||||
const input = 'text\n```html\n<!-- not stripped -->\n```\nmore'
|
||||
const result = stripHtmlComments(input)
|
||||
expect(result.content).toContain('<!-- not stripped -->')
|
||||
})
|
||||
|
||||
test("preserves inline comments within paragraphs", () => {
|
||||
test('preserves inline comments within paragraphs', () => {
|
||||
// Inline comments are NOT stripped (CommonMark paragraph semantics)
|
||||
const result = stripHtmlComments("text <!-- inline --> more");
|
||||
expect(result.content).toContain("<!-- inline -->");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
const result = stripHtmlComments('text <!-- inline --> more')
|
||||
expect(result.content).toContain('<!-- inline -->')
|
||||
expect(result.stripped).toBe(false)
|
||||
})
|
||||
|
||||
test("leaves unclosed HTML comment unchanged", () => {
|
||||
const result = stripHtmlComments("<!-- no close some text");
|
||||
expect(result.content).toBe("<!-- no close some text");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
test('leaves unclosed HTML comment unchanged', () => {
|
||||
const result = stripHtmlComments('<!-- no close some text')
|
||||
expect(result.content).toBe('<!-- no close some text')
|
||||
expect(result.stripped).toBe(false)
|
||||
})
|
||||
|
||||
test("strips comment and keeps same-line residual content", () => {
|
||||
const result = stripHtmlComments("<!-- note -->some text");
|
||||
expect(result.content).toContain("some text");
|
||||
expect(result.content).not.toContain("<!--");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
});
|
||||
test('strips comment and keeps same-line residual content', () => {
|
||||
const result = stripHtmlComments('<!-- note -->some text')
|
||||
expect(result.content).toContain('some text')
|
||||
expect(result.content).not.toContain('<!--')
|
||||
expect(result.stripped).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isMemoryFilePath", () => {
|
||||
test("returns true for CLAUDE.md path", () => {
|
||||
expect(isMemoryFilePath("/project/CLAUDE.md")).toBe(true);
|
||||
});
|
||||
describe('isMemoryFilePath', () => {
|
||||
test('returns true for CLAUDE.md path', () => {
|
||||
expect(isMemoryFilePath('/project/CLAUDE.md')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for CLAUDE.local.md path", () => {
|
||||
expect(isMemoryFilePath("/project/CLAUDE.local.md")).toBe(true);
|
||||
});
|
||||
test('returns true for CLAUDE.local.md path', () => {
|
||||
expect(isMemoryFilePath('/project/CLAUDE.local.md')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for .claude/rules/ path", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/rules/foo.md")).toBe(true);
|
||||
});
|
||||
test('returns true for .claude/rules/ path', () => {
|
||||
expect(isMemoryFilePath('/project/.claude/rules/foo.md')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for regular file", () => {
|
||||
expect(isMemoryFilePath("/project/src/main.ts")).toBe(false);
|
||||
});
|
||||
test('returns false for regular file', () => {
|
||||
expect(isMemoryFilePath('/project/src/main.ts')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for unrelated .md file", () => {
|
||||
expect(isMemoryFilePath("/project/README.md")).toBe(false);
|
||||
});
|
||||
test('returns false for unrelated .md file', () => {
|
||||
expect(isMemoryFilePath('/project/README.md')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for .claude directory non-rules file", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/settings.json")).toBe(false);
|
||||
});
|
||||
test('returns false for .claude directory non-rules file', () => {
|
||||
expect(isMemoryFilePath('/project/.claude/settings.json')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for lowercase claude.md (case-sensitive match)", () => {
|
||||
expect(isMemoryFilePath("/project/claude.md")).toBe(false);
|
||||
});
|
||||
test('returns false for lowercase claude.md (case-sensitive match)', () => {
|
||||
expect(isMemoryFilePath('/project/claude.md')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-.md file in .claude/rules/", () => {
|
||||
expect(isMemoryFilePath(".claude/rules/foo.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-.md file in .claude/rules/', () => {
|
||||
expect(isMemoryFilePath('.claude/rules/foo.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLargeMemoryFiles", () => {
|
||||
test("returns files exceeding threshold", () => {
|
||||
const largeContent = "x".repeat(MAX_MEMORY_CHARACTER_COUNT + 1);
|
||||
describe('getLargeMemoryFiles', () => {
|
||||
test('returns files exceeding threshold', () => {
|
||||
const largeContent = 'x'.repeat(MAX_MEMORY_CHARACTER_COUNT + 1)
|
||||
const files = [
|
||||
mockMemoryFile({ content: "small" }),
|
||||
mockMemoryFile({ content: largeContent, path: "/big.md" }),
|
||||
];
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe("/big.md");
|
||||
});
|
||||
mockMemoryFile({ content: 'small' }),
|
||||
mockMemoryFile({ content: largeContent, path: '/big.md' }),
|
||||
]
|
||||
const result = getLargeMemoryFiles(files)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].path).toBe('/big.md')
|
||||
})
|
||||
|
||||
test("returns empty array when all files are small", () => {
|
||||
test('returns empty array when all files are small', () => {
|
||||
const files = [
|
||||
mockMemoryFile({ content: "small" }),
|
||||
mockMemoryFile({ content: "also small" }),
|
||||
];
|
||||
expect(getLargeMemoryFiles(files)).toEqual([]);
|
||||
});
|
||||
mockMemoryFile({ content: 'small' }),
|
||||
mockMemoryFile({ content: 'also small' }),
|
||||
]
|
||||
expect(getLargeMemoryFiles(files)).toEqual([])
|
||||
})
|
||||
|
||||
test("correctly identifies threshold boundary", () => {
|
||||
const atThreshold = "x".repeat(MAX_MEMORY_CHARACTER_COUNT);
|
||||
const overThreshold = "x".repeat(MAX_MEMORY_CHARACTER_COUNT + 1);
|
||||
test('correctly identifies threshold boundary', () => {
|
||||
const atThreshold = 'x'.repeat(MAX_MEMORY_CHARACTER_COUNT)
|
||||
const overThreshold = 'x'.repeat(MAX_MEMORY_CHARACTER_COUNT + 1)
|
||||
const files = [
|
||||
mockMemoryFile({ content: atThreshold }),
|
||||
mockMemoryFile({ content: overThreshold }),
|
||||
];
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
]
|
||||
const result = getLargeMemoryFiles(files)
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(getLargeMemoryFiles([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('returns empty array for empty input', () => {
|
||||
expect(getLargeMemoryFiles([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,136 +1,138 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { collapseHookSummaries } from "../collapseHookSummaries";
|
||||
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 {
|
||||
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",
|
||||
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" } };
|
||||
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);
|
||||
});
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
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", () => {
|
||||
test('does not collapse messages with different hookLabels', () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "PostToolUse" }),
|
||||
makeHookSummary({ hookLabel: "PreToolUse" }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
makeHookSummary({ hookLabel: 'PostToolUse' }),
|
||||
makeHookSummary({ hookLabel: 'PreToolUse' }),
|
||||
]
|
||||
const result = collapseHookSummaries(messages)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("aggregates hookCount across collapsed messages", () => {
|
||||
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);
|
||||
});
|
||||
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" };
|
||||
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]);
|
||||
});
|
||||
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");
|
||||
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);
|
||||
});
|
||||
makeHookSummary({ hookLabel: 'A', hookErrors: [err1] }),
|
||||
makeHookSummary({ hookLabel: 'A', hookErrors: [err2] }),
|
||||
]
|
||||
const result = collapseHookSummaries(messages)
|
||||
expect(result[0].hookErrors).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("takes max totalDurationMs", () => {
|
||||
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);
|
||||
});
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
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('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", () => {
|
||||
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);
|
||||
});
|
||||
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", () => {
|
||||
test('preserves non-hook messages in between', () => {
|
||||
const messages = [
|
||||
makeHookSummary({ hookLabel: "A" }),
|
||||
makeHookSummary({ hookLabel: 'A' }),
|
||||
makeNonHookMessage(),
|
||||
makeHookSummary({ hookLabel: "A" }),
|
||||
];
|
||||
const result = collapseHookSummaries(messages);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
makeHookSummary({ hookLabel: 'A' }),
|
||||
]
|
||||
const result = collapseHookSummaries(messages)
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(collapseHookSummaries([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('returns empty array for empty input', () => {
|
||||
expect(collapseHookSummaries([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,94 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { collapseTeammateShutdowns } from "../collapseTeammateShutdowns";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { collapseTeammateShutdowns } from '../collapseTeammateShutdowns'
|
||||
|
||||
function makeShutdownMsg(uuid = "1"): any {
|
||||
function makeShutdownMsg(uuid = '1'): any {
|
||||
return {
|
||||
type: "attachment",
|
||||
type: 'attachment',
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
attachment: {
|
||||
type: "task_status",
|
||||
taskType: "in_process_teammate",
|
||||
status: "completed",
|
||||
type: 'task_status',
|
||||
taskType: 'in_process_teammate',
|
||||
status: 'completed',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeNonShutdownMsg(): any {
|
||||
return { type: "user", message: { content: "hello" } };
|
||||
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);
|
||||
});
|
||||
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('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] as any).attachment.type).toBe("teammate_shutdown_batch");
|
||||
});
|
||||
test('collapses consecutive shutdown messages into batch', () => {
|
||||
const msgs = [makeShutdownMsg('1'), makeShutdownMsg('2')]
|
||||
const result = collapseTeammateShutdowns(msgs)
|
||||
expect(result).toHaveLength(1)
|
||||
expect((result[0] as any).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] as any).attachment.count).toBe(3);
|
||||
});
|
||||
test('batch attachment has correct count', () => {
|
||||
const msgs = [
|
||||
makeShutdownMsg('1'),
|
||||
makeShutdownMsg('2'),
|
||||
makeShutdownMsg('3'),
|
||||
]
|
||||
const result = collapseTeammateShutdowns(msgs)
|
||||
expect((result[0] as any).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] as any).attachment.type).toBe("task_status");
|
||||
expect((result[2] as any).attachment.type).toBe("task_status");
|
||||
});
|
||||
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] as any).attachment.type).toBe('task_status')
|
||||
expect((result[2] as any).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('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 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] as any).attachment.type).toBe("teammate_shutdown_batch");
|
||||
});
|
||||
test('handles mixed message types', () => {
|
||||
const msgs = [
|
||||
makeNonShutdownMsg(),
|
||||
makeShutdownMsg('1'),
|
||||
makeShutdownMsg('2'),
|
||||
makeNonShutdownMsg(),
|
||||
]
|
||||
const result = collapseTeammateShutdowns(msgs)
|
||||
expect(result).toHaveLength(3)
|
||||
expect((result[1] as any).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] as any).attachment.count).toBe(5);
|
||||
});
|
||||
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] as any).attachment.count).toBe(5)
|
||||
})
|
||||
|
||||
test("non-teammate task_status messages are not collapsed", () => {
|
||||
test('non-teammate task_status messages are not collapsed', () => {
|
||||
const nonTeammate: any = {
|
||||
type: "attachment",
|
||||
uuid: "x",
|
||||
type: 'attachment',
|
||||
uuid: 'x',
|
||||
timestamp: Date.now(),
|
||||
attachment: {
|
||||
type: "task_status",
|
||||
taskType: "subagent",
|
||||
status: "completed",
|
||||
type: 'task_status',
|
||||
taskType: 'subagent',
|
||||
status: 'completed',
|
||||
},
|
||||
};
|
||||
const msgs = [nonTeammate, { ...nonTeammate, uuid: "y" }];
|
||||
const result = collapseTeammateShutdowns(msgs);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
const msgs = [nonTeammate, { ...nonTeammate, uuid: 'y' }]
|
||||
const result = collapseTeammateShutdowns(msgs)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
NOTIFICATION_CHANNELS,
|
||||
EDITOR_MODES,
|
||||
TEAMMATE_MODES,
|
||||
} from "../configConstants";
|
||||
} 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");
|
||||
});
|
||||
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);
|
||||
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);
|
||||
});
|
||||
expect(NOTIFICATION_CHANNELS.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("includes all documented channels", () => {
|
||||
test('includes all documented channels', () => {
|
||||
expect(NOTIFICATION_CHANNELS).toEqual([
|
||||
"auto",
|
||||
"iterm2",
|
||||
"iterm2_with_bell",
|
||||
"terminal_bell",
|
||||
"kitty",
|
||||
"ghostty",
|
||||
"notifications_disabled",
|
||||
]);
|
||||
});
|
||||
'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);
|
||||
});
|
||||
});
|
||||
test('has no duplicate entries', () => {
|
||||
const unique = new Set(NOTIFICATION_CHANNELS)
|
||||
expect(unique.size).toBe(NOTIFICATION_CHANNELS.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDITOR_MODES", () => {
|
||||
describe('EDITOR_MODES', () => {
|
||||
test("contains 'normal' and 'vim'", () => {
|
||||
expect(EDITOR_MODES).toContain("normal");
|
||||
expect(EDITOR_MODES).toContain("vim");
|
||||
});
|
||||
expect(EDITOR_MODES).toContain('normal')
|
||||
expect(EDITOR_MODES).toContain('vim')
|
||||
})
|
||||
|
||||
test("has exactly 2 entries", () => {
|
||||
expect(EDITOR_MODES).toHaveLength(2);
|
||||
});
|
||||
test('has exactly 2 entries', () => {
|
||||
expect(EDITOR_MODES).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("is ordered: normal, vim", () => {
|
||||
expect(EDITOR_MODES).toEqual(["normal", "vim"]);
|
||||
});
|
||||
});
|
||||
test('is ordered: normal, vim', () => {
|
||||
expect(EDITOR_MODES).toEqual(['normal', 'vim'])
|
||||
})
|
||||
})
|
||||
|
||||
describe("TEAMMATE_MODES", () => {
|
||||
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");
|
||||
});
|
||||
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('has exactly 3 entries', () => {
|
||||
expect(TEAMMATE_MODES).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("is ordered: auto, tmux, in-process", () => {
|
||||
expect(TEAMMATE_MODES).toEqual(["auto", "tmux", "in-process"]);
|
||||
});
|
||||
});
|
||||
test('is ordered: auto, tmux, in-process', () => {
|
||||
expect(TEAMMATE_MODES).toEqual(['auto', 'tmux', 'in-process'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { insertBlockAfterToolResults } from "../contentArray";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { insertBlockAfterToolResults } from '../contentArray'
|
||||
|
||||
describe("insertBlockAfterToolResults", () => {
|
||||
test("inserts after last tool_result", () => {
|
||||
describe('insertBlockAfterToolResults', () => {
|
||||
test('inserts after last tool_result', () => {
|
||||
const content: any[] = [
|
||||
{ type: "tool_result", content: "r1" },
|
||||
{ type: "text", text: "hello" },
|
||||
];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "inserted" });
|
||||
expect(content[1]).toEqual({ type: "text", text: "inserted" });
|
||||
expect(content).toHaveLength(3);
|
||||
});
|
||||
{ type: 'tool_result', content: 'r1' },
|
||||
{ type: 'text', text: 'hello' },
|
||||
]
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'inserted' })
|
||||
expect(content[1]).toEqual({ type: 'text', text: 'inserted' })
|
||||
expect(content).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("inserts after last of multiple tool_results", () => {
|
||||
test('inserts after last of multiple tool_results', () => {
|
||||
const content: any[] = [
|
||||
{ type: "tool_result", content: "r1" },
|
||||
{ type: "tool_result", content: "r2" },
|
||||
{ type: "text", text: "end" },
|
||||
];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "new" });
|
||||
expect(content[2]).toEqual({ type: "text", text: "new" });
|
||||
});
|
||||
{ type: 'tool_result', content: 'r1' },
|
||||
{ type: 'tool_result', content: 'r2' },
|
||||
{ type: 'text', text: 'end' },
|
||||
]
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'new' })
|
||||
expect(content[2]).toEqual({ type: 'text', text: 'new' })
|
||||
})
|
||||
|
||||
test("appends continuation when inserted block would be last", () => {
|
||||
const content: any[] = [{ type: "tool_result", content: "r1" }];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "new" });
|
||||
expect(content).toHaveLength(3); // original + inserted + continuation
|
||||
expect(content[2]).toEqual({ type: "text", text: "." });
|
||||
});
|
||||
test('appends continuation when inserted block would be last', () => {
|
||||
const content: any[] = [{ type: 'tool_result', content: 'r1' }]
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'new' })
|
||||
expect(content).toHaveLength(3) // original + inserted + continuation
|
||||
expect(content[2]).toEqual({ type: 'text', text: '.' })
|
||||
})
|
||||
|
||||
test("inserts before last block when no tool_results", () => {
|
||||
test('inserts before last block when no tool_results', () => {
|
||||
const content: any[] = [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: "text", text: "b" },
|
||||
];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "new" });
|
||||
expect(content[1]).toEqual({ type: "text", text: "new" });
|
||||
expect(content).toHaveLength(3);
|
||||
});
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
]
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'new' })
|
||||
expect(content[1]).toEqual({ type: 'text', text: 'new' })
|
||||
expect(content).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("handles empty array", () => {
|
||||
const content: any[] = [];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "new" });
|
||||
expect(content).toHaveLength(1);
|
||||
expect(content[0]).toEqual({ type: "text", text: "new" });
|
||||
});
|
||||
test('handles empty array', () => {
|
||||
const content: any[] = []
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'new' })
|
||||
expect(content).toHaveLength(1)
|
||||
expect(content[0]).toEqual({ type: 'text', text: 'new' })
|
||||
})
|
||||
|
||||
test("handles single element array with no tool_result", () => {
|
||||
const content: any[] = [{ type: "text", text: "only" }];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "new" });
|
||||
expect(content[0]).toEqual({ type: "text", text: "new" });
|
||||
expect(content[1]).toEqual({ type: "text", text: "only" });
|
||||
});
|
||||
test('handles single element array with no tool_result', () => {
|
||||
const content: any[] = [{ type: 'text', text: 'only' }]
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'new' })
|
||||
expect(content[0]).toEqual({ type: 'text', text: 'new' })
|
||||
expect(content[1]).toEqual({ type: 'text', text: 'only' })
|
||||
})
|
||||
|
||||
test("inserts after last tool_result with mixed interleaving", () => {
|
||||
test('inserts after last tool_result with mixed interleaving', () => {
|
||||
const content: any[] = [
|
||||
{ type: "tool_result", content: "r1" },
|
||||
{ type: "text", text: "mid1" },
|
||||
{ type: "tool_result", content: "r2" },
|
||||
{ type: "text", text: "mid2" },
|
||||
{ type: "tool_result", content: "r3" },
|
||||
{ type: "text", text: "end" },
|
||||
];
|
||||
insertBlockAfterToolResults(content, { type: "text", text: "inserted" });
|
||||
{ type: 'tool_result', content: 'r1' },
|
||||
{ type: 'text', text: 'mid1' },
|
||||
{ type: 'tool_result', content: 'r2' },
|
||||
{ type: 'text', text: 'mid2' },
|
||||
{ type: 'tool_result', content: 'r3' },
|
||||
{ type: 'text', text: 'end' },
|
||||
]
|
||||
insertBlockAfterToolResults(content, { type: 'text', text: 'inserted' })
|
||||
// Inserted after r3 (index 4), so at index 5
|
||||
expect(content[5]).toEqual({ type: "text", text: "inserted" });
|
||||
expect(content[5]).toEqual({ type: 'text', text: 'inserted' })
|
||||
// Original end text should shift to index 6
|
||||
expect(content[6]).toEqual({ type: "text", text: "end" });
|
||||
expect(content).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
expect(content[6]).toEqual({ type: 'text', text: 'end' })
|
||||
expect(content).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeControlMessageKeys } from "../controlMessageCompat";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { normalizeControlMessageKeys } from '../controlMessageCompat'
|
||||
|
||||
describe("normalizeControlMessageKeys", () => {
|
||||
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('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" });
|
||||
});
|
||||
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");
|
||||
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");
|
||||
});
|
||||
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('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('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", () => {
|
||||
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");
|
||||
});
|
||||
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 null as-is', () => {
|
||||
expect(normalizeControlMessageKeys(null)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns undefined as-is", () => {
|
||||
expect(normalizeControlMessageKeys(undefined)).toBeUndefined();
|
||||
});
|
||||
test('returns undefined as-is', () => {
|
||||
expect(normalizeControlMessageKeys(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns string as-is", () => {
|
||||
expect(normalizeControlMessageKeys("hello")).toBe("hello");
|
||||
});
|
||||
test('returns string as-is', () => {
|
||||
expect(normalizeControlMessageKeys('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
test("returns number as-is", () => {
|
||||
expect(normalizeControlMessageKeys(42)).toBe(42);
|
||||
});
|
||||
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('empty object is unchanged', () => {
|
||||
const obj = {}
|
||||
normalizeControlMessageKeys(obj)
|
||||
expect(obj).toEqual({})
|
||||
})
|
||||
|
||||
test("mutates the original object in place", () => {
|
||||
const obj: Record<string, unknown> = { requestId: "abc", other: "data" };
|
||||
const result = normalizeControlMessageKeys(obj);
|
||||
expect(result).toBe(obj); // same reference
|
||||
expect(obj).toEqual({ request_id: "abc", other: "data" });
|
||||
});
|
||||
test('mutates the original object in place', () => {
|
||||
const obj: Record<string, unknown> = { 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('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 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");
|
||||
});
|
||||
});
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,253 +1,255 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCronExpression, computeNextCronRun, cronToHuman } from "../cron";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseCronExpression, computeNextCronRun, cronToHuman } from '../cron'
|
||||
|
||||
describe("parseCronExpression", () => {
|
||||
describe("valid expressions", () => {
|
||||
test("parses wildcard fields", () => {
|
||||
const result = parseCronExpression("* * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toHaveLength(60);
|
||||
expect(result!.hour).toHaveLength(24);
|
||||
expect(result!.dayOfMonth).toHaveLength(31);
|
||||
expect(result!.month).toHaveLength(12);
|
||||
expect(result!.dayOfWeek).toHaveLength(7);
|
||||
});
|
||||
describe('parseCronExpression', () => {
|
||||
describe('valid expressions', () => {
|
||||
test('parses wildcard fields', () => {
|
||||
const result = parseCronExpression('* * * * *')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toHaveLength(60)
|
||||
expect(result!.hour).toHaveLength(24)
|
||||
expect(result!.dayOfMonth).toHaveLength(31)
|
||||
expect(result!.month).toHaveLength(12)
|
||||
expect(result!.dayOfWeek).toHaveLength(7)
|
||||
})
|
||||
|
||||
test("parses specific values", () => {
|
||||
const result = parseCronExpression("30 14 1 6 3");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([30]);
|
||||
expect(result!.hour).toEqual([14]);
|
||||
expect(result!.dayOfMonth).toEqual([1]);
|
||||
expect(result!.month).toEqual([6]);
|
||||
expect(result!.dayOfWeek).toEqual([3]);
|
||||
});
|
||||
test('parses specific values', () => {
|
||||
const result = parseCronExpression('30 14 1 6 3')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toEqual([30])
|
||||
expect(result!.hour).toEqual([14])
|
||||
expect(result!.dayOfMonth).toEqual([1])
|
||||
expect(result!.month).toEqual([6])
|
||||
expect(result!.dayOfWeek).toEqual([3])
|
||||
})
|
||||
|
||||
test("parses step syntax", () => {
|
||||
const result = parseCronExpression("*/5 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
|
||||
});
|
||||
test('parses step syntax', () => {
|
||||
const result = parseCronExpression('*/5 * * * *')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toEqual([
|
||||
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55,
|
||||
])
|
||||
})
|
||||
|
||||
test("parses range syntax", () => {
|
||||
const result = parseCronExpression("1-5 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
test('parses range syntax', () => {
|
||||
const result = parseCronExpression('1-5 * * * *')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
|
||||
test("parses range with step", () => {
|
||||
const result = parseCronExpression("1-10/3 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
test('parses range with step', () => {
|
||||
const result = parseCronExpression('1-10/3 * * * *')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toEqual([1, 4, 7, 10])
|
||||
})
|
||||
|
||||
test("parses comma-separated list", () => {
|
||||
const result = parseCronExpression("1,15,30 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 15, 30]);
|
||||
});
|
||||
test('parses comma-separated list', () => {
|
||||
const result = parseCronExpression('1,15,30 * * * *')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toEqual([1, 15, 30])
|
||||
})
|
||||
|
||||
test("parses day-of-week 7 as Sunday alias", () => {
|
||||
const result = parseCronExpression("0 0 * * 7");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dayOfWeek).toEqual([0]);
|
||||
});
|
||||
test('parses day-of-week 7 as Sunday alias', () => {
|
||||
const result = parseCronExpression('0 0 * * 7')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.dayOfWeek).toEqual([0])
|
||||
})
|
||||
|
||||
test("parses range with day-of-week 7", () => {
|
||||
const result = parseCronExpression("0 0 * * 5-7");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dayOfWeek).toEqual([0, 5, 6]);
|
||||
});
|
||||
test('parses range with day-of-week 7', () => {
|
||||
const result = parseCronExpression('0 0 * * 5-7')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.dayOfWeek).toEqual([0, 5, 6])
|
||||
})
|
||||
|
||||
test("parses complex combined expression", () => {
|
||||
const result = parseCronExpression("0,30 9-17 * * 1-5");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([0, 30]);
|
||||
expect(result!.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
||||
expect(result!.dayOfWeek).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
test('parses complex combined expression', () => {
|
||||
const result = parseCronExpression('0,30 9-17 * * 1-5')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.minute).toEqual([0, 30])
|
||||
expect(result!.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17])
|
||||
expect(result!.dayOfWeek).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid expressions", () => {
|
||||
test("returns null for wrong field count", () => {
|
||||
expect(parseCronExpression("* * *")).toBeNull();
|
||||
});
|
||||
describe('invalid expressions', () => {
|
||||
test('returns null for wrong field count', () => {
|
||||
expect(parseCronExpression('* * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for out-of-range values", () => {
|
||||
expect(parseCronExpression("60 * * * *")).toBeNull();
|
||||
});
|
||||
test('returns null for out-of-range values', () => {
|
||||
expect(parseCronExpression('60 * * * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for invalid step", () => {
|
||||
expect(parseCronExpression("*/0 * * * *")).toBeNull();
|
||||
});
|
||||
test('returns null for invalid step', () => {
|
||||
expect(parseCronExpression('*/0 * * * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for reversed range", () => {
|
||||
expect(parseCronExpression("10-5 * * * *")).toBeNull();
|
||||
});
|
||||
test('returns null for reversed range', () => {
|
||||
expect(parseCronExpression('10-5 * * * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseCronExpression("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(parseCronExpression('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for non-numeric tokens", () => {
|
||||
expect(parseCronExpression("abc * * * *")).toBeNull();
|
||||
});
|
||||
});
|
||||
test('returns null for non-numeric tokens', () => {
|
||||
expect(parseCronExpression('abc * * * *')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("field range validation", () => {
|
||||
test("minute: 0-59", () => {
|
||||
expect(parseCronExpression("0 * * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("59 * * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("60 * * * *")).toBeNull();
|
||||
});
|
||||
describe('field range validation', () => {
|
||||
test('minute: 0-59', () => {
|
||||
expect(parseCronExpression('0 * * * *')).not.toBeNull()
|
||||
expect(parseCronExpression('59 * * * *')).not.toBeNull()
|
||||
expect(parseCronExpression('60 * * * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("hour: 0-23", () => {
|
||||
expect(parseCronExpression("* 0 * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* 23 * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* 24 * * *")).toBeNull();
|
||||
});
|
||||
test('hour: 0-23', () => {
|
||||
expect(parseCronExpression('* 0 * * *')).not.toBeNull()
|
||||
expect(parseCronExpression('* 23 * * *')).not.toBeNull()
|
||||
expect(parseCronExpression('* 24 * * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("dayOfMonth: 1-31", () => {
|
||||
expect(parseCronExpression("* * 1 * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * 31 * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * 0 * *")).toBeNull();
|
||||
expect(parseCronExpression("* * 32 * *")).toBeNull();
|
||||
});
|
||||
test('dayOfMonth: 1-31', () => {
|
||||
expect(parseCronExpression('* * 1 * *')).not.toBeNull()
|
||||
expect(parseCronExpression('* * 31 * *')).not.toBeNull()
|
||||
expect(parseCronExpression('* * 0 * *')).toBeNull()
|
||||
expect(parseCronExpression('* * 32 * *')).toBeNull()
|
||||
})
|
||||
|
||||
test("month: 1-12", () => {
|
||||
expect(parseCronExpression("* * * 1 *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * 12 *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * 0 *")).toBeNull();
|
||||
expect(parseCronExpression("* * * 13 *")).toBeNull();
|
||||
});
|
||||
test('month: 1-12', () => {
|
||||
expect(parseCronExpression('* * * 1 *')).not.toBeNull()
|
||||
expect(parseCronExpression('* * * 12 *')).not.toBeNull()
|
||||
expect(parseCronExpression('* * * 0 *')).toBeNull()
|
||||
expect(parseCronExpression('* * * 13 *')).toBeNull()
|
||||
})
|
||||
|
||||
test("dayOfWeek: 0-6 (plus 7 alias)", () => {
|
||||
expect(parseCronExpression("* * * * 0")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * * 6")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * * 7")).not.toBeNull(); // alias for 0
|
||||
expect(parseCronExpression("* * * * 8")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
test('dayOfWeek: 0-6 (plus 7 alias)', () => {
|
||||
expect(parseCronExpression('* * * * 0')).not.toBeNull()
|
||||
expect(parseCronExpression('* * * * 6')).not.toBeNull()
|
||||
expect(parseCronExpression('* * * * 7')).not.toBeNull() // alias for 0
|
||||
expect(parseCronExpression('* * * * 8')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("computeNextCronRun", () => {
|
||||
test("finds next minute", () => {
|
||||
const fields = parseCronExpression("31 14 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30, 45); // 14:30:45
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(14);
|
||||
expect(next!.getMinutes()).toBe(31);
|
||||
});
|
||||
describe('computeNextCronRun', () => {
|
||||
test('finds next minute', () => {
|
||||
const fields = parseCronExpression('31 14 * * *')!
|
||||
const from = new Date(2026, 0, 15, 14, 30, 45) // 14:30:45
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getHours()).toBe(14)
|
||||
expect(next!.getMinutes()).toBe(31)
|
||||
})
|
||||
|
||||
test("finds next hour", () => {
|
||||
const fields = parseCronExpression("0 15 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(15);
|
||||
expect(next!.getMinutes()).toBe(0);
|
||||
});
|
||||
test('finds next hour', () => {
|
||||
const fields = parseCronExpression('0 15 * * *')!
|
||||
const from = new Date(2026, 0, 15, 14, 30)
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getHours()).toBe(15)
|
||||
expect(next!.getMinutes()).toBe(0)
|
||||
})
|
||||
|
||||
test("rolls to next day", () => {
|
||||
const fields = parseCronExpression("0 10 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getDate()).toBe(16);
|
||||
expect(next!.getHours()).toBe(10);
|
||||
});
|
||||
test('rolls to next day', () => {
|
||||
const fields = parseCronExpression('0 10 * * *')!
|
||||
const from = new Date(2026, 0, 15, 14, 30)
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getDate()).toBe(16)
|
||||
expect(next!.getHours()).toBe(10)
|
||||
})
|
||||
|
||||
test("is strictly after from date", () => {
|
||||
const fields = parseCronExpression("30 14 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30, 0); // exactly on cron time
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getTime()).toBeGreaterThan(from.getTime());
|
||||
});
|
||||
test('is strictly after from date', () => {
|
||||
const fields = parseCronExpression('30 14 * * *')!
|
||||
const from = new Date(2026, 0, 15, 14, 30, 0) // exactly on cron time
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getTime()).toBeGreaterThan(from.getTime())
|
||||
})
|
||||
|
||||
test("every 5 minutes from arbitrary time", () => {
|
||||
const fields = parseCronExpression("*/5 * * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 32);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getMinutes()).toBe(35);
|
||||
});
|
||||
test('every 5 minutes from arbitrary time', () => {
|
||||
const fields = parseCronExpression('*/5 * * * *')!
|
||||
const from = new Date(2026, 0, 15, 14, 32)
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getMinutes()).toBe(35)
|
||||
})
|
||||
|
||||
test("every minute", () => {
|
||||
const fields = parseCronExpression("* * * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 32, 45);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getMinutes()).toBe(33);
|
||||
});
|
||||
test('every minute', () => {
|
||||
const fields = parseCronExpression('* * * * *')!
|
||||
const from = new Date(2026, 0, 15, 14, 32, 45)
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getMinutes()).toBe(33)
|
||||
})
|
||||
|
||||
test("handles step across midnight", () => {
|
||||
const fields = parseCronExpression("0 0 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 23, 59);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(0);
|
||||
expect(next!.getDate()).toBe(16);
|
||||
});
|
||||
test('handles step across midnight', () => {
|
||||
const fields = parseCronExpression('0 0 * * *')!
|
||||
const from = new Date(2026, 0, 15, 23, 59)
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!.getHours()).toBe(0)
|
||||
expect(next!.getDate()).toBe(16)
|
||||
})
|
||||
|
||||
test("OR semantics when both dom and dow constrained", () => {
|
||||
test('OR semantics when both dom and dow constrained', () => {
|
||||
// dom=15, dow=3(Wed) - matches 15th OR Wednesday
|
||||
const fields = parseCronExpression("0 0 15 * 3")!;
|
||||
const from = new Date(2026, 0, 12, 0, 0); // Monday Jan 12
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
const fields = parseCronExpression('0 0 15 * 3')!
|
||||
const from = new Date(2026, 0, 12, 0, 0) // Monday Jan 12
|
||||
const next = computeNextCronRun(fields, from)
|
||||
expect(next).not.toBeNull()
|
||||
// Should match the first of either: next Wednesday(Jan 14) or 15th(Jan 15)
|
||||
const dayOfWeek = next!.getDay();
|
||||
const dayOfMonth = next!.getDate();
|
||||
expect(dayOfWeek === 3 || dayOfMonth === 15).toBe(true);
|
||||
});
|
||||
});
|
||||
const dayOfWeek = next!.getDay()
|
||||
const dayOfMonth = next!.getDate()
|
||||
expect(dayOfWeek === 3 || dayOfMonth === 15).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cronToHuman", () => {
|
||||
test("every N minutes", () => {
|
||||
expect(cronToHuman("*/5 * * * *")).toBe("Every 5 minutes");
|
||||
});
|
||||
describe('cronToHuman', () => {
|
||||
test('every N minutes', () => {
|
||||
expect(cronToHuman('*/5 * * * *')).toBe('Every 5 minutes')
|
||||
})
|
||||
|
||||
test("every minute", () => {
|
||||
expect(cronToHuman("*/1 * * * *")).toBe("Every minute");
|
||||
});
|
||||
test('every minute', () => {
|
||||
expect(cronToHuman('*/1 * * * *')).toBe('Every minute')
|
||||
})
|
||||
|
||||
test("every hour at :00", () => {
|
||||
expect(cronToHuman("0 * * * *")).toBe("Every hour");
|
||||
});
|
||||
test('every hour at :00', () => {
|
||||
expect(cronToHuman('0 * * * *')).toBe('Every hour')
|
||||
})
|
||||
|
||||
test("every hour at :30", () => {
|
||||
expect(cronToHuman("30 * * * *")).toBe("Every hour at :30");
|
||||
});
|
||||
test('every hour at :30', () => {
|
||||
expect(cronToHuman('30 * * * *')).toBe('Every hour at :30')
|
||||
})
|
||||
|
||||
test("every N hours", () => {
|
||||
expect(cronToHuman("0 */2 * * *")).toBe("Every 2 hours");
|
||||
});
|
||||
test('every N hours', () => {
|
||||
expect(cronToHuman('0 */2 * * *')).toBe('Every 2 hours')
|
||||
})
|
||||
|
||||
test("daily at specific time", () => {
|
||||
const result = cronToHuman("30 9 * * *");
|
||||
expect(result).toContain("Every day at");
|
||||
expect(result).toContain("9:30");
|
||||
});
|
||||
test('daily at specific time', () => {
|
||||
const result = cronToHuman('30 9 * * *')
|
||||
expect(result).toContain('Every day at')
|
||||
expect(result).toContain('9:30')
|
||||
})
|
||||
|
||||
test("specific day of week", () => {
|
||||
const result = cronToHuman("0 9 * * 3");
|
||||
expect(result).toContain("Wednesday");
|
||||
expect(result).toContain("9:00");
|
||||
});
|
||||
test('specific day of week', () => {
|
||||
const result = cronToHuman('0 9 * * 3')
|
||||
expect(result).toContain('Wednesday')
|
||||
expect(result).toContain('9:00')
|
||||
})
|
||||
|
||||
test("weekdays", () => {
|
||||
const result = cronToHuman("0 9 * * 1-5");
|
||||
expect(result).toContain("Weekdays");
|
||||
expect(result).toContain("9:00");
|
||||
});
|
||||
test('weekdays', () => {
|
||||
const result = cronToHuman('0 9 * * 1-5')
|
||||
expect(result).toContain('Weekdays')
|
||||
expect(result).toContain('9:00')
|
||||
})
|
||||
|
||||
test("returns raw cron for complex patterns", () => {
|
||||
expect(cronToHuman("0,30 9-17 * * 1-5")).toBe("0,30 9-17 * * 1-5");
|
||||
});
|
||||
test('returns raw cron for complex patterns', () => {
|
||||
expect(cronToHuman('0,30 9-17 * * 1-5')).toBe('0,30 9-17 * * 1-5')
|
||||
})
|
||||
|
||||
test("returns raw cron for wrong field count", () => {
|
||||
expect(cronToHuman("* * *")).toBe("* * *");
|
||||
});
|
||||
});
|
||||
test('returns raw cron for wrong field count', () => {
|
||||
expect(cronToHuman('* * *')).toBe('* * *')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,13 @@ describe('cronScheduler baseline helpers', () => {
|
||||
test('isRecurringTaskAged returns false when maxAgeMs is zero', () => {
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
|
||||
{
|
||||
id: 'a',
|
||||
cron: '* * * * *',
|
||||
prompt: 'x',
|
||||
createdAt: 0,
|
||||
recurring: true,
|
||||
},
|
||||
10_000,
|
||||
0,
|
||||
),
|
||||
@@ -41,7 +47,13 @@ describe('cronScheduler baseline helpers', () => {
|
||||
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'c', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
|
||||
{
|
||||
id: 'c',
|
||||
cron: '* * * * *',
|
||||
prompt: 'x',
|
||||
createdAt: 0,
|
||||
recurring: true,
|
||||
},
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
|
||||
@@ -41,7 +41,12 @@ afterEach(async () => {
|
||||
|
||||
describe('cronTasks baseline', () => {
|
||||
test('session-only cron tasks remain in memory and do not create the cron file', async () => {
|
||||
const id = await addCronTask('* * * * *', 'session-only prompt', true, false)
|
||||
const id = await addCronTask(
|
||||
'* * * * *',
|
||||
'session-only prompt',
|
||||
true,
|
||||
false,
|
||||
)
|
||||
|
||||
const tasks = await listAllCronTasks()
|
||||
|
||||
@@ -107,7 +112,12 @@ describe('cronTasks baseline', () => {
|
||||
|
||||
test('daemon-style listAllCronTasks(dir) excludes session-only tasks', async () => {
|
||||
await addCronTask('* * * * *', 'session prompt', true, false)
|
||||
const durableId = await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
const durableId = await addCronTask(
|
||||
'* * * * *',
|
||||
'durable prompt',
|
||||
true,
|
||||
true,
|
||||
)
|
||||
|
||||
const sessionView = await listAllCronTasks()
|
||||
const daemonView = await listAllCronTasks(tempDir)
|
||||
@@ -130,7 +140,12 @@ describe('cronTasks baseline', () => {
|
||||
})
|
||||
|
||||
test('removeCronTasks with dir does not mutate session-only task storage', async () => {
|
||||
const sessionId = await addCronTask('* * * * *', 'keep session task', true, false)
|
||||
const sessionId = await addCronTask(
|
||||
'* * * * *',
|
||||
'keep session task',
|
||||
true,
|
||||
false,
|
||||
)
|
||||
await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
await removeCronTasks([sessionId], tempDir)
|
||||
@@ -194,7 +209,11 @@ describe('cronTasks baseline', () => {
|
||||
test('jitteredNextCronRunMs returns the exact next fire time when no second match exists in range', () => {
|
||||
const fromMs = new Date('2026-04-12T10:00:00').getTime()
|
||||
const exact = nextCronRunMs('0 0 29 2 *', fromMs)
|
||||
const jittered = oneShotJitteredNextCronRunMs('0 0 29 2 *', fromMs, '89abcdef')
|
||||
const jittered = oneShotJitteredNextCronRunMs(
|
||||
'0 0 29 2 *',
|
||||
fromMs,
|
||||
'89abcdef',
|
||||
)
|
||||
|
||||
expect(exact).not.toBeNull()
|
||||
expect(jittered).not.toBeNull()
|
||||
|
||||
@@ -1,108 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitRemote, parseGitHubRepository } from "../detectRepository";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseGitRemote, parseGitHubRepository } from '../detectRepository'
|
||||
|
||||
describe("parseGitRemote", () => {
|
||||
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: 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 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");
|
||||
});
|
||||
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: 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" });
|
||||
});
|
||||
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" });
|
||||
});
|
||||
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" });
|
||||
});
|
||||
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 invalid URL', () => {
|
||||
expect(parseGitRemote('not-a-url')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseGitRemote("")).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 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('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('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" });
|
||||
});
|
||||
});
|
||||
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", () => {
|
||||
describe('parseGitHubRepository', () => {
|
||||
test("extracts 'owner/repo' from valid remote URL", () => {
|
||||
expect(parseGitHubRepository("https://github.com/owner/repo.git")).toBe("owner/repo");
|
||||
});
|
||||
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");
|
||||
});
|
||||
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 non-GitHub host', () => {
|
||||
expect(
|
||||
parseGitHubRepository('https://gitlab.com/owner/repo.git'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for invalid input", () => {
|
||||
expect(parseGitHubRepository("not-valid")).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('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('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('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");
|
||||
});
|
||||
});
|
||||
test('handles plain owner/repo with .git suffix', () => {
|
||||
expect(parseGitHubRepository('owner/repo.git')).toBe('owner/repo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,117 +1,130 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adjustHunkLineNumbers, getPatchFromContents } from "../diff";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { adjustHunkLineNumbers, getPatchFromContents } from '../diff'
|
||||
|
||||
describe("adjustHunkLineNumbers", () => {
|
||||
test("shifts hunk line numbers by offset", () => {
|
||||
describe('adjustHunkLineNumbers', () => {
|
||||
test('shifts hunk line numbers by offset', () => {
|
||||
const hunks = [
|
||||
{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [" a", "-b", "+c", "+d", " e"] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, 10);
|
||||
expect(result[0].oldStart).toBe(11);
|
||||
expect(result[0].newStart).toBe(11);
|
||||
});
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 3,
|
||||
newStart: 1,
|
||||
newLines: 4,
|
||||
lines: [' a', '-b', '+c', '+d', ' e'],
|
||||
},
|
||||
] as any[]
|
||||
const result = adjustHunkLineNumbers(hunks, 10)
|
||||
expect(result[0].oldStart).toBe(11)
|
||||
expect(result[0].newStart).toBe(11)
|
||||
})
|
||||
|
||||
test("returns original hunks for zero offset", () => {
|
||||
test('returns original hunks for zero offset', () => {
|
||||
const hunks = [
|
||||
{ oldStart: 5, oldLines: 2, newStart: 5, newLines: 2, lines: [] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, 0);
|
||||
expect(result).toBe(hunks); // same reference
|
||||
});
|
||||
] as any[]
|
||||
const result = adjustHunkLineNumbers(hunks, 0)
|
||||
expect(result).toBe(hunks) // same reference
|
||||
})
|
||||
|
||||
test("handles negative offset", () => {
|
||||
test('handles negative offset', () => {
|
||||
const hunks = [
|
||||
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, -5);
|
||||
expect(result[0].oldStart).toBe(5);
|
||||
expect(result[0].newStart).toBe(5);
|
||||
});
|
||||
] as any[]
|
||||
const result = adjustHunkLineNumbers(hunks, -5)
|
||||
expect(result[0].oldStart).toBe(5)
|
||||
expect(result[0].newStart).toBe(5)
|
||||
})
|
||||
|
||||
test("handles empty hunks array", () => {
|
||||
expect(adjustHunkLineNumbers([], 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('handles empty hunks array', () => {
|
||||
expect(adjustHunkLineNumbers([], 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPatchFromContents", () => {
|
||||
test("returns hunks for different content", () => {
|
||||
describe('getPatchFromContents', () => {
|
||||
test('returns hunks for different content', () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "hello\nworld",
|
||||
newContent: "hello\nplanet",
|
||||
});
|
||||
expect(hunks.length).toBe(1);
|
||||
const allLines = hunks[0].lines;
|
||||
expect(allLines).toContain("-world");
|
||||
expect(allLines).toContain("+planet");
|
||||
});
|
||||
filePath: 'test.txt',
|
||||
oldContent: 'hello\nworld',
|
||||
newContent: 'hello\nplanet',
|
||||
})
|
||||
expect(hunks.length).toBe(1)
|
||||
const allLines = hunks[0].lines
|
||||
expect(allLines).toContain('-world')
|
||||
expect(allLines).toContain('+planet')
|
||||
})
|
||||
|
||||
test("returns empty hunks for identical content", () => {
|
||||
test('returns empty hunks for identical content', () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "same content",
|
||||
newContent: "same content",
|
||||
});
|
||||
expect(hunks).toEqual([]);
|
||||
});
|
||||
filePath: 'test.txt',
|
||||
oldContent: 'same content',
|
||||
newContent: 'same content',
|
||||
})
|
||||
expect(hunks).toEqual([])
|
||||
})
|
||||
|
||||
test("handles content with ampersands", () => {
|
||||
test('handles content with ampersands', () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "a & b",
|
||||
newContent: "a & c",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
filePath: 'test.txt',
|
||||
oldContent: 'a & b',
|
||||
newContent: 'a & c',
|
||||
})
|
||||
expect(hunks.length).toBeGreaterThan(0)
|
||||
// Verify ampersands are unescaped in the output
|
||||
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||
expect(allLines.some((l: string) => l.includes("&"))).toBe(true);
|
||||
});
|
||||
const allLines = hunks.flatMap((h: any) => h.lines)
|
||||
expect(allLines.some((l: string) => l.includes('&'))).toBe(true)
|
||||
})
|
||||
|
||||
test("handles empty old content (new file)", () => {
|
||||
test('handles empty old content (new file)', () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "",
|
||||
newContent: "new content",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||
expect(allLines.some((l: string) => l.startsWith("+"))).toBe(true);
|
||||
});
|
||||
filePath: 'test.txt',
|
||||
oldContent: '',
|
||||
newContent: 'new content',
|
||||
})
|
||||
expect(hunks.length).toBeGreaterThan(0)
|
||||
const allLines = hunks.flatMap((h: any) => h.lines)
|
||||
expect(allLines.some((l: string) => l.startsWith('+'))).toBe(true)
|
||||
})
|
||||
|
||||
test("handles content with dollar signs", () => {
|
||||
test('handles content with dollar signs', () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "price: $5",
|
||||
newContent: "price: $10",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||
expect(allLines.some((l: string) => l.includes("$"))).toBe(true);
|
||||
filePath: 'test.txt',
|
||||
oldContent: 'price: $5',
|
||||
newContent: 'price: $10',
|
||||
})
|
||||
expect(hunks.length).toBeGreaterThan(0)
|
||||
const allLines = hunks.flatMap((h: any) => h.lines)
|
||||
expect(allLines.some((l: string) => l.includes('$'))).toBe(true)
|
||||
// Verify dollar signs are unescaped (not the token)
|
||||
expect(allLines.some((l: string) => l.includes("<<:DOLLAR_TOKEN:>>"))).toBe(false);
|
||||
});
|
||||
expect(allLines.some((l: string) => l.includes('<<:DOLLAR_TOKEN:>>'))).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("handles deleting all content", () => {
|
||||
test('handles deleting all content', () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "line1\nline2\nline3",
|
||||
newContent: "",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||
expect(allLines.some((l: string) => l.startsWith("-"))).toBe(true);
|
||||
expect(allLines.every((l: string) => l.startsWith("-") || l.startsWith(" ") || l.startsWith("\\"))).toBe(true);
|
||||
});
|
||||
filePath: 'test.txt',
|
||||
oldContent: 'line1\nline2\nline3',
|
||||
newContent: '',
|
||||
})
|
||||
expect(hunks.length).toBeGreaterThan(0)
|
||||
const allLines = hunks.flatMap((h: any) => h.lines)
|
||||
expect(allLines.some((l: string) => l.startsWith('-'))).toBe(true)
|
||||
expect(
|
||||
allLines.every(
|
||||
(l: string) =>
|
||||
l.startsWith('-') || l.startsWith(' ') || l.startsWith('\\'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("ignoreWhitespace treats indentation changes as identical", () => {
|
||||
const old = "function foo() {\n return 42;\n}\n";
|
||||
const nw = "function foo() {\n\treturn 42;\n}\n";
|
||||
test('ignoreWhitespace treats indentation changes as identical', () => {
|
||||
const old = 'function foo() {\n return 42;\n}\n'
|
||||
const nw = 'function foo() {\n\treturn 42;\n}\n'
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
filePath: 'test.txt',
|
||||
oldContent: old,
|
||||
newContent: nw,
|
||||
ignoreWhitespace: true,
|
||||
});
|
||||
expect(hunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(hunks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,110 +1,119 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseDirectMemberMessage, sendDirectMemberMessage } from "../directMemberMessage";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
parseDirectMemberMessage,
|
||||
sendDirectMemberMessage,
|
||||
} from '../directMemberMessage'
|
||||
|
||||
describe("parseDirectMemberMessage", () => {
|
||||
describe('parseDirectMemberMessage', () => {
|
||||
test("parses '@agent-name hello world'", () => {
|
||||
const result = parseDirectMemberMessage("@agent-name hello world");
|
||||
expect(result).toEqual({ recipientName: "agent-name", message: "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" });
|
||||
});
|
||||
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 non-matching input', () => {
|
||||
expect(parseDirectMemberMessage('hello world')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseDirectMemberMessage("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(parseDirectMemberMessage('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for '@name' without message", () => {
|
||||
expect(parseDirectMemberMessage("@name")).toBeNull();
|
||||
});
|
||||
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" });
|
||||
});
|
||||
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('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('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");
|
||||
});
|
||||
});
|
||||
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" });
|
||||
});
|
||||
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 () => {
|
||||
test('returns error for unknown recipient', async () => {
|
||||
const teamContext = {
|
||||
teamName: "team1",
|
||||
teammates: { alice: { name: "alice" } },
|
||||
};
|
||||
teamName: 'team1',
|
||||
teammates: { alice: { name: 'alice' } },
|
||||
}
|
||||
const result = await sendDirectMemberMessage(
|
||||
"bob",
|
||||
"hello",
|
||||
'bob',
|
||||
'hello',
|
||||
teamContext as any,
|
||||
async () => {},
|
||||
);
|
||||
)
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: "unknown_recipient",
|
||||
recipientName: "bob",
|
||||
});
|
||||
});
|
||||
error: 'unknown_recipient',
|
||||
recipientName: 'bob',
|
||||
})
|
||||
})
|
||||
|
||||
test("calls writeToMailbox with correct args for valid recipient", async () => {
|
||||
let mailboxArgs: any = null;
|
||||
test('calls writeToMailbox with correct args for valid recipient', async () => {
|
||||
let mailboxArgs: any = null
|
||||
const teamContext = {
|
||||
teamName: "team1",
|
||||
teammates: { alice: { name: "alice" } },
|
||||
};
|
||||
teamName: 'team1',
|
||||
teammates: { alice: { name: 'alice' } },
|
||||
}
|
||||
const result = await sendDirectMemberMessage(
|
||||
"alice",
|
||||
"hello",
|
||||
'alice',
|
||||
'hello',
|
||||
teamContext as any,
|
||||
async (recipient, msg, team) => {
|
||||
mailboxArgs = { 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");
|
||||
});
|
||||
)
|
||||
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 () => {
|
||||
test('returns success for valid message', async () => {
|
||||
const teamContext = {
|
||||
teamName: "team1",
|
||||
teammates: { bob: { name: "bob" } },
|
||||
};
|
||||
teamName: 'team1',
|
||||
teammates: { bob: { name: 'bob' } },
|
||||
}
|
||||
const result = await sendDirectMemberMessage(
|
||||
"bob",
|
||||
"test message",
|
||||
'bob',
|
||||
'test message',
|
||||
teamContext as any,
|
||||
async () => {},
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
)
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.recipientName).toBe("bob");
|
||||
expect(result.recipientName).toBe('bob')
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,134 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
stripDisplayTags,
|
||||
stripDisplayTagsAllowEmpty,
|
||||
stripIdeContextTags,
|
||||
} from "../displayTags";
|
||||
} from '../displayTags'
|
||||
|
||||
describe("stripDisplayTags", () => {
|
||||
test("strips a single system tag and returns remaining text", () => {
|
||||
describe('stripDisplayTags', () => {
|
||||
test('strips a single system tag and returns remaining text', () => {
|
||||
expect(
|
||||
stripDisplayTags("<system-reminder>secret stuff</system-reminder>text")
|
||||
).toBe("text");
|
||||
});
|
||||
stripDisplayTags('<system-reminder>secret stuff</system-reminder>text'),
|
||||
).toBe('text')
|
||||
})
|
||||
|
||||
test("strips multiple tags and preserves text between them", () => {
|
||||
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");
|
||||
});
|
||||
'<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 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 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('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('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('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('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('returns trimmed result', () => {
|
||||
expect(stripDisplayTags(' <tag>content</tag> hello ')).toBe('hello')
|
||||
})
|
||||
|
||||
test("handles empty string input", () => {
|
||||
test('handles empty string input', () => {
|
||||
// Empty string is falsy, so stripDisplayTags returns original
|
||||
expect(stripDisplayTags("")).toBe("");
|
||||
});
|
||||
expect(stripDisplayTags('')).toBe('')
|
||||
})
|
||||
|
||||
test("handles whitespace-only input", () => {
|
||||
test('handles whitespace-only input', () => {
|
||||
// After trim, result is empty string which is falsy, returns original
|
||||
expect(stripDisplayTags(" ")).toBe(" ");
|
||||
});
|
||||
});
|
||||
expect(stripDisplayTags(' ')).toBe(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe("stripDisplayTagsAllowEmpty", () => {
|
||||
test("returns empty string when all content is tags", () => {
|
||||
describe('stripDisplayTagsAllowEmpty', () => {
|
||||
test('returns empty string when all content is tags', () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<system-reminder>stuff</system-reminder>")
|
||||
).toBe("");
|
||||
});
|
||||
stripDisplayTagsAllowEmpty('<system-reminder>stuff</system-reminder>'),
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
test("strips tags and returns remaining text", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<tag>content</tag>hello")
|
||||
).toBe("hello");
|
||||
});
|
||||
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 empty input', () => {
|
||||
expect(stripDisplayTagsAllowEmpty('')).toBe('')
|
||||
})
|
||||
|
||||
test("returns empty string for whitespace-only content after strip", () => {
|
||||
expect(
|
||||
stripDisplayTagsAllowEmpty("<tag>content</tag> ")
|
||||
).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", () => {
|
||||
describe('stripIdeContextTags', () => {
|
||||
test('strips ide_opened_file tags', () => {
|
||||
expect(
|
||||
stripIdeContextTags(
|
||||
"<ide_opened_file>path/to/file.ts</ide_opened_file>hello"
|
||||
)
|
||||
).toBe("hello");
|
||||
});
|
||||
'<ide_opened_file>path/to/file.ts</ide_opened_file>hello',
|
||||
),
|
||||
).toBe('hello')
|
||||
})
|
||||
|
||||
test("strips ide_selection tags", () => {
|
||||
test('strips ide_selection tags', () => {
|
||||
expect(
|
||||
stripIdeContextTags("<ide_selection>selected code</ide_selection>world")
|
||||
).toBe("world");
|
||||
});
|
||||
stripIdeContextTags('<ide_selection>selected code</ide_selection>world'),
|
||||
).toBe('world')
|
||||
})
|
||||
|
||||
test("strips ide tags with attributes", () => {
|
||||
test('strips ide tags with attributes', () => {
|
||||
expect(
|
||||
stripIdeContextTags(
|
||||
'<ide_opened_file path="foo.ts">content</ide_opened_file>text'
|
||||
)
|
||||
).toBe("text");
|
||||
});
|
||||
'<ide_opened_file path="foo.ts">content</ide_opened_file>text',
|
||||
),
|
||||
).toBe('text')
|
||||
})
|
||||
|
||||
test("preserves other lowercase tags", () => {
|
||||
test('preserves other lowercase tags', () => {
|
||||
expect(
|
||||
stripIdeContextTags("<system-reminder>data</system-reminder>hello")
|
||||
).toBe("<system-reminder>data</system-reminder>hello");
|
||||
});
|
||||
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('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", () => {
|
||||
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";
|
||||
'<ide_opened_file>f.ts</ide_opened_file><system-reminder>x</system-reminder>text'
|
||||
expect(stripIdeContextTags(input)).toBe(
|
||||
"<system-reminder>x</system-reminder>text"
|
||||
);
|
||||
});
|
||||
});
|
||||
'<system-reminder>x</system-reminder>text',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,8 @@ mock.module('src/utils/auth.js', () => ({
|
||||
isTeamSubscriber: () => false,
|
||||
}))
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, defaultValue: unknown) => defaultValue ?? {},
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: (_key: string, defaultValue: unknown) =>
|
||||
defaultValue ?? {},
|
||||
}))
|
||||
mock.module('src/utils/model/modelSupportOverrides.js', () => ({
|
||||
get3PModelCapabilityOverride: () => undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import {
|
||||
isEnvTruthy,
|
||||
isEnvDefinedFalsy,
|
||||
@@ -10,324 +10,333 @@ import {
|
||||
isBareMode,
|
||||
shouldMaintainProjectWorkingDir,
|
||||
getClaudeConfigHomeDir,
|
||||
} from "../envUtils";
|
||||
} from '../envUtils'
|
||||
|
||||
// ─── isEnvTruthy ───────────────────────────────────────────────────────
|
||||
|
||||
describe("isEnvTruthy", () => {
|
||||
describe('isEnvTruthy', () => {
|
||||
test("returns true for '1'", () => {
|
||||
expect(isEnvTruthy("1")).toBe(true);
|
||||
});
|
||||
expect(isEnvTruthy('1')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'true'", () => {
|
||||
expect(isEnvTruthy("true")).toBe(true);
|
||||
});
|
||||
expect(isEnvTruthy('true')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'TRUE'", () => {
|
||||
expect(isEnvTruthy("TRUE")).toBe(true);
|
||||
});
|
||||
expect(isEnvTruthy('TRUE')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'yes'", () => {
|
||||
expect(isEnvTruthy("yes")).toBe(true);
|
||||
});
|
||||
expect(isEnvTruthy('yes')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'on'", () => {
|
||||
expect(isEnvTruthy("on")).toBe(true);
|
||||
});
|
||||
expect(isEnvTruthy('on')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for boolean true", () => {
|
||||
expect(isEnvTruthy(true)).toBe(true);
|
||||
});
|
||||
test('returns true for boolean true', () => {
|
||||
expect(isEnvTruthy(true)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for '0'", () => {
|
||||
expect(isEnvTruthy("0")).toBe(false);
|
||||
});
|
||||
expect(isEnvTruthy('0')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for 'false'", () => {
|
||||
expect(isEnvTruthy("false")).toBe(false);
|
||||
});
|
||||
expect(isEnvTruthy('false')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isEnvTruthy("")).toBe(false);
|
||||
});
|
||||
test('returns false for empty string', () => {
|
||||
expect(isEnvTruthy('')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isEnvTruthy(undefined)).toBe(false);
|
||||
});
|
||||
test('returns false for undefined', () => {
|
||||
expect(isEnvTruthy(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for boolean false", () => {
|
||||
expect(isEnvTruthy(false)).toBe(false);
|
||||
});
|
||||
test('returns false for boolean false', () => {
|
||||
expect(isEnvTruthy(false)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for ' true ' (trimmed)", () => {
|
||||
expect(isEnvTruthy(" true ")).toBe(true);
|
||||
});
|
||||
});
|
||||
expect(isEnvTruthy(' true ')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isEnvDefinedFalsy ─────────────────────────────────────────────────
|
||||
|
||||
describe("isEnvDefinedFalsy", () => {
|
||||
describe('isEnvDefinedFalsy', () => {
|
||||
test("returns true for '0'", () => {
|
||||
expect(isEnvDefinedFalsy("0")).toBe(true);
|
||||
});
|
||||
expect(isEnvDefinedFalsy('0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'false'", () => {
|
||||
expect(isEnvDefinedFalsy("false")).toBe(true);
|
||||
});
|
||||
expect(isEnvDefinedFalsy('false')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'no'", () => {
|
||||
expect(isEnvDefinedFalsy("no")).toBe(true);
|
||||
});
|
||||
expect(isEnvDefinedFalsy('no')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for 'off'", () => {
|
||||
expect(isEnvDefinedFalsy("off")).toBe(true);
|
||||
});
|
||||
expect(isEnvDefinedFalsy('off')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for boolean false", () => {
|
||||
expect(isEnvDefinedFalsy(false)).toBe(true);
|
||||
});
|
||||
test('returns true for boolean false', () => {
|
||||
expect(isEnvDefinedFalsy(false)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for undefined", () => {
|
||||
expect(isEnvDefinedFalsy(undefined)).toBe(false);
|
||||
});
|
||||
test('returns false for undefined', () => {
|
||||
expect(isEnvDefinedFalsy(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for '1'", () => {
|
||||
expect(isEnvDefinedFalsy("1")).toBe(false);
|
||||
});
|
||||
expect(isEnvDefinedFalsy('1')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for 'true'", () => {
|
||||
expect(isEnvDefinedFalsy("true")).toBe(false);
|
||||
});
|
||||
expect(isEnvDefinedFalsy('true')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isEnvDefinedFalsy("")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for empty string', () => {
|
||||
expect(isEnvDefinedFalsy('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseEnvVars ──────────────────────────────────────────────────────
|
||||
|
||||
describe("parseEnvVars", () => {
|
||||
test("parses KEY=VALUE pairs", () => {
|
||||
const result = parseEnvVars(["FOO=bar", "BAZ=qux"]);
|
||||
expect(result).toEqual({ FOO: "bar", BAZ: "qux" });
|
||||
});
|
||||
describe('parseEnvVars', () => {
|
||||
test('parses KEY=VALUE pairs', () => {
|
||||
const result = parseEnvVars(['FOO=bar', 'BAZ=qux'])
|
||||
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' })
|
||||
})
|
||||
|
||||
test("handles value with equals sign", () => {
|
||||
const result = parseEnvVars(["URL=http://host?a=1&b=2"]);
|
||||
expect(result).toEqual({ URL: "http://host?a=1&b=2" });
|
||||
});
|
||||
test('handles value with equals sign', () => {
|
||||
const result = parseEnvVars(['URL=http://host?a=1&b=2'])
|
||||
expect(result).toEqual({ URL: 'http://host?a=1&b=2' })
|
||||
})
|
||||
|
||||
test("returns empty object for undefined", () => {
|
||||
expect(parseEnvVars(undefined)).toEqual({});
|
||||
});
|
||||
test('returns empty object for undefined', () => {
|
||||
expect(parseEnvVars(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
expect(parseEnvVars([])).toEqual({});
|
||||
});
|
||||
test('returns empty object for empty array', () => {
|
||||
expect(parseEnvVars([])).toEqual({})
|
||||
})
|
||||
|
||||
test("throws for missing value", () => {
|
||||
expect(() => parseEnvVars(["NOVALUE"])).toThrow("Invalid environment variable format");
|
||||
});
|
||||
test('throws for missing value', () => {
|
||||
expect(() => parseEnvVars(['NOVALUE'])).toThrow(
|
||||
'Invalid environment variable format',
|
||||
)
|
||||
})
|
||||
|
||||
test("throws for empty key", () => {
|
||||
expect(() => parseEnvVars(["=value"])).toThrow("Invalid environment variable format");
|
||||
});
|
||||
});
|
||||
test('throws for empty key', () => {
|
||||
expect(() => parseEnvVars(['=value'])).toThrow(
|
||||
'Invalid environment variable format',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── hasNodeOption ─────────────────────────────────────────────────────
|
||||
|
||||
describe("hasNodeOption", () => {
|
||||
const saved = process.env.NODE_OPTIONS;
|
||||
describe('hasNodeOption', () => {
|
||||
const saved = process.env.NODE_OPTIONS
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.NODE_OPTIONS;
|
||||
else process.env.NODE_OPTIONS = saved;
|
||||
});
|
||||
if (saved === undefined) delete process.env.NODE_OPTIONS
|
||||
else process.env.NODE_OPTIONS = saved
|
||||
})
|
||||
|
||||
test("returns true when flag present", () => {
|
||||
process.env.NODE_OPTIONS = "--max-old-space-size=4096 --inspect";
|
||||
expect(hasNodeOption("--inspect")).toBe(true);
|
||||
});
|
||||
test('returns true when flag present', () => {
|
||||
process.env.NODE_OPTIONS = '--max-old-space-size=4096 --inspect'
|
||||
expect(hasNodeOption('--inspect')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when flag absent", () => {
|
||||
process.env.NODE_OPTIONS = "--max-old-space-size=4096";
|
||||
expect(hasNodeOption("--inspect")).toBe(false);
|
||||
});
|
||||
test('returns false when flag absent', () => {
|
||||
process.env.NODE_OPTIONS = '--max-old-space-size=4096'
|
||||
expect(hasNodeOption('--inspect')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when NODE_OPTIONS not set", () => {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
expect(hasNodeOption("--inspect")).toBe(false);
|
||||
});
|
||||
test('returns false when NODE_OPTIONS not set', () => {
|
||||
delete process.env.NODE_OPTIONS
|
||||
expect(hasNodeOption('--inspect')).toBe(false)
|
||||
})
|
||||
|
||||
test("does not match partial flags", () => {
|
||||
process.env.NODE_OPTIONS = "--inspect-brk";
|
||||
expect(hasNodeOption("--inspect")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('does not match partial flags', () => {
|
||||
process.env.NODE_OPTIONS = '--inspect-brk'
|
||||
expect(hasNodeOption('--inspect')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getAWSRegion ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getAWSRegion", () => {
|
||||
const savedRegion = process.env.AWS_REGION;
|
||||
const savedDefault = process.env.AWS_DEFAULT_REGION;
|
||||
describe('getAWSRegion', () => {
|
||||
const savedRegion = process.env.AWS_REGION
|
||||
const savedDefault = process.env.AWS_DEFAULT_REGION
|
||||
|
||||
afterEach(() => {
|
||||
if (savedRegion === undefined) delete process.env.AWS_REGION;
|
||||
else process.env.AWS_REGION = savedRegion;
|
||||
if (savedDefault === undefined) delete process.env.AWS_DEFAULT_REGION;
|
||||
else process.env.AWS_DEFAULT_REGION = savedDefault;
|
||||
});
|
||||
if (savedRegion === undefined) delete process.env.AWS_REGION
|
||||
else process.env.AWS_REGION = savedRegion
|
||||
if (savedDefault === undefined) delete process.env.AWS_DEFAULT_REGION
|
||||
else process.env.AWS_DEFAULT_REGION = savedDefault
|
||||
})
|
||||
|
||||
test("uses AWS_REGION when set", () => {
|
||||
process.env.AWS_REGION = "eu-west-1";
|
||||
expect(getAWSRegion()).toBe("eu-west-1");
|
||||
});
|
||||
test('uses AWS_REGION when set', () => {
|
||||
process.env.AWS_REGION = 'eu-west-1'
|
||||
expect(getAWSRegion()).toBe('eu-west-1')
|
||||
})
|
||||
|
||||
test("falls back to AWS_DEFAULT_REGION", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
process.env.AWS_DEFAULT_REGION = "ap-northeast-1";
|
||||
expect(getAWSRegion()).toBe("ap-northeast-1");
|
||||
});
|
||||
test('falls back to AWS_DEFAULT_REGION', () => {
|
||||
delete process.env.AWS_REGION
|
||||
process.env.AWS_DEFAULT_REGION = 'ap-northeast-1'
|
||||
expect(getAWSRegion()).toBe('ap-northeast-1')
|
||||
})
|
||||
|
||||
test("defaults to us-east-1", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
expect(getAWSRegion()).toBe("us-east-1");
|
||||
});
|
||||
});
|
||||
test('defaults to us-east-1', () => {
|
||||
delete process.env.AWS_REGION
|
||||
delete process.env.AWS_DEFAULT_REGION
|
||||
expect(getAWSRegion()).toBe('us-east-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getDefaultVertexRegion ────────────────────────────────────────────
|
||||
|
||||
describe("getDefaultVertexRegion", () => {
|
||||
const saved = process.env.CLOUD_ML_REGION;
|
||||
describe('getDefaultVertexRegion', () => {
|
||||
const saved = process.env.CLOUD_ML_REGION
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLOUD_ML_REGION;
|
||||
else process.env.CLOUD_ML_REGION = saved;
|
||||
});
|
||||
if (saved === undefined) delete process.env.CLOUD_ML_REGION
|
||||
else process.env.CLOUD_ML_REGION = saved
|
||||
})
|
||||
|
||||
test("uses CLOUD_ML_REGION when set", () => {
|
||||
process.env.CLOUD_ML_REGION = "europe-west4";
|
||||
expect(getDefaultVertexRegion()).toBe("europe-west4");
|
||||
});
|
||||
test('uses CLOUD_ML_REGION when set', () => {
|
||||
process.env.CLOUD_ML_REGION = 'europe-west4'
|
||||
expect(getDefaultVertexRegion()).toBe('europe-west4')
|
||||
})
|
||||
|
||||
test("defaults to us-east5", () => {
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getDefaultVertexRegion()).toBe("us-east5");
|
||||
});
|
||||
});
|
||||
test('defaults to us-east5', () => {
|
||||
delete process.env.CLOUD_ML_REGION
|
||||
expect(getDefaultVertexRegion()).toBe('us-east5')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getVertexRegionForModel ───────────────────────────────────────────
|
||||
|
||||
describe("getVertexRegionForModel", () => {
|
||||
describe('getVertexRegionForModel', () => {
|
||||
const envKeys = [
|
||||
"VERTEX_REGION_CLAUDE_HAIKU_4_5",
|
||||
"VERTEX_REGION_CLAUDE_4_0_SONNET",
|
||||
"VERTEX_REGION_CLAUDE_4_6_SONNET",
|
||||
"CLOUD_ML_REGION",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
'VERTEX_REGION_CLAUDE_HAIKU_4_5',
|
||||
'VERTEX_REGION_CLAUDE_4_0_SONNET',
|
||||
'VERTEX_REGION_CLAUDE_4_6_SONNET',
|
||||
'CLOUD_ML_REGION',
|
||||
]
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of envKeys) saved[k] = process.env[k];
|
||||
});
|
||||
for (const k of envKeys) saved[k] = process.env[k]
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const k of envKeys) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
if (saved[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = saved[k]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("returns model-specific override when set", () => {
|
||||
process.env.VERTEX_REGION_CLAUDE_HAIKU_4_5 = "us-central1";
|
||||
expect(getVertexRegionForModel("claude-haiku-4-5-20251001")).toBe("us-central1");
|
||||
});
|
||||
test('returns model-specific override when set', () => {
|
||||
process.env.VERTEX_REGION_CLAUDE_HAIKU_4_5 = 'us-central1'
|
||||
expect(getVertexRegionForModel('claude-haiku-4-5-20251001')).toBe(
|
||||
'us-central1',
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back to default vertex region when override not set", () => {
|
||||
delete process.env.VERTEX_REGION_CLAUDE_4_0_SONNET;
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getVertexRegionForModel("claude-sonnet-4-some-variant")).toBe("us-east5");
|
||||
});
|
||||
test('falls back to default vertex region when override not set', () => {
|
||||
delete process.env.VERTEX_REGION_CLAUDE_4_0_SONNET
|
||||
delete process.env.CLOUD_ML_REGION
|
||||
expect(getVertexRegionForModel('claude-sonnet-4-some-variant')).toBe(
|
||||
'us-east5',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns default region for unknown model prefix", () => {
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getVertexRegionForModel("unknown-model-123")).toBe("us-east5");
|
||||
});
|
||||
test('returns default region for unknown model prefix', () => {
|
||||
delete process.env.CLOUD_ML_REGION
|
||||
expect(getVertexRegionForModel('unknown-model-123')).toBe('us-east5')
|
||||
})
|
||||
|
||||
test("returns default region for undefined model", () => {
|
||||
delete process.env.CLOUD_ML_REGION;
|
||||
expect(getVertexRegionForModel(undefined)).toBe("us-east5");
|
||||
});
|
||||
});
|
||||
test('returns default region for undefined model', () => {
|
||||
delete process.env.CLOUD_ML_REGION
|
||||
expect(getVertexRegionForModel(undefined)).toBe('us-east5')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isBareMode ────────────────────────────────────────────────────────
|
||||
|
||||
describe("isBareMode", () => {
|
||||
const saved = process.env.CLAUDE_CODE_SIMPLE;
|
||||
const originalArgv = [...process.argv];
|
||||
describe('isBareMode', () => {
|
||||
const saved = process.env.CLAUDE_CODE_SIMPLE
|
||||
const originalArgv = [...process.argv]
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLAUDE_CODE_SIMPLE;
|
||||
else process.env.CLAUDE_CODE_SIMPLE = saved;
|
||||
process.argv.length = 0;
|
||||
process.argv.push(...originalArgv);
|
||||
});
|
||||
if (saved === undefined) delete process.env.CLAUDE_CODE_SIMPLE
|
||||
else process.env.CLAUDE_CODE_SIMPLE = saved
|
||||
process.argv.length = 0
|
||||
process.argv.push(...originalArgv)
|
||||
})
|
||||
|
||||
test("returns true when CLAUDE_CODE_SIMPLE=1", () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = "1";
|
||||
expect(isBareMode()).toBe(true);
|
||||
});
|
||||
test('returns true when CLAUDE_CODE_SIMPLE=1', () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||
expect(isBareMode()).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true when --bare in argv", () => {
|
||||
process.argv.push("--bare");
|
||||
expect(isBareMode()).toBe(true);
|
||||
});
|
||||
test('returns true when --bare in argv', () => {
|
||||
process.argv.push('--bare')
|
||||
expect(isBareMode()).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when neither set", () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE;
|
||||
test('returns false when neither set', () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
// argv doesn't have --bare by default
|
||||
expect(isBareMode()).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(isBareMode()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── shouldMaintainProjectWorkingDir ───────────────────────────────────
|
||||
|
||||
describe("shouldMaintainProjectWorkingDir", () => {
|
||||
const saved = process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR;
|
||||
describe('shouldMaintainProjectWorkingDir', () => {
|
||||
const saved = process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR;
|
||||
else process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = saved;
|
||||
});
|
||||
if (saved === undefined)
|
||||
delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR
|
||||
else process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = saved
|
||||
})
|
||||
|
||||
test("returns true when set to truthy", () => {
|
||||
process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1";
|
||||
expect(shouldMaintainProjectWorkingDir()).toBe(true);
|
||||
});
|
||||
test('returns true when set to truthy', () => {
|
||||
process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = '1'
|
||||
expect(shouldMaintainProjectWorkingDir()).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when not set", () => {
|
||||
delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR;
|
||||
expect(shouldMaintainProjectWorkingDir()).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false when not set', () => {
|
||||
delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR
|
||||
expect(shouldMaintainProjectWorkingDir()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getClaudeConfigHomeDir ────────────────────────────────────────────
|
||||
|
||||
describe("getClaudeConfigHomeDir", () => {
|
||||
const saved = process.env.CLAUDE_CONFIG_DIR;
|
||||
describe('getClaudeConfigHomeDir', () => {
|
||||
const saved = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = saved;
|
||||
});
|
||||
if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR
|
||||
else process.env.CLAUDE_CONFIG_DIR = saved
|
||||
})
|
||||
|
||||
test("uses CLAUDE_CONFIG_DIR when set", () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = "/tmp/test-claude";
|
||||
test('uses CLAUDE_CONFIG_DIR when set', () => {
|
||||
process.env.CLAUDE_CONFIG_DIR = '/tmp/test-claude'
|
||||
// Memoized by CLAUDE_CONFIG_DIR key, so changing env gives fresh value
|
||||
expect(getClaudeConfigHomeDir()).toBe("/tmp/test-claude");
|
||||
});
|
||||
expect(getClaudeConfigHomeDir()).toBe('/tmp/test-claude')
|
||||
})
|
||||
|
||||
test("returns a string ending with .claude by default", () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
const result = getClaudeConfigHomeDir();
|
||||
expect(result).toMatch(/\.claude$/);
|
||||
});
|
||||
});
|
||||
test('returns a string ending with .claude by default', () => {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
const result = getClaudeConfigHomeDir()
|
||||
expect(result).toMatch(/\.claude$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../tests/mocks/debug";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
import { debugMock } from '../../../tests/mocks/debug'
|
||||
|
||||
// Mock debug.ts to cut bootstrap/state dependency chain
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
const { validateBoundedIntEnvVar } = await import("../envValidation");
|
||||
const { validateBoundedIntEnvVar } = await import('../envValidation')
|
||||
|
||||
describe("validateBoundedIntEnvVar", () => {
|
||||
test("returns default when value is undefined", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", undefined, 100, 1000);
|
||||
expect(result).toEqual({ effective: 100, status: "valid" });
|
||||
});
|
||||
describe('validateBoundedIntEnvVar', () => {
|
||||
test('returns default when value is undefined', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', undefined, 100, 1000)
|
||||
expect(result).toEqual({ effective: 100, status: 'valid' })
|
||||
})
|
||||
|
||||
test("returns default when value is empty string", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "", 100, 1000);
|
||||
expect(result).toEqual({ effective: 100, status: "valid" });
|
||||
});
|
||||
test('returns default when value is empty string', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '', 100, 1000)
|
||||
expect(result).toEqual({ effective: 100, status: 'valid' })
|
||||
})
|
||||
|
||||
test("returns parsed value when valid and within limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "500", 100, 1000);
|
||||
expect(result).toEqual({ effective: 500, status: "valid" });
|
||||
});
|
||||
test('returns parsed value when valid and within limit', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '500', 100, 1000)
|
||||
expect(result).toEqual({ effective: 500, status: 'valid' })
|
||||
})
|
||||
|
||||
test("caps value at upper limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "2000", 100, 1000);
|
||||
expect(result.effective).toBe(1000);
|
||||
expect(result.status).toBe("capped");
|
||||
expect(result.message).toBe("Capped from 2000 to 1000");
|
||||
});
|
||||
test('caps value at upper limit', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '2000', 100, 1000)
|
||||
expect(result.effective).toBe(1000)
|
||||
expect(result.status).toBe('capped')
|
||||
expect(result.message).toBe('Capped from 2000 to 1000')
|
||||
})
|
||||
|
||||
test("returns default for non-numeric value", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "abc", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
expect(result.message).toBe('Invalid value "abc" (using default: 100)');
|
||||
});
|
||||
test('returns default for non-numeric value', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', 'abc', 100, 1000)
|
||||
expect(result.effective).toBe(100)
|
||||
expect(result.status).toBe('invalid')
|
||||
expect(result.message).toBe('Invalid value "abc" (using default: 100)')
|
||||
})
|
||||
|
||||
test("returns default for zero", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "0", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
});
|
||||
test('returns default for zero', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '0', 100, 1000)
|
||||
expect(result.effective).toBe(100)
|
||||
expect(result.status).toBe('invalid')
|
||||
})
|
||||
|
||||
test("returns default for negative value", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "-5", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
});
|
||||
test('returns default for negative value', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '-5', 100, 1000)
|
||||
expect(result.effective).toBe(100)
|
||||
expect(result.status).toBe('invalid')
|
||||
})
|
||||
|
||||
test("handles value at exact upper limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1000", 100, 1000);
|
||||
expect(result.effective).toBe(1000);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
test('handles value at exact upper limit', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '1000', 100, 1000)
|
||||
expect(result.effective).toBe(1000)
|
||||
expect(result.status).toBe('valid')
|
||||
})
|
||||
|
||||
test("handles value of 1 (no lower bound check, only parsed > 0)", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000);
|
||||
expect(result.effective).toBe(1);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
test('handles value of 1 (no lower bound check, only parsed > 0)', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '1', 100, 1000)
|
||||
expect(result.effective).toBe(1)
|
||||
expect(result.status).toBe('valid')
|
||||
})
|
||||
|
||||
test("truncates float input via parseInt", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "50.7", 100, 1000);
|
||||
expect(result.effective).toBe(50);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
test('truncates float input via parseInt', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '50.7', 100, 1000)
|
||||
expect(result.effective).toBe(50)
|
||||
expect(result.status).toBe('valid')
|
||||
})
|
||||
|
||||
test("handles whitespace in value", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", " 500 ", 100, 1000);
|
||||
expect(result.effective).toBe(500);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
test('handles whitespace in value', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', ' 500 ', 100, 1000)
|
||||
expect(result.effective).toBe(500)
|
||||
expect(result.status).toBe('valid')
|
||||
})
|
||||
|
||||
test("value=1 with high defaultValue returns 1 (no lower bound enforcement)", () => {
|
||||
test('value=1 with high defaultValue returns 1 (no lower bound enforcement)', () => {
|
||||
// Function only checks parsed > 0 and parsed <= upperLimit
|
||||
// It does NOT enforce that parsed >= defaultValue
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000);
|
||||
expect(result.effective).toBe(1);
|
||||
expect(result.status).toBe("valid");
|
||||
});
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '1', 100, 1000)
|
||||
expect(result.effective).toBe(1)
|
||||
expect(result.status).toBe('valid')
|
||||
})
|
||||
|
||||
test("caps very large number at upper limit", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "999999999", 100, 1000);
|
||||
expect(result.effective).toBe(1000);
|
||||
expect(result.status).toBe("capped");
|
||||
});
|
||||
test('caps very large number at upper limit', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', '999999999', 100, 1000)
|
||||
expect(result.effective).toBe(1000)
|
||||
expect(result.status).toBe('capped')
|
||||
})
|
||||
|
||||
test("treats NaN-producing strings as invalid", () => {
|
||||
const result = validateBoundedIntEnvVar("TEST_VAR", "NaN", 100, 1000);
|
||||
expect(result.effective).toBe(100);
|
||||
expect(result.status).toBe("invalid");
|
||||
});
|
||||
});
|
||||
test('treats NaN-producing strings as invalid', () => {
|
||||
const result = validateBoundedIntEnvVar('TEST_VAR', 'NaN', 100, 1000)
|
||||
expect(result.effective).toBe(100)
|
||||
expect(result.status).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
AbortError,
|
||||
ClaudeError,
|
||||
@@ -16,274 +16,292 @@ import {
|
||||
shortErrorStack,
|
||||
isFsInaccessible,
|
||||
classifyAxiosError,
|
||||
} from "../errors";
|
||||
} from '../errors'
|
||||
|
||||
// ─── Error classes ──────────────────────────────────────────────────────
|
||||
|
||||
describe("ClaudeError", () => {
|
||||
test("sets name to constructor name", () => {
|
||||
const e = new ClaudeError("test");
|
||||
expect(e.name).toBe("ClaudeError");
|
||||
expect(e.message).toBe("test");
|
||||
});
|
||||
});
|
||||
describe('ClaudeError', () => {
|
||||
test('sets name to constructor name', () => {
|
||||
const e = new ClaudeError('test')
|
||||
expect(e.name).toBe('ClaudeError')
|
||||
expect(e.message).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
describe("AbortError", () => {
|
||||
test("sets name to AbortError", () => {
|
||||
const e = new AbortError("cancelled");
|
||||
expect(e.name).toBe("AbortError");
|
||||
});
|
||||
});
|
||||
describe('AbortError', () => {
|
||||
test('sets name to AbortError', () => {
|
||||
const e = new AbortError('cancelled')
|
||||
expect(e.name).toBe('AbortError')
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConfigParseError", () => {
|
||||
test("stores filePath and defaultConfig", () => {
|
||||
const e = new ConfigParseError("bad", "/tmp/cfg", { x: 1 });
|
||||
expect(e.filePath).toBe("/tmp/cfg");
|
||||
expect(e.defaultConfig).toEqual({ x: 1 });
|
||||
});
|
||||
});
|
||||
describe('ConfigParseError', () => {
|
||||
test('stores filePath and defaultConfig', () => {
|
||||
const e = new ConfigParseError('bad', '/tmp/cfg', { x: 1 })
|
||||
expect(e.filePath).toBe('/tmp/cfg')
|
||||
expect(e.defaultConfig).toEqual({ x: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ShellError", () => {
|
||||
test("stores stdout, stderr, code, interrupted", () => {
|
||||
const e = new ShellError("out", "err", 1, false);
|
||||
expect(e.stdout).toBe("out");
|
||||
expect(e.stderr).toBe("err");
|
||||
expect(e.code).toBe(1);
|
||||
expect(e.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
describe('ShellError', () => {
|
||||
test('stores stdout, stderr, code, interrupted', () => {
|
||||
const e = new ShellError('out', 'err', 1, false)
|
||||
expect(e.stdout).toBe('out')
|
||||
expect(e.stderr).toBe('err')
|
||||
expect(e.code).toBe(1)
|
||||
expect(e.interrupted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TelemetrySafeError", () => {
|
||||
test("uses message as telemetryMessage by default", () => {
|
||||
const e =
|
||||
new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS("msg");
|
||||
expect(e.telemetryMessage).toBe("msg");
|
||||
});
|
||||
describe('TelemetrySafeError', () => {
|
||||
test('uses message as telemetryMessage by default', () => {
|
||||
const e = new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
|
||||
'msg',
|
||||
)
|
||||
expect(e.telemetryMessage).toBe('msg')
|
||||
})
|
||||
|
||||
test("uses separate telemetryMessage when provided", () => {
|
||||
const e =
|
||||
new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
|
||||
"full msg",
|
||||
"safe msg"
|
||||
);
|
||||
expect(e.message).toBe("full msg");
|
||||
expect(e.telemetryMessage).toBe("safe msg");
|
||||
});
|
||||
});
|
||||
test('uses separate telemetryMessage when provided', () => {
|
||||
const e = new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(
|
||||
'full msg',
|
||||
'safe msg',
|
||||
)
|
||||
expect(e.message).toBe('full msg')
|
||||
expect(e.telemetryMessage).toBe('safe msg')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isAbortError ───────────────────────────────────────────────────────
|
||||
|
||||
describe("isAbortError", () => {
|
||||
test("returns true for AbortError instance", () => {
|
||||
expect(isAbortError(new AbortError())).toBe(true);
|
||||
});
|
||||
describe('isAbortError', () => {
|
||||
test('returns true for AbortError instance', () => {
|
||||
expect(isAbortError(new AbortError())).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for DOMException-style abort", () => {
|
||||
const e = new Error("aborted");
|
||||
e.name = "AbortError";
|
||||
expect(isAbortError(e)).toBe(true);
|
||||
});
|
||||
test('returns true for DOMException-style abort', () => {
|
||||
const e = new Error('aborted')
|
||||
e.name = 'AbortError'
|
||||
expect(isAbortError(e)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for regular error", () => {
|
||||
expect(isAbortError(new Error("nope"))).toBe(false);
|
||||
});
|
||||
test('returns false for regular error', () => {
|
||||
expect(isAbortError(new Error('nope'))).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-error", () => {
|
||||
expect(isAbortError("string")).toBe(false);
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-error', () => {
|
||||
expect(isAbortError('string')).toBe(false)
|
||||
expect(isAbortError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── hasExactErrorMessage ───────────────────────────────────────────────
|
||||
|
||||
describe("hasExactErrorMessage", () => {
|
||||
test("returns true for matching message", () => {
|
||||
expect(hasExactErrorMessage(new Error("test"), "test")).toBe(true);
|
||||
});
|
||||
describe('hasExactErrorMessage', () => {
|
||||
test('returns true for matching message', () => {
|
||||
expect(hasExactErrorMessage(new Error('test'), 'test')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for different message", () => {
|
||||
expect(hasExactErrorMessage(new Error("a"), "b")).toBe(false);
|
||||
});
|
||||
test('returns false for different message', () => {
|
||||
expect(hasExactErrorMessage(new Error('a'), 'b')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-Error", () => {
|
||||
expect(hasExactErrorMessage("string", "string")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-Error', () => {
|
||||
expect(hasExactErrorMessage('string', 'string')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── toError ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("toError", () => {
|
||||
test("returns Error as-is", () => {
|
||||
const e = new Error("test");
|
||||
expect(toError(e)).toBe(e);
|
||||
});
|
||||
describe('toError', () => {
|
||||
test('returns Error as-is', () => {
|
||||
const e = new Error('test')
|
||||
expect(toError(e)).toBe(e)
|
||||
})
|
||||
|
||||
test("wraps string in Error", () => {
|
||||
const e = toError("oops");
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
expect(e.message).toBe("oops");
|
||||
});
|
||||
test('wraps string in Error', () => {
|
||||
const e = toError('oops')
|
||||
expect(e).toBeInstanceOf(Error)
|
||||
expect(e.message).toBe('oops')
|
||||
})
|
||||
|
||||
test("wraps number in Error", () => {
|
||||
expect(toError(42).message).toBe("42");
|
||||
});
|
||||
});
|
||||
test('wraps number in Error', () => {
|
||||
expect(toError(42).message).toBe('42')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── errorMessage ───────────────────────────────────────────────────────
|
||||
|
||||
describe("errorMessage", () => {
|
||||
test("extracts message from Error", () => {
|
||||
expect(errorMessage(new Error("hello"))).toBe("hello");
|
||||
});
|
||||
describe('errorMessage', () => {
|
||||
test('extracts message from Error', () => {
|
||||
expect(errorMessage(new Error('hello'))).toBe('hello')
|
||||
})
|
||||
|
||||
test("stringifies non-Error", () => {
|
||||
expect(errorMessage(42)).toBe("42");
|
||||
expect(errorMessage(null)).toBe("null");
|
||||
});
|
||||
});
|
||||
test('stringifies non-Error', () => {
|
||||
expect(errorMessage(42)).toBe('42')
|
||||
expect(errorMessage(null)).toBe('null')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getErrnoCode / isENOENT / getErrnoPath ────────────────────────────
|
||||
|
||||
describe("getErrnoCode", () => {
|
||||
test("extracts code from errno-like error", () => {
|
||||
const e = Object.assign(new Error(), { code: "ENOENT" });
|
||||
expect(getErrnoCode(e)).toBe("ENOENT");
|
||||
});
|
||||
describe('getErrnoCode', () => {
|
||||
test('extracts code from errno-like error', () => {
|
||||
const e = Object.assign(new Error(), { code: 'ENOENT' })
|
||||
expect(getErrnoCode(e)).toBe('ENOENT')
|
||||
})
|
||||
|
||||
test("returns undefined for no code", () => {
|
||||
expect(getErrnoCode(new Error())).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for no code', () => {
|
||||
expect(getErrnoCode(new Error())).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for non-string code", () => {
|
||||
expect(getErrnoCode({ code: 123 })).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for non-string code', () => {
|
||||
expect(getErrnoCode({ code: 123 })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for non-object", () => {
|
||||
expect(getErrnoCode(null)).toBeUndefined();
|
||||
expect(getErrnoCode("string")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('returns undefined for non-object', () => {
|
||||
expect(getErrnoCode(null)).toBeUndefined()
|
||||
expect(getErrnoCode('string')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isENOENT", () => {
|
||||
test("returns true for ENOENT", () => {
|
||||
expect(isENOENT(Object.assign(new Error(), { code: "ENOENT" }))).toBe(true);
|
||||
});
|
||||
describe('isENOENT', () => {
|
||||
test('returns true for ENOENT', () => {
|
||||
expect(isENOENT(Object.assign(new Error(), { code: 'ENOENT' }))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for other codes", () => {
|
||||
expect(isENOENT(Object.assign(new Error(), { code: "EACCES" }))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
test('returns false for other codes', () => {
|
||||
expect(isENOENT(Object.assign(new Error(), { code: 'EACCES' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getErrnoPath", () => {
|
||||
test("extracts path from errno error", () => {
|
||||
const e = Object.assign(new Error(), { path: "/tmp/file" });
|
||||
expect(getErrnoPath(e)).toBe("/tmp/file");
|
||||
});
|
||||
describe('getErrnoPath', () => {
|
||||
test('extracts path from errno error', () => {
|
||||
const e = Object.assign(new Error(), { path: '/tmp/file' })
|
||||
expect(getErrnoPath(e)).toBe('/tmp/file')
|
||||
})
|
||||
|
||||
test("returns undefined when no path", () => {
|
||||
expect(getErrnoPath(new Error())).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('returns undefined when no path', () => {
|
||||
expect(getErrnoPath(new Error())).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── shortErrorStack ────────────────────────────────────────────────────
|
||||
|
||||
describe("shortErrorStack", () => {
|
||||
test("returns string for non-Error", () => {
|
||||
expect(shortErrorStack("oops")).toBe("oops");
|
||||
});
|
||||
describe('shortErrorStack', () => {
|
||||
test('returns string for non-Error', () => {
|
||||
expect(shortErrorStack('oops')).toBe('oops')
|
||||
})
|
||||
|
||||
test("returns message when no stack", () => {
|
||||
const e = new Error("test");
|
||||
e.stack = undefined;
|
||||
expect(shortErrorStack(e)).toBe("test");
|
||||
});
|
||||
test('returns message when no stack', () => {
|
||||
const e = new Error('test')
|
||||
e.stack = undefined
|
||||
expect(shortErrorStack(e)).toBe('test')
|
||||
})
|
||||
|
||||
test("truncates long stacks", () => {
|
||||
const e = new Error("test");
|
||||
const frames = Array.from({ length: 20 }, (_, i) => ` at frame${i}`);
|
||||
e.stack = `Error: test\n${frames.join("\n")}`;
|
||||
const result = shortErrorStack(e, 3);
|
||||
const lines = result.split("\n");
|
||||
expect(lines).toHaveLength(4); // header + 3 frames
|
||||
});
|
||||
test('truncates long stacks', () => {
|
||||
const e = new Error('test')
|
||||
const frames = Array.from({ length: 20 }, (_, i) => ` at frame${i}`)
|
||||
e.stack = `Error: test\n${frames.join('\n')}`
|
||||
const result = shortErrorStack(e, 3)
|
||||
const lines = result.split('\n')
|
||||
expect(lines).toHaveLength(4) // header + 3 frames
|
||||
})
|
||||
|
||||
test("preserves short stacks", () => {
|
||||
const e = new Error("test");
|
||||
e.stack = "Error: test\n at frame1\n at frame2";
|
||||
expect(shortErrorStack(e, 5)).toBe(e.stack);
|
||||
});
|
||||
});
|
||||
test('preserves short stacks', () => {
|
||||
const e = new Error('test')
|
||||
e.stack = 'Error: test\n at frame1\n at frame2'
|
||||
expect(shortErrorStack(e, 5)).toBe(e.stack)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isFsInaccessible ──────────────────────────────────────────────────
|
||||
|
||||
describe("isFsInaccessible", () => {
|
||||
test("returns true for ENOENT", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "ENOENT" }))).toBe(true);
|
||||
});
|
||||
describe('isFsInaccessible', () => {
|
||||
test('returns true for ENOENT', () => {
|
||||
expect(
|
||||
isFsInaccessible(Object.assign(new Error(), { code: 'ENOENT' })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for EACCES", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "EACCES" }))).toBe(true);
|
||||
});
|
||||
test('returns true for EACCES', () => {
|
||||
expect(
|
||||
isFsInaccessible(Object.assign(new Error(), { code: 'EACCES' })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for EPERM", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "EPERM" }))).toBe(true);
|
||||
});
|
||||
test('returns true for EPERM', () => {
|
||||
expect(
|
||||
isFsInaccessible(Object.assign(new Error(), { code: 'EPERM' })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for ENOTDIR", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "ENOTDIR" }))).toBe(true);
|
||||
});
|
||||
test('returns true for ENOTDIR', () => {
|
||||
expect(
|
||||
isFsInaccessible(Object.assign(new Error(), { code: 'ENOTDIR' })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for ELOOP", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "ELOOP" }))).toBe(true);
|
||||
});
|
||||
test('returns true for ELOOP', () => {
|
||||
expect(
|
||||
isFsInaccessible(Object.assign(new Error(), { code: 'ELOOP' })),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for other codes", () => {
|
||||
expect(isFsInaccessible(Object.assign(new Error(), { code: "EEXIST" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for other codes', () => {
|
||||
expect(
|
||||
isFsInaccessible(Object.assign(new Error(), { code: 'EEXIST' })),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── classifyAxiosError ─────────────────────────────────────────────────
|
||||
|
||||
describe("classifyAxiosError", () => {
|
||||
describe('classifyAxiosError', () => {
|
||||
test("returns 'other' for non-axios error", () => {
|
||||
expect(classifyAxiosError(new Error("test")).kind).toBe("other");
|
||||
});
|
||||
expect(classifyAxiosError(new Error('test')).kind).toBe('other')
|
||||
})
|
||||
|
||||
test("returns 'auth' for 401", () => {
|
||||
const e = { isAxiosError: true, response: { status: 401 }, message: "unauth" };
|
||||
expect(classifyAxiosError(e).kind).toBe("auth");
|
||||
});
|
||||
const e = {
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
message: 'unauth',
|
||||
}
|
||||
expect(classifyAxiosError(e).kind).toBe('auth')
|
||||
})
|
||||
|
||||
test("returns 'auth' for 403", () => {
|
||||
const e = { isAxiosError: true, response: { status: 403 }, message: "forbidden" };
|
||||
expect(classifyAxiosError(e).kind).toBe("auth");
|
||||
});
|
||||
const e = {
|
||||
isAxiosError: true,
|
||||
response: { status: 403 },
|
||||
message: 'forbidden',
|
||||
}
|
||||
expect(classifyAxiosError(e).kind).toBe('auth')
|
||||
})
|
||||
|
||||
test("returns 'timeout' for ECONNABORTED", () => {
|
||||
const e = { isAxiosError: true, code: "ECONNABORTED", message: "timeout" };
|
||||
expect(classifyAxiosError(e).kind).toBe("timeout");
|
||||
});
|
||||
const e = { isAxiosError: true, code: 'ECONNABORTED', message: 'timeout' }
|
||||
expect(classifyAxiosError(e).kind).toBe('timeout')
|
||||
})
|
||||
|
||||
test("returns 'network' for ECONNREFUSED", () => {
|
||||
const e = { isAxiosError: true, code: "ECONNREFUSED", message: "refused" };
|
||||
expect(classifyAxiosError(e).kind).toBe("network");
|
||||
});
|
||||
const e = { isAxiosError: true, code: 'ECONNREFUSED', message: 'refused' }
|
||||
expect(classifyAxiosError(e).kind).toBe('network')
|
||||
})
|
||||
|
||||
test("returns 'network' for ENOTFOUND", () => {
|
||||
const e = { isAxiosError: true, code: "ENOTFOUND", message: "nope" };
|
||||
expect(classifyAxiosError(e).kind).toBe("network");
|
||||
});
|
||||
const e = { isAxiosError: true, code: 'ENOTFOUND', message: 'nope' }
|
||||
expect(classifyAxiosError(e).kind).toBe('network')
|
||||
})
|
||||
|
||||
test("returns 'http' for other axios errors", () => {
|
||||
const e = { isAxiosError: true, response: { status: 500 }, message: "err" };
|
||||
const result = classifyAxiosError(e);
|
||||
expect(result.kind).toBe("http");
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
const e = { isAxiosError: true, response: { status: 500 }, message: 'err' }
|
||||
const result = classifyAxiosError(e)
|
||||
expect(result.kind).toBe('http')
|
||||
expect(result.status).toBe(500)
|
||||
})
|
||||
|
||||
test("returns 'other' for null", () => {
|
||||
expect(classifyAxiosError(null).kind).toBe("other");
|
||||
});
|
||||
});
|
||||
expect(classifyAxiosError(null).kind).toBe('other')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,92 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
convertLeadingTabsToSpaces,
|
||||
addLineNumbers,
|
||||
stripLineNumberPrefix,
|
||||
pathsEqual,
|
||||
normalizePathForComparison,
|
||||
} from "../file";
|
||||
} from '../file'
|
||||
|
||||
describe("convertLeadingTabsToSpaces", () => {
|
||||
test("converts leading tabs to 2 spaces each", () => {
|
||||
expect(convertLeadingTabsToSpaces("\t\thello")).toBe(" hello");
|
||||
});
|
||||
describe('convertLeadingTabsToSpaces', () => {
|
||||
test('converts leading tabs to 2 spaces each', () => {
|
||||
expect(convertLeadingTabsToSpaces('\t\thello')).toBe(' hello')
|
||||
})
|
||||
|
||||
test("only converts leading tabs", () => {
|
||||
expect(convertLeadingTabsToSpaces("\thello\tworld")).toBe(" hello\tworld");
|
||||
});
|
||||
test('only converts leading tabs', () => {
|
||||
expect(convertLeadingTabsToSpaces('\thello\tworld')).toBe(' hello\tworld')
|
||||
})
|
||||
|
||||
test("returns unchanged if no tabs", () => {
|
||||
expect(convertLeadingTabsToSpaces("no tabs")).toBe("no tabs");
|
||||
});
|
||||
test('returns unchanged if no tabs', () => {
|
||||
expect(convertLeadingTabsToSpaces('no tabs')).toBe('no tabs')
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(convertLeadingTabsToSpaces("")).toBe("");
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(convertLeadingTabsToSpaces('')).toBe('')
|
||||
})
|
||||
|
||||
test("handles multiline content", () => {
|
||||
const input = "\tline1\n\t\tline2\nline3";
|
||||
const expected = " line1\n line2\nline3";
|
||||
expect(convertLeadingTabsToSpaces(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
test('handles multiline content', () => {
|
||||
const input = '\tline1\n\t\tline2\nline3'
|
||||
const expected = ' line1\n line2\nline3'
|
||||
expect(convertLeadingTabsToSpaces(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("addLineNumbers", () => {
|
||||
test("adds line numbers starting from 1", () => {
|
||||
const result = addLineNumbers({ content: "a\nb\nc", startLine: 1 });
|
||||
expect(result).toMatch(/^\s*1[→\t]a\n\s*2[→\t]b\n\s*3[→\t]c$/);
|
||||
});
|
||||
describe('addLineNumbers', () => {
|
||||
test('adds line numbers starting from 1', () => {
|
||||
const result = addLineNumbers({ content: 'a\nb\nc', startLine: 1 })
|
||||
expect(result).toMatch(/^\s*1[→\t]a\n\s*2[→\t]b\n\s*3[→\t]c$/)
|
||||
})
|
||||
|
||||
test("returns empty string for empty content", () => {
|
||||
expect(addLineNumbers({ content: "", startLine: 1 })).toBe("");
|
||||
});
|
||||
test('returns empty string for empty content', () => {
|
||||
expect(addLineNumbers({ content: '', startLine: 1 })).toBe('')
|
||||
})
|
||||
|
||||
test("respects startLine offset", () => {
|
||||
const result = addLineNumbers({ content: "hello", startLine: 10 });
|
||||
expect(result).toMatch(/^\s*10[→\t]hello$/);
|
||||
});
|
||||
});
|
||||
test('respects startLine offset', () => {
|
||||
const result = addLineNumbers({ content: 'hello', startLine: 10 })
|
||||
expect(result).toMatch(/^\s*10[→\t]hello$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("stripLineNumberPrefix", () => {
|
||||
test("strips arrow-separated prefix", () => {
|
||||
expect(stripLineNumberPrefix(" 1→content")).toBe("content");
|
||||
});
|
||||
describe('stripLineNumberPrefix', () => {
|
||||
test('strips arrow-separated prefix', () => {
|
||||
expect(stripLineNumberPrefix(' 1→content')).toBe('content')
|
||||
})
|
||||
|
||||
test("strips tab-separated prefix", () => {
|
||||
expect(stripLineNumberPrefix("1\tcontent")).toBe("content");
|
||||
});
|
||||
test('strips tab-separated prefix', () => {
|
||||
expect(stripLineNumberPrefix('1\tcontent')).toBe('content')
|
||||
})
|
||||
|
||||
test("returns line unchanged if no prefix", () => {
|
||||
expect(stripLineNumberPrefix("no prefix")).toBe("no prefix");
|
||||
});
|
||||
test('returns line unchanged if no prefix', () => {
|
||||
expect(stripLineNumberPrefix('no prefix')).toBe('no prefix')
|
||||
})
|
||||
|
||||
test("handles large line numbers", () => {
|
||||
expect(stripLineNumberPrefix("123456→content")).toBe("content");
|
||||
});
|
||||
});
|
||||
test('handles large line numbers', () => {
|
||||
expect(stripLineNumberPrefix('123456→content')).toBe('content')
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizePathForComparison", () => {
|
||||
test("normalizes redundant separators", () => {
|
||||
const result = normalizePathForComparison("/a//b/c");
|
||||
expect(result).toBe("/a/b/c");
|
||||
});
|
||||
describe('normalizePathForComparison', () => {
|
||||
test('normalizes redundant separators', () => {
|
||||
const result = normalizePathForComparison('/a//b/c')
|
||||
expect(result).toBe('/a/b/c')
|
||||
})
|
||||
|
||||
test("resolves dot segments", () => {
|
||||
const result = normalizePathForComparison("/a/./b/../c");
|
||||
expect(result).toBe("/a/c");
|
||||
});
|
||||
});
|
||||
test('resolves dot segments', () => {
|
||||
const result = normalizePathForComparison('/a/./b/../c')
|
||||
expect(result).toBe('/a/c')
|
||||
})
|
||||
})
|
||||
|
||||
describe("pathsEqual", () => {
|
||||
test("returns true for identical paths", () => {
|
||||
expect(pathsEqual("/a/b/c", "/a/b/c")).toBe(true);
|
||||
});
|
||||
describe('pathsEqual', () => {
|
||||
test('returns true for identical paths', () => {
|
||||
expect(pathsEqual('/a/b/c', '/a/b/c')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for equivalent paths with dot segments", () => {
|
||||
expect(pathsEqual("/a/./b", "/a/b")).toBe(true);
|
||||
});
|
||||
test('returns true for equivalent paths with dot segments', () => {
|
||||
expect(pathsEqual('/a/./b', '/a/b')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for different paths", () => {
|
||||
expect(pathsEqual("/a/b", "/a/c")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for different paths', () => {
|
||||
expect(pathsEqual('/a/b', '/a/c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,12 @@ describe('FileStateCache LRU eviction', () => {
|
||||
|
||||
test('sizeCalculation handles null/undefined content', () => {
|
||||
const cache = new FileStateCache(100, 10000)
|
||||
cache.set('a', { content: null as unknown as string, timestamp: 0, offset: undefined, limit: undefined })
|
||||
cache.set('a', {
|
||||
content: null as unknown as string,
|
||||
timestamp: 0,
|
||||
offset: undefined,
|
||||
limit: undefined,
|
||||
})
|
||||
expect(cache.calculatedSize).toBe(1) // Math.max(1, 0) = 1
|
||||
})
|
||||
|
||||
|
||||
@@ -1,122 +1,121 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
FINGERPRINT_SALT,
|
||||
extractFirstMessageText,
|
||||
computeFingerprint,
|
||||
} from "../fingerprint";
|
||||
} from '../fingerprint'
|
||||
|
||||
describe("FINGERPRINT_SALT", () => {
|
||||
describe('FINGERPRINT_SALT', () => {
|
||||
test("has expected value '59cf53e54c78'", () => {
|
||||
expect(FINGERPRINT_SALT).toBe("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");
|
||||
});
|
||||
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", () => {
|
||||
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",
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{ type: "image", url: "http://example.com/img.png" },
|
||||
{ type: "text", text: "after image" },
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'image', url: 'x' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("after image");
|
||||
});
|
||||
]
|
||||
expect(extractFirstMessageText(messages as any)).toBe('hello')
|
||||
})
|
||||
|
||||
test("returns empty string for empty array", () => {
|
||||
expect(extractFirstMessageText([])).toBe("");
|
||||
});
|
||||
test('returns empty string when no user messages', () => {
|
||||
const messages = [{ type: 'assistant', message: { content: 'hi' } }]
|
||||
expect(extractFirstMessageText(messages as any)).toBe('')
|
||||
})
|
||||
|
||||
test("returns empty string when content has no text block", () => {
|
||||
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",
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [{ type: "image", url: "x" }],
|
||||
content: [
|
||||
{ type: 'image', url: 'http://example.com/img.png' },
|
||||
{ type: 'text', text: 'after image' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(extractFirstMessageText(messages as any)).toBe("");
|
||||
});
|
||||
});
|
||||
]
|
||||
expect(extractFirstMessageText(messages as any)).toBe('after image')
|
||||
})
|
||||
|
||||
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('returns empty string for empty array', () => {
|
||||
expect(extractFirstMessageText([])).toBe('')
|
||||
})
|
||||
|
||||
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('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('')
|
||||
})
|
||||
})
|
||||
|
||||
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);
|
||||
});
|
||||
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("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('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("handles short strings (length < 21)", () => {
|
||||
const result = computeFingerprint("hi", "1.0.0");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
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("handles empty string", () => {
|
||||
const result = computeFingerprint("", "1.0.0");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
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("fingerprint is valid hex", () => {
|
||||
const result = computeFingerprint("any message here for testing", "3.5.1");
|
||||
expect(result).toMatch(/^[0-9a-f]{3}$/);
|
||||
});
|
||||
});
|
||||
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}$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
formatFileSize,
|
||||
formatSecondsShort,
|
||||
@@ -8,291 +8,291 @@ import {
|
||||
formatRelativeTime,
|
||||
formatRelativeTimeAgo,
|
||||
formatLogMetadata,
|
||||
} from "../format";
|
||||
} from '../format'
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
test("formats bytes", () => {
|
||||
expect(formatFileSize(500)).toBe("500 bytes");
|
||||
});
|
||||
describe('formatFileSize', () => {
|
||||
test('formats bytes', () => {
|
||||
expect(formatFileSize(500)).toBe('500 bytes')
|
||||
})
|
||||
|
||||
test("formats kilobytes", () => {
|
||||
expect(formatFileSize(1536)).toBe("1.5KB");
|
||||
});
|
||||
test('formats kilobytes', () => {
|
||||
expect(formatFileSize(1536)).toBe('1.5KB')
|
||||
})
|
||||
|
||||
test("formats megabytes", () => {
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1.5MB");
|
||||
});
|
||||
test('formats megabytes', () => {
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5MB')
|
||||
})
|
||||
|
||||
test("formats gigabytes", () => {
|
||||
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe("2GB");
|
||||
});
|
||||
test('formats gigabytes', () => {
|
||||
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe('2GB')
|
||||
})
|
||||
|
||||
test("removes trailing .0", () => {
|
||||
expect(formatFileSize(1024)).toBe("1KB");
|
||||
});
|
||||
});
|
||||
test('removes trailing .0', () => {
|
||||
expect(formatFileSize(1024)).toBe('1KB')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSecondsShort", () => {
|
||||
test("formats milliseconds to seconds", () => {
|
||||
expect(formatSecondsShort(1234)).toBe("1.2s");
|
||||
});
|
||||
describe('formatSecondsShort', () => {
|
||||
test('formats milliseconds to seconds', () => {
|
||||
expect(formatSecondsShort(1234)).toBe('1.2s')
|
||||
})
|
||||
|
||||
test("formats zero", () => {
|
||||
expect(formatSecondsShort(0)).toBe("0.0s");
|
||||
});
|
||||
test('formats zero', () => {
|
||||
expect(formatSecondsShort(0)).toBe('0.0s')
|
||||
})
|
||||
|
||||
test("formats sub-second", () => {
|
||||
expect(formatSecondsShort(500)).toBe("0.5s");
|
||||
});
|
||||
});
|
||||
test('formats sub-second', () => {
|
||||
expect(formatSecondsShort(500)).toBe('0.5s')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatDuration", () => {
|
||||
test("formats 0 as 0s", () => {
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
});
|
||||
describe('formatDuration', () => {
|
||||
test('formats 0 as 0s', () => {
|
||||
expect(formatDuration(0)).toBe('0s')
|
||||
})
|
||||
|
||||
test("formats seconds", () => {
|
||||
expect(formatDuration(5000)).toBe("5s");
|
||||
});
|
||||
test('formats seconds', () => {
|
||||
expect(formatDuration(5000)).toBe('5s')
|
||||
})
|
||||
|
||||
test("formats minutes and seconds", () => {
|
||||
expect(formatDuration(125000)).toBe("2m 5s");
|
||||
});
|
||||
test('formats minutes and seconds', () => {
|
||||
expect(formatDuration(125000)).toBe('2m 5s')
|
||||
})
|
||||
|
||||
test("formats hours", () => {
|
||||
expect(formatDuration(3661000)).toBe("1h 1m 1s");
|
||||
});
|
||||
test('formats hours', () => {
|
||||
expect(formatDuration(3661000)).toBe('1h 1m 1s')
|
||||
})
|
||||
|
||||
test("formats days", () => {
|
||||
expect(formatDuration(90000000)).toBe("1d 1h 0m");
|
||||
});
|
||||
test('formats days', () => {
|
||||
expect(formatDuration(90000000)).toBe('1d 1h 0m')
|
||||
})
|
||||
|
||||
test("hideTrailingZeros removes zero components", () => {
|
||||
expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe("1h");
|
||||
expect(formatDuration(60000, { hideTrailingZeros: true })).toBe("1m");
|
||||
});
|
||||
test('hideTrailingZeros removes zero components', () => {
|
||||
expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe('1h')
|
||||
expect(formatDuration(60000, { hideTrailingZeros: true })).toBe('1m')
|
||||
})
|
||||
|
||||
test("mostSignificantOnly returns largest unit", () => {
|
||||
expect(formatDuration(90000000, { mostSignificantOnly: true })).toBe("1d");
|
||||
expect(formatDuration(3661000, { mostSignificantOnly: true })).toBe("1h");
|
||||
});
|
||||
});
|
||||
test('mostSignificantOnly returns largest unit', () => {
|
||||
expect(formatDuration(90000000, { mostSignificantOnly: true })).toBe('1d')
|
||||
expect(formatDuration(3661000, { mostSignificantOnly: true })).toBe('1h')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatNumber", () => {
|
||||
test("formats small numbers as-is", () => {
|
||||
expect(formatNumber(900)).toBe("900");
|
||||
});
|
||||
describe('formatNumber', () => {
|
||||
test('formats small numbers as-is', () => {
|
||||
expect(formatNumber(900)).toBe('900')
|
||||
})
|
||||
|
||||
test("formats thousands with k suffix", () => {
|
||||
expect(formatNumber(1321)).toBe("1.3k");
|
||||
});
|
||||
test('formats thousands with k suffix', () => {
|
||||
expect(formatNumber(1321)).toBe('1.3k')
|
||||
})
|
||||
|
||||
test("formats millions", () => {
|
||||
expect(formatNumber(1500000)).toBe("1.5m");
|
||||
});
|
||||
test('formats millions', () => {
|
||||
expect(formatNumber(1500000)).toBe('1.5m')
|
||||
})
|
||||
|
||||
test("formats 0 as-is", () => {
|
||||
expect(formatNumber(0)).toBe("0");
|
||||
});
|
||||
test('formats 0 as-is', () => {
|
||||
expect(formatNumber(0)).toBe('0')
|
||||
})
|
||||
|
||||
test("formats billions", () => {
|
||||
expect(formatNumber(1500000000)).toBe("1.5b");
|
||||
});
|
||||
});
|
||||
test('formats billions', () => {
|
||||
expect(formatNumber(1500000000)).toBe('1.5b')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTokens", () => {
|
||||
test("removes .0 from formatted number", () => {
|
||||
expect(formatTokens(1000)).toBe("1k");
|
||||
});
|
||||
describe('formatTokens', () => {
|
||||
test('removes .0 from formatted number', () => {
|
||||
expect(formatTokens(1000)).toBe('1k')
|
||||
})
|
||||
|
||||
test("formats small numbers", () => {
|
||||
expect(formatTokens(500)).toBe("500");
|
||||
});
|
||||
test('formats small numbers', () => {
|
||||
expect(formatTokens(500)).toBe('500')
|
||||
})
|
||||
|
||||
test("formats 1000 without .0", () => {
|
||||
expect(formatTokens(1000)).toBe("1k");
|
||||
});
|
||||
test('formats 1000 without .0', () => {
|
||||
expect(formatTokens(1000)).toBe('1k')
|
||||
})
|
||||
|
||||
test("formats 1500 as 1.5k", () => {
|
||||
expect(formatTokens(1500)).toBe("1.5k");
|
||||
});
|
||||
});
|
||||
test('formats 1500 as 1.5k', () => {
|
||||
expect(formatTokens(1500)).toBe('1.5k')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z");
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = new Date('2026-01-15T12:00:00Z')
|
||||
|
||||
test("formats seconds ago", () => {
|
||||
const date = new Date("2026-01-15T11:59:30Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("30s ago");
|
||||
});
|
||||
test('formats seconds ago', () => {
|
||||
const date = new Date('2026-01-15T11:59:30Z')
|
||||
expect(formatRelativeTime(date, { now })).toBe('30s ago')
|
||||
})
|
||||
|
||||
test("formats minutes ago", () => {
|
||||
const date = new Date("2026-01-15T11:55:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("5m ago");
|
||||
});
|
||||
test('formats minutes ago', () => {
|
||||
const date = new Date('2026-01-15T11:55:00Z')
|
||||
expect(formatRelativeTime(date, { now })).toBe('5m ago')
|
||||
})
|
||||
|
||||
test("formats future time", () => {
|
||||
const date = new Date("2026-01-15T13:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("in 1h");
|
||||
});
|
||||
test('formats future time', () => {
|
||||
const date = new Date('2026-01-15T13:00:00Z')
|
||||
expect(formatRelativeTime(date, { now })).toBe('in 1h')
|
||||
})
|
||||
|
||||
test("handles zero difference", () => {
|
||||
expect(formatRelativeTime(now, { now })).toBe("0s ago");
|
||||
});
|
||||
test('handles zero difference', () => {
|
||||
expect(formatRelativeTime(now, { now })).toBe('0s ago')
|
||||
})
|
||||
|
||||
test("formats hours ago", () => {
|
||||
const date = new Date("2026-01-15T09:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("3h ago");
|
||||
});
|
||||
test('formats hours ago', () => {
|
||||
const date = new Date('2026-01-15T09:00:00Z')
|
||||
expect(formatRelativeTime(date, { now })).toBe('3h ago')
|
||||
})
|
||||
|
||||
test("formats days ago", () => {
|
||||
const date = new Date("2026-01-13T12:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("2d ago");
|
||||
});
|
||||
test('formats days ago', () => {
|
||||
const date = new Date('2026-01-13T12:00:00Z')
|
||||
expect(formatRelativeTime(date, { now })).toBe('2d ago')
|
||||
})
|
||||
|
||||
test("formats weeks ago", () => {
|
||||
const date = new Date("2026-01-01T12:00:00Z");
|
||||
expect(formatRelativeTime(date, { now })).toBe("2w ago");
|
||||
});
|
||||
});
|
||||
test('formats weeks ago', () => {
|
||||
const date = new Date('2026-01-01T12:00:00Z')
|
||||
expect(formatRelativeTime(date, { now })).toBe('2w ago')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatRelativeTimeAgo", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z");
|
||||
describe('formatRelativeTimeAgo', () => {
|
||||
const now = new Date('2026-01-15T12:00:00Z')
|
||||
|
||||
test("formats past date with 'ago' suffix", () => {
|
||||
const date = new Date("2026-01-15T11:59:30Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).toBe("30s ago");
|
||||
});
|
||||
const date = new Date('2026-01-15T11:59:30Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).toBe('30s ago')
|
||||
})
|
||||
|
||||
test("formats future date without 'ago' suffix", () => {
|
||||
const date = new Date("2026-01-15T13:00:00Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).toBe("in 1h");
|
||||
});
|
||||
const date = new Date('2026-01-15T13:00:00Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).toBe('in 1h')
|
||||
})
|
||||
|
||||
test("formats minutes ago", () => {
|
||||
const date = new Date("2026-01-15T11:55:00Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).toBe("5m ago");
|
||||
});
|
||||
test('formats minutes ago', () => {
|
||||
const date = new Date('2026-01-15T11:55:00Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).toBe('5m ago')
|
||||
})
|
||||
|
||||
test("formats hours ago", () => {
|
||||
const date = new Date("2026-01-15T09:00:00Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).toBe("3h ago");
|
||||
});
|
||||
test('formats hours ago', () => {
|
||||
const date = new Date('2026-01-15T09:00:00Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).toBe('3h ago')
|
||||
})
|
||||
|
||||
test("formats days ago", () => {
|
||||
const date = new Date("2026-01-13T12:00:00Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).toBe("2d ago");
|
||||
});
|
||||
test('formats days ago', () => {
|
||||
const date = new Date('2026-01-13T12:00:00Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).toBe('2d ago')
|
||||
})
|
||||
|
||||
test("handles date equal to now as past", () => {
|
||||
test('handles date equal to now as past', () => {
|
||||
// date === now, treated as past (not future)
|
||||
const result = formatRelativeTimeAgo(now, { now });
|
||||
expect(result).toBe("0s ago");
|
||||
});
|
||||
const result = formatRelativeTimeAgo(now, { now })
|
||||
expect(result).toBe('0s ago')
|
||||
})
|
||||
|
||||
test("uses numeric always for past dates", () => {
|
||||
test('uses numeric always for past dates', () => {
|
||||
// Should always use numeric format for past dates
|
||||
const date = new Date("2026-01-15T11:59:00Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).toContain("ago");
|
||||
});
|
||||
const date = new Date('2026-01-15T11:59:00Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).toContain('ago')
|
||||
})
|
||||
|
||||
test("future date does not contain 'ago'", () => {
|
||||
const date = new Date("2026-01-15T14:00:00Z");
|
||||
const result = formatRelativeTimeAgo(date, { now });
|
||||
expect(result).not.toContain("ago");
|
||||
});
|
||||
});
|
||||
const date = new Date('2026-01-15T14:00:00Z')
|
||||
const result = formatRelativeTimeAgo(date, { now })
|
||||
expect(result).not.toContain('ago')
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatLogMetadata", () => {
|
||||
describe('formatLogMetadata', () => {
|
||||
// Use a date very recently in the past so it always shows "Xs ago" or similar
|
||||
const modified = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const modified = new Date(Date.now() - 5 * 60 * 1000) // 5 minutes ago
|
||||
|
||||
test("includes relative time and message count", () => {
|
||||
test('includes relative time and message count', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 10,
|
||||
});
|
||||
expect(result).toContain("ago");
|
||||
expect(result).toContain("10 messages");
|
||||
});
|
||||
})
|
||||
expect(result).toContain('ago')
|
||||
expect(result).toContain('10 messages')
|
||||
})
|
||||
|
||||
test("uses fileSize instead of messageCount when provided", () => {
|
||||
test('uses fileSize instead of messageCount when provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 5,
|
||||
fileSize: 1536,
|
||||
});
|
||||
expect(result).toContain("1.5KB");
|
||||
expect(result).not.toContain("messages");
|
||||
});
|
||||
})
|
||||
expect(result).toContain('1.5KB')
|
||||
expect(result).not.toContain('messages')
|
||||
})
|
||||
|
||||
test("includes gitBranch when provided", () => {
|
||||
test('includes gitBranch when provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 3,
|
||||
gitBranch: "main",
|
||||
});
|
||||
expect(result).toContain("main");
|
||||
});
|
||||
gitBranch: 'main',
|
||||
})
|
||||
expect(result).toContain('main')
|
||||
})
|
||||
|
||||
test("omits gitBranch when not provided", () => {
|
||||
test('omits gitBranch when not provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 3,
|
||||
});
|
||||
})
|
||||
// Should not have a dangling separator from missing branch
|
||||
expect(result).not.toMatch(/^ · | · $/);
|
||||
});
|
||||
expect(result).not.toMatch(/^ · | · $/)
|
||||
})
|
||||
|
||||
test("includes tag when provided", () => {
|
||||
test('includes tag when provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 3,
|
||||
tag: "my-tag",
|
||||
});
|
||||
expect(result).toContain("#my-tag");
|
||||
});
|
||||
tag: 'my-tag',
|
||||
})
|
||||
expect(result).toContain('#my-tag')
|
||||
})
|
||||
|
||||
test("includes agentSetting when provided", () => {
|
||||
test('includes agentSetting when provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 3,
|
||||
agentSetting: "custom-agent",
|
||||
});
|
||||
expect(result).toContain("@custom-agent");
|
||||
});
|
||||
agentSetting: 'custom-agent',
|
||||
})
|
||||
expect(result).toContain('@custom-agent')
|
||||
})
|
||||
|
||||
test("includes prNumber when provided", () => {
|
||||
test('includes prNumber when provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 3,
|
||||
prNumber: 42,
|
||||
});
|
||||
expect(result).toContain("#42");
|
||||
});
|
||||
})
|
||||
expect(result).toContain('#42')
|
||||
})
|
||||
|
||||
test("includes prRepository with prNumber when both provided", () => {
|
||||
test('includes prRepository with prNumber when both provided', () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 3,
|
||||
prNumber: 99,
|
||||
prRepository: "owner/repo",
|
||||
});
|
||||
expect(result).toContain("owner/repo#99");
|
||||
});
|
||||
prRepository: 'owner/repo',
|
||||
})
|
||||
expect(result).toContain('owner/repo#99')
|
||||
})
|
||||
|
||||
test("parts are joined with ' · ' separator", () => {
|
||||
const result = formatLogMetadata({
|
||||
modified,
|
||||
messageCount: 5,
|
||||
gitBranch: "feat/x",
|
||||
});
|
||||
expect(result).toContain(" · ");
|
||||
});
|
||||
});
|
||||
gitBranch: 'feat/x',
|
||||
})
|
||||
expect(result).toContain(' · ')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
||||
import { formatBriefTimestamp } from "../formatBriefTimestamp";
|
||||
import { describe, expect, test, beforeAll, afterAll } from 'bun:test'
|
||||
import { formatBriefTimestamp } from '../formatBriefTimestamp'
|
||||
|
||||
let savedLcAll: string | undefined;
|
||||
let savedLcAll: string | undefined
|
||||
beforeAll(() => {
|
||||
savedLcAll = process.env.LC_ALL;
|
||||
process.env.LC_ALL = "en_US.UTF-8";
|
||||
});
|
||||
savedLcAll = process.env.LC_ALL
|
||||
process.env.LC_ALL = 'en_US.UTF-8'
|
||||
})
|
||||
afterAll(() => {
|
||||
if (savedLcAll === undefined) delete process.env.LC_ALL;
|
||||
else process.env.LC_ALL = savedLcAll;
|
||||
});
|
||||
if (savedLcAll === undefined) delete process.env.LC_ALL
|
||||
else process.env.LC_ALL = savedLcAll
|
||||
})
|
||||
|
||||
describe("formatBriefTimestamp", () => {
|
||||
describe('formatBriefTimestamp', () => {
|
||||
// Fixed "now" for deterministic tests: 2026-04-02T14:00:00Z (Thursday)
|
||||
const now = new Date("2026-04-02T14:00:00Z");
|
||||
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(":");
|
||||
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/
|
||||
);
|
||||
});
|
||||
/Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday/,
|
||||
)
|
||||
})
|
||||
|
||||
test("yesterday returns weekday and time", () => {
|
||||
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(":");
|
||||
});
|
||||
const result = formatBriefTimestamp('2026-04-01T16:15:00Z', now)
|
||||
expect(result).toContain('Wednesday')
|
||||
expect(result).toContain(':')
|
||||
})
|
||||
|
||||
test("3 days ago returns weekday and time", () => {
|
||||
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(":");
|
||||
});
|
||||
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)", () => {
|
||||
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(":");
|
||||
});
|
||||
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", () => {
|
||||
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(":");
|
||||
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/);
|
||||
});
|
||||
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('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('invalid ISO string returns empty string', () => {
|
||||
expect(formatBriefTimestamp('not-a-date', now)).toBe('')
|
||||
})
|
||||
|
||||
test("empty string returns empty string", () => {
|
||||
expect(formatBriefTimestamp("", 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(":");
|
||||
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);
|
||||
});
|
||||
expect(result.length).toBeLessThan(20)
|
||||
})
|
||||
|
||||
test("uses current time as default when now is not provided", () => {
|
||||
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(":");
|
||||
});
|
||||
});
|
||||
const recent = new Date()
|
||||
recent.setMinutes(recent.getMinutes() - 5)
|
||||
const result = formatBriefTimestamp(recent.toISOString())
|
||||
expect(result).not.toBe('')
|
||||
expect(result).toContain(':')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,164 +1,167 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
parseFrontmatter,
|
||||
splitPathInFrontmatter,
|
||||
parsePositiveIntFromFrontmatter,
|
||||
parseBooleanFrontmatter,
|
||||
parseShellFrontmatter,
|
||||
} from "../frontmatterParser";
|
||||
} from '../frontmatterParser'
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
test("parses valid frontmatter", () => {
|
||||
describe('parseFrontmatter', () => {
|
||||
test('parses valid frontmatter', () => {
|
||||
const md = `---
|
||||
description: A test
|
||||
type: user
|
||||
---
|
||||
Content here`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter.description).toBe("A test");
|
||||
expect(result.frontmatter.type).toBe("user");
|
||||
expect(result.content).toBe("Content here");
|
||||
});
|
||||
Content here`
|
||||
const result = parseFrontmatter(md)
|
||||
expect(result.frontmatter.description).toBe('A test')
|
||||
expect(result.frontmatter.type).toBe('user')
|
||||
expect(result.content).toBe('Content here')
|
||||
})
|
||||
|
||||
test("returns empty frontmatter when none exists", () => {
|
||||
const md = "Just content, no frontmatter";
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter).toEqual({});
|
||||
expect(result.content).toBe(md);
|
||||
});
|
||||
test('returns empty frontmatter when none exists', () => {
|
||||
const md = 'Just content, no frontmatter'
|
||||
const result = parseFrontmatter(md)
|
||||
expect(result.frontmatter).toEqual({})
|
||||
expect(result.content).toBe(md)
|
||||
})
|
||||
|
||||
test("handles empty frontmatter block", () => {
|
||||
test('handles empty frontmatter block', () => {
|
||||
const md = `---
|
||||
---
|
||||
Content`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter).toEqual({});
|
||||
expect(result.content).toBe("Content");
|
||||
});
|
||||
Content`
|
||||
const result = parseFrontmatter(md)
|
||||
expect(result.frontmatter).toEqual({})
|
||||
expect(result.content).toBe('Content')
|
||||
})
|
||||
|
||||
test("handles frontmatter with list values", () => {
|
||||
test('handles frontmatter with list values', () => {
|
||||
const md = `---
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
Content`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter["allowed-tools"]).toEqual(["Bash", "Read"]);
|
||||
});
|
||||
});
|
||||
Content`
|
||||
const result = parseFrontmatter(md)
|
||||
expect(result.frontmatter['allowed-tools']).toEqual(['Bash', 'Read'])
|
||||
})
|
||||
})
|
||||
|
||||
describe("splitPathInFrontmatter", () => {
|
||||
test("splits comma-separated paths", () => {
|
||||
expect(splitPathInFrontmatter("a, b, c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
describe('splitPathInFrontmatter', () => {
|
||||
test('splits comma-separated paths', () => {
|
||||
expect(splitPathInFrontmatter('a, b, c')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test("expands brace patterns", () => {
|
||||
expect(splitPathInFrontmatter("src/*.{ts,tsx}")).toEqual([
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
]);
|
||||
});
|
||||
test('expands brace patterns', () => {
|
||||
expect(splitPathInFrontmatter('src/*.{ts,tsx}')).toEqual([
|
||||
'src/*.ts',
|
||||
'src/*.tsx',
|
||||
])
|
||||
})
|
||||
|
||||
test("handles nested brace expansion", () => {
|
||||
expect(splitPathInFrontmatter("{a,b}/{c,d}")).toEqual([
|
||||
"a/c", "a/d", "b/c", "b/d",
|
||||
]);
|
||||
});
|
||||
test('handles nested brace expansion', () => {
|
||||
expect(splitPathInFrontmatter('{a,b}/{c,d}')).toEqual([
|
||||
'a/c',
|
||||
'a/d',
|
||||
'b/c',
|
||||
'b/d',
|
||||
])
|
||||
})
|
||||
|
||||
test("handles array input", () => {
|
||||
expect(splitPathInFrontmatter(["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
test('handles array input', () => {
|
||||
expect(splitPathInFrontmatter(['a', 'b'])).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
test("returns empty array for non-string", () => {
|
||||
expect(splitPathInFrontmatter(123 as any)).toEqual([]);
|
||||
});
|
||||
test('returns empty array for non-string', () => {
|
||||
expect(splitPathInFrontmatter(123 as any)).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves braces in comma-separated list", () => {
|
||||
expect(splitPathInFrontmatter("a, src/*.{ts,tsx}")).toEqual([
|
||||
"a",
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
]);
|
||||
});
|
||||
});
|
||||
test('preserves braces in comma-separated list', () => {
|
||||
expect(splitPathInFrontmatter('a, src/*.{ts,tsx}')).toEqual([
|
||||
'a',
|
||||
'src/*.ts',
|
||||
'src/*.tsx',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("parsePositiveIntFromFrontmatter", () => {
|
||||
test("returns number for positive integer", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(5)).toBe(5);
|
||||
});
|
||||
describe('parsePositiveIntFromFrontmatter', () => {
|
||||
test('returns number for positive integer', () => {
|
||||
expect(parsePositiveIntFromFrontmatter(5)).toBe(5)
|
||||
})
|
||||
|
||||
test("parses string number", () => {
|
||||
expect(parsePositiveIntFromFrontmatter("10")).toBe(10);
|
||||
});
|
||||
test('parses string number', () => {
|
||||
expect(parsePositiveIntFromFrontmatter('10')).toBe(10)
|
||||
})
|
||||
|
||||
test("returns undefined for zero", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(0)).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for zero', () => {
|
||||
expect(parsePositiveIntFromFrontmatter(0)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for negative number", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(-1)).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for negative number', () => {
|
||||
expect(parsePositiveIntFromFrontmatter(-1)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for float", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(1.5)).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for float', () => {
|
||||
expect(parsePositiveIntFromFrontmatter(1.5)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for null/undefined", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(null)).toBeUndefined();
|
||||
expect(parsePositiveIntFromFrontmatter(undefined)).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for null/undefined', () => {
|
||||
expect(parsePositiveIntFromFrontmatter(null)).toBeUndefined()
|
||||
expect(parsePositiveIntFromFrontmatter(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for non-numeric string", () => {
|
||||
expect(parsePositiveIntFromFrontmatter("abc")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('returns undefined for non-numeric string', () => {
|
||||
expect(parsePositiveIntFromFrontmatter('abc')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseBooleanFrontmatter", () => {
|
||||
test("returns true for boolean true", () => {
|
||||
expect(parseBooleanFrontmatter(true)).toBe(true);
|
||||
});
|
||||
describe('parseBooleanFrontmatter', () => {
|
||||
test('returns true for boolean true', () => {
|
||||
expect(parseBooleanFrontmatter(true)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for string 'true'", () => {
|
||||
expect(parseBooleanFrontmatter("true")).toBe(true);
|
||||
});
|
||||
expect(parseBooleanFrontmatter('true')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for boolean false", () => {
|
||||
expect(parseBooleanFrontmatter(false)).toBe(false);
|
||||
});
|
||||
test('returns false for boolean false', () => {
|
||||
expect(parseBooleanFrontmatter(false)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for string 'false'", () => {
|
||||
expect(parseBooleanFrontmatter("false")).toBe(false);
|
||||
});
|
||||
expect(parseBooleanFrontmatter('false')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for null/undefined", () => {
|
||||
expect(parseBooleanFrontmatter(null)).toBe(false);
|
||||
expect(parseBooleanFrontmatter(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for null/undefined', () => {
|
||||
expect(parseBooleanFrontmatter(null)).toBe(false)
|
||||
expect(parseBooleanFrontmatter(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseShellFrontmatter", () => {
|
||||
describe('parseShellFrontmatter', () => {
|
||||
test("returns bash for 'bash'", () => {
|
||||
expect(parseShellFrontmatter("bash", "test")).toBe("bash");
|
||||
});
|
||||
expect(parseShellFrontmatter('bash', 'test')).toBe('bash')
|
||||
})
|
||||
|
||||
test("returns powershell for 'powershell'", () => {
|
||||
expect(parseShellFrontmatter("powershell", "test")).toBe("powershell");
|
||||
});
|
||||
expect(parseShellFrontmatter('powershell', 'test')).toBe('powershell')
|
||||
})
|
||||
|
||||
test("returns undefined for null", () => {
|
||||
expect(parseShellFrontmatter(null, "test")).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for null', () => {
|
||||
expect(parseShellFrontmatter(null, 'test')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for unrecognized value", () => {
|
||||
expect(parseShellFrontmatter("zsh", "test")).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for unrecognized value', () => {
|
||||
expect(parseShellFrontmatter('zsh', 'test')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("is case insensitive", () => {
|
||||
expect(parseShellFrontmatter("BASH", "test")).toBe("bash");
|
||||
});
|
||||
test('is case insensitive', () => {
|
||||
expect(parseShellFrontmatter('BASH', 'test')).toBe('bash')
|
||||
})
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseShellFrontmatter("", "test")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('returns undefined for empty string', () => {
|
||||
expect(parseShellFrontmatter('', 'test')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { lastX, returnValue, all, toArray, fromArray } from "../generators";
|
||||
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;
|
||||
yield i
|
||||
}
|
||||
}
|
||||
|
||||
describe("lastX", () => {
|
||||
test("returns last yielded value", async () => {
|
||||
const result = await lastX(range(5));
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
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('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");
|
||||
});
|
||||
});
|
||||
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 () => {
|
||||
describe('returnValue', () => {
|
||||
test('returns generator return value', async () => {
|
||||
async function* gen(): AsyncGenerator<number, string> {
|
||||
yield 1;
|
||||
return "done";
|
||||
yield 1
|
||||
return 'done'
|
||||
}
|
||||
const result = await returnValue(gen());
|
||||
expect(result).toBe("done");
|
||||
});
|
||||
const result = await returnValue(gen())
|
||||
expect(result).toBe('done')
|
||||
})
|
||||
|
||||
test("returns undefined for void return", async () => {
|
||||
test('returns undefined for void return', async () => {
|
||||
async function* gen(): AsyncGenerator<number, void> {
|
||||
yield 1;
|
||||
yield 1
|
||||
}
|
||||
const result = await returnValue(gen());
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
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]);
|
||||
});
|
||||
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('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"]);
|
||||
});
|
||||
});
|
||||
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]);
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
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]));
|
||||
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]);
|
||||
});
|
||||
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('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 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 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]));
|
||||
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]);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,124 +1,122 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeGitRemoteUrl } from "../git";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { normalizeGitRemoteUrl } from '../git'
|
||||
|
||||
describe("normalizeGitRemoteUrl", () => {
|
||||
describe("SSH format (git@host:owner/repo)", () => {
|
||||
test("normalizes basic SSH URL", () => {
|
||||
expect(normalizeGitRemoteUrl("git@github.com:owner/repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
describe('normalizeGitRemoteUrl', () => {
|
||||
describe('SSH format (git@host:owner/repo)', () => {
|
||||
test('normalizes basic SSH URL', () => {
|
||||
expect(normalizeGitRemoteUrl('git@github.com:owner/repo.git')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles SSH URL without .git suffix", () => {
|
||||
expect(normalizeGitRemoteUrl("git@github.com:owner/repo")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
test('handles SSH URL without .git suffix', () => {
|
||||
expect(normalizeGitRemoteUrl('git@github.com:owner/repo')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles nested paths", () => {
|
||||
expect(normalizeGitRemoteUrl("git@gitlab.com:group/sub/repo.git")).toBe(
|
||||
"gitlab.com/group/sub/repo"
|
||||
);
|
||||
});
|
||||
});
|
||||
test('handles nested paths', () => {
|
||||
expect(normalizeGitRemoteUrl('git@gitlab.com:group/sub/repo.git')).toBe(
|
||||
'gitlab.com/group/sub/repo',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("HTTPS format", () => {
|
||||
test("normalizes basic HTTPS URL", () => {
|
||||
describe('HTTPS format', () => {
|
||||
test('normalizes basic HTTPS URL', () => {
|
||||
expect(normalizeGitRemoteUrl('https://github.com/owner/repo.git')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles HTTPS without .git suffix', () => {
|
||||
expect(normalizeGitRemoteUrl('https://github.com/owner/repo')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles HTTP URL', () => {
|
||||
expect(normalizeGitRemoteUrl('http://github.com/owner/repo.git')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles HTTPS with auth', () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
normalizeGitRemoteUrl('https://user@github.com/owner/repo.git'),
|
||||
).toBe('github.com/owner/repo')
|
||||
})
|
||||
})
|
||||
|
||||
test("handles HTTPS without .git suffix", () => {
|
||||
expect(normalizeGitRemoteUrl("https://github.com/owner/repo")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
describe('ssh:// format', () => {
|
||||
test('normalizes ssh:// URL', () => {
|
||||
expect(normalizeGitRemoteUrl('ssh://git@github.com/owner/repo')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles HTTP URL", () => {
|
||||
expect(normalizeGitRemoteUrl("http://github.com/owner/repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
test('handles ssh:// with .git suffix', () => {
|
||||
expect(normalizeGitRemoteUrl('ssh://git@github.com/owner/repo.git')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("handles HTTPS with auth", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://user@github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssh:// format", () => {
|
||||
test("normalizes ssh:// URL", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("ssh://git@github.com/owner/repo")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles ssh:// with .git suffix", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("ssh://git@github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CCR proxy URLs", () => {
|
||||
test("handles legacy proxy format (assumes github.com)", () => {
|
||||
describe('CCR proxy URLs', () => {
|
||||
test('handles legacy proxy format (assumes github.com)', () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://local_proxy@127.0.0.1:16583/git/owner/repo"
|
||||
)
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
'http://local_proxy@127.0.0.1:16583/git/owner/repo',
|
||||
),
|
||||
).toBe('github.com/owner/repo')
|
||||
})
|
||||
|
||||
test("handles GHE proxy format (host in path)", () => {
|
||||
test('handles GHE proxy format (host in path)', () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://local_proxy@127.0.0.1:16583/git/ghe.company.com/owner/repo"
|
||||
)
|
||||
).toBe("ghe.company.com/owner/repo");
|
||||
});
|
||||
'http://local_proxy@127.0.0.1:16583/git/ghe.company.com/owner/repo',
|
||||
),
|
||||
).toBe('ghe.company.com/owner/repo')
|
||||
})
|
||||
|
||||
test("handles localhost proxy", () => {
|
||||
test('handles localhost proxy', () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://proxy@localhost:8080/git/owner/repo"
|
||||
)
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
normalizeGitRemoteUrl('http://proxy@localhost:8080/git/owner/repo'),
|
||||
).toBe('github.com/owner/repo')
|
||||
})
|
||||
})
|
||||
|
||||
describe("case normalization", () => {
|
||||
test("converts to lowercase", () => {
|
||||
expect(normalizeGitRemoteUrl("git@GitHub.COM:Owner/Repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
describe('case normalization', () => {
|
||||
test('converts to lowercase', () => {
|
||||
expect(normalizeGitRemoteUrl('git@GitHub.COM:Owner/Repo.git')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
|
||||
test("converts HTTPS to lowercase", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://GitHub.COM/Owner/Repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
test('converts HTTPS to lowercase', () => {
|
||||
expect(normalizeGitRemoteUrl('https://GitHub.COM/Owner/Repo.git')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("returns null for empty string", () => {
|
||||
expect(normalizeGitRemoteUrl("")).toBeNull();
|
||||
});
|
||||
describe('edge cases', () => {
|
||||
test('returns null for empty string', () => {
|
||||
expect(normalizeGitRemoteUrl('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for whitespace only", () => {
|
||||
expect(normalizeGitRemoteUrl(" ")).toBeNull();
|
||||
});
|
||||
test('returns null for whitespace only', () => {
|
||||
expect(normalizeGitRemoteUrl(' ')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for unrecognized format", () => {
|
||||
expect(normalizeGitRemoteUrl("not-a-url")).toBeNull();
|
||||
});
|
||||
test('returns null for unrecognized format', () => {
|
||||
expect(normalizeGitRemoteUrl('not-a-url')).toBeNull()
|
||||
})
|
||||
|
||||
test("trims whitespace before parsing", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(" git@github.com:owner/repo.git ")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
});
|
||||
test('trims whitespace before parsing', () => {
|
||||
expect(normalizeGitRemoteUrl(' git@github.com:owner/repo.git ')).toBe(
|
||||
'github.com/owner/repo',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,286 +1,294 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitNumstat, parseGitDiff, parseShortstat } from "../gitDiff";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseGitNumstat, parseGitDiff, parseShortstat } from '../gitDiff'
|
||||
|
||||
describe("parseGitNumstat", () => {
|
||||
test("parses single file", () => {
|
||||
const result = parseGitNumstat("5\t3\tsrc/foo.ts");
|
||||
describe('parseGitNumstat', () => {
|
||||
test('parses single file', () => {
|
||||
const result = parseGitNumstat('5\t3\tsrc/foo.ts')
|
||||
expect(result.stats).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 5,
|
||||
linesRemoved: 3,
|
||||
});
|
||||
expect(result.perFileStats.get("src/foo.ts")).toEqual({
|
||||
})
|
||||
expect(result.perFileStats.get('src/foo.ts')).toEqual({
|
||||
added: 5,
|
||||
removed: 3,
|
||||
isBinary: false,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses multiple files", () => {
|
||||
const input = "10\t2\ta.ts\n3\t0\tb.ts\n0\t7\tc.ts";
|
||||
const result = parseGitNumstat(input);
|
||||
test('parses multiple files', () => {
|
||||
const input = '10\t2\ta.ts\n3\t0\tb.ts\n0\t7\tc.ts'
|
||||
const result = parseGitNumstat(input)
|
||||
expect(result.stats).toEqual({
|
||||
filesCount: 3,
|
||||
linesAdded: 13,
|
||||
linesRemoved: 9,
|
||||
});
|
||||
expect(result.perFileStats.size).toBe(3);
|
||||
});
|
||||
})
|
||||
expect(result.perFileStats.size).toBe(3)
|
||||
})
|
||||
|
||||
test("handles binary file with dash counts", () => {
|
||||
const result = parseGitNumstat("-\t-\timage.png");
|
||||
expect(result.perFileStats.get("image.png")).toEqual({
|
||||
test('handles binary file with dash counts', () => {
|
||||
const result = parseGitNumstat('-\t-\timage.png')
|
||||
expect(result.perFileStats.get('image.png')).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
isBinary: true,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("handles rename format", () => {
|
||||
const result = parseGitNumstat("1\t0\told.txt => new.txt");
|
||||
const entry = result.perFileStats.get("old.txt => new.txt");
|
||||
expect(entry).not.toBeUndefined();
|
||||
expect(entry!.added).toBe(1);
|
||||
expect(entry!.isBinary).toBe(false);
|
||||
});
|
||||
test('handles rename format', () => {
|
||||
const result = parseGitNumstat('1\t0\told.txt => new.txt')
|
||||
const entry = result.perFileStats.get('old.txt => new.txt')
|
||||
expect(entry).not.toBeUndefined()
|
||||
expect(entry!.added).toBe(1)
|
||||
expect(entry!.isBinary).toBe(false)
|
||||
})
|
||||
|
||||
test("handles filename with tabs", () => {
|
||||
const result = parseGitNumstat('1\t0\t"tab\tfile.txt"');
|
||||
test('handles filename with tabs', () => {
|
||||
const result = parseGitNumstat('1\t0\t"tab\tfile.txt"')
|
||||
// parts.slice(2).join('\t') preserves the rest
|
||||
expect(result.stats.filesCount).toBe(1);
|
||||
});
|
||||
expect(result.stats.filesCount).toBe(1)
|
||||
})
|
||||
|
||||
test("returns empty for empty string", () => {
|
||||
const result = parseGitNumstat("");
|
||||
test('returns empty for empty string', () => {
|
||||
const result = parseGitNumstat('')
|
||||
expect(result.stats).toEqual({
|
||||
filesCount: 0,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
expect(result.perFileStats.size).toBe(0);
|
||||
});
|
||||
})
|
||||
expect(result.perFileStats.size).toBe(0)
|
||||
})
|
||||
|
||||
test("skips lines with fewer than 3 tab-separated parts", () => {
|
||||
const result = parseGitNumstat("invalid-line\n5\t3\tsrc/foo.ts");
|
||||
expect(result.stats.filesCount).toBe(1);
|
||||
});
|
||||
test('skips lines with fewer than 3 tab-separated parts', () => {
|
||||
const result = parseGitNumstat('invalid-line\n5\t3\tsrc/foo.ts')
|
||||
expect(result.stats.filesCount).toBe(1)
|
||||
})
|
||||
|
||||
test("handles zero additions and zero deletions", () => {
|
||||
const result = parseGitNumstat("0\t0\tempty-change.ts");
|
||||
expect(result.perFileStats.get("empty-change.ts")).toEqual({
|
||||
test('handles zero additions and zero deletions', () => {
|
||||
const result = parseGitNumstat('0\t0\tempty-change.ts')
|
||||
expect(result.perFileStats.get('empty-change.ts')).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
isBinary: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseGitDiff", () => {
|
||||
test("parses single file with one hunk", () => {
|
||||
describe('parseGitDiff', () => {
|
||||
test('parses single file with one hunk', () => {
|
||||
const input = [
|
||||
"diff --git a/foo.ts b/foo.ts",
|
||||
"index abc..def 100644",
|
||||
"--- a/foo.ts",
|
||||
"+++ b/foo.ts",
|
||||
"@@ -1,3 +1,4 @@",
|
||||
" line1",
|
||||
"+added",
|
||||
" line2",
|
||||
" line3",
|
||||
].join("\n");
|
||||
'diff --git a/foo.ts b/foo.ts',
|
||||
'index abc..def 100644',
|
||||
'--- a/foo.ts',
|
||||
'+++ b/foo.ts',
|
||||
'@@ -1,3 +1,4 @@',
|
||||
' line1',
|
||||
'+added',
|
||||
' line2',
|
||||
' line3',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
expect(result.size).toBe(1);
|
||||
const hunks = result.get("foo.ts")!;
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0].oldStart).toBe(1);
|
||||
expect(hunks[0].oldLines).toBe(3);
|
||||
expect(hunks[0].newStart).toBe(1);
|
||||
expect(hunks[0].newLines).toBe(4);
|
||||
expect(hunks[0].lines).toEqual([" line1", "+added", " line2", " line3"]);
|
||||
});
|
||||
const result = parseGitDiff(input)
|
||||
expect(result.size).toBe(1)
|
||||
const hunks = result.get('foo.ts')!
|
||||
expect(hunks).toHaveLength(1)
|
||||
expect(hunks[0].oldStart).toBe(1)
|
||||
expect(hunks[0].oldLines).toBe(3)
|
||||
expect(hunks[0].newStart).toBe(1)
|
||||
expect(hunks[0].newLines).toBe(4)
|
||||
expect(hunks[0].lines).toEqual([' line1', '+added', ' line2', ' line3'])
|
||||
})
|
||||
|
||||
test("parses multiple hunks in one file", () => {
|
||||
test('parses multiple hunks in one file', () => {
|
||||
const input = [
|
||||
"diff --git a/bar.ts b/bar.ts",
|
||||
"index abc..def 100644",
|
||||
"--- a/bar.ts",
|
||||
"+++ b/bar.ts",
|
||||
"@@ -1,2 +1,3 @@",
|
||||
" a",
|
||||
"+b",
|
||||
" c",
|
||||
"@@ -10,2 +11,2 @@",
|
||||
" d",
|
||||
"-e",
|
||||
"+f",
|
||||
].join("\n");
|
||||
'diff --git a/bar.ts b/bar.ts',
|
||||
'index abc..def 100644',
|
||||
'--- a/bar.ts',
|
||||
'+++ b/bar.ts',
|
||||
'@@ -1,2 +1,3 @@',
|
||||
' a',
|
||||
'+b',
|
||||
' c',
|
||||
'@@ -10,2 +11,2 @@',
|
||||
' d',
|
||||
'-e',
|
||||
'+f',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("bar.ts")!;
|
||||
expect(hunks).toHaveLength(2);
|
||||
expect(hunks[0].oldStart).toBe(1);
|
||||
expect(hunks[1].oldStart).toBe(10);
|
||||
});
|
||||
const result = parseGitDiff(input)
|
||||
const hunks = result.get('bar.ts')!
|
||||
expect(hunks).toHaveLength(2)
|
||||
expect(hunks[0].oldStart).toBe(1)
|
||||
expect(hunks[1].oldStart).toBe(10)
|
||||
})
|
||||
|
||||
test("skips binary files marker", () => {
|
||||
test('skips binary files marker', () => {
|
||||
const input = [
|
||||
"diff --git a/img.png b/img.png",
|
||||
"Binary files a/img.png and b/img.png differ",
|
||||
].join("\n");
|
||||
'diff --git a/img.png b/img.png',
|
||||
'Binary files a/img.png and b/img.png differ',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const result = parseGitDiff(input)
|
||||
// Binary file has no hunks, so it's not in the result
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("parses new file mode", () => {
|
||||
test('parses new file mode', () => {
|
||||
const input = [
|
||||
"diff --git a/new.ts b/new.ts",
|
||||
"new file mode 100644",
|
||||
"--- /dev/null",
|
||||
"+++ b/new.ts",
|
||||
"@@ -0,0 +1,2 @@",
|
||||
"+line1",
|
||||
"+line2",
|
||||
].join("\n");
|
||||
'diff --git a/new.ts b/new.ts',
|
||||
'new file mode 100644',
|
||||
'--- /dev/null',
|
||||
'+++ b/new.ts',
|
||||
'@@ -0,0 +1,2 @@',
|
||||
'+line1',
|
||||
'+line2',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("new.ts")!;
|
||||
expect(hunks).toHaveLength(1);
|
||||
expect(hunks[0].lines).toEqual(["+line1", "+line2"]);
|
||||
});
|
||||
const result = parseGitDiff(input)
|
||||
const hunks = result.get('new.ts')!
|
||||
expect(hunks).toHaveLength(1)
|
||||
expect(hunks[0].lines).toEqual(['+line1', '+line2'])
|
||||
})
|
||||
|
||||
test("parses deleted file", () => {
|
||||
test('parses deleted file', () => {
|
||||
const input = [
|
||||
"diff --git a/old.ts b/old.ts",
|
||||
"deleted file mode 100644",
|
||||
"--- a/old.ts",
|
||||
"+++ /dev/null",
|
||||
"@@ -1,2 +0,0 @@",
|
||||
"-line1",
|
||||
"-line2",
|
||||
].join("\n");
|
||||
'diff --git a/old.ts b/old.ts',
|
||||
'deleted file mode 100644',
|
||||
'--- a/old.ts',
|
||||
'+++ /dev/null',
|
||||
'@@ -1,2 +0,0 @@',
|
||||
'-line1',
|
||||
'-line2',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("old.ts")!;
|
||||
expect(hunks).toHaveLength(1);
|
||||
});
|
||||
const result = parseGitDiff(input)
|
||||
const hunks = result.get('old.ts')!
|
||||
expect(hunks).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("returns empty map for empty input", () => {
|
||||
const result = parseGitDiff("");
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
test('returns empty map for empty input', () => {
|
||||
const result = parseGitDiff('')
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
test("handles multiple files", () => {
|
||||
test('handles multiple files', () => {
|
||||
const input = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-x",
|
||||
"+y",
|
||||
].join("\n");
|
||||
'diff --git a/a.ts b/a.ts',
|
||||
'--- a/a.ts',
|
||||
'+++ b/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old',
|
||||
'+new',
|
||||
'diff --git a/b.ts b/b.ts',
|
||||
'--- a/b.ts',
|
||||
'+++ b/b.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-x',
|
||||
'+y',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.has("a.ts")).toBe(true);
|
||||
expect(result.has("b.ts")).toBe(true);
|
||||
});
|
||||
const result = parseGitDiff(input)
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has('a.ts')).toBe(true)
|
||||
expect(result.has('b.ts')).toBe(true)
|
||||
})
|
||||
|
||||
test("skips hunk without comma (single line)", () => {
|
||||
test('skips hunk without comma (single line)', () => {
|
||||
const input = [
|
||||
"diff --git a/solo.ts b/solo.ts",
|
||||
"--- a/solo.ts",
|
||||
"+++ b/solo.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
].join("\n");
|
||||
'diff --git a/solo.ts b/solo.ts',
|
||||
'--- a/solo.ts',
|
||||
'+++ b/solo.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old',
|
||||
'+new',
|
||||
].join('\n')
|
||||
|
||||
const result = parseGitDiff(input);
|
||||
const hunks = result.get("solo.ts")!;
|
||||
expect(hunks[0].oldLines).toBe(1); // default when no comma
|
||||
expect(hunks[0].newLines).toBe(1);
|
||||
});
|
||||
});
|
||||
const result = parseGitDiff(input)
|
||||
const hunks = result.get('solo.ts')!
|
||||
expect(hunks[0].oldLines).toBe(1) // default when no comma
|
||||
expect(hunks[0].newLines).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseShortstat", () => {
|
||||
test("parses full shortstat with insertions and deletions", () => {
|
||||
const result = parseShortstat(" 3 files changed, 10 insertions(+), 5 deletions(-)");
|
||||
describe('parseShortstat', () => {
|
||||
test('parses full shortstat with insertions and deletions', () => {
|
||||
const result = parseShortstat(
|
||||
' 3 files changed, 10 insertions(+), 5 deletions(-)',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
filesCount: 3,
|
||||
linesAdded: 10,
|
||||
linesRemoved: 5,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses single file", () => {
|
||||
const result = parseShortstat(" 1 file changed, 2 insertions(+), 1 deletion(-)");
|
||||
test('parses single file', () => {
|
||||
const result = parseShortstat(
|
||||
' 1 file changed, 2 insertions(+), 1 deletion(-)',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 2,
|
||||
linesRemoved: 1,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses insertions only", () => {
|
||||
const result = parseShortstat(" 2 files changed, 5 insertions(+)");
|
||||
test('parses insertions only', () => {
|
||||
const result = parseShortstat(' 2 files changed, 5 insertions(+)')
|
||||
expect(result).toEqual({
|
||||
filesCount: 2,
|
||||
linesAdded: 5,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses deletions only", () => {
|
||||
const result = parseShortstat(" 1 file changed, 3 deletions(-)");
|
||||
test('parses deletions only', () => {
|
||||
const result = parseShortstat(' 1 file changed, 3 deletions(-)')
|
||||
expect(result).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 3,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses files changed only (no insertions or deletions)", () => {
|
||||
const result = parseShortstat(" 2 files changed");
|
||||
test('parses files changed only (no insertions or deletions)', () => {
|
||||
const result = parseShortstat(' 2 files changed')
|
||||
expect(result).toEqual({
|
||||
filesCount: 2,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseShortstat("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(parseShortstat('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for non-matching string", () => {
|
||||
expect(parseShortstat("nothing to see here")).toBeNull();
|
||||
});
|
||||
test('returns null for non-matching string', () => {
|
||||
expect(parseShortstat('nothing to see here')).toBeNull()
|
||||
})
|
||||
|
||||
test("handles large numbers", () => {
|
||||
const result = parseShortstat(" 100 files changed, 50000 insertions(+), 30000 deletions(-)");
|
||||
test('handles large numbers', () => {
|
||||
const result = parseShortstat(
|
||||
' 100 files changed, 50000 insertions(+), 30000 deletions(-)',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
filesCount: 100,
|
||||
linesAdded: 50000,
|
||||
linesRemoved: 30000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("handles zero insertions and deletions explicitly", () => {
|
||||
test('handles zero insertions and deletions explicitly', () => {
|
||||
// git can output "0 insertions(+), 0 deletions(-)"
|
||||
const result = parseShortstat(" 1 file changed, 0 insertions(+), 0 deletions(-)");
|
||||
const result = parseShortstat(
|
||||
' 1 file changed, 0 insertions(+), 0 deletions(-)',
|
||||
)
|
||||
expect(result).toEqual({
|
||||
filesCount: 1,
|
||||
linesAdded: 0,
|
||||
linesRemoved: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractGlobBaseDirectory } from "../glob";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { extractGlobBaseDirectory } from '../glob'
|
||||
|
||||
describe("extractGlobBaseDirectory", () => {
|
||||
test("extracts base dir from glob with *", () => {
|
||||
const result = extractGlobBaseDirectory("src/utils/*.ts");
|
||||
expect(result.baseDir).toBe("src/utils");
|
||||
expect(result.relativePattern).toBe("*.ts");
|
||||
});
|
||||
describe('extractGlobBaseDirectory', () => {
|
||||
test('extracts base dir from glob with *', () => {
|
||||
const result = extractGlobBaseDirectory('src/utils/*.ts')
|
||||
expect(result.baseDir).toBe('src/utils')
|
||||
expect(result.relativePattern).toBe('*.ts')
|
||||
})
|
||||
|
||||
test("extracts base dir from glob with **", () => {
|
||||
const result = extractGlobBaseDirectory("src/**/*.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("**/*.ts");
|
||||
});
|
||||
test('extracts base dir from glob with **', () => {
|
||||
const result = extractGlobBaseDirectory('src/**/*.ts')
|
||||
expect(result.baseDir).toBe('src')
|
||||
expect(result.relativePattern).toBe('**/*.ts')
|
||||
})
|
||||
|
||||
test("returns dirname for literal path", () => {
|
||||
const result = extractGlobBaseDirectory("src/utils/file.ts");
|
||||
expect(result.baseDir).toBe("src/utils");
|
||||
expect(result.relativePattern).toBe("file.ts");
|
||||
});
|
||||
test('returns dirname for literal path', () => {
|
||||
const result = extractGlobBaseDirectory('src/utils/file.ts')
|
||||
expect(result.baseDir).toBe('src/utils')
|
||||
expect(result.relativePattern).toBe('file.ts')
|
||||
})
|
||||
|
||||
test("handles glob starting with pattern", () => {
|
||||
const result = extractGlobBaseDirectory("*.ts");
|
||||
expect(result.baseDir).toBe("");
|
||||
expect(result.relativePattern).toBe("*.ts");
|
||||
});
|
||||
test('handles glob starting with pattern', () => {
|
||||
const result = extractGlobBaseDirectory('*.ts')
|
||||
expect(result.baseDir).toBe('')
|
||||
expect(result.relativePattern).toBe('*.ts')
|
||||
})
|
||||
|
||||
test("handles braces pattern", () => {
|
||||
const result = extractGlobBaseDirectory("src/{a,b}/*.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("{a,b}/*.ts");
|
||||
});
|
||||
test('handles braces pattern', () => {
|
||||
const result = extractGlobBaseDirectory('src/{a,b}/*.ts')
|
||||
expect(result.baseDir).toBe('src')
|
||||
expect(result.relativePattern).toBe('{a,b}/*.ts')
|
||||
})
|
||||
|
||||
test("handles question mark pattern", () => {
|
||||
const result = extractGlobBaseDirectory("src/?.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("?.ts");
|
||||
});
|
||||
});
|
||||
test('handles question mark pattern', () => {
|
||||
const result = extractGlobBaseDirectory('src/?.ts')
|
||||
expect(result.baseDir).toBe('src')
|
||||
expect(result.relativePattern).toBe('?.ts')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,152 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { applyGrouping } from "../groupToolUses";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { applyGrouping } from '../groupToolUses'
|
||||
|
||||
// Helper: build minimal tool-use assistant message
|
||||
function makeToolUseMsg(
|
||||
uuid: string,
|
||||
messageId: string,
|
||||
toolUseId: string,
|
||||
toolName: string
|
||||
toolName: string,
|
||||
): any {
|
||||
return {
|
||||
type: "assistant",
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
message: {
|
||||
id: messageId,
|
||||
content: [{ type: "tool_use", id: toolUseId, name: toolName, input: {} }],
|
||||
content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: {} }],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: build minimal tool-result user message
|
||||
function makeToolResultMsg(uuid: string, toolUseId: string): any {
|
||||
return {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content: "ok" }],
|
||||
content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'ok' }],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: build minimal text assistant message
|
||||
function makeTextMsg(uuid: string, text: string): any {
|
||||
return {
|
||||
type: "assistant",
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
timestamp: Date.now(),
|
||||
message: { id: `msg-${uuid}`, content: [{ type: "text", text }] },
|
||||
};
|
||||
message: { id: `msg-${uuid}`, content: [{ type: 'text', text }] },
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal tool definitions
|
||||
const groupableTool: any = { name: "Grep", renderGroupedToolUse: true };
|
||||
const nonGroupableTool: any = { name: "Bash", renderGroupedToolUse: undefined };
|
||||
const groupableTool: any = { name: 'Grep', renderGroupedToolUse: true }
|
||||
const nonGroupableTool: any = { name: 'Bash', renderGroupedToolUse: undefined }
|
||||
|
||||
// ─── applyGrouping ────────────────────────────────────────────────────
|
||||
|
||||
describe("applyGrouping", () => {
|
||||
test("returns all messages in verbose mode", () => {
|
||||
describe('applyGrouping', () => {
|
||||
test('returns all messages in verbose mode', () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool], true);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages).toBe(msgs); // same reference
|
||||
});
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Grep'),
|
||||
makeToolUseMsg('u2', 'm1', 'tu2', 'Grep'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [groupableTool], true)
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages).toBe(msgs) // same reference
|
||||
})
|
||||
|
||||
test("does not group when tool lacks renderGroupedToolUse", () => {
|
||||
test('does not group when tool lacks renderGroupedToolUse', () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Bash"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Bash"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [nonGroupableTool]);
|
||||
expect(result.messages).toHaveLength(2);
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Bash'),
|
||||
makeToolUseMsg('u2', 'm1', 'tu2', 'Bash'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [nonGroupableTool])
|
||||
expect(result.messages).toHaveLength(2)
|
||||
// Both messages should pass through as-is
|
||||
expect(result.messages[0]).toBe(msgs[0]);
|
||||
});
|
||||
expect(result.messages[0]).toBe(msgs[0])
|
||||
})
|
||||
|
||||
test("does not group single tool use", () => {
|
||||
const msgs = [makeToolUseMsg("u1", "m1", "tu1", "Grep")];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect((result.messages[0] as any).type).toBe("assistant");
|
||||
});
|
||||
test('does not group single tool use', () => {
|
||||
const msgs = [makeToolUseMsg('u1', 'm1', 'tu1', 'Grep')]
|
||||
const result = applyGrouping(msgs, [groupableTool])
|
||||
expect(result.messages).toHaveLength(1)
|
||||
expect((result.messages[0] as any).type).toBe('assistant')
|
||||
})
|
||||
|
||||
test("groups 2+ tool uses of same type from same message", () => {
|
||||
test('groups 2+ tool uses of same type from same message', () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeToolUseMsg("u3", "m1", "tu3", "Grep"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const grouped = result.messages[0] as any;
|
||||
expect(grouped.type).toBe("grouped_tool_use");
|
||||
expect(grouped.toolName).toBe("Grep");
|
||||
expect(grouped.messages).toHaveLength(3);
|
||||
});
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Grep'),
|
||||
makeToolUseMsg('u2', 'm1', 'tu2', 'Grep'),
|
||||
makeToolUseMsg('u3', 'm1', 'tu3', 'Grep'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [groupableTool])
|
||||
expect(result.messages).toHaveLength(1)
|
||||
const grouped = result.messages[0] as any
|
||||
expect(grouped.type).toBe('grouped_tool_use')
|
||||
expect(grouped.toolName).toBe('Grep')
|
||||
expect(grouped.messages).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("does not group tool uses from different messages", () => {
|
||||
test('does not group tool uses from different messages', () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m2", "tu2", "Grep"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Grep'),
|
||||
makeToolUseMsg('u2', 'm2', 'tu2', 'Grep'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [groupableTool])
|
||||
// Each belongs to a different message.id, so no group (< 2 per group)
|
||||
expect(result.messages).toHaveLength(2);
|
||||
});
|
||||
expect(result.messages).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("collects tool results for grouped uses", () => {
|
||||
test('collects tool results for grouped uses', () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeToolResultMsg("u3", "tu1"),
|
||||
makeToolResultMsg("u4", "tu2"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
const grouped = result.messages[0] as any;
|
||||
expect(grouped.type).toBe("grouped_tool_use");
|
||||
expect(grouped.results).toHaveLength(2);
|
||||
});
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Grep'),
|
||||
makeToolUseMsg('u2', 'm1', 'tu2', 'Grep'),
|
||||
makeToolResultMsg('u3', 'tu1'),
|
||||
makeToolResultMsg('u4', 'tu2'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [groupableTool])
|
||||
const grouped = result.messages[0] as any
|
||||
expect(grouped.type).toBe('grouped_tool_use')
|
||||
expect(grouped.results).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("skips user messages whose tool_results are all grouped", () => {
|
||||
test('skips user messages whose tool_results are all grouped', () => {
|
||||
const msgs = [
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeToolResultMsg("u3", "tu1"),
|
||||
makeToolResultMsg("u4", "tu2"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Grep'),
|
||||
makeToolUseMsg('u2', 'm1', 'tu2', 'Grep'),
|
||||
makeToolResultMsg('u3', 'tu1'),
|
||||
makeToolResultMsg('u4', 'tu2'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [groupableTool])
|
||||
// Only the grouped message should remain — result messages are consumed
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
expect(result.messages).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("preserves non-grouped messages alongside groups", () => {
|
||||
test('preserves non-grouped messages alongside groups', () => {
|
||||
const msgs = [
|
||||
makeTextMsg("u0", "thinking..."),
|
||||
makeToolUseMsg("u1", "m1", "tu1", "Grep"),
|
||||
makeToolUseMsg("u2", "m1", "tu2", "Grep"),
|
||||
makeTextMsg("u3", "done"),
|
||||
];
|
||||
const result = applyGrouping(msgs, [groupableTool]);
|
||||
expect(result.messages).toHaveLength(3); // text + grouped + text
|
||||
expect((result.messages[0] as any).type).toBe("assistant");
|
||||
expect((result.messages[1] as any).type).toBe("grouped_tool_use");
|
||||
expect((result.messages[2] as any).type).toBe("assistant");
|
||||
});
|
||||
makeTextMsg('u0', 'thinking...'),
|
||||
makeToolUseMsg('u1', 'm1', 'tu1', 'Grep'),
|
||||
makeToolUseMsg('u2', 'm1', 'tu2', 'Grep'),
|
||||
makeTextMsg('u3', 'done'),
|
||||
]
|
||||
const result = applyGrouping(msgs, [groupableTool])
|
||||
expect(result.messages).toHaveLength(3) // text + grouped + text
|
||||
expect((result.messages[0] as any).type).toBe('assistant')
|
||||
expect((result.messages[1] as any).type).toBe('grouped_tool_use')
|
||||
expect((result.messages[2] as any).type).toBe('assistant')
|
||||
})
|
||||
|
||||
test("handles empty messages array", () => {
|
||||
const result = applyGrouping([], [groupableTool]);
|
||||
expect(result.messages).toHaveLength(0);
|
||||
});
|
||||
test('handles empty messages array', () => {
|
||||
const result = applyGrouping([], [groupableTool])
|
||||
expect(result.messages).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("handles empty tools array", () => {
|
||||
const msgs = [makeToolUseMsg("u1", "m1", "tu1", "Grep")];
|
||||
const result = applyGrouping(msgs, []);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
test('handles empty tools array', () => {
|
||||
const msgs = [makeToolUseMsg('u1', 'm1', 'tu1', 'Grep')]
|
||||
const result = applyGrouping(msgs, [])
|
||||
expect(result.messages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { djb2Hash, hashContent, hashPair } from "../hash";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { djb2Hash, hashContent, hashPair } from '../hash'
|
||||
|
||||
describe("djb2Hash", () => {
|
||||
test("returns a number", () => {
|
||||
expect(typeof djb2Hash("hello")).toBe("number");
|
||||
});
|
||||
describe('djb2Hash', () => {
|
||||
test('returns a number', () => {
|
||||
expect(typeof djb2Hash('hello')).toBe('number')
|
||||
})
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(djb2Hash("")).toBe(0);
|
||||
});
|
||||
test('returns 0 for empty string', () => {
|
||||
expect(djb2Hash('')).toBe(0)
|
||||
})
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(djb2Hash("test")).toBe(djb2Hash("test"));
|
||||
});
|
||||
test('is deterministic', () => {
|
||||
expect(djb2Hash('test')).toBe(djb2Hash('test'))
|
||||
})
|
||||
|
||||
test("different strings produce different hashes", () => {
|
||||
expect(djb2Hash("abc")).not.toBe(djb2Hash("def"));
|
||||
});
|
||||
test('different strings produce different hashes', () => {
|
||||
expect(djb2Hash('abc')).not.toBe(djb2Hash('def'))
|
||||
})
|
||||
|
||||
test("returns 32-bit integer", () => {
|
||||
const hash = djb2Hash("some long string to hash");
|
||||
expect(Number.isSafeInteger(hash)).toBe(true);
|
||||
});
|
||||
test('returns 32-bit integer', () => {
|
||||
const hash = djb2Hash('some long string to hash')
|
||||
expect(Number.isSafeInteger(hash)).toBe(true)
|
||||
})
|
||||
|
||||
test("has known answer for 'hello'", () => {
|
||||
expect(djb2Hash("hello")).toBe(99162322);
|
||||
});
|
||||
});
|
||||
expect(djb2Hash('hello')).toBe(99162322)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hashContent", () => {
|
||||
test("returns a string", () => {
|
||||
expect(typeof hashContent("hello")).toBe("string");
|
||||
});
|
||||
describe('hashContent', () => {
|
||||
test('returns a string', () => {
|
||||
expect(typeof hashContent('hello')).toBe('string')
|
||||
})
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashContent("test")).toBe(hashContent("test"));
|
||||
});
|
||||
test('is deterministic', () => {
|
||||
expect(hashContent('test')).toBe(hashContent('test'))
|
||||
})
|
||||
|
||||
test("different strings produce different hashes", () => {
|
||||
expect(hashContent("abc")).not.toBe(hashContent("def"));
|
||||
});
|
||||
test('different strings produce different hashes', () => {
|
||||
expect(hashContent('abc')).not.toBe(hashContent('def'))
|
||||
})
|
||||
|
||||
test("returns numeric string for empty string", () => {
|
||||
expect(hashContent("")).toMatch(/^\d+$/);
|
||||
});
|
||||
test('returns numeric string for empty string', () => {
|
||||
expect(hashContent('')).toMatch(/^\d+$/)
|
||||
})
|
||||
|
||||
test("returns numeric string format", () => {
|
||||
expect(hashContent("hello")).toMatch(/^\d+$/);
|
||||
});
|
||||
});
|
||||
test('returns numeric string format', () => {
|
||||
expect(hashContent('hello')).toMatch(/^\d+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hashPair", () => {
|
||||
test("returns a string", () => {
|
||||
expect(typeof hashPair("a", "b")).toBe("string");
|
||||
});
|
||||
describe('hashPair', () => {
|
||||
test('returns a string', () => {
|
||||
expect(typeof hashPair('a', 'b')).toBe('string')
|
||||
})
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashPair("a", "b")).toBe(hashPair("a", "b"));
|
||||
});
|
||||
test('is deterministic', () => {
|
||||
expect(hashPair('a', 'b')).toBe(hashPair('a', 'b'))
|
||||
})
|
||||
|
||||
test("order matters", () => {
|
||||
expect(hashPair("a", "b")).not.toBe(hashPair("b", "a"));
|
||||
});
|
||||
test('order matters', () => {
|
||||
expect(hashPair('a', 'b')).not.toBe(hashPair('b', 'a'))
|
||||
})
|
||||
|
||||
test("disambiguates different splits", () => {
|
||||
expect(hashPair("ts", "code")).not.toBe(hashPair("tsc", "ode"));
|
||||
});
|
||||
test('disambiguates different splits', () => {
|
||||
expect(hashPair('ts', 'code')).not.toBe(hashPair('tsc', 'ode'))
|
||||
})
|
||||
|
||||
test("handles empty strings", () => {
|
||||
expect(hashPair("", "")).toMatch(/^\d+$/);
|
||||
expect(hashPair("", "a")).toMatch(/^\d+$/);
|
||||
expect(hashPair("a", "")).toMatch(/^\d+$/);
|
||||
expect(hashPair("", "a")).not.toBe(hashPair("a", ""));
|
||||
});
|
||||
});
|
||||
test('handles empty strings', () => {
|
||||
expect(hashPair('', '')).toMatch(/^\d+$/)
|
||||
expect(hashPair('', 'a')).toMatch(/^\d+$/)
|
||||
expect(hashPair('a', '')).toMatch(/^\d+$/)
|
||||
expect(hashPair('', 'a')).not.toBe(hashPair('a', ''))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,163 +1,163 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { calculateHorizontalScrollWindow } from "../horizontalScroll";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { calculateHorizontalScrollWindow } from '../horizontalScroll'
|
||||
|
||||
describe("calculateHorizontalScrollWindow", () => {
|
||||
describe('calculateHorizontalScrollWindow', () => {
|
||||
// Basic scenarios
|
||||
test("all items fit within available width", () => {
|
||||
const result = calculateHorizontalScrollWindow([10, 10, 10], 50, 3, 1);
|
||||
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);
|
||||
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 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 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 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];
|
||||
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);
|
||||
});
|
||||
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('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('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('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];
|
||||
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);
|
||||
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);
|
||||
});
|
||||
expect(result.startIndex).toBeLessThanOrEqual(3)
|
||||
expect(result.endIndex).toBeGreaterThan(3)
|
||||
})
|
||||
|
||||
// Boundary conditions
|
||||
test("empty itemWidths array", () => {
|
||||
const result = calculateHorizontalScrollWindow([], 50, 3, 0);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
});
|
||||
expect(result.startIndex).toBe(0)
|
||||
})
|
||||
|
||||
test("item wider than available width", () => {
|
||||
const result = calculateHorizontalScrollWindow([100], 50, 3, 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);
|
||||
});
|
||||
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('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('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);
|
||||
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);
|
||||
});
|
||||
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('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];
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createHyperlink, OSC8_START, OSC8_END } from "../hyperlink";
|
||||
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;;");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
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 });
|
||||
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);
|
||||
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}`);
|
||||
});
|
||||
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 });
|
||||
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}`);
|
||||
});
|
||||
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", {
|
||||
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);
|
||||
});
|
||||
expect(result).toBe(url)
|
||||
})
|
||||
|
||||
test("not supported + no content: returns plain URL", () => {
|
||||
const url = "https://example.com/path?q=1";
|
||||
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);
|
||||
});
|
||||
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 });
|
||||
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}`);
|
||||
});
|
||||
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";
|
||||
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);
|
||||
});
|
||||
expect(result).toBe(url)
|
||||
})
|
||||
|
||||
test("supported link text contains the display content", () => {
|
||||
const result = createHyperlink("https://example.com", "text", {
|
||||
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
|
||||
);
|
||||
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");
|
||||
});
|
||||
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 });
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
expect(result).toStartWith(`${OSC8_START}${url}${OSC8_END}`)
|
||||
expect(result).toEndWith(`${OSC8_START}${OSC8_END}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
* causing API errors. The fix detects the actual format from magic bytes.
|
||||
*/
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { detectImageFormatFromBase64, detectImageFormatFromBuffer } from '../imageResizer.js'
|
||||
import {
|
||||
detectImageFormatFromBase64,
|
||||
detectImageFormatFromBuffer,
|
||||
} from '../imageResizer.js'
|
||||
|
||||
// ── Magic byte helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -17,9 +20,18 @@ const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])
|
||||
const GIF_HEADER = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
|
||||
/** WebP: RIFF....WEBP */
|
||||
const WEBP_HEADER = Buffer.from([
|
||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||
0x00, 0x00, 0x00, 0x00, // file size (placeholder)
|
||||
0x57, 0x45, 0x42, 0x50, // WEBP
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // RIFF
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // file size (placeholder)
|
||||
0x57,
|
||||
0x45,
|
||||
0x42,
|
||||
0x50, // WEBP
|
||||
])
|
||||
|
||||
function toBase64(buf: Buffer): string {
|
||||
@@ -64,7 +76,9 @@ describe('detectImageFormatFromBase64', () => {
|
||||
})
|
||||
|
||||
test('detects JPEG from base64-encoded JPEG header', () => {
|
||||
expect(detectImageFormatFromBase64(toBase64(JPEG_HEADER))).toBe('image/jpeg')
|
||||
expect(detectImageFormatFromBase64(toBase64(JPEG_HEADER))).toBe(
|
||||
'image/jpeg',
|
||||
)
|
||||
})
|
||||
|
||||
test('detects GIF from base64-encoded GIF header', () => {
|
||||
@@ -72,7 +86,9 @@ describe('detectImageFormatFromBase64', () => {
|
||||
})
|
||||
|
||||
test('detects WebP from base64-encoded WebP header', () => {
|
||||
expect(detectImageFormatFromBase64(toBase64(WEBP_HEADER))).toBe('image/webp')
|
||||
expect(detectImageFormatFromBase64(toBase64(WEBP_HEADER))).toBe(
|
||||
'image/webp',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns image/png as default for empty string', () => {
|
||||
|
||||
@@ -1,149 +1,149 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
import { logMock } from '../../../tests/mocks/log'
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain (log.ts → bootstrap/state.ts → analytics)
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
const { safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray } =
|
||||
await import("../json");
|
||||
await import('../json')
|
||||
|
||||
// ─── safeParseJSON ──────────────────────────────────────────────────────
|
||||
|
||||
describe("safeParseJSON", () => {
|
||||
test("parses valid object", () => {
|
||||
expect(safeParseJSON('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
describe('safeParseJSON', () => {
|
||||
test('parses valid object', () => {
|
||||
expect(safeParseJSON('{"a":1}')).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("parses valid array", () => {
|
||||
expect(safeParseJSON("[1,2,3]")).toEqual([1, 2, 3]);
|
||||
});
|
||||
test('parses valid array', () => {
|
||||
expect(safeParseJSON('[1,2,3]')).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("parses string value", () => {
|
||||
expect(safeParseJSON('"hello"')).toBe("hello");
|
||||
});
|
||||
test('parses string value', () => {
|
||||
expect(safeParseJSON('"hello"')).toBe('hello')
|
||||
})
|
||||
|
||||
test("parses number value", () => {
|
||||
expect(safeParseJSON("42")).toBe(42);
|
||||
});
|
||||
test('parses number value', () => {
|
||||
expect(safeParseJSON('42')).toBe(42)
|
||||
})
|
||||
|
||||
test("parses boolean value", () => {
|
||||
expect(safeParseJSON("true")).toBe(true);
|
||||
});
|
||||
test('parses boolean value', () => {
|
||||
expect(safeParseJSON('true')).toBe(true)
|
||||
})
|
||||
|
||||
test("parses null value", () => {
|
||||
expect(safeParseJSON("null")).toBeNull();
|
||||
});
|
||||
test('parses null value', () => {
|
||||
expect(safeParseJSON('null')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for invalid JSON", () => {
|
||||
expect(safeParseJSON("{bad}")).toBeNull();
|
||||
});
|
||||
test('returns null for invalid JSON', () => {
|
||||
expect(safeParseJSON('{bad}')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(safeParseJSON("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(safeParseJSON('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for undefined input", () => {
|
||||
expect(safeParseJSON(undefined as any)).toBeNull();
|
||||
});
|
||||
test('returns null for undefined input', () => {
|
||||
expect(safeParseJSON(undefined as any)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for null input", () => {
|
||||
expect(safeParseJSON(null as any)).toBeNull();
|
||||
});
|
||||
test('returns null for null input', () => {
|
||||
expect(safeParseJSON(null as any)).toBeNull()
|
||||
})
|
||||
|
||||
test("handles JSON with BOM", () => {
|
||||
expect(safeParseJSON('\uFEFF{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
test('handles JSON with BOM', () => {
|
||||
expect(safeParseJSON('\uFEFF{"a":1}')).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("parses nested objects", () => {
|
||||
const input = '{"a":{"b":{"c":1}}}';
|
||||
expect(safeParseJSON(input)).toEqual({ a: { b: { c: 1 } } });
|
||||
});
|
||||
});
|
||||
test('parses nested objects', () => {
|
||||
const input = '{"a":{"b":{"c":1}}}'
|
||||
expect(safeParseJSON(input)).toEqual({ a: { b: { c: 1 } } })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── safeParseJSONC ─────────────────────────────────────────────────────
|
||||
|
||||
describe("safeParseJSONC", () => {
|
||||
test("parses standard JSON", () => {
|
||||
expect(safeParseJSONC('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
describe('safeParseJSONC', () => {
|
||||
test('parses standard JSON', () => {
|
||||
expect(safeParseJSONC('{"a":1}')).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("parses JSON with single-line comments", () => {
|
||||
expect(safeParseJSONC('{\n// comment\n"a":1\n}')).toEqual({ a: 1 });
|
||||
});
|
||||
test('parses JSON with single-line comments', () => {
|
||||
expect(safeParseJSONC('{\n// comment\n"a":1\n}')).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("parses JSON with block comments", () => {
|
||||
expect(safeParseJSONC('{\n/* comment */\n"a":1\n}')).toEqual({ a: 1 });
|
||||
});
|
||||
test('parses JSON with block comments', () => {
|
||||
expect(safeParseJSONC('{\n/* comment */\n"a":1\n}')).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("parses JSON with trailing commas", () => {
|
||||
expect(safeParseJSONC('{"a":1,}')).toEqual({ a: 1 });
|
||||
});
|
||||
test('parses JSON with trailing commas', () => {
|
||||
expect(safeParseJSONC('{"a":1,}')).toEqual({ a: 1 })
|
||||
})
|
||||
|
||||
test("returns null for null input", () => {
|
||||
expect(safeParseJSONC(null as any)).toBeNull();
|
||||
});
|
||||
test('returns null for null input', () => {
|
||||
expect(safeParseJSONC(null as any)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(safeParseJSONC("")).toBeNull();
|
||||
});
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(safeParseJSONC('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── parseJSONL ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseJSONL", () => {
|
||||
test("parses multiple lines", () => {
|
||||
const result = parseJSONL('{"a":1}\n{"b":2}');
|
||||
expect(result).toEqual([{ a: 1 }, { b: 2 }]);
|
||||
});
|
||||
describe('parseJSONL', () => {
|
||||
test('parses multiple lines', () => {
|
||||
const result = parseJSONL('{"a":1}\n{"b":2}')
|
||||
expect(result).toEqual([{ a: 1 }, { b: 2 }])
|
||||
})
|
||||
|
||||
test("returns empty array for empty string", () => {
|
||||
expect(parseJSONL("")).toEqual([]);
|
||||
});
|
||||
test('returns empty array for empty string', () => {
|
||||
expect(parseJSONL('')).toEqual([])
|
||||
})
|
||||
|
||||
test("parses single line", () => {
|
||||
expect(parseJSONL('{"a":1}')).toEqual([{ a: 1 }]);
|
||||
});
|
||||
test('parses single line', () => {
|
||||
expect(parseJSONL('{"a":1}')).toEqual([{ a: 1 }])
|
||||
})
|
||||
|
||||
test("accepts Buffer input", () => {
|
||||
const buf = Buffer.from('{"x":1}\n{"y":2}');
|
||||
const result = parseJSONL(buf as any);
|
||||
expect(result).toEqual([{ x: 1 }, { y: 2 }]);
|
||||
});
|
||||
test('accepts Buffer input', () => {
|
||||
const buf = Buffer.from('{"x":1}\n{"y":2}')
|
||||
const result = parseJSONL(buf as any)
|
||||
expect(result).toEqual([{ x: 1 }, { y: 2 }])
|
||||
})
|
||||
|
||||
// NOTE: Skipping malformed-line test — Bun.JSONL.parseChunk hangs
|
||||
// indefinitely in its error-recovery loop when encountering bad lines.
|
||||
});
|
||||
})
|
||||
|
||||
// ─── addItemToJSONCArray ────────────────────────────────────────────────
|
||||
|
||||
describe("addItemToJSONCArray", () => {
|
||||
test("appends to existing array", () => {
|
||||
const result = addItemToJSONCArray('["a","b"]', "c");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
describe('addItemToJSONCArray', () => {
|
||||
test('appends to existing array', () => {
|
||||
const result = addItemToJSONCArray('["a","b"]', 'c')
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test("appends to empty array", () => {
|
||||
const result = addItemToJSONCArray("[]", "item");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["item"]);
|
||||
});
|
||||
test('appends to empty array', () => {
|
||||
const result = addItemToJSONCArray('[]', 'item')
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual(['item'])
|
||||
})
|
||||
|
||||
test("creates array from empty content", () => {
|
||||
const result = addItemToJSONCArray("", "first");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["first"]);
|
||||
});
|
||||
test('creates array from empty content', () => {
|
||||
const result = addItemToJSONCArray('', 'first')
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual(['first'])
|
||||
})
|
||||
|
||||
test("handles object item", () => {
|
||||
const result = addItemToJSONCArray("[]", { key: "val" });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual([{ key: "val" }]);
|
||||
});
|
||||
test('handles object item', () => {
|
||||
const result = addItemToJSONCArray('[]', { key: 'val' })
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual([{ key: 'val' }])
|
||||
})
|
||||
|
||||
test("wraps item in new array for non-array content", () => {
|
||||
const result = addItemToJSONCArray('{"a":1}', "item");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["item"]);
|
||||
});
|
||||
});
|
||||
test('wraps item in new array for non-array content', () => {
|
||||
const result = addItemToJSONCArray('{"a":1}', 'item')
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed).toEqual(['item'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,10 +21,14 @@ const { LanBeacon } = await import('../lanBeacon.js')
|
||||
|
||||
type MockCall = [string, ...unknown[]]
|
||||
|
||||
function getMessageHandler(): ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined {
|
||||
function getMessageHandler():
|
||||
| ((msg: Buffer, rinfo: { address: string; port: number }) => void)
|
||||
| undefined {
|
||||
const calls = mockSocket.on.mock.calls as unknown as MockCall[]
|
||||
const call = calls.find(c => c[0] === 'message')
|
||||
return call?.[1] as ((msg: Buffer, rinfo: { address: string; port: number }) => void) | undefined
|
||||
return call?.[1] as
|
||||
| ((msg: Buffer, rinfo: { address: string; port: number }) => void)
|
||||
| undefined
|
||||
}
|
||||
|
||||
describe('LanBeacon', () => {
|
||||
@@ -155,7 +159,10 @@ describe('LanBeacon', () => {
|
||||
beacon.updateAnnounce({ role: 'sub' })
|
||||
beacon.start()
|
||||
// The send call should include the updated role
|
||||
const sendCalls = mockSocket.send.mock.calls as unknown as [Buffer, ...unknown[]][]
|
||||
const sendCalls = mockSocket.send.mock.calls as unknown as [
|
||||
Buffer,
|
||||
...unknown[],
|
||||
][]
|
||||
const sendCall = sendCalls[0]
|
||||
if (sendCall) {
|
||||
const payload = JSON.parse(sendCall[0].toString())
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { lazySchema } from "../lazySchema";
|
||||
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");
|
||||
});
|
||||
describe('lazySchema', () => {
|
||||
test('returns a function', () => {
|
||||
const factory = lazySchema(() => 42)
|
||||
expect(typeof factory).toBe('function')
|
||||
})
|
||||
|
||||
test("calls factory on first invocation", () => {
|
||||
let callCount = 0;
|
||||
test('calls factory on first invocation', () => {
|
||||
let callCount = 0
|
||||
const factory = lazySchema(() => {
|
||||
callCount++;
|
||||
return "result";
|
||||
});
|
||||
factory();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
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('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;
|
||||
test('factory is called only once', () => {
|
||||
let callCount = 0
|
||||
const factory = lazySchema(() => {
|
||||
callCount++;
|
||||
return "cached";
|
||||
});
|
||||
factory();
|
||||
factory();
|
||||
factory();
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
callCount++
|
||||
return 'cached'
|
||||
})
|
||||
factory()
|
||||
factory()
|
||||
factory()
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
test("works with different return types", () => {
|
||||
const numFactory = lazySchema(() => 123);
|
||||
expect(numFactory()).toBe(123);
|
||||
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]);
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { padAligned } from "../markdown";
|
||||
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);
|
||||
});
|
||||
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('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('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('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('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('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 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 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('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);
|
||||
});
|
||||
});
|
||||
test('center alignment with odd padding distribution', () => {
|
||||
const result = padAligned('hi', 2, 7, 'center')
|
||||
expect(result).toBe(' hi ')
|
||||
expect(result.length).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
import { mock, describe, expect, test, beforeEach } from "bun:test";
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
import { mock, describe, expect, test, beforeEach } from 'bun:test'
|
||||
import { logMock } from '../../../tests/mocks/log'
|
||||
|
||||
// Mock log.ts to cut the bootstrap/state dependency chain
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import(
|
||||
"../memoize"
|
||||
);
|
||||
'../memoize'
|
||||
)
|
||||
|
||||
// ─── memoizeWithTTL ────────────────────────────────────────────────────
|
||||
|
||||
describe("memoizeWithTTL", () => {
|
||||
test("returns cached value on second call", () => {
|
||||
let calls = 0;
|
||||
describe('memoizeWithTTL', () => {
|
||||
test('returns cached value on second call', () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTL((x: number) => {
|
||||
calls++;
|
||||
return x * 2;
|
||||
}, 60_000);
|
||||
calls++
|
||||
return x * 2
|
||||
}, 60_000)
|
||||
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
expect(fn(5)).toBe(10)
|
||||
expect(fn(5)).toBe(10)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("different args get separate cache entries", () => {
|
||||
let calls = 0;
|
||||
test('different args get separate cache entries', () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTL((x: number) => {
|
||||
calls++;
|
||||
return x + 1;
|
||||
}, 60_000);
|
||||
calls++
|
||||
return x + 1
|
||||
}, 60_000)
|
||||
|
||||
expect(fn(1)).toBe(2);
|
||||
expect(fn(2)).toBe(3);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
expect(fn(1)).toBe(2)
|
||||
expect(fn(2)).toBe(3)
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test("cache.clear empties the cache", () => {
|
||||
let calls = 0;
|
||||
test('cache.clear empties the cache', () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTL(() => {
|
||||
calls++;
|
||||
return "val";
|
||||
}, 60_000);
|
||||
calls++
|
||||
return 'val'
|
||||
}, 60_000)
|
||||
|
||||
fn();
|
||||
fn.cache.clear();
|
||||
fn();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
fn()
|
||||
fn.cache.clear()
|
||||
fn()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test("returns stale value and triggers background refresh after TTL", async () => {
|
||||
let calls = 0;
|
||||
test('returns stale value and triggers background refresh after TTL', async () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTL((x: number) => {
|
||||
calls++;
|
||||
return x * calls;
|
||||
}, 1); // 1ms TTL
|
||||
calls++
|
||||
return x * calls
|
||||
}, 1) // 1ms TTL
|
||||
|
||||
const first = fn(10);
|
||||
expect(first).toBe(10); // calls=1, 10*1
|
||||
const first = fn(10)
|
||||
expect(first).toBe(10) // calls=1, 10*1
|
||||
|
||||
// Wait for TTL to expire
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
// Should return stale value (10) and trigger background refresh
|
||||
const second = fn(10);
|
||||
expect(second).toBe(10); // stale value returned immediately
|
||||
const second = fn(10)
|
||||
expect(second).toBe(10) // stale value returned immediately
|
||||
|
||||
// Wait for background refresh microtask
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
// Now cache should have refreshed value (calls=2 during refresh, 10*2=20)
|
||||
const third = fn(10);
|
||||
expect(third).toBe(20);
|
||||
});
|
||||
});
|
||||
const third = fn(10)
|
||||
expect(third).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── memoizeWithTTLAsync ───────────────────────────────────────────────
|
||||
|
||||
describe("memoizeWithTTLAsync", () => {
|
||||
test("caches async result", async () => {
|
||||
let calls = 0;
|
||||
describe('memoizeWithTTLAsync', () => {
|
||||
test('caches async result', async () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTLAsync(async (x: number) => {
|
||||
calls++;
|
||||
return x * 2;
|
||||
}, 60_000);
|
||||
calls++
|
||||
return x * 2
|
||||
}, 60_000)
|
||||
|
||||
expect(await fn(5)).toBe(10);
|
||||
expect(await fn(5)).toBe(10);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
expect(await fn(5)).toBe(10)
|
||||
expect(await fn(5)).toBe(10)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent cold-miss calls", async () => {
|
||||
let calls = 0;
|
||||
test('deduplicates concurrent cold-miss calls', async () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTLAsync(async (x: number) => {
|
||||
calls++;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return x;
|
||||
}, 60_000);
|
||||
calls++
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
return x
|
||||
}, 60_000)
|
||||
|
||||
const [a, b, c] = await Promise.all([fn(1), fn(1), fn(1)]);
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
expect(c).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
const [a, b, c] = await Promise.all([fn(1), fn(1), fn(1)])
|
||||
expect(a).toBe(1)
|
||||
expect(b).toBe(1)
|
||||
expect(c).toBe(1)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("cache.clear forces re-computation", async () => {
|
||||
let calls = 0;
|
||||
test('cache.clear forces re-computation', async () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTLAsync(async () => {
|
||||
calls++;
|
||||
return "v";
|
||||
}, 60_000);
|
||||
calls++
|
||||
return 'v'
|
||||
}, 60_000)
|
||||
|
||||
await fn();
|
||||
fn.cache.clear();
|
||||
await fn();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
await fn()
|
||||
fn.cache.clear()
|
||||
await fn()
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
test("returns stale value on TTL expiry", async () => {
|
||||
let calls = 0;
|
||||
test('returns stale value on TTL expiry', async () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithTTLAsync(async () => {
|
||||
calls++;
|
||||
return calls;
|
||||
}, 1); // 1ms TTL
|
||||
calls++
|
||||
return calls
|
||||
}, 1) // 1ms TTL
|
||||
|
||||
const first = await fn();
|
||||
expect(first).toBe(1);
|
||||
const first = await fn()
|
||||
expect(first).toBe(1)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
// Should return stale value (1) immediately
|
||||
const second = await fn();
|
||||
expect(second).toBe(1);
|
||||
});
|
||||
});
|
||||
const second = await fn()
|
||||
expect(second).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── memoizeWithLRU ────────────────────────────────────────────────────
|
||||
|
||||
describe("memoizeWithLRU", () => {
|
||||
test("caches results by key", () => {
|
||||
let calls = 0;
|
||||
describe('memoizeWithLRU', () => {
|
||||
test('caches results by key', () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => {
|
||||
calls++;
|
||||
return x * 2;
|
||||
calls++
|
||||
return x * 2
|
||||
},
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
x => String(x),
|
||||
10,
|
||||
)
|
||||
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(fn(5)).toBe(10);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
expect(fn(5)).toBe(10)
|
||||
expect(fn(5)).toBe(10)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("evicts least recently used when max reached", () => {
|
||||
let calls = 0;
|
||||
test('evicts least recently used when max reached', () => {
|
||||
let calls = 0
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => {
|
||||
calls++;
|
||||
return x;
|
||||
calls++
|
||||
return x
|
||||
},
|
||||
(x) => String(x),
|
||||
3
|
||||
);
|
||||
x => String(x),
|
||||
3,
|
||||
)
|
||||
|
||||
fn(1);
|
||||
fn(2);
|
||||
fn(3);
|
||||
expect(calls).toBe(3);
|
||||
fn(1)
|
||||
fn(2)
|
||||
fn(3)
|
||||
expect(calls).toBe(3)
|
||||
|
||||
fn(4); // evicts key "1"
|
||||
expect(fn.cache.has("1")).toBe(false);
|
||||
expect(fn.cache.has("4")).toBe(true);
|
||||
});
|
||||
fn(4) // evicts key "1"
|
||||
expect(fn.cache.has('1')).toBe(false)
|
||||
expect(fn.cache.has('4')).toBe(true)
|
||||
})
|
||||
|
||||
test("cache.size returns current size", () => {
|
||||
test('cache.size returns current size', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
x => String(x),
|
||||
10,
|
||||
)
|
||||
|
||||
fn(1);
|
||||
fn(2);
|
||||
expect(fn.cache.size()).toBe(2);
|
||||
});
|
||||
fn(1)
|
||||
fn(2)
|
||||
expect(fn.cache.size()).toBe(2)
|
||||
})
|
||||
|
||||
test("cache.delete removes entry", () => {
|
||||
test('cache.delete removes entry', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
x => String(x),
|
||||
10,
|
||||
)
|
||||
|
||||
fn(1);
|
||||
expect(fn.cache.has("1")).toBe(true);
|
||||
fn.cache.delete("1");
|
||||
expect(fn.cache.has("1")).toBe(false);
|
||||
});
|
||||
fn(1)
|
||||
expect(fn.cache.has('1')).toBe(true)
|
||||
fn.cache.delete('1')
|
||||
expect(fn.cache.has('1')).toBe(false)
|
||||
})
|
||||
|
||||
test("cache.get returns value without updating recency", () => {
|
||||
test('cache.get returns value without updating recency', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x * 10,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
x => String(x),
|
||||
10,
|
||||
)
|
||||
|
||||
fn(5);
|
||||
expect(fn.cache.get("5")).toBe(50);
|
||||
});
|
||||
fn(5)
|
||||
expect(fn.cache.get('5')).toBe(50)
|
||||
})
|
||||
|
||||
test("cache.clear empties everything", () => {
|
||||
test('cache.clear empties everything', () => {
|
||||
const fn = memoizeWithLRU(
|
||||
(x: number) => x,
|
||||
(x) => String(x),
|
||||
10
|
||||
);
|
||||
x => String(x),
|
||||
10,
|
||||
)
|
||||
|
||||
fn(1);
|
||||
fn(2);
|
||||
fn.cache.clear();
|
||||
expect(fn.cache.size()).toBe(0);
|
||||
});
|
||||
});
|
||||
fn(1)
|
||||
fn(2)
|
||||
fn.cache.clear()
|
||||
expect(fn.cache.size()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,197 +1,215 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
clearCommandQueue,
|
||||
dequeue,
|
||||
dequeueAllMatching,
|
||||
enqueue,
|
||||
enqueuePendingNotification,
|
||||
hasCommandsInQueue,
|
||||
isSlashCommand,
|
||||
peek,
|
||||
resetCommandQueue,
|
||||
clearCommandQueue,
|
||||
dequeue,
|
||||
dequeueAllMatching,
|
||||
enqueue,
|
||||
enqueuePendingNotification,
|
||||
hasCommandsInQueue,
|
||||
isSlashCommand,
|
||||
peek,
|
||||
resetCommandQueue,
|
||||
} from '../messageQueueManager.js'
|
||||
|
||||
// Reset module-level queue state between tests
|
||||
beforeEach(() => {
|
||||
resetCommandQueue()
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCommandQueue()
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
describe('messageQueueManager.isSlashCommand', () => {
|
||||
test('treats normal slash commands as slash commands', () => {
|
||||
expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true)
|
||||
})
|
||||
test('treats normal slash commands as slash commands', () => {
|
||||
expect(isSlashCommand({ value: '/help', mode: 'prompt' } as any)).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
bridgeOrigin: true,
|
||||
} as any),
|
||||
).toBe(true)
|
||||
})
|
||||
test('keeps remote bridge slash commands slash-routed when bridgeOrigin is set', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
bridgeOrigin: true,
|
||||
} as any),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
} as any),
|
||||
).toBe(false)
|
||||
})
|
||||
test('keeps skipSlashCommands text-only when bridgeOrigin is absent', () => {
|
||||
expect(
|
||||
isSlashCommand({
|
||||
value: '/proactive',
|
||||
mode: 'prompt',
|
||||
skipSlashCommands: true,
|
||||
} as any),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager.enqueue', () => {
|
||||
test('adds command to queue with default next priority', () => {
|
||||
enqueue({ value: 'hello', mode: 'prompt' } as any)
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
const cmd = dequeue()
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.value).toBe('hello')
|
||||
expect(cmd!.priority).toBe('next')
|
||||
})
|
||||
test('adds command to queue with default next priority', () => {
|
||||
enqueue({ value: 'hello', mode: 'prompt' } as any)
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
const cmd = dequeue()
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.value).toBe('hello')
|
||||
expect(cmd!.priority).toBe('next')
|
||||
})
|
||||
|
||||
test('preserves explicit priority', () => {
|
||||
enqueue({ value: 'urgent', mode: 'prompt', priority: 'now' } as any)
|
||||
const cmd = dequeue()
|
||||
expect(cmd!.priority).toBe('now')
|
||||
})
|
||||
test('preserves explicit priority', () => {
|
||||
enqueue({ value: 'urgent', mode: 'prompt', priority: 'now' } as any)
|
||||
const cmd = dequeue()
|
||||
expect(cmd!.priority).toBe('now')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager.enqueuePendingNotification', () => {
|
||||
test('adds command with later priority', () => {
|
||||
enqueuePendingNotification({ value: '<task-notification/>', mode: 'task-notification' } as any)
|
||||
const cmd = dequeue()
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.priority).toBe('later')
|
||||
expect(cmd!.mode).toBe('task-notification')
|
||||
})
|
||||
test('adds command with later priority', () => {
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification/>',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
const cmd = dequeue()
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.priority).toBe('later')
|
||||
expect(cmd!.mode).toBe('task-notification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager.dequeue', () => {
|
||||
test('returns undefined when queue empty', () => {
|
||||
expect(dequeue()).toBeUndefined()
|
||||
})
|
||||
test('returns undefined when queue empty', () => {
|
||||
expect(dequeue()).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns highest priority command', () => {
|
||||
enqueuePendingNotification({ value: 'later-cmd', mode: 'task-notification' } as any)
|
||||
enqueue({ value: 'next-cmd', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'now-cmd', mode: 'prompt', priority: 'now' } as any)
|
||||
test('returns highest priority command', () => {
|
||||
enqueuePendingNotification({
|
||||
value: 'later-cmd',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
enqueue({ value: 'next-cmd', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'now-cmd', mode: 'prompt', priority: 'now' } as any)
|
||||
|
||||
const first = dequeue()
|
||||
expect(first!.value).toBe('now-cmd')
|
||||
const first = dequeue()
|
||||
expect(first!.value).toBe('now-cmd')
|
||||
|
||||
const second = dequeue()
|
||||
expect(second!.value).toBe('next-cmd')
|
||||
const second = dequeue()
|
||||
expect(second!.value).toBe('next-cmd')
|
||||
|
||||
const third = dequeue()
|
||||
expect(third!.value).toBe('later-cmd')
|
||||
})
|
||||
const third = dequeue()
|
||||
expect(third!.value).toBe('later-cmd')
|
||||
})
|
||||
|
||||
test('FIFO within same priority', () => {
|
||||
enqueue({ value: 'first', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'second', mode: 'prompt' } as any)
|
||||
test('FIFO within same priority', () => {
|
||||
enqueue({ value: 'first', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'second', mode: 'prompt' } as any)
|
||||
|
||||
expect(dequeue()!.value).toBe('first')
|
||||
expect(dequeue()!.value).toBe('second')
|
||||
})
|
||||
expect(dequeue()!.value).toBe('first')
|
||||
expect(dequeue()!.value).toBe('second')
|
||||
})
|
||||
|
||||
test('respects filter parameter', () => {
|
||||
enqueue({ value: 'prompt-cmd', mode: 'prompt' } as any)
|
||||
enqueuePendingNotification({ value: 'task-cmd', mode: 'task-notification' } as any)
|
||||
test('respects filter parameter', () => {
|
||||
enqueue({ value: 'prompt-cmd', mode: 'prompt' } as any)
|
||||
enqueuePendingNotification({
|
||||
value: 'task-cmd',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
|
||||
// Filter to only task-notification commands
|
||||
const cmd = dequeue(c => c.mode === 'task-notification')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.value).toBe('task-cmd')
|
||||
// Filter to only task-notification commands
|
||||
const cmd = dequeue(c => c.mode === 'task-notification')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.value).toBe('task-cmd')
|
||||
|
||||
// Prompt command should still be in queue
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
expect(dequeue()!.value).toBe('prompt-cmd')
|
||||
})
|
||||
// Prompt command should still be in queue
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
expect(dequeue()!.value).toBe('prompt-cmd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager.peek', () => {
|
||||
test('returns undefined when queue empty', () => {
|
||||
expect(peek()).toBeUndefined()
|
||||
})
|
||||
test('returns undefined when queue empty', () => {
|
||||
expect(peek()).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns highest priority without removing', () => {
|
||||
enqueuePendingNotification({ value: 'later', mode: 'task-notification' } as any)
|
||||
enqueue({ value: 'next', mode: 'prompt' } as any)
|
||||
test('returns highest priority without removing', () => {
|
||||
enqueuePendingNotification({
|
||||
value: 'later',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
enqueue({ value: 'next', mode: 'prompt' } as any)
|
||||
|
||||
expect(peek()!.value).toBe('next')
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
expect(dequeue()!.value).toBe('next')
|
||||
})
|
||||
expect(peek()!.value).toBe('next')
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
expect(dequeue()!.value).toBe('next')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager.dequeueAllMatching', () => {
|
||||
test('removes all matching commands', () => {
|
||||
enqueue({ value: 'a', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'b', mode: 'task-notification' } as any)
|
||||
enqueue({ value: 'c', mode: 'task-notification' } as any)
|
||||
test('removes all matching commands', () => {
|
||||
enqueue({ value: 'a', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'b', mode: 'task-notification' } as any)
|
||||
enqueue({ value: 'c', mode: 'task-notification' } as any)
|
||||
|
||||
const matched = dequeueAllMatching(c => c.mode === 'task-notification')
|
||||
expect(matched).toHaveLength(2)
|
||||
expect(matched.map(c => c.value)).toEqual(['b', 'c'])
|
||||
const matched = dequeueAllMatching(c => c.mode === 'task-notification')
|
||||
expect(matched).toHaveLength(2)
|
||||
expect(matched.map(c => c.value)).toEqual(['b', 'c'])
|
||||
|
||||
// Remaining command should still be in queue
|
||||
expect(dequeue()!.value).toBe('a')
|
||||
})
|
||||
// Remaining command should still be in queue
|
||||
expect(dequeue()!.value).toBe('a')
|
||||
})
|
||||
|
||||
test('returns empty array when no matches', () => {
|
||||
enqueue({ value: 'a', mode: 'prompt' } as any)
|
||||
const matched = dequeueAllMatching(c => c.mode === 'bash')
|
||||
expect(matched).toHaveLength(0)
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
})
|
||||
test('returns empty array when no matches', () => {
|
||||
enqueue({ value: 'a', mode: 'prompt' } as any)
|
||||
const matched = dequeueAllMatching(c => c.mode === 'bash')
|
||||
expect(matched).toHaveLength(0)
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
})
|
||||
|
||||
test('returns empty array when queue empty', () => {
|
||||
const matched = dequeueAllMatching(() => true)
|
||||
expect(matched).toHaveLength(0)
|
||||
})
|
||||
test('returns empty array when queue empty', () => {
|
||||
const matched = dequeueAllMatching(() => true)
|
||||
expect(matched).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager.clearCommandQueue', () => {
|
||||
test('removes all commands', () => {
|
||||
enqueue({ value: 'a', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'b', mode: 'prompt' } as any)
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
test('removes all commands', () => {
|
||||
enqueue({ value: 'a', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'b', mode: 'prompt' } as any)
|
||||
expect(hasCommandsInQueue()).toBe(true)
|
||||
|
||||
clearCommandQueue()
|
||||
expect(hasCommandsInQueue()).toBe(false)
|
||||
})
|
||||
clearCommandQueue()
|
||||
expect(hasCommandsInQueue()).toBe(false)
|
||||
})
|
||||
|
||||
test('no-op on empty queue', () => {
|
||||
clearCommandQueue()
|
||||
expect(hasCommandsInQueue()).toBe(false)
|
||||
})
|
||||
test('no-op on empty queue', () => {
|
||||
clearCommandQueue()
|
||||
expect(hasCommandsInQueue()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageQueueManager priority ordering', () => {
|
||||
test('now dequeued before next and later', () => {
|
||||
enqueuePendingNotification({ value: 'later', mode: 'task-notification' } as any)
|
||||
enqueue({ value: 'next', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'now', mode: 'prompt', priority: 'now' } as any)
|
||||
test('now dequeued before next and later', () => {
|
||||
enqueuePendingNotification({
|
||||
value: 'later',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
enqueue({ value: 'next', mode: 'prompt' } as any)
|
||||
enqueue({ value: 'now', mode: 'prompt', priority: 'now' } as any)
|
||||
|
||||
expect(dequeue()!.value).toBe('now')
|
||||
expect(dequeue()!.value).toBe('next')
|
||||
expect(dequeue()!.value).toBe('later')
|
||||
})
|
||||
expect(dequeue()!.value).toBe('now')
|
||||
expect(dequeue()!.value).toBe('next')
|
||||
expect(dequeue()!.value).toBe('later')
|
||||
})
|
||||
|
||||
test('next dequeued before later', () => {
|
||||
enqueuePendingNotification({ value: 'later', mode: 'task-notification' } as any)
|
||||
enqueue({ value: 'next', mode: 'prompt' } as any)
|
||||
test('next dequeued before later', () => {
|
||||
enqueuePendingNotification({
|
||||
value: 'later',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
enqueue({ value: 'next', mode: 'prompt' } as any)
|
||||
|
||||
expect(dequeue()!.value).toBe('next')
|
||||
expect(dequeue()!.value).toBe('later')
|
||||
})
|
||||
expect(dequeue()!.value).toBe('next')
|
||||
expect(dequeue()!.value).toBe('later')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
deriveShortMessageId,
|
||||
INTERRUPT_MESSAGE,
|
||||
@@ -27,488 +27,492 @@ import {
|
||||
AUTO_REJECT_MESSAGE,
|
||||
DONT_ASK_REJECT_MESSAGE,
|
||||
SYNTHETIC_MODEL,
|
||||
} from "../messages";
|
||||
import type { Message, AssistantMessage, UserMessage } from "../../types/message";
|
||||
} from '../messages'
|
||||
import type {
|
||||
Message,
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
} from '../../types/message'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAssistantMsg(
|
||||
contentBlocks: Array<{ type: string; text?: string; [key: string]: any }>
|
||||
contentBlocks: Array<{ type: string; text?: string; [key: string]: any }>,
|
||||
): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content: contentBlocks as any,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function makeUserMsg(text: string): UserMessage {
|
||||
return createUserMessage({ content: text });
|
||||
return createUserMessage({ content: text })
|
||||
}
|
||||
|
||||
// ─── deriveShortMessageId ───────────────────────────────────────────────
|
||||
|
||||
describe("deriveShortMessageId", () => {
|
||||
test("returns 6-char string", () => {
|
||||
const id = deriveShortMessageId("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(id).toHaveLength(6);
|
||||
});
|
||||
describe('deriveShortMessageId', () => {
|
||||
test('returns 6-char string', () => {
|
||||
const id = deriveShortMessageId('550e8400-e29b-41d4-a716-446655440000')
|
||||
expect(id).toHaveLength(6)
|
||||
})
|
||||
|
||||
test("is deterministic for same input", () => {
|
||||
const uuid = "a0b1c2d3-e4f5-6789-abcd-ef0123456789";
|
||||
expect(deriveShortMessageId(uuid)).toBe(deriveShortMessageId(uuid));
|
||||
});
|
||||
test('is deterministic for same input', () => {
|
||||
const uuid = 'a0b1c2d3-e4f5-6789-abcd-ef0123456789'
|
||||
expect(deriveShortMessageId(uuid)).toBe(deriveShortMessageId(uuid))
|
||||
})
|
||||
|
||||
test("produces different IDs for different UUIDs", () => {
|
||||
const id1 = deriveShortMessageId("00000000-0000-0000-0000-000000000001");
|
||||
const id2 = deriveShortMessageId("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
test('produces different IDs for different UUIDs', () => {
|
||||
const id1 = deriveShortMessageId('00000000-0000-0000-0000-000000000001')
|
||||
const id2 = deriveShortMessageId('ffffffff-ffff-ffff-ffff-ffffffffffff')
|
||||
expect(id1).not.toBe(id2)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("message constants", () => {
|
||||
test("SYNTHETIC_MESSAGES contains expected messages", () => {
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE_FOR_TOOL_USE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(CANCEL_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(REJECT_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(NO_RESPONSE_REQUESTED)).toBe(true);
|
||||
});
|
||||
describe('message constants', () => {
|
||||
test('SYNTHETIC_MESSAGES contains expected messages', () => {
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE)).toBe(true)
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE_FOR_TOOL_USE)).toBe(true)
|
||||
expect(SYNTHETIC_MESSAGES.has(CANCEL_MESSAGE)).toBe(true)
|
||||
expect(SYNTHETIC_MESSAGES.has(REJECT_MESSAGE)).toBe(true)
|
||||
expect(SYNTHETIC_MESSAGES.has(NO_RESPONSE_REQUESTED)).toBe(true)
|
||||
})
|
||||
|
||||
test("SYNTHETIC_MODEL is <synthetic>", () => {
|
||||
expect(SYNTHETIC_MODEL).toBe("<synthetic>");
|
||||
});
|
||||
});
|
||||
test('SYNTHETIC_MODEL is <synthetic>', () => {
|
||||
expect(SYNTHETIC_MODEL).toBe('<synthetic>')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Message factories ──────────────────────────────────────────────────
|
||||
|
||||
describe("createAssistantMessage", () => {
|
||||
test("creates assistant message with string content", () => {
|
||||
const msg = createAssistantMessage({ content: "hello" });
|
||||
expect(msg.type).toBe("assistant");
|
||||
expect(msg.message!.role).toBe("assistant");
|
||||
expect(msg.message!.content![0] as any).toBeTruthy();
|
||||
expect((msg.message!.content![0] as any).text).toBe("hello");
|
||||
});
|
||||
describe('createAssistantMessage', () => {
|
||||
test('creates assistant message with string content', () => {
|
||||
const msg = createAssistantMessage({ content: 'hello' })
|
||||
expect(msg.type).toBe('assistant')
|
||||
expect(msg.message!.role).toBe('assistant')
|
||||
expect(msg.message!.content![0] as any).toBeTruthy()
|
||||
expect((msg.message!.content![0] as any).text).toBe('hello')
|
||||
})
|
||||
|
||||
test("creates assistant message with content blocks", () => {
|
||||
const blocks = [{ type: "text" as const, text: "hello" }];
|
||||
const msg = createAssistantMessage({ content: blocks as any });
|
||||
expect(msg.type).toBe("assistant");
|
||||
expect(msg.message.content).toHaveLength(1);
|
||||
});
|
||||
test('creates assistant message with content blocks', () => {
|
||||
const blocks = [{ type: 'text' as const, text: 'hello' }]
|
||||
const msg = createAssistantMessage({ content: blocks as any })
|
||||
expect(msg.type).toBe('assistant')
|
||||
expect(msg.message.content).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("generates unique uuid per call", () => {
|
||||
const msg1 = createAssistantMessage({ content: "a" });
|
||||
const msg2 = createAssistantMessage({ content: "b" });
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid);
|
||||
});
|
||||
test('generates unique uuid per call', () => {
|
||||
const msg1 = createAssistantMessage({ content: 'a' })
|
||||
const msg2 = createAssistantMessage({ content: 'b' })
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid)
|
||||
})
|
||||
|
||||
test("has isApiErrorMessage false", () => {
|
||||
const msg = createAssistantMessage({ content: "test" });
|
||||
expect(msg.isApiErrorMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
test('has isApiErrorMessage false', () => {
|
||||
const msg = createAssistantMessage({ content: 'test' })
|
||||
expect(msg.isApiErrorMessage).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAssistantAPIErrorMessage", () => {
|
||||
test("sets isApiErrorMessage to true", () => {
|
||||
const msg = createAssistantAPIErrorMessage({ content: "error" });
|
||||
expect(msg.isApiErrorMessage).toBe(true);
|
||||
});
|
||||
describe('createAssistantAPIErrorMessage', () => {
|
||||
test('sets isApiErrorMessage to true', () => {
|
||||
const msg = createAssistantAPIErrorMessage({ content: 'error' })
|
||||
expect(msg.isApiErrorMessage).toBe(true)
|
||||
})
|
||||
|
||||
test("includes error details", () => {
|
||||
test('includes error details', () => {
|
||||
const msg = createAssistantAPIErrorMessage({
|
||||
content: "fail",
|
||||
errorDetails: "rate limited",
|
||||
});
|
||||
expect(msg.errorDetails).toBe("rate limited");
|
||||
});
|
||||
});
|
||||
content: 'fail',
|
||||
errorDetails: 'rate limited',
|
||||
})
|
||||
expect(msg.errorDetails).toBe('rate limited')
|
||||
})
|
||||
})
|
||||
|
||||
describe("createUserMessage", () => {
|
||||
test("creates user message with string content", () => {
|
||||
const msg = createUserMessage({ content: "hello" });
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.message.role).toBe("user");
|
||||
expect(msg.message.content).toBe("hello");
|
||||
});
|
||||
describe('createUserMessage', () => {
|
||||
test('creates user message with string content', () => {
|
||||
const msg = createUserMessage({ content: 'hello' })
|
||||
expect(msg.type).toBe('user')
|
||||
expect(msg.message.role).toBe('user')
|
||||
expect(msg.message.content).toBe('hello')
|
||||
})
|
||||
|
||||
test("generates unique uuid", () => {
|
||||
const msg1 = createUserMessage({ content: "a" });
|
||||
const msg2 = createUserMessage({ content: "b" });
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid);
|
||||
});
|
||||
test('generates unique uuid', () => {
|
||||
const msg1 = createUserMessage({ content: 'a' })
|
||||
const msg2 = createUserMessage({ content: 'b' })
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid)
|
||||
})
|
||||
|
||||
test("uses provided uuid when given", () => {
|
||||
test('uses provided uuid when given', () => {
|
||||
const msg = createUserMessage({
|
||||
content: "test",
|
||||
uuid: "custom-uuid-1234-5678-abcd-ef0123456789",
|
||||
});
|
||||
expect(msg.uuid).toBe("custom-uuid-1234-5678-abcd-ef0123456789");
|
||||
});
|
||||
content: 'test',
|
||||
uuid: 'custom-uuid-1234-5678-abcd-ef0123456789',
|
||||
})
|
||||
expect(msg.uuid).toBe('custom-uuid-1234-5678-abcd-ef0123456789')
|
||||
})
|
||||
|
||||
test("sets isMeta flag", () => {
|
||||
const msg = createUserMessage({ content: "test", isMeta: true });
|
||||
expect(msg.isMeta).toBe(true);
|
||||
});
|
||||
});
|
||||
test('sets isMeta flag', () => {
|
||||
const msg = createUserMessage({ content: 'test', isMeta: true })
|
||||
expect(msg.isMeta).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createUserInterruptionMessage", () => {
|
||||
test("creates interrupt message without tool use", () => {
|
||||
const msg = createUserInterruptionMessage({});
|
||||
expect(msg.type).toBe("user");
|
||||
expect((msg.message.content as any)[0].text).toBe(INTERRUPT_MESSAGE);
|
||||
});
|
||||
describe('createUserInterruptionMessage', () => {
|
||||
test('creates interrupt message without tool use', () => {
|
||||
const msg = createUserInterruptionMessage({})
|
||||
expect(msg.type).toBe('user')
|
||||
expect((msg.message.content as any)[0].text).toBe(INTERRUPT_MESSAGE)
|
||||
})
|
||||
|
||||
test("creates interrupt message with tool use", () => {
|
||||
const msg = createUserInterruptionMessage({ toolUse: true });
|
||||
test('creates interrupt message with tool use', () => {
|
||||
const msg = createUserInterruptionMessage({ toolUse: true })
|
||||
expect((msg.message.content as any)[0].text).toBe(
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE
|
||||
);
|
||||
});
|
||||
});
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("prepareUserContent", () => {
|
||||
test("returns string when no preceding blocks", () => {
|
||||
describe('prepareUserContent', () => {
|
||||
test('returns string when no preceding blocks', () => {
|
||||
const result = prepareUserContent({
|
||||
inputString: "hello",
|
||||
inputString: 'hello',
|
||||
precedingInputBlocks: [],
|
||||
});
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
})
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
test("returns array when preceding blocks exist", () => {
|
||||
const blocks = [{ type: "image" as const, source: {} } as any];
|
||||
test('returns array when preceding blocks exist', () => {
|
||||
const blocks = [{ type: 'image' as const, source: {} } as any]
|
||||
const result = prepareUserContent({
|
||||
inputString: "describe this",
|
||||
inputString: 'describe this',
|
||||
precedingInputBlocks: blocks,
|
||||
});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as any[]).length).toBe(2);
|
||||
expect((result as any[])[1].text).toBe("describe this");
|
||||
});
|
||||
});
|
||||
})
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
expect((result as any[]).length).toBe(2)
|
||||
expect((result as any[])[1].text).toBe('describe this')
|
||||
})
|
||||
})
|
||||
|
||||
describe("createToolResultStopMessage", () => {
|
||||
test("creates tool result with error flag", () => {
|
||||
const result = createToolResultStopMessage("tool-123");
|
||||
expect(result.type).toBe("tool_result");
|
||||
expect(result.is_error).toBe(true);
|
||||
expect(result.tool_use_id).toBe("tool-123");
|
||||
expect(result.content).toBe(CANCEL_MESSAGE);
|
||||
});
|
||||
});
|
||||
describe('createToolResultStopMessage', () => {
|
||||
test('creates tool result with error flag', () => {
|
||||
const result = createToolResultStopMessage('tool-123')
|
||||
expect(result.type).toBe('tool_result')
|
||||
expect(result.is_error).toBe(true)
|
||||
expect(result.tool_use_id).toBe('tool-123')
|
||||
expect(result.content).toBe(CANCEL_MESSAGE)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isSyntheticMessage ─────────────────────────────────────────────────
|
||||
|
||||
describe("isSyntheticMessage", () => {
|
||||
test("identifies interrupt message as synthetic", () => {
|
||||
describe('isSyntheticMessage', () => {
|
||||
test('identifies interrupt message as synthetic', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(true);
|
||||
});
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: INTERRUPT_MESSAGE }] },
|
||||
}
|
||||
expect(isSyntheticMessage(msg)).toBe(true)
|
||||
})
|
||||
|
||||
test("identifies cancel message as synthetic", () => {
|
||||
test('identifies cancel message as synthetic', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: CANCEL_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(true);
|
||||
});
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: CANCEL_MESSAGE }] },
|
||||
}
|
||||
expect(isSyntheticMessage(msg)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for normal user message", () => {
|
||||
test('returns false for normal user message', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: "hello" }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'hello' }] },
|
||||
}
|
||||
expect(isSyntheticMessage(msg)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for progress message", () => {
|
||||
test('returns false for progress message', () => {
|
||||
const msg: any = {
|
||||
type: "progress",
|
||||
message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
type: 'progress',
|
||||
message: { content: [{ type: 'text', text: INTERRUPT_MESSAGE }] },
|
||||
}
|
||||
expect(isSyntheticMessage(msg)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for string content", () => {
|
||||
test('returns false for string content', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
message: { content: INTERRUPT_MESSAGE },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(isSyntheticMessage(msg)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getLastAssistantMessage ────────────────────────────────────────────
|
||||
|
||||
describe("getLastAssistantMessage", () => {
|
||||
test("returns last assistant message", () => {
|
||||
const a1 = makeAssistantMsg([{ type: "text", text: "first" }]);
|
||||
const u = makeUserMsg("mid");
|
||||
const a2 = makeAssistantMsg([{ type: "text", text: "last" }]);
|
||||
const result = getLastAssistantMessage([a1, u, a2]);
|
||||
expect(result).toBe(a2);
|
||||
});
|
||||
describe('getLastAssistantMessage', () => {
|
||||
test('returns last assistant message', () => {
|
||||
const a1 = makeAssistantMsg([{ type: 'text', text: 'first' }])
|
||||
const u = makeUserMsg('mid')
|
||||
const a2 = makeAssistantMsg([{ type: 'text', text: 'last' }])
|
||||
const result = getLastAssistantMessage([a1, u, a2])
|
||||
expect(result).toBe(a2)
|
||||
})
|
||||
|
||||
test("returns undefined for empty array", () => {
|
||||
expect(getLastAssistantMessage([])).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for empty array', () => {
|
||||
expect(getLastAssistantMessage([])).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined when no assistant messages", () => {
|
||||
const u = makeUserMsg("hello");
|
||||
expect(getLastAssistantMessage([u])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('returns undefined when no assistant messages', () => {
|
||||
const u = makeUserMsg('hello')
|
||||
expect(getLastAssistantMessage([u])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── hasToolCallsInLastAssistantTurn ────────────────────────────────────
|
||||
|
||||
describe("hasToolCallsInLastAssistantTurn", () => {
|
||||
test("returns true when last assistant has tool_use", () => {
|
||||
describe('hasToolCallsInLastAssistantTurn', () => {
|
||||
test('returns true when last assistant has tool_use', () => {
|
||||
const msg = makeAssistantMsg([
|
||||
{ type: "text", text: "let me check" },
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: {} },
|
||||
]);
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(true);
|
||||
});
|
||||
{ type: 'text', text: 'let me check' },
|
||||
{ type: 'tool_use', id: 't1', name: 'Bash', input: {} },
|
||||
])
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when last assistant has only text", () => {
|
||||
const msg = makeAssistantMsg([{ type: "text", text: "done" }]);
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(false);
|
||||
});
|
||||
test('returns false when last assistant has only text', () => {
|
||||
const msg = makeAssistantMsg([{ type: 'text', text: 'done' }])
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty messages", () => {
|
||||
expect(hasToolCallsInLastAssistantTurn([])).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for empty messages', () => {
|
||||
expect(hasToolCallsInLastAssistantTurn([])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── extractTag ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("extractTag", () => {
|
||||
test("extracts simple tag content", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "foo")).toBe("bar");
|
||||
});
|
||||
describe('extractTag', () => {
|
||||
test('extracts simple tag content', () => {
|
||||
expect(extractTag('<foo>bar</foo>', 'foo')).toBe('bar')
|
||||
})
|
||||
|
||||
test("extracts tag with attributes", () => {
|
||||
expect(extractTag('<foo class="a">bar</foo>', "foo")).toBe("bar");
|
||||
});
|
||||
test('extracts tag with attributes', () => {
|
||||
expect(extractTag('<foo class="a">bar</foo>', 'foo')).toBe('bar')
|
||||
})
|
||||
|
||||
test("handles multiline content", () => {
|
||||
expect(extractTag("<foo>\nline1\nline2\n</foo>", "foo")).toBe(
|
||||
"\nline1\nline2\n"
|
||||
);
|
||||
});
|
||||
test('handles multiline content', () => {
|
||||
expect(extractTag('<foo>\nline1\nline2\n</foo>', 'foo')).toBe(
|
||||
'\nline1\nline2\n',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns null for missing tag", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "baz")).toBeNull();
|
||||
});
|
||||
test('returns null for missing tag', () => {
|
||||
expect(extractTag('<foo>bar</foo>', 'baz')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty html", () => {
|
||||
expect(extractTag("", "foo")).toBeNull();
|
||||
});
|
||||
test('returns null for empty html', () => {
|
||||
expect(extractTag('', 'foo')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty tagName", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "")).toBeNull();
|
||||
});
|
||||
test('returns null for empty tagName', () => {
|
||||
expect(extractTag('<foo>bar</foo>', '')).toBeNull()
|
||||
})
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(extractTag("<FOO>bar</FOO>", "foo")).toBe("bar");
|
||||
});
|
||||
});
|
||||
test('is case-insensitive', () => {
|
||||
expect(extractTag('<FOO>bar</FOO>', 'foo')).toBe('bar')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isNotEmptyMessage ──────────────────────────────────────────────────
|
||||
|
||||
describe("isNotEmptyMessage", () => {
|
||||
test("returns true for message with text content", () => {
|
||||
describe('isNotEmptyMessage', () => {
|
||||
test('returns true for message with text content', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: "hello" },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
type: 'user',
|
||||
message: { content: 'hello' },
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for empty string content", () => {
|
||||
test('returns false for empty string content', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: " " },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
type: 'user',
|
||||
message: { content: ' ' },
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty content array", () => {
|
||||
test('returns false for empty content array', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
message: { content: [] },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for progress message", () => {
|
||||
test('returns true for progress message', () => {
|
||||
const msg: any = {
|
||||
type: "progress",
|
||||
type: 'progress',
|
||||
message: { content: [] },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for multi-block content", () => {
|
||||
test('returns true for multi-block content', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: 'text', text: 'a' },
|
||||
{ type: 'text', text: 'b' },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for non-text block", () => {
|
||||
test('returns true for non-text block', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [{ type: "tool_use", id: "t1", name: "Bash", input: {} }],
|
||||
content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for whitespace-only text block in content array", () => {
|
||||
test('returns false for whitespace-only text block in content array', () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
message: {
|
||||
content: [{ type: "text", text: " " }],
|
||||
content: [{ type: 'text', text: ' ' }],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(isNotEmptyMessage(msg)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── deriveUUID ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("deriveUUID", () => {
|
||||
test("produces deterministic output", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
expect(deriveUUID(parent, 0)).toBe(deriveUUID(parent, 0));
|
||||
});
|
||||
describe('deriveUUID', () => {
|
||||
test('produces deterministic output', () => {
|
||||
const parent = '550e8400-e29b-41d4-a716-446655440000' as any
|
||||
expect(deriveUUID(parent, 0)).toBe(deriveUUID(parent, 0))
|
||||
})
|
||||
|
||||
test("produces different output for different indices", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
expect(deriveUUID(parent, 0)).not.toBe(deriveUUID(parent, 1));
|
||||
});
|
||||
test('produces different output for different indices', () => {
|
||||
const parent = '550e8400-e29b-41d4-a716-446655440000' as any
|
||||
expect(deriveUUID(parent, 0)).not.toBe(deriveUUID(parent, 1))
|
||||
})
|
||||
|
||||
test("preserves UUID-like length", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
const derived = deriveUUID(parent, 5);
|
||||
expect(derived.length).toBe(parent.length);
|
||||
});
|
||||
});
|
||||
test('preserves UUID-like length', () => {
|
||||
const parent = '550e8400-e29b-41d4-a716-446655440000' as any
|
||||
const derived = deriveUUID(parent, 5)
|
||||
expect(derived.length).toBe(parent.length)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isClassifierDenial ─────────────────────────────────────────────────
|
||||
|
||||
describe("isClassifierDenial", () => {
|
||||
test("returns true for classifier denial prefix", () => {
|
||||
describe('isClassifierDenial', () => {
|
||||
test('returns true for classifier denial prefix', () => {
|
||||
expect(
|
||||
isClassifierDenial(
|
||||
"Permission for this action has been denied. Reason: unsafe"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
'Permission for this action has been denied. Reason: unsafe',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for normal content", () => {
|
||||
expect(isClassifierDenial("hello world")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for normal content', () => {
|
||||
expect(isClassifierDenial('hello world')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Message builder functions ──────────────────────────────────────────
|
||||
|
||||
describe("AUTO_REJECT_MESSAGE", () => {
|
||||
test("includes tool name", () => {
|
||||
const msg = AUTO_REJECT_MESSAGE("Bash");
|
||||
expect(msg).toContain("Bash");
|
||||
expect(msg).toContain("denied");
|
||||
});
|
||||
});
|
||||
describe('AUTO_REJECT_MESSAGE', () => {
|
||||
test('includes tool name', () => {
|
||||
const msg = AUTO_REJECT_MESSAGE('Bash')
|
||||
expect(msg).toContain('Bash')
|
||||
expect(msg).toContain('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe("DONT_ASK_REJECT_MESSAGE", () => {
|
||||
test("includes tool name and dont ask mode", () => {
|
||||
const msg = DONT_ASK_REJECT_MESSAGE("Write");
|
||||
expect(msg).toContain("Write");
|
||||
expect(msg).toContain("don't ask mode");
|
||||
});
|
||||
});
|
||||
describe('DONT_ASK_REJECT_MESSAGE', () => {
|
||||
test('includes tool name and dont ask mode', () => {
|
||||
const msg = DONT_ASK_REJECT_MESSAGE('Write')
|
||||
expect(msg).toContain('Write')
|
||||
expect(msg).toContain("don't ask mode")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildYoloRejectionMessage", () => {
|
||||
test("includes reason", () => {
|
||||
const msg = buildYoloRejectionMessage("potentially destructive");
|
||||
expect(msg).toContain("potentially destructive");
|
||||
expect(msg).toContain("denied");
|
||||
});
|
||||
});
|
||||
describe('buildYoloRejectionMessage', () => {
|
||||
test('includes reason', () => {
|
||||
const msg = buildYoloRejectionMessage('potentially destructive')
|
||||
expect(msg).toContain('potentially destructive')
|
||||
expect(msg).toContain('denied')
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildClassifierUnavailableMessage", () => {
|
||||
test("includes tool name and model", () => {
|
||||
const msg = buildClassifierUnavailableMessage("Bash", "classifier-v1");
|
||||
expect(msg).toContain("Bash");
|
||||
expect(msg).toContain("classifier-v1");
|
||||
expect(msg).toContain("unavailable");
|
||||
});
|
||||
describe('buildClassifierUnavailableMessage', () => {
|
||||
test('includes tool name and model', () => {
|
||||
const msg = buildClassifierUnavailableMessage('Bash', 'classifier-v1')
|
||||
expect(msg).toContain('Bash')
|
||||
expect(msg).toContain('classifier-v1')
|
||||
expect(msg).toContain('unavailable')
|
||||
})
|
||||
|
||||
test("tells the model to wait and retry later", () => {
|
||||
const msg = buildClassifierUnavailableMessage("Bash", "classifier-v1");
|
||||
expect(msg).toContain("Wait briefly and then try this action again.");
|
||||
expect(msg).toContain("come back to it later");
|
||||
});
|
||||
});
|
||||
test('tells the model to wait and retry later', () => {
|
||||
const msg = buildClassifierUnavailableMessage('Bash', 'classifier-v1')
|
||||
expect(msg).toContain('Wait briefly and then try this action again.')
|
||||
expect(msg).toContain('come back to it later')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe("normalizeMessages", () => {
|
||||
test("splits multi-block assistant message into individual messages", () => {
|
||||
describe('normalizeMessages', () => {
|
||||
test('splits multi-block assistant message into individual messages', () => {
|
||||
const msg = makeAssistantMsg([
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
const normalized = normalizeMessages([msg]);
|
||||
expect(normalized.length).toBe(2);
|
||||
{ type: 'text', text: 'first' },
|
||||
{ type: 'text', text: 'second' },
|
||||
])
|
||||
const normalized = normalizeMessages([msg])
|
||||
expect(normalized.length).toBe(2)
|
||||
// Verify each split message contains only one content block
|
||||
expect(normalized[0].message.content).toHaveLength(1);
|
||||
expect((normalized[0].message.content as any[])[0].text).toBe("first");
|
||||
expect(normalized[1].message.content).toHaveLength(1);
|
||||
expect((normalized[1].message.content as any[])[0].text).toBe("second");
|
||||
});
|
||||
expect(normalized[0].message.content).toHaveLength(1)
|
||||
expect((normalized[0].message.content as any[])[0].text).toBe('first')
|
||||
expect(normalized[1].message.content).toHaveLength(1)
|
||||
expect((normalized[1].message.content as any[])[0].text).toBe('second')
|
||||
})
|
||||
|
||||
test("handles empty array", () => {
|
||||
const result = normalizeMessages([] as AssistantMessage[]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
test('handles empty array', () => {
|
||||
const result = normalizeMessages([] as AssistantMessage[])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves single-block message", () => {
|
||||
const msg = makeAssistantMsg([{ type: "text", text: "hello" }]);
|
||||
const normalized = normalizeMessages([msg]);
|
||||
expect(normalized.length).toBe(1);
|
||||
});
|
||||
});
|
||||
test('preserves single-block message', () => {
|
||||
const msg = makeAssistantMsg([{ type: 'text', text: 'hello' }])
|
||||
const normalized = normalizeMessages([msg])
|
||||
expect(normalized.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeMessagesForAPI", () => {
|
||||
test("preserves Gemini thought signature metadata on tool_use blocks", () => {
|
||||
describe('normalizeMessagesForAPI', () => {
|
||||
test('preserves Gemini thought signature metadata on tool_use blocks', () => {
|
||||
const assistant = makeAssistantMsg([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "Bash",
|
||||
input: { command: "pwd" },
|
||||
_geminiThoughtSignature: "sig-123",
|
||||
type: 'tool_use',
|
||||
id: 'tool-1',
|
||||
name: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
_geminiThoughtSignature: 'sig-123',
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
const normalized = normalizeMessagesForAPI([assistant]);
|
||||
const block = (normalized[0] as AssistantMessage).message!.content![0] as any;
|
||||
const normalized = normalizeMessagesForAPI([assistant])
|
||||
const block = (normalized[0] as AssistantMessage).message!
|
||||
.content![0] as any
|
||||
|
||||
expect(block.type).toBe("tool_use");
|
||||
expect(block._geminiThoughtSignature).toBe("sig-123");
|
||||
});
|
||||
});
|
||||
expect(block.type).toBe('tool_use')
|
||||
expect(block._geminiThoughtSignature).toBe('sig-123')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
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.
|
||||
@@ -18,63 +18,63 @@ function formatModelPricing(costs: {
|
||||
return `${formatPrice(costs.inputTokens)}/${formatPrice(costs.outputTokens)} per Mtok`
|
||||
}
|
||||
|
||||
describe("COST_TIER constant values", () => {
|
||||
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)", () => {
|
||||
test('COST_TIER_3_15: $3/$15 (Sonnet tier)', () => {
|
||||
expect(formatModelPricing({ inputTokens: 3, outputTokens: 15 })).toBe(
|
||||
"$3/$15 per Mtok",
|
||||
'$3/$15 per Mtok',
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_TIER_15_75: $15/$75 (Opus 4/4.1 tier)", () => {
|
||||
test('COST_TIER_15_75: $15/$75 (Opus 4/4.1 tier)', () => {
|
||||
expect(formatModelPricing({ inputTokens: 15, outputTokens: 75 })).toBe(
|
||||
"$15/$75 per Mtok",
|
||||
'$15/$75 per Mtok',
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_TIER_5_25: $5/$25 (Opus 4.5/4.6 tier)", () => {
|
||||
test('COST_TIER_5_25: $5/$25 (Opus 4.5/4.6 tier)', () => {
|
||||
expect(formatModelPricing({ inputTokens: 5, outputTokens: 25 })).toBe(
|
||||
"$5/$25 per Mtok",
|
||||
'$5/$25 per Mtok',
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_TIER_30_150: $30/$150 (Fast Opus 4.6)", () => {
|
||||
test('COST_TIER_30_150: $30/$150 (Fast Opus 4.6)', () => {
|
||||
expect(formatModelPricing({ inputTokens: 30, outputTokens: 150 })).toBe(
|
||||
"$30/$150 per Mtok",
|
||||
'$30/$150 per Mtok',
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_HAIKU_35: $0.80/$4 (Haiku 3.5)", () => {
|
||||
test('COST_HAIKU_35: $0.80/$4 (Haiku 3.5)', () => {
|
||||
expect(formatModelPricing({ inputTokens: 0.8, outputTokens: 4 })).toBe(
|
||||
"$0.80/$4 per Mtok",
|
||||
'$0.80/$4 per Mtok',
|
||||
)
|
||||
})
|
||||
|
||||
test("COST_HAIKU_45: $1/$5 (Haiku 4.5)", () => {
|
||||
test('COST_HAIKU_45: $1/$5 (Haiku 4.5)', () => {
|
||||
expect(formatModelPricing({ inputTokens: 1, outputTokens: 5 })).toBe(
|
||||
"$1/$5 per Mtok",
|
||||
'$1/$5 per Mtok',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatPrice", () => {
|
||||
describe('formatPrice', () => {
|
||||
test("formats integers without decimals: 3 → '$3'", () => {
|
||||
expect(formatPrice(3)).toBe("$3")
|
||||
expect(formatPrice(3)).toBe('$3')
|
||||
})
|
||||
|
||||
test("formats floats with 2 decimals: 0.8 → '$0.80'", () => {
|
||||
expect(formatPrice(0.8)).toBe("$0.80")
|
||||
expect(formatPrice(0.8)).toBe('$0.80')
|
||||
})
|
||||
|
||||
test("formats large integers: 150 → '$150'", () => {
|
||||
expect(formatPrice(150)).toBe("$150")
|
||||
expect(formatPrice(150)).toBe('$150')
|
||||
})
|
||||
|
||||
test("formats 1 as integer: '$1'", () => {
|
||||
expect(formatPrice(1)).toBe("$1")
|
||||
expect(formatPrice(1)).toBe('$1')
|
||||
})
|
||||
|
||||
test("formats mixed decimal: 22.5 → '$22.50'", () => {
|
||||
expect(formatPrice(22.5)).toBe("$22.50")
|
||||
expect(formatPrice(22.5)).toBe('$22.50')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,170 +1,170 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCellId, mapNotebookCellsToToolResult } from "../notebook";
|
||||
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);
|
||||
});
|
||||
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-5 to 5', () => {
|
||||
expect(parseCellId('cell-5')).toBe(5)
|
||||
})
|
||||
|
||||
test("parses cell-100 to 100", () => {
|
||||
expect(parseCellId("cell-100")).toBe(100);
|
||||
});
|
||||
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- (no number)', () => {
|
||||
expect(parseCellId('cell-')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for cell-abc (non-numeric)", () => {
|
||||
expect(parseCellId("cell-abc")).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 other-format', () => {
|
||||
expect(parseCellId('other-format')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseCellId("")).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for empty string', () => {
|
||||
expect(parseCellId('')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for prefix-only match like cell-0-extra", () => {
|
||||
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();
|
||||
});
|
||||
expect(parseCellId('cell-0-extra')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for negative numbers", () => {
|
||||
expect(parseCellId("cell--1")).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for negative numbers', () => {
|
||||
expect(parseCellId('cell--1')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("parses leading zeros correctly", () => {
|
||||
expect(parseCellId("cell-007")).toBe(7);
|
||||
});
|
||||
});
|
||||
test('parses leading zeros correctly', () => {
|
||||
expect(parseCellId('cell-007')).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── mapNotebookCellsToToolResult ──────────────────────────────────────
|
||||
|
||||
describe("mapNotebookCellsToToolResult", () => {
|
||||
test("returns tool result with correct tool_use_id", () => {
|
||||
describe('mapNotebookCellsToToolResult', () => {
|
||||
test('returns tool result with correct tool_use_id', () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
cellType: 'code',
|
||||
source: 'print("hello")',
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
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");
|
||||
});
|
||||
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", () => {
|
||||
test('content array contains text blocks for cell content', () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
cellType: 'code',
|
||||
source: 'x = 1',
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
cell_id: 'cell-0',
|
||||
language: 'python',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-1");
|
||||
expect(result.content).toBeInstanceOf(Array);
|
||||
expect(result.content!.length).toBeGreaterThanOrEqual(1);
|
||||
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 id="cell-0"');
|
||||
expect(firstBlock.text).toContain("x = 1");
|
||||
});
|
||||
const firstBlock = result.content![0] as { type: string; text: string }
|
||||
expect(firstBlock.type).toBe('text')
|
||||
expect(firstBlock.text).toContain('cell id="cell-0"')
|
||||
expect(firstBlock.text).toContain('x = 1')
|
||||
})
|
||||
|
||||
test("merges adjacent text blocks from multiple cells", () => {
|
||||
test('merges adjacent text blocks from multiple cells', () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "a = 1",
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
cellType: 'code',
|
||||
source: 'a = 1',
|
||||
cell_id: 'cell-0',
|
||||
language: 'python',
|
||||
},
|
||||
{
|
||||
cellType: "code",
|
||||
source: "b = 2",
|
||||
cell_id: "cell-1",
|
||||
language: "python",
|
||||
cellType: 'code',
|
||||
source: 'b = 2',
|
||||
cell_id: 'cell-1',
|
||||
language: 'python',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-2");
|
||||
const result = mapNotebookCellsToToolResult(data, 'tool-2')
|
||||
// Two adjacent text blocks should be merged into one
|
||||
const textBlocks = (result.content as any[]).filter(
|
||||
(b: any) => b.type === "text"
|
||||
);
|
||||
expect(textBlocks).toHaveLength(1);
|
||||
});
|
||||
(b: any) => b.type === 'text',
|
||||
)
|
||||
expect(textBlocks).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("preserves image blocks without merging", () => {
|
||||
test('preserves image blocks without merging', () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "plot()",
|
||||
cell_id: "cell-0",
|
||||
language: "python",
|
||||
cellType: 'code',
|
||||
source: 'plot()',
|
||||
cell_id: 'cell-0',
|
||||
language: 'python',
|
||||
outputs: [
|
||||
{
|
||||
output_type: "display_data",
|
||||
text: "",
|
||||
output_type: 'display_data',
|
||||
text: '',
|
||||
image: {
|
||||
image_data: "iVBORw0KGgo=",
|
||||
media_type: "image/png" as const,
|
||||
image_data: 'iVBORw0KGgo=',
|
||||
media_type: 'image/png' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cellType: "code",
|
||||
source: "print(1)",
|
||||
cell_id: "cell-1",
|
||||
language: "python",
|
||||
cellType: 'code',
|
||||
source: 'print(1)',
|
||||
cell_id: 'cell-1',
|
||||
language: 'python',
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const result = mapNotebookCellsToToolResult(data, "tool-3");
|
||||
const types = (result.content as any[]).map((b: any) => b.type);
|
||||
expect(types).toContain("image");
|
||||
});
|
||||
const result = mapNotebookCellsToToolResult(data, 'tool-3')
|
||||
const types = (result.content as any[]).map((b: any) => b.type)
|
||||
expect(types).toContain('image')
|
||||
})
|
||||
|
||||
test("markdown cell includes cell_type metadata", () => {
|
||||
test('markdown cell includes cell_type metadata', () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "markdown",
|
||||
source: "# Title",
|
||||
cell_id: "cell-0",
|
||||
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>");
|
||||
});
|
||||
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", () => {
|
||||
test('non-python code cell includes language metadata', () => {
|
||||
const data = [
|
||||
{
|
||||
cellType: "code",
|
||||
source: "val x = 1",
|
||||
cell_id: "cell-0",
|
||||
language: "scala",
|
||||
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>");
|
||||
});
|
||||
});
|
||||
const result = mapNotebookCellsToToolResult(data, 'tool-5')
|
||||
const textBlock = result.content![0] as { type: string; text: string }
|
||||
expect(textBlock.text).toContain('<language>scala</language>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { objectGroupBy } from "../objectGroupBy";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { objectGroupBy } from '../objectGroupBy'
|
||||
|
||||
describe("objectGroupBy", () => {
|
||||
test("groups items by key", () => {
|
||||
const result = objectGroupBy([1, 2, 3, 4], (n) =>
|
||||
n % 2 === 0 ? "even" : "odd"
|
||||
);
|
||||
expect(result.even).toEqual([2, 4]);
|
||||
expect(result.odd).toEqual([1, 3]);
|
||||
});
|
||||
describe('objectGroupBy', () => {
|
||||
test('groups items by key', () => {
|
||||
const result = objectGroupBy([1, 2, 3, 4], n =>
|
||||
n % 2 === 0 ? 'even' : 'odd',
|
||||
)
|
||||
expect(result.even).toEqual([2, 4])
|
||||
expect(result.odd).toEqual([1, 3])
|
||||
})
|
||||
|
||||
test("returns empty object for empty input", () => {
|
||||
const result = objectGroupBy([], () => "key");
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
test('returns empty object for empty input', () => {
|
||||
const result = objectGroupBy([], () => 'key')
|
||||
expect(Object.keys(result)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("handles single group", () => {
|
||||
const result = objectGroupBy(["a", "b", "c"], () => "all");
|
||||
expect(result.all).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
test('handles single group', () => {
|
||||
const result = objectGroupBy(['a', 'b', 'c'], () => 'all')
|
||||
expect(result.all).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test("passes index to keySelector", () => {
|
||||
const result = objectGroupBy(["a", "b", "c", "d"], (_, i) =>
|
||||
i < 2 ? "first" : "second"
|
||||
);
|
||||
expect(result.first).toEqual(["a", "b"]);
|
||||
expect(result.second).toEqual(["c", "d"]);
|
||||
});
|
||||
test('passes index to keySelector', () => {
|
||||
const result = objectGroupBy(['a', 'b', 'c', 'd'], (_, i) =>
|
||||
i < 2 ? 'first' : 'second',
|
||||
)
|
||||
expect(result.first).toEqual(['a', 'b'])
|
||||
expect(result.second).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
test("works with objects", () => {
|
||||
test('works with objects', () => {
|
||||
const items = [
|
||||
{ name: "Alice", role: "admin" },
|
||||
{ name: "Bob", role: "user" },
|
||||
{ name: "Charlie", role: "admin" },
|
||||
];
|
||||
const result = objectGroupBy(items, (item) => item.role);
|
||||
expect(result.admin).toHaveLength(2);
|
||||
expect(result.user).toHaveLength(1);
|
||||
});
|
||||
{ name: 'Alice', role: 'admin' },
|
||||
{ name: 'Bob', role: 'user' },
|
||||
{ name: 'Charlie', role: 'admin' },
|
||||
]
|
||||
const result = objectGroupBy(items, item => item.role)
|
||||
expect(result.admin).toHaveLength(2)
|
||||
expect(result.user).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("handles key function returning undefined", () => {
|
||||
const result = objectGroupBy([1, 2, 3], () => undefined as any);
|
||||
expect(result["undefined"]).toEqual([1, 2, 3]);
|
||||
});
|
||||
test('handles key function returning undefined', () => {
|
||||
const result = objectGroupBy([1, 2, 3], () => undefined as any)
|
||||
expect(result['undefined']).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles keys with special characters", () => {
|
||||
test('handles keys with special characters', () => {
|
||||
const result = objectGroupBy(
|
||||
[{ key: "a/b" }, { key: "a.b" }, { key: "a/b" }],
|
||||
(item) => item.key
|
||||
);
|
||||
expect(result["a/b"]).toHaveLength(2);
|
||||
expect(result["a.b"]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
[{ key: 'a/b' }, { key: 'a.b' }, { key: 'a/b' }],
|
||||
item => item.key,
|
||||
)
|
||||
expect(result['a/b']).toHaveLength(2)
|
||||
expect(result['a.b']).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,249 +1,249 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { tmpdir } from "os";
|
||||
import { resolve } from "path";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { tmpdir } from 'os'
|
||||
import { resolve } from 'path'
|
||||
import {
|
||||
getFsImplementation,
|
||||
setFsImplementation,
|
||||
setOriginalFsImplementation,
|
||||
type FsOperations,
|
||||
} from "../fsOperations";
|
||||
} from '../fsOperations'
|
||||
import {
|
||||
containsPathTraversal,
|
||||
expandPath,
|
||||
normalizePathForConfigKey,
|
||||
toRelativePath,
|
||||
getDirectoryForPath,
|
||||
} from "../path";
|
||||
} from '../path'
|
||||
|
||||
// ─── containsPathTraversal ──────────────────────────────────────────────
|
||||
|
||||
describe("containsPathTraversal", () => {
|
||||
test("detects ../ at start", () => {
|
||||
expect(containsPathTraversal("../foo")).toBe(true);
|
||||
});
|
||||
describe('containsPathTraversal', () => {
|
||||
test('detects ../ at start', () => {
|
||||
expect(containsPathTraversal('../foo')).toBe(true)
|
||||
})
|
||||
|
||||
test("detects ../ in middle", () => {
|
||||
expect(containsPathTraversal("foo/../bar")).toBe(true);
|
||||
});
|
||||
test('detects ../ in middle', () => {
|
||||
expect(containsPathTraversal('foo/../bar')).toBe(true)
|
||||
})
|
||||
|
||||
test("detects .. at end", () => {
|
||||
expect(containsPathTraversal("foo/..")).toBe(true);
|
||||
});
|
||||
test('detects .. at end', () => {
|
||||
expect(containsPathTraversal('foo/..')).toBe(true)
|
||||
})
|
||||
|
||||
test("detects standalone ..", () => {
|
||||
expect(containsPathTraversal("..")).toBe(true);
|
||||
});
|
||||
test('detects standalone ..', () => {
|
||||
expect(containsPathTraversal('..')).toBe(true)
|
||||
})
|
||||
|
||||
test("detects backslash traversal", () => {
|
||||
expect(containsPathTraversal("foo\\..\\bar")).toBe(true);
|
||||
});
|
||||
test('detects backslash traversal', () => {
|
||||
expect(containsPathTraversal('foo\\..\\bar')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for normal path", () => {
|
||||
expect(containsPathTraversal("foo/bar/baz")).toBe(false);
|
||||
});
|
||||
test('returns false for normal path', () => {
|
||||
expect(containsPathTraversal('foo/bar/baz')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for single dot", () => {
|
||||
expect(containsPathTraversal("./foo")).toBe(false);
|
||||
});
|
||||
test('returns false for single dot', () => {
|
||||
expect(containsPathTraversal('./foo')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for ... in filename", () => {
|
||||
expect(containsPathTraversal("foo/...bar")).toBe(false);
|
||||
});
|
||||
test('returns false for ... in filename', () => {
|
||||
expect(containsPathTraversal('foo/...bar')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(containsPathTraversal("")).toBe(false);
|
||||
});
|
||||
test('returns false for empty string', () => {
|
||||
expect(containsPathTraversal('')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for dotdot in filename without separator", () => {
|
||||
expect(containsPathTraversal("foo..bar")).toBe(false);
|
||||
});
|
||||
test('returns false for dotdot in filename without separator', () => {
|
||||
expect(containsPathTraversal('foo..bar')).toBe(false)
|
||||
})
|
||||
|
||||
test("detects backslash traversal foo\\..\\bar", () => {
|
||||
expect(containsPathTraversal("foo\\..\\bar")).toBe(true);
|
||||
});
|
||||
test('detects backslash traversal foo\\..\\bar', () => {
|
||||
expect(containsPathTraversal('foo\\..\\bar')).toBe(true)
|
||||
})
|
||||
|
||||
test("detects .. at end of absolute path", () => {
|
||||
expect(containsPathTraversal("/path/to/..")).toBe(true);
|
||||
});
|
||||
});
|
||||
test('detects .. at end of absolute path', () => {
|
||||
expect(containsPathTraversal('/path/to/..')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── expandPath ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("expandPath", () => {
|
||||
test("expands ~/ to home directory", () => {
|
||||
const result = expandPath("~/Documents");
|
||||
expect(result).not.toContain("~");
|
||||
expect(result).toContain("Documents");
|
||||
});
|
||||
describe('expandPath', () => {
|
||||
test('expands ~/ to home directory', () => {
|
||||
const result = expandPath('~/Documents')
|
||||
expect(result).not.toContain('~')
|
||||
expect(result).toContain('Documents')
|
||||
})
|
||||
|
||||
test("expands bare ~ to home directory", () => {
|
||||
const result = expandPath("~");
|
||||
expect(result).not.toContain("~");
|
||||
test('expands bare ~ to home directory', () => {
|
||||
const result = expandPath('~')
|
||||
expect(result).not.toContain('~')
|
||||
// Should equal home directory
|
||||
const { homedir } = require("os");
|
||||
expect(result).toBe(homedir());
|
||||
});
|
||||
const { homedir } = require('os')
|
||||
expect(result).toBe(homedir())
|
||||
})
|
||||
|
||||
test("passes absolute paths through normalized", () => {
|
||||
expect(expandPath("/usr/local/bin")).toBe("/usr/local/bin");
|
||||
});
|
||||
test('passes absolute paths through normalized', () => {
|
||||
expect(expandPath('/usr/local/bin')).toBe('/usr/local/bin')
|
||||
})
|
||||
|
||||
test("resolves relative path against baseDir", () => {
|
||||
expect(expandPath("src", "/project")).toBe("/project/src");
|
||||
});
|
||||
test('resolves relative path against baseDir', () => {
|
||||
expect(expandPath('src', '/project')).toBe('/project/src')
|
||||
})
|
||||
|
||||
test("returns baseDir for empty string", () => {
|
||||
expect(expandPath("", "/project")).toBe("/project");
|
||||
});
|
||||
test('returns baseDir for empty string', () => {
|
||||
expect(expandPath('', '/project')).toBe('/project')
|
||||
})
|
||||
|
||||
test("returns cwd-based path for empty string without baseDir", () => {
|
||||
const result = expandPath("");
|
||||
test('returns cwd-based path for empty string without baseDir', () => {
|
||||
const result = expandPath('')
|
||||
// Should be a valid absolute path (cwd normalized)
|
||||
const { isAbsolute } = require("path");
|
||||
expect(isAbsolute(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
const { isAbsolute } = require('path')
|
||||
expect(isAbsolute(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── normalizePathForConfigKey ──────────────────────────────────────────
|
||||
|
||||
describe("normalizePathForConfigKey", () => {
|
||||
test("normalizes forward slashes (no change on POSIX)", () => {
|
||||
expect(normalizePathForConfigKey("foo/bar/baz")).toBe("foo/bar/baz");
|
||||
});
|
||||
describe('normalizePathForConfigKey', () => {
|
||||
test('normalizes forward slashes (no change on POSIX)', () => {
|
||||
expect(normalizePathForConfigKey('foo/bar/baz')).toBe('foo/bar/baz')
|
||||
})
|
||||
|
||||
test("resolves dot segments", () => {
|
||||
expect(normalizePathForConfigKey("foo/./bar")).toBe("foo/bar");
|
||||
});
|
||||
test('resolves dot segments', () => {
|
||||
expect(normalizePathForConfigKey('foo/./bar')).toBe('foo/bar')
|
||||
})
|
||||
|
||||
test("resolves double-dot segments", () => {
|
||||
expect(normalizePathForConfigKey("foo/bar/../baz")).toBe("foo/baz");
|
||||
});
|
||||
test('resolves double-dot segments', () => {
|
||||
expect(normalizePathForConfigKey('foo/bar/../baz')).toBe('foo/baz')
|
||||
})
|
||||
|
||||
test("handles absolute path", () => {
|
||||
const result = normalizePathForConfigKey("/Users/test/project");
|
||||
expect(result).toBe("/Users/test/project");
|
||||
});
|
||||
test('handles absolute path', () => {
|
||||
const result = normalizePathForConfigKey('/Users/test/project')
|
||||
expect(result).toBe('/Users/test/project')
|
||||
})
|
||||
|
||||
test("converts backslashes to forward slashes", () => {
|
||||
const result = normalizePathForConfigKey("foo\\bar\\baz");
|
||||
expect(result).toBe("foo/bar/baz");
|
||||
});
|
||||
test('converts backslashes to forward slashes', () => {
|
||||
const result = normalizePathForConfigKey('foo\\bar\\baz')
|
||||
expect(result).toBe('foo/bar/baz')
|
||||
})
|
||||
|
||||
test("normalizes mixed separators foo/bar\\baz", () => {
|
||||
const result = normalizePathForConfigKey("foo/bar\\baz");
|
||||
expect(result).toBe("foo/bar/baz");
|
||||
});
|
||||
test('normalizes mixed separators foo/bar\\baz', () => {
|
||||
const result = normalizePathForConfigKey('foo/bar\\baz')
|
||||
expect(result).toBe('foo/bar/baz')
|
||||
})
|
||||
|
||||
test("normalizes redundant separators foo//bar", () => {
|
||||
const result = normalizePathForConfigKey("foo//bar");
|
||||
expect(result).toBe("foo/bar");
|
||||
});
|
||||
});
|
||||
test('normalizes redundant separators foo//bar', () => {
|
||||
const result = normalizePathForConfigKey('foo//bar')
|
||||
expect(result).toBe('foo/bar')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── toRelativePath ─────────────────────────────────────────────────────
|
||||
|
||||
describe("toRelativePath", () => {
|
||||
test("returns relative path for a child of cwd", () => {
|
||||
describe('toRelativePath', () => {
|
||||
test('returns relative path for a child of cwd', () => {
|
||||
// Build a path that is inside the current working directory.
|
||||
// resolve() returns an absolute path, and toRelativePath should give
|
||||
// back just the final segment (or relative form without ..).
|
||||
const abs = resolve(process.cwd(), "package.json");
|
||||
const result = toRelativePath(abs);
|
||||
expect(result).toBe("package.json");
|
||||
expect(result).not.toContain("..");
|
||||
});
|
||||
const abs = resolve(process.cwd(), 'package.json')
|
||||
const result = toRelativePath(abs)
|
||||
expect(result).toBe('package.json')
|
||||
expect(result).not.toContain('..')
|
||||
})
|
||||
|
||||
test("returns absolute path when target is outside cwd", () => {
|
||||
test('returns absolute path when target is outside cwd', () => {
|
||||
// A well-known absolute path that is always outside any typical cwd
|
||||
// (any absolute path that doesn't start with process.cwd() will work)
|
||||
const cwd = process.cwd();
|
||||
const cwd = process.cwd()
|
||||
// Build a path guaranteed to be outside cwd by going to the root's parent
|
||||
// of cwd, then a sibling directory with an unlikely name
|
||||
const outsidePath = resolve(cwd, "../../__unlikely_dir_xyz__");
|
||||
const result = toRelativePath(outsidePath);
|
||||
const outsidePath = resolve(cwd, '../../__unlikely_dir_xyz__')
|
||||
const result = toRelativePath(outsidePath)
|
||||
// relative(cwd, outsidePath) will start with '../..' so function returns absolute
|
||||
expect(result).toBe(outsidePath);
|
||||
});
|
||||
expect(result).toBe(outsidePath)
|
||||
})
|
||||
|
||||
test("returns empty string for cwd itself", () => {
|
||||
const cwd = process.cwd();
|
||||
const result = toRelativePath(cwd);
|
||||
test('returns empty string for cwd itself', () => {
|
||||
const cwd = process.cwd()
|
||||
const result = toRelativePath(cwd)
|
||||
// relative(cwd, cwd) === '' which does not start with '..'
|
||||
expect(result).toBe("");
|
||||
});
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
test("returns a string for any absolute path", () => {
|
||||
const abs = resolve(process.cwd(), "src");
|
||||
const result = toRelativePath(abs);
|
||||
expect(typeof result).toBe("string");
|
||||
});
|
||||
});
|
||||
test('returns a string for any absolute path', () => {
|
||||
const abs = resolve(process.cwd(), 'src')
|
||||
const result = toRelativePath(abs)
|
||||
expect(typeof result).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getDirectoryForPath ─────────────────────────────────────────────────
|
||||
|
||||
describe("getDirectoryForPath", () => {
|
||||
test("returns the path itself when given an existing directory", () => {
|
||||
setOriginalFsImplementation();
|
||||
const dir = resolve(tmpdir(), "ccb-existing-dir");
|
||||
const baseFs = getFsImplementation();
|
||||
describe('getDirectoryForPath', () => {
|
||||
test('returns the path itself when given an existing directory', () => {
|
||||
setOriginalFsImplementation()
|
||||
const dir = resolve(tmpdir(), 'ccb-existing-dir')
|
||||
const baseFs = getFsImplementation()
|
||||
setFsImplementation({
|
||||
...baseFs,
|
||||
statSync: ((path: string) => {
|
||||
if (path === dir) {
|
||||
return { isDirectory: () => true } as any;
|
||||
return { isDirectory: () => true } as any
|
||||
}
|
||||
return baseFs.statSync(path);
|
||||
}) as FsOperations["statSync"],
|
||||
});
|
||||
return baseFs.statSync(path)
|
||||
}) as FsOperations['statSync'],
|
||||
})
|
||||
try {
|
||||
const result = getDirectoryForPath(dir);
|
||||
expect(result).toBe(dir);
|
||||
const result = getDirectoryForPath(dir)
|
||||
expect(result).toBe(dir)
|
||||
} finally {
|
||||
setOriginalFsImplementation();
|
||||
setOriginalFsImplementation()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("returns parent directory for a known file", () => {
|
||||
setOriginalFsImplementation();
|
||||
const expectedParent = resolve(tmpdir(), "ccb-file-parent");
|
||||
const file = resolve(expectedParent, "sample.txt");
|
||||
const baseFs = getFsImplementation();
|
||||
test('returns parent directory for a known file', () => {
|
||||
setOriginalFsImplementation()
|
||||
const expectedParent = resolve(tmpdir(), 'ccb-file-parent')
|
||||
const file = resolve(expectedParent, 'sample.txt')
|
||||
const baseFs = getFsImplementation()
|
||||
setFsImplementation({
|
||||
...baseFs,
|
||||
statSync: ((path: string) => {
|
||||
if (path === file) {
|
||||
return { isDirectory: () => false } as any;
|
||||
return { isDirectory: () => false } as any
|
||||
}
|
||||
return baseFs.statSync(path);
|
||||
}) as FsOperations["statSync"],
|
||||
});
|
||||
return baseFs.statSync(path)
|
||||
}) as FsOperations['statSync'],
|
||||
})
|
||||
try {
|
||||
const result = getDirectoryForPath(file);
|
||||
expect(result).toBe(expectedParent);
|
||||
const result = getDirectoryForPath(file)
|
||||
expect(result).toBe(expectedParent)
|
||||
} finally {
|
||||
setOriginalFsImplementation();
|
||||
setOriginalFsImplementation()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("returns parent directory for a non-existent path", () => {
|
||||
setOriginalFsImplementation();
|
||||
const expectedParent = resolve(tmpdir(), "ccb-missing-parent");
|
||||
const nonExistent = resolve(expectedParent, "does-not-exist-xyz123.ts");
|
||||
const baseFs = getFsImplementation();
|
||||
test('returns parent directory for a non-existent path', () => {
|
||||
setOriginalFsImplementation()
|
||||
const expectedParent = resolve(tmpdir(), 'ccb-missing-parent')
|
||||
const nonExistent = resolve(expectedParent, 'does-not-exist-xyz123.ts')
|
||||
const baseFs = getFsImplementation()
|
||||
setFsImplementation({
|
||||
...baseFs,
|
||||
statSync: ((path: string) => {
|
||||
if (path === nonExistent) {
|
||||
throw new Error("ENOENT");
|
||||
throw new Error('ENOENT')
|
||||
}
|
||||
return baseFs.statSync(path);
|
||||
}) as FsOperations["statSync"],
|
||||
});
|
||||
return baseFs.statSync(path)
|
||||
}) as FsOperations['statSync'],
|
||||
})
|
||||
try {
|
||||
const result = getDirectoryForPath(nonExistent);
|
||||
expect(result).toBe(expectedParent);
|
||||
const result = getDirectoryForPath(nonExistent)
|
||||
expect(result).toBe(expectedParent)
|
||||
} finally {
|
||||
setOriginalFsImplementation();
|
||||
setOriginalFsImplementation()
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,110 +1,115 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getPrivacyLevel,
|
||||
isEssentialTrafficOnly,
|
||||
isTelemetryDisabled,
|
||||
getEssentialTrafficOnlyReason,
|
||||
} from "../privacyLevel";
|
||||
} from '../privacyLevel'
|
||||
|
||||
describe("getPrivacyLevel", () => {
|
||||
const originalDisableNonessential = process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
const originalDisableTelemetry = process.env.DISABLE_TELEMETRY;
|
||||
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;
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
||||
delete process.env.DISABLE_TELEMETRY
|
||||
if (originalDisableNonessential !== undefined) {
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = originalDisableNonessential;
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC =
|
||||
originalDisableNonessential
|
||||
}
|
||||
if (originalDisableTelemetry !== undefined) {
|
||||
process.env.DISABLE_TELEMETRY = originalDisableTelemetry;
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
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;
|
||||
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;
|
||||
});
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
||||
process.env.DISABLE_TELEMETRY = '1'
|
||||
expect(isEssentialTrafficOnly()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTelemetryDisabled", () => {
|
||||
describe('isTelemetryDisabled', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
delete process.env.DISABLE_TELEMETRY;
|
||||
});
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1'
|
||||
expect(isTelemetryDisabled()).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for 'default' level", () => {
|
||||
expect(isTelemetryDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
expect(isTelemetryDisabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEssentialTrafficOnlyReason", () => {
|
||||
describe('getEssentialTrafficOnlyReason', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC;
|
||||
});
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
test('returns null when unrestricted', () => {
|
||||
delete process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
|
||||
expect(getEssentialTrafficOnlyReason()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,162 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
resetCommandQueue,
|
||||
enqueue,
|
||||
enqueuePendingNotification,
|
||||
resetCommandQueue,
|
||||
enqueue,
|
||||
enqueuePendingNotification,
|
||||
} from '../messageQueueManager.js'
|
||||
import { hasQueuedCommands, processQueueIfReady } from '../queueProcessor.js'
|
||||
|
||||
beforeEach(() => {
|
||||
resetCommandQueue()
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCommandQueue()
|
||||
resetCommandQueue()
|
||||
})
|
||||
|
||||
describe('processQueueIfReady', () => {
|
||||
test('returns processed:false when queue empty', () => {
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async () => {},
|
||||
})
|
||||
expect(result.processed).toBe(false)
|
||||
})
|
||||
test('returns processed:false when queue empty', () => {
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async () => {},
|
||||
})
|
||||
expect(result.processed).toBe(false)
|
||||
})
|
||||
|
||||
test('processes single slash command individually', () => {
|
||||
const executed: string[][] = []
|
||||
enqueue({ value: '/help', mode: 'prompt' } as any)
|
||||
test('processes single slash command individually', () => {
|
||||
const executed: string[][] = []
|
||||
enqueue({ value: '/help', mode: 'prompt' } as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['/help'])
|
||||
})
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['/help'])
|
||||
})
|
||||
|
||||
test('processes bash mode command individually', () => {
|
||||
const executed: string[][] = []
|
||||
enqueue({ value: 'git status', mode: 'bash' } as any)
|
||||
test('processes bash mode command individually', () => {
|
||||
const executed: string[][] = []
|
||||
enqueue({ value: 'git status', mode: 'bash' } as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['git status'])
|
||||
})
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['git status'])
|
||||
})
|
||||
|
||||
test('batches commands with same mode', () => {
|
||||
const executed: string[][] = []
|
||||
enqueuePendingNotification({ value: '<task1/>', mode: 'task-notification' } as any)
|
||||
enqueuePendingNotification({ value: '<task2/>', mode: 'task-notification' } as any)
|
||||
test('batches commands with same mode', () => {
|
||||
const executed: string[][] = []
|
||||
enqueuePendingNotification({
|
||||
value: '<task1/>',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
enqueuePendingNotification({
|
||||
value: '<task2/>',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['<task1/>', '<task2/>'])
|
||||
})
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['<task1/>', '<task2/>'])
|
||||
})
|
||||
|
||||
test('does not mix different modes in same batch', () => {
|
||||
const executed: string[][] = []
|
||||
enqueue({ value: 'hello', mode: 'prompt' } as any)
|
||||
enqueuePendingNotification({ value: '<task/>', mode: 'task-notification' } as any)
|
||||
test('does not mix different modes in same batch', () => {
|
||||
const executed: string[][] = []
|
||||
enqueue({ value: 'hello', mode: 'prompt' } as any)
|
||||
enqueuePendingNotification({
|
||||
value: '<task/>',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.processed).toBe(true)
|
||||
// Only the 'prompt' mode command should be processed (higher priority than task-notification)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['hello'])
|
||||
expect(result.processed).toBe(true)
|
||||
// Only the 'prompt' mode command should be processed (higher priority than task-notification)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['hello'])
|
||||
|
||||
// The task-notification is still in queue
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
// The task-notification is still in queue
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
|
||||
test('skips commands with agentId set (subagent notifications)', () => {
|
||||
// This simulates the v2.1.119 fix: subagent task-notification with agentId
|
||||
// should not be processed by the main thread queue processor
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification>subagent result</task-notification>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-123',
|
||||
} as any)
|
||||
test('skips commands with agentId set (subagent notifications)', () => {
|
||||
// This simulates the v2.1.119 fix: subagent task-notification with agentId
|
||||
// should not be processed by the main thread queue processor
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification>subagent result</task-notification>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-123',
|
||||
} as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async () => {},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async () => {},
|
||||
})
|
||||
|
||||
// Should not process — it's a subagent notification
|
||||
expect(result.processed).toBe(false)
|
||||
})
|
||||
// Should not process — it's a subagent notification
|
||||
expect(result.processed).toBe(false)
|
||||
})
|
||||
|
||||
test('returns processed:false when only subagent commands in queue', () => {
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification/>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-456',
|
||||
} as any)
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification/>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-789',
|
||||
} as any)
|
||||
test('returns processed:false when only subagent commands in queue', () => {
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification/>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-456',
|
||||
} as any)
|
||||
enqueuePendingNotification({
|
||||
value: '<task-notification/>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-789',
|
||||
} as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async () => {},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async () => {},
|
||||
})
|
||||
|
||||
expect(result.processed).toBe(false)
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
expect(result.processed).toBe(false)
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
|
||||
test('processes main-thread command but skips subagent command', () => {
|
||||
const executed: string[][] = []
|
||||
enqueuePendingNotification({ value: '<main-task/>', mode: 'task-notification' } as any)
|
||||
enqueuePendingNotification({
|
||||
value: '<sub-task/>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-123',
|
||||
} as any)
|
||||
test('processes main-thread command but skips subagent command', () => {
|
||||
const executed: string[][] = []
|
||||
enqueuePendingNotification({
|
||||
value: '<main-task/>',
|
||||
mode: 'task-notification',
|
||||
} as any)
|
||||
enqueuePendingNotification({
|
||||
value: '<sub-task/>',
|
||||
mode: 'task-notification',
|
||||
agentId: 'agent-123',
|
||||
} as any)
|
||||
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
const result = processQueueIfReady({
|
||||
executeInput: async cmds => {
|
||||
executed.push(cmds.map(c => c.value as string))
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['<main-task/>'])
|
||||
expect(result.processed).toBe(true)
|
||||
expect(executed).toHaveLength(1)
|
||||
expect(executed[0]).toEqual(['<main-task/>'])
|
||||
|
||||
// Subagent command still in queue
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
// Subagent command still in queue
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasQueuedCommands', () => {
|
||||
test('returns false when queue empty', () => {
|
||||
expect(hasQueuedCommands()).toBe(false)
|
||||
})
|
||||
test('returns false when queue empty', () => {
|
||||
expect(hasQueuedCommands()).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when commands in queue', () => {
|
||||
enqueue({ value: 'hello', mode: 'prompt' } as any)
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
test('returns true when commands in queue', () => {
|
||||
enqueue({ value: 'hello', mode: 'prompt' } as any)
|
||||
expect(hasQueuedCommands()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,75 +1,75 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
partiallySanitizeUnicode,
|
||||
recursivelySanitizeUnicode,
|
||||
} from "../sanitization";
|
||||
} from '../sanitization'
|
||||
|
||||
// ─── partiallySanitizeUnicode ───────────────────────────────────────────
|
||||
|
||||
describe("partiallySanitizeUnicode", () => {
|
||||
test("preserves normal ASCII text", () => {
|
||||
expect(partiallySanitizeUnicode("hello world")).toBe("hello world");
|
||||
});
|
||||
describe('partiallySanitizeUnicode', () => {
|
||||
test('preserves normal ASCII text', () => {
|
||||
expect(partiallySanitizeUnicode('hello world')).toBe('hello world')
|
||||
})
|
||||
|
||||
test("preserves CJK characters", () => {
|
||||
expect(partiallySanitizeUnicode("你好世界")).toBe("你好世界");
|
||||
});
|
||||
test('preserves CJK characters', () => {
|
||||
expect(partiallySanitizeUnicode('你好世界')).toBe('你好世界')
|
||||
})
|
||||
|
||||
test("removes zero-width spaces", () => {
|
||||
expect(partiallySanitizeUnicode("hello\u200Bworld")).toBe("helloworld");
|
||||
});
|
||||
test('removes zero-width spaces', () => {
|
||||
expect(partiallySanitizeUnicode('hello\u200Bworld')).toBe('helloworld')
|
||||
})
|
||||
|
||||
test("removes BOM", () => {
|
||||
expect(partiallySanitizeUnicode("\uFEFFhello")).toBe("hello");
|
||||
});
|
||||
test('removes BOM', () => {
|
||||
expect(partiallySanitizeUnicode('\uFEFFhello')).toBe('hello')
|
||||
})
|
||||
|
||||
test("removes directional formatting", () => {
|
||||
expect(partiallySanitizeUnicode("hello\u202Aworld")).toBe("helloworld");
|
||||
});
|
||||
test('removes directional formatting', () => {
|
||||
expect(partiallySanitizeUnicode('hello\u202Aworld')).toBe('helloworld')
|
||||
})
|
||||
|
||||
test("removes private use area characters", () => {
|
||||
expect(partiallySanitizeUnicode("hello\uE000world")).toBe("helloworld");
|
||||
});
|
||||
test('removes private use area characters', () => {
|
||||
expect(partiallySanitizeUnicode('hello\uE000world')).toBe('helloworld')
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(partiallySanitizeUnicode("")).toBe("");
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(partiallySanitizeUnicode('')).toBe('')
|
||||
})
|
||||
|
||||
test("handles string with only dangerous characters", () => {
|
||||
const result = partiallySanitizeUnicode("\u200B\u200C\u200D\uFEFF");
|
||||
expect(result.length).toBeLessThanOrEqual(1); // ZWJ may survive NFKC
|
||||
});
|
||||
});
|
||||
test('handles string with only dangerous characters', () => {
|
||||
const result = partiallySanitizeUnicode('\u200B\u200C\u200D\uFEFF')
|
||||
expect(result.length).toBeLessThanOrEqual(1) // ZWJ may survive NFKC
|
||||
})
|
||||
})
|
||||
|
||||
// ─── recursivelySanitizeUnicode ─────────────────────────────────────────
|
||||
|
||||
describe("recursivelySanitizeUnicode", () => {
|
||||
test("sanitizes string values", () => {
|
||||
expect(recursivelySanitizeUnicode("hello\u200Bworld")).toBe("helloworld");
|
||||
});
|
||||
describe('recursivelySanitizeUnicode', () => {
|
||||
test('sanitizes string values', () => {
|
||||
expect(recursivelySanitizeUnicode('hello\u200Bworld')).toBe('helloworld')
|
||||
})
|
||||
|
||||
test("sanitizes array elements", () => {
|
||||
const result = recursivelySanitizeUnicode(["a\u200Bb", "c\uFEFFd"]);
|
||||
expect(result).toEqual(["ab", "cd"]);
|
||||
});
|
||||
test('sanitizes array elements', () => {
|
||||
const result = recursivelySanitizeUnicode(['a\u200Bb', 'c\uFEFFd'])
|
||||
expect(result).toEqual(['ab', 'cd'])
|
||||
})
|
||||
|
||||
test("sanitizes object values recursively", () => {
|
||||
test('sanitizes object values recursively', () => {
|
||||
const result = recursivelySanitizeUnicode({
|
||||
key: "val\u200Bue",
|
||||
nested: { inner: "te\uFEFFst" },
|
||||
});
|
||||
expect(result).toEqual({ key: "value", nested: { inner: "test" } });
|
||||
});
|
||||
key: 'val\u200Bue',
|
||||
nested: { inner: 'te\uFEFFst' },
|
||||
})
|
||||
expect(result).toEqual({ key: 'value', nested: { inner: 'test' } })
|
||||
})
|
||||
|
||||
test("preserves numbers", () => {
|
||||
expect(recursivelySanitizeUnicode(42)).toBe(42);
|
||||
});
|
||||
test('preserves numbers', () => {
|
||||
expect(recursivelySanitizeUnicode(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("preserves booleans", () => {
|
||||
expect(recursivelySanitizeUnicode(true)).toBe(true);
|
||||
});
|
||||
test('preserves booleans', () => {
|
||||
expect(recursivelySanitizeUnicode(true)).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves null", () => {
|
||||
expect(recursivelySanitizeUnicode(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
test('preserves null', () => {
|
||||
expect(recursivelySanitizeUnicode(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { z } from "zod/v4";
|
||||
import { semanticBoolean } from "../semanticBoolean";
|
||||
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);
|
||||
});
|
||||
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 boolean false to false', () => {
|
||||
expect(semanticBoolean().parse(false)).toBe(false)
|
||||
})
|
||||
|
||||
test("parses string 'true' to true", () => {
|
||||
expect(semanticBoolean().parse("true")).toBe(true);
|
||||
});
|
||||
expect(semanticBoolean().parse('true')).toBe(true)
|
||||
})
|
||||
|
||||
test("parses string 'false' to false", () => {
|
||||
expect(semanticBoolean().parse("false")).toBe(false);
|
||||
});
|
||||
expect(semanticBoolean().parse('false')).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects string 'TRUE' (case-sensitive)", () => {
|
||||
expect(() => semanticBoolean().parse("TRUE")).toThrow();
|
||||
});
|
||||
expect(() => semanticBoolean().parse('TRUE')).toThrow()
|
||||
})
|
||||
|
||||
test("rejects string 'FALSE' (case-sensitive)", () => {
|
||||
expect(() => semanticBoolean().parse("FALSE")).toThrow();
|
||||
});
|
||||
expect(() => semanticBoolean().parse('FALSE')).toThrow()
|
||||
})
|
||||
|
||||
test("rejects number 1", () => {
|
||||
expect(() => semanticBoolean().parse(1)).toThrow();
|
||||
});
|
||||
test('rejects number 1', () => {
|
||||
expect(() => semanticBoolean().parse(1)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects null", () => {
|
||||
expect(() => semanticBoolean().parse(null)).toThrow();
|
||||
});
|
||||
test('rejects null', () => {
|
||||
expect(() => semanticBoolean().parse(null)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects undefined", () => {
|
||||
expect(() => semanticBoolean().parse(undefined)).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();
|
||||
});
|
||||
});
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { z } from "zod/v4";
|
||||
import { semanticNumber } from "../semanticNumber";
|
||||
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);
|
||||
});
|
||||
describe('semanticNumber', () => {
|
||||
test('parses number 42', () => {
|
||||
expect(semanticNumber().parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("parses number 0", () => {
|
||||
expect(semanticNumber().parse(0)).toBe(0);
|
||||
});
|
||||
test('parses number 0', () => {
|
||||
expect(semanticNumber().parse(0)).toBe(0)
|
||||
})
|
||||
|
||||
test("parses negative number -5", () => {
|
||||
expect(semanticNumber().parse(-5)).toBe(-5);
|
||||
});
|
||||
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 float 3.14', () => {
|
||||
expect(semanticNumber().parse(3.14)).toBeCloseTo(3.14)
|
||||
})
|
||||
|
||||
test("parses string '42' to 42", () => {
|
||||
expect(semanticNumber().parse("42")).toBe(42);
|
||||
});
|
||||
expect(semanticNumber().parse('42')).toBe(42)
|
||||
})
|
||||
|
||||
test("parses string '-7.5' to -7.5", () => {
|
||||
expect(semanticNumber().parse("-7.5")).toBe(-7.5);
|
||||
});
|
||||
expect(semanticNumber().parse('-7.5')).toBe(-7.5)
|
||||
})
|
||||
|
||||
test("rejects string 'abc'", () => {
|
||||
expect(() => semanticNumber().parse("abc")).toThrow();
|
||||
});
|
||||
expect(() => semanticNumber().parse('abc')).toThrow()
|
||||
})
|
||||
|
||||
test("rejects empty string ''", () => {
|
||||
expect(() => semanticNumber().parse("")).toThrow();
|
||||
});
|
||||
expect(() => semanticNumber().parse('')).toThrow()
|
||||
})
|
||||
|
||||
test("rejects null", () => {
|
||||
expect(() => semanticNumber().parse(null)).toThrow();
|
||||
});
|
||||
test('rejects null', () => {
|
||||
expect(() => semanticNumber().parse(null)).toThrow()
|
||||
})
|
||||
|
||||
test("rejects boolean true", () => {
|
||||
expect(() => semanticNumber().parse(true)).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();
|
||||
});
|
||||
});
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { gt, gte, lt, lte, satisfies, order } from "../semver";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { gt, gte, lt, lte, satisfies, order } from '../semver'
|
||||
|
||||
describe("gt", () => {
|
||||
test("returns true when a > b", () => {
|
||||
expect(gt("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
describe('gt', () => {
|
||||
test('returns true when a > b', () => {
|
||||
expect(gt('2.0.0', '1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(gt("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
test('returns false when a < b', () => {
|
||||
expect(gt('1.0.0', '2.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when equal", () => {
|
||||
expect(gt("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
test('returns false when equal', () => {
|
||||
expect(gt('1.0.0', '1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for 0.0.0 vs 0.0.0", () => {
|
||||
expect(gt("0.0.0", "0.0.0")).toBe(false);
|
||||
});
|
||||
test('returns false for 0.0.0 vs 0.0.0', () => {
|
||||
expect(gt('0.0.0', '0.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test("release is greater than pre-release", () => {
|
||||
expect(gt("1.0.0", "1.0.0-alpha")).toBe(true);
|
||||
});
|
||||
});
|
||||
test('release is greater than pre-release', () => {
|
||||
expect(gt('1.0.0', '1.0.0-alpha')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("gte", () => {
|
||||
test("returns true when a > b", () => {
|
||||
expect(gte("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
describe('gte', () => {
|
||||
test('returns true when a > b', () => {
|
||||
expect(gte('2.0.0', '1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true when equal", () => {
|
||||
expect(gte("1.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
test('returns true when equal', () => {
|
||||
expect(gte('1.0.0', '1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(gte("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false when a < b', () => {
|
||||
expect(gte('1.0.0', '2.0.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lt", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(lt("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
describe('lt', () => {
|
||||
test('returns true when a < b', () => {
|
||||
expect(lt('1.0.0', '2.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a > b", () => {
|
||||
expect(lt("2.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
test('returns false when a > b', () => {
|
||||
expect(lt('2.0.0', '1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when equal", () => {
|
||||
expect(lt("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false when equal', () => {
|
||||
expect(lt('1.0.0', '1.0.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("lte", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(lte("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
describe('lte', () => {
|
||||
test('returns true when a < b', () => {
|
||||
expect(lte('1.0.0', '2.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true when equal", () => {
|
||||
expect(lte("1.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
test('returns true when equal', () => {
|
||||
expect(lte('1.0.0', '1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a > b", () => {
|
||||
expect(lte("2.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false when a > b', () => {
|
||||
expect(lte('2.0.0', '1.0.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("satisfies", () => {
|
||||
test("matches exact version", () => {
|
||||
expect(satisfies("1.2.3", "1.2.3")).toBe(true);
|
||||
});
|
||||
describe('satisfies', () => {
|
||||
test('matches exact version', () => {
|
||||
expect(satisfies('1.2.3', '1.2.3')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches range", () => {
|
||||
expect(satisfies("1.2.3", ">=1.0.0")).toBe(true);
|
||||
});
|
||||
test('matches range', () => {
|
||||
expect(satisfies('1.2.3', '>=1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match out-of-range version", () => {
|
||||
expect(satisfies("0.9.0", ">=1.0.0")).toBe(false);
|
||||
});
|
||||
test('does not match out-of-range version', () => {
|
||||
expect(satisfies('0.9.0', '>=1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test("matches caret range", () => {
|
||||
expect(satisfies("1.2.3", "^1.0.0")).toBe(true);
|
||||
});
|
||||
test('matches caret range', () => {
|
||||
expect(satisfies('1.2.3', '^1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match major bump in caret", () => {
|
||||
expect(satisfies("2.0.0", "^1.0.0")).toBe(false);
|
||||
});
|
||||
test('does not match major bump in caret', () => {
|
||||
expect(satisfies('2.0.0', '^1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test("matches tilde range", () => {
|
||||
expect(satisfies("1.2.5", "~1.2.3")).toBe(true);
|
||||
});
|
||||
test('matches tilde range', () => {
|
||||
expect(satisfies('1.2.5', '~1.2.3')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches wildcard range", () => {
|
||||
expect(satisfies("2.0.0", "*")).toBe(true);
|
||||
});
|
||||
});
|
||||
test('matches wildcard range', () => {
|
||||
expect(satisfies('2.0.0', '*')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("order", () => {
|
||||
test("returns 1 when a > b", () => {
|
||||
expect(order("2.0.0", "1.0.0")).toBe(1);
|
||||
});
|
||||
describe('order', () => {
|
||||
test('returns 1 when a > b', () => {
|
||||
expect(order('2.0.0', '1.0.0')).toBe(1)
|
||||
})
|
||||
|
||||
test("returns -1 when a < b", () => {
|
||||
expect(order("1.0.0", "2.0.0")).toBe(-1);
|
||||
});
|
||||
test('returns -1 when a < b', () => {
|
||||
expect(order('1.0.0', '2.0.0')).toBe(-1)
|
||||
})
|
||||
|
||||
test("returns 0 when equal", () => {
|
||||
expect(order("1.0.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
test('returns 0 when equal', () => {
|
||||
expect(order('1.0.0', '1.0.0')).toBe(0)
|
||||
})
|
||||
|
||||
test("compares patch versions", () => {
|
||||
expect(order("1.0.1", "1.0.0")).toBe(1);
|
||||
});
|
||||
});
|
||||
test('compares patch versions', () => {
|
||||
expect(order('1.0.1', '1.0.0')).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sequential } from "../sequential";
|
||||
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);
|
||||
});
|
||||
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('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[] = [];
|
||||
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;
|
||||
});
|
||||
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]);
|
||||
});
|
||||
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('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;
|
||||
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";
|
||||
});
|
||||
callCount++
|
||||
if (callCount === 1) throw new Error('first fail')
|
||||
return 'ok'
|
||||
})
|
||||
|
||||
await expect(fn()).rejects.toThrow("first fail");
|
||||
expect(await fn()).toBe("ok");
|
||||
});
|
||||
await expect(fn()).rejects.toThrow('first fail')
|
||||
expect(await fn()).toBe('ok')
|
||||
})
|
||||
|
||||
test("preserves rejection reason", async () => {
|
||||
test('preserves rejection reason', async () => {
|
||||
const fn = sequential(async () => {
|
||||
throw new Error("specific error");
|
||||
});
|
||||
await expect(fn()).rejects.toThrow("specific error");
|
||||
});
|
||||
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('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('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[] = [];
|
||||
test('handles rapid concurrent calls', async () => {
|
||||
const order: number[] = []
|
||||
const fn = sequential(async (n: number) => {
|
||||
order.push(n);
|
||||
return n;
|
||||
});
|
||||
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]);
|
||||
});
|
||||
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[] = [];
|
||||
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;
|
||||
});
|
||||
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");
|
||||
});
|
||||
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 () => {
|
||||
test('works with functions returning different types', async () => {
|
||||
const fn = sequential(async (x: number): Promise<string | number> => {
|
||||
return x > 0 ? "positive" : x;
|
||||
});
|
||||
expect(await fn(5)).toBe("positive");
|
||||
expect(await fn(-1)).toBe(-1);
|
||||
});
|
||||
});
|
||||
return x > 0 ? 'positive' : x
|
||||
})
|
||||
expect(await fn(5)).toBe('positive')
|
||||
expect(await fn(-1)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { difference, every, intersects, union } from "../set";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { difference, every, intersects, union } from '../set'
|
||||
|
||||
describe("difference", () => {
|
||||
test("returns elements in a but not in b", () => {
|
||||
const result = difference(new Set([1, 2, 3]), new Set([2, 3, 4]));
|
||||
expect(result).toEqual(new Set([1]));
|
||||
});
|
||||
describe('difference', () => {
|
||||
test('returns elements in a but not in b', () => {
|
||||
const result = difference(new Set([1, 2, 3]), new Set([2, 3, 4]))
|
||||
expect(result).toEqual(new Set([1]))
|
||||
})
|
||||
|
||||
test("returns empty set when a is subset of b", () => {
|
||||
expect(difference(new Set([1, 2]), new Set([1, 2, 3]))).toEqual(new Set());
|
||||
});
|
||||
test('returns empty set when a is subset of b', () => {
|
||||
expect(difference(new Set([1, 2]), new Set([1, 2, 3]))).toEqual(new Set())
|
||||
})
|
||||
|
||||
test("returns a when b is empty", () => {
|
||||
expect(difference(new Set([1, 2]), new Set())).toEqual(new Set([1, 2]));
|
||||
});
|
||||
});
|
||||
test('returns a when b is empty', () => {
|
||||
expect(difference(new Set([1, 2]), new Set())).toEqual(new Set([1, 2]))
|
||||
})
|
||||
})
|
||||
|
||||
describe("intersects", () => {
|
||||
test("returns true when sets share elements", () => {
|
||||
expect(intersects(new Set([1, 2]), new Set([2, 3]))).toBe(true);
|
||||
});
|
||||
describe('intersects', () => {
|
||||
test('returns true when sets share elements', () => {
|
||||
expect(intersects(new Set([1, 2]), new Set([2, 3]))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when sets are disjoint", () => {
|
||||
expect(intersects(new Set([1, 2]), new Set([3, 4]))).toBe(false);
|
||||
});
|
||||
test('returns false when sets are disjoint', () => {
|
||||
expect(intersects(new Set([1, 2]), new Set([3, 4]))).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty sets", () => {
|
||||
expect(intersects(new Set(), new Set([1]))).toBe(false);
|
||||
expect(intersects(new Set([1]), new Set())).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for empty sets', () => {
|
||||
expect(intersects(new Set(), new Set([1]))).toBe(false)
|
||||
expect(intersects(new Set([1]), new Set())).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("every", () => {
|
||||
test("returns true when a is subset of b", () => {
|
||||
expect(every(new Set([1, 2]), new Set([1, 2, 3]))).toBe(true);
|
||||
});
|
||||
describe('every', () => {
|
||||
test('returns true when a is subset of b', () => {
|
||||
expect(every(new Set([1, 2]), new Set([1, 2, 3]))).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a has elements not in b", () => {
|
||||
expect(every(new Set([1, 4]), new Set([1, 2, 3]))).toBe(false);
|
||||
});
|
||||
test('returns false when a has elements not in b', () => {
|
||||
expect(every(new Set([1, 4]), new Set([1, 2, 3]))).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for empty a", () => {
|
||||
expect(every(new Set(), new Set([1, 2]))).toBe(true);
|
||||
});
|
||||
});
|
||||
test('returns true for empty a', () => {
|
||||
expect(every(new Set(), new Set([1, 2]))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("union", () => {
|
||||
test("combines both sets", () => {
|
||||
const result = union(new Set([1, 2]), new Set([3, 4]));
|
||||
expect(result).toEqual(new Set([1, 2, 3, 4]));
|
||||
});
|
||||
describe('union', () => {
|
||||
test('combines both sets', () => {
|
||||
const result = union(new Set([1, 2]), new Set([3, 4]))
|
||||
expect(result).toEqual(new Set([1, 2, 3, 4]))
|
||||
})
|
||||
|
||||
test("deduplicates shared elements", () => {
|
||||
const result = union(new Set([1, 2]), new Set([2, 3]));
|
||||
expect(result).toEqual(new Set([1, 2, 3]));
|
||||
});
|
||||
test('deduplicates shared elements', () => {
|
||||
const result = union(new Set([1, 2]), new Set([2, 3]))
|
||||
expect(result).toEqual(new Set([1, 2, 3]))
|
||||
})
|
||||
|
||||
test("handles empty sets", () => {
|
||||
expect(union(new Set(), new Set([1]))).toEqual(new Set([1]));
|
||||
expect(union(new Set([1]), new Set())).toEqual(new Set([1]));
|
||||
});
|
||||
});
|
||||
test('handles empty sets', () => {
|
||||
expect(union(new Set(), new Set([1]))).toEqual(new Set([1]))
|
||||
expect(union(new Set([1]), new Set())).toEqual(new Set([1]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseSlashCommand } from "../slashCommandParsing";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseSlashCommand } from '../slashCommandParsing'
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
test("parses simple command", () => {
|
||||
const result = parseSlashCommand("/search foo bar");
|
||||
describe('parseSlashCommand', () => {
|
||||
test('parses simple command', () => {
|
||||
const result = parseSlashCommand('/search foo bar')
|
||||
expect(result).toEqual({
|
||||
commandName: "search",
|
||||
args: "foo bar",
|
||||
commandName: 'search',
|
||||
args: 'foo bar',
|
||||
isMcp: false,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses command without args", () => {
|
||||
const result = parseSlashCommand("/help");
|
||||
test('parses command without args', () => {
|
||||
const result = parseSlashCommand('/help')
|
||||
expect(result).toEqual({
|
||||
commandName: "help",
|
||||
args: "",
|
||||
commandName: 'help',
|
||||
args: '',
|
||||
isMcp: false,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses MCP command", () => {
|
||||
const result = parseSlashCommand("/tool (MCP) arg1 arg2");
|
||||
test('parses MCP command', () => {
|
||||
const result = parseSlashCommand('/tool (MCP) arg1 arg2')
|
||||
expect(result).toEqual({
|
||||
commandName: "tool (MCP)",
|
||||
args: "arg1 arg2",
|
||||
commandName: 'tool (MCP)',
|
||||
args: 'arg1 arg2',
|
||||
isMcp: true,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("parses MCP command without args", () => {
|
||||
const result = parseSlashCommand("/tool (MCP)");
|
||||
test('parses MCP command without args', () => {
|
||||
const result = parseSlashCommand('/tool (MCP)')
|
||||
expect(result).toEqual({
|
||||
commandName: "tool (MCP)",
|
||||
args: "",
|
||||
commandName: 'tool (MCP)',
|
||||
args: '',
|
||||
isMcp: true,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null for non-slash input", () => {
|
||||
expect(parseSlashCommand("hello")).toBeNull();
|
||||
});
|
||||
test('returns null for non-slash input', () => {
|
||||
expect(parseSlashCommand('hello')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseSlashCommand("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(parseSlashCommand('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for just slash", () => {
|
||||
expect(parseSlashCommand("/")).toBeNull();
|
||||
});
|
||||
test('returns null for just slash', () => {
|
||||
expect(parseSlashCommand('/')).toBeNull()
|
||||
})
|
||||
|
||||
test("trims whitespace before parsing", () => {
|
||||
const result = parseSlashCommand(" /search foo ");
|
||||
expect(result!.commandName).toBe("search");
|
||||
expect(result!.args).toBe("foo");
|
||||
});
|
||||
});
|
||||
test('trims whitespace before parsing', () => {
|
||||
const result = parseSlashCommand(' /search foo ')
|
||||
expect(result!.commandName).toBe('search')
|
||||
expect(result!.args).toBe('foo')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,130 +1,126 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { sleep, withTimeout } from "../sleep";
|
||||
import { sequential } from "../sequential";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { sleep, withTimeout } from '../sleep'
|
||||
import { sequential } from '../sequential'
|
||||
|
||||
// ─── sleep ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("sleep", () => {
|
||||
test("resolves after timeout", async () => {
|
||||
const start = Date.now();
|
||||
await sleep(50);
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
describe('sleep', () => {
|
||||
test('resolves after timeout', async () => {
|
||||
const start = Date.now()
|
||||
await sleep(50)
|
||||
expect(Date.now() - start).toBeGreaterThanOrEqual(40)
|
||||
})
|
||||
|
||||
test("resolves immediately when signal already aborted", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const start = Date.now();
|
||||
await sleep(10_000, ac.signal);
|
||||
expect(Date.now() - start).toBeLessThan(50);
|
||||
});
|
||||
test('resolves immediately when signal already aborted', async () => {
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
const start = Date.now()
|
||||
await sleep(10_000, ac.signal)
|
||||
expect(Date.now() - start).toBeLessThan(50)
|
||||
})
|
||||
|
||||
test("resolves early on abort (default: no throw)", async () => {
|
||||
const ac = new AbortController();
|
||||
const start = Date.now();
|
||||
const p = sleep(10_000, ac.signal);
|
||||
setTimeout(() => ac.abort(), 30);
|
||||
await p;
|
||||
expect(Date.now() - start).toBeLessThan(200);
|
||||
});
|
||||
test('resolves early on abort (default: no throw)', async () => {
|
||||
const ac = new AbortController()
|
||||
const start = Date.now()
|
||||
const p = sleep(10_000, ac.signal)
|
||||
setTimeout(() => ac.abort(), 30)
|
||||
await p
|
||||
expect(Date.now() - start).toBeLessThan(200)
|
||||
})
|
||||
|
||||
test("rejects on abort with throwOnAbort", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
test('rejects on abort with throwOnAbort', async () => {
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
await expect(
|
||||
sleep(10_000, ac.signal, { throwOnAbort: true })
|
||||
).rejects.toThrow("aborted");
|
||||
});
|
||||
sleep(10_000, ac.signal, { throwOnAbort: true }),
|
||||
).rejects.toThrow('aborted')
|
||||
})
|
||||
|
||||
test("rejects with custom abortError", async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
const customErr = () => new Error("custom abort");
|
||||
test('rejects with custom abortError', async () => {
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
const customErr = () => new Error('custom abort')
|
||||
await expect(
|
||||
sleep(10_000, ac.signal, { abortError: customErr })
|
||||
).rejects.toThrow("custom abort");
|
||||
});
|
||||
sleep(10_000, ac.signal, { abortError: customErr }),
|
||||
).rejects.toThrow('custom abort')
|
||||
})
|
||||
|
||||
test("throwOnAbort rejects on mid-sleep abort", async () => {
|
||||
const ac = new AbortController();
|
||||
const p = sleep(10_000, ac.signal, { throwOnAbort: true });
|
||||
setTimeout(() => ac.abort(), 20);
|
||||
await expect(p).rejects.toThrow("aborted");
|
||||
});
|
||||
test('throwOnAbort rejects on mid-sleep abort', async () => {
|
||||
const ac = new AbortController()
|
||||
const p = sleep(10_000, ac.signal, { throwOnAbort: true })
|
||||
setTimeout(() => ac.abort(), 20)
|
||||
await expect(p).rejects.toThrow('aborted')
|
||||
})
|
||||
|
||||
test("works without signal", async () => {
|
||||
await sleep(10);
|
||||
test('works without signal', async () => {
|
||||
await sleep(10)
|
||||
// just verify it resolves
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── withTimeout ───────────────────────────────────────────────────────
|
||||
|
||||
describe("withTimeout", () => {
|
||||
test("resolves when promise completes before timeout", async () => {
|
||||
const result = await withTimeout(
|
||||
Promise.resolve(42),
|
||||
1000,
|
||||
"timed out"
|
||||
);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
describe('withTimeout', () => {
|
||||
test('resolves when promise completes before timeout', async () => {
|
||||
const result = await withTimeout(Promise.resolve(42), 1000, 'timed out')
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
test("rejects when promise takes too long", async () => {
|
||||
const slow = new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
await expect(
|
||||
withTimeout(slow, 50, "operation timed out")
|
||||
).rejects.toThrow("operation timed out");
|
||||
});
|
||||
test('rejects when promise takes too long', async () => {
|
||||
const slow = new Promise(resolve => setTimeout(resolve, 5000))
|
||||
await expect(withTimeout(slow, 50, 'operation timed out')).rejects.toThrow(
|
||||
'operation timed out',
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects propagate through", async () => {
|
||||
test('rejects propagate through', async () => {
|
||||
await expect(
|
||||
withTimeout(Promise.reject(new Error("inner")), 1000, "timeout")
|
||||
).rejects.toThrow("inner");
|
||||
});
|
||||
});
|
||||
withTimeout(Promise.reject(new Error('inner')), 1000, 'timeout'),
|
||||
).rejects.toThrow('inner')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── sequential ────────────────────────────────────────────────────────
|
||||
|
||||
describe("sequential", () => {
|
||||
test("executes calls in order", async () => {
|
||||
const order: number[] = [];
|
||||
describe('sequential', () => {
|
||||
test('executes calls in order', async () => {
|
||||
const order: number[] = []
|
||||
const fn = sequential(async (n: number) => {
|
||||
await sleep(10);
|
||||
order.push(n);
|
||||
return n;
|
||||
});
|
||||
await sleep(10)
|
||||
order.push(n)
|
||||
return n
|
||||
})
|
||||
|
||||
const results = await Promise.all([fn(1), fn(2), fn(3)]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
expect(results).toEqual([1, 2, 3]);
|
||||
});
|
||||
const results = await Promise.all([fn(1), fn(2), fn(3)])
|
||||
expect(order).toEqual([1, 2, 3])
|
||||
expect(results).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("returns correct result for each call", async () => {
|
||||
const fn = sequential(async (x: number) => x * 2);
|
||||
const r1 = await fn(5);
|
||||
const r2 = await fn(10);
|
||||
expect(r1).toBe(10);
|
||||
expect(r2).toBe(20);
|
||||
});
|
||||
test('returns correct result for each call', async () => {
|
||||
const fn = sequential(async (x: number) => x * 2)
|
||||
const r1 = await fn(5)
|
||||
const r2 = await fn(10)
|
||||
expect(r1).toBe(10)
|
||||
expect(r2).toBe(20)
|
||||
})
|
||||
|
||||
test("propagates errors without blocking queue", async () => {
|
||||
test('propagates errors without blocking queue', async () => {
|
||||
const fn = sequential(async (x: number) => {
|
||||
if (x === 2) throw new Error("fail");
|
||||
return x;
|
||||
});
|
||||
if (x === 2) throw new Error('fail')
|
||||
return x
|
||||
})
|
||||
|
||||
const p1 = fn(1);
|
||||
const p2 = fn(2);
|
||||
const p3 = fn(3);
|
||||
const p1 = fn(1)
|
||||
const p2 = fn(2)
|
||||
const p3 = fn(3)
|
||||
|
||||
expect(await p1).toBe(1);
|
||||
await expect(p2).rejects.toThrow("fail");
|
||||
expect(await p3).toBe(3);
|
||||
});
|
||||
expect(await p1).toBe(1)
|
||||
await expect(p2).rejects.toThrow('fail')
|
||||
expect(await p3).toBe(3)
|
||||
})
|
||||
|
||||
test("handles single call", async () => {
|
||||
const fn = sequential(async (s: string) => s.toUpperCase());
|
||||
expect(await fn("hello")).toBe("HELLO");
|
||||
});
|
||||
});
|
||||
test('handles single call', async () => {
|
||||
const fn = sequential(async (s: string) => s.toUpperCase())
|
||||
expect(await fn('hello')).toBe('HELLO')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,86 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const sliceAnsi = (await import("../sliceAnsi")).default;
|
||||
const ESC = "\x1b";
|
||||
const sliceAnsi = (await import('../sliceAnsi')).default
|
||||
const ESC = '\x1b'
|
||||
|
||||
describe("sliceAnsi", () => {
|
||||
test("plain text slice identical to String.slice", () => {
|
||||
expect(sliceAnsi("hello world", 0, 5)).toBe("hello");
|
||||
expect(sliceAnsi("hello world", 6)).toBe("world");
|
||||
});
|
||||
describe('sliceAnsi', () => {
|
||||
test('plain text slice identical to String.slice', () => {
|
||||
expect(sliceAnsi('hello world', 0, 5)).toBe('hello')
|
||||
expect(sliceAnsi('hello world', 6)).toBe('world')
|
||||
})
|
||||
|
||||
test("slice entire string", () => {
|
||||
expect(sliceAnsi("abc", 0)).toBe("abc");
|
||||
});
|
||||
test('slice entire string', () => {
|
||||
expect(sliceAnsi('abc', 0)).toBe('abc')
|
||||
})
|
||||
|
||||
test("empty slice (start === end)", () => {
|
||||
expect(sliceAnsi("abc", 2, 2)).toBe("");
|
||||
});
|
||||
test('empty slice (start === end)', () => {
|
||||
expect(sliceAnsi('abc', 2, 2)).toBe('')
|
||||
})
|
||||
|
||||
test("preserves ANSI color codes within slice", () => {
|
||||
const input = "\x1b[31mred\x1b[0m normal";
|
||||
const result = sliceAnsi(input, 0, 3);
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toContain("red");
|
||||
});
|
||||
test('preserves ANSI color codes within slice', () => {
|
||||
const input = '\x1b[31mred\x1b[0m normal'
|
||||
const result = sliceAnsi(input, 0, 3)
|
||||
expect(result).toContain('\x1b[31m')
|
||||
expect(result).toContain('red')
|
||||
})
|
||||
|
||||
test("closes opened ANSI styles at slice end", () => {
|
||||
const input = "\x1b[31mhello world\x1b[0m";
|
||||
const result = sliceAnsi(input, 0, 5);
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toContain("hello");
|
||||
test('closes opened ANSI styles at slice end', () => {
|
||||
const input = '\x1b[31mhello world\x1b[0m'
|
||||
const result = sliceAnsi(input, 0, 5)
|
||||
expect(result).toContain('\x1b[31m')
|
||||
expect(result).toContain('hello')
|
||||
// undoAnsiCodes uses specific close codes (e.g. \x1b[39m for foreground)
|
||||
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m`));
|
||||
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m`))
|
||||
// The result should start with open code and end with a close code
|
||||
const withoutText = result.replace("hello", "");
|
||||
const withoutText = result.replace('hello', '')
|
||||
// Should have at least one open and one close code
|
||||
expect(withoutText.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(withoutText.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("slice starting mid-ANSI skips codes before start", () => {
|
||||
const input = "\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m";
|
||||
const result = sliceAnsi(input, 6, 11);
|
||||
expect(result).toContain("world");
|
||||
expect(result).toContain("\x1b[32m");
|
||||
expect(result).not.toContain("\x1b[31m");
|
||||
});
|
||||
test('slice starting mid-ANSI skips codes before start', () => {
|
||||
const input = '\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m'
|
||||
const result = sliceAnsi(input, 6, 11)
|
||||
expect(result).toContain('world')
|
||||
expect(result).toContain('\x1b[32m')
|
||||
expect(result).not.toContain('\x1b[31m')
|
||||
})
|
||||
|
||||
test("slice of plain text from middle", () => {
|
||||
expect(sliceAnsi("abcdefgh", 2, 5)).toBe("cde");
|
||||
});
|
||||
test('slice of plain text from middle', () => {
|
||||
expect(sliceAnsi('abcdefgh', 2, 5)).toBe('cde')
|
||||
})
|
||||
|
||||
test("slice past end of string returns everything", () => {
|
||||
expect(sliceAnsi("abc", 0, 100)).toBe("abc");
|
||||
});
|
||||
test('slice past end of string returns everything', () => {
|
||||
expect(sliceAnsi('abc', 0, 100)).toBe('abc')
|
||||
})
|
||||
|
||||
test("slice starting at end returns empty", () => {
|
||||
expect(sliceAnsi("abc", 3)).toBe("");
|
||||
});
|
||||
test('slice starting at end returns empty', () => {
|
||||
expect(sliceAnsi('abc', 3)).toBe('')
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(sliceAnsi("", 0, 5)).toBe("");
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(sliceAnsi('', 0, 5)).toBe('')
|
||||
})
|
||||
|
||||
test("multiple ANSI codes nested", () => {
|
||||
const input = "\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m";
|
||||
const result = sliceAnsi(input, 0, 4);
|
||||
expect(result).toContain("bold");
|
||||
test('multiple ANSI codes nested', () => {
|
||||
const input = '\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m'
|
||||
const result = sliceAnsi(input, 0, 4)
|
||||
expect(result).toContain('bold')
|
||||
// Both styles should be opened and then closed
|
||||
expect(result).toContain("\x1b[1m");
|
||||
expect(result).toContain("\x1b[31m");
|
||||
});
|
||||
expect(result).toContain('\x1b[1m')
|
||||
expect(result).toContain('\x1b[31m')
|
||||
})
|
||||
|
||||
test("slice with no end parameter returns to end of string", () => {
|
||||
expect(sliceAnsi("hello world", 6)).toBe("world");
|
||||
});
|
||||
test('slice with no end parameter returns to end of string', () => {
|
||||
expect(sliceAnsi('hello world', 6)).toBe('world')
|
||||
})
|
||||
|
||||
test("ANSI codes at boundaries are handled correctly", () => {
|
||||
const input = "a\x1b[31mb\x1b[0mc";
|
||||
test('ANSI codes at boundaries are handled correctly', () => {
|
||||
const input = 'a\x1b[31mb\x1b[0mc'
|
||||
// "abc" visually, position: a=0, b=1, c=2
|
||||
const result = sliceAnsi(input, 1, 2);
|
||||
const result = sliceAnsi(input, 1, 2)
|
||||
// undoAnsiCodes uses \x1b[39m for foreground reset, not \x1b[0m
|
||||
expect(result).toContain("b");
|
||||
expect(result).toContain("\x1b[31m");
|
||||
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m.*${ESC}\\[\\d+m`)); // open + close codes
|
||||
});
|
||||
});
|
||||
expect(result).toContain('b')
|
||||
expect(result).toContain('\x1b[31m')
|
||||
expect(result).toMatch(new RegExp(`${ESC}\\[\\d+m.*${ESC}\\[\\d+m`)) // open + close codes
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,162 +1,164 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Stream } from "../stream";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { Stream } from '../stream'
|
||||
|
||||
describe("Stream", () => {
|
||||
test("enqueue then read resolves with the value", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.enqueue(42);
|
||||
const result = await stream.next();
|
||||
expect(result).toEqual({ done: false, value: 42 });
|
||||
});
|
||||
describe('Stream', () => {
|
||||
test('enqueue then read resolves with the value', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
stream.enqueue(42)
|
||||
const result = await stream.next()
|
||||
expect(result).toEqual({ done: false, value: 42 })
|
||||
})
|
||||
|
||||
test("enqueue multiple then drain in order", async () => {
|
||||
const stream = new Stream<string>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.enqueue("a");
|
||||
stream.enqueue("b");
|
||||
stream.enqueue("c");
|
||||
expect(await stream.next()).toEqual({ done: false, value: "a" });
|
||||
expect(await stream.next()).toEqual({ done: false, value: "b" });
|
||||
expect(await stream.next()).toEqual({ done: false, value: "c" });
|
||||
});
|
||||
test('enqueue multiple then drain in order', async () => {
|
||||
const stream = new Stream<string>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
stream.enqueue('a')
|
||||
stream.enqueue('b')
|
||||
stream.enqueue('c')
|
||||
expect(await stream.next()).toEqual({ done: false, value: 'a' })
|
||||
expect(await stream.next()).toEqual({ done: false, value: 'b' })
|
||||
expect(await stream.next()).toEqual({ done: false, value: 'c' })
|
||||
})
|
||||
|
||||
test("next() blocks until enqueue provides a value", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const promise = stream.next();
|
||||
test('next() blocks until enqueue provides a value', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
const promise = stream.next()
|
||||
// Not resolved yet — enqueue after a microtask
|
||||
stream.enqueue(99);
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ done: false, value: 99 });
|
||||
});
|
||||
stream.enqueue(99)
|
||||
const result = await promise
|
||||
expect(result).toEqual({ done: false, value: 99 })
|
||||
})
|
||||
|
||||
test("done() resolves pending reader with done:true", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const promise = stream.next();
|
||||
stream.done();
|
||||
expect(await promise).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
test('done() resolves pending reader with done:true', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
const promise = stream.next()
|
||||
stream.done()
|
||||
expect(await promise).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("done() with no pending reader — subsequent next returns done:true", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.done();
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
test('done() with no pending reader — subsequent next returns done:true', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
stream.done()
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("error() rejects pending reader", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const promise = stream.next();
|
||||
stream.error(new Error("boom"));
|
||||
expect(promise).rejects.toThrow("boom");
|
||||
});
|
||||
test('error() rejects pending reader', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
const promise = stream.next()
|
||||
stream.error(new Error('boom'))
|
||||
expect(promise).rejects.toThrow('boom')
|
||||
})
|
||||
|
||||
test("error() after done — hasError is set but next returns done:true (isDone checked first)", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.done();
|
||||
stream.error(new Error("late error"));
|
||||
test('error() after done — hasError is set but next returns done:true (isDone checked first)', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
stream.done()
|
||||
stream.error(new Error('late error'))
|
||||
// next() checks isDone before hasError, so it returns done:true
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("enqueue after done — queue is checked before isDone, value is consumed", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
stream.done();
|
||||
stream.enqueue(1);
|
||||
test('enqueue after done — queue is checked before isDone, value is consumed', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
stream.done()
|
||||
stream.enqueue(1)
|
||||
// next() checks queue.length > 0 first, so enqueued value is returned
|
||||
expect(await stream.next()).toEqual({ done: false, value: 1 });
|
||||
expect(await stream.next()).toEqual({ done: false, value: 1 })
|
||||
// After draining queue, done takes effect
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("return() marks stream as done and calls returned callback", async () => {
|
||||
let called = false;
|
||||
const stream = new Stream<number>(() => { called = true; });
|
||||
stream[Symbol.asyncIterator]();
|
||||
const result = await stream.return();
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
expect(called).toBe(true);
|
||||
test('return() marks stream as done and calls returned callback', async () => {
|
||||
let called = false
|
||||
const stream = new Stream<number>(() => {
|
||||
called = true
|
||||
})
|
||||
stream[Symbol.asyncIterator]()
|
||||
const result = await stream.return()
|
||||
expect(result).toEqual({ done: true, value: undefined })
|
||||
expect(called).toBe(true)
|
||||
// Subsequent next returns done
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
expect(await stream.next()).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("return() without callback still works", async () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
const result = await stream.return();
|
||||
expect(result).toEqual({ done: true, value: undefined });
|
||||
});
|
||||
test('return() without callback still works', async () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
const result = await stream.return()
|
||||
expect(result).toEqual({ done: true, value: undefined })
|
||||
})
|
||||
|
||||
test("Symbol.asyncIterator throws on second call", () => {
|
||||
const stream = new Stream<number>();
|
||||
stream[Symbol.asyncIterator]();
|
||||
test('Symbol.asyncIterator throws on second call', () => {
|
||||
const stream = new Stream<number>()
|
||||
stream[Symbol.asyncIterator]()
|
||||
expect(() => stream[Symbol.asyncIterator]()).toThrow(
|
||||
"Stream can only be iterated once"
|
||||
);
|
||||
});
|
||||
'Stream can only be iterated once',
|
||||
)
|
||||
})
|
||||
|
||||
test("for-await-of iteration drains queued values then ends", async () => {
|
||||
const stream = new Stream<string>();
|
||||
stream.enqueue("x");
|
||||
stream.enqueue("y");
|
||||
stream.done();
|
||||
const results: string[] = [];
|
||||
test('for-await-of iteration drains queued values then ends', async () => {
|
||||
const stream = new Stream<string>()
|
||||
stream.enqueue('x')
|
||||
stream.enqueue('y')
|
||||
stream.done()
|
||||
const results: string[] = []
|
||||
for await (const value of stream) {
|
||||
results.push(value);
|
||||
results.push(value)
|
||||
}
|
||||
expect(results).toEqual(["x", "y"]);
|
||||
});
|
||||
expect(results).toEqual(['x', 'y'])
|
||||
})
|
||||
|
||||
test("for-await-of blocks until done", async () => {
|
||||
const stream = new Stream<number>();
|
||||
const results: number[] = [];
|
||||
test('for-await-of blocks until done', async () => {
|
||||
const stream = new Stream<number>()
|
||||
const results: number[] = []
|
||||
|
||||
const iterPromise = (async () => {
|
||||
for await (const v of stream) {
|
||||
results.push(v);
|
||||
results.push(v)
|
||||
}
|
||||
})();
|
||||
})()
|
||||
|
||||
// Enqueue after a tick
|
||||
await Promise.resolve();
|
||||
stream.enqueue(1);
|
||||
stream.enqueue(2);
|
||||
stream.done();
|
||||
await Promise.resolve()
|
||||
stream.enqueue(1)
|
||||
stream.enqueue(2)
|
||||
stream.done()
|
||||
|
||||
await iterPromise;
|
||||
expect(results).toEqual([1, 2]);
|
||||
});
|
||||
await iterPromise
|
||||
expect(results).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("error during for-await-of rejects the loop", async () => {
|
||||
const stream = new Stream<number>();
|
||||
test('error during for-await-of rejects the loop', async () => {
|
||||
const stream = new Stream<number>()
|
||||
const iterPromise = (async () => {
|
||||
for await (const _ of stream) {
|
||||
// will error before any value
|
||||
}
|
||||
})();
|
||||
stream.error(new Error("stream broken"));
|
||||
expect(iterPromise).rejects.toThrow("stream broken");
|
||||
});
|
||||
})()
|
||||
stream.error(new Error('stream broken'))
|
||||
expect(iterPromise).rejects.toThrow('stream broken')
|
||||
})
|
||||
|
||||
test("concurrent enqueue from multiple sources does not lose data", async () => {
|
||||
const stream = new Stream<number>();
|
||||
test('concurrent enqueue from multiple sources does not lose data', async () => {
|
||||
const stream = new Stream<number>()
|
||||
// Rapid sequential enqueue
|
||||
for (let i = 0; i < 100; i++) {
|
||||
stream.enqueue(i);
|
||||
stream.enqueue(i)
|
||||
}
|
||||
stream.done();
|
||||
stream.done()
|
||||
|
||||
const results: number[] = [];
|
||||
const results: number[] = []
|
||||
for await (const v of stream) {
|
||||
results.push(v);
|
||||
results.push(v)
|
||||
}
|
||||
expect(results.length).toBe(100);
|
||||
expect(results[0]).toBe(0);
|
||||
expect(results[99]).toBe(99);
|
||||
});
|
||||
});
|
||||
expect(results.length).toBe(100)
|
||||
expect(results[0]).toBe(0)
|
||||
expect(results[99]).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
escapeRegExp,
|
||||
capitalize,
|
||||
@@ -10,187 +10,187 @@ import {
|
||||
safeJoinLines,
|
||||
EndTruncatingAccumulator,
|
||||
truncateToLines,
|
||||
} from "../stringUtils";
|
||||
} from '../stringUtils'
|
||||
|
||||
describe("escapeRegExp", () => {
|
||||
test("escapes special regex chars", () => {
|
||||
expect(escapeRegExp("a.b*c?d")).toBe("a\\.b\\*c\\?d");
|
||||
});
|
||||
describe('escapeRegExp', () => {
|
||||
test('escapes special regex chars', () => {
|
||||
expect(escapeRegExp('a.b*c?d')).toBe('a\\.b\\*c\\?d')
|
||||
})
|
||||
|
||||
test("escapes brackets and parens", () => {
|
||||
expect(escapeRegExp("[foo](bar)")).toBe("\\[foo\\]\\(bar\\)");
|
||||
});
|
||||
test('escapes brackets and parens', () => {
|
||||
expect(escapeRegExp('[foo](bar)')).toBe('\\[foo\\]\\(bar\\)')
|
||||
})
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
const allSpecialChars = "^$" + "{}()|[]\\.*+?";
|
||||
test('escapes all special chars', () => {
|
||||
const allSpecialChars = '^$' + '{}()|[]\\.*+?'
|
||||
expect(escapeRegExp(allSpecialChars)).toBe(
|
||||
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
|
||||
);
|
||||
});
|
||||
'\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns normal string unchanged", () => {
|
||||
expect(escapeRegExp("hello")).toBe("hello");
|
||||
});
|
||||
});
|
||||
test('returns normal string unchanged', () => {
|
||||
expect(escapeRegExp('hello')).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe("capitalize", () => {
|
||||
test("uppercases first char", () => {
|
||||
expect(capitalize("hello")).toBe("Hello");
|
||||
});
|
||||
describe('capitalize', () => {
|
||||
test('uppercases first char', () => {
|
||||
expect(capitalize('hello')).toBe('Hello')
|
||||
})
|
||||
|
||||
test("does NOT lowercase rest", () => {
|
||||
expect(capitalize("fooBar")).toBe("FooBar");
|
||||
});
|
||||
test('does NOT lowercase rest', () => {
|
||||
expect(capitalize('fooBar')).toBe('FooBar')
|
||||
})
|
||||
|
||||
test("handles single char", () => {
|
||||
expect(capitalize("a")).toBe("A");
|
||||
});
|
||||
test('handles single char', () => {
|
||||
expect(capitalize('a')).toBe('A')
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(capitalize("")).toBe("");
|
||||
});
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(capitalize('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe("plural", () => {
|
||||
test("returns singular for 1", () => {
|
||||
expect(plural(1, "file")).toBe("file");
|
||||
});
|
||||
describe('plural', () => {
|
||||
test('returns singular for 1', () => {
|
||||
expect(plural(1, 'file')).toBe('file')
|
||||
})
|
||||
|
||||
test("returns plural for 0", () => {
|
||||
expect(plural(0, "file")).toBe("files");
|
||||
});
|
||||
test('returns plural for 0', () => {
|
||||
expect(plural(0, 'file')).toBe('files')
|
||||
})
|
||||
|
||||
test("returns plural for many", () => {
|
||||
expect(plural(3, "file")).toBe("files");
|
||||
});
|
||||
test('returns plural for many', () => {
|
||||
expect(plural(3, 'file')).toBe('files')
|
||||
})
|
||||
|
||||
test("uses custom plural form", () => {
|
||||
expect(plural(2, "entry", "entries")).toBe("entries");
|
||||
});
|
||||
});
|
||||
test('uses custom plural form', () => {
|
||||
expect(plural(2, 'entry', 'entries')).toBe('entries')
|
||||
})
|
||||
})
|
||||
|
||||
describe("firstLineOf", () => {
|
||||
test("returns first line of multiline string", () => {
|
||||
expect(firstLineOf("line1\nline2\nline3")).toBe("line1");
|
||||
});
|
||||
describe('firstLineOf', () => {
|
||||
test('returns first line of multiline string', () => {
|
||||
expect(firstLineOf('line1\nline2\nline3')).toBe('line1')
|
||||
})
|
||||
|
||||
test("returns whole string if no newline", () => {
|
||||
expect(firstLineOf("single line")).toBe("single line");
|
||||
});
|
||||
test('returns whole string if no newline', () => {
|
||||
expect(firstLineOf('single line')).toBe('single line')
|
||||
})
|
||||
|
||||
test("returns empty string for leading newline", () => {
|
||||
expect(firstLineOf("\nline2")).toBe("");
|
||||
});
|
||||
});
|
||||
test('returns empty string for leading newline', () => {
|
||||
expect(firstLineOf('\nline2')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe("countCharInString", () => {
|
||||
test("counts occurrences of a character", () => {
|
||||
expect(countCharInString("hello world", "l")).toBe(3);
|
||||
});
|
||||
describe('countCharInString', () => {
|
||||
test('counts occurrences of a character', () => {
|
||||
expect(countCharInString('hello world', 'l')).toBe(3)
|
||||
})
|
||||
|
||||
test("returns 0 for no match", () => {
|
||||
expect(countCharInString("hello", "z")).toBe(0);
|
||||
});
|
||||
test('returns 0 for no match', () => {
|
||||
expect(countCharInString('hello', 'z')).toBe(0)
|
||||
})
|
||||
|
||||
test("counts from start offset", () => {
|
||||
expect(countCharInString("aabaa", "a", 2)).toBe(2);
|
||||
});
|
||||
test('counts from start offset', () => {
|
||||
expect(countCharInString('aabaa', 'a', 2)).toBe(2)
|
||||
})
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(countCharInString("", "a")).toBe(0);
|
||||
});
|
||||
});
|
||||
test('returns 0 for empty string', () => {
|
||||
expect(countCharInString('', 'a')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeFullWidthDigits", () => {
|
||||
test("converts full-width digits to half-width", () => {
|
||||
expect(normalizeFullWidthDigits("0123456789")).toBe("0123456789");
|
||||
});
|
||||
describe('normalizeFullWidthDigits', () => {
|
||||
test('converts full-width digits to half-width', () => {
|
||||
expect(normalizeFullWidthDigits('0123456789')).toBe('0123456789')
|
||||
})
|
||||
|
||||
test("leaves half-width digits unchanged", () => {
|
||||
expect(normalizeFullWidthDigits("0123")).toBe("0123");
|
||||
});
|
||||
test('leaves half-width digits unchanged', () => {
|
||||
expect(normalizeFullWidthDigits('0123')).toBe('0123')
|
||||
})
|
||||
|
||||
test("handles mixed content", () => {
|
||||
expect(normalizeFullWidthDigits("test123")).toBe("test123");
|
||||
});
|
||||
});
|
||||
test('handles mixed content', () => {
|
||||
expect(normalizeFullWidthDigits('test123')).toBe('test123')
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizeFullWidthSpace", () => {
|
||||
test("converts full-width space to half-width", () => {
|
||||
expect(normalizeFullWidthSpace("a\u3000b")).toBe("a b");
|
||||
});
|
||||
describe('normalizeFullWidthSpace', () => {
|
||||
test('converts full-width space to half-width', () => {
|
||||
expect(normalizeFullWidthSpace('a\u3000b')).toBe('a b')
|
||||
})
|
||||
|
||||
test("leaves normal spaces unchanged", () => {
|
||||
expect(normalizeFullWidthSpace("a b")).toBe("a b");
|
||||
});
|
||||
});
|
||||
test('leaves normal spaces unchanged', () => {
|
||||
expect(normalizeFullWidthSpace('a b')).toBe('a b')
|
||||
})
|
||||
})
|
||||
|
||||
describe("safeJoinLines", () => {
|
||||
test("joins lines with delimiter", () => {
|
||||
expect(safeJoinLines(["a", "b", "c"], ",")).toBe("a,b,c");
|
||||
});
|
||||
describe('safeJoinLines', () => {
|
||||
test('joins lines with delimiter', () => {
|
||||
expect(safeJoinLines(['a', 'b', 'c'], ',')).toBe('a,b,c')
|
||||
})
|
||||
|
||||
test("truncates when exceeding maxSize", () => {
|
||||
const result = safeJoinLines(["hello", "world", "foo"], ",", 12);
|
||||
expect(result.length).toBeLessThanOrEqual(12 + "...[truncated]".length);
|
||||
expect(result).toContain("...[truncated]");
|
||||
});
|
||||
test('truncates when exceeding maxSize', () => {
|
||||
const result = safeJoinLines(['hello', 'world', 'foo'], ',', 12)
|
||||
expect(result.length).toBeLessThanOrEqual(12 + '...[truncated]'.length)
|
||||
expect(result).toContain('...[truncated]')
|
||||
})
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(safeJoinLines([])).toBe("");
|
||||
});
|
||||
});
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(safeJoinLines([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe("EndTruncatingAccumulator", () => {
|
||||
test("accumulates text", () => {
|
||||
const acc = new EndTruncatingAccumulator(100);
|
||||
acc.append("hello ");
|
||||
acc.append("world");
|
||||
expect(acc.toString()).toBe("hello world");
|
||||
});
|
||||
describe('EndTruncatingAccumulator', () => {
|
||||
test('accumulates text', () => {
|
||||
const acc = new EndTruncatingAccumulator(100)
|
||||
acc.append('hello ')
|
||||
acc.append('world')
|
||||
expect(acc.toString()).toBe('hello world')
|
||||
})
|
||||
|
||||
test("truncates when exceeding maxSize", () => {
|
||||
const acc = new EndTruncatingAccumulator(10);
|
||||
acc.append("12345678901234567890");
|
||||
expect(acc.truncated).toBe(true);
|
||||
expect(acc.length).toBe(10);
|
||||
});
|
||||
test('truncates when exceeding maxSize', () => {
|
||||
const acc = new EndTruncatingAccumulator(10)
|
||||
acc.append('12345678901234567890')
|
||||
expect(acc.truncated).toBe(true)
|
||||
expect(acc.length).toBe(10)
|
||||
})
|
||||
|
||||
test("reports total bytes received", () => {
|
||||
const acc = new EndTruncatingAccumulator(5);
|
||||
acc.append("1234567890");
|
||||
expect(acc.totalBytes).toBe(10);
|
||||
});
|
||||
test('reports total bytes received', () => {
|
||||
const acc = new EndTruncatingAccumulator(5)
|
||||
acc.append('1234567890')
|
||||
expect(acc.totalBytes).toBe(10)
|
||||
})
|
||||
|
||||
test("clear resets state", () => {
|
||||
const acc = new EndTruncatingAccumulator(100);
|
||||
acc.append("hello");
|
||||
acc.clear();
|
||||
expect(acc.toString()).toBe("");
|
||||
expect(acc.length).toBe(0);
|
||||
expect(acc.truncated).toBe(false);
|
||||
});
|
||||
test('clear resets state', () => {
|
||||
const acc = new EndTruncatingAccumulator(100)
|
||||
acc.append('hello')
|
||||
acc.clear()
|
||||
expect(acc.toString()).toBe('')
|
||||
expect(acc.length).toBe(0)
|
||||
expect(acc.truncated).toBe(false)
|
||||
})
|
||||
|
||||
test("stops accepting data once truncated and full", () => {
|
||||
const acc = new EndTruncatingAccumulator(5);
|
||||
acc.append("12345");
|
||||
acc.append("67890");
|
||||
expect(acc.length).toBe(5);
|
||||
acc.append("more");
|
||||
expect(acc.length).toBe(5);
|
||||
});
|
||||
});
|
||||
test('stops accepting data once truncated and full', () => {
|
||||
const acc = new EndTruncatingAccumulator(5)
|
||||
acc.append('12345')
|
||||
acc.append('67890')
|
||||
expect(acc.length).toBe(5)
|
||||
acc.append('more')
|
||||
expect(acc.length).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateToLines", () => {
|
||||
test("returns text unchanged if within limit", () => {
|
||||
expect(truncateToLines("a\nb\nc", 5)).toBe("a\nb\nc");
|
||||
});
|
||||
describe('truncateToLines', () => {
|
||||
test('returns text unchanged if within limit', () => {
|
||||
expect(truncateToLines('a\nb\nc', 5)).toBe('a\nb\nc')
|
||||
})
|
||||
|
||||
test("truncates text exceeding limit", () => {
|
||||
expect(truncateToLines("a\nb\nc\nd\ne", 3)).toBe("a\nb\nc…");
|
||||
});
|
||||
test('truncates text exceeding limit', () => {
|
||||
expect(truncateToLines('a\nb\nc\nd\ne', 3)).toBe('a\nb\nc…')
|
||||
})
|
||||
|
||||
test("handles single line", () => {
|
||||
expect(truncateToLines("hello", 1)).toBe("hello");
|
||||
});
|
||||
});
|
||||
test('handles single line', () => {
|
||||
expect(truncateToLines('hello', 1)).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildEffectiveSystemPrompt } from "../systemPrompt";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildEffectiveSystemPrompt } from '../systemPrompt'
|
||||
|
||||
const defaultPrompt = ["You are a helpful assistant.", "Follow instructions."];
|
||||
const defaultPrompt = ['You are a helpful assistant.', 'Follow instructions.']
|
||||
|
||||
function buildPrompt(overrides: Record<string, unknown> = {}) {
|
||||
return buildEffectiveSystemPrompt({
|
||||
@@ -11,78 +11,78 @@ function buildPrompt(overrides: Record<string, unknown> = {}) {
|
||||
defaultSystemPrompt: defaultPrompt,
|
||||
appendSystemPrompt: undefined,
|
||||
...overrides,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
describe("buildEffectiveSystemPrompt", () => {
|
||||
test("returns default system prompt when no overrides", () => {
|
||||
const result = buildPrompt();
|
||||
expect(Array.from(result)).toEqual(defaultPrompt);
|
||||
});
|
||||
describe('buildEffectiveSystemPrompt', () => {
|
||||
test('returns default system prompt when no overrides', () => {
|
||||
const result = buildPrompt()
|
||||
expect(Array.from(result)).toEqual(defaultPrompt)
|
||||
})
|
||||
|
||||
test("overrideSystemPrompt replaces everything", () => {
|
||||
const result = buildPrompt({ overrideSystemPrompt: "override" });
|
||||
expect(Array.from(result)).toEqual(["override"]);
|
||||
});
|
||||
test('overrideSystemPrompt replaces everything', () => {
|
||||
const result = buildPrompt({ overrideSystemPrompt: 'override' })
|
||||
expect(Array.from(result)).toEqual(['override'])
|
||||
})
|
||||
|
||||
test("customSystemPrompt replaces default", () => {
|
||||
const result = buildPrompt({ customSystemPrompt: "custom" });
|
||||
expect(Array.from(result)).toEqual(["custom"]);
|
||||
});
|
||||
test('customSystemPrompt replaces default', () => {
|
||||
const result = buildPrompt({ customSystemPrompt: 'custom' })
|
||||
expect(Array.from(result)).toEqual(['custom'])
|
||||
})
|
||||
|
||||
test("appendSystemPrompt is appended after main prompt", () => {
|
||||
const result = buildPrompt({ appendSystemPrompt: "appended" });
|
||||
expect(Array.from(result)).toEqual([...defaultPrompt, "appended"]);
|
||||
});
|
||||
test('appendSystemPrompt is appended after main prompt', () => {
|
||||
const result = buildPrompt({ appendSystemPrompt: 'appended' })
|
||||
expect(Array.from(result)).toEqual([...defaultPrompt, 'appended'])
|
||||
})
|
||||
|
||||
test("agent definition replaces default prompt", () => {
|
||||
test('agent definition replaces default prompt', () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({ mainThreadAgentDefinition: agentDef });
|
||||
expect(Array.from(result)).toEqual(["agent prompt"]);
|
||||
});
|
||||
getSystemPrompt: () => 'agent prompt',
|
||||
agentType: 'custom',
|
||||
} as any
|
||||
const result = buildPrompt({ mainThreadAgentDefinition: agentDef })
|
||||
expect(Array.from(result)).toEqual(['agent prompt'])
|
||||
})
|
||||
|
||||
test("agent definition with append combines both", () => {
|
||||
test('agent definition with append combines both', () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
getSystemPrompt: () => 'agent prompt',
|
||||
agentType: 'custom',
|
||||
} as any
|
||||
const result = buildPrompt({
|
||||
mainThreadAgentDefinition: agentDef,
|
||||
appendSystemPrompt: "extra",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["agent prompt", "extra"]);
|
||||
});
|
||||
appendSystemPrompt: 'extra',
|
||||
})
|
||||
expect(Array.from(result)).toEqual(['agent prompt', 'extra'])
|
||||
})
|
||||
|
||||
test("override takes precedence over agent and custom", () => {
|
||||
test('override takes precedence over agent and custom', () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
getSystemPrompt: () => 'agent prompt',
|
||||
agentType: 'custom',
|
||||
} as any
|
||||
const result = buildPrompt({
|
||||
mainThreadAgentDefinition: agentDef,
|
||||
customSystemPrompt: "custom",
|
||||
appendSystemPrompt: "extra",
|
||||
overrideSystemPrompt: "override",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["override"]);
|
||||
});
|
||||
customSystemPrompt: 'custom',
|
||||
appendSystemPrompt: 'extra',
|
||||
overrideSystemPrompt: 'override',
|
||||
})
|
||||
expect(Array.from(result)).toEqual(['override'])
|
||||
})
|
||||
|
||||
test("returns array of strings", () => {
|
||||
const result = buildPrompt();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
test('returns array of strings', () => {
|
||||
const result = buildPrompt()
|
||||
expect(Array.isArray(result)).toBe(true)
|
||||
for (const item of result) {
|
||||
expect(typeof item).toBe("string");
|
||||
expect(typeof item).toBe('string')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("custom + append combines both", () => {
|
||||
test('custom + append combines both', () => {
|
||||
const result = buildPrompt({
|
||||
customSystemPrompt: "custom",
|
||||
appendSystemPrompt: "extra",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["custom", "extra"]);
|
||||
});
|
||||
});
|
||||
customSystemPrompt: 'custom',
|
||||
appendSystemPrompt: 'extra',
|
||||
})
|
||||
expect(Array.from(result)).toEqual(['custom', 'extra'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,104 +1,91 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { toTaggedId } from "../taggedId";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { toTaggedId } from '../taggedId'
|
||||
|
||||
const BASE_58_CHARS =
|
||||
"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
||||
|
||||
describe("toTaggedId", () => {
|
||||
describe('toTaggedId', () => {
|
||||
test("zero UUID produces all base58 '1's (first char)", () => {
|
||||
const result = toTaggedId(
|
||||
"user",
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
);
|
||||
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));
|
||||
});
|
||||
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"
|
||||
);
|
||||
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}$`)
|
||||
);
|
||||
});
|
||||
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('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", () => {
|
||||
test('UUID with hyphens equals UUID without hyphens', () => {
|
||||
const withHyphens = toTaggedId(
|
||||
"user",
|
||||
"550e8400-e29b-41d4-a716-446655440000"
|
||||
);
|
||||
'user',
|
||||
'550e8400-e29b-41d4-a716-446655440000',
|
||||
)
|
||||
const withoutHyphens = toTaggedId(
|
||||
"user",
|
||||
"550e8400e29b41d4a716446655440000"
|
||||
);
|
||||
expect(withHyphens).toBe(withoutHyphens);
|
||||
});
|
||||
'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);
|
||||
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);
|
||||
});
|
||||
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('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", () => {
|
||||
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",
|
||||
];
|
||||
'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);
|
||||
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 short)', () => {
|
||||
expect(() => toTaggedId('user', 'abcdef')).toThrow(
|
||||
'Invalid UUID hex length',
|
||||
)
|
||||
})
|
||||
|
||||
test("throws on invalid UUID (too long)", () => {
|
||||
test('throws on invalid UUID (too long)', () => {
|
||||
expect(() =>
|
||||
toTaggedId("user", "550e8400e29b41d4a716446655440000ff")
|
||||
).toThrow("Invalid UUID hex length");
|
||||
});
|
||||
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);
|
||||
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);
|
||||
expect(BASE_58_CHARS).toContain(ch)
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,84 +1,87 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildTaskStateMessage,
|
||||
getTaskStateSnapshotKey,
|
||||
} from "../taskStateMessage";
|
||||
} from '../taskStateMessage'
|
||||
|
||||
describe("buildTaskStateMessage", () => {
|
||||
test("filters internal tasks and preserves public task fields", () => {
|
||||
const message = buildTaskStateMessage("tasklist", [
|
||||
describe('buildTaskStateMessage', () => {
|
||||
test('filters internal tasks and preserves public task fields', () => {
|
||||
const message = buildTaskStateMessage('tasklist', [
|
||||
{
|
||||
id: "1",
|
||||
subject: "Visible task",
|
||||
description: "Shown in web UI",
|
||||
activeForm: "Doing visible task",
|
||||
status: "in_progress",
|
||||
owner: "agent-1",
|
||||
blocks: ["2"],
|
||||
id: '1',
|
||||
subject: 'Visible task',
|
||||
description: 'Shown in web UI',
|
||||
activeForm: 'Doing visible task',
|
||||
status: 'in_progress',
|
||||
owner: 'agent-1',
|
||||
blocks: ['2'],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
subject: "Internal task",
|
||||
description: "Hidden from web UI",
|
||||
status: "pending",
|
||||
id: '2',
|
||||
subject: 'Internal task',
|
||||
description: 'Hidden from web UI',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: { _internal: true },
|
||||
},
|
||||
]);
|
||||
])
|
||||
|
||||
expect(message.type).toBe("task_state");
|
||||
expect(message.task_list_id).toBe("tasklist");
|
||||
expect(message.uuid).toEqual(expect.any(String));
|
||||
expect(message.type).toBe('task_state')
|
||||
expect(message.task_list_id).toBe('tasklist')
|
||||
expect(message.uuid).toEqual(expect.any(String))
|
||||
expect(message.tasks).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
subject: "Visible task",
|
||||
description: "Shown in web UI",
|
||||
activeForm: "Doing visible task",
|
||||
status: "in_progress",
|
||||
owner: "agent-1",
|
||||
blocks: ["2"],
|
||||
id: '1',
|
||||
subject: 'Visible task',
|
||||
description: 'Shown in web UI',
|
||||
activeForm: 'Doing visible task',
|
||||
status: 'in_progress',
|
||||
owner: 'agent-1',
|
||||
blocks: ['2'],
|
||||
blockedBy: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
])
|
||||
})
|
||||
|
||||
test("builds a stable snapshot key for equivalent public tasks", () => {
|
||||
test('builds a stable snapshot key for equivalent public tasks', () => {
|
||||
const tasks = [
|
||||
{
|
||||
id: "2",
|
||||
subject: "Second",
|
||||
description: "Second task",
|
||||
status: "pending",
|
||||
id: '2',
|
||||
subject: 'Second',
|
||||
description: 'Second task',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
subject: "First",
|
||||
description: "First task",
|
||||
status: "in_progress",
|
||||
blocks: ["2"],
|
||||
id: '1',
|
||||
subject: 'First',
|
||||
description: 'First task',
|
||||
status: 'in_progress',
|
||||
blocks: ['2'],
|
||||
blockedBy: [],
|
||||
},
|
||||
{
|
||||
id: "internal",
|
||||
subject: "Internal task",
|
||||
description: "Hidden",
|
||||
status: "pending",
|
||||
id: 'internal',
|
||||
subject: 'Internal task',
|
||||
description: 'Hidden',
|
||||
status: 'pending',
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
metadata: { _internal: true },
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const firstKey = getTaskStateSnapshotKey("tasklist", tasks as any);
|
||||
const secondKey = getTaskStateSnapshotKey("tasklist", [...tasks].reverse() as any);
|
||||
const message = buildTaskStateMessage("tasklist", tasks as any);
|
||||
const firstKey = getTaskStateSnapshotKey('tasklist', tasks as any)
|
||||
const secondKey = getTaskStateSnapshotKey(
|
||||
'tasklist',
|
||||
[...tasks].reverse() as any,
|
||||
)
|
||||
const message = buildTaskStateMessage('tasklist', tasks as any)
|
||||
|
||||
expect(firstKey).toBe(secondKey);
|
||||
expect(message.tasks.map(task => task.id)).toEqual(["1", "2"]);
|
||||
});
|
||||
});
|
||||
expect(firstKey).toBe(secondKey)
|
||||
expect(message.tasks.map(task => task.id)).toEqual(['1', '2'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -366,9 +366,7 @@ describe('teammate mailbox retention', () => {
|
||||
throw new Error('Expected filesystem errno code')
|
||||
}
|
||||
const expectedCodes =
|
||||
process.platform === 'win32'
|
||||
? ['EISDIR', 'EPERM', 'EACCES']
|
||||
: ['EISDIR']
|
||||
process.platform === 'win32' ? ['EISDIR', 'EPERM', 'EACCES'] : ['EISDIR']
|
||||
expect(expectedCodes).toContain(code)
|
||||
expect((await stat(inboxPath)).isDirectory()).toBe(true)
|
||||
})
|
||||
@@ -384,7 +382,11 @@ describe('teammate mailbox retention', () => {
|
||||
test('readMailbox rejects non-array mailbox files', async () => {
|
||||
const inboxPath = getInboxPath('worker', 'alpha')
|
||||
await mkdir(dirname(inboxPath), { recursive: true })
|
||||
await writeFile(inboxPath, JSON.stringify({ text: 'not an array' }), 'utf-8')
|
||||
await writeFile(
|
||||
inboxPath,
|
||||
JSON.stringify({ text: 'not an array' }),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
|
||||
'expected message array',
|
||||
@@ -418,7 +420,11 @@ describe('teammate mailbox retention', () => {
|
||||
test('readMailbox rejects oversized mailbox files before parsing', async () => {
|
||||
const inboxPath = getInboxPath('worker', 'alpha')
|
||||
await mkdir(dirname(inboxPath), { recursive: true })
|
||||
await writeFile(inboxPath, `[${' '.repeat(MAX_MAILBOX_FILE_BYTES)}]`, 'utf-8')
|
||||
await writeFile(
|
||||
inboxPath,
|
||||
`[${' '.repeat(MAX_MAILBOX_FILE_BYTES)}]`,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
await expect(readMailbox('worker', 'alpha')).rejects.toThrow(
|
||||
'Mailbox file exceeds',
|
||||
|
||||
@@ -1,131 +1,141 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { segmentTextByHighlights, type TextHighlight } from "../textHighlighting";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
segmentTextByHighlights,
|
||||
type TextHighlight,
|
||||
} from '../textHighlighting'
|
||||
|
||||
describe("segmentTextByHighlights", () => {
|
||||
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 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", () => {
|
||||
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);
|
||||
});
|
||||
]
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('hello world', highlights)
|
||||
expect(segments.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("highlight covering entire text", () => {
|
||||
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();
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('hello', highlights)
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].highlight).toBeDefined()
|
||||
})
|
||||
|
||||
// Multiple highlights
|
||||
test("handles non-overlapping 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);
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('abcXYZdef', highlights)
|
||||
const highlighted = segments.filter(s => s.highlight)
|
||||
expect(highlighted.length).toBe(2)
|
||||
})
|
||||
|
||||
test("handles overlapping highlights (priority-based)", () => {
|
||||
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);
|
||||
]
|
||||
const segments = segmentTextByHighlights('hello world', highlights)
|
||||
// Overlapping: higher priority wins or they don't overlap
|
||||
expect(segments.length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(segments.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("handles adjacent highlights", () => {
|
||||
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);
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('abcdef', highlights)
|
||||
const highlighted = segments.filter(s => s.highlight)
|
||||
expect(highlighted.length).toBe(2)
|
||||
})
|
||||
|
||||
// Boundary
|
||||
test("highlight starting at 0", () => {
|
||||
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);
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('abcdef', highlights)
|
||||
expect(segments[0].start).toBe(0)
|
||||
})
|
||||
|
||||
test("highlight ending at text length", () => {
|
||||
const text = "hello";
|
||||
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);
|
||||
});
|
||||
]
|
||||
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();
|
||||
});
|
||||
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", () => {
|
||||
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 as string).toBe("primary");
|
||||
});
|
||||
{ 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 as string).toBe('primary')
|
||||
})
|
||||
|
||||
test("preserves highlight priority property", () => {
|
||||
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);
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('abc', highlights)
|
||||
const highlighted = segments.find(s => s.highlight)
|
||||
expect(highlighted?.highlight?.priority).toBe(5)
|
||||
})
|
||||
|
||||
test("preserves dimColor and inverse flags", () => {
|
||||
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);
|
||||
});
|
||||
{
|
||||
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", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
]
|
||||
const segments = segmentTextByHighlights('abcdef', highlights)
|
||||
expect(segments).toHaveLength(1)
|
||||
expect(segments[0].highlight).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,150 +1,150 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
parseTokenBudget,
|
||||
findTokenBudgetPositions,
|
||||
getBudgetContinuationMessage,
|
||||
} from "../tokenBudget";
|
||||
} from '../tokenBudget'
|
||||
|
||||
describe("parseTokenBudget", () => {
|
||||
describe('parseTokenBudget', () => {
|
||||
// --- shorthand at start ---
|
||||
test("parses +500k at start", () => {
|
||||
expect(parseTokenBudget("+500k")).toBe(500_000);
|
||||
});
|
||||
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 +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 +1b at start', () => {
|
||||
expect(parseTokenBudget('+1b')).toBe(1_000_000_000)
|
||||
})
|
||||
|
||||
test("parses shorthand with leading whitespace", () => {
|
||||
expect(parseTokenBudget(" +500k")).toBe(500_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 +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 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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
expect(parseTokenBudget('use 2M tokens')).toBe(2_000_000)
|
||||
})
|
||||
|
||||
test("parses 'spend 500k tokens'", () => {
|
||||
expect(parseTokenBudget("spend 500k tokens")).toBe(500_000);
|
||||
});
|
||||
expect(parseTokenBudget('spend 500k tokens')).toBe(500_000)
|
||||
})
|
||||
|
||||
test("parses verbose with singular 'token'", () => {
|
||||
expect(parseTokenBudget("use 1k token")).toBe(1_000);
|
||||
});
|
||||
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
|
||||
);
|
||||
});
|
||||
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 plain text', () => {
|
||||
expect(parseTokenBudget('hello world')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for bare number without +", () => {
|
||||
expect(parseTokenBudget("500k")).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 number without suffix', () => {
|
||||
expect(parseTokenBudget('+500')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseTokenBudget("")).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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
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 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);
|
||||
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"
|
||||
);
|
||||
});
|
||||
'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 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('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);
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
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 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");
|
||||
});
|
||||
});
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { logMock } from "../../../tests/mocks/log";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
import { logMock } from '../../../tests/mocks/log'
|
||||
|
||||
// Mock heavy dependency chain: tokenEstimation.ts → log.ts → bootstrap/state.ts
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
// Mock tokenEstimation to avoid pulling in API provider deps
|
||||
mock.module("src/services/tokenEstimation.ts", () => ({
|
||||
mock.module('src/services/tokenEstimation.ts', () => ({
|
||||
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
|
||||
roughTokenCountEstimationForMessages: (msgs: any[]) => msgs.length * 100,
|
||||
roughTokenCountEstimationForMessage: () => 100,
|
||||
@@ -14,7 +14,7 @@ mock.module("src/services/tokenEstimation.ts", () => ({
|
||||
countTokensWithAPI: async () => 0,
|
||||
countMessagesTokensWithAPI: async () => 0,
|
||||
countTokensViaHaikuFallback: async () => 0,
|
||||
}));
|
||||
}))
|
||||
|
||||
// Mock slowOperations to avoid bun:bundle import
|
||||
mock.module('src/utils/slowOperations.ts', () => ({
|
||||
@@ -36,7 +36,7 @@ const {
|
||||
getCurrentUsage,
|
||||
doesMostRecentAssistantMessageExceed200k,
|
||||
getAssistantMessageContentLength,
|
||||
} = await import("../tokens");
|
||||
} = await import('../tokens')
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,16 +44,16 @@ function makeAssistantMessage(
|
||||
content: any[],
|
||||
usage?: any,
|
||||
model?: string,
|
||||
id?: string
|
||||
id?: string,
|
||||
) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
type: 'assistant' as const,
|
||||
uuid: `test-${Math.random()}`,
|
||||
message: {
|
||||
id: id ?? `msg_${Math.random()}`,
|
||||
role: "assistant" as const,
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
model: model ?? "claude-sonnet-4-20250514",
|
||||
model: model ?? 'claude-sonnet-4-20250514',
|
||||
usage: usage ?? {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
@@ -62,221 +62,221 @@ function makeAssistantMessage(
|
||||
},
|
||||
},
|
||||
isApiErrorMessage: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeUserMessage(text: string) {
|
||||
return {
|
||||
type: "user" as const,
|
||||
type: 'user' as const,
|
||||
uuid: `test-${Math.random()}`,
|
||||
message: { role: "user" as const, content: text },
|
||||
};
|
||||
message: { role: 'user' as const, content: text },
|
||||
}
|
||||
}
|
||||
|
||||
// ─── getTokenCountFromUsage ─────────────────────────────────────────────
|
||||
|
||||
describe("getTokenCountFromUsage", () => {
|
||||
test("sums all token fields", () => {
|
||||
describe('getTokenCountFromUsage', () => {
|
||||
test('sums all token fields', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 10,
|
||||
};
|
||||
expect(getTokenCountFromUsage(usage as any)).toBe(180);
|
||||
});
|
||||
}
|
||||
expect(getTokenCountFromUsage(usage as any)).toBe(180)
|
||||
})
|
||||
|
||||
test("handles missing cache fields", () => {
|
||||
test('handles missing cache fields', () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
};
|
||||
expect(getTokenCountFromUsage(usage as any)).toBe(150);
|
||||
});
|
||||
}
|
||||
expect(getTokenCountFromUsage(usage as any)).toBe(150)
|
||||
})
|
||||
|
||||
test("handles zero values", () => {
|
||||
test('handles zero values', () => {
|
||||
const usage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
};
|
||||
expect(getTokenCountFromUsage(usage as any)).toBe(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
expect(getTokenCountFromUsage(usage as any)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getTokenUsage ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getTokenUsage", () => {
|
||||
test("returns usage for valid assistant message", () => {
|
||||
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
|
||||
const usage = getTokenUsage(msg as any);
|
||||
expect(usage).toBeDefined();
|
||||
expect(usage!.input_tokens).toBe(100);
|
||||
});
|
||||
describe('getTokenUsage', () => {
|
||||
test('returns usage for valid assistant message', () => {
|
||||
const msg = makeAssistantMessage([{ type: 'text', text: 'hello' }])
|
||||
const usage = getTokenUsage(msg as any)
|
||||
expect(usage).toBeDefined()
|
||||
expect(usage!.input_tokens).toBe(100)
|
||||
})
|
||||
|
||||
test("returns undefined for user message", () => {
|
||||
const msg = makeUserMessage("hello");
|
||||
expect(getTokenUsage(msg as any)).toBeUndefined();
|
||||
});
|
||||
test('returns undefined for user message', () => {
|
||||
const msg = makeUserMessage('hello')
|
||||
expect(getTokenUsage(msg as any)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns undefined for synthetic model", () => {
|
||||
test('returns undefined for synthetic model', () => {
|
||||
const msg = makeAssistantMessage(
|
||||
[{ type: "text", text: "hello" }],
|
||||
[{ type: 'text', text: 'hello' }],
|
||||
{ input_tokens: 10, output_tokens: 5 },
|
||||
"<synthetic>"
|
||||
);
|
||||
expect(getTokenUsage(msg as any)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
'<synthetic>',
|
||||
)
|
||||
expect(getTokenUsage(msg as any)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── tokenCountFromLastAPIResponse ──────────────────────────────────────
|
||||
|
||||
describe("tokenCountFromLastAPIResponse", () => {
|
||||
test("returns token count from last assistant message", () => {
|
||||
describe('tokenCountFromLastAPIResponse', () => {
|
||||
test('returns token count from last assistant message', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 200,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25,
|
||||
}),
|
||||
];
|
||||
expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(375);
|
||||
});
|
||||
]
|
||||
expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(375)
|
||||
})
|
||||
|
||||
test("returns 0 for empty messages", () => {
|
||||
expect(tokenCountFromLastAPIResponse([])).toBe(0);
|
||||
});
|
||||
test('returns 0 for empty messages', () => {
|
||||
expect(tokenCountFromLastAPIResponse([])).toBe(0)
|
||||
})
|
||||
|
||||
test("skips user messages to find last assistant", () => {
|
||||
test('skips user messages to find last assistant', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
}),
|
||||
makeUserMessage("reply"),
|
||||
];
|
||||
expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(150);
|
||||
});
|
||||
});
|
||||
makeUserMessage('reply'),
|
||||
]
|
||||
expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(150)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── messageTokenCountFromLastAPIResponse ───────────────────────────────
|
||||
|
||||
describe("messageTokenCountFromLastAPIResponse", () => {
|
||||
test("returns output_tokens from last assistant", () => {
|
||||
describe('messageTokenCountFromLastAPIResponse', () => {
|
||||
test('returns output_tokens from last assistant', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 200,
|
||||
output_tokens: 75,
|
||||
}),
|
||||
];
|
||||
expect(messageTokenCountFromLastAPIResponse(msgs as any)).toBe(75);
|
||||
});
|
||||
]
|
||||
expect(messageTokenCountFromLastAPIResponse(msgs as any)).toBe(75)
|
||||
})
|
||||
|
||||
test("returns 0 for empty messages", () => {
|
||||
expect(messageTokenCountFromLastAPIResponse([])).toBe(0);
|
||||
});
|
||||
});
|
||||
test('returns 0 for empty messages', () => {
|
||||
expect(messageTokenCountFromLastAPIResponse([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getCurrentUsage ────────────────────────────────────────────────────
|
||||
|
||||
describe("getCurrentUsage", () => {
|
||||
test("returns usage object from last assistant", () => {
|
||||
describe('getCurrentUsage', () => {
|
||||
test('returns usage object from last assistant', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
}),
|
||||
];
|
||||
const usage = getCurrentUsage(msgs as any);
|
||||
]
|
||||
const usage = getCurrentUsage(msgs as any)
|
||||
expect(usage).toEqual({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null for empty messages", () => {
|
||||
expect(getCurrentUsage([])).toBeNull();
|
||||
});
|
||||
test('returns null for empty messages', () => {
|
||||
expect(getCurrentUsage([])).toBeNull()
|
||||
})
|
||||
|
||||
test("defaults cache fields to 0", () => {
|
||||
test('defaults cache fields to 0', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
}),
|
||||
];
|
||||
const usage = getCurrentUsage(msgs as any);
|
||||
expect(usage!.cache_creation_input_tokens).toBe(0);
|
||||
expect(usage!.cache_read_input_tokens).toBe(0);
|
||||
});
|
||||
});
|
||||
]
|
||||
const usage = getCurrentUsage(msgs as any)
|
||||
expect(usage!.cache_creation_input_tokens).toBe(0)
|
||||
expect(usage!.cache_read_input_tokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── doesMostRecentAssistantMessageExceed200k ───────────────────────────
|
||||
|
||||
describe("doesMostRecentAssistantMessageExceed200k", () => {
|
||||
test("returns false when under 200k", () => {
|
||||
describe('doesMostRecentAssistantMessageExceed200k', () => {
|
||||
test('returns false when under 200k', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
}),
|
||||
];
|
||||
expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(false);
|
||||
});
|
||||
]
|
||||
expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when over 200k", () => {
|
||||
test('returns true when over 200k', () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
makeAssistantMessage([{ type: 'text', text: 'hi' }], {
|
||||
input_tokens: 190000,
|
||||
output_tokens: 15000,
|
||||
}),
|
||||
];
|
||||
expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(true);
|
||||
});
|
||||
]
|
||||
expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for empty messages", () => {
|
||||
expect(doesMostRecentAssistantMessageExceed200k([])).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for empty messages', () => {
|
||||
expect(doesMostRecentAssistantMessageExceed200k([])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getAssistantMessageContentLength ───────────────────────────────────
|
||||
|
||||
describe("getAssistantMessageContentLength", () => {
|
||||
test("counts text content length", () => {
|
||||
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(5);
|
||||
});
|
||||
describe('getAssistantMessageContentLength', () => {
|
||||
test('counts text content length', () => {
|
||||
const msg = makeAssistantMessage([{ type: 'text', text: 'hello' }])
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(5)
|
||||
})
|
||||
|
||||
test("counts multiple blocks", () => {
|
||||
test('counts multiple blocks', () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "world" },
|
||||
]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(10);
|
||||
});
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'text', text: 'world' },
|
||||
])
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(10)
|
||||
})
|
||||
|
||||
test("counts thinking content", () => {
|
||||
test('counts thinking content', () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "thinking", thinking: "let me think" },
|
||||
]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(12);
|
||||
});
|
||||
{ type: 'thinking', thinking: 'let me think' },
|
||||
])
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(12)
|
||||
})
|
||||
|
||||
test("returns 0 for empty content", () => {
|
||||
const msg = makeAssistantMessage([]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(0);
|
||||
});
|
||||
test('returns 0 for empty content', () => {
|
||||
const msg = makeAssistantMessage([])
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(0)
|
||||
})
|
||||
|
||||
test("counts tool_use input", () => {
|
||||
test('counts tool_use input', () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } },
|
||||
]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' } },
|
||||
])
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
const { treeify } = await import("../treeify");
|
||||
const { treeify } = await import('../treeify')
|
||||
|
||||
describe("treeify", () => {
|
||||
test("renders flat tree with two keys", () => {
|
||||
const result = treeify({ a: "value-a", b: "value-b" });
|
||||
const lines = result.split("\n");
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toContain("a");
|
||||
expect(lines[0]).toContain("value-a");
|
||||
expect(lines[1]).toContain("b");
|
||||
expect(lines[1]).toContain("value-b");
|
||||
});
|
||||
describe('treeify', () => {
|
||||
test('renders flat tree with two keys', () => {
|
||||
const result = treeify({ a: 'value-a', b: 'value-b' })
|
||||
const lines = result.split('\n')
|
||||
expect(lines.length).toBe(2)
|
||||
expect(lines[0]).toContain('a')
|
||||
expect(lines[0]).toContain('value-a')
|
||||
expect(lines[1]).toContain('b')
|
||||
expect(lines[1]).toContain('value-b')
|
||||
})
|
||||
|
||||
test("uses branch character for non-last items", () => {
|
||||
const result = treeify({ a: "1", b: "2" });
|
||||
test('uses branch character for non-last items', () => {
|
||||
const result = treeify({ a: '1', b: '2' })
|
||||
// First item uses ├ (branch), last uses └ (lastBranch)
|
||||
expect(result).toContain("├");
|
||||
expect(result).toContain("└");
|
||||
});
|
||||
expect(result).toContain('├')
|
||||
expect(result).toContain('└')
|
||||
})
|
||||
|
||||
test("uses lastBranch for single item", () => {
|
||||
const result = treeify({ only: "val" });
|
||||
expect(result).toContain("└");
|
||||
expect(result).not.toContain("├");
|
||||
});
|
||||
test('uses lastBranch for single item', () => {
|
||||
const result = treeify({ only: 'val' })
|
||||
expect(result).toContain('└')
|
||||
expect(result).not.toContain('├')
|
||||
})
|
||||
|
||||
test("renders nested objects", () => {
|
||||
const result = treeify({ parent: { child: "val" } });
|
||||
expect(result).toContain("parent");
|
||||
expect(result).toContain("child");
|
||||
expect(result).toContain("val");
|
||||
});
|
||||
test('renders nested objects', () => {
|
||||
const result = treeify({ parent: { child: 'val' } })
|
||||
expect(result).toContain('parent')
|
||||
expect(result).toContain('child')
|
||||
expect(result).toContain('val')
|
||||
})
|
||||
|
||||
test("renders arrays with length", () => {
|
||||
const result = treeify({ items: ["1", "2", "3"] } as any);
|
||||
expect(result).toContain("items");
|
||||
expect(result).toContain("[Array(3)]");
|
||||
});
|
||||
test('renders arrays with length', () => {
|
||||
const result = treeify({ items: ['1', '2', '3'] } as any)
|
||||
expect(result).toContain('items')
|
||||
expect(result).toContain('[Array(3)]')
|
||||
})
|
||||
|
||||
test("detects circular references", () => {
|
||||
const obj: Record<string, unknown> = { name: "root" };
|
||||
obj.self = obj;
|
||||
const result = treeify(obj as any);
|
||||
expect(result).toContain("[Circular]");
|
||||
});
|
||||
test('detects circular references', () => {
|
||||
const obj: Record<string, unknown> = { name: 'root' }
|
||||
obj.self = obj
|
||||
const result = treeify(obj as any)
|
||||
expect(result).toContain('[Circular]')
|
||||
})
|
||||
|
||||
test("returns (empty) for empty object", () => {
|
||||
const result = treeify({});
|
||||
expect(result).toBe("(empty)");
|
||||
});
|
||||
test('returns (empty) for empty object', () => {
|
||||
const result = treeify({})
|
||||
expect(result).toBe('(empty)')
|
||||
})
|
||||
|
||||
test("hideFunctions filters out function values", () => {
|
||||
const obj = { name: "test", fn: () => {} };
|
||||
const result = treeify(obj as any, { hideFunctions: true });
|
||||
expect(result).toContain("name");
|
||||
expect(result).not.toContain("fn");
|
||||
});
|
||||
test('hideFunctions filters out function values', () => {
|
||||
const obj = { name: 'test', fn: () => {} }
|
||||
const result = treeify(obj as any, { hideFunctions: true })
|
||||
expect(result).toContain('name')
|
||||
expect(result).not.toContain('fn')
|
||||
})
|
||||
|
||||
test("showValues false hides leaf values", () => {
|
||||
const obj = { name: "test" };
|
||||
const result = treeify(obj, { showValues: false });
|
||||
expect(result).toContain("name");
|
||||
expect(result).not.toContain("test");
|
||||
});
|
||||
test('showValues false hides leaf values', () => {
|
||||
const obj = { name: 'test' }
|
||||
const result = treeify(obj, { showValues: false })
|
||||
expect(result).toContain('name')
|
||||
expect(result).not.toContain('test')
|
||||
})
|
||||
|
||||
test("showValues true shows function as [Function]", () => {
|
||||
const obj = { fn: () => {} };
|
||||
const result = treeify(obj as any, { showValues: true });
|
||||
expect(result).toContain("[Function]");
|
||||
});
|
||||
test('showValues true shows function as [Function]', () => {
|
||||
const obj = { fn: () => {} }
|
||||
const result = treeify(obj as any, { showValues: true })
|
||||
expect(result).toContain('[Function]')
|
||||
})
|
||||
|
||||
test("deep nesting produces correct indentation", () => {
|
||||
const obj = { a: { b: { c: "deep" } } };
|
||||
const result = treeify(obj);
|
||||
const lines = result.split("\n");
|
||||
expect(lines.length).toBe(3);
|
||||
test('deep nesting produces correct indentation', () => {
|
||||
const obj = { a: { b: { c: 'deep' } } }
|
||||
const result = treeify(obj)
|
||||
const lines = result.split('\n')
|
||||
expect(lines.length).toBe(3)
|
||||
// Each level adds indentation
|
||||
expect(lines[2].length).toBeGreaterThan(lines[1].length);
|
||||
});
|
||||
expect(lines[2].length).toBeGreaterThan(lines[1].length)
|
||||
})
|
||||
|
||||
test("handles empty string key with string value", () => {
|
||||
const obj = { " ": "whitespace-key" };
|
||||
const result = treeify(obj);
|
||||
expect(result).toContain("whitespace-key");
|
||||
});
|
||||
test('handles empty string key with string value', () => {
|
||||
const obj = { ' ': 'whitespace-key' }
|
||||
const result = treeify(obj)
|
||||
expect(result).toContain('whitespace-key')
|
||||
})
|
||||
|
||||
test("handles mixed object and primitive values", () => {
|
||||
const obj = { name: "test", nested: { inner: "val" }, count: 5 };
|
||||
const result = treeify(obj as any);
|
||||
expect(result).toContain("name");
|
||||
expect(result).toContain("nested");
|
||||
expect(result).toContain("inner");
|
||||
expect(result).toContain("count");
|
||||
});
|
||||
});
|
||||
test('handles mixed object and primitive values', () => {
|
||||
const obj = { name: 'test', nested: { inner: 'val' }, count: 5 }
|
||||
const result = treeify(obj as any)
|
||||
expect(result).toContain('name')
|
||||
expect(result).toContain('nested')
|
||||
expect(result).toContain('inner')
|
||||
expect(result).toContain('count')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
truncatePathMiddle,
|
||||
@@ -7,185 +7,185 @@ import {
|
||||
truncateToWidthNoEllipsis,
|
||||
truncate,
|
||||
wrapText,
|
||||
} from "../truncate";
|
||||
} from '../truncate'
|
||||
|
||||
// ─── truncateToWidth ────────────────────────────────────────────────────
|
||||
|
||||
describe("truncateToWidth", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncateToWidth("hello", 10)).toBe("hello");
|
||||
});
|
||||
describe('truncateToWidth', () => {
|
||||
test('returns original when within limit', () => {
|
||||
expect(truncateToWidth('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
test("truncates long string with ellipsis", () => {
|
||||
const result = truncateToWidth("hello world", 8);
|
||||
expect(result).toBe("hello w…");
|
||||
});
|
||||
test('truncates long string with ellipsis', () => {
|
||||
const result = truncateToWidth('hello world', 8)
|
||||
expect(result).toBe('hello w…')
|
||||
})
|
||||
|
||||
test("returns ellipsis for maxWidth 1", () => {
|
||||
expect(truncateToWidth("hello", 1)).toBe("…");
|
||||
});
|
||||
test('returns ellipsis for maxWidth 1', () => {
|
||||
expect(truncateToWidth('hello', 1)).toBe('…')
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(truncateToWidth("", 10)).toBe("");
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(truncateToWidth('', 10)).toBe('')
|
||||
})
|
||||
|
||||
// ── CJK / wide-character tests ──
|
||||
|
||||
test("truncates CJK string at width boundary (2 per char)", () => {
|
||||
expect(truncateToWidth("你好世界", 4)).toBe("你…");
|
||||
});
|
||||
test('truncates CJK string at width boundary (2 per char)', () => {
|
||||
expect(truncateToWidth('你好世界', 4)).toBe('你…')
|
||||
})
|
||||
|
||||
test("truncates CJK string preserving full characters", () => {
|
||||
expect(truncateToWidth("你好世界", 6)).toBe("你好…");
|
||||
});
|
||||
test('truncates CJK string preserving full characters', () => {
|
||||
expect(truncateToWidth('你好世界', 6)).toBe('你好…')
|
||||
})
|
||||
|
||||
test("passes through CJK string when within limit", () => {
|
||||
expect(truncateToWidth("你好", 4)).toBe("你好");
|
||||
});
|
||||
test('passes through CJK string when within limit', () => {
|
||||
expect(truncateToWidth('你好', 4)).toBe('你好')
|
||||
})
|
||||
|
||||
test("handles mixed ASCII + CJK", () => {
|
||||
expect(truncateToWidth("hello你好", 8)).toBe("hello你…");
|
||||
});
|
||||
test('handles mixed ASCII + CJK', () => {
|
||||
expect(truncateToWidth('hello你好', 8)).toBe('hello你…')
|
||||
})
|
||||
|
||||
test("passes through mixed ASCII + CJK at exact limit", () => {
|
||||
expect(truncateToWidth("hello你好", 9)).toBe("hello你好");
|
||||
});
|
||||
test('passes through mixed ASCII + CJK at exact limit', () => {
|
||||
expect(truncateToWidth('hello你好', 9)).toBe('hello你好')
|
||||
})
|
||||
|
||||
test("truncates string containing emoji", () => {
|
||||
const result = truncateToWidth("hello 👋 world", 10);
|
||||
expect(result).toBe("hello 👋 …");
|
||||
});
|
||||
test('truncates string containing emoji', () => {
|
||||
const result = truncateToWidth('hello 👋 world', 10)
|
||||
expect(result).toBe('hello 👋 …')
|
||||
})
|
||||
|
||||
test("passes through single emoji at sufficient width", () => {
|
||||
expect(truncateToWidth("👋", 2)).toBe("👋");
|
||||
});
|
||||
});
|
||||
test('passes through single emoji at sufficient width', () => {
|
||||
expect(truncateToWidth('👋', 2)).toBe('👋')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── truncateStartToWidth ───────────────────────────────────────────────
|
||||
|
||||
describe("truncateStartToWidth", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncateStartToWidth("hello", 10)).toBe("hello");
|
||||
});
|
||||
describe('truncateStartToWidth', () => {
|
||||
test('returns original when within limit', () => {
|
||||
expect(truncateStartToWidth('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
test("truncates from start with ellipsis prefix", () => {
|
||||
expect(truncateStartToWidth("hello world", 8)).toBe("…o world");
|
||||
});
|
||||
test('truncates from start with ellipsis prefix', () => {
|
||||
expect(truncateStartToWidth('hello world', 8)).toBe('…o world')
|
||||
})
|
||||
|
||||
test("returns ellipsis for maxWidth 1", () => {
|
||||
expect(truncateStartToWidth("hello", 1)).toBe("…");
|
||||
});
|
||||
test('returns ellipsis for maxWidth 1', () => {
|
||||
expect(truncateStartToWidth('hello', 1)).toBe('…')
|
||||
})
|
||||
|
||||
test("truncates CJK from start", () => {
|
||||
expect(truncateStartToWidth("你好世界", 4)).toBe("…界");
|
||||
});
|
||||
test('truncates CJK from start', () => {
|
||||
expect(truncateStartToWidth('你好世界', 4)).toBe('…界')
|
||||
})
|
||||
|
||||
test("truncates CJK from start preserving characters", () => {
|
||||
expect(truncateStartToWidth("你好世界", 6)).toBe("…世界");
|
||||
});
|
||||
});
|
||||
test('truncates CJK from start preserving characters', () => {
|
||||
expect(truncateStartToWidth('你好世界', 6)).toBe('…世界')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── truncateToWidthNoEllipsis ──────────────────────────────────────────
|
||||
|
||||
describe("truncateToWidthNoEllipsis", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncateToWidthNoEllipsis("hello", 10)).toBe("hello");
|
||||
});
|
||||
describe('truncateToWidthNoEllipsis', () => {
|
||||
test('returns original when within limit', () => {
|
||||
expect(truncateToWidthNoEllipsis('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
test("truncates without ellipsis", () => {
|
||||
const result = truncateToWidthNoEllipsis("hello world", 5);
|
||||
expect(result).toBe("hello");
|
||||
expect(result.includes("…")).toBe(false);
|
||||
});
|
||||
test('truncates without ellipsis', () => {
|
||||
const result = truncateToWidthNoEllipsis('hello world', 5)
|
||||
expect(result).toBe('hello')
|
||||
expect(result.includes('…')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns empty for maxWidth 0", () => {
|
||||
expect(truncateToWidthNoEllipsis("hello", 0)).toBe("");
|
||||
});
|
||||
test('returns empty for maxWidth 0', () => {
|
||||
expect(truncateToWidthNoEllipsis('hello', 0)).toBe('')
|
||||
})
|
||||
|
||||
test("truncates CJK without ellipsis", () => {
|
||||
expect(truncateToWidthNoEllipsis("你好世界", 4)).toBe("你好");
|
||||
});
|
||||
});
|
||||
test('truncates CJK without ellipsis', () => {
|
||||
expect(truncateToWidthNoEllipsis('你好世界', 4)).toBe('你好')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── truncatePathMiddle ─────────────────────────────────────────────────
|
||||
|
||||
describe("truncatePathMiddle", () => {
|
||||
test("returns original when path fits", () => {
|
||||
expect(truncatePathMiddle("src/index.ts", 50)).toBe("src/index.ts");
|
||||
});
|
||||
describe('truncatePathMiddle', () => {
|
||||
test('returns original when path fits', () => {
|
||||
expect(truncatePathMiddle('src/index.ts', 50)).toBe('src/index.ts')
|
||||
})
|
||||
|
||||
test("truncates middle of long path", () => {
|
||||
const path = "src/components/deeply/nested/folder/MyComponent.tsx";
|
||||
const result = truncatePathMiddle(path, 30);
|
||||
expect(result).toContain("…");
|
||||
expect(result.endsWith("MyComponent.tsx")).toBe(true);
|
||||
});
|
||||
test('truncates middle of long path', () => {
|
||||
const path = 'src/components/deeply/nested/folder/MyComponent.tsx'
|
||||
const result = truncatePathMiddle(path, 30)
|
||||
expect(result).toContain('…')
|
||||
expect(result.endsWith('MyComponent.tsx')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns ellipsis for maxLength 0", () => {
|
||||
expect(truncatePathMiddle("src/index.ts", 0)).toBe("…");
|
||||
});
|
||||
test('returns ellipsis for maxLength 0', () => {
|
||||
expect(truncatePathMiddle('src/index.ts', 0)).toBe('…')
|
||||
})
|
||||
|
||||
test("handles path without slashes", () => {
|
||||
const result = truncatePathMiddle("verylongfilename.ts", 10);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
test('handles path without slashes', () => {
|
||||
const result = truncatePathMiddle('verylongfilename.ts', 10)
|
||||
expect(result).toContain('…')
|
||||
})
|
||||
|
||||
test("handles short maxLength < 5", () => {
|
||||
expect(truncatePathMiddle("src/components/foo.ts", 4)).toBe("src…");
|
||||
});
|
||||
test('handles short maxLength < 5', () => {
|
||||
expect(truncatePathMiddle('src/components/foo.ts', 4)).toBe('src…')
|
||||
})
|
||||
|
||||
test("handles very short maxLength 1", () => {
|
||||
expect(truncatePathMiddle("/a/b", 1)).toBe("…");
|
||||
});
|
||||
});
|
||||
test('handles very short maxLength 1', () => {
|
||||
expect(truncatePathMiddle('/a/b', 1)).toBe('…')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── truncate ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("truncate", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncate("hello", 10)).toBe("hello");
|
||||
});
|
||||
describe('truncate', () => {
|
||||
test('returns original when within limit', () => {
|
||||
expect(truncate('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
test("truncates long string", () => {
|
||||
const result = truncate("hello world foo bar", 10);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
test('truncates long string', () => {
|
||||
const result = truncate('hello world foo bar', 10)
|
||||
expect(result).toContain('…')
|
||||
})
|
||||
|
||||
test("truncates at newline in singleLine mode", () => {
|
||||
const result = truncate("first line\nsecond line", 50, true);
|
||||
expect(result).toBe("first line…");
|
||||
});
|
||||
test('truncates at newline in singleLine mode', () => {
|
||||
const result = truncate('first line\nsecond line', 50, true)
|
||||
expect(result).toBe('first line…')
|
||||
})
|
||||
|
||||
test("does not truncate at newline when singleLine is false", () => {
|
||||
const result = truncate("first\nsecond", 50, false);
|
||||
expect(result).toBe("first\nsecond");
|
||||
});
|
||||
test('does not truncate at newline when singleLine is false', () => {
|
||||
const result = truncate('first\nsecond', 50, false)
|
||||
expect(result).toBe('first\nsecond')
|
||||
})
|
||||
|
||||
test("truncates singleLine when first line exceeds maxWidth", () => {
|
||||
const result = truncate("a very long first line\nsecond", 10, true);
|
||||
expect(result).toContain("…");
|
||||
expect(result).not.toContain("\n");
|
||||
});
|
||||
});
|
||||
test('truncates singleLine when first line exceeds maxWidth', () => {
|
||||
const result = truncate('a very long first line\nsecond', 10, true)
|
||||
expect(result).toContain('…')
|
||||
expect(result).not.toContain('\n')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── wrapText ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("wrapText", () => {
|
||||
test("wraps text at specified width", () => {
|
||||
const result = wrapText("hello world", 6);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
describe('wrapText', () => {
|
||||
test('wraps text at specified width', () => {
|
||||
const result = wrapText('hello world', 6)
|
||||
expect(result.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
test("returns single line when text fits", () => {
|
||||
expect(wrapText("hello", 10)).toEqual(["hello"]);
|
||||
});
|
||||
test('returns single line when text fits', () => {
|
||||
expect(wrapText('hello', 10)).toEqual(['hello'])
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(wrapText("", 10)).toEqual([]);
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(wrapText('', 10)).toEqual([])
|
||||
})
|
||||
|
||||
test("wraps each character on width 1", () => {
|
||||
const result = wrapText("abc", 1);
|
||||
expect(result).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
test('wraps each character on width 1', () => {
|
||||
const result = wrapText('abc', 1)
|
||||
expect(result).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,7 +91,9 @@ afterEach(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
||||
async function closeServer(
|
||||
server: ReturnType<typeof createServer>,
|
||||
): Promise<void> {
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
|
||||
@@ -110,9 +110,7 @@ describe('attachUdsResponseReader', () => {
|
||||
},
|
||||
})
|
||||
|
||||
socket.emitData(
|
||||
Buffer.from(`\n${JSON.stringify({ type: 'response' })}\n`),
|
||||
)
|
||||
socket.emitData(Buffer.from(`\n${JSON.stringify({ type: 'response' })}\n`))
|
||||
|
||||
expect(settled).toBe(true)
|
||||
expect(settledError).toBeUndefined()
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
matchesNegativeKeyword,
|
||||
matchesKeepGoingKeyword,
|
||||
} from "../userPromptKeywords";
|
||||
} from '../userPromptKeywords'
|
||||
|
||||
describe("matchesNegativeKeyword", () => {
|
||||
describe('matchesNegativeKeyword', () => {
|
||||
test("matches 'wtf'", () => {
|
||||
expect(matchesNegativeKeyword("wtf is going on")).toBe(true);
|
||||
});
|
||||
expect(matchesNegativeKeyword('wtf is going on')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'shit'", () => {
|
||||
expect(matchesNegativeKeyword("this is shit")).toBe(true);
|
||||
});
|
||||
expect(matchesNegativeKeyword('this is shit')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'fucking broken'", () => {
|
||||
expect(matchesNegativeKeyword("this is fucking broken")).toBe(true);
|
||||
});
|
||||
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);
|
||||
});
|
||||
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('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);
|
||||
});
|
||||
});
|
||||
test('matches partial word in sentence', () => {
|
||||
expect(matchesNegativeKeyword('please help, damn it')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("matchesKeepGoingKeyword", () => {
|
||||
describe('matchesKeepGoingKeyword', () => {
|
||||
test("matches exact 'continue'", () => {
|
||||
expect(matchesKeepGoingKeyword("continue")).toBe(true);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('continue')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'keep going'", () => {
|
||||
expect(matchesKeepGoingKeyword("keep going")).toBe(true);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('keep going')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'go on'", () => {
|
||||
expect(matchesKeepGoingKeyword("go on")).toBe(true);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('go on')).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match 'cont'", () => {
|
||||
expect(matchesKeepGoingKeyword("cont")).toBe(false);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('cont')).toBe(false)
|
||||
})
|
||||
|
||||
test("does not match empty string", () => {
|
||||
expect(matchesKeepGoingKeyword("")).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);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('please continue')).toBe(false)
|
||||
})
|
||||
|
||||
test("matches 'keep going' in sentence", () => {
|
||||
expect(matchesKeepGoingKeyword("please keep going")).toBe(true);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('please keep going')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'go on' in sentence", () => {
|
||||
expect(matchesKeepGoingKeyword("yes, go on")).toBe(true);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('yes, go on')).toBe(true)
|
||||
})
|
||||
|
||||
test("is case-insensitive for 'continue'", () => {
|
||||
expect(matchesKeepGoingKeyword("Continue")).toBe(true);
|
||||
expect(matchesKeepGoingKeyword("CONTINUE")).toBe(true);
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('Continue')).toBe(true)
|
||||
expect(matchesKeepGoingKeyword('CONTINUE')).toBe(true)
|
||||
})
|
||||
|
||||
test("is case-insensitive for 'keep going'", () => {
|
||||
expect(matchesKeepGoingKeyword("Keep Going")).toBe(true);
|
||||
});
|
||||
});
|
||||
expect(matchesKeepGoingKeyword('Keep Going')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateUuid, createAgentId } from "../uuid";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { validateUuid, createAgentId } from '../uuid'
|
||||
|
||||
describe("validateUuid", () => {
|
||||
test("validates correct UUID", () => {
|
||||
const result = validateUuid("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||||
});
|
||||
describe('validateUuid', () => {
|
||||
test('validates correct UUID', () => {
|
||||
const result = validateUuid('550e8400-e29b-41d4-a716-446655440000')
|
||||
expect(result).toBe('550e8400-e29b-41d4-a716-446655440000')
|
||||
})
|
||||
|
||||
test("validates uppercase UUID", () => {
|
||||
const result = validateUuid("550E8400-E29B-41D4-A716-446655440000");
|
||||
expect(result).toBe("550E8400-E29B-41D4-A716-446655440000");
|
||||
});
|
||||
test('validates uppercase UUID', () => {
|
||||
const result = validateUuid('550E8400-E29B-41D4-A716-446655440000')
|
||||
expect(result).toBe('550E8400-E29B-41D4-A716-446655440000')
|
||||
})
|
||||
|
||||
test("returns null for non-string", () => {
|
||||
expect(validateUuid(123)).toBeNull();
|
||||
expect(validateUuid(null)).toBeNull();
|
||||
expect(validateUuid(undefined)).toBeNull();
|
||||
});
|
||||
test('returns null for non-string', () => {
|
||||
expect(validateUuid(123)).toBeNull()
|
||||
expect(validateUuid(null)).toBeNull()
|
||||
expect(validateUuid(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for invalid UUID format", () => {
|
||||
expect(validateUuid("not-a-uuid")).toBeNull();
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716")).toBeNull();
|
||||
expect(validateUuid("550e8400e29b41d4a716446655440000")).toBeNull();
|
||||
});
|
||||
test('returns null for invalid UUID format', () => {
|
||||
expect(validateUuid('not-a-uuid')).toBeNull()
|
||||
expect(validateUuid('550e8400-e29b-41d4-a716')).toBeNull()
|
||||
expect(validateUuid('550e8400e29b41d4a716446655440000')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(validateUuid("")).toBeNull();
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(validateUuid('')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for UUID with invalid chars", () => {
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716-44665544000g")).toBeNull();
|
||||
});
|
||||
test('returns null for UUID with invalid chars', () => {
|
||||
expect(validateUuid('550e8400-e29b-41d4-a716-44665544000g')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for UUID with leading/trailing whitespace", () => {
|
||||
expect(validateUuid(" 550e8400-e29b-41d4-a716-446655440000")).toBeNull();
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716-446655440000 ")).toBeNull();
|
||||
});
|
||||
});
|
||||
test('returns null for UUID with leading/trailing whitespace', () => {
|
||||
expect(validateUuid(' 550e8400-e29b-41d4-a716-446655440000')).toBeNull()
|
||||
expect(validateUuid('550e8400-e29b-41d4-a716-446655440000 ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("createAgentId", () => {
|
||||
test("generates id without label in correct format", () => {
|
||||
const id = createAgentId();
|
||||
expect(id).toMatch(/^a[0-9a-f]{16}$/);
|
||||
});
|
||||
describe('createAgentId', () => {
|
||||
test('generates id without label in correct format', () => {
|
||||
const id = createAgentId()
|
||||
expect(id).toMatch(/^a[0-9a-f]{16}$/)
|
||||
})
|
||||
|
||||
test("generates id with label in correct format", () => {
|
||||
const id = createAgentId("compact");
|
||||
expect(id).toMatch(/^acompact-[0-9a-f]{16}$/);
|
||||
});
|
||||
});
|
||||
test('generates id with label in correct format', () => {
|
||||
const id = createAgentId('compact')
|
||||
expect(id).toMatch(/^acompact-[0-9a-f]{16}$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,116 +1,111 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
windowsPathToPosixPath,
|
||||
posixPathToWindowsPath,
|
||||
} from "../windowsPaths";
|
||||
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");
|
||||
});
|
||||
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('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('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 UNC path', () => {
|
||||
expect(windowsPathToPosixPath('\\\\server\\share\\dir')).toBe(
|
||||
'//server/share/dir',
|
||||
)
|
||||
})
|
||||
|
||||
test("converts root drive path", () => {
|
||||
expect(windowsPathToPosixPath("D:\\")).toBe("/d/");
|
||||
});
|
||||
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('converts relative path by flipping backslashes', () => {
|
||||
expect(windowsPathToPosixPath('src\\main.ts')).toBe('src/main.ts')
|
||||
})
|
||||
|
||||
test("handles forward slashes in windows drive path", () => {
|
||||
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");
|
||||
});
|
||||
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('already-posix relative path passes through', () => {
|
||||
expect(windowsPathToPosixPath('src/main.ts')).toBe('src/main.ts')
|
||||
})
|
||||
|
||||
test("handles deeply nested path", () => {
|
||||
test('handles deeply nested path', () => {
|
||||
expect(
|
||||
windowsPathToPosixPath("C:\\Users\\me\\Documents\\project\\src\\index.ts")
|
||||
).toBe("/c/Users/me/Documents/project/src/index.ts");
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
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('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 path', () => {
|
||||
expect(posixPathToWindowsPath('/cygdrive/d/work')).toBe('D:\\work')
|
||||
})
|
||||
|
||||
test("converts cygdrive root path", () => {
|
||||
expect(posixPathToWindowsPath("/cygdrive/c/")).toBe("C:\\");
|
||||
});
|
||||
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 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 root drive posix path', () => {
|
||||
expect(posixPathToWindowsPath('/d/')).toBe('D:\\')
|
||||
})
|
||||
|
||||
test("converts bare drive mount (no trailing slash)", () => {
|
||||
test('converts bare drive mount (no trailing slash)', () => {
|
||||
// /d matches the regex ^\/([A-Za-z])(\/|$) where $2 is empty
|
||||
expect(posixPathToWindowsPath("/d")).toBe("D:\\");
|
||||
});
|
||||
expect(posixPathToWindowsPath('/d')).toBe('D:\\')
|
||||
})
|
||||
|
||||
test("converts relative path by flipping forward slashes", () => {
|
||||
expect(posixPathToWindowsPath("src/main.ts")).toBe("src\\main.ts");
|
||||
});
|
||||
test('converts relative path by flipping forward slashes', () => {
|
||||
expect(posixPathToWindowsPath('src/main.ts')).toBe('src\\main.ts')
|
||||
})
|
||||
|
||||
test("handles already-windows relative path", () => {
|
||||
test('handles already-windows relative path', () => {
|
||||
// No leading / or //, just flips / to backslash
|
||||
expect(posixPathToWindowsPath("foo\\bar")).toBe("foo\\bar");
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { withResolvers } from "../withResolvers";
|
||||
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");
|
||||
});
|
||||
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 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('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('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('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('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('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);
|
||||
});
|
||||
});
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { generateWordSlug, generateShortWordSlug } from "../words";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { generateWordSlug, generateShortWordSlug } from '../words'
|
||||
|
||||
describe("generateWordSlug", () => {
|
||||
test("returns three-part hyphenated slug", () => {
|
||||
const slug = generateWordSlug();
|
||||
const parts = slug.split("-");
|
||||
expect(parts.length).toBe(3);
|
||||
});
|
||||
describe('generateWordSlug', () => {
|
||||
test('returns three-part hyphenated slug', () => {
|
||||
const slug = generateWordSlug()
|
||||
const parts = slug.split('-')
|
||||
expect(parts.length).toBe(3)
|
||||
})
|
||||
|
||||
test("all parts are non-empty", () => {
|
||||
test('all parts are non-empty', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateWordSlug();
|
||||
const parts = slug.split("-");
|
||||
const slug = generateWordSlug()
|
||||
const parts = slug.split('-')
|
||||
for (const part of parts) {
|
||||
expect(part.length).toBeGreaterThan(0);
|
||||
expect(part.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("all parts are lowercase", () => {
|
||||
test('all parts are lowercase', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateWordSlug();
|
||||
expect(slug).toBe(slug.toLowerCase());
|
||||
const slug = generateWordSlug()
|
||||
expect(slug).toBe(slug.toLowerCase())
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("no consecutive hyphens", () => {
|
||||
test('no consecutive hyphens', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateWordSlug();
|
||||
expect(slug).not.toContain("--");
|
||||
const slug = generateWordSlug()
|
||||
expect(slug).not.toContain('--')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("multiple calls produce varied results", () => {
|
||||
const slugs = new Set<string>();
|
||||
test('multiple calls produce varied results', () => {
|
||||
const slugs = new Set<string>()
|
||||
for (let i = 0; i < 20; i++) {
|
||||
slugs.add(generateWordSlug());
|
||||
slugs.add(generateWordSlug())
|
||||
}
|
||||
// With 50+ adjectives × 50+ verbs × 50+ nouns, 20 calls should produce mostly unique slugs
|
||||
expect(slugs.size).toBeGreaterThan(10);
|
||||
});
|
||||
expect(slugs.size).toBeGreaterThan(10)
|
||||
})
|
||||
|
||||
test("slug matches adjective-verb-noun pattern", () => {
|
||||
const slug = generateWordSlug();
|
||||
expect(slug).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
|
||||
});
|
||||
});
|
||||
test('slug matches adjective-verb-noun pattern', () => {
|
||||
const slug = generateWordSlug()
|
||||
expect(slug).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateShortWordSlug", () => {
|
||||
test("returns two-part hyphenated slug", () => {
|
||||
const slug = generateShortWordSlug();
|
||||
const parts = slug.split("-");
|
||||
expect(parts.length).toBe(2);
|
||||
});
|
||||
describe('generateShortWordSlug', () => {
|
||||
test('returns two-part hyphenated slug', () => {
|
||||
const slug = generateShortWordSlug()
|
||||
const parts = slug.split('-')
|
||||
expect(parts.length).toBe(2)
|
||||
})
|
||||
|
||||
test("all parts are non-empty", () => {
|
||||
test('all parts are non-empty', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateShortWordSlug();
|
||||
const parts = slug.split("-");
|
||||
const slug = generateShortWordSlug()
|
||||
const parts = slug.split('-')
|
||||
for (const part of parts) {
|
||||
expect(part.length).toBeGreaterThan(0);
|
||||
expect(part.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("all parts are lowercase", () => {
|
||||
test('all parts are lowercase', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateShortWordSlug();
|
||||
expect(slug).toBe(slug.toLowerCase());
|
||||
const slug = generateShortWordSlug()
|
||||
expect(slug).toBe(slug.toLowerCase())
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("slug matches adjective-noun pattern", () => {
|
||||
const slug = generateShortWordSlug();
|
||||
expect(slug).toMatch(/^[a-z]+-[a-z]+$/);
|
||||
});
|
||||
test('slug matches adjective-noun pattern', () => {
|
||||
const slug = generateShortWordSlug()
|
||||
expect(slug).toMatch(/^[a-z]+-[a-z]+$/)
|
||||
})
|
||||
|
||||
test("no consecutive hyphens", () => {
|
||||
test('no consecutive hyphens', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const slug = generateShortWordSlug();
|
||||
expect(slug).not.toContain("--");
|
||||
const slug = generateShortWordSlug()
|
||||
expect(slug).not.toContain('--')
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
getXDGStateHome,
|
||||
getXDGCacheHome,
|
||||
getXDGDataHome,
|
||||
getUserBinDir,
|
||||
} from "../xdg";
|
||||
} from '../xdg'
|
||||
|
||||
describe("getXDGStateHome", () => {
|
||||
test("returns ~/.local/state by default", () => {
|
||||
const result = getXDGStateHome({ homedir: "/home/user" });
|
||||
expect(result).toBe("/home/user/.local/state");
|
||||
});
|
||||
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", () => {
|
||||
test('respects XDG_STATE_HOME env var', () => {
|
||||
const result = getXDGStateHome({
|
||||
homedir: "/home/user",
|
||||
env: { XDG_STATE_HOME: "/custom/state" },
|
||||
});
|
||||
expect(result).toBe("/custom/state");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
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", () => {
|
||||
test('respects XDG_CACHE_HOME env var', () => {
|
||||
const result = getXDGCacheHome({
|
||||
homedir: "/home/user",
|
||||
env: { XDG_CACHE_HOME: "/tmp/cache" },
|
||||
});
|
||||
expect(result).toBe("/tmp/cache");
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
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", () => {
|
||||
test('respects XDG_DATA_HOME env var', () => {
|
||||
const result = getXDGDataHome({
|
||||
homedir: "/home/user",
|
||||
env: { XDG_DATA_HOME: "/custom/data" },
|
||||
});
|
||||
expect(result).toBe("/custom/data");
|
||||
});
|
||||
});
|
||||
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");
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
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$/);
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
test('respects HOME via homedir override', () => {
|
||||
const result = getXDGStateHome({ homedir: '/Users/me' })
|
||||
expect(result).toBe('/Users/me/.local/state')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { escapeXml, escapeXmlAttr } from "../xml";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { escapeXml, escapeXmlAttr } from '../xml'
|
||||
|
||||
describe("escapeXml", () => {
|
||||
test("escapes ampersand", () => {
|
||||
expect(escapeXml("a & b")).toBe("a & b");
|
||||
});
|
||||
describe('escapeXml', () => {
|
||||
test('escapes ampersand', () => {
|
||||
expect(escapeXml('a & b')).toBe('a & b')
|
||||
})
|
||||
|
||||
test("escapes less-than", () => {
|
||||
expect(escapeXml("<div>")).toBe("<div>");
|
||||
});
|
||||
test('escapes less-than', () => {
|
||||
expect(escapeXml('<div>')).toBe('<div>')
|
||||
})
|
||||
|
||||
test("escapes greater-than", () => {
|
||||
expect(escapeXml("a > b")).toBe("a > b");
|
||||
});
|
||||
test('escapes greater-than', () => {
|
||||
expect(escapeXml('a > b')).toBe('a > b')
|
||||
})
|
||||
|
||||
test("escapes multiple special chars", () => {
|
||||
expect(escapeXml("<a & b>")).toBe("<a & b>");
|
||||
});
|
||||
test('escapes multiple special chars', () => {
|
||||
expect(escapeXml('<a & b>')).toBe('<a & b>')
|
||||
})
|
||||
|
||||
test("returns empty string unchanged", () => {
|
||||
expect(escapeXml("")).toBe("");
|
||||
});
|
||||
test('returns empty string unchanged', () => {
|
||||
expect(escapeXml('')).toBe('')
|
||||
})
|
||||
|
||||
test("returns normal text unchanged", () => {
|
||||
expect(escapeXml("hello world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
test('returns normal text unchanged', () => {
|
||||
expect(escapeXml('hello world')).toBe('hello world')
|
||||
})
|
||||
})
|
||||
|
||||
describe("escapeXmlAttr", () => {
|
||||
test("escapes double quotes", () => {
|
||||
expect(escapeXmlAttr('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
describe('escapeXmlAttr', () => {
|
||||
test('escapes double quotes', () => {
|
||||
expect(escapeXmlAttr('say "hello"')).toBe('say "hello"')
|
||||
})
|
||||
|
||||
test("escapes single quotes", () => {
|
||||
expect(escapeXmlAttr("it's")).toBe("it's");
|
||||
});
|
||||
test('escapes single quotes', () => {
|
||||
expect(escapeXmlAttr("it's")).toBe('it's')
|
||||
})
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
expect(escapeXmlAttr('<a & "b">')).toBe("<a & "b">");
|
||||
});
|
||||
});
|
||||
test('escapes all special chars', () => {
|
||||
expect(escapeXmlAttr('<a & "b">')).toBe('<a & "b">')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import z from "zod/v4";
|
||||
import { zodToJsonSchema } from "../zodToJsonSchema";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import z from 'zod/v4'
|
||||
import { zodToJsonSchema } from '../zodToJsonSchema'
|
||||
|
||||
describe("zodToJsonSchema", () => {
|
||||
test("converts string schema", () => {
|
||||
const schema = z.string();
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("string");
|
||||
});
|
||||
describe('zodToJsonSchema', () => {
|
||||
test('converts string schema', () => {
|
||||
const schema = z.string()
|
||||
const result = zodToJsonSchema(schema)
|
||||
expect(result.type).toBe('string')
|
||||
})
|
||||
|
||||
test("converts number schema", () => {
|
||||
const schema = z.number();
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("number");
|
||||
});
|
||||
test('converts number schema', () => {
|
||||
const schema = z.number()
|
||||
const result = zodToJsonSchema(schema)
|
||||
expect(result.type).toBe('number')
|
||||
})
|
||||
|
||||
test("converts object schema with properties", () => {
|
||||
test('converts object schema with properties', () => {
|
||||
const schema = z.object({
|
||||
name: z.string(),
|
||||
age: z.number(),
|
||||
});
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("object");
|
||||
expect(result.properties).toBeDefined();
|
||||
expect((result.properties as any).name).toEqual({ type: "string" });
|
||||
expect((result.properties as any).age).toEqual({ type: "number" });
|
||||
});
|
||||
})
|
||||
const result = zodToJsonSchema(schema)
|
||||
expect(result.type).toBe('object')
|
||||
expect(result.properties).toBeDefined()
|
||||
expect((result.properties as any).name).toEqual({ type: 'string' })
|
||||
expect((result.properties as any).age).toEqual({ type: 'number' })
|
||||
})
|
||||
|
||||
test("converts enum schema", () => {
|
||||
const schema = z.enum(["a", "b", "c"]);
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.enum).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
test('converts enum schema', () => {
|
||||
const schema = z.enum(['a', 'b', 'c'])
|
||||
const result = zodToJsonSchema(schema)
|
||||
expect(result.enum).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test("converts optional fields", () => {
|
||||
test('converts optional fields', () => {
|
||||
const schema = z.object({
|
||||
required: z.string(),
|
||||
optional: z.string().optional(),
|
||||
});
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.required).toEqual(["required"]);
|
||||
expect(result.required).not.toContain("optional");
|
||||
});
|
||||
})
|
||||
const result = zodToJsonSchema(schema)
|
||||
expect(result.required).toEqual(['required'])
|
||||
expect(result.required).not.toContain('optional')
|
||||
})
|
||||
|
||||
test("caches results for same schema reference", () => {
|
||||
const schema = z.string();
|
||||
const first = zodToJsonSchema(schema);
|
||||
const second = zodToJsonSchema(schema);
|
||||
expect(first).toBe(second); // same reference (cached)
|
||||
});
|
||||
test('caches results for same schema reference', () => {
|
||||
const schema = z.string()
|
||||
const first = zodToJsonSchema(schema)
|
||||
const second = zodToJsonSchema(schema)
|
||||
expect(first).toBe(second) // same reference (cached)
|
||||
})
|
||||
|
||||
test("different schemas get different results", () => {
|
||||
const s1 = z.string();
|
||||
const s2 = z.number();
|
||||
const r1 = zodToJsonSchema(s1);
|
||||
const r2 = zodToJsonSchema(s2);
|
||||
expect(r1).not.toBe(r2);
|
||||
expect(r1.type).not.toBe(r2.type);
|
||||
});
|
||||
test('different schemas get different results', () => {
|
||||
const s1 = z.string()
|
||||
const s2 = z.number()
|
||||
const r1 = zodToJsonSchema(s1)
|
||||
const r2 = zodToJsonSchema(s2)
|
||||
expect(r1).not.toBe(r2)
|
||||
expect(r1.type).not.toBe(r2.type)
|
||||
})
|
||||
|
||||
test("converts array schema", () => {
|
||||
const schema = z.array(z.string());
|
||||
const result = zodToJsonSchema(schema);
|
||||
expect(result.type).toBe("array");
|
||||
expect((result.items as any).type).toBe("string");
|
||||
});
|
||||
test('converts array schema', () => {
|
||||
const schema = z.array(z.string())
|
||||
const result = zodToJsonSchema(schema)
|
||||
expect(result.type).toBe('array')
|
||||
expect((result.items as any).type).toBe('string')
|
||||
})
|
||||
|
||||
test("converts boolean schema", () => {
|
||||
const result = zodToJsonSchema(z.boolean());
|
||||
expect(result.type).toBe("boolean");
|
||||
});
|
||||
});
|
||||
test('converts boolean schema', () => {
|
||||
const result = zodToJsonSchema(z.boolean())
|
||||
expect(result.type).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -384,7 +384,9 @@ async function countBuiltInToolTokens(
|
||||
|
||||
// Check if tool search is enabled
|
||||
const { isToolSearchEnabled } = await import('./toolSearch.js')
|
||||
const { isDeferredTool } = await import('@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js')
|
||||
const { isDeferredTool } = await import(
|
||||
'@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
||||
)
|
||||
const isDeferred = await isToolSearchEnabled(
|
||||
model ?? '',
|
||||
tools,
|
||||
@@ -668,7 +670,9 @@ export async function countMcpToolTokens(
|
||||
// Check if tool search is enabled - if so, MCP tools are deferred
|
||||
// isToolSearchEnabled handles threshold calculation internally for TstAuto mode
|
||||
const { isToolSearchEnabled } = await import('./toolSearch.js')
|
||||
const { isDeferredTool } = await import('@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js')
|
||||
const { isDeferredTool } = await import(
|
||||
'@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
|
||||
)
|
||||
|
||||
const isDeferred = await isToolSearchEnabled(
|
||||
model,
|
||||
@@ -786,12 +790,18 @@ function processAssistantMessage(
|
||||
breakdown: MessageBreakdown,
|
||||
): void {
|
||||
// Process each content block individually
|
||||
const contentBlocks = Array.isArray(msg.message!.content) ? msg.message!.content : []
|
||||
const contentBlocks = Array.isArray(msg.message!.content)
|
||||
? msg.message!.content
|
||||
: []
|
||||
for (const block of contentBlocks) {
|
||||
const blockStr = jsonStringify(block)
|
||||
const blockTokens = roughTokenCountEstimation(blockStr)
|
||||
|
||||
if (typeof block !== 'string' && 'type' in block && block.type === 'tool_use') {
|
||||
if (
|
||||
typeof block !== 'string' &&
|
||||
'type' in block &&
|
||||
block.type === 'tool_use'
|
||||
) {
|
||||
breakdown.toolCallTokens += blockTokens
|
||||
const toolName = ('name' in block ? block.name : undefined) || 'unknown'
|
||||
breakdown.toolCallsByType.set(
|
||||
@@ -819,7 +829,7 @@ function processUserMessage(
|
||||
}
|
||||
|
||||
// Process each content block individually
|
||||
for (const block of (msg.message!.content ?? [])) {
|
||||
for (const block of msg.message!.content ?? []) {
|
||||
const blockStr = jsonStringify(block)
|
||||
const blockTokens = roughTokenCountEstimation(blockStr)
|
||||
|
||||
@@ -875,10 +885,16 @@ async function approximateMessageTokens(
|
||||
for (const msg of microcompactResult.messages) {
|
||||
if (msg.type === 'assistant' && Array.isArray(msg.message!.content)) {
|
||||
for (const block of msg.message!.content) {
|
||||
if (typeof block !== 'string' && 'type' in block && block.type === 'tool_use') {
|
||||
if (
|
||||
typeof block !== 'string' &&
|
||||
'type' in block &&
|
||||
block.type === 'tool_use'
|
||||
) {
|
||||
const toolUseId = 'id' in block ? (block.id as string) : undefined
|
||||
const toolName =
|
||||
(('name' in block ? block.name : undefined) as string | undefined) || 'unknown'
|
||||
(('name' in block ? block.name : undefined) as
|
||||
| string
|
||||
| undefined) || 'unknown'
|
||||
if (toolUseId) {
|
||||
toolUseIdToName.set(toolUseId, toolName)
|
||||
}
|
||||
|
||||
@@ -823,11 +823,12 @@ export async function getAttachments(
|
||||
suppressNextDiscovery = false
|
||||
return []
|
||||
}
|
||||
const result = await skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
|
||||
input,
|
||||
messages ?? [],
|
||||
context,
|
||||
)
|
||||
const result =
|
||||
await skillSearchModules.prefetch.getTurnZeroSkillDiscovery(
|
||||
input,
|
||||
messages ?? [],
|
||||
context,
|
||||
)
|
||||
return result ? [result] : []
|
||||
}),
|
||||
]
|
||||
@@ -1016,11 +1017,13 @@ export async function getAttachments(
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
// Defensive: a getter leaking [undefined] crashes .map(a => a.type) below.
|
||||
return ([
|
||||
...userAttachmentResults.flat(),
|
||||
...threadAttachmentResults.flat(),
|
||||
...mainThreadAttachmentResults.flat(),
|
||||
] as Attachment[]).filter(a => a !== undefined && a !== null)
|
||||
return (
|
||||
[
|
||||
...userAttachmentResults.flat(),
|
||||
...threadAttachmentResults.flat(),
|
||||
...mainThreadAttachmentResults.flat(),
|
||||
] as Attachment[]
|
||||
).filter(a => a !== undefined && a !== null)
|
||||
}
|
||||
|
||||
async function maybe<A>(label: string, f: () => Promise<A[]>): Promise<A[]> {
|
||||
@@ -1547,7 +1550,8 @@ export function getAgentListingDeltaAttachment(
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment!.type !== 'agent_listing_delta') continue
|
||||
for (const t of msg.attachment!.addedTypes as string[]) announced.add(t)
|
||||
for (const t of msg.attachment!.removedTypes as string[]) announced.delete(t)
|
||||
for (const t of msg.attachment!.removedTypes as string[])
|
||||
announced.delete(t)
|
||||
}
|
||||
|
||||
const currentTypes = new Set(filtered.map(a => a.agentType))
|
||||
@@ -1770,7 +1774,6 @@ export function memoryFilesToAttachments(
|
||||
isPartialView: memoryFile.contentDiffersFromDisk,
|
||||
})
|
||||
|
||||
|
||||
// Fire InstructionsLoaded hook for audit/observability (fire-and-forget)
|
||||
if (shouldFireHook && isInstructionsMemoryType(memoryFile.type)) {
|
||||
const loadReason = memoryFile.globs
|
||||
@@ -2279,7 +2282,11 @@ export function collectSurfacedMemories(messages: ReadonlyArray<Message>): {
|
||||
let totalBytes = 0
|
||||
for (const m of messages) {
|
||||
if (m.type === 'attachment' && m.attachment!.type === 'relevant_memories') {
|
||||
for (const mem of m.attachment!.memories as { path: string; content: string; mtimeMs: number }[]) {
|
||||
for (const mem of m.attachment!.memories as {
|
||||
path: string
|
||||
content: string
|
||||
mtimeMs: number
|
||||
}[]) {
|
||||
paths.add(mem.path)
|
||||
totalBytes += mem.content.length
|
||||
}
|
||||
@@ -2393,7 +2400,8 @@ export function startRelevantMemoryPrefetch(
|
||||
}
|
||||
|
||||
// Poor mode: skip the side-query to save tokens
|
||||
const { isPoorModeActive } = require('../commands/poor/poorMode.js') as typeof import('../commands/poor/poorMode.js')
|
||||
const { isPoorModeActive } =
|
||||
require('../commands/poor/poorMode.js') as typeof import('../commands/poor/poorMode.js')
|
||||
if (isPoorModeActive()) {
|
||||
return undefined
|
||||
}
|
||||
@@ -2503,7 +2511,11 @@ export function collectRecentSuccessfulTools(
|
||||
if (!m) continue
|
||||
if (isHumanTurn(m) && m !== lastUserMessage) break
|
||||
if (m.type === 'assistant' && typeof m.message!.content !== 'string') {
|
||||
for (const block of m.message!.content as Array<{type: string; id: string; name: string}>) {
|
||||
for (const block of m.message!.content as Array<{
|
||||
type: string
|
||||
id: string
|
||||
name: string
|
||||
}>) {
|
||||
if (block.type === 'tool_use') useIdToName.set(block.id, block.name)
|
||||
}
|
||||
} else if (
|
||||
@@ -2511,7 +2523,7 @@ export function collectRecentSuccessfulTools(
|
||||
'message' in m &&
|
||||
Array.isArray(m.message!.content)
|
||||
) {
|
||||
for (const block of m.message!.content as Array<{type: string}>) {
|
||||
for (const block of m.message!.content as Array<{ type: string }>) {
|
||||
if (isToolResultBlock(block)) {
|
||||
resultByUseId.set(block.tool_use_id, block.is_error === true)
|
||||
}
|
||||
@@ -2532,7 +2544,6 @@ export function collectRecentSuccessfulTools(
|
||||
return [...succeeded].filter(t => !failed.has(t))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Filters prefetched memory attachments to exclude memories the model already
|
||||
* has in context via FileRead/Write/Edit tool calls (any iteration this turn)
|
||||
@@ -4025,7 +4036,6 @@ export function getContextEfficiencyAttachment(
|
||||
return [{ type: 'context_efficiency' }]
|
||||
}
|
||||
|
||||
|
||||
function isFileReadDenied(
|
||||
filePath: string,
|
||||
toolPermissionContext: ToolPermissionContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export const clearAttributionCaches: () => void = () => {};
|
||||
export const sweepFileContentCache: () => void = () => {};
|
||||
export const registerAttributionHooks: () => void = () => {};
|
||||
export const clearAttributionCaches: () => void = () => {}
|
||||
export const sweepFileContentCache: () => void = () => {}
|
||||
export const registerAttributionHooks: () => void = () => {}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const buildPRTrailers: (attributionData: unknown, attribution: unknown) => string[] = () => [];
|
||||
export {}
|
||||
export const buildPRTrailers: (
|
||||
attributionData: unknown,
|
||||
attribution: unknown,
|
||||
) => string[] = () => []
|
||||
|
||||
@@ -1,36 +1,32 @@
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
|
||||
type Props = {
|
||||
onRun: () => void
|
||||
onCancel: () => void
|
||||
reason: string
|
||||
}
|
||||
onRun: () => void;
|
||||
onCancel: () => void;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that shows a notification about running /issue command
|
||||
* with the ability to cancel via ESC key
|
||||
*/
|
||||
export function AutoRunIssueNotification({
|
||||
onRun,
|
||||
onCancel,
|
||||
reason,
|
||||
}: Props): React.ReactNode {
|
||||
const hasRunRef = useRef(false)
|
||||
export function AutoRunIssueNotification({ onRun, onCancel, reason }: Props): React.ReactNode {
|
||||
const hasRunRef = useRef(false);
|
||||
|
||||
// Handle ESC key to cancel
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Confirmation' })
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Confirmation' });
|
||||
|
||||
// Run /issue immediately on mount
|
||||
useEffect(() => {
|
||||
if (!hasRunRef.current) {
|
||||
hasRunRef.current = true
|
||||
onRun()
|
||||
hasRunRef.current = true;
|
||||
onRun();
|
||||
}
|
||||
}, [onRun])
|
||||
}, [onRun]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
@@ -46,10 +42,10 @@ export function AutoRunIssueNotification({
|
||||
<Text dimColor>Reason: {reason}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'
|
||||
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good';
|
||||
|
||||
/**
|
||||
* Determines if /issue should auto-run for Ant users
|
||||
@@ -57,16 +53,16 @@ export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'
|
||||
export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
|
||||
// Only for Ant users
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case 'feedback_survey_bad':
|
||||
return false
|
||||
return false;
|
||||
case 'feedback_survey_good':
|
||||
return false
|
||||
return false;
|
||||
default:
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +73,9 @@ export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
|
||||
export function getAutoRunCommand(reason: AutoRunIssueReason): string {
|
||||
// Only ant builds have the /good-claude command
|
||||
if (process.env.USER_TYPE === 'ant' && reason === 'feedback_survey_good') {
|
||||
return '/good-claude'
|
||||
return '/good-claude';
|
||||
}
|
||||
return '/issue'
|
||||
return '/issue';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,10 +84,10 @@ export function getAutoRunCommand(reason: AutoRunIssueReason): string {
|
||||
export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string {
|
||||
switch (reason) {
|
||||
case 'feedback_survey_bad':
|
||||
return 'You responded "Bad" to the feedback survey'
|
||||
return 'You responded "Bad" to the feedback survey';
|
||||
case 'feedback_survey_good':
|
||||
return 'You responded "Good" to the feedback survey'
|
||||
return 'You responded "Good" to the feedback survey';
|
||||
default:
|
||||
return 'Unknown reason'
|
||||
return 'Unknown reason';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,10 @@ import { has1mContext } from './context.js'
|
||||
import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
|
||||
import { getCanonicalName } from './model/model.js'
|
||||
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js'
|
||||
import {
|
||||
getAPIProvider,
|
||||
isFirstPartyAnthropicBaseUrl,
|
||||
} from './model/providers.js'
|
||||
import { getInitialSettings } from './settings/settings.js'
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { LogOption } from 'src/types/logs.js';
|
||||
export const parseCcshareId: (resume: string) => string | null = () => null;
|
||||
export const loadCcshare: (ccshareId: string) => Promise<LogOption> = async () => { throw new Error('ccshare not implemented'); };
|
||||
import type { LogOption } from 'src/types/logs.js'
|
||||
export const parseCcshareId: (resume: string) => string | null = () => null
|
||||
export const loadCcshare: (ccshareId: string) => Promise<LogOption> =
|
||||
async () => {
|
||||
throw new Error('ccshare not implemented')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import { MessageResponse } from '../../components/MessageResponse.js'
|
||||
import { supportsHyperlinks } from '@anthropic/ink'
|
||||
import { Link, Text } from '@anthropic/ink'
|
||||
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '@claude-code-best/builtin-tools/tools/MCPTool/UI.js'
|
||||
import type { MCPToolResult } from '../../utils/mcpValidation.js'
|
||||
import { truncateToWidth } from '../format.js'
|
||||
import { trackClaudeInChromeTabId } from './common.js'
|
||||
import * as React from 'react';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { supportsHyperlinks } from '@anthropic/ink';
|
||||
import { Link, Text } from '@anthropic/ink';
|
||||
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '@claude-code-best/builtin-tools/tools/MCPTool/UI.js';
|
||||
import type { MCPToolResult } from '../../utils/mcpValidation.js';
|
||||
import { truncateToWidth } from '../format.js';
|
||||
import { trackClaudeInChromeTabId } from './common.js';
|
||||
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
/**
|
||||
* All tool names from BROWSER_TOOLS in @ant/claude-for-chrome-mcp.
|
||||
@@ -30,44 +30,44 @@ export type ChromeToolName =
|
||||
| 'read_console_messages'
|
||||
| 'read_network_requests'
|
||||
| 'shortcuts_list'
|
||||
| 'shortcuts_execute'
|
||||
| 'shortcuts_execute';
|
||||
|
||||
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/'
|
||||
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/';
|
||||
|
||||
function renderChromeToolUseMessage(
|
||||
input: Record<string, unknown>,
|
||||
toolName: ChromeToolName,
|
||||
verbose: boolean,
|
||||
): React.ReactNode {
|
||||
const tabId = input.tabId
|
||||
const tabId = input.tabId;
|
||||
if (typeof tabId === 'number') {
|
||||
trackClaudeInChromeTabId(tabId)
|
||||
trackClaudeInChromeTabId(tabId);
|
||||
}
|
||||
|
||||
// Build secondary info based on tool type and input
|
||||
const secondaryInfo: string[] = []
|
||||
const secondaryInfo: string[] = [];
|
||||
|
||||
switch (toolName) {
|
||||
case 'navigate':
|
||||
if (typeof input.url === 'string') {
|
||||
try {
|
||||
const url = new URL(input.url)
|
||||
secondaryInfo.push(url.hostname)
|
||||
const url = new URL(input.url);
|
||||
secondaryInfo.push(url.hostname);
|
||||
} catch {
|
||||
secondaryInfo.push(truncateToWidth(input.url, 30))
|
||||
secondaryInfo.push(truncateToWidth(input.url, 30));
|
||||
}
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'find':
|
||||
if (typeof input.query === 'string') {
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`)
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`);
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'computer':
|
||||
if (typeof input.action === 'string') {
|
||||
const action = input.action
|
||||
const action = input.action;
|
||||
if (
|
||||
action === 'left_click' ||
|
||||
action === 'right_click' ||
|
||||
@@ -75,71 +75,68 @@ function renderChromeToolUseMessage(
|
||||
action === 'middle_click'
|
||||
) {
|
||||
if (typeof input.ref === 'string') {
|
||||
secondaryInfo.push(`${action} on ${input.ref}`)
|
||||
secondaryInfo.push(`${action} on ${input.ref}`);
|
||||
} else if (Array.isArray(input.coordinate)) {
|
||||
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`)
|
||||
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`);
|
||||
} else {
|
||||
secondaryInfo.push(action)
|
||||
secondaryInfo.push(action);
|
||||
}
|
||||
} else if (action === 'type' && typeof input.text === 'string') {
|
||||
secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`)
|
||||
secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`);
|
||||
} else if (action === 'key' && typeof input.text === 'string') {
|
||||
secondaryInfo.push(`key ${input.text}`)
|
||||
} else if (
|
||||
action === 'scroll' &&
|
||||
typeof input.scroll_direction === 'string'
|
||||
) {
|
||||
secondaryInfo.push(`scroll ${input.scroll_direction}`)
|
||||
secondaryInfo.push(`key ${input.text}`);
|
||||
} else if (action === 'scroll' && typeof input.scroll_direction === 'string') {
|
||||
secondaryInfo.push(`scroll ${input.scroll_direction}`);
|
||||
} else if (action === 'wait' && typeof input.duration === 'number') {
|
||||
secondaryInfo.push(`wait ${input.duration}s`)
|
||||
secondaryInfo.push(`wait ${input.duration}s`);
|
||||
} else if (action === 'left_click_drag') {
|
||||
secondaryInfo.push('drag')
|
||||
secondaryInfo.push('drag');
|
||||
} else {
|
||||
secondaryInfo.push(action)
|
||||
secondaryInfo.push(action);
|
||||
}
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'gif_creator':
|
||||
if (typeof input.action === 'string') {
|
||||
secondaryInfo.push(`${input.action}`)
|
||||
secondaryInfo.push(`${input.action}`);
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'resize_window':
|
||||
if (typeof input.width === 'number' && typeof input.height === 'number') {
|
||||
secondaryInfo.push(`${input.width}x${input.height}`)
|
||||
secondaryInfo.push(`${input.width}x${input.height}`);
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'read_console_messages':
|
||||
if (typeof input.pattern === 'string') {
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`)
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`);
|
||||
}
|
||||
if (input.onlyErrors === true) {
|
||||
secondaryInfo.push('errors only')
|
||||
secondaryInfo.push('errors only');
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'read_network_requests':
|
||||
if (typeof input.urlPattern === 'string') {
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`)
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`);
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'shortcuts_execute':
|
||||
if (typeof input.shortcutId === 'string') {
|
||||
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`)
|
||||
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`);
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 'javascript_tool':
|
||||
// In verbose mode, show the full code
|
||||
if (verbose && typeof input.text === 'string') {
|
||||
return input.text
|
||||
return input.text;
|
||||
}
|
||||
// In non-verbose mode, return empty string to preserve View Tab layout
|
||||
return ''
|
||||
return '';
|
||||
|
||||
case 'tabs_create_mcp':
|
||||
case 'tabs_context_mcp':
|
||||
@@ -151,10 +148,10 @@ function renderChromeToolUseMessage(
|
||||
case 'update_plan':
|
||||
// These tools don't have meaningful secondary info to show inline.
|
||||
// Return empty string (not null) to ensure tool header still renders.
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
return secondaryInfo.join(', ') || null
|
||||
return secondaryInfo.join(', ') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,21 +163,17 @@ function renderChromeToolUseMessage(
|
||||
*/
|
||||
function renderChromeViewTabLink(input: unknown): React.ReactNode {
|
||||
if (!supportsHyperlinks()) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
if (typeof input !== 'object' || input === null || !('tabId' in input)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const tabId =
|
||||
typeof input.tabId === 'number'
|
||||
? input.tabId
|
||||
: typeof input.tabId === 'string'
|
||||
? parseInt(input.tabId, 10)
|
||||
: NaN
|
||||
typeof input.tabId === 'number' ? input.tabId : typeof input.tabId === 'string' ? parseInt(input.tabId, 10) : NaN;
|
||||
if (isNaN(tabId)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`
|
||||
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`;
|
||||
return (
|
||||
<Text>
|
||||
{' '}
|
||||
@@ -188,7 +181,7 @@ function renderChromeViewTabLink(input: unknown): React.ReactNode {
|
||||
<Text color="subtle">[View Tab]</Text>
|
||||
</Link>
|
||||
</Text>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,62 +195,62 @@ export function renderChromeToolResultMessage(
|
||||
verbose: boolean,
|
||||
): React.ReactNode {
|
||||
if (verbose) {
|
||||
return renderDefaultMCPToolResultMessage(output, [], { verbose })
|
||||
return renderDefaultMCPToolResultMessage(output, [], { verbose });
|
||||
}
|
||||
|
||||
let summary: string | null = null
|
||||
let summary: string | null = null;
|
||||
switch (toolName) {
|
||||
case 'navigate':
|
||||
summary = 'Navigation completed'
|
||||
break
|
||||
summary = 'Navigation completed';
|
||||
break;
|
||||
case 'tabs_create_mcp':
|
||||
summary = 'Tab created'
|
||||
break
|
||||
summary = 'Tab created';
|
||||
break;
|
||||
case 'tabs_context_mcp':
|
||||
summary = 'Tabs read'
|
||||
break
|
||||
summary = 'Tabs read';
|
||||
break;
|
||||
case 'form_input':
|
||||
summary = 'Input completed'
|
||||
break
|
||||
summary = 'Input completed';
|
||||
break;
|
||||
case 'computer':
|
||||
summary = 'Action completed'
|
||||
break
|
||||
summary = 'Action completed';
|
||||
break;
|
||||
case 'resize_window':
|
||||
summary = 'Window resized'
|
||||
break
|
||||
summary = 'Window resized';
|
||||
break;
|
||||
case 'find':
|
||||
summary = 'Search completed'
|
||||
break
|
||||
summary = 'Search completed';
|
||||
break;
|
||||
case 'gif_creator':
|
||||
summary = 'GIF action completed'
|
||||
break
|
||||
summary = 'GIF action completed';
|
||||
break;
|
||||
case 'read_console_messages':
|
||||
summary = 'Console messages retrieved'
|
||||
break
|
||||
summary = 'Console messages retrieved';
|
||||
break;
|
||||
case 'read_network_requests':
|
||||
summary = 'Network requests retrieved'
|
||||
break
|
||||
summary = 'Network requests retrieved';
|
||||
break;
|
||||
case 'shortcuts_list':
|
||||
summary = 'Shortcuts retrieved'
|
||||
break
|
||||
summary = 'Shortcuts retrieved';
|
||||
break;
|
||||
case 'shortcuts_execute':
|
||||
summary = 'Shortcut executed'
|
||||
break
|
||||
summary = 'Shortcut executed';
|
||||
break;
|
||||
case 'javascript_tool':
|
||||
summary = 'Script executed'
|
||||
break
|
||||
summary = 'Script executed';
|
||||
break;
|
||||
case 'read_page':
|
||||
summary = 'Page read'
|
||||
break
|
||||
summary = 'Page read';
|
||||
break;
|
||||
case 'upload_image':
|
||||
summary = 'Image uploaded'
|
||||
break
|
||||
summary = 'Image uploaded';
|
||||
break;
|
||||
case 'get_page_text':
|
||||
summary = 'Page text retrieved'
|
||||
break
|
||||
summary = 'Page text retrieved';
|
||||
break;
|
||||
case 'update_plan':
|
||||
summary = 'Plan updated'
|
||||
break
|
||||
summary = 'Plan updated';
|
||||
break;
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
@@ -265,10 +258,10 @@ export function renderChromeToolResultMessage(
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,36 +269,26 @@ export function renderChromeToolResultMessage(
|
||||
* rendering for chrome tools in a single spread operation.
|
||||
*/
|
||||
export function getClaudeInChromeMCPToolOverrides(toolName: string): {
|
||||
userFacingName: (input?: Record<string, unknown>) => string
|
||||
renderToolUseMessage: (
|
||||
input: Record<string, unknown>,
|
||||
options: { verbose: boolean },
|
||||
) => React.ReactNode
|
||||
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode
|
||||
userFacingName: (input?: Record<string, unknown>) => string;
|
||||
renderToolUseMessage: (input: Record<string, unknown>, options: { verbose: boolean }) => React.ReactNode;
|
||||
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode;
|
||||
renderToolResultMessage: (
|
||||
output: string | MCPToolResult,
|
||||
progressMessagesForMessage: unknown[],
|
||||
options: { verbose: boolean },
|
||||
) => React.ReactNode
|
||||
) => React.ReactNode;
|
||||
} {
|
||||
return {
|
||||
userFacingName(_input?: Record<string, unknown>) {
|
||||
// Trim the _mcp postfix that show up in some of the tool names
|
||||
const displayName = toolName.replace(/_mcp$/, '')
|
||||
return `Claude in Chrome[${displayName}]`
|
||||
const displayName = toolName.replace(/_mcp$/, '');
|
||||
return `Claude in Chrome[${displayName}]`;
|
||||
},
|
||||
renderToolUseMessage(
|
||||
input: Record<string, unknown>,
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
return renderChromeToolUseMessage(
|
||||
input,
|
||||
toolName as ChromeToolName,
|
||||
verbose,
|
||||
)
|
||||
renderToolUseMessage(input: Record<string, unknown>, { verbose }: { verbose: boolean }): React.ReactNode {
|
||||
return renderChromeToolUseMessage(input, toolName as ChromeToolName, verbose);
|
||||
},
|
||||
renderToolUseTag(input: Partial<Record<string, unknown>>): React.ReactNode {
|
||||
return renderChromeViewTabLink(input)
|
||||
return renderChromeViewTabLink(input);
|
||||
},
|
||||
renderToolResultMessage(
|
||||
output: string | MCPToolResult,
|
||||
@@ -313,19 +296,13 @@ export function getClaudeInChromeMCPToolOverrides(toolName: string): {
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
if (!isMCPToolResult(output)) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return renderChromeToolResultMessage(
|
||||
output,
|
||||
toolName as ChromeToolName,
|
||||
verbose,
|
||||
)
|
||||
return renderChromeToolResultMessage(output, toolName as ChromeToolName, verbose);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isMCPToolResult(
|
||||
output: string | MCPToolResult,
|
||||
): output is MCPToolResult {
|
||||
return typeof output === 'object' && output !== null
|
||||
function isMCPToolResult(output: string | MCPToolResult): output is MCPToolResult {
|
||||
return typeof output === 'object' && output !== null;
|
||||
}
|
||||
|
||||
@@ -1442,10 +1442,7 @@ export function isMemoryFilePath(filePath: string): boolean {
|
||||
}
|
||||
|
||||
// .md files in .claude/rules/ directories
|
||||
if (
|
||||
name.endsWith('.md') &&
|
||||
normalizedPath.includes('/.claude/rules/')
|
||||
) {
|
||||
if (name.endsWith('.md') && normalizedPath.includes('/.claude/rules/')) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,18 @@ export type CliHighlight = {
|
||||
// One promise shared by Fallback.tsx, markdown.ts, events.ts, getLanguageName.
|
||||
let cliHighlightPromise: Promise<CliHighlight | null> | undefined
|
||||
|
||||
let loadedGetLanguage: ((name: string) => { name?: string } | undefined) | undefined
|
||||
let loadedGetLanguage:
|
||||
| ((name: string) => { name?: string } | undefined)
|
||||
| undefined
|
||||
|
||||
async function loadCliHighlight(): Promise<CliHighlight | null> {
|
||||
try {
|
||||
const cliHighlight = await import('cli-highlight')
|
||||
// highlight.js CJS interop: `export =` wraps in .default under ESM
|
||||
const hljsMod = hljs as { getLanguage?: typeof loadedGetLanguage; default?: typeof hljs }
|
||||
const hljsMod = hljs as {
|
||||
getLanguage?: typeof loadedGetLanguage
|
||||
default?: typeof hljs
|
||||
}
|
||||
loadedGetLanguage = hljsMod.getLanguage ?? hljsMod.default?.getLanguage
|
||||
return {
|
||||
highlight: cliHighlight.highlight,
|
||||
|
||||
@@ -15,8 +15,11 @@ function isCompletedBackgroundBash(
|
||||
msg: RenderableMessage,
|
||||
): msg is NormalizedUserMessage {
|
||||
if (msg.type !== 'user') return false
|
||||
const content0 = Array.isArray(msg.message.content) ? msg.message.content[0] : undefined
|
||||
if (!content0 || typeof content0 === 'string' || content0?.type !== 'text') return false
|
||||
const content0 = Array.isArray(msg.message.content)
|
||||
? msg.message.content[0]
|
||||
: undefined
|
||||
if (!content0 || typeof content0 === 'string' || content0?.type !== 'text')
|
||||
return false
|
||||
if (!content0.text.includes(`<${TASK_NOTIFICATION_TAG}`)) return false
|
||||
// Only collapse successful completions — failed/killed stay visible individually.
|
||||
if (extractTag(content0.text, STATUS_TAG) !== 'completed') return false
|
||||
|
||||
@@ -28,7 +28,9 @@ import type {
|
||||
* Safely get the first content item from a MessageContent value.
|
||||
* Returns undefined for string content or empty arrays.
|
||||
*/
|
||||
function getFirstContentItem(content: MessageContent | undefined): ContentItem | undefined {
|
||||
function getFirstContentItem(
|
||||
content: MessageContent | undefined,
|
||||
): ContentItem | undefined {
|
||||
if (!content || typeof content === 'string') return undefined
|
||||
return content[0]
|
||||
}
|
||||
@@ -325,16 +327,25 @@ function getCollapsibleToolInfo(
|
||||
if (msg.type === 'assistant') {
|
||||
const content = getFirstContentItem(msg.message?.content)
|
||||
if (!content) return null
|
||||
const info = getSearchOrReadFromContent(content as { type: string; name?: string; input?: unknown }, tools)
|
||||
const info = getSearchOrReadFromContent(
|
||||
content as { type: string; name?: string; input?: unknown },
|
||||
tools,
|
||||
)
|
||||
if (info && content.type === 'tool_use') {
|
||||
const toolUse = content as { type: 'tool_use'; name: string; input: unknown }
|
||||
const toolUse = content as {
|
||||
type: 'tool_use'
|
||||
name: string
|
||||
input: unknown
|
||||
}
|
||||
return { name: toolUse.name, input: toolUse.input, ...info }
|
||||
}
|
||||
}
|
||||
if (msg.type === 'grouped_tool_use') {
|
||||
// For grouped tool uses, check the first message's input
|
||||
const firstContent = getFirstContentItem(msg.messages[0]?.message?.content)
|
||||
const firstToolUse = firstContent as { type: string; input?: unknown } | undefined
|
||||
const firstToolUse = firstContent as
|
||||
| { type: string; input?: unknown }
|
||||
| undefined
|
||||
const info = getSearchOrReadFromContent(
|
||||
firstToolUse
|
||||
? { type: 'tool_use', name: msg.toolName, input: firstToolUse.input }
|
||||
@@ -354,7 +365,11 @@ function getCollapsibleToolInfo(
|
||||
function isTextBreaker(msg: RenderableMessage): boolean {
|
||||
if (msg.type === 'assistant') {
|
||||
const content = getFirstContentItem(msg.message?.content)
|
||||
if (content && content.type === 'text' && (content as { type: 'text'; text: string }).text.trim().length > 0) {
|
||||
if (
|
||||
content &&
|
||||
content.type === 'text' &&
|
||||
(content as { type: 'text'; text: string }).text.trim().length > 0
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -372,8 +387,13 @@ function isNonCollapsibleToolUse(
|
||||
if (msg.type === 'assistant') {
|
||||
const content = getFirstContentItem(msg.message?.content)
|
||||
if (
|
||||
content && content.type === 'tool_use' &&
|
||||
!isToolSearchOrRead((content as { name: string }).name, (content as { input: unknown }).input, tools)
|
||||
content &&
|
||||
content.type === 'tool_use' &&
|
||||
!isToolSearchOrRead(
|
||||
(content as { name: string }).name,
|
||||
(content as { input: unknown }).input,
|
||||
tools,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
@@ -381,8 +401,13 @@ function isNonCollapsibleToolUse(
|
||||
if (msg.type === 'grouped_tool_use') {
|
||||
const firstContent = getFirstContentItem(msg.messages[0]?.message?.content)
|
||||
if (
|
||||
firstContent && firstContent.type === 'tool_use' &&
|
||||
!isToolSearchOrRead(msg.toolName, (firstContent as { input: unknown }).input, tools)
|
||||
firstContent &&
|
||||
firstContent.type === 'tool_use' &&
|
||||
!isToolSearchOrRead(
|
||||
msg.toolName,
|
||||
(firstContent as { input: unknown }).input,
|
||||
tools,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
@@ -408,7 +433,10 @@ function shouldSkipMessage(msg: RenderableMessage): boolean {
|
||||
if (msg.type === 'assistant') {
|
||||
const content = getFirstContentItem(msg.message?.content)
|
||||
// Skip thinking blocks and other non-text, non-tool content
|
||||
if (content && (content.type === 'thinking' || content.type === 'redacted_thinking')) {
|
||||
if (
|
||||
content &&
|
||||
(content.type === 'thinking' || content.type === 'redacted_thinking')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -433,15 +461,25 @@ function isCollapsibleToolUse(
|
||||
if (msg.type === 'assistant') {
|
||||
const content = getFirstContentItem(msg.message?.content)
|
||||
return (
|
||||
content !== undefined && content.type === 'tool_use' &&
|
||||
isToolSearchOrRead((content as { name: string }).name, (content as { input: unknown }).input, tools)
|
||||
content !== undefined &&
|
||||
content.type === 'tool_use' &&
|
||||
isToolSearchOrRead(
|
||||
(content as { name: string }).name,
|
||||
(content as { input: unknown }).input,
|
||||
tools,
|
||||
)
|
||||
)
|
||||
}
|
||||
if (msg.type === 'grouped_tool_use') {
|
||||
const firstContent = getFirstContentItem(msg.messages[0]?.message?.content)
|
||||
return (
|
||||
firstContent !== undefined && firstContent.type === 'tool_use' &&
|
||||
isToolSearchOrRead(msg.toolName, (firstContent as { input: unknown }).input, tools)
|
||||
firstContent !== undefined &&
|
||||
firstContent.type === 'tool_use' &&
|
||||
isToolSearchOrRead(
|
||||
msg.toolName,
|
||||
(firstContent as { input: unknown }).input,
|
||||
tools,
|
||||
)
|
||||
)
|
||||
}
|
||||
return false
|
||||
@@ -552,7 +590,9 @@ function getFilePathsFromReadMessage(msg: RenderableMessage): string[] {
|
||||
if (msg.type === 'assistant') {
|
||||
const content = getFirstContentItem(msg.message?.content)
|
||||
if (content && content.type === 'tool_use') {
|
||||
const input = (content as { input: unknown }).input as { file_path?: string } | undefined
|
||||
const input = (content as { input: unknown }).input as
|
||||
| { file_path?: string }
|
||||
| undefined
|
||||
if (input?.file_path) {
|
||||
paths.push(input.file_path)
|
||||
}
|
||||
@@ -561,7 +601,9 @@ function getFilePathsFromReadMessage(msg: RenderableMessage): string[] {
|
||||
for (const m of msg.messages) {
|
||||
const content = getFirstContentItem(m.message?.content)
|
||||
if (content && content.type === 'tool_use') {
|
||||
const input = (content as { input: unknown }).input as { file_path?: string } | undefined
|
||||
const input = (content as { input: unknown }).input as
|
||||
| { file_path?: string }
|
||||
| undefined
|
||||
if (input?.file_path) {
|
||||
paths.push(input.file_path)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function registerEscHotkey(onEscape: () => void): boolean {
|
||||
export function unregisterEscHotkey(): void {
|
||||
if (!registered) return
|
||||
try {
|
||||
(requireComputerUseSwift() as any).hotkey?.unregister()
|
||||
;(requireComputerUseSwift() as any).hotkey?.unregister()
|
||||
} finally {
|
||||
releasePump()
|
||||
registered = false
|
||||
@@ -51,5 +51,5 @@ export function unregisterEscHotkey(): void {
|
||||
|
||||
export function notifyExpectedEscape(): void {
|
||||
if (!registered) return
|
||||
(requireComputerUseSwift() as any).hotkey?.notifyExpectedEscape()
|
||||
;(requireComputerUseSwift() as any).hotkey?.notifyExpectedEscape()
|
||||
}
|
||||
|
||||
@@ -69,18 +69,26 @@ function computeTargetDims(
|
||||
|
||||
async function readClipboardViaPbpaste(): Promise<string> {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout, code } = await execFileNoThrow('powershell', ['-NoProfile', '-Command', 'Get-Clipboard'], {
|
||||
useCwd: false,
|
||||
})
|
||||
const { stdout, code } = await execFileNoThrow(
|
||||
'powershell',
|
||||
['-NoProfile', '-Command', 'Get-Clipboard'],
|
||||
{
|
||||
useCwd: false,
|
||||
},
|
||||
)
|
||||
if (code !== 0) {
|
||||
throw new Error(`PowerShell Get-Clipboard exited with code ${code}`)
|
||||
}
|
||||
return stdout
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const { stdout, code } = await execFileNoThrow('xclip', ['-selection', 'clipboard', '-o'], {
|
||||
useCwd: false,
|
||||
})
|
||||
const { stdout, code } = await execFileNoThrow(
|
||||
'xclip',
|
||||
['-selection', 'clipboard', '-o'],
|
||||
{
|
||||
useCwd: false,
|
||||
},
|
||||
)
|
||||
if (code !== 0) {
|
||||
throw new Error(`xclip exited with code ${code}`)
|
||||
}
|
||||
@@ -97,19 +105,31 @@ async function readClipboardViaPbpaste(): Promise<string> {
|
||||
|
||||
async function writeClipboardViaPbcopy(text: string): Promise<void> {
|
||||
if (process.platform === 'win32') {
|
||||
const { code } = await execFileNoThrow('powershell', ['-NoProfile', '-Command', `Set-Clipboard -Value '${text.replace(/'/g, "''")}'`], {
|
||||
useCwd: false,
|
||||
})
|
||||
const { code } = await execFileNoThrow(
|
||||
'powershell',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Set-Clipboard -Value '${text.replace(/'/g, "''")}'`,
|
||||
],
|
||||
{
|
||||
useCwd: false,
|
||||
},
|
||||
)
|
||||
if (code !== 0) {
|
||||
throw new Error(`PowerShell Set-Clipboard exited with code ${code}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const { code } = await execFileNoThrow('xclip', ['-selection', 'clipboard'], {
|
||||
input: text,
|
||||
useCwd: false,
|
||||
})
|
||||
const { code } = await execFileNoThrow(
|
||||
'xclip',
|
||||
['-selection', 'clipboard'],
|
||||
{
|
||||
input: text,
|
||||
useCwd: false,
|
||||
},
|
||||
)
|
||||
if (code !== 0) {
|
||||
throw new Error(`xclip exited with code ${code}`)
|
||||
}
|
||||
@@ -301,7 +321,8 @@ export function createCliExecutor(opts: {
|
||||
// No macOS code paths, no drainRunLoop, no @ant packages.
|
||||
if (process.platform !== 'darwin') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { createCrossPlatformExecutor } = require('./executorCrossPlatform.js') as typeof import('./executorCrossPlatform.js')
|
||||
const { createCrossPlatformExecutor } =
|
||||
require('./executorCrossPlatform.js') as typeof import('./executorCrossPlatform.js')
|
||||
return createCrossPlatformExecutor(opts)
|
||||
}
|
||||
|
||||
@@ -428,7 +449,10 @@ export function createCliExecutor(opts: {
|
||||
// Ensure the result has fields expected by toolCalls.ts (hidden, displayId).
|
||||
// macOS native returns these from Swift; our cross-platform ComputerUseAPI
|
||||
// returns {base64, width, height} — fill in the missing fields.
|
||||
const baseResult = raw as Partial<ResolvePrepareCaptureResult> & { width?: number; height?: number }
|
||||
const baseResult = raw as Partial<ResolvePrepareCaptureResult> & {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
return {
|
||||
...raw,
|
||||
displayWidth: baseResult.displayWidth ?? baseResult.width,
|
||||
@@ -436,7 +460,8 @@ export function createCliExecutor(opts: {
|
||||
originX: baseResult.originX ?? 0,
|
||||
originY: baseResult.originY ?? 0,
|
||||
hidden: baseResult.hidden ?? [],
|
||||
displayId: baseResult.displayId ?? opts.preferredDisplayId ?? d.displayId,
|
||||
displayId:
|
||||
baseResult.displayId ?? opts.preferredDisplayId ?? d.displayId,
|
||||
} as ResolvePrepareCaptureResult
|
||||
},
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ class DebugLogger implements Logger {
|
||||
function checkAccessibilityJXA(): boolean {
|
||||
try {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', 'tell application "System Events" to get name of every process whose background only is false'],
|
||||
cmd: [
|
||||
'osascript',
|
||||
'-e',
|
||||
'tell application "System Events" to get name of every process whose background only is false',
|
||||
],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
@@ -71,7 +71,13 @@ const input: InputPlatform = {
|
||||
const screenshot: ScreenshotPlatform = {
|
||||
async captureScreen(displayId) {
|
||||
const swift = requireComputerUseSwift()
|
||||
return swift.screenshot.captureExcluding([], undefined, undefined, undefined, displayId)
|
||||
return swift.screenshot.captureExcluding(
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
displayId,
|
||||
)
|
||||
},
|
||||
|
||||
async captureRegion(x, y, w, h) {
|
||||
@@ -110,7 +116,7 @@ const apps: AppsPlatform = {
|
||||
const running = swift.apps.listRunning()
|
||||
return running.map((app: any) => ({
|
||||
id: app.bundleId ?? '',
|
||||
pid: 0, // macOS listRunning doesn't expose PID through this API
|
||||
pid: 0, // macOS listRunning doesn't expose PID through this API
|
||||
title: app.displayName ?? '',
|
||||
}))
|
||||
},
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
* Each backend implements the same unified interface.
|
||||
*/
|
||||
|
||||
import type { InputPlatform, ScreenshotPlatform, DisplayPlatform, AppsPlatform, WindowManagementPlatform } from './types.js'
|
||||
import type {
|
||||
InputPlatform,
|
||||
ScreenshotPlatform,
|
||||
DisplayPlatform,
|
||||
AppsPlatform,
|
||||
WindowManagementPlatform,
|
||||
} from './types.js'
|
||||
|
||||
export interface Platform {
|
||||
input: InputPlatform
|
||||
@@ -37,5 +43,18 @@ export function loadPlatform(): Platform {
|
||||
return cached!
|
||||
}
|
||||
|
||||
export type { InputPlatform, ScreenshotPlatform, DisplayPlatform, AppsPlatform, WindowManagementPlatform } from './types.js'
|
||||
export type { WindowHandle, ScreenshotResult, DisplayInfo, InstalledApp, FrontmostAppInfo, WindowAction } from './types.js'
|
||||
export type {
|
||||
InputPlatform,
|
||||
ScreenshotPlatform,
|
||||
DisplayPlatform,
|
||||
AppsPlatform,
|
||||
WindowManagementPlatform,
|
||||
} from './types.js'
|
||||
export type {
|
||||
WindowHandle,
|
||||
ScreenshotResult,
|
||||
DisplayInfo,
|
||||
InstalledApp,
|
||||
FrontmostAppInfo,
|
||||
WindowAction,
|
||||
} from './types.js'
|
||||
|
||||
@@ -41,7 +41,11 @@ async function runAsync(cmd: string[]): Promise<string> {
|
||||
}
|
||||
|
||||
function commandExists(name: string): boolean {
|
||||
const result = Bun.spawnSync({ cmd: ['which', name], stdout: 'pipe', stderr: 'pipe' })
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['which', name],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
@@ -50,23 +54,75 @@ function commandExists(name: string): boolean {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KEY_MAP: Record<string, string> = {
|
||||
return: 'Return', enter: 'Return', tab: 'Tab', space: 'space',
|
||||
backspace: 'BackSpace', delete: 'Delete', escape: 'Escape', esc: 'Escape',
|
||||
left: 'Left', up: 'Up', right: 'Right', down: 'Down',
|
||||
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
||||
f1: 'F1', f2: 'F2', f3: 'F3', f4: 'F4', f5: 'F5', f6: 'F6',
|
||||
f7: 'F7', f8: 'F8', f9: 'F9', f10: 'F10', f11: 'F11', f12: 'F12',
|
||||
shift: 'shift', lshift: 'shift', rshift: 'shift',
|
||||
control: 'ctrl', ctrl: 'ctrl', lcontrol: 'ctrl', rcontrol: 'ctrl',
|
||||
alt: 'alt', option: 'alt', lalt: 'alt', ralt: 'alt',
|
||||
win: 'super', meta: 'super', command: 'super', cmd: 'super', super: 'super',
|
||||
insert: 'Insert', printscreen: 'Print', pause: 'Pause',
|
||||
numlock: 'Num_Lock', capslock: 'Caps_Lock', scrolllock: 'Scroll_Lock',
|
||||
return: 'Return',
|
||||
enter: 'Return',
|
||||
tab: 'Tab',
|
||||
space: 'space',
|
||||
backspace: 'BackSpace',
|
||||
delete: 'Delete',
|
||||
escape: 'Escape',
|
||||
esc: 'Escape',
|
||||
left: 'Left',
|
||||
up: 'Up',
|
||||
right: 'Right',
|
||||
down: 'Down',
|
||||
home: 'Home',
|
||||
end: 'End',
|
||||
pageup: 'Prior',
|
||||
pagedown: 'Next',
|
||||
f1: 'F1',
|
||||
f2: 'F2',
|
||||
f3: 'F3',
|
||||
f4: 'F4',
|
||||
f5: 'F5',
|
||||
f6: 'F6',
|
||||
f7: 'F7',
|
||||
f8: 'F8',
|
||||
f9: 'F9',
|
||||
f10: 'F10',
|
||||
f11: 'F11',
|
||||
f12: 'F12',
|
||||
shift: 'shift',
|
||||
lshift: 'shift',
|
||||
rshift: 'shift',
|
||||
control: 'ctrl',
|
||||
ctrl: 'ctrl',
|
||||
lcontrol: 'ctrl',
|
||||
rcontrol: 'ctrl',
|
||||
alt: 'alt',
|
||||
option: 'alt',
|
||||
lalt: 'alt',
|
||||
ralt: 'alt',
|
||||
win: 'super',
|
||||
meta: 'super',
|
||||
command: 'super',
|
||||
cmd: 'super',
|
||||
super: 'super',
|
||||
insert: 'Insert',
|
||||
printscreen: 'Print',
|
||||
pause: 'Pause',
|
||||
numlock: 'Num_Lock',
|
||||
capslock: 'Caps_Lock',
|
||||
scrolllock: 'Scroll_Lock',
|
||||
}
|
||||
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'shift', 'lshift', 'rshift', 'control', 'ctrl', 'lcontrol', 'rcontrol',
|
||||
'alt', 'option', 'lalt', 'ralt', 'win', 'meta', 'command', 'cmd', 'super',
|
||||
'shift',
|
||||
'lshift',
|
||||
'rshift',
|
||||
'control',
|
||||
'ctrl',
|
||||
'lcontrol',
|
||||
'rcontrol',
|
||||
'alt',
|
||||
'option',
|
||||
'lalt',
|
||||
'ralt',
|
||||
'win',
|
||||
'meta',
|
||||
'command',
|
||||
'cmd',
|
||||
'super',
|
||||
])
|
||||
|
||||
function mapKey(name: string): string {
|
||||
@@ -83,11 +139,23 @@ function mouseButtonNum(button: 'left' | 'right' | 'middle'): string {
|
||||
|
||||
const input: InputPlatform = {
|
||||
async moveMouse(x, y) {
|
||||
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
|
||||
run([
|
||||
'xdotool',
|
||||
'mousemove',
|
||||
'--sync',
|
||||
String(Math.round(x)),
|
||||
String(Math.round(y)),
|
||||
])
|
||||
},
|
||||
|
||||
async click(x, y, button) {
|
||||
run(['xdotool', 'mousemove', '--sync', String(Math.round(x)), String(Math.round(y))])
|
||||
run([
|
||||
'xdotool',
|
||||
'mousemove',
|
||||
'--sync',
|
||||
String(Math.round(x)),
|
||||
String(Math.round(y)),
|
||||
])
|
||||
run(['xdotool', 'click', mouseButtonNum(button)])
|
||||
},
|
||||
|
||||
@@ -125,11 +193,13 @@ const input: InputPlatform = {
|
||||
if (direction === 'vertical') {
|
||||
const btn = amount >= 0 ? '5' : '4'
|
||||
const repeats = Math.abs(Math.round(amount))
|
||||
if (repeats > 0) run(['xdotool', 'click', '--repeat', String(repeats), btn])
|
||||
if (repeats > 0)
|
||||
run(['xdotool', 'click', '--repeat', String(repeats), btn])
|
||||
} else {
|
||||
const btn = amount >= 0 ? '7' : '6'
|
||||
const repeats = Math.abs(Math.round(amount))
|
||||
if (repeats > 0) run(['xdotool', 'click', '--repeat', String(repeats), btn])
|
||||
if (repeats > 0)
|
||||
run(['xdotool', 'click', '--repeat', String(repeats), btn])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -153,7 +223,11 @@ const input: InputPlatform = {
|
||||
const SCREENSHOT_TMP = '/tmp/cu-screenshot-tmp.png'
|
||||
const SCREENSHOT_JPG = '/tmp/cu-screenshot.jpg'
|
||||
|
||||
async function pngToJpegBase64(pngPath: string, width: number, height: number): Promise<ScreenshotResult> {
|
||||
async function pngToJpegBase64(
|
||||
pngPath: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<ScreenshotResult> {
|
||||
// Try ImageMagick convert first
|
||||
if (commandExists('convert')) {
|
||||
await runAsync(['convert', pngPath, '-quality', '75', SCREENSHOT_JPG])
|
||||
@@ -189,7 +263,13 @@ const screenshot: ScreenshotPlatform = {
|
||||
|
||||
async captureRegion(x, y, w, h) {
|
||||
try {
|
||||
await runAsync(['scrot', '-a', `${x},${y},${w},${h}`, '-o', SCREENSHOT_TMP])
|
||||
await runAsync([
|
||||
'scrot',
|
||||
'-a',
|
||||
`${x},${y},${w},${h}`,
|
||||
'-o',
|
||||
SCREENSHOT_TMP,
|
||||
])
|
||||
return pngToJpegBase64(SCREENSHOT_TMP, w, h)
|
||||
} catch {
|
||||
return { base64: '', width: w, height: h }
|
||||
@@ -282,7 +362,9 @@ const apps: AppsPlatform = {
|
||||
const title = parts.slice(4).join(' ')
|
||||
|
||||
let exePath = ''
|
||||
try { exePath = run(['readlink', '-f', `/proc/${pid}/exe`]) } catch {}
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pid}/exe`])
|
||||
} catch {}
|
||||
|
||||
handles.push({
|
||||
id: windowId ?? '',
|
||||
@@ -294,11 +376,13 @@ const apps: AppsPlatform = {
|
||||
|
||||
// Deduplicate by id
|
||||
const seen = new Set<string>()
|
||||
return handles.filter(h => {
|
||||
if (seen.has(h.id)) return false
|
||||
seen.add(h.id)
|
||||
return true
|
||||
}).slice(0, 50)
|
||||
return handles
|
||||
.filter(h => {
|
||||
if (seen.has(h.id)) return false
|
||||
seen.add(h.id)
|
||||
return true
|
||||
})
|
||||
.slice(0, 50)
|
||||
}
|
||||
|
||||
// Fallback: xdotool search
|
||||
@@ -307,7 +391,9 @@ const apps: AppsPlatform = {
|
||||
for (const windowId of raw.split('\n').filter(Boolean).slice(0, 50)) {
|
||||
const title = run(['xdotool', 'getwindowname', windowId])
|
||||
let pid = 0
|
||||
try { pid = Number(run(['xdotool', 'getwindowpid', windowId])) } catch {}
|
||||
try {
|
||||
pid = Number(run(['xdotool', 'getwindowpid', windowId]))
|
||||
} catch {}
|
||||
if (title) {
|
||||
handles.push({ id: windowId, pid, title })
|
||||
}
|
||||
@@ -331,7 +417,9 @@ const apps: AppsPlatform = {
|
||||
let files: string
|
||||
try {
|
||||
files = run(['find', dir, '-name', '*.desktop', '-maxdepth', '1'])
|
||||
} catch { continue }
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const filepath of files.split('\n').filter(Boolean)) {
|
||||
try {
|
||||
@@ -350,7 +438,9 @@ const apps: AppsPlatform = {
|
||||
displayName: name,
|
||||
path: exec.split(/\s+/)[0] ?? '',
|
||||
})
|
||||
} catch { /* skip unreadable */ }
|
||||
} catch {
|
||||
/* skip unreadable */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +457,9 @@ const apps: AppsPlatform = {
|
||||
await runAsync(['gtk-launch', desktopName])
|
||||
return
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
await runAsync(['xdg-open', name])
|
||||
},
|
||||
|
||||
@@ -380,12 +472,19 @@ const apps: AppsPlatform = {
|
||||
if (!pidStr) return null
|
||||
|
||||
let exePath = ''
|
||||
try { exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`]) } catch {}
|
||||
try {
|
||||
exePath = run(['readlink', '-f', `/proc/${pidStr}/exe`])
|
||||
} catch {}
|
||||
let appName = ''
|
||||
try { appName = run(['cat', `/proc/${pidStr}/comm`]) } catch {}
|
||||
try {
|
||||
appName = run(['cat', `/proc/${pidStr}/comm`])
|
||||
} catch {}
|
||||
|
||||
if (!exePath && !appName) return null
|
||||
return { id: exePath || `/proc/${pidStr}/exe`, appName: appName || 'unknown' }
|
||||
return {
|
||||
id: exePath || `/proc/${pidStr}/exe`,
|
||||
appName: appName || 'unknown',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -400,7 +499,9 @@ const apps: AppsPlatform = {
|
||||
|
||||
const windowTitle = run(['xdotool', 'getwindowname', windowId])
|
||||
let pid = 0
|
||||
try { pid = Number(run(['xdotool', 'getwindowpid', windowId])) } catch {}
|
||||
try {
|
||||
pid = Number(run(['xdotool', 'getwindowpid', windowId]))
|
||||
} catch {}
|
||||
|
||||
return { id: windowId, pid, title: windowTitle }
|
||||
} catch {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user