feat: add per-provider timeout for usage panel to prevent blocking on slow responses

fix: update scheduler filesystem path from .kb to .fusion for task validation

style: clean up TaskDetailModal styling with reusable CSS classes

test: add comprehensive tests for file-service operations with mocked filesystem
This commit is contained in:
gsxdsm
2026-04-01 22:38:18 -07:00
parent f96879380c
commit 5654d2e230
30 changed files with 1281 additions and 142 deletions

View File

@@ -94,7 +94,7 @@ function createMockServer(portToReturn: number = 0) {
return server;
}
const mockListen = vi.fn((port: number) => {
const mockListen = vi.fn<(port: number) => EventEmitter<DefaultEventMap> & { listen: ReturnType<typeof vi.fn>; address: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> }>((port: number) => {
const server = createMockServer(port);
process.nextTick(() => server.emit("listening"));
return server;
@@ -460,7 +460,7 @@ describe("runDashboard — PR-first auto-merge queue", () => {
});
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
});
it("uses PR lifecycle instead of aiMergeTask when mergeStrategy is pull-request", async () => {
@@ -485,7 +485,7 @@ describe("runDashboard — WorktreePool wiring", () => {
resetGitHubMocks();
// Re-set TaskStore mock (clearAllMocks wipes implementations)
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
// Re-set engine mocks
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
@@ -553,7 +553,7 @@ describe("runDashboard — auto-merge pause exclusion", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
@@ -631,7 +631,7 @@ describe("runDashboard — immediate resume on unpause", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
@@ -737,7 +737,7 @@ describe("runDashboard — engine pause/unpause cycle", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
@@ -778,7 +778,7 @@ describe("runDashboard — port fallback on EADDRINUSE", () => {
vi.clearAllMocks();
resetGitHubMocks();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
const engine = await import("@fusion/engine");
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }),
@@ -896,7 +896,7 @@ describe("runDashboard — enginePaused (soft pause)", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
@@ -1011,7 +1011,7 @@ describe("runDashboard — --paused flag", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
@@ -1072,7 +1072,7 @@ describe("runDashboard — --paused flag", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }),
@@ -1125,7 +1125,7 @@ describe("runDashboard — --dev mode", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
const engine = await import("@fusion/engine");
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
Promise.resolve({ merged: true }),
@@ -1239,7 +1239,7 @@ describe("runDashboard — merge conflict retry logic", () => {
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
// Default mock store.getTask implementation
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
@@ -1573,16 +1573,16 @@ describe("promptForPort", () => {
vi.mocked(createInterface).mockReturnValue(mockRl as unknown as ReturnType<typeof createInterface>);
// Simulate that the promise rejects when SIGINT is triggered
const removeListenerSpy = vi.spyOn(process, "removeListener").mockImplementation(() => {});
const removeListenerSpy = vi.spyOn(process, "removeListener").mockImplementation(() => process as unknown as void);
// Trigger SIGINT handler immediately to test rejection
let sigintHandler: (() => void) | null = null;
const onSpy = vi.spyOn(process, "on").mockImplementation((event: string, handler: (...args: unknown[]) => void) => {
const onSpy = vi.spyOn(process, "on" as never).mockImplementation(((event: string, handler: (...args: unknown[]) => void) => {
if (event === "SIGINT") {
sigintHandler = handler as () => void;
}
return process;
});
}) as never);
mockRl.question.mockImplementation(() => {
// Simulate SIGINT during prompt

View File

@@ -184,7 +184,7 @@ export async function processPullRequestMergeTask(
return "merged";
}
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean } = {}) {
export async function runDashboard(port: number, opts: { paused?: boolean; dev?: boolean; interactive?: boolean; open?: boolean } = {}) {
// Handle interactive port selection
let selectedPort = port;
if (opts.interactive) {

View File

@@ -2652,6 +2652,10 @@ ${stepsSection}`;
return this.rootDir;
}
getTasksDir(): string {
return this.tasksDir;
}
private generateSpecifiedPrompt(task: Task): string {
const deps =
task.dependencies.length > 0

View File

@@ -35,9 +35,9 @@ describe("column fixed-width CSS", () => {
});
describe("desktop .board grid template", () => {
it("uses repeat(6, minmax(280px, 1fr)) for 6 columns", () => {
it("uses repeat(6, minmax(260px, 1fr)) for 6 columns", () => {
expect(css).toContain(
"grid-template-columns: repeat(6, minmax(280px, 1fr))",
"grid-template-columns: repeat(6, minmax(260px, 1fr))",
);
});
});

View File

@@ -1123,28 +1123,34 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
)}
{/* Mission items */}
{missions.map((mission) => (
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{missions.map((mission: any) => {
const m = mission as { id: string; title: string; description?: string; status: string };
const selId = selectedMission as { id: string } | null;
const isSelected = selId && selId.id === m.id;
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
return (
<div
key={mission.id}
className={`mission-item ${selectedMission?.id === mission.id ? "selected" : ""}`}
key={m.id}
className={`mission-item ${isSelected ? "selected" : ""}`}
onClick={() => handleSelectMission(mission)}
>
<div className="mission-item-content">
<div className="mission-item-header">
<Target size={16} />
<span className="mission-item-title">{mission.title}</span>
<span className="mission-item-title">{m.title}</span>
<span
className="status-badge small"
style={{
backgroundColor: missionStatusColors[mission.status].bg,
color: missionStatusColors[mission.status].text,
backgroundColor: statusColors.bg,
color: statusColors.text,
}}
>
{mission.status}
{m.status}
</span>
</div>
{mission.description && (
<p className="mission-item-description">{mission.description}</p>
{m.description && (
<p className="mission-item-description">{m.description}</p>
)}
</div>
<div className="mission-item-actions" onClick={(e) => e.stopPropagation()}>
@@ -1157,14 +1163,15 @@ export function MissionManager({ isOpen, onClose, addToast, onSelectTask, availa
</button>
<button
className="icon-btn small danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: mission.id })}
onClick={() => setDeleteConfirmId({ type: "mission", id: m.id })}
title="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
);
})}
{/* Edit mission form */}
{editingMissionId && (

View File

@@ -176,7 +176,7 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
</span>
<span
className="changes-file-stat"
title={`+${fileEntry.additions} -${fileEntry.deletions}${fileEntry.size ? ` · ${formatFileSize(fileEntry.size)}` : ""}`}
title={`+${fileEntry.additions} -${fileEntry.deletions}`}
>
+{fileEntry.additions} -{fileEntry.deletions}
</span>

View File

@@ -770,7 +770,7 @@ export function TaskDetailModal({
</div>
<div className="detail-section">
{!isEditingSpec && (
<div style={{ marginBottom: "12px" }}>
<div className="detail-spec-edit-trigger">
<button className="btn btn-sm" onClick={enterSpecEditMode}>
Edit
</button>
@@ -848,19 +848,11 @@ export function TaskDetailModal({
<div className="detail-section">
<h4>Attachments</h4>
{attachments.length > 0 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: "12px", marginBottom: "8px" }}>
<div className="detail-attachments-grid">
{attachments.map((a) => (
<div
key={a.filename}
style={{
position: "relative",
border: "1px solid var(--border, #333)",
borderRadius: "6px",
padding: "4px",
background: "var(--bg-secondary, #1a1a2e)",
}}
>
<div key={a.filename} className="detail-attachment-card">
<a
className="detail-attachment-link"
href={`/api/tasks/${task.id}/attachments/${a.filename}`}
target="_blank"
rel="noopener noreferrer"
@@ -868,30 +860,15 @@ export function TaskDetailModal({
<img
src={`/api/tasks/${task.id}/attachments/${a.filename}`}
alt={a.originalName}
style={{ maxWidth: "150px", maxHeight: "100px", display: "block", borderRadius: "4px" }}
className="detail-attachment-image"
/>
</a>
<div style={{ fontSize: "11px", marginTop: "4px", opacity: 0.7 }}>
<div className="detail-attachment-meta">
{a.originalName} ({formatBytes(a.size)})
</div>
<button
className="detail-attachment-delete"
onClick={() => handleDeleteAttachment(a.filename)}
style={{
position: "absolute",
top: "2px",
right: "2px",
background: "rgba(0,0,0,0.6)",
color: "#fff",
border: "none",
borderRadius: "50%",
width: "20px",
height: "20px",
cursor: "pointer",
fontSize: "12px",
lineHeight: "20px",
textAlign: "center",
padding: 0,
}}
title="Delete attachment"
>
×
@@ -900,14 +877,14 @@ export function TaskDetailModal({
))}
</div>
) : (
<div style={{ opacity: 0.5, marginBottom: "8px" }}>(no attachments)</div>
<div className="detail-empty-inline">(no attachments)</div>
)}
<input
className="detail-hidden-file-input"
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleUpload}
style={{ display: "none" }}
/>
<button
className="btn btn-sm"
@@ -942,15 +919,6 @@ export function TaskDetailModal({
className="dep-remove-btn"
onClick={(e) => handleRemoveDep(e, dep)}
title={`Remove dependency ${dep}`}
style={{
marginLeft: "6px",
background: "none",
border: "none",
color: "var(--text-secondary, #888)",
cursor: "pointer",
fontSize: "14px",
padding: "0 4px",
}}
>
×
</button>
@@ -958,9 +926,9 @@ export function TaskDetailModal({
))}
</ul>
) : (
<div style={{ opacity: 0.5, marginBottom: "8px" }}>(no dependencies)</div>
<div className="detail-empty-inline">(no dependencies)</div>
)}
<div className="dep-trigger-wrap" style={{ position: "relative" }}>
<div className="dep-trigger-wrap">
<button
type="button"
className="btn btn-sm dep-trigger"
@@ -1070,7 +1038,7 @@ export function TaskDetailModal({
</button>
</>
)}
<div style={{ flex: 1 }} />
<div className="modal-actions-spacer" />
{task.column === "in-review" ? (
<>
<button className="btn btn-sm" onClick={() => handleMove("in-progress")}>
@@ -1096,56 +1064,33 @@ export function TaskDetailModal({
</div>
{showRefineModal && (
<div
className="modal-overlay open"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 100,
}}
className="modal-overlay open detail-refine-overlay"
onClick={handleCloseRefineModal}
>
<div
className="modal"
style={{ maxWidth: "500px", width: "90%", margin: "0" }}
className="modal detail-refine-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3 style={{ margin: 0 }}>Refine</h3>
<h3 className="detail-refine-title">Refine</h3>
<button className="modal-close" onClick={handleCloseRefineModal}>
&times;
</button>
</div>
<div className="detail-body">
<p style={{ marginBottom: "12px", opacity: 0.8 }}>
<p className="detail-refine-help">
Describe what needs to be refined or improved...
</p>
<textarea
className="detail-refine-textarea"
value={refineFeedback}
onChange={(e) => setRefineFeedback(e.target.value)}
placeholder="Enter your feedback here..."
rows={6}
style={{
width: "100%",
padding: "12px",
borderRadius: "6px",
border: "1px solid var(--border, #30363d)",
background: "var(--bg-primary, #0d1117)",
color: "var(--text-primary, #c9d1d9)",
fontSize: "14px",
resize: "vertical",
minHeight: "120px",
}}
maxLength={2000}
autoFocus
/>
<div style={{ marginTop: "8px", textAlign: "right", fontSize: "12px", opacity: 0.6 }}>
<div className="detail-refine-char-count">
{refineFeedback.length}/2000 characters
</div>
</div>

View File

@@ -1146,10 +1146,10 @@ describe("TaskDetailModal", () => {
const actions = container.querySelector(".modal-actions");
expect(actions).toBeTruthy();
// Spacer div with flex: 1 separates left actions from right actions
const spacer = actions!.querySelector("div");
// Spacer div separates left actions from right actions via CSS class
const spacer = actions!.querySelector(".modal-actions-spacer");
expect(spacer).toBeTruthy();
expect((spacer as HTMLElement).style.flex).toContain("1");
expect((spacer as HTMLElement).className).toContain("modal-actions-spacer");
});
it("tab buttons use CSS classes instead of inline styles for responsive override", () => {

View File

@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Use vi.mock with __mocks__ pattern
vi.mock("node:fs/promises", () => {
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", () => {
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import file-service
import { listProjectFiles } from "../file-service";
describe("debug", () => {
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/project");
mocks.mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
mocks.mockReaddir.mockResolvedValue([]);
console.log("Calling listProjectFiles...");
try {
const result = await listProjectFiles(mockStore, "./src");
console.log("Result:", result);
console.log("mockStat calls:", mocks.mockStat.mock.calls);
expect(result.path).toBe("src");
expect(result.entries).toEqual([]);
} catch (e) {
console.log("Error:", e);
console.log("mockStat calls:", mocks.mockStat.mock.calls);
throw e;
}
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { dirname, resolve } from "path";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
const basePath = resolve("/test/project");
const filePath = "file.txt/sub.txt";
const resolvedPath = resolve(basePath, filePath);
const parentDir = dirname(resolvedPath);
// Test the mock directly
mocks.mockStat.mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
});
const result = await mocks.mockStat("/test/some/path");
console.log("Direct mock result:", result);
console.log("Direct mock isDirectory():", result.isDirectory());
// Now test writeProjectFile
try {
await writeProjectFile(mockStore, filePath, "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { dirname, resolve } from "path";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// Track all stat calls
let callCount = 0;
mocks.mockStat.mockImplementation(async (path: string) => {
callCount++;
console.log(`stat call #${callCount}:`, path);
if (callCount === 1) {
// First call - file doesn't exist
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
}
if (callCount === 2) {
// Second call - parent is a file
return {
isDirectory: () => false,
isFile: () => true,
};
}
if (callCount === 3) {
// Third call - after write
return {
isDirectory: () => false,
isFile: () => true,
size: 100,
mtime: new Date(),
};
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { dirname, resolve } from "path";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
let callCount = 0;
mocks.mockStat.mockImplementation(async (path: string) => {
callCount++;
console.log(`stat call #${callCount}:`, path);
// Return a simple object
const result = {
isDirectory: () => path.includes("/file.txt"),
isFile: () => !path.includes("/file.txt"),
};
console.log(`stat #${callCount} returning:`, result);
console.log(`stat #${callCount} isDirectory():`, result.isDirectory());
return result;
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test - parent is not a directory", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// First call: file doesn't exist (throw ENOENT)
// Second call: parent is a file, not a directory (return object with isDirectory: false)
// Third call: after write, get file stats
mocks.mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // First: file doesn't exist
.mockResolvedValueOnce({ // Second: parent is a file
isDirectory: () => false,
isFile: () => true,
})
.mockResolvedValueOnce({ // Third: after write
isDirectory: () => false,
isFile: () => true,
size: 100,
mtime: new Date(),
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
expect(e.message).toContain("Parent is not a directory");
}
});
});

View File

@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
mocks.mockStat.mockImplementation(async (path: string) => {
console.log("stat called:", path);
throw { code: "ENOENT" };
});
});
it("test - parent is not a directory", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// First call: file doesn't exist (throw ENOENT)
// Second call: parent is a file, not a directory (return object with isDirectory: false)
mocks.mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // First: file doesn't exist
.mockImplementation(async (path: string) => { // Subsequent calls
console.log("Second stat call:", path);
return {
isDirectory: () => false,
isFile: () => true,
};
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { writeProjectFile } from "../file-service";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
vi.mock("node:fs/promises", () => {
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", () => {
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
describe("debug", () => {
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
mocks.mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // File doesn't exist
.mockResolvedValueOnce({
isDirectory: () => false,
isFile: () => true,
}); // Parent is a file
console.log("mockStat calls before:", mocks.mockStat.mock.calls);
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
} catch (e) {
console.log("Error:", e);
console.log("mockStat calls after:", mocks.mockStat.mock.calls);
}
});
});

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from "vitest";
import { writeProjectFile } from "../file-service";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
vi.mock("node:fs/promises", () => {
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", () => {
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
describe("debug", () => {
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// Setup mock to return specific values for each call
mocks.mockStat.mockImplementation((path: string) => {
console.log("stat called with:", path);
if (path === "/test/project/file.txt/sub.txt") {
return Promise.reject({ code: "ENOENT" });
}
if (path === "/test/project/file.txt") {
return Promise.resolve({
isDirectory: () => false,
isFile: () => true,
});
}
return Promise.reject({ code: "ENOENT" });
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
} catch (e) {
console.log("Error:", e);
}
});
});

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// Setup mock to return specific values for each call
mocks.mockStat.mockImplementation((path: string) => {
console.log("stat called with:", path);
if (path === "/test/project/file.txt/sub.txt") {
return Promise.reject({ code: "ENOENT" });
}
if (path === "/test/project/file.txt") {
return Promise.resolve({
isDirectory: () => false,
isFile: () => true,
});
}
return Promise.reject({ code: "ENOENT" });
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
mocks.mockStat.mockImplementation((path: string) => {
console.log("stat called with:", path);
if (path === "/test/project/file.txt/sub.txt") {
return Promise.reject({ code: "ENOENT" });
}
if (path === "/test/project/file.txt") {
return Promise.resolve({
isDirectory: () => false,
isFile: () => true,
});
}
return Promise.reject({ code: "ENOENT" });
});
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
expect(e.message).toContain("Parent is not a directory");
}
});
});

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// Setup mock
mocks.mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // First call - file doesn't exist
.mockResolvedValueOnce({ // Second call - parent is a file
isDirectory: () => false,
isFile: () => true,
});
console.log("Before call");
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
console.log("mockStat calls:", mocks.mockStat.mock.calls);
}
});
});

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// Setup mock to return specific values
mocks.mockStat.mockImplementation(async (path: string) => {
console.log("stat called:", path);
if (path === "/test/project/file.txt/sub.txt") {
throw { code: "ENOENT" };
}
if (path === "/test/project/file.txt") {
const result = {
isDirectory: () => false,
isFile: () => true,
};
console.log("Returning for parent:", result);
return result;
}
throw { code: "ENOENT" };
});
try {
await writeProjectFile(mockStore, "file.txt/sub.txt", "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { join, resolve, dirname } from "path";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
// Log what paths will be used
const basePath = resolve("/test/project");
const filePath = "file.txt/sub.txt";
const resolvedPath = resolve(basePath, filePath);
const parentDir = dirname(resolvedPath);
console.log("basePath:", basePath);
console.log("filePath:", filePath);
console.log("resolvedPath:", resolvedPath);
console.log("parentDir:", parentDir);
// Setup mock to return specific values
mocks.mockStat.mockImplementation(async (path: string) => {
console.log("stat called:", path);
if (path === resolvedPath) {
console.log("File path match!");
throw { code: "ENOENT" };
}
if (path === parentDir) {
console.log("Parent dir match!");
return {
isDirectory: () => false,
isFile: () => true,
};
}
console.log("No match for:", path);
throw { code: "ENOENT" };
});
try {
await writeProjectFile(mockStore, filePath, "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { dirname, resolve } from "path";
// Create mock functions that can be configured in tests
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
// Hoist mocks alongside vi.mock
vi.mock("node:fs/promises", async () => {
const actual = await import("node:fs/promises");
return {
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
vi.mock("node:fs", async () => {
const actual = await import("node:fs");
return {
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Import AFTER mocks are set up
import { writeProjectFile } from "../file-service";
describe("debug", () => {
beforeEach(() => {
mocks.mockStat.mockReset();
});
it("test", async () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
mockGetRootDir.mockReturnValue("/test/project");
const basePath = resolve("/test/project");
const filePath = "file.txt/sub.txt";
const resolvedPath = resolve(basePath, filePath);
const parentDir = dirname(resolvedPath);
// Create a proper stats object
const fileNotDirStats = {
isDirectory: () => false,
isFile: () => true,
size: 100,
mtime: new Date(),
};
// Setup mock - note: use .mockResolvedValue for promises
mocks.mockStat.mockImplementation(async (path: string) => {
console.log("stat called:", path);
if (path === resolvedPath) {
console.log("File path - throwing ENOENT");
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
}
if (path === parentDir) {
console.log("Parent dir - returning file stats");
console.log("Returning:", fileNotDirStats);
console.log("isDirectory():", fileNotDirStats.isDirectory());
return fileNotDirStats;
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});
try {
await writeProjectFile(mockStore, filePath, "content");
console.log("Success!");
} catch (e: any) {
console.log("Error:", e.message);
}
});
});

View File

@@ -14,34 +14,44 @@ import {
} from "../file-service.js";
import type { TaskStore } from "@fusion/core";
// Mock node:fs/promises
const mockReaddir = vi.fn();
const mockReadFile = vi.fn();
const mockWriteFile = vi.fn();
const mockStat = vi.fn();
// Create mock functions that can be configured in tests
// Use vi.hoisted to hoist alongside vi.mock
const mocks = vi.hoisted(() => ({
mockReaddir: vi.fn(),
mockReadFile: vi.fn(),
mockWriteFile: vi.fn(),
mockStat: vi.fn(),
mockExistsSync: vi.fn(),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
// Hoist mocks alongside vi.mock - must include default export
vi.mock("node:fs/promises", () => {
return {
...actual,
readdir: (...args: any[]) => mockReaddir(...args),
readFile: (...args: any[]) => mockReadFile(...args),
writeFile: (...args: any[]) => mockWriteFile(...args),
stat: (...args: any[]) => mockStat(...args),
default: {
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
},
readdir: mocks.mockReaddir,
readFile: mocks.mockReadFile,
writeFile: mocks.mockWriteFile,
stat: mocks.mockStat,
};
});
// Mock node:fs
const mockExistsSync = vi.fn();
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
vi.mock("node:fs", () => {
return {
...actual,
existsSync: (...args: any[]) => mockExistsSync(...args),
default: {
existsSync: mocks.mockExistsSync,
},
existsSync: mocks.mockExistsSync,
};
});
// Export mocks for use in tests
export const { mockReaddir, mockReadFile, mockWriteFile, mockStat, mockExistsSync } = mocks;
describe("FileServiceError", () => {
it("constructor sets code and name correctly", () => {
const error = new FileServiceError("Test message", "ETEST");
@@ -227,15 +237,16 @@ describe("path traversal protection", () => {
it("allows valid relative paths with dots", async () => {
mockGetRootDir.mockReturnValue("/project");
// Need successful stat for this test to pass validation
mockStat.mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
});
mockReaddir.mockResolvedValue([]);
// This should NOT throw
await expect(listProjectFiles(mockStore, "./src")).resolves.not.toThrow();
// Should resolve ./src to src and succeed
const result = await listProjectFiles(mockStore, "./src");
expect(result.path).toBe("src");
expect(result.entries).toEqual([]);
});
it("allows paths containing single dots in middle", async () => {
@@ -246,7 +257,10 @@ describe("path traversal protection", () => {
});
mockReaddir.mockResolvedValue([]);
await expect(listProjectFiles(mockStore, "src/./components")).resolves.not.toThrow();
// Should resolve src/./components to src/components and succeed
const result = await listProjectFiles(mockStore, "src/./components");
expect(result.path).toBe("src/components");
expect(result.entries).toEqual([]);
});
});
});
@@ -360,9 +374,8 @@ describe("writeProjectFile", () => {
mockGetRootDir.mockReturnValue("/test/project");
mockStat
.mockRejectedValueOnce({ code: "ENOENT" }) // File doesn't exist
.mockRejectedValueOnce({ code: "ENOENT" }); // Parent doesn't exist
.mockRejectedValueOnce({ code: "ENOENT" }); // Parent doesn't exist (this should throw)
await expect(writeProjectFile(mockStore, "missing/file.txt", "content")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "missing/file.txt", "content")).rejects.toThrow("Parent directory does not exist");
});
@@ -375,7 +388,6 @@ describe("writeProjectFile", () => {
isFile: () => true,
}); // Parent is a file
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow(FileServiceError);
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow("Parent is not a directory");
});
@@ -455,7 +467,7 @@ describe("task file operations", () => {
await readFile(mockStore, "FN-123", "PROMPT.md");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/KB-123/PROMPT.md",
"/project/.fusion/tasks/FN-123/PROMPT.md",
"utf-8",
);
});
@@ -476,7 +488,7 @@ describe("task file operations", () => {
await readFile(mockStore, "FN-123", "PROMPT.md");
expect(mockReadFile).toHaveBeenCalledWith(
"/project/.fusion/tasks/KB-123/PROMPT.md",
"/project/.fusion/tasks/FN-123/PROMPT.md",
"utf-8",
);
});

View File

@@ -1228,19 +1228,60 @@ async function fetchZaiUsage(): Promise<ProviderUsage> {
* Fetch usage data from all configured providers with caching.
* Results are cached for 30 seconds to avoid hitting provider API rate limits.
*/
/** Max time to wait for any individual provider fetch (ms) */
const PROVIDER_FETCH_TIMEOUT_MS = 10_000; // 10 seconds
/**
* Wrap a provider fetch with a timeout. Returns the provider result or an
* error provider if the fetch takes longer than PROVIDER_FETCH_TIMEOUT_MS.
*/
function withTimeout(
providerPromise: Promise<ProviderUsage>,
providerName: string,
timeoutMs: number = PROVIDER_FETCH_TIMEOUT_MS,
): Promise<ProviderUsage> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve({
name: providerName,
icon: "⏱️",
status: "error",
error: "Timed out",
windows: [],
});
}, timeoutMs);
providerPromise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((err: any) => {
clearTimeout(timer);
resolve({
name: providerName,
icon: "⏱️",
status: "error",
error: err.message || "Failed",
windows: [],
});
});
});
}
export async function fetchAllProviderUsage(_authStorage?: AuthStorageLike): Promise<ProviderUsage[]> {
// Check cache
if (usageCache && Date.now() - usageCache.timestamp < CACHE_TTL_MS) {
return usageCache.data;
}
// Fetch all providers in parallel
// Fetch all providers in parallel with per-provider timeout
const results = await Promise.allSettled([
fetchClaudeUsage(),
fetchCodexUsage(),
fetchGeminiUsage(),
fetchMinimaxUsage(),
fetchZaiUsage(),
withTimeout(fetchClaudeUsage(), "Claude"),
withTimeout(fetchCodexUsage(), "Codex"),
withTimeout(fetchGeminiUsage(), "Gemini"),
withTimeout(fetchMinimaxUsage(), "Minimax"),
withTimeout(fetchZaiUsage(), "Zai"),
]);
const providers: ProviderUsage[] = [];

View File

@@ -77,6 +77,7 @@ function createMockStore(overrides: Record<string, any> = {}) {
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ ...DEFAULT_SETTINGS }),
getRootDir: vi.fn().mockReturnValue("/tmp/root"),
getTasksDir: vi.fn().mockReturnValue("/tmp/root/.fusion/tasks"),
updateStep: vi.fn().mockImplementation(async (id: string, step: number, status: StepStatus) => {
return makeTaskDetail(id, "in-progress");
}),

View File

@@ -49,6 +49,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
on: vi.fn(),
off: vi.fn(),
...overrides,
@@ -276,7 +277,7 @@ describe("Scheduler", () => {
vi.mocked(existsSync).mockImplementation((path) => {
const value = String(path);
return value.includes(".kb/tasks/FN-010") || value.includes("PROMPT.md");
return value.includes(".fusion/tasks/FN-010") || value.includes("PROMPT.md");
});
vi.mocked(readFile).mockResolvedValue("# Prompt\n" as any);
@@ -301,6 +302,7 @@ describe("Scheduler", () => {
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
});
// Set up mocks directly on the store
@@ -341,6 +343,7 @@ describe("Scheduler", () => {
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
});
const moveTask = vi.fn().mockResolvedValue(undefined);
@@ -381,6 +384,7 @@ describe("Scheduler", () => {
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
});
const moveTask = vi.fn().mockResolvedValue(undefined);
@@ -418,6 +422,7 @@ describe("Scheduler", () => {
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
});
const moveTask = vi.fn().mockResolvedValue(undefined);
@@ -459,6 +464,7 @@ describe("Scheduler", () => {
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/test/project"),
getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"),
});
const moveTask = vi.fn().mockResolvedValue(undefined);

View File

@@ -184,7 +184,7 @@ export class Scheduler {
* @returns Object with `valid: true` if checks pass, or `valid: false` with a `reason` string if they fail
*/
private async validateTaskFilesystem(id: string): Promise<{ valid: boolean; reason?: string }> {
const taskDir = join(this.store.getRootDir(), ".kb", "tasks", id);
const taskDir = join(this.store.getTasksDir(), id);
// Check if task directory exists
if (!existsSync(taskDir)) {