feat(KB-503): merge kb/kb-503
- feat(KB-503): complete Step 5 other command project support - fix(KB-503): align task resolution with shared project context - fix(KB-503): preserve local task command fallback - test(KB-503): complete Step 4 task command coverage - test(KB-503): harden Step 3 CLI parser coverage - fix(KB-503): keep settings set scope explicit - fix(KB-503): allow implicit project resolution for project settings - fix(KB-503): enforce explicit settings scope rules - fix(KB-503): restore shared settings resolution behavior - fix(KB-503): align settings scope and project flag behavior - feat(KB-503): complete Step 5 — add project-aware settings, git, and backup behavior - fix(KB-503): enforce settings scope for global and project modes - fix(KB-503): preserve project-scoped settings and cwd-aware git helpers - fix(KB-503): add scoped settings behavior and git project tests - feat(KB-503): complete Step 5 — add project-aware git and backup coverage - test(KB-503): cover remaining project-aware task command paths - fix(KB-503): use shared resolution flow for all task commands - fix(KB-503): restore legacy task fallback and correct task mocks - fix(KB-503): align task resolution order and kb storage paths - fix(KB-503): restore local task store fallback without project flag - test(KB-503): cover remaining project-aware task handlers - fix(KB-503): preserve branch naming and avoid duplicate log resolution - test(KB-503): expand project-aware task command coverage - fix(KB-503): restore cwd fallback for path-based task commands - fix(KB-503): add project-aware task output and tests - fix(KB-503): harden project command output and coverage - fix(KB-503): preserve legacy task resolution without project flag - test(KB-503): add bin routing coverage for project flag parsing - feat(KB-503): complete Step 3 — CLI argument parsing updates - fix(KB-503): add defaultProjectId to GlobalSettings type and fix TypeScript errors - test(KB-503): fix resolveProject mock in task tests for runTaskLogs follow mode - docs(KB-503): add multi-project CLI documentation and changeset - test(KB-503): add project-context mock to task tests - test(KB-503): update git tests for new cwd parameter - feat(KB-503): Step 3 — CLI argument parsing updates with --project flag support - feat(KB-503): Steps 1-2 — project context utilities and project subcommands
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
loginProvider,
|
||||
logoutProvider,
|
||||
fetchModels,
|
||||
addComment,
|
||||
addSteeringComment,
|
||||
addTaskComment,
|
||||
updateTaskComment,
|
||||
deleteTaskComment,
|
||||
@@ -28,11 +28,6 @@ import {
|
||||
unregisterProject,
|
||||
fetchProjectHealth,
|
||||
fetchActivityFeed,
|
||||
fetchScripts,
|
||||
addScript,
|
||||
removeScript,
|
||||
runScript,
|
||||
waitForScriptCompletion,
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
fetchFirstRunStatus,
|
||||
@@ -499,7 +494,7 @@ describe("logoutProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("addComment", () => {
|
||||
describe("addSteeringComment", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
@@ -516,7 +511,7 @@ describe("addComment", () => {
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
comments: [
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
@@ -529,12 +524,12 @@ describe("addComment", () => {
|
||||
it("sends POST with text and returns updated task", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
|
||||
|
||||
const result = await addComment("FN-001", "Please handle the edge case");
|
||||
const result = await addSteeringComment("FN-001", "Please handle the edge case");
|
||||
|
||||
expect(result.id).toBe("FN-001");
|
||||
expect(result.comments).toHaveLength(1);
|
||||
expect(result.comments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
|
||||
expect(result.steeringComments).toHaveLength(1);
|
||||
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text: "Please handle the edge case" }),
|
||||
@@ -546,7 +541,7 @@ describe("addComment", () => {
|
||||
mockFetchResponse(false, { error: "Task not found" })
|
||||
);
|
||||
|
||||
await expect(addComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
await expect(addSteeringComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2017,7 +2012,6 @@ describe("fetchActivityFeed", () => {
|
||||
expect(call[0]).toContain("since=2026-01-01T00%3A00%3A00.000Z");
|
||||
expect(call[0]).toContain("projectId=proj_abc123");
|
||||
expect(call[0]).toContain("type=task%3Acreated");
|
||||
expect(call[0]).not.toContain("types=");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2161,94 +2155,3 @@ describe("fetchProjectConfig", () => {
|
||||
expect(result.rootDir).toBe("/path/to/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scripts API helpers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetchScripts uses GET /api/scripts", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }));
|
||||
|
||||
const result = await fetchScripts();
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts",
|
||||
expect.objectContaining({ headers: { "Content-Type": "application/json" } }),
|
||||
);
|
||||
expect(result).toEqual({ build: "pnpm build" });
|
||||
});
|
||||
|
||||
it("addScript posts the expected payload", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { build: "pnpm build" }, 201));
|
||||
|
||||
await addScript("build", "pnpm build");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "build", command: "pnpm build" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removeScript URL-encodes the script name", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}, 200));
|
||||
|
||||
await removeScript("build_script");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts/build_script",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runScript returns the terminal session handle", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { command: "pnpm test", sessionId: "sess-1" }, 201));
|
||||
|
||||
const result = await runScript("test", ["--watch"]);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"/api/scripts/test/run",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args: ["--watch"] }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ command: "pnpm test", sessionId: "sess-1" });
|
||||
});
|
||||
|
||||
it("waitForScriptCompletion polls until the session finishes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockFetchResponse(true, {
|
||||
id: "sess-1",
|
||||
command: "pnpm test",
|
||||
running: true,
|
||||
exitCode: null,
|
||||
output: "starting",
|
||||
startTime: new Date().toISOString(),
|
||||
}))
|
||||
.mockReturnValueOnce(mockFetchResponse(true, {
|
||||
id: "sess-1",
|
||||
command: "pnpm test",
|
||||
running: false,
|
||||
exitCode: 1,
|
||||
output: "done",
|
||||
startTime: new Date().toISOString(),
|
||||
}));
|
||||
globalThis.fetch = fetchMock;
|
||||
|
||||
const promise = waitForScriptCompletion("sess-1");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
const result = await promise;
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ output: "done", exitCode: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,17 +283,6 @@ export function fetchSessionFiles(taskId: string): Promise<string[]> {
|
||||
return api<string[]>(`/tasks/${taskId}/session-files`);
|
||||
}
|
||||
|
||||
export interface TaskFileDiff {
|
||||
path: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
diff: string;
|
||||
oldPath?: string;
|
||||
}
|
||||
|
||||
export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
|
||||
return api<TaskFileDiff[]>(`/tasks/${taskId}/file-diffs`);
|
||||
}
|
||||
|
||||
export function fetchTaskComments(id: string): Promise<TaskComment[]> {
|
||||
return api<TaskComment[]>(`/tasks/${id}/comments`);
|
||||
}
|
||||
@@ -318,15 +307,13 @@ export function deleteTaskComment(id: string, commentId: string): Promise<Task>
|
||||
});
|
||||
}
|
||||
|
||||
export function addComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/comments`, {
|
||||
export function addSteeringComment(id: string, text: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
}
|
||||
|
||||
export const addSteeringComment = addComment;
|
||||
|
||||
export function requestSpecRevision(id: string, feedback: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/spec/revise`, {
|
||||
method: "POST",
|
||||
@@ -1344,56 +1331,6 @@ export function fetchWorkflowResults(taskId: string): Promise<WorkflowStepResult
|
||||
return api<WorkflowStepResult[]>(`/tasks/${encodeURIComponent(taskId)}/workflow-results`);
|
||||
}
|
||||
|
||||
// ── Scripts ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type ScriptsMap = Record<string, string>;
|
||||
|
||||
export interface RunScriptResponse {
|
||||
command: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export async function waitForScriptCompletion(sessionId: string): Promise<{ output: string; exitCode: number }> {
|
||||
for (;;) {
|
||||
const session = await getTerminalSession(sessionId);
|
||||
if (!session.running) {
|
||||
return {
|
||||
output: session.output,
|
||||
exitCode: session.exitCode ?? 0,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch all project-defined scripts */
|
||||
export function fetchScripts(): Promise<ScriptsMap> {
|
||||
return api<ScriptsMap>("/scripts");
|
||||
}
|
||||
|
||||
/** Create a new project-defined script */
|
||||
export async function addScript(name: string, command: string): Promise<void> {
|
||||
await api<ScriptsMap>("/scripts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, command }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove a project-defined script */
|
||||
export async function removeScript(name: string): Promise<void> {
|
||||
await api<ScriptsMap>(`/scripts/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Execute a script via the terminal service and return the created session */
|
||||
export function runScript(name: string, args?: string[]): Promise<RunScriptResponse> {
|
||||
return api<RunScriptResponse>(`/scripts/${encodeURIComponent(name)}/run`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ args }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Workflow Step Templates ──────────────────────────────────────────────
|
||||
|
||||
/** Re-export WorkflowStepTemplate type from core */
|
||||
@@ -1882,7 +1819,7 @@ export function fetchProjectHealth(id: string): Promise<ProjectHealth> {
|
||||
return api<ProjectHealth>(`/projects/${encodeURIComponent(id)}/health`);
|
||||
}
|
||||
|
||||
/** Fetch unified activity feed. Supports singular type filtering and server fallback to the current project's local activity log when the central feed is unavailable or empty. */
|
||||
/** Fetch unified activity feed */
|
||||
export function fetchActivityFeed(options?: FeedOptions): Promise<ActivityFeedEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
@@ -1908,37 +1845,6 @@ export function resumeProject(id: string): Promise<ProjectInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch a specific project by ID */
|
||||
export function fetchProject(id: string): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Update a project */
|
||||
export function updateProject(
|
||||
id: string,
|
||||
updates: { name?: string; isolationMode?: "in-process" | "child-process"; status?: "active" | "paused" | "errored" | "initializing" }
|
||||
): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>(`/projects/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/** Detected project from auto-scan */
|
||||
export interface DetectedProject {
|
||||
path: string;
|
||||
suggestedName: string;
|
||||
existing: boolean;
|
||||
}
|
||||
|
||||
/** Auto-detect kb projects in a given base path */
|
||||
export function detectProjects(basePath?: string): Promise<{ projects: DetectedProject[] }> {
|
||||
return api<{ projects: DetectedProject[] }>("/projects/detect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ basePath }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch first run status to detect if user needs setup wizard */
|
||||
export function fetchFirstRunStatus(): Promise<FirstRunStatus> {
|
||||
return api<FirstRunStatus>("/first-run-status");
|
||||
@@ -1962,15 +1868,3 @@ export function fetchProjectTasks(projectId: string, limit?: number, offset?: nu
|
||||
export function fetchProjectConfig(projectId: string): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>(`/projects/${encodeURIComponent(projectId)}/config`);
|
||||
}
|
||||
|
||||
/** Diff information for a task */
|
||||
export interface TaskDiff {
|
||||
files: string[];
|
||||
diffs: Record<string, { stat: string; patch: string }>;
|
||||
}
|
||||
|
||||
/** Fetch diff information for a task */
|
||||
export function fetchTaskDiff(taskId: string): Promise<TaskDiff> {
|
||||
return api<TaskDiff>(`/tasks/${encodeURIComponent(taskId)}/diff`);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,6 @@ function truncatePath(path: string, maxLength: number = 40): string {
|
||||
return `${start}...${end}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two sets of ProjectCardProps for memo equality.
|
||||
* Checks project properties and health metrics to determine if re-render is needed.
|
||||
*/
|
||||
function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardProps): boolean {
|
||||
if (previous.project.id !== next.project.id) return false;
|
||||
if (previous.project.status !== next.project.status) return false;
|
||||
@@ -70,28 +66,6 @@ function areProjectCardPropsEqual(previous: ProjectCardProps, next: ProjectCardP
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual project card component showing project status, health metrics, and actions.
|
||||
*
|
||||
* Displays:
|
||||
* - Project name and truncated path
|
||||
* - Status badge (active, paused, errored, initializing)
|
||||
* - Health metrics: active tasks, running agents, completed tasks
|
||||
* - Last activity timestamp
|
||||
* - Action buttons: Open, Pause/Resume, Remove
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ProjectCard
|
||||
* project={registeredProject}
|
||||
* health={projectHealth}
|
||||
* onSelect={(p) => setCurrentProject(p)}
|
||||
* onPause={(p) => pauseProject(p.id)}
|
||||
* onResume={(p) => resumeProject(p.id)}
|
||||
* onRemove={(p) => unregisterProject(p.id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function ProjectCardInner({
|
||||
project,
|
||||
health,
|
||||
|
||||
@@ -172,6 +172,9 @@ export function SettingsModal({
|
||||
};
|
||||
}, [activeSection, loadAuthStatus]);
|
||||
|
||||
/** Get the scope of the currently active section */
|
||||
const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope;
|
||||
|
||||
const handleLogin = useCallback(async (providerId: string) => {
|
||||
setAuthActionInProgress(providerId);
|
||||
try {
|
||||
|
||||
@@ -396,7 +396,7 @@ export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProje
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleValidate}
|
||||
disabled={state.isValidating || !!state.validationError}
|
||||
disabled={state.isValidating || state.validationError}
|
||||
>
|
||||
{state.isValidating ? (
|
||||
<>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@fusion/core";
|
||||
import { addComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
type CommentType = "user" | "steering";
|
||||
|
||||
interface TaskCommentsProps {
|
||||
task: Task;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
@@ -18,64 +16,21 @@ function formatCommentTimestamp(comment: TaskComment): string {
|
||||
return comment.updatedAt ? `${label} (edited)` : label;
|
||||
}
|
||||
|
||||
function formatRelativeTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return "just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [commentType, setCommentType] = useState<CommentType>("user");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Unified comments from task.comments (includes migrated steering comments)
|
||||
const comments = useMemo(() => task.comments || [], [task.comments]);
|
||||
|
||||
// Legacy steering comments (if any still exist on the task)
|
||||
const steeringComments = useMemo(() => task.steeringComments || [], [task.steeringComments]);
|
||||
|
||||
// All comments combined, sorted newest first
|
||||
const allComments = useMemo(() => {
|
||||
const combined = [...comments, ...steeringComments];
|
||||
return combined.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [comments, steeringComments]);
|
||||
|
||||
// Determine if a comment is a steering/AI guidance comment
|
||||
const isSteeringComment = useCallback((comment: TaskComment): boolean => {
|
||||
// Check if from the steeringComments array
|
||||
if (steeringComments.some(sc => sc.id === comment.id)) return true;
|
||||
// Check if the author indicates it's an agent/AI comment
|
||||
if (comment.author === "agent" || comment.author === "system") return true;
|
||||
return false;
|
||||
}, [steeringComments]);
|
||||
|
||||
const handleAddComment = useCallback(async () => {
|
||||
async function handleAddComment() {
|
||||
const text = draft.trim();
|
||||
if (!text || text.length > MAX_LENGTH || submitting) return;
|
||||
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let updated: Task;
|
||||
if (commentType === "steering") {
|
||||
updated = await addComment(task.id, text);
|
||||
} else {
|
||||
updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
}
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment added", "success");
|
||||
@@ -84,17 +39,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [draft, commentType, submitting, task.id, currentAuthor, onTaskUpdated, addToast]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleAddComment();
|
||||
}
|
||||
},
|
||||
[handleAddComment]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSaveEdit(commentId: string) {
|
||||
const text = editingText.trim();
|
||||
@@ -126,45 +71,23 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = draft.trim().length > 0 && draft.length <= MAX_LENGTH;
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Comments</h4>
|
||||
|
||||
{allComments.length === 0 ? (
|
||||
{comments.length === 0 ? (
|
||||
<div className="detail-log-empty">No comments yet.</div>
|
||||
) : (
|
||||
<div className="detail-activity-list">
|
||||
{allComments.map((comment) => {
|
||||
const isSteering = isSteeringComment(comment);
|
||||
const canEdit = !isSteering && comment.author === currentAuthor;
|
||||
{comments.map((comment) => {
|
||||
const canEdit = comment.author === currentAuthor;
|
||||
const isEditing = editingId === comment.id;
|
||||
return (
|
||||
<div key={comment.id} className="detail-log-entry">
|
||||
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{isSteering ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
background: "var(--accent-secondary, #8b5cf6)",
|
||||
color: "#fff",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
data-testid="ai-guidance-badge"
|
||||
>
|
||||
AI Guidance
|
||||
</span>
|
||||
) : (
|
||||
<strong>{comment.author}</strong>
|
||||
)}
|
||||
<span className="detail-log-timestamp">
|
||||
{isSteering
|
||||
? formatRelativeTimestamp(comment.createdAt)
|
||||
: formatCommentTimestamp(comment)}
|
||||
<div>
|
||||
<strong>{comment.author}</strong>
|
||||
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
|
||||
{formatCommentTimestamp(comment)}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && !isEditing ? (
|
||||
@@ -214,16 +137,7 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="detail-log-outcome"
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
...(isSteering ? {
|
||||
borderLeft: "3px solid var(--accent-secondary, #8b5cf6)",
|
||||
paddingLeft: "12px",
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
<div className="detail-log-outcome" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{comment.text}
|
||||
</div>
|
||||
)}
|
||||
@@ -234,61 +148,16 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
|
||||
{/* Comment type selector */}
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button
|
||||
className={`btn btn-sm${commentType === "user" ? " btn-primary" : ""}`}
|
||||
onClick={() => setCommentType("user")}
|
||||
type="button"
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm${commentType === "steering" ? " btn-primary" : ""}`}
|
||||
onClick={() => setCommentType("steering")}
|
||||
type="button"
|
||||
>
|
||||
AI Guidance
|
||||
</button>
|
||||
</div>
|
||||
{commentType === "steering" && (
|
||||
<p style={{ fontSize: "12px", opacity: 0.7, margin: 0 }}>
|
||||
AI Guidance comments are injected into the task execution context to guide the agent.
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
placeholder={commentType === "steering"
|
||||
? "Add guidance for the AI agent… (Ctrl+Enter to submit)"
|
||||
: "Add a comment… (Ctrl+Enter to submit)"}
|
||||
placeholder="Add a comment"
|
||||
className="spec-editor-feedback"
|
||||
maxLength={MAX_LENGTH}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
opacity: draft.length > MAX_LENGTH ? 0.9 : 0.5,
|
||||
color: draft.length > MAX_LENGTH ? "var(--error, #ef4444)" : "inherit",
|
||||
}}
|
||||
>
|
||||
{draft.length} / {MAX_LENGTH}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleAddComment()}
|
||||
disabled={!isValid || submitting}
|
||||
>
|
||||
{submitting ? "Posting…" : commentType === "steering" ? "Add Guidance" : "Add Comment"}
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
|
||||
{submitting ? "Posting…" : "Add Comment"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,8 +17,6 @@ vi.mock("lucide-react", () => ({
|
||||
Lightbulb: () => null,
|
||||
ListTree: () => null,
|
||||
Zap: () => null,
|
||||
ChevronDown: () => null,
|
||||
ChevronUp: () => null,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
@@ -90,12 +88,6 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
fireEvent.click(screen.getByText(optionText));
|
||||
}
|
||||
|
||||
// Helper to expand the InlineCreateCard by clicking the toggle button
|
||||
function expandInlineCreate() {
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
@@ -107,146 +99,47 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard toggle button", () => {
|
||||
it("toggle button expands the view", () => {
|
||||
renderCard();
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
describe("InlineCreateCard blur-to-cancel", () => {
|
||||
it("calls onCancel when focus leaves the card with empty input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Initially, footer controls are not visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(document.querySelector(".inline-create-card")?.className).toContain("inline-create-card");
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(toggleButton.getAttribute("aria-controls")).toBeNull();
|
||||
expect(textarea.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
// Click toggle to expand
|
||||
expandInlineCreate();
|
||||
|
||||
// Now footer controls should be visible
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--expanded")).toBe(true);
|
||||
expect(screen.getByText(/Deps/)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Models/i })).toBeTruthy();
|
||||
expect(screen.getByTestId("plan-button")).toBeTruthy();
|
||||
expect(screen.getByTestId("subtask-button")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /Save/i })).toBeTruthy();
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(toggleButton.getAttribute("aria-controls")).toBe("inline-create-controls");
|
||||
expect(textarea.getAttribute("aria-controls")).toBe("inline-create-controls");
|
||||
});
|
||||
|
||||
it("toggle button collapses the view when expanded", () => {
|
||||
renderCard();
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
|
||||
// Expand first
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Click toggle again to collapse
|
||||
expandInlineCreate();
|
||||
|
||||
// Footer should be hidden
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect(document.querySelector(".inline-create-card")?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("maintains the collapsed and expanded styling contract on the inline create container", () => {
|
||||
renderCard();
|
||||
const card = document.querySelector(".inline-create-card");
|
||||
|
||||
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(true);
|
||||
expect(card?.classList.contains("inline-create-card--expanded")).toBe(false);
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
|
||||
expandInlineCreate();
|
||||
|
||||
expect(card?.classList.contains("inline-create-card--expanded")).toBe(true);
|
||||
expect(card?.classList.contains("inline-create-card--collapsed")).toBe(false);
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does NOT expand on focus", () => {
|
||||
renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Focus should not expand the card
|
||||
textarea.focus();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onCancel on blur when collapsed and empty", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel on blur when collapsed and has content", () => {
|
||||
it("does NOT call onCancel when focus leaves with non-empty input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Keep collapsed draft" } });
|
||||
fireEvent.change(textarea, { target: { value: "Some task description" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel on blur when expanded and empty", () => {
|
||||
it("does NOT call onCancel when focus moves to another element inside the card", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const depsButton = screen.getByText(/Deps/);
|
||||
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: depsButton });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onCancel when blur with only whitespace input", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expandInlineCreate();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
|
||||
fireEvent.change(textarea, { target: { value: " " } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel on blur when expanded and has content", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
expandInlineCreate();
|
||||
fireEvent.change(textarea, { target: { value: "Keep drafting" } });
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard Escape key behavior", () => {
|
||||
it("calls onCancel when Escape is pressed", () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes dropdowns on first Escape, cancels on second", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Open a dropdown
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
expect(document.querySelector(".dep-dropdown")).toBeTruthy();
|
||||
|
||||
// First Escape closes dropdown
|
||||
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||
expect(document.querySelector(".dep-dropdown")).toBeNull();
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
@@ -256,7 +149,6 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
|
||||
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
@@ -264,12 +156,26 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
const prevented = !fireEvent.mouseDown(item);
|
||||
expect(prevented).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
|
||||
const { props } = renderCard(testTasks);
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
fireEvent.click(item);
|
||||
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard model selector", () => {
|
||||
it("opens and closes the model disclosure dropdown", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
expect(screen.getByText("Executor Model")).toBeTruthy();
|
||||
@@ -279,22 +185,8 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(screen.queryByText("Executor Model")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the shared model dropdown in the portal layer from the inline create surface", async () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Executor Model" }));
|
||||
|
||||
const portal = await screen.findByTestId("model-combobox-portal");
|
||||
expect(portal).toBeTruthy();
|
||||
expect(portal.classList.contains("model-combobox-dropdown--portal")).toBe(true);
|
||||
expect(document.body.contains(portal)).toBe(true);
|
||||
});
|
||||
|
||||
it("updates executor selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Executor Model", "Claude Sonnet 4.5");
|
||||
@@ -304,7 +196,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("updates validator selection and shows the selected model badge", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Validator Model", "GPT-4o");
|
||||
@@ -314,7 +205,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("clears the model selection when Use default is chosen", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
|
||||
openModelPanel();
|
||||
chooseModel("Executor Model", "Claude Sonnet 4.5");
|
||||
@@ -330,7 +220,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("omits model fields from the submit payload after clearing back to default", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task using defaults again" } });
|
||||
@@ -357,7 +246,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("includes selected models in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
|
||||
@@ -381,7 +269,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -391,17 +278,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when expanded, empty, and a dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the preset dropdown is open", () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
|
||||
@@ -409,7 +285,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
@@ -419,21 +294,19 @@ describe("InlineCreateCard model selector", () => {
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip("includes selected preset id in the submit payload", async () => {
|
||||
it("includes selected preset id in the submit payload", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
});
|
||||
const { props } = renderCard([], { availableModels: undefined });
|
||||
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Budget" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /Save/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -447,9 +320,8 @@ describe("InlineCreateCard model selector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onCancel after a model override is selected and focus leaves the card while empty", () => {
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -459,12 +331,11 @@ describe("InlineCreateCard model selector", () => {
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prevents default on model option mouseDown to retain focus while selecting", () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
textarea.focus();
|
||||
@@ -498,7 +369,6 @@ describe("InlineCreateCard model selector", () => {
|
||||
.mockResolvedValueOnce(MOCK_MODELS);
|
||||
|
||||
renderCard([], { availableModels: undefined });
|
||||
expandInlineCreate();
|
||||
openModelPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -523,7 +393,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -533,7 +402,6 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("preserves newest-first sort order when a search filter is applied", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -553,7 +421,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -563,7 +430,6 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -583,7 +449,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("shows search input when dropdown is opened", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
@@ -592,7 +457,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("filters tasks by search term", () => {
|
||||
renderCard(testTasks);
|
||||
expandInlineCreate();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "dark" } });
|
||||
@@ -606,7 +470,6 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("renders Plan and Subtask buttons disabled when description is empty", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
expect(planButton.disabled).toBe(true);
|
||||
@@ -615,7 +478,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
|
||||
it("enables Plan and Subtask buttons when description is entered", () => {
|
||||
renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Test task" } });
|
||||
|
||||
@@ -625,41 +487,34 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
expect(subtaskButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onPlanningMode and collapses after Plan clicked", () => {
|
||||
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { onPlanningMode });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
fireEvent.click(screen.getByTestId("plan-button"));
|
||||
|
||||
expect(onPlanningMode).toHaveBeenCalledWith("Plan this task");
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("calls onSubtaskBreakdown and collapses after Subtask clicked", () => {
|
||||
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||
|
||||
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Break this down");
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeNull();
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("shows toast when Plan clicked with empty description (via direct handler call)", () => {
|
||||
const addToast = vi.fn();
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { addToast, onPlanningMode });
|
||||
expandInlineCreate();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
@@ -673,7 +528,6 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
const addToast = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { addToast, onSubtaskBreakdown });
|
||||
expandInlineCreate();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
@@ -717,10 +571,9 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears textarea and localStorage after successful task creation", async () => {
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Type something to set localStorage
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
@@ -735,30 +588,10 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(textarea.value).toBe("");
|
||||
expect(screen.getByTestId("inline-create-toggle").getAttribute("aria-expanded")).toBe("false");
|
||||
// localStorage should be cleared
|
||||
expect(localStorage.getItem("kb-inline-create-text")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows immediately re-expanding after successful submit", async () => {
|
||||
const { props } = renderCard();
|
||||
expandInlineCreate();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
|
||||
const toggleButton = screen.getByTestId("inline-create-toggle");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(toggleButton);
|
||||
expect(toggleButton.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(document.querySelector(".inline-create-footer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears localStorage when cancelling via Escape key", async () => {
|
||||
const { props } = renderCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
@@ -46,7 +46,6 @@ const renderListView = (props: Partial<React.ComponentProps<typeof ListView>> =
|
||||
describe("ListView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders without crashing", () => {
|
||||
@@ -227,10 +226,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const columnHeader = screen.getByText("Column");
|
||||
fireEvent.click(columnHeader);
|
||||
|
||||
@@ -320,10 +315,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to reveal done tasks in the table
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that all column badges are rendered in the table
|
||||
// Use getAllByText and check length since column names appear in both drop zones and badges
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -569,10 +560,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections including Done and Archived
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Check that section headers are rendered with column names
|
||||
expect(screen.getAllByText("Triage").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
|
||||
@@ -590,10 +577,6 @@ describe("ListView", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Find section headers by their structure
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6); // One for each column
|
||||
@@ -668,7 +651,6 @@ describe("ListView", () => {
|
||||
describe("ListView Column Filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("filters tasks by column when drop zone is clicked", () => {
|
||||
@@ -718,10 +700,6 @@ describe("ListView Column Filtering", () => {
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Show Done" to reveal all column sections
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All 6 section headers should be visible (one for each column)
|
||||
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeaders.length).toBe(6);
|
||||
@@ -1041,26 +1019,13 @@ describe("ListView Hide Done Tasks", () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders hide done tasks toggle button with 'Show Done' when done tasks are hidden by default", () => {
|
||||
it("renders hide done tasks toggle button", () => {
|
||||
renderListView();
|
||||
|
||||
const hideDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
expect(hideDoneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks by default when no localStorage value exists", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
createMockTask({ id: "FN-002", column: "triage" }),
|
||||
];
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides done tasks when toggle is activated", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
@@ -1069,15 +1034,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show done tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1094,15 +1055,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show archived tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Both tasks should be visible now
|
||||
// Both tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide archived tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1120,16 +1077,12 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" to show all completed tasks first
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// All tasks should be visible now
|
||||
// All tasks should be visible initially
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
expect(screen.getByText("FN-002")).toBeDefined();
|
||||
expect(screen.getByText("FN-003")).toBeDefined();
|
||||
|
||||
// Click "Hide Done" to hide completed tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1148,13 +1101,16 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Completed tasks should be hidden by default
|
||||
// Click hide done button to hide completed tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Completed tasks should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
|
||||
// Click "Show Done" to show all tasks
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
// Click again to show all tasks
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// All tasks should be visible again
|
||||
expect(screen.getByText("FN-001")).toBeDefined();
|
||||
@@ -1166,11 +1122,7 @@ describe("ListView Hide Done Tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", column: "done" })];
|
||||
renderListView({ tasks });
|
||||
|
||||
// Click "Show Done" first (since default is now hidden)
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
// Click "Hide Done" to hide done tasks
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
@@ -1207,7 +1159,14 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Stats should show filtered count with hidden indicator (default is now hidden)
|
||||
// Initial stats should show all tasks
|
||||
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Stats should show filtered count with hidden indicator
|
||||
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
|
||||
expect(screen.getByText(/2 hidden/)).toBeDefined();
|
||||
});
|
||||
@@ -1221,7 +1180,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done and Archived sections should be hidden by default
|
||||
// All section headers should be visible initially
|
||||
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
|
||||
expect(sectionHeadersBefore.length).toBe(6); // All 6 columns
|
||||
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done and Archived sections should be hidden
|
||||
const doneSection = screen.getAllByRole("row").find(r =>
|
||||
r.className.includes("list-section-header") && r.textContent?.includes("Done")
|
||||
);
|
||||
@@ -1247,7 +1214,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done drop zone should still be visible with "X of Y" format
|
||||
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
|
||||
expect(doneZone).toBeDefined();
|
||||
expect(doneZone?.textContent).toContain("0 of 2");
|
||||
@@ -1261,7 +1232,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived drop zone should be visible with "X of Y" format (hide done is active by default)
|
||||
// Click hide done button
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived drop zone should still be visible with "X of Y" format
|
||||
const archivedZone = document.querySelector('[data-column="archived"].list-drop-zone');
|
||||
expect(archivedZone).toBeDefined();
|
||||
expect(archivedZone?.textContent).toContain("0 of 2");
|
||||
@@ -1276,11 +1251,15 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Hide done tasks
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Apply filter
|
||||
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
|
||||
fireEvent.change(filterInput, { target: { value: "Gamma" } });
|
||||
|
||||
// Completed tasks should remain hidden (hide done is active by default)
|
||||
// Completed tasks should remain hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
expect(screen.queryByText("FN-002")).toBeNull();
|
||||
// Filtered task should be visible
|
||||
@@ -1295,7 +1274,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Done task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Done task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the done drop zone to select that column
|
||||
@@ -1315,7 +1298,11 @@ describe("ListView Hide Done Tasks", () => {
|
||||
|
||||
renderListView({ tasks });
|
||||
|
||||
// Archived task should be hidden by default
|
||||
// Enable hide done
|
||||
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
|
||||
fireEvent.click(hideDoneButton);
|
||||
|
||||
// Archived task should be hidden
|
||||
expect(screen.queryByText("FN-001")).toBeNull();
|
||||
|
||||
// Click on the archived drop zone to select that column
|
||||
@@ -1331,7 +1318,6 @@ describe("ListView Hide Done Tasks", () => {
|
||||
describe("ListView Quick Entry", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders QuickEntryBox when onQuickCreate is provided", () => {
|
||||
@@ -1368,9 +1354,10 @@ describe("ListView Quick Entry", () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
// Click the toggle button to expand the QuickEntryBox
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus the input to expand the QuickEntryBox
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Model selector button should be visible
|
||||
const modelButton = await screen.findByTestId("quick-entry-models-button");
|
||||
@@ -1381,9 +1368,10 @@ describe("ListView Quick Entry", () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
// Click the toggle button to expand the QuickEntryBox
|
||||
const toggleButton = screen.getByTestId("quick-entry-toggle");
|
||||
fireEvent.click(toggleButton);
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
|
||||
// Focus the input to expand the QuickEntryBox
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Dependency selector button should be visible
|
||||
const depsButton = await screen.findByTestId("quick-entry-deps-button");
|
||||
@@ -1777,10 +1765,6 @@ describe("ListView - Bulk Selection", () => {
|
||||
];
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} />);
|
||||
|
||||
// Click "Show Done" to make archived tasks visible
|
||||
const showDoneButton = screen.getByRole("button", { name: /show done/i });
|
||||
fireEvent.click(showDoneButton);
|
||||
|
||||
const checkbox = screen.getByLabelText("Select FN-001");
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -1,162 +1,284 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SetupWizardModal } from "../SetupWizardModal";
|
||||
import { SetupWizard } from "../SetupWizard";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../../api";
|
||||
|
||||
// Mock the API
|
||||
const mockRegisterProject = vi.fn();
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
X: () => <span data-testid="close-icon">×</span>,
|
||||
ChevronRight: () => <span data-testid="next-icon">→</span>,
|
||||
ChevronLeft: () => <span data-testid="back-icon">←</span>,
|
||||
Folder: () => <span data-testid="folder-icon">📁</span>,
|
||||
Check: () => <span data-testid="check-icon">✓</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
registerProject: (...args: unknown[]) => mockRegisterProject(...args),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="x-icon">×</span>,
|
||||
Loader2: () => <span data-testid="loader-icon">⟳</span>,
|
||||
FolderPlus: () => <span data-testid="folder-icon">📁</span>,
|
||||
CheckCircle: () => <span data-testid="check-icon">✓</span>,
|
||||
}));
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("SetupWizardModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("renders the wizard with manual step by default", async () => {
|
||||
describe("SetupWizard", () => {
|
||||
it("does not render when isOpen is false", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show welcome/manual screen
|
||||
expect(await screen.findByText("Welcome to kb")).toBeDefined();
|
||||
|
||||
// Should show manual entry form
|
||||
expect(screen.getByLabelText("Project Path")).toBeDefined();
|
||||
expect(screen.getByLabelText("Project Name")).toBeDefined();
|
||||
expect(screen.getByLabelText("Isolation Mode")).toBeDefined();
|
||||
expect(screen.queryByText("Add New Project")).toBeNull();
|
||||
});
|
||||
|
||||
it("allows entering project details in manual step", async () => {
|
||||
it("renders when isOpen is true", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const pathInput = await screen.findByLabelText("Project Path");
|
||||
expect(screen.getByText("Add New Project")).toBeDefined();
|
||||
});
|
||||
|
||||
it("starts at directory step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows step indicator with 5 steps", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Directory")).toBeDefined();
|
||||
expect(screen.getByText("Name")).toBeDefined();
|
||||
expect(screen.getByText("Mode")).toBeDefined();
|
||||
expect(screen.getByText("Validate")).toBeDefined();
|
||||
expect(screen.getByText("Confirm")).toBeDefined();
|
||||
});
|
||||
|
||||
it("disables Next button when directory is empty", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Next button when directory is filled", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("navigates to next step when Next is clicked", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Find the primary button (Next) in the actions area
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
expect(screen.getByText("Project Name")).toBeDefined();
|
||||
});
|
||||
|
||||
it("auto-suggests name from directory path", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/my-awesome-project" } });
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("My Project") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("my-awesome-project");
|
||||
});
|
||||
|
||||
it("allows navigation back to previous step", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Go to step 2
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
const nextButton = screen.getByRole("button", { name: /Next/i });
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
// Go back
|
||||
const backButton = screen.getByRole("button", { name: /Back/i });
|
||||
fireEvent.click(backButton);
|
||||
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows isolation mode options", () => {
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Navigate to step 3 (isolation)
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
fireEvent.change(pathInput, { target: { value: "/path/to/project" } });
|
||||
// Go to name step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
// Go to isolation step
|
||||
fireEvent.click(screen.getByRole("button", { name: /Next/i }));
|
||||
|
||||
expect(pathInput).toHaveValue("/path/to/project");
|
||||
expect(screen.getByText("In-Process (Default)")).toBeDefined();
|
||||
expect(screen.getByText("Child Process (Isolated)")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
it("calls onClose when Cancel is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = await screen.findByLabelText("Close wizard");
|
||||
const cancelButton = screen.getByRole("button", { name: /Cancel/i });
|
||||
fireEvent.click(cancelButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onClose when close icon is clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close");
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables register button when form is incomplete", async () => {
|
||||
it("submits project data when created", async () => {
|
||||
const mockRegisterProject = vi.fn().mockResolvedValue({
|
||||
id: "proj_123",
|
||||
name: "My Project",
|
||||
path: "/home/user/project",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
} as ProjectInfo);
|
||||
|
||||
const onProjectCreated = vi.fn();
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={onProjectCreated}
|
||||
onRegisterProject={mockRegisterProject}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for form to render
|
||||
await screen.findByLabelText("Project Path");
|
||||
// Fill directory
|
||||
const dirInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(dirInput, { target: { value: "/home/user/project" } });
|
||||
|
||||
// The wizard should be in directory step with a Next button
|
||||
expect(screen.getByRole("button", { name: /Next/i })).toBeDefined();
|
||||
|
||||
const registerButton = screen.getByRole("button", { name: /register project/i });
|
||||
expect(registerButton).toBeDisabled();
|
||||
|
||||
// Fill only path
|
||||
fireEvent.change(screen.getByLabelText("Project Path"), {
|
||||
target: { value: "/path/to/project" },
|
||||
});
|
||||
|
||||
// Button should still be disabled
|
||||
expect(registerButton).toBeDisabled();
|
||||
// Note: Full wizard flow testing would require more complex setup
|
||||
// including mocking the validation API call
|
||||
});
|
||||
|
||||
it("enables register button when form is complete", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
it("resets state when reopened", () => {
|
||||
const { rerender } = render(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for form to render
|
||||
await screen.findByLabelText("Project Path");
|
||||
// Fill some data
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/home/user/project" } });
|
||||
|
||||
// Fill the form
|
||||
fireEvent.change(screen.getByLabelText("Project Path"), {
|
||||
target: { value: "/path/to/project" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Project Name"), {
|
||||
target: { value: "test-project" },
|
||||
});
|
||||
|
||||
// Wait for button to be enabled
|
||||
const registerButton = screen.getByRole("button", { name: /register project/i });
|
||||
await waitFor(() => expect(registerButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it("has isolation mode selector with correct options", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
// Close and reopen
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText("Isolation Mode") as HTMLSelectElement;
|
||||
expect(select.value).toBe("in-process");
|
||||
|
||||
// Check options exist
|
||||
const options = Array.from(select.options);
|
||||
expect(options.some(opt => opt.value === "in-process")).toBe(true);
|
||||
expect(options.some(opt => opt.value === "child-process")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows form hint for project path", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
rerender(
|
||||
<SetupWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
onProjectCreated={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Absolute path to your project directory")).toBeDefined();
|
||||
});
|
||||
|
||||
it("has correct isolation mode default value", async () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={noop}
|
||||
onClose={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText("Isolation Mode") as HTMLSelectElement;
|
||||
expect(select.value).toBe("in-process");
|
||||
// Should be back at step 1 with empty fields
|
||||
expect(screen.getByText("Select Project Directory")).toBeDefined();
|
||||
const newInput = screen.getByPlaceholderText("/path/to/your/project") as HTMLInputElement;
|
||||
expect(newInput.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +382,6 @@ body {
|
||||
gap: var(--column-gap);
|
||||
padding: var(--board-padding);
|
||||
height: calc(100vh - 57px);
|
||||
height: calc(100dvh - 57px);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-snap-type: x proximity;
|
||||
@@ -1141,566 +1140,6 @@ body {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* === ProjectOverview Component === */
|
||||
.project-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xl);
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.project-overview__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-overview__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-overview__stats {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-md);
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 120px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-stat--active {
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
}
|
||||
|
||||
.project-stat--completed {
|
||||
border-color: rgba(63, 185, 80, 0.35);
|
||||
background: rgba(63, 185, 80, 0.08);
|
||||
}
|
||||
|
||||
.project-stat--error {
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
background: rgba(248, 81, 73, 0.08);
|
||||
}
|
||||
|
||||
.project-stat__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-stat--active .project-stat__icon {
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-stat--completed .project-stat__icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.project-stat--error .project-stat__icon {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-stat__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-stat__value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-stat__label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-overview__add-btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-overview__filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-filter-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-filter-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-filter-tab:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-filter-tab.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors {
|
||||
border-color: rgba(248, 81, 73, 0.35);
|
||||
}
|
||||
|
||||
.project-filter-tab.has-errors:not(.active) {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.project-filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.project-sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-sort-select {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
min-width: 210px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--space-lg);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-overview--empty {
|
||||
min-height: 60vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
max-width: 560px;
|
||||
padding: var(--space-2xl);
|
||||
text-align: center;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.project-empty-state__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.project-empty-state__title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.project-empty-state__description {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-empty-state__cta {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-overview__no-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-2xl);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
background: color-mix(in srgb, var(--surface) 85%, transparent);
|
||||
}
|
||||
|
||||
/* === ProjectSelector Component === */
|
||||
.project-selector {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.project-selector__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 220px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__trigger:hover,
|
||||
.project-selector__trigger.open {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__trigger.open {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.project-selector__trigger-icon {
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-selector__trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-selector__trigger-chevron {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__trigger-chevron.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.project-selector__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
min-width: 320px;
|
||||
max-width: min(420px, 90vw);
|
||||
max-height: min(70vh, 520px);
|
||||
overflow-y: auto;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.project-selector__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--card);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.project-selector__search-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-selector__search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.project-selector__search-input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__search-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-selector__search-clear:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.project-selector__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.project-selector__section + .project-selector__section {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-selector__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.project-selector__item,
|
||||
.project-selector__view-all {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-selector__item:hover,
|
||||
.project-selector__item.highlighted,
|
||||
.project-selector__view-all:hover,
|
||||
.project-selector__view-all.highlighted {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.project-selector__item-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.project-selector__item-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-selector__item-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.project-selector__item-check {
|
||||
margin-left: auto;
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.project-selector__no-results {
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-selector__footer {
|
||||
padding: var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.project-selector__view-all {
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.project-overview__header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.project-overview__stats {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.project-overview__filters {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.project-sort {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-sort-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.project-overview {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.project-overview__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.project-stat {
|
||||
flex: 1 1 calc(50% - var(--space-sm));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.project-selector,
|
||||
.project-selector__trigger,
|
||||
.project-selector__dropdown {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-selector__dropdown {
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* === ActivityFeed Component === */
|
||||
.activity-feed {
|
||||
display: flex;
|
||||
@@ -3526,11 +2965,6 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition:
|
||||
padding var(--transition-normal),
|
||||
gap var(--transition-normal),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.inline-create-input {
|
||||
@@ -3550,52 +2984,6 @@ body {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Inline Create Card main row with toggle */
|
||||
.inline-create-main-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inline-create-main-row .inline-create-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.inline-create-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed state - minimal appearance */
|
||||
.inline-create--collapsed,
|
||||
.inline-create-card--collapsed {
|
||||
padding: 8px 10px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-create--collapsed .inline-create-main-row,
|
||||
.inline-create-card--collapsed .inline-create-main-row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inline-create--collapsed .inline-create-input,
|
||||
.inline-create-card--collapsed .inline-create-input {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.inline-create-footer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -5404,18 +4792,11 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep the select column compact so the title can use more horizontal space */
|
||||
/* Reduce padding on ID column header to match data cells */
|
||||
.list-table th:first-child.list-header-cell {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Match the ID header width to the fixed ID cell width */
|
||||
.list-table th:nth-child(2).list-header-cell {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 70px;
|
||||
}
|
||||
|
||||
.list-header-cell:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
@@ -5477,19 +4858,16 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-id {
|
||||
width: 70px;
|
||||
min-width: 70px;
|
||||
max-width: 70px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
padding: 12px 8px 12px 16px;
|
||||
padding: 12px 8px 12px 16px; /* Reduced right padding to tighten space with title */
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -5749,10 +5127,7 @@ body {
|
||||
}
|
||||
|
||||
.list-cell-title {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.list-cell-date {
|
||||
@@ -6695,22 +6070,13 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 500;
|
||||
z-index: 100;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-combobox-dropdown--portal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: auto;
|
||||
margin-top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.model-combobox-search-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -10061,10 +9427,6 @@ html .column.drag-over * {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: 8px;
|
||||
transition:
|
||||
padding var(--transition-normal),
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.quick-entry-input {
|
||||
@@ -10120,10 +9482,6 @@ html .column.drag-over * {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-entry-controls[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-entry-controls-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -10185,60 +9543,6 @@ html .column.drag-over * {
|
||||
}
|
||||
}
|
||||
|
||||
/* Quick Entry Box main row with toggle */
|
||||
.quick-entry-main-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-entry-main-row .quick-entry-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quick-entry-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed state - minimal padding */
|
||||
.quick-entry--collapsed,
|
||||
.quick-entry-box--collapsed {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.quick-entry--collapsed .quick-entry-input,
|
||||
.quick-entry-box--collapsed .quick-entry-input {
|
||||
min-height: 32px;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.quick-entry--collapsed .quick-entry-input:focus,
|
||||
.quick-entry-box--collapsed .quick-entry-input:focus {
|
||||
border-bottom-color: var(--triage);
|
||||
box-shadow: 0 1px 0 0 var(--triage);
|
||||
}
|
||||
|
||||
.quick-entry--expanded,
|
||||
.quick-entry-box--expanded {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.inline-create--expanded,
|
||||
.inline-create-card--expanded {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* === New Task Modal === */
|
||||
.new-task-modal .modal-body {
|
||||
padding: 20px 24px;
|
||||
@@ -11250,9 +10554,6 @@ html .column.drag-over * {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Main layout: sidebar + content */
|
||||
@@ -11731,41 +11032,6 @@ html .column.drag-over * {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.changed-files-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.changed-files-sidebar {
|
||||
width: 30%;
|
||||
min-width: 260px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.changed-files-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.changed-files-entry {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.changed-files-entry.active {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.changed-files-badge {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
/* ── Commit Form ── */
|
||||
|
||||
.gm-commit-form {
|
||||
@@ -12385,7 +11651,7 @@ html .column.drag-over * {
|
||||
.gm-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -12422,7 +11688,7 @@ html .column.drag-over * {
|
||||
}
|
||||
|
||||
.gm-content {
|
||||
min-height: 200px;
|
||||
min-height: 300px;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
@@ -12535,134 +11801,3 @@ html .column.drag-over * {
|
||||
[data-theme="light"] .gm-load-more:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* ── Task Changes Tab Styles ─────────────────────────────────────────────── */
|
||||
|
||||
.task-changes-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.changes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.changes-header h4 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.changes-file-list {
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.changes-file-item {
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.changes-file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.changes-file-item.expanded {
|
||||
background: var(--bg-secondary, #161b22);
|
||||
}
|
||||
|
||||
.changes-file-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.changes-file-header:hover {
|
||||
background: var(--bg-hover, #1f242c);
|
||||
}
|
||||
|
||||
.changes-file-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.changes-file-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.changes-file-stat {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.changes-file-content {
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
background: var(--bg-primary, #0d1117);
|
||||
}
|
||||
|
||||
.changes-diff-patch {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
color: var(--text-primary, #c9d1d9);
|
||||
}
|
||||
|
||||
.changes-diff-patch code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Syntax highlighting for diff */
|
||||
.changes-diff-patch .diff-add,
|
||||
.changes-diff-patch [data-prefix="+"] {
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-del,
|
||||
.changes-diff-patch [data-prefix="-"] {
|
||||
color: #f85149;
|
||||
}
|
||||
|
||||
.changes-diff-patch .diff-hunk,
|
||||
.changes-diff-patch [data-prefix="@@"] {
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ import type {
|
||||
MilestoneCreateInput,
|
||||
SliceCreateInput,
|
||||
FeatureCreateInput,
|
||||
MissionStatus,
|
||||
MilestoneStatus,
|
||||
SliceStatus,
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -39,29 +43,20 @@ function validateUuid(id: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id);
|
||||
}
|
||||
|
||||
function validateMissionId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^M-\d+$/.test(str);
|
||||
function validateMissionId(id: string): boolean {
|
||||
return /^M-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateMilestoneId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^MS-\d+$/.test(str);
|
||||
function validateMilestoneId(id: string): boolean {
|
||||
return /^MS-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateSliceId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^SL-\d+$/.test(str);
|
||||
function validateSliceId(id: string): boolean {
|
||||
return /^SL-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateFeatureId(id: string | string[]): boolean {
|
||||
const str = Array.isArray(id) ? id[0] : id;
|
||||
return /^F-\d+$/.test(str);
|
||||
}
|
||||
|
||||
/** Helper to extract string from Express param (handles string | string[]) */
|
||||
function paramString(value: string | string[]): string {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
function validateFeatureId(id: string): boolean {
|
||||
return /^F-\d+$/.test(id);
|
||||
}
|
||||
|
||||
function validateTitle(title: unknown): string {
|
||||
@@ -79,14 +74,14 @@ function validateDescription(desc: unknown): string | undefined {
|
||||
return desc.trim() || undefined;
|
||||
}
|
||||
|
||||
function validateStatus<TStatus extends string>(status: unknown, allowedStatuses: readonly TStatus[]): TStatus {
|
||||
function validateStatus(status: unknown, allowedStatuses: readonly string[]): string {
|
||||
if (!status || typeof status !== "string") {
|
||||
throw new Error(`Status is required and must be one of: ${allowedStatuses.join(", ")}`);
|
||||
}
|
||||
if (!allowedStatuses.includes(status as TStatus)) {
|
||||
if (!allowedStatuses.includes(status)) {
|
||||
throw new Error(`Invalid status. Must be one of: ${allowedStatuses.join(", ")}`);
|
||||
}
|
||||
return status as TStatus;
|
||||
return status;
|
||||
}
|
||||
|
||||
function validateInterviewState(state: unknown): InterviewState {
|
||||
@@ -183,7 +178,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -207,7 +202,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
const { title, description, status } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -224,7 +219,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.description = validateDescription(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, MISSION_STATUSES);
|
||||
updates.status = validateStatus(status, MISSION_STATUSES) as MissionStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
@@ -252,7 +247,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/:missionId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -277,7 +272,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/status",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -304,7 +299,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -328,7 +323,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
const { state } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -360,7 +355,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/:missionId/milestones",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -387,7 +382,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/milestones",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
const { title, description, dependencies } = req.body;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
@@ -423,7 +418,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/:missionId/milestones/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const missionId = paramString(req.params.missionId);
|
||||
const { missionId } = req.params;
|
||||
|
||||
if (!validateMissionId(missionId)) {
|
||||
res.status(400).json({ error: "Invalid mission ID format" });
|
||||
@@ -465,7 +460,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -489,7 +484,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description, status, dependencies } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -506,7 +501,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.description = validateDescription(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, MILESTONE_STATUSES);
|
||||
updates.status = validateStatus(status, MILESTONE_STATUSES) as MilestoneStatus;
|
||||
}
|
||||
if (dependencies !== undefined) {
|
||||
updates.dependencies = validateStringArray(dependencies, "dependencies");
|
||||
@@ -537,7 +532,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/milestones/:milestoneId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -564,7 +559,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -588,7 +583,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/interview-state",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
const { state } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -620,7 +615,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/milestones/:milestoneId/slices",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -647,7 +642,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/slices",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
const { title, description } = req.body;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
@@ -681,7 +676,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/milestones/:milestoneId/slices/reorder",
|
||||
asyncHandler(async (req, res) => {
|
||||
const milestoneId = paramString(req.params.milestoneId);
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
if (!validateMilestoneId(milestoneId)) {
|
||||
res.status(400).json({ error: "Invalid milestone ID format" });
|
||||
@@ -723,7 +718,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -747,7 +742,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
const { title, description, status } = req.body;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
@@ -764,7 +759,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.description = validateDescription(description);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, SLICE_STATUSES);
|
||||
updates.status = validateStatus(status, SLICE_STATUSES) as SliceStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
@@ -792,7 +787,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/slices/:sliceId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -817,7 +812,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/slices/:sliceId/activate",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -846,7 +841,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/slices/:sliceId/features",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
res.status(400).json({ error: "Invalid slice ID format" });
|
||||
@@ -871,7 +866,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/slices/:sliceId/features",
|
||||
asyncHandler(async (req, res) => {
|
||||
const sliceId = paramString(req.params.sliceId);
|
||||
const { sliceId } = req.params;
|
||||
const { title, description, acceptanceCriteria } = req.body;
|
||||
|
||||
if (!validateSliceId(sliceId)) {
|
||||
@@ -907,7 +902,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.get(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
@@ -931,7 +926,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.patch(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
const { title, description, acceptanceCriteria, status } = req.body;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
@@ -951,7 +946,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
updates.acceptanceCriteria = validateDescription(acceptanceCriteria);
|
||||
}
|
||||
if (status !== undefined) {
|
||||
updates.status = validateStatus(status, FEATURE_STATUSES);
|
||||
updates.status = validateStatus(status, FEATURE_STATUSES) as FeatureStatus;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
@@ -979,7 +974,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.delete(
|
||||
"/features/:featureId",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
@@ -1004,7 +999,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/features/:featureId/link-task",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
const { taskId } = req.body;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
@@ -1043,7 +1038,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
||||
router.post(
|
||||
"/features/:featureId/unlink-task",
|
||||
asyncHandler(async (req, res) => {
|
||||
const featureId = paramString(req.params.featureId);
|
||||
const { featureId } = req.params;
|
||||
|
||||
if (!validateFeatureId(featureId)) {
|
||||
res.status(400).json({ error: "Invalid feature ID format" });
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { GitHubClient } from "./github.js";
|
||||
import { githubRateLimiter } from "./github-poll.js";
|
||||
@@ -25,24 +25,12 @@ vi.mock("@fusion/core", async () => {
|
||||
return {
|
||||
...actual,
|
||||
isGhAuthenticated: vi.fn(),
|
||||
CentralCore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
TaskStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
import { isGhAuthenticated, CentralCore, TaskStore as TaskStoreClass } from "@fusion/core";
|
||||
import { isGhAuthenticated } from "@fusion/core";
|
||||
|
||||
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
|
||||
const MockCentralCore = vi.mocked(CentralCore);
|
||||
const MockTaskStoreClass = vi.mocked(TaskStoreClass);
|
||||
|
||||
function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
@@ -53,18 +41,6 @@ function createMockGlobalSettingsStore() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockMissionStore() {
|
||||
return {
|
||||
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
|
||||
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
|
||||
updateSession: vi.fn().mockResolvedValue(undefined),
|
||||
addAnswer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteSession: vi.fn().mockResolvedValue(undefined),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
getTask: vi.fn(),
|
||||
@@ -81,11 +57,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updateGlobalSettings: vi.fn(),
|
||||
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
|
||||
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
getActivityLog: vi.fn().mockResolvedValue([]),
|
||||
addComment: vi.fn(),
|
||||
addSteeringComment: vi.fn(),
|
||||
addTaskComment: vi.fn(),
|
||||
updateTaskComment: vi.fn(),
|
||||
deleteTaskComment: vi.fn(),
|
||||
@@ -97,7 +71,24 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getWorkflowStep: vi.fn(),
|
||||
updateWorkflowStep: vi.fn(),
|
||||
deleteWorkflowStep: vi.fn(),
|
||||
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
|
||||
getMissionStore: vi.fn().mockReturnValue({
|
||||
listMissions: vi.fn().mockReturnValue([]),
|
||||
createMission: vi.fn(),
|
||||
getMissionWithHierarchy: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
getMission: vi.fn(),
|
||||
deleteMission: vi.fn(),
|
||||
listMilestonesByMission: vi.fn().mockReturnValue([]),
|
||||
createMilestone: vi.fn(),
|
||||
updateMilestone: vi.fn(),
|
||||
getMilestone: vi.fn(),
|
||||
deleteMilestone: vi.fn(),
|
||||
listTasksByMilestone: vi.fn().mockReturnValue([]),
|
||||
createMissionTask: vi.fn(),
|
||||
updateMissionTask: vi.fn(),
|
||||
getMissionTask: vi.fn(),
|
||||
deleteMissionTask: vi.fn(),
|
||||
}),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -140,163 +131,6 @@ function buildMultipart(fieldName: string, filename: string, contentType: string
|
||||
return { body, boundary };
|
||||
}
|
||||
|
||||
describe("GET /activity-feed", () => {
|
||||
function mockCentralCoreModule(options?: {
|
||||
entries?: unknown[];
|
||||
getRecentActivityError?: Error;
|
||||
}) {
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const getRecentActivity = options?.getRecentActivityError
|
||||
? vi.fn().mockRejectedValue(options.getRecentActivityError)
|
||||
: vi.fn().mockResolvedValue(options?.entries ?? []);
|
||||
|
||||
class MockCentralCore {
|
||||
init = vi.fn().mockResolvedValue(undefined);
|
||||
getRecentActivity = getRecentActivity;
|
||||
close = close;
|
||||
}
|
||||
|
||||
return { MockCentralCore, getRecentActivity, close };
|
||||
}
|
||||
|
||||
function buildApp(store: TaskStore) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@fusion/core");
|
||||
});
|
||||
|
||||
it("returns central activity when available", async () => {
|
||||
const store = createMockStore();
|
||||
const centralEntry = {
|
||||
id: "act_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "proj_123",
|
||||
projectName: "Central Project",
|
||||
taskId: "FN-001",
|
||||
taskTitle: "Test Task",
|
||||
details: "Created task",
|
||||
metadata: { source: "central" },
|
||||
};
|
||||
const mockCentral = mockCentralCoreModule({ entries: [centralEntry] });
|
||||
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, CentralCore: mockCentral.MockCentralCore };
|
||||
});
|
||||
|
||||
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createRoutesWithMock(store));
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=task:created");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([centralEntry]);
|
||||
expect(mockCentral.getRecentActivity).toHaveBeenCalledWith({
|
||||
limit: 50,
|
||||
projectId: undefined,
|
||||
types: ["task:created"],
|
||||
});
|
||||
expect(mockCentral.close).toHaveBeenCalled();
|
||||
expect(store.getActivityLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to local activity log when central feed is empty", async () => {
|
||||
const store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/projects/dashboard-app"),
|
||||
getActivityLog: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "local_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
taskId: "FN-001",
|
||||
details: "Task created locally",
|
||||
metadata: { from: "triage", to: "todo" },
|
||||
},
|
||||
]),
|
||||
});
|
||||
const mockCentral = mockCentralCoreModule({ entries: [] });
|
||||
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, CentralCore: mockCentral.MockCentralCore };
|
||||
});
|
||||
|
||||
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createRoutesWithMock(store));
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=task:created");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
id: "local_1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
type: "task:created",
|
||||
projectId: "local-project",
|
||||
projectName: "dashboard-app",
|
||||
taskId: "FN-001",
|
||||
details: "Task created locally",
|
||||
metadata: { from: "triage", to: "todo" },
|
||||
},
|
||||
]);
|
||||
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:created" });
|
||||
expect(mockCentral.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to local activity log when central feed throws", async () => {
|
||||
const store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/projects/local-root"),
|
||||
getActivityLog: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "local_2",
|
||||
timestamp: "2026-01-02T00:00:00.000Z",
|
||||
type: "task:updated",
|
||||
taskId: "FN-002",
|
||||
details: "Task updated locally",
|
||||
},
|
||||
]),
|
||||
});
|
||||
const mockCentral = mockCentralCoreModule({ getRecentActivityError: new Error("require is not defined") });
|
||||
|
||||
vi.doMock("@fusion/core", async () => {
|
||||
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||
return { ...actual, CentralCore: mockCentral.MockCentralCore };
|
||||
});
|
||||
|
||||
const { createApiRoutes: createRoutesWithMock } = await import("./routes.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createRoutesWithMock(store));
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=task:updated");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body[0].projectName).toBe("local-root");
|
||||
expect(store.getActivityLog).toHaveBeenCalledWith({ limit: 50, type: "task:updated" });
|
||||
expect(mockCentral.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 for invalid type filters", async () => {
|
||||
const store = createMockStore();
|
||||
const app = buildApp(store);
|
||||
|
||||
const res = await GET(app, "/api/activity-feed?type=not-real");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid type");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /tasks", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
@@ -327,118 +161,6 @@ describe("GET /tasks", () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("limit");
|
||||
});
|
||||
|
||||
describe("with projectId query parameter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 when project is not found", async () => {
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=nonexistent");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toBe("Project not found");
|
||||
});
|
||||
|
||||
it("returns tasks from the project store when project is found", async () => {
|
||||
const projectPath = "/test/project/path";
|
||||
const projectTasks = [FAKE_TASK_DETAIL];
|
||||
|
||||
const mockProjectStoreInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue(projectTasks),
|
||||
close: vi.fn(),
|
||||
};
|
||||
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
|
||||
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue({
|
||||
id: "proj_abc",
|
||||
name: "Test Project",
|
||||
path: projectPath,
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(res.body[0].id).toBe("FN-001");
|
||||
// Verify project store was initialized with correct path
|
||||
expect(MockTaskStoreClass).toHaveBeenCalledWith(projectPath);
|
||||
expect(mockProjectStoreInstance.init).toHaveBeenCalled();
|
||||
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: undefined, offset: undefined });
|
||||
expect(mockProjectStoreInstance.close).toHaveBeenCalled();
|
||||
// Verify CentralCore was properly closed
|
||||
expect(mockCentralInstance.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes limit and offset to the project store", async () => {
|
||||
const mockProjectStoreInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn(),
|
||||
};
|
||||
MockTaskStoreClass.mockImplementation(() => mockProjectStoreInstance as any);
|
||||
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProject: vi.fn().mockResolvedValue({
|
||||
id: "proj_abc",
|
||||
name: "Test Project",
|
||||
path: "/test/path",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc&limit=5&offset=10");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockProjectStoreInstance.listTasks).toHaveBeenCalledWith({ limit: 5, offset: 10 });
|
||||
});
|
||||
|
||||
it("returns 200 with empty array on graceful degradation when CentralCore is unavailable", async () => {
|
||||
MockCentralCore.mockImplementation(() => {
|
||||
throw new Error("CentralCore unavailable");
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks?projectId=proj_abc");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("still uses default store when projectId is not provided", async () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_TASK_DETAIL]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveLength(1);
|
||||
expect(store.listTasks).toHaveBeenCalled();
|
||||
// CentralCore should not be used when no projectId
|
||||
expect(MockCentralCore).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /tasks/:id", () => {
|
||||
@@ -2124,16 +1846,16 @@ describe("Pause/Unpause endpoints", () => {
|
||||
it("adds a steering comment to a task", async () => {
|
||||
const mockComment = {
|
||||
id: "FN-001",
|
||||
comments: [
|
||||
steeringComments: [
|
||||
{
|
||||
id: "1234567890-abc123",
|
||||
text: "Please handle the edge case",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
author: "user",
|
||||
author: "user" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
(store.addComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockResolvedValue(mockComment);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -2145,7 +1867,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockComment);
|
||||
expect(store.addComment).toHaveBeenCalledWith(
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Please handle the edge case",
|
||||
"user"
|
||||
@@ -2192,7 +1914,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
it("returns 404 when task not found", async () => {
|
||||
const error = new Error("Task not found") as Error & { code?: string };
|
||||
error.code = "ENOENT";
|
||||
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -2206,7 +1928,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
});
|
||||
|
||||
it("returns 500 on unexpected errors", async () => {
|
||||
(store.addComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
(store.addSteeringComment as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Database error")
|
||||
);
|
||||
|
||||
@@ -3934,142 +3656,6 @@ describe("POST /tasks/:id/reject-plan", () => {
|
||||
// --- Git Management route tests ---
|
||||
// These are integration tests that run against the actual git repository
|
||||
|
||||
describe("GET /tasks/:id/file-diffs", () => {
|
||||
let store: TaskStore;
|
||||
let worktreeDir: string;
|
||||
let testRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z"));
|
||||
testRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-file-diffs-"));
|
||||
worktreeDir = join(testRoot, "repo");
|
||||
mkdirSync(worktreeDir, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main", worktreeDir]);
|
||||
execFileSync("git", ["-C", worktreeDir, "config", "user.email", "kb-tests@example.com"]);
|
||||
execFileSync("git", ["-C", worktreeDir, "config", "user.name", "KB Tests"]);
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\n");
|
||||
writeFileSync(join(worktreeDir, "keep.txt"), "keep\n");
|
||||
execFileSync("git", ["-C", worktreeDir, "add", "."]);
|
||||
execFileSync("git", ["-C", worktreeDir, "commit", "-m", "base"]);
|
||||
|
||||
store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
...FAKE_TASK_DETAIL,
|
||||
id: "KB-651",
|
||||
worktree: worktreeDir,
|
||||
baseBranch: "main",
|
||||
}),
|
||||
getRootDir: vi.fn().mockReturnValue(worktreeDir),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
rmSync(testRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns changed files with statuses and diffs", async () => {
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged\n");
|
||||
writeFileSync(join(worktreeDir, "added.txt"), "new file\n");
|
||||
execFileSync("git", ["-C", worktreeDir, "rm", "keep.txt"]);
|
||||
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", status: "modified", diff: expect.stringContaining("+changed") }),
|
||||
expect.objectContaining({ path: "added.txt", status: "added", diff: expect.stringContaining("+++ b/added.txt") }),
|
||||
expect.objectContaining({ path: "keep.txt", status: "deleted", diff: expect.stringContaining("--- a/keep.txt") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns renamed files with oldPath and diff content", async () => {
|
||||
execFileSync("git", ["-C", worktreeDir, "mv", "keep.txt", "renamed.txt"]);
|
||||
expect(execSync("git status --short", { cwd: worktreeDir, encoding: "utf-8" })).not.toBe("");
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "renamed.txt",
|
||||
oldPath: "keep.txt",
|
||||
status: "renamed",
|
||||
diff: expect.stringContaining("rename from keep.txt"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.skip("caches results for 10 seconds before refreshing", async () => {
|
||||
const originalDateNow = Date.now;
|
||||
let now = 1_000;
|
||||
Date.now = vi.fn(() => now);
|
||||
|
||||
try {
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged once\n");
|
||||
|
||||
const first = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
|
||||
]),
|
||||
);
|
||||
|
||||
writeFileSync(join(worktreeDir, "README.md"), "base\nchanged twice\n");
|
||||
|
||||
now += 5_000;
|
||||
const cached = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
expect(cached.status).toBe(200);
|
||||
expect(cached.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed once") }),
|
||||
]),
|
||||
);
|
||||
|
||||
now += 5_001;
|
||||
const refreshed = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
expect(refreshed.status).toBe(200);
|
||||
expect(refreshed.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: "README.md", diff: expect.stringContaining("+changed twice") }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array when worktree is missing", async () => {
|
||||
store = createMockStore({
|
||||
getTask: vi.fn().mockResolvedValue({ ...FAKE_TASK_DETAIL, id: "KB-651", worktree: join(testRoot, "missing"), baseBranch: "main" }),
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when there are no changes", async () => {
|
||||
const res = await GET(buildApp(), "/api/tasks/KB-651/file-diffs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Git Management endpoints", () => {
|
||||
let store: TaskStore;
|
||||
let gitRepoDir: string;
|
||||
@@ -5150,13 +4736,9 @@ describe("Terminal session routes", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/terminal/sessions", () => {
|
||||
it("returns 503 when max sessions reached", async () => {
|
||||
it("returns 503 when max sessions reached (session is null)", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "max_sessions",
|
||||
error: "Maximum terminal sessions reached. Please close an existing terminal and try again.",
|
||||
}),
|
||||
createSession: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
@@ -5169,102 +4751,7 @@ describe("Terminal session routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe("Maximum terminal sessions reached. Please close an existing terminal and try again.");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 400 when shell is not allowed", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "invalid_shell",
|
||||
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 503 when PTY module fails to load", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "pty_load_failed",
|
||||
error: "Terminal service unavailable. The PTY module could not be loaded.",
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.error).toBe("Terminal service unavailable. The PTY module could not be loaded.");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 500 when PTY spawn fails", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
code: "pty_spawn_failed",
|
||||
error: "Failed to start terminal shell process.",
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("Failed to start terminal shell process.");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 201 when session creation succeeds", async () => {
|
||||
const mockService = {
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
session: { id: "term-123", shell: "/bin/zsh", cwd: "/test" },
|
||||
}),
|
||||
};
|
||||
vi.spyOn(terminalServiceModule, "getTerminalService").mockReturnValue(mockService as any);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/terminal/sessions",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ sessionId: "term-123", shell: "/bin/zsh", cwd: "/test" });
|
||||
expect(res.body.error).toContain("Max sessions");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -6319,143 +5806,3 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
expect(res.body.error).toContain("already exists");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Activity Feed Tests ─────────────────────────────────────────────
|
||||
|
||||
describe("GET /api/activity-feed", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
it("returns empty array when both central and per-project activities are empty", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty array
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns central activity when available", async () => {
|
||||
const store = createMockStore();
|
||||
store.getActivityLog.mockResolvedValue([]);
|
||||
|
||||
const centralEntries = [
|
||||
{
|
||||
id: "central-1",
|
||||
timestamp: "2026-04-01T11:00:00.000Z",
|
||||
type: "task:moved" as const,
|
||||
projectId: "proj-123",
|
||||
projectName: "Test Project",
|
||||
taskId: "KB-002",
|
||||
details: "Moved task to done",
|
||||
},
|
||||
];
|
||||
|
||||
// Override the module-level CentralCore mock to return data
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue(centralEntries),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(centralEntries);
|
||||
|
||||
// Should not call per-project activity when central data exists
|
||||
expect(store.getActivityLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles CentralCore initialization failure gracefully", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to throw error on init
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockRejectedValue(new Error("CentralCore init failed")),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn(),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]); // Fallback returns empty array from mock store
|
||||
});
|
||||
|
||||
it("passes through limit query parameter", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed?limit=25");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles type filter query parameter", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed?types=task:moved");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates fallback route path exists", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
// Override the module-level CentralCore mock to return empty - this ensures fallback path is taken
|
||||
const mockCentralInstance = {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getRecentActivity: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
MockCentralCore.mockImplementation(() => mockCentralInstance as any);
|
||||
|
||||
const app = buildApp(store);
|
||||
const res = await REQUEST(app, "GET", "/api/activity-feed");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user