- Move all co-located *.test.* files into sibling __tests__/ directories so the
layout is consistent across packages (159 renames + content-rewrite moves).
Updates relative imports, vi.mock specifiers, and __dirname/import.meta.url
path resolutions where tests read fixtures from disk.
- Drop tracked tsc-emit alongside engine .ts sources (auth-storage/logger/
skill-resolver/context-limit-detector/pi.{js,d.ts,*.map}). These were
accidentally committed in a merge and the stale pi.js was masking a real
test-mock vs source mismatch (tests imported "../pi.js" and vite preferred
the stale build over pi.ts).
- Add packages/engine/.gitignore to block future src/*.{js,d.ts,map}.
- Refactor plugin pi-module seams (openclaw/paperclip/hermes) to ESM-import
createFnAgent / promptWithFallback / describeModel from @fusion/engine
instead of require()-ing packages/engine/src/pi.js. Adds @fusion/engine to
the two plugin package.jsons that were missing it; exports describeModel
from the engine public API.
- Fix engine test mocks now that they run against current pi.ts: add
ModelRegistry.create static to mocks in pi.test.ts and pi-create-fn-agent
.test.ts; switch three boundary-result toEqual assertions to toMatchObject
so the new content/isError fields don't trip exact-match comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1501 lines
40 KiB
TypeScript
1501 lines
40 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
validateSuggestionInput,
|
|
generateMilestoneSuggestions,
|
|
validateFeatureSuggestionInput,
|
|
generateFeatureSuggestions,
|
|
ValidationError,
|
|
ParseError,
|
|
ServiceUnavailableError,
|
|
SUGGESTION_TIMEOUT_MS,
|
|
__resetSuggestionState,
|
|
__setCreateFnAgent,
|
|
} from "../roadmap-suggestions";
|
|
|
|
describe("roadmap-suggestions", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
__resetSuggestionState();
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetSuggestionState();
|
|
});
|
|
|
|
describe("validateSuggestionInput", () => {
|
|
it("accepts valid input with all fields", () => {
|
|
const input = {
|
|
goalPrompt: "Build a modern e-commerce platform",
|
|
count: 5,
|
|
};
|
|
|
|
expect(() => validateSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts valid input without optional count", () => {
|
|
const input = {
|
|
goalPrompt: "Build a modern e-commerce platform",
|
|
};
|
|
|
|
expect(() => validateSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts count at minimum boundary (1)", () => {
|
|
const input = {
|
|
goalPrompt: "Test goal",
|
|
count: 1,
|
|
};
|
|
|
|
expect(() => validateSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts count at maximum boundary (10)", () => {
|
|
const input = {
|
|
goalPrompt: "Test goal",
|
|
count: 10,
|
|
};
|
|
|
|
expect(() => validateSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("rejects null input", () => {
|
|
expect(() => validateSuggestionInput(null)).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects non-object input", () => {
|
|
expect(() => validateSuggestionInput("string")).toThrow(ValidationError);
|
|
expect(() => validateSuggestionInput(123)).toThrow(ValidationError);
|
|
expect(() => validateSuggestionInput([])).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects missing goalPrompt", () => {
|
|
expect(() => validateSuggestionInput({})).toThrow(ValidationError);
|
|
expect(() => validateSuggestionInput({ count: 5 })).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects non-string goalPrompt", () => {
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: 123 })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: null })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: [] })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects empty goalPrompt", () => {
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: "" })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: " " })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects goalPrompt exceeding max length", () => {
|
|
const longPrompt = "a".repeat(4001);
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: longPrompt })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("accepts goalPrompt at exactly max length", () => {
|
|
const maxPrompt = "a".repeat(4000);
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: maxPrompt })
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("rejects non-integer count", () => {
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: "Test", count: 3.5 })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects count below minimum", () => {
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: "Test", count: 0 })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: "Test", count: -1 })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects count above maximum", () => {
|
|
expect(() =>
|
|
validateSuggestionInput({ goalPrompt: "Test", count: 11 })
|
|
).toThrow(ValidationError);
|
|
});
|
|
});
|
|
|
|
describe("generateMilestoneSuggestions", () => {
|
|
const rootDir = "/test/project";
|
|
|
|
it("generates milestone suggestions successfully", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Foundation Setup", "description": "Set up core infrastructure"},\n {"title": "User Authentication", "description": "Implement login and user management"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateMilestoneSuggestions(
|
|
"Build a modern e-commerce platform",
|
|
5,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0]).toEqual({
|
|
title: "Foundation Setup",
|
|
description: "Set up core infrastructure",
|
|
});
|
|
expect(suggestions[1]).toEqual({
|
|
title: "User Authentication",
|
|
description: "Implement login and user management",
|
|
});
|
|
});
|
|
|
|
it("uses default count of 5 when not specified", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateMilestoneSuggestions("Test goal", undefined, rootDir);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: rootDir,
|
|
systemPrompt: expect.stringContaining("milestone"),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("respects the count parameter", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateMilestoneSuggestions("Test goal", 3, rootDir);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalled();
|
|
});
|
|
|
|
it("includes count in user message", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateMilestoneSuggestions("Build a platform", 5, rootDir);
|
|
|
|
expect(mockSession.prompt).toHaveBeenCalledWith(
|
|
expect.stringContaining("5 milestones")
|
|
);
|
|
});
|
|
|
|
it("disposes session after successful generation", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateMilestoneSuggestions("Test", 5, rootDir);
|
|
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when AI service is unavailable", async () => {
|
|
__setCreateFnAgent(undefined);
|
|
|
|
await expect(
|
|
generateMilestoneSuggestions("Test", 5, rootDir)
|
|
).rejects.toThrow("AI service is not available");
|
|
});
|
|
|
|
it("throws when rootDir is missing", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateMilestoneSuggestions("Test", 5)
|
|
).rejects.toThrow("rootDir is required");
|
|
});
|
|
|
|
it("handles markdown-wrapped JSON response", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '```json\n[\n {"title": "Setup", "description": "Initial setup"}\n]\n```',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
|
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0].title).toBe("Setup");
|
|
});
|
|
|
|
it("handles plain array response without markdown", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Phase 1", "description": "First phase"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
|
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0].title).toBe("Phase 1");
|
|
});
|
|
|
|
it("handles suggestions without description", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[{"title": "Phase 1"}, {"title": "Phase 2", "description": "With desc"}]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
|
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0]).toEqual({ title: "Phase 1", description: undefined });
|
|
expect(suggestions[1]).toEqual({ title: "Phase 2", description: "With desc" });
|
|
});
|
|
|
|
it("limits suggestions to requested count", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "One"}, {"title": "Two"}, {"title": "Three"}, {"title": "Four"}, {"title": "Five"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateMilestoneSuggestions("Test", 2, rootDir);
|
|
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0].title).toBe("One");
|
|
expect(suggestions[1].title).toBe("Two");
|
|
});
|
|
|
|
it("strips whitespace from titles and descriptions", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": " Trimmed Title ", "description": " With whitespace "}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
|
|
|
|
expect(suggestions[0]).toEqual({
|
|
title: "Trimmed Title",
|
|
description: "With whitespace",
|
|
});
|
|
});
|
|
|
|
it("throws ParseError when AI returns no JSON", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Here are some milestones without JSON",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateMilestoneSuggestions("Test", 5, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("throws ParseError when JSON is not an array", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '{"title": "Not an array"}',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateMilestoneSuggestions("Test", 5, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("throws ParseError when milestone is missing title", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[{"description": "Missing title"}]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateMilestoneSuggestions("Test", 5, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("supports model override parameters", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateMilestoneSuggestions(
|
|
"Test",
|
|
5,
|
|
rootDir,
|
|
"openai",
|
|
"gpt-4o"
|
|
);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("times out when AI prompt hangs", async () => {
|
|
// Create a mock session whose prompt hangs (never resolves)
|
|
const mockSession = {
|
|
prompt: vi.fn().mockReturnValue(
|
|
new Promise<undefined>(() => {
|
|
// Never resolves - simulates hanging AI
|
|
})
|
|
),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
// Use fake timers
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
const promise = generateMilestoneSuggestions("Test goal", 5, rootDir);
|
|
|
|
// Ensure the promise rejection is captured by attaching a handler that won't interfere
|
|
// with the test assertion but prevents unhandled rejection warnings
|
|
const rejectionHandler = vi.fn();
|
|
promise.catch(rejectionHandler);
|
|
|
|
// Advance timers past the timeout threshold
|
|
await vi.advanceTimersByTimeAsync(SUGGESTION_TIMEOUT_MS + 100);
|
|
|
|
// Flush all pending ticks/microtasks to ensure the rejection is fully processed
|
|
await vi.runAllTicks();
|
|
|
|
// The promise should have been rejected with ServiceUnavailableError
|
|
expect(rejectionHandler).toHaveBeenCalledWith(expect.any(ServiceUnavailableError));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("validateFeatureSuggestionInput", () => {
|
|
it("accepts valid input with all fields", () => {
|
|
const input = {
|
|
prompt: "Focus on user authentication features",
|
|
count: 5,
|
|
};
|
|
|
|
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts valid input without optional fields", () => {
|
|
const input = {};
|
|
|
|
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts empty object", () => {
|
|
expect(() => validateFeatureSuggestionInput({})).not.toThrow();
|
|
});
|
|
|
|
it("accepts input with only count", () => {
|
|
const input = {
|
|
count: 3,
|
|
};
|
|
|
|
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts count at minimum boundary (1)", () => {
|
|
const input = {
|
|
count: 1,
|
|
};
|
|
|
|
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("accepts count at maximum boundary (10)", () => {
|
|
const input = {
|
|
count: 10,
|
|
};
|
|
|
|
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
|
});
|
|
|
|
it("rejects null input", () => {
|
|
expect(() => validateFeatureSuggestionInput(null)).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects non-object input", () => {
|
|
expect(() => validateFeatureSuggestionInput("string")).toThrow(ValidationError);
|
|
expect(() => validateFeatureSuggestionInput(123)).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects array input", () => {
|
|
expect(() => validateFeatureSuggestionInput([])).toThrow(ValidationError);
|
|
expect(() => validateFeatureSuggestionInput([{ prompt: "test" }])).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects non-string prompt", () => {
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ prompt: 123 })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ prompt: null })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ prompt: [] })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects prompt exceeding max length", () => {
|
|
const longPrompt = "a".repeat(2001);
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ prompt: longPrompt })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("accepts prompt at exactly max length", () => {
|
|
const maxPrompt = "a".repeat(2000);
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ prompt: maxPrompt })
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("rejects non-integer count", () => {
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ count: 3.5 })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects count below minimum", () => {
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ count: 0 })
|
|
).toThrow(ValidationError);
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ count: -1 })
|
|
).toThrow(ValidationError);
|
|
});
|
|
|
|
it("rejects count above maximum", () => {
|
|
expect(() =>
|
|
validateFeatureSuggestionInput({ count: 11 })
|
|
).toThrow(ValidationError);
|
|
});
|
|
});
|
|
|
|
describe("generateFeatureSuggestions", () => {
|
|
const rootDir = "/test/project";
|
|
|
|
const baseContext = {
|
|
roadmapTitle: "E-Commerce Platform",
|
|
roadmapDescription: "Build a modern e-commerce platform",
|
|
milestoneTitle: "User Authentication",
|
|
milestoneDescription: "Implement user login and management",
|
|
existingFeatureTitles: [],
|
|
};
|
|
|
|
it("generates feature suggestions successfully", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Login Form", "description": "Basic login form UI"}, {"title": "OAuth Integration", "description": "Support social login"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0]).toEqual({
|
|
title: "Login Form",
|
|
description: "Basic login form UI",
|
|
});
|
|
expect(suggestions[1]).toEqual({
|
|
title: "OAuth Integration",
|
|
description: "Support social login",
|
|
});
|
|
});
|
|
|
|
it("includes milestone context in system prompt", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cwd: rootDir,
|
|
systemPrompt: expect.stringContaining("User Authentication"),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("includes existing features in context when present", async () => {
|
|
const contextWithExistingFeatures = {
|
|
...baseContext,
|
|
existingFeatureTitles: ["Login Form", "Password Reset"],
|
|
};
|
|
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "New Feature", "description": "A new feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateFeatureSuggestions(
|
|
contextWithExistingFeatures,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
systemPrompt: expect.stringContaining("Login Form"),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("includes optional prompt in user message", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
"Focus on security features",
|
|
rootDir
|
|
);
|
|
|
|
expect(mockSession.prompt).toHaveBeenCalledWith(
|
|
expect.stringContaining("Focus on security features")
|
|
);
|
|
});
|
|
|
|
it("respects the count parameter", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateFeatureSuggestions(baseContext, 3, undefined, rootDir);
|
|
|
|
expect(mockSession.prompt).toHaveBeenCalledWith(
|
|
expect.stringContaining("3 features")
|
|
);
|
|
});
|
|
|
|
it("disposes session after successful generation", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
|
|
|
|
expect(mockSession.dispose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when AI service is unavailable", async () => {
|
|
__setCreateFnAgent(undefined);
|
|
|
|
await expect(
|
|
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
|
).rejects.toThrow("AI service is not available");
|
|
});
|
|
|
|
it("throws when rootDir is missing", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateFeatureSuggestions(baseContext, 5, undefined)
|
|
).rejects.toThrow("rootDir is required");
|
|
});
|
|
|
|
it("handles markdown-wrapped JSON response", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '```json\n[\n {"title": "Feature", "description": "A feature"}\n]\n```',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0].title).toBe("Feature");
|
|
});
|
|
|
|
it("handles plain array response without markdown", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Feature 1", "description": "First feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions).toHaveLength(1);
|
|
expect(suggestions[0].title).toBe("Feature 1");
|
|
});
|
|
|
|
it("handles suggestions without description", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[{"title": "No Description Feature"}, {"title": "With Description", "description": "Has description"}]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0]).toEqual({ title: "No Description Feature", description: undefined });
|
|
expect(suggestions[1]).toEqual({ title: "With Description", description: "Has description" });
|
|
});
|
|
|
|
it("limits suggestions to requested count", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "One"}, {"title": "Two"}, {"title": "Three"}, {"title": "Four"}, {"title": "Five"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
2,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0].title).toBe("One");
|
|
expect(suggestions[1].title).toBe("Two");
|
|
});
|
|
|
|
it("strips whitespace from titles and descriptions", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": " Trimmed Title ", "description": " With whitespace "}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
expect(suggestions[0]).toEqual({
|
|
title: "Trimmed Title",
|
|
description: "With whitespace",
|
|
});
|
|
});
|
|
|
|
it("throws ParseError when AI returns no JSON", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: "Here are some features without JSON",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("throws ParseError when JSON is not an array", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '{"title": "Not an array"}',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("throws ParseError when feature is missing title", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[{"description": "Missing title"}]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("supports model override parameters", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir,
|
|
"openai",
|
|
"gpt-4o"
|
|
);
|
|
|
|
expect(mockCreateFnAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
defaultProvider: "openai",
|
|
defaultModelId: "gpt-4o",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("filters out invalid items and returns valid ones", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Valid Feature"}, {"title": ""}, {"title": " "}, {"title": "Also Valid", "description": "Has desc"}, {"title": null}]\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
// Should filter out empty/whitespace titles and return valid ones
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0]).toEqual({ title: "Valid Feature", description: undefined });
|
|
expect(suggestions[1]).toEqual({ title: "Also Valid", description: "Has desc" });
|
|
});
|
|
|
|
it("throws ParseError when all items are invalid", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"description": "Only has desc"}, {"title": " "}, {"title": null}]\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
await expect(
|
|
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
|
).rejects.toThrow(ParseError);
|
|
});
|
|
|
|
it("filters out non-object items", async () => {
|
|
const mockSession = {
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: '[\n {"title": "Valid"}, "string item", null, {"title": "Also Valid"}]\n]',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
const suggestions = await generateFeatureSuggestions(
|
|
baseContext,
|
|
5,
|
|
undefined,
|
|
rootDir
|
|
);
|
|
|
|
// Should filter out non-object items and return valid ones
|
|
expect(suggestions).toHaveLength(2);
|
|
expect(suggestions[0]).toEqual({ title: "Valid", description: undefined });
|
|
expect(suggestions[1]).toEqual({ title: "Also Valid", description: undefined });
|
|
});
|
|
|
|
it("times out when AI prompt hangs", async () => {
|
|
// Create a mock session whose prompt hangs (never resolves)
|
|
const mockSession = {
|
|
prompt: vi.fn().mockReturnValue(
|
|
new Promise<undefined>(() => {
|
|
// Never resolves - simulates hanging AI
|
|
})
|
|
),
|
|
dispose: vi.fn(),
|
|
state: {
|
|
messages: [],
|
|
},
|
|
};
|
|
|
|
const mockCreateFnAgent = vi.fn().mockResolvedValue({
|
|
session: mockSession,
|
|
});
|
|
|
|
__setCreateFnAgent(mockCreateFnAgent);
|
|
|
|
// Use fake timers
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
const promise = generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
|
|
|
|
// Ensure the promise rejection is captured by attaching a handler that won't interfere
|
|
// with the test assertion but prevents unhandled rejection warnings
|
|
const rejectionHandler = vi.fn();
|
|
promise.catch(rejectionHandler);
|
|
|
|
// Advance timers past the timeout threshold
|
|
await vi.advanceTimersByTimeAsync(SUGGESTION_TIMEOUT_MS + 100);
|
|
|
|
// Flush all pending ticks/microtasks to ensure the rejection is fully processed
|
|
await vi.runAllTicks();
|
|
|
|
// The promise should have been rejected with ServiceUnavailableError
|
|
expect(rejectionHandler).toHaveBeenCalledWith(expect.any(ServiceUnavailableError));
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
});
|