Update CLI test mocks so Vitest-spied constructors remain new-able under current mock semantics. - Add constructible mock wrappers for TaskStore and related CLI test constructor mocks. - Replace arrow-function constructor mock implementations with function-based implementations. - Bring task retry fixture data in line with the graph resume retry counter shape. Files changed: .../cli/src/__tests__/experiment-finalize.test.ts | 19 +++++++++++++++++-- .../__tests__/extension-experiment-finalize.test.ts | 19 +++++++++++++++++-- packages/cli/src/__tests__/plugin-dev.test.ts | 19 +++++++++++++++++-- packages/cli/src/__tests__/project-resolver.test.ts | 17 ++++++++++++++++- packages/cli/src/__tests__/task-plan.test.ts | 17 ++++++++++++++++- packages/cli/src/__tests__/task-steer.test.ts | 17 ++++++++++++++++- packages/cli/src/__tests__/update-cache.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/agent.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/backup.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/db.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/desktop.test.ts | 4 +++- packages/cli/src/commands/__tests__/init.test.ts | 17 ++++++++++++++++- .../src/commands/__tests__/memory-backup.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/message.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/node.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/plugin.test.ts | 19 +++++++++++++++++-- packages/cli/src/commands/__tests__/project.test.ts | 21 ++++++++++++++++++--- packages/cli/src/commands/__tests__/serve.test.ts | 17 ++++++++++++++++- .../src/commands/__tests__/settings-export.test.ts | 17 ++++++++++++++++- .../src/commands/__tests__/settings-import.test.ts | 17 ++++++++++++++++- .../cli/src/commands/__tests__/settings.test.ts | 17 ++++++++++++++++- packages/cli/src/commands/__tests__/task.test.ts | 2 ++ 22 files changed, 331 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-6367 Fusion-Task-Lineage: d900dfe0-e286-4175-a5ba-7c319e2527b0
271 lines
8.5 KiB
TypeScript
271 lines
8.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
|
|
function makeConstructibleMock<T extends (...args: any[]) => unknown>(impl?: T) {
|
|
const mock = vi.fn(function () {});
|
|
const originalMockImplementation = mock.mockImplementation.bind(mock);
|
|
const originalMockImplementationOnce = mock.mockImplementationOnce.bind(mock);
|
|
const wrap = (nextImpl: T) => function (this: unknown, ...args: Parameters<T>) {
|
|
return nextImpl(...args);
|
|
};
|
|
mock.mockImplementation = ((nextImpl: T) => originalMockImplementation(wrap(nextImpl))) as typeof mock.mockImplementation;
|
|
mock.mockImplementationOnce = ((nextImpl: T) => originalMockImplementationOnce(wrap(nextImpl))) as typeof mock.mockImplementationOnce;
|
|
if (impl) {
|
|
mock.mockImplementation(impl);
|
|
}
|
|
return mock;
|
|
}
|
|
|
|
// Mock node:readline/promises before importing
|
|
vi.mock("node:readline/promises", () => ({
|
|
createInterface: vi.fn(),
|
|
}));
|
|
|
|
// Mock @fusion/core before importing
|
|
vi.mock("@fusion/core", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@fusion/core")>()),
|
|
TaskStore: makeConstructibleMock(),
|
|
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
|
COLUMN_LABELS: {
|
|
triage: "Triage",
|
|
todo: "Todo",
|
|
"in-progress": "In Progress",
|
|
"in-review": "In Review",
|
|
done: "Done",
|
|
archived: "Archived",
|
|
},
|
|
deterministicGuardLocks: new Map(),
|
|
}));
|
|
|
|
// Import after mocking
|
|
import { createInterface } from "node:readline/promises";
|
|
import { TaskStore } from "@fusion/core";
|
|
import { runTaskSteer } from "../commands/task.js";
|
|
|
|
describe("runTaskSteer", () => {
|
|
let mockConsoleLog: ReturnType<typeof vi.spyOn>;
|
|
let mockConsoleError: ReturnType<typeof vi.spyOn>;
|
|
const mockQuestion = vi.fn();
|
|
const mockClose = vi.fn();
|
|
const mockAddComment = vi.fn();
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
mockQuestion.mockReset();
|
|
(createInterface as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
|
question: mockQuestion,
|
|
close: mockClose,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
mockConsoleLog.mockRestore();
|
|
mockConsoleError.mockRestore();
|
|
});
|
|
|
|
function setupTaskStoreMock(overrides: Record<string, unknown> = {}) {
|
|
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
|
init: vi.fn().mockResolvedValue(undefined),
|
|
addSteeringComment: mockAddComment,
|
|
...overrides,
|
|
}));
|
|
}
|
|
|
|
it("adds steering comment with message argument", async () => {
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-001",
|
|
title: "Test Task",
|
|
});
|
|
|
|
await runTaskSteer("FN-001", "Focus on error handling");
|
|
|
|
expect(mockAddComment).toHaveBeenCalledWith("FN-001", "Focus on error handling", "user");
|
|
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
expect.stringContaining("Steering comment added to FN-001")
|
|
);
|
|
});
|
|
|
|
it("reads message from stdin when not provided as argument", async () => {
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-002",
|
|
title: "Another Task",
|
|
});
|
|
|
|
mockQuestion.mockResolvedValueOnce("This is a steering comment from stdin");
|
|
|
|
await runTaskSteer("FN-002", undefined);
|
|
|
|
expect(mockQuestion).toHaveBeenCalledWith("Message: ");
|
|
expect(mockAddComment).toHaveBeenCalledWith("FN-002", "This is a steering comment from stdin", "user");
|
|
expect(mockClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects messages longer than 2000 characters", async () => {
|
|
setupTaskStoreMock();
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
throw new Error(`Process.exit called with ${code}`);
|
|
});
|
|
|
|
const longMessage = "a".repeat(2001);
|
|
|
|
await expect(runTaskSteer("FN-003", longMessage)).rejects.toThrow();
|
|
|
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Message must be between 1 and 2000 characters")
|
|
);
|
|
expect(mockAddComment).not.toHaveBeenCalled();
|
|
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("rejects empty messages", async () => {
|
|
setupTaskStoreMock();
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
throw new Error(`Process.exit called with ${code}`);
|
|
});
|
|
|
|
await expect(runTaskSteer("FN-004", "")).rejects.toThrow();
|
|
|
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Message is required")
|
|
);
|
|
expect(mockAddComment).not.toHaveBeenCalled();
|
|
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("rejects whitespace-only messages", async () => {
|
|
setupTaskStoreMock();
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
throw new Error(`Process.exit called with ${code}`);
|
|
});
|
|
|
|
await expect(runTaskSteer("FN-005", " ")).rejects.toThrow();
|
|
|
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Message is required")
|
|
);
|
|
expect(mockAddComment).not.toHaveBeenCalled();
|
|
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("handles task not found error (ENOENT)", async () => {
|
|
setupTaskStoreMock();
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
throw new Error(`Process.exit called with ${code}`);
|
|
});
|
|
|
|
const error = new Error("Task not found") as Error & { code: string };
|
|
error.code = "ENOENT";
|
|
mockAddComment.mockRejectedValueOnce(error);
|
|
|
|
await expect(runTaskSteer("KB-999", "Some message")).rejects.toThrow();
|
|
|
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Task not found: KB-999")
|
|
);
|
|
|
|
exitSpy.mockRestore();
|
|
});
|
|
|
|
it("shows success output with preview for short messages", async () => {
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-006",
|
|
title: "Short Message Task",
|
|
});
|
|
|
|
await runTaskSteer("FN-006", "Short comment");
|
|
|
|
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
expect.stringContaining("Short comment")
|
|
);
|
|
});
|
|
|
|
it("truncates long messages in success preview", async () => {
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-007",
|
|
title: "Long Message Task",
|
|
});
|
|
|
|
const longMessage = "a".repeat(100);
|
|
await runTaskSteer("FN-007", longMessage);
|
|
|
|
// Should show first 60 chars + ellipsis
|
|
const expectedPreview = "a".repeat(60) + "…";
|
|
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
expect.stringContaining(expectedPreview)
|
|
);
|
|
});
|
|
|
|
it("trims whitespace from messages", async () => {
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-008",
|
|
title: "Trim Test Task",
|
|
});
|
|
|
|
await runTaskSteer("FN-008", " Some message with whitespace ");
|
|
|
|
expect(mockAddComment).toHaveBeenCalledWith("FN-008", "Some message with whitespace", "user");
|
|
});
|
|
|
|
it("accepts messages at boundary lengths (1 and 2000 chars)", async () => {
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-009",
|
|
title: "Boundary Test",
|
|
});
|
|
|
|
// Test 1 character
|
|
await runTaskSteer("FN-009", "x");
|
|
expect(mockAddComment).toHaveBeenCalledWith("FN-009", "x", "user");
|
|
|
|
// Reset mock for next test
|
|
vi.clearAllMocks();
|
|
setupTaskStoreMock();
|
|
mockAddComment.mockResolvedValueOnce({
|
|
id: "FN-010",
|
|
title: "Boundary Test 2",
|
|
});
|
|
|
|
// Test exactly 2000 characters
|
|
const exact2000 = "b".repeat(2000);
|
|
await runTaskSteer("FN-010", exact2000);
|
|
expect(mockAddComment).toHaveBeenCalledWith("FN-010", exact2000, "user");
|
|
});
|
|
|
|
it("rethrows non-ENOENT errors", async () => {
|
|
setupTaskStoreMock();
|
|
|
|
const error = new Error("Database error");
|
|
mockAddComment.mockRejectedValueOnce(error);
|
|
|
|
await expect(runTaskSteer("FN-011", "Message")).rejects.toThrow("Database error");
|
|
});
|
|
|
|
it("treats empty string as validation error, not prompt trigger", async () => {
|
|
setupTaskStoreMock();
|
|
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
|
|
throw new Error(`Process.exit called with ${code}`);
|
|
});
|
|
|
|
// Empty string as argument is a validation error, not a prompt trigger
|
|
await expect(runTaskSteer("FN-012", "")).rejects.toThrow();
|
|
|
|
// Should NOT prompt, should error instead
|
|
expect(mockQuestion).not.toHaveBeenCalled();
|
|
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
expect.stringContaining("Message is required")
|
|
);
|
|
expect(mockAddComment).not.toHaveBeenCalled();
|
|
|
|
exitSpy.mockRestore();
|
|
});
|
|
});
|