Files
fusion/packages/dashboard/src/__tests__/ai-refine.test.ts
gsxdsm bdcb048e20 chore: consolidate test files into __tests__/ dirs and clean stray engine artifacts
- 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>
2026-04-24 19:39:20 -07:00

392 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
refineText,
validateRefineRequest,
checkRateLimit,
getRateLimitResetTime,
__resetRefineState,
ValidationError,
InvalidTypeError,
AiServiceError,
VALID_REFINEMENT_TYPES,
MIN_TEXT_LENGTH,
MAX_TEXT_LENGTH,
MAX_REQUESTS_PER_HOUR,
RATE_LIMIT_WINDOW_MS,
} from "../ai-refine.js";
// Hoisted mock factory
const { mockCreateFnAgent } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
}));
// Mock the engine module to avoid dynamic import issues in tests
vi.mock("@fusion/engine", () => ({
createFnAgent: mockCreateFnAgent,
}));
describe("ai-refine module", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
__resetRefineState();
vi.clearAllMocks();
mockCreateFnAgent.mockResolvedValue(null);
});
afterEach(() => {
vi.useRealTimers();
});
describe("VALID_REFINEMENT_TYPES", () => {
it("contains all four refinement types", () => {
expect(VALID_REFINEMENT_TYPES).toEqual([
"clarify",
"add-details",
"expand",
"simplify",
]);
});
});
describe("validateRefineRequest", () => {
it("accepts valid text and 'clarify' type", () => {
const result = validateRefineRequest("Some text", "clarify");
expect(result).toEqual({ text: "Some text", type: "clarify" });
});
it("accepts valid text and 'add-details' type", () => {
const result = validateRefineRequest("Some text", "add-details");
expect(result).toEqual({ text: "Some text", type: "add-details" });
});
it("accepts valid text and 'expand' type", () => {
const result = validateRefineRequest("Some text", "expand");
expect(result).toEqual({ text: "Some text", type: "expand" });
});
it("accepts valid text and 'simplify' type", () => {
const result = validateRefineRequest("Some text", "simplify");
expect(result).toEqual({ text: "Some text", type: "simplify" });
});
it("throws ValidationError for missing text", () => {
expect(() => validateRefineRequest(undefined, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(undefined, "clarify")).toThrow("text is required");
});
it("throws ValidationError for null text", () => {
expect(() => validateRefineRequest(null, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(null, "clarify")).toThrow("text is required");
});
it("throws ValidationError for non-string text", () => {
expect(() => validateRefineRequest(123, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(123, "clarify")).toThrow("text must be a string");
});
it("throws ValidationError for empty text", () => {
expect(() => validateRefineRequest("", "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest("", "clarify")).toThrow(
`text must be at least ${MIN_TEXT_LENGTH} character`
);
});
it("throws ValidationError for text exceeding MAX_TEXT_LENGTH", () => {
const longText = "a".repeat(MAX_TEXT_LENGTH + 1);
expect(() => validateRefineRequest(longText, "clarify")).toThrow(ValidationError);
expect(() => validateRefineRequest(longText, "clarify")).toThrow(
`text must not exceed ${MAX_TEXT_LENGTH} characters`
);
});
it("accepts text at exactly MAX_TEXT_LENGTH", () => {
const maxText = "a".repeat(MAX_TEXT_LENGTH);
const result = validateRefineRequest(maxText, "clarify");
expect(result.text).toHaveLength(MAX_TEXT_LENGTH);
});
it("accepts text at exactly MIN_TEXT_LENGTH", () => {
const result = validateRefineRequest("a", "clarify");
expect(result.text).toBe("a");
});
it("throws ValidationError for missing type", () => {
expect(() => validateRefineRequest("some text", undefined)).toThrow(ValidationError);
expect(() => validateRefineRequest("some text", undefined)).toThrow("type is required");
});
it("throws ValidationError for null type", () => {
expect(() => validateRefineRequest("some text", null)).toThrow(ValidationError);
expect(() => validateRefineRequest("some text", null)).toThrow("type is required");
});
it("throws InvalidTypeError for invalid type string", () => {
expect(() => validateRefineRequest("some text", "invalid")).toThrow(InvalidTypeError);
expect(() => validateRefineRequest("some text", "invalid")).toThrow(
"type must be one of: clarify, add-details, expand, simplify"
);
});
it("throws InvalidTypeError for numeric type", () => {
expect(() => validateRefineRequest("some text", 123)).toThrow(InvalidTypeError);
});
});
describe("checkRateLimit", () => {
it("allows first request from an IP", () => {
expect(checkRateLimit("192.168.1.1")).toBe(true);
});
it("allows up to MAX_REQUESTS_PER_HOUR requests", () => {
const ip = "192.168.1.1";
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
});
it("blocks request beyond MAX_REQUESTS_PER_HOUR", () => {
const ip = "192.168.1.1";
// Use up the quota
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
checkRateLimit(ip);
}
// 11th request should be blocked
expect(checkRateLimit(ip)).toBe(false);
});
it("tracks different IPs independently", () => {
const ip1 = "192.168.1.1";
const ip2 = "192.168.1.2";
// Use up quota for ip1
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
checkRateLimit(ip1);
}
expect(checkRateLimit(ip1)).toBe(false);
// ip2 should still have full quota
expect(checkRateLimit(ip2)).toBe(true);
});
it("resets rate limit after RATE_LIMIT_WINDOW_MS", () => {
const ip = "192.168.1.1";
// Use up the quota
for (let i = 0; i < MAX_REQUESTS_PER_HOUR; i++) {
checkRateLimit(ip);
}
expect(checkRateLimit(ip)).toBe(false);
// Advance time by 1 hour + 1ms
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1);
// Should be allowed again
expect(checkRateLimit(ip)).toBe(true);
});
it("resets count but tracks new window after expiry", () => {
const ip = "192.168.1.1";
// Make 5 requests
for (let i = 0; i < 5; i++) {
checkRateLimit(ip);
}
// Advance time by 1 hour + 1ms
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1);
// First request in new window should work
expect(checkRateLimit(ip)).toBe(true);
// Use up remaining quota in new window
for (let i = 0; i < MAX_REQUESTS_PER_HOUR - 1; i++) {
expect(checkRateLimit(ip)).toBe(true);
}
// Next request should be blocked
expect(checkRateLimit(ip)).toBe(false);
});
});
describe("getRateLimitResetTime", () => {
it("returns null for unknown IP", () => {
expect(getRateLimitResetTime("unknown-ip")).toBeNull();
});
it("returns reset time after a request is made", () => {
const ip = "192.168.1.1";
const beforeRequest = Date.now();
checkRateLimit(ip);
const resetTime = getRateLimitResetTime(ip);
expect(resetTime).not.toBeNull();
expect(resetTime!.getTime()).toBe(beforeRequest + RATE_LIMIT_WINDOW_MS);
});
it("returns updated reset time after window resets", () => {
const ip = "192.168.1.1";
checkRateLimit(ip);
const firstResetTime = getRateLimitResetTime(ip);
// Advance time past the window
vi.advanceTimersByTime(RATE_LIMIT_WINDOW_MS + 1000);
// Make another request
checkRateLimit(ip);
const secondResetTime = getRateLimitResetTime(ip);
// Second reset time should be later than first
expect(secondResetTime!.getTime()).toBeGreaterThan(firstResetTime!.getTime());
});
});
describe("error classes", () => {
it("ValidationError has correct name", () => {
const error = new ValidationError("test");
expect(error.name).toBe("ValidationError");
expect(error.message).toBe("test");
});
it("InvalidTypeError has correct name", () => {
const error = new InvalidTypeError("test");
expect(error.name).toBe("InvalidTypeError");
expect(error.message).toBe("test");
});
it("AiServiceError has correct name", () => {
const error = new AiServiceError("test");
expect(error.name).toBe("AiServiceError");
expect(error.message).toBe("test");
});
});
describe("refineText", () => {
// Note: refineText requires the AI engine which is not available in tests.
// These tests verify error handling when the engine is unavailable.
it("throws AiServiceError when AI engine is not available", async () => {
await expect(refineText("some text", "clarify", "/some/path")).rejects.toThrow(
AiServiceError
);
await expect(refineText("some text", "clarify", "/some/path")).rejects.toThrow(
"Failed to initialize AI agent"
);
});
});
describe("__resetRefineState", () => {
it("clears all rate limit entries", () => {
const ip = "192.168.1.1";
// Make requests to populate rate limits
for (let i = 0; i < 5; i++) {
checkRateLimit(ip);
}
expect(getRateLimitResetTime(ip)).not.toBeNull();
// Reset state
__resetRefineState();
// Should be like starting fresh
expect(getRateLimitResetTime(ip)).toBeNull();
expect(checkRateLimit(ip)).toBe(true);
});
});
describe("prompt override regression", () => {
const customPrompt = "You are a custom refine assistant.";
function createRefineMockAgent(responseText: string) {
return {
session: {
state: {
messages: [
{ role: "assistant", content: responseText },
],
},
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
};
}
it("uses default prompt when no overrides provided", async () => {
const mockAgent = createRefineMockAgent("Refined text here");
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
await refineText("some text", "clarify", "/tmp/project");
expect(mockCreateFnAgent).toHaveBeenCalled();
const call = mockCreateFnAgent.mock.calls[0];
expect(call[0].systemPrompt).toMatch(/^You are a text refinement assistant/);
});
it("uses override prompt when promptOverrides provided", async () => {
const mockAgent = createRefineMockAgent("Refined text here");
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
await refineText("some text", "clarify", "/tmp/project", {
"ai-refine-system": customPrompt,
});
expect(mockCreateFnAgent).toHaveBeenCalled();
const call = mockCreateFnAgent.mock.calls[0];
expect(call[0].systemPrompt).toBe(customPrompt);
});
it("falls back to default prompt when override is empty string", async () => {
const mockAgent = createRefineMockAgent("Refined text here");
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
await refineText("some text", "simplify", "/tmp/project", {
"ai-refine-system": "",
});
expect(mockCreateFnAgent).toHaveBeenCalled();
const call = mockCreateFnAgent.mock.calls[0];
expect(call[0].systemPrompt).toMatch(/^You are a text refinement assistant/);
});
it("does not introduce unexpected model/provider override fields in createFnAgent", async () => {
const mockAgent = createRefineMockAgent("Refined text here");
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
await refineText("some text", "expand", "/tmp/project", {
"ai-refine-system": customPrompt,
});
expect(mockCreateFnAgent).toHaveBeenCalled();
const call = mockCreateFnAgent.mock.calls[0];
const agentConfig = call[0];
// Verify only expected fields are present
expect(agentConfig).toHaveProperty("cwd");
expect(agentConfig).toHaveProperty("systemPrompt");
expect(agentConfig).toHaveProperty("tools");
// Verify no unexpected model override fields
expect(agentConfig).not.toHaveProperty("modelProvider");
expect(agentConfig).not.toHaveProperty("modelId");
expect(agentConfig).not.toHaveProperty("provider");
expect(agentConfig).not.toHaveProperty("model");
});
it("prompt overrides do not affect other prompt keys", async () => {
const mockAgent = createRefineMockAgent("Refined text here");
mockCreateFnAgent.mockResolvedValueOnce(mockAgent);
// Provide overrides for a different key only
await refineText("some text", "clarify", "/tmp/project", {
"workflow-step-refine": "Should not affect AI refine",
});
expect(mockCreateFnAgent).toHaveBeenCalled();
const call = mockCreateFnAgent.mock.calls[0];
// AI refine should use its own default prompt, not affected by workflow-step-refine override
expect(call[0].systemPrompt).toMatch(/^You are a text refinement assistant/);
});
});
});