mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add session management (v0.10.0)
- Add ISessionStorage interface and RedisSessionStorage implementation - Add ContextManager for token budget and compression - Add StartSession, HandleMessage, UndoChange use cases - Update CHANGELOG and TODO documentation - 88 new tests (1174 total), 97.73% coverage
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { RedisSessionStorage } from "../../../../src/infrastructure/storage/RedisSessionStorage.js"
|
||||
import { RedisClient } from "../../../../src/infrastructure/storage/RedisClient.js"
|
||||
import { Session } from "../../../../src/domain/entities/Session.js"
|
||||
import type { UndoEntry } from "../../../../src/domain/value-objects/UndoEntry.js"
|
||||
import { SessionKeys, SessionFields } from "../../../../src/infrastructure/storage/schema.js"
|
||||
|
||||
describe("RedisSessionStorage", () => {
|
||||
let storage: RedisSessionStorage
|
||||
let mockRedis: {
|
||||
hset: ReturnType<typeof vi.fn>
|
||||
hget: ReturnType<typeof vi.fn>
|
||||
hgetall: ReturnType<typeof vi.fn>
|
||||
del: ReturnType<typeof vi.fn>
|
||||
lrange: ReturnType<typeof vi.fn>
|
||||
lpush: ReturnType<typeof vi.fn>
|
||||
lpos: ReturnType<typeof vi.fn>
|
||||
lrem: ReturnType<typeof vi.fn>
|
||||
rpush: ReturnType<typeof vi.fn>
|
||||
rpop: ReturnType<typeof vi.fn>
|
||||
llen: ReturnType<typeof vi.fn>
|
||||
lpop: ReturnType<typeof vi.fn>
|
||||
exists: ReturnType<typeof vi.fn>
|
||||
pipeline: ReturnType<typeof vi.fn>
|
||||
}
|
||||
let mockClient: RedisClient
|
||||
|
||||
beforeEach(() => {
|
||||
mockRedis = {
|
||||
hset: vi.fn().mockResolvedValue(1),
|
||||
hget: vi.fn().mockResolvedValue(null),
|
||||
hgetall: vi.fn().mockResolvedValue({}),
|
||||
del: vi.fn().mockResolvedValue(1),
|
||||
lrange: vi.fn().mockResolvedValue([]),
|
||||
lpush: vi.fn().mockResolvedValue(1),
|
||||
lpos: vi.fn().mockResolvedValue(null),
|
||||
lrem: vi.fn().mockResolvedValue(1),
|
||||
rpush: vi.fn().mockResolvedValue(1),
|
||||
rpop: vi.fn().mockResolvedValue(null),
|
||||
llen: vi.fn().mockResolvedValue(0),
|
||||
lpop: vi.fn().mockResolvedValue(null),
|
||||
exists: vi.fn().mockResolvedValue(0),
|
||||
pipeline: vi.fn().mockReturnValue({
|
||||
hset: vi.fn().mockReturnThis(),
|
||||
del: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}
|
||||
|
||||
mockClient = {
|
||||
getClient: () => mockRedis,
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
} as unknown as RedisClient
|
||||
|
||||
storage = new RedisSessionStorage(mockClient)
|
||||
})
|
||||
|
||||
describe("saveSession", () => {
|
||||
it("should save session data to Redis", async () => {
|
||||
const session = new Session("test-session-1", "test-project")
|
||||
session.history = [{ role: "user", content: "Hello", timestamp: Date.now() }]
|
||||
|
||||
await storage.saveSession(session)
|
||||
|
||||
const pipeline = mockRedis.pipeline()
|
||||
expect(pipeline.hset).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should add session to list if not exists", async () => {
|
||||
const session = new Session("test-session-2", "test-project")
|
||||
|
||||
await storage.saveSession(session)
|
||||
|
||||
expect(mockRedis.lpos).toHaveBeenCalledWith(SessionKeys.list, "test-session-2")
|
||||
expect(mockRedis.lpush).toHaveBeenCalledWith(SessionKeys.list, "test-session-2")
|
||||
})
|
||||
|
||||
it("should not add session to list if already exists", async () => {
|
||||
const session = new Session("existing-session", "test-project")
|
||||
mockRedis.lpos.mockResolvedValue(0)
|
||||
|
||||
await storage.saveSession(session)
|
||||
|
||||
expect(mockRedis.lpush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadSession", () => {
|
||||
it("should return null for non-existent session", async () => {
|
||||
mockRedis.hgetall.mockResolvedValue({})
|
||||
|
||||
const result = await storage.loadSession("non-existent")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should load session from Redis", async () => {
|
||||
const sessionData = {
|
||||
[SessionFields.projectName]: "test-project",
|
||||
[SessionFields.createdAt]: "1700000000000",
|
||||
[SessionFields.lastActivityAt]: "1700001000000",
|
||||
[SessionFields.history]: "[]",
|
||||
[SessionFields.context]: JSON.stringify({
|
||||
filesInContext: [],
|
||||
tokenUsage: 0,
|
||||
needsCompression: false,
|
||||
}),
|
||||
[SessionFields.stats]: JSON.stringify({
|
||||
totalTokens: 0,
|
||||
totalTimeMs: 0,
|
||||
toolCalls: 0,
|
||||
editsApplied: 0,
|
||||
editsRejected: 0,
|
||||
}),
|
||||
[SessionFields.inputHistory]: "[]",
|
||||
}
|
||||
mockRedis.hgetall.mockResolvedValue(sessionData)
|
||||
mockRedis.lrange.mockResolvedValue([])
|
||||
|
||||
const result = await storage.loadSession("test-session")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe("test-session")
|
||||
expect(result?.projectName).toBe("test-project")
|
||||
expect(result?.createdAt).toBe(1700000000000)
|
||||
})
|
||||
|
||||
it("should load undo stack with session", async () => {
|
||||
const sessionData = {
|
||||
[SessionFields.projectName]: "test-project",
|
||||
[SessionFields.createdAt]: "1700000000000",
|
||||
[SessionFields.lastActivityAt]: "1700001000000",
|
||||
[SessionFields.history]: "[]",
|
||||
[SessionFields.context]: "{}",
|
||||
[SessionFields.stats]: "{}",
|
||||
[SessionFields.inputHistory]: "[]",
|
||||
}
|
||||
const undoEntry: UndoEntry = {
|
||||
id: "undo-1",
|
||||
timestamp: Date.now(),
|
||||
filePath: "test.ts",
|
||||
previousContent: ["old"],
|
||||
newContent: ["new"],
|
||||
description: "Edit",
|
||||
}
|
||||
mockRedis.hgetall.mockResolvedValue(sessionData)
|
||||
mockRedis.lrange.mockResolvedValue([JSON.stringify(undoEntry)])
|
||||
|
||||
const result = await storage.loadSession("test-session")
|
||||
|
||||
expect(result?.undoStack).toHaveLength(1)
|
||||
expect(result?.undoStack[0].id).toBe("undo-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteSession", () => {
|
||||
it("should delete session data and undo stack", async () => {
|
||||
await storage.deleteSession("test-session")
|
||||
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.data("test-session"))
|
||||
expect(mockRedis.del).toHaveBeenCalledWith(SessionKeys.undo("test-session"))
|
||||
expect(mockRedis.lrem).toHaveBeenCalledWith(SessionKeys.list, 0, "test-session")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listSessions", () => {
|
||||
it("should return empty array when no sessions", async () => {
|
||||
mockRedis.lrange.mockResolvedValue([])
|
||||
|
||||
const result = await storage.listSessions()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should list all sessions", async () => {
|
||||
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
|
||||
mockRedis.hgetall.mockImplementation((key: string) => {
|
||||
if (key.includes("session-1")) {
|
||||
return Promise.resolve({
|
||||
[SessionFields.projectName]: "project-1",
|
||||
[SessionFields.createdAt]: "1700000000000",
|
||||
[SessionFields.lastActivityAt]: "1700001000000",
|
||||
[SessionFields.history]: "[]",
|
||||
})
|
||||
}
|
||||
if (key.includes("session-2")) {
|
||||
return Promise.resolve({
|
||||
[SessionFields.projectName]: "project-2",
|
||||
[SessionFields.createdAt]: "1700002000000",
|
||||
[SessionFields.lastActivityAt]: "1700003000000",
|
||||
[SessionFields.history]: '[{"role":"user","content":"Hi"}]',
|
||||
})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const result = await storage.listSessions()
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("session-2")
|
||||
expect(result[1].id).toBe("session-1")
|
||||
})
|
||||
|
||||
it("should filter by project name", async () => {
|
||||
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
|
||||
mockRedis.hgetall.mockImplementation((key: string) => {
|
||||
if (key.includes("session-1")) {
|
||||
return Promise.resolve({
|
||||
[SessionFields.projectName]: "project-1",
|
||||
[SessionFields.createdAt]: "1700000000000",
|
||||
[SessionFields.lastActivityAt]: "1700001000000",
|
||||
[SessionFields.history]: "[]",
|
||||
})
|
||||
}
|
||||
if (key.includes("session-2")) {
|
||||
return Promise.resolve({
|
||||
[SessionFields.projectName]: "project-2",
|
||||
[SessionFields.createdAt]: "1700002000000",
|
||||
[SessionFields.lastActivityAt]: "1700003000000",
|
||||
[SessionFields.history]: "[]",
|
||||
})
|
||||
}
|
||||
return Promise.resolve({})
|
||||
})
|
||||
|
||||
const result = await storage.listSessions("project-1")
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].projectName).toBe("project-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLatestSession", () => {
|
||||
it("should return null when no sessions", async () => {
|
||||
mockRedis.lrange.mockResolvedValue([])
|
||||
|
||||
const result = await storage.getLatestSession("test-project")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return the most recent session", async () => {
|
||||
mockRedis.lrange.mockImplementation((key: string) => {
|
||||
if (key === SessionKeys.list) {
|
||||
return Promise.resolve(["session-1"])
|
||||
}
|
||||
return Promise.resolve([])
|
||||
})
|
||||
mockRedis.hgetall.mockResolvedValue({
|
||||
[SessionFields.projectName]: "test-project",
|
||||
[SessionFields.createdAt]: "1700000000000",
|
||||
[SessionFields.lastActivityAt]: "1700001000000",
|
||||
[SessionFields.history]: "[]",
|
||||
[SessionFields.context]: "{}",
|
||||
[SessionFields.stats]: "{}",
|
||||
[SessionFields.inputHistory]: "[]",
|
||||
})
|
||||
|
||||
const result = await storage.getLatestSession("test-project")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe("session-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionExists", () => {
|
||||
it("should return false for non-existent session", async () => {
|
||||
mockRedis.exists.mockResolvedValue(0)
|
||||
|
||||
const result = await storage.sessionExists("non-existent")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for existing session", async () => {
|
||||
mockRedis.exists.mockResolvedValue(1)
|
||||
|
||||
const result = await storage.sessionExists("existing")
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("undo stack operations", () => {
|
||||
const undoEntry: UndoEntry = {
|
||||
id: "undo-1",
|
||||
timestamp: Date.now(),
|
||||
filePath: "test.ts",
|
||||
previousContent: ["old"],
|
||||
newContent: ["new"],
|
||||
description: "Edit",
|
||||
}
|
||||
|
||||
describe("pushUndoEntry", () => {
|
||||
it("should push undo entry to stack", async () => {
|
||||
mockRedis.llen.mockResolvedValue(1)
|
||||
|
||||
await storage.pushUndoEntry("session-1", undoEntry)
|
||||
|
||||
expect(mockRedis.rpush).toHaveBeenCalledWith(
|
||||
SessionKeys.undo("session-1"),
|
||||
JSON.stringify(undoEntry),
|
||||
)
|
||||
})
|
||||
|
||||
it("should remove oldest entry when stack exceeds limit", async () => {
|
||||
mockRedis.llen.mockResolvedValue(11)
|
||||
|
||||
await storage.pushUndoEntry("session-1", undoEntry)
|
||||
|
||||
expect(mockRedis.lpop).toHaveBeenCalledWith(SessionKeys.undo("session-1"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("popUndoEntry", () => {
|
||||
it("should return null for empty stack", async () => {
|
||||
mockRedis.rpop.mockResolvedValue(null)
|
||||
|
||||
const result = await storage.popUndoEntry("session-1")
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should pop and return undo entry", async () => {
|
||||
mockRedis.rpop.mockResolvedValue(JSON.stringify(undoEntry))
|
||||
|
||||
const result = await storage.popUndoEntry("session-1")
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.id).toBe("undo-1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUndoStack", () => {
|
||||
it("should return empty array for empty stack", async () => {
|
||||
mockRedis.lrange.mockResolvedValue([])
|
||||
|
||||
const result = await storage.getUndoStack("session-1")
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("should return all undo entries", async () => {
|
||||
mockRedis.lrange.mockResolvedValue([
|
||||
JSON.stringify({ ...undoEntry, id: "undo-1" }),
|
||||
JSON.stringify({ ...undoEntry, id: "undo-2" }),
|
||||
])
|
||||
|
||||
const result = await storage.getUndoStack("session-1")
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].id).toBe("undo-1")
|
||||
expect(result[1].id).toBe("undo-2")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("touchSession", () => {
|
||||
it("should update last activity timestamp", async () => {
|
||||
const beforeTouch = Date.now()
|
||||
|
||||
await storage.touchSession("session-1")
|
||||
|
||||
expect(mockRedis.hset).toHaveBeenCalledWith(
|
||||
SessionKeys.data("session-1"),
|
||||
SessionFields.lastActivityAt,
|
||||
expect.any(String),
|
||||
)
|
||||
|
||||
const callArgs = mockRedis.hset.mock.calls[0]
|
||||
const timestamp = Number(callArgs[2])
|
||||
expect(timestamp).toBeGreaterThanOrEqual(beforeTouch)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clearAllSessions", () => {
|
||||
it("should clear all session data", async () => {
|
||||
mockRedis.lrange.mockResolvedValue(["session-1", "session-2"])
|
||||
|
||||
await storage.clearAllSessions()
|
||||
|
||||
const pipeline = mockRedis.pipeline()
|
||||
expect(pipeline.del).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -35,10 +35,7 @@ function createMockStorage(): IStorage {
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult: boolean = true,
|
||||
): ToolContext {
|
||||
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
@@ -47,9 +44,7 @@ function createMockContext(
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStatusResult(
|
||||
overrides: Partial<StatusResult> = {},
|
||||
): StatusResult {
|
||||
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
|
||||
return {
|
||||
not_added: [],
|
||||
conflicted: [],
|
||||
@@ -70,9 +65,7 @@ function createMockStatusResult(
|
||||
} as StatusResult
|
||||
}
|
||||
|
||||
function createMockCommitResult(
|
||||
overrides: Partial<CommitResult> = {},
|
||||
): CommitResult {
|
||||
function createMockCommitResult(overrides: Partial<CommitResult> = {}): CommitResult {
|
||||
return {
|
||||
commit: "abc1234",
|
||||
branch: "main",
|
||||
@@ -96,9 +89,7 @@ function createMockGit(options: {
|
||||
}): SimpleGit {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||
status: vi.fn().mockResolvedValue(
|
||||
options.status ?? createMockStatusResult(),
|
||||
),
|
||||
status: vi.fn().mockResolvedValue(options.status ?? createMockStatusResult()),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
}
|
||||
@@ -112,9 +103,7 @@ function createMockGit(options: {
|
||||
if (options.error) {
|
||||
mockGit.commit.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.commit.mockResolvedValue(
|
||||
options.commitResult ?? createMockCommitResult(),
|
||||
)
|
||||
mockGit.commit.mockResolvedValue(options.commitResult ?? createMockCommitResult())
|
||||
}
|
||||
|
||||
return mockGit as unknown as SimpleGit
|
||||
@@ -175,21 +164,15 @@ describe("GitCommitTool", () => {
|
||||
})
|
||||
|
||||
it("should return null for valid message with files", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }),
|
||||
).toBeNull()
|
||||
expect(tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-array files", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: "a.ts" }),
|
||||
).toContain("array")
|
||||
expect(tool.validateParams({ message: "fix: bug", files: "a.ts" })).toContain("array")
|
||||
})
|
||||
|
||||
it("should return error for non-string in files array", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: [1, 2] }),
|
||||
).toContain("strings")
|
||||
expect(tool.validateParams({ message: "fix: bug", files: [1, 2] })).toContain("strings")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -200,10 +183,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Not a git repository")
|
||||
@@ -218,10 +198,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Nothing to commit")
|
||||
@@ -241,10 +218,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "feat: new feature" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "feat: new feature" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitCommitResult
|
||||
@@ -268,10 +242,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitCommitResult
|
||||
@@ -290,10 +261,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute(
|
||||
{ message: "test", files: ["a.ts", "b.ts"] },
|
||||
ctx,
|
||||
)
|
||||
await toolWithMock.execute({ message: "test", files: ["a.ts", "b.ts"] }, ctx)
|
||||
|
||||
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
|
||||
})
|
||||
@@ -303,10 +271,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute(
|
||||
{ message: "test", files: [] },
|
||||
ctx,
|
||||
)
|
||||
await toolWithMock.execute({ message: "test", files: [] }, ctx)
|
||||
|
||||
expect(mockGit.add).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -337,8 +302,8 @@ describe("GitCommitTool", () => {
|
||||
await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0] as string
|
||||
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0] as string
|
||||
expect(confirmMessage).toContain("Committing")
|
||||
expect(confirmMessage).toContain("test commit")
|
||||
})
|
||||
@@ -348,10 +313,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext(undefined, false)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("cancelled")
|
||||
@@ -363,10 +325,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext(undefined, true)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
|
||||
@@ -381,10 +340,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Git commit failed")
|
||||
@@ -400,10 +356,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
@@ -416,10 +369,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
@@ -431,10 +381,7 @@ describe("GitCommitTool", () => {
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^git_commit-\d+$/)
|
||||
})
|
||||
|
||||
@@ -69,9 +69,7 @@ function createMockGit(options: {
|
||||
if (options.error) {
|
||||
mockGit.diffSummary.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.diffSummary.mockResolvedValue(
|
||||
options.diffSummary ?? createMockDiffSummary(),
|
||||
)
|
||||
mockGit.diffSummary.mockResolvedValue(options.diffSummary ?? createMockDiffSummary())
|
||||
mockGit.diff.mockResolvedValue(options.diff ?? "")
|
||||
}
|
||||
|
||||
@@ -224,9 +222,7 @@ describe("GitDiffTool", () => {
|
||||
it("should handle binary files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "image.png", insertions: 0, deletions: 0, binary: true },
|
||||
],
|
||||
files: [{ file: "image.png", insertions: 0, deletions: 0, binary: true }],
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
@@ -293,11 +289,7 @@ describe("GitDiffTool", () => {
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith([
|
||||
"--cached",
|
||||
"--",
|
||||
"src/index.ts",
|
||||
])
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached", "--", "src/index.ts"])
|
||||
})
|
||||
|
||||
it("should return null pathFilter when not provided", async () => {
|
||||
|
||||
@@ -14,12 +14,8 @@ describe("CommandSecurity", () => {
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should use default blacklist and whitelist", () => {
|
||||
expect(security.getBlacklist()).toEqual(
|
||||
DEFAULT_BLACKLIST.map((c) => c.toLowerCase()),
|
||||
)
|
||||
expect(security.getWhitelist()).toEqual(
|
||||
DEFAULT_WHITELIST.map((c) => c.toLowerCase()),
|
||||
)
|
||||
expect(security.getBlacklist()).toEqual(DEFAULT_BLACKLIST.map((c) => c.toLowerCase()))
|
||||
expect(security.getWhitelist()).toEqual(DEFAULT_WHITELIST.map((c) => c.toLowerCase()))
|
||||
})
|
||||
|
||||
it("should accept custom blacklist and whitelist", () => {
|
||||
|
||||
@@ -35,10 +35,7 @@ function createMockStorage(): IStorage {
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult: boolean = true,
|
||||
): ToolContext {
|
||||
function createMockContext(storage?: IStorage, confirmResult: boolean = true): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
@@ -48,10 +45,7 @@ function createMockContext(
|
||||
}
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string }
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ExecResult>
|
||||
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
|
||||
|
||||
function createMockExec(options: {
|
||||
stdout?: string
|
||||
@@ -123,27 +117,19 @@ describe("RunCommandTool", () => {
|
||||
})
|
||||
|
||||
it("should return error for non-number timeout", () => {
|
||||
expect(
|
||||
tool.validateParams({ command: "ls", timeout: "5000" }),
|
||||
).toContain("number")
|
||||
expect(tool.validateParams({ command: "ls", timeout: "5000" })).toContain("number")
|
||||
})
|
||||
|
||||
it("should return error for negative timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain(
|
||||
"positive",
|
||||
)
|
||||
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain("positive")
|
||||
})
|
||||
|
||||
it("should return error for zero timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain(
|
||||
"positive",
|
||||
)
|
||||
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain("positive")
|
||||
})
|
||||
|
||||
it("should return error for timeout > 10 minutes", () => {
|
||||
expect(
|
||||
tool.validateParams({ command: "ls", timeout: 600001 }),
|
||||
).toContain("600000")
|
||||
expect(tool.validateParams({ command: "ls", timeout: 600001 })).toContain("600000")
|
||||
})
|
||||
|
||||
it("should return null for valid timeout", () => {
|
||||
@@ -180,10 +166,7 @@ describe("RunCommandTool", () => {
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "git push --force" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ command: "git push --force" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
@@ -250,10 +233,7 @@ describe("RunCommandTool", () => {
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext(undefined, true)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-script" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
@@ -266,10 +246,7 @@ describe("RunCommandTool", () => {
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext(undefined, false)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-script" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ command: "custom-script" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("cancelled")
|
||||
@@ -364,10 +341,7 @@ describe("RunCommandTool", () => {
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ timeout: 30000 }),
|
||||
)
|
||||
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 30000 }))
|
||||
})
|
||||
|
||||
it("should use custom timeout", async () => {
|
||||
@@ -377,10 +351,7 @@ describe("RunCommandTool", () => {
|
||||
|
||||
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
)
|
||||
expect(execFn).toHaveBeenCalledWith("ls", expect.objectContaining({ timeout: 5000 }))
|
||||
})
|
||||
|
||||
it("should execute in project root", async () => {
|
||||
@@ -493,10 +464,7 @@ describe("RunCommandTool", () => {
|
||||
|
||||
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-safe arg" },
|
||||
ctx,
|
||||
)
|
||||
const result = await toolWithMock.execute({ command: "custom-safe arg" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||
|
||||
@@ -45,10 +45,7 @@ function createMockContext(storage?: IStorage): ToolContext {
|
||||
}
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string }
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ExecResult>
|
||||
type ExecFn = (command: string, options: Record<string, unknown>) => Promise<ExecResult>
|
||||
|
||||
function createMockExec(options: {
|
||||
stdout?: string
|
||||
@@ -127,9 +124,7 @@ describe("RunTestsTool", () => {
|
||||
})
|
||||
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "src", filter: "login", watch: true }),
|
||||
).toBeNull()
|
||||
expect(tool.validateParams({ path: "src", filter: "login", watch: true })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for invalid path", () => {
|
||||
|
||||
Reference in New Issue
Block a user