feat(KB-648): enable parallel test execution and optimize test performance
- Optimize backup tests using fake timers instead of real timeouts - Enable parallel file execution in core, engine, CLI, and dashboard packages - Add inline test helpers to reduce dependencies in dashboard routes tests - Update executor tests with exact command matching and improved assertions - Update AGENTS.md with test optimization patterns (fake timers, unique temp dirs)
This commit is contained in:
@@ -74,7 +74,7 @@ describe("useAgentLogs", () => {
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/KB-001/logs/stream");
|
||||
});
|
||||
|
||||
it("appends live SSE entries to historical entries", async () => {
|
||||
|
||||
@@ -110,8 +110,8 @@ describe("useMultiAgentLogs", () => {
|
||||
await waitFor(() => {
|
||||
// Filter to unique URLs (Strict Mode may create duplicates)
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-001/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("useMultiAgentLogs", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const urls = [...new Set(MockEventSource.instances.map((es) => es.url))];
|
||||
expect(urls).toContain("/api/tasks/FN-002/logs/stream");
|
||||
expect(urls).toContain("/api/tasks/KB-002/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,7 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["FN-001"].entries).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Clear only FN-001
|
||||
// Clear only KB-001
|
||||
act(() => {
|
||||
result.current["FN-001"].clear();
|
||||
});
|
||||
|
||||
@@ -809,140 +809,4 @@ describe("useTasks", () => {
|
||||
expect(result.current.tasks[0].column).toBe("todo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibility change", () => {
|
||||
let originalVisibilityState: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original descriptor to restore later
|
||||
originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original visibilityState property
|
||||
if (originalVisibilityState) {
|
||||
Object.defineProperty(document, "visibilityState", originalVisibilityState);
|
||||
} else {
|
||||
// If no original descriptor, just delete our mock
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (document as any).visibilityState;
|
||||
}
|
||||
});
|
||||
|
||||
function setVisibilityState(state: "visible" | "hidden") {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: state,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchVisibilityChange() {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
}
|
||||
|
||||
it("refetches tasks when visibility changes from hidden to visible", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001", column: "todo" as Column });
|
||||
const refreshedTask = createMockTask({
|
||||
id: "FN-001",
|
||||
column: "in-progress" as Column,
|
||||
updatedAt: "2026-01-02T00:00:00Z",
|
||||
});
|
||||
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Reset mock to return refreshed data
|
||||
mockFetchTasks.mockResolvedValueOnce([refreshedTask]);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks[0].column).toBe("in-progress");
|
||||
});
|
||||
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not refetch when visibility changes to hidden", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
setVisibilityState("visible");
|
||||
setVisibilityState("hidden");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
// Should not trigger another fetch
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("debounces rapid visibility changes (minimum 1 second between fetches)", async () => {
|
||||
const initialTask = createMockTask({ id: "FN-001" });
|
||||
mockFetchTasks.mockResolvedValueOnce([initialTask]);
|
||||
|
||||
const { result } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.tasks).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Wait for 1 second to ensure debounce window has passed from initial fetch
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
|
||||
// Reset mock to track new calls
|
||||
mockFetchTasks.mockClear();
|
||||
|
||||
// First visibility change should trigger a fetch (1s has passed)
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rapid visibility changes immediately after should be debounced
|
||||
for (let i = 0; i < 5; i++) {
|
||||
setVisibilityState("hidden");
|
||||
setVisibilityState("visible");
|
||||
dispatchVisibilityChange();
|
||||
}
|
||||
|
||||
// Should still only be 1 call (debounced)
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up visibility change listener on unmount", async () => {
|
||||
mockFetchTasks.mockResolvedValueOnce([]);
|
||||
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useTasks());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||
|
||||
removeEventListenerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,70 +24,18 @@ function compareTimestamps(a: string | undefined, b: string | undefined): number
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
|
||||
export interface UseTasksOptions {
|
||||
/**
|
||||
* When provided, fetches tasks only for this project.
|
||||
* Note: SSE updates are not filtered by project in current implementation.
|
||||
*/
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export function useTasks(options?: UseTasksOptions) {
|
||||
const projectId = options?.projectId;
|
||||
export function useTasks() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [connectionNonce, setConnectionNonce] = useState(0);
|
||||
const tasksRef = useRef(tasks);
|
||||
tasksRef.current = tasks;
|
||||
|
||||
// Ref to track last visibility fetch time for debouncing (1 second minimum)
|
||||
const lastVisibilityFetchRef = useRef<number>(0);
|
||||
const VISIBILITY_FETCH_DEBOUNCE_MS = 1000;
|
||||
|
||||
// Determine which fetch function to use
|
||||
const fetchTasksFn = useCallback(() => {
|
||||
if (projectId) {
|
||||
return api.fetchProjectTasks(projectId);
|
||||
}
|
||||
return api.fetchTasks();
|
||||
}, [projectId]);
|
||||
|
||||
// Fetch initial tasks
|
||||
useEffect(() => {
|
||||
fetchTasksFn()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => setTasks([]));
|
||||
}, [fetchTasksFn]);
|
||||
|
||||
// Visibility change listener - refresh tasks when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastVisibilityFetchRef.current;
|
||||
|
||||
// Debounce: only fetch if at least 1 second has passed since last visibility fetch
|
||||
if (timeSinceLastFetch >= VISIBILITY_FETCH_DEBOUNCE_MS) {
|
||||
lastVisibilityFetchRef.current = now;
|
||||
fetchTasksFn()
|
||||
.then((tasks) => setTasks(tasks.map(normalizeTask)))
|
||||
.catch(() => {
|
||||
// Silently ignore fetch errors on visibility change
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [fetchTasksFn]);
|
||||
api.fetchTasks().then((tasks) => setTasks(tasks.map(normalizeTask))).catch(() => setTasks([]));
|
||||
}, []);
|
||||
|
||||
// SSE live updates
|
||||
// Note: In multi-project mode, SSE receives all task events.
|
||||
// Tasks are filtered by ID match, so cross-project updates won't affect
|
||||
// the local state since task IDs are unique and we only fetch from one project.
|
||||
useEffect(() => {
|
||||
let closedByCleanup = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -95,17 +43,12 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
const handleCreated = (e: MessageEvent) => {
|
||||
const task = normalizeTask(JSON.parse(e.data) as Task);
|
||||
// In project mode, only add if this task belongs to our project
|
||||
// Since we can't determine project from event, we add and let subsequent
|
||||
// fetches correct the state, or filter by checking if task exists in our set
|
||||
setTasks((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((t) => t.id === task.id)) return prev;
|
||||
return [...prev, task];
|
||||
});
|
||||
setTasks((prev) => [...prev, task]);
|
||||
};
|
||||
|
||||
const handleMoved = (e: MessageEvent) => {
|
||||
// Payload: { task, from, to } - task object includes server-set columnMovedAt
|
||||
// We use 'to' as the authoritative column and trust the server's columnMovedAt
|
||||
const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
@@ -121,24 +64,35 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
prev.map((t) => {
|
||||
if (t.id !== incoming.id) return t;
|
||||
|
||||
// First check overall freshness using updatedAt
|
||||
const updatedAtCompare = compareTimestamps(incoming.updatedAt, t.updatedAt);
|
||||
|
||||
// If incoming is older overall, skip the update
|
||||
if (updatedAtCompare < 0) {
|
||||
return t;
|
||||
}
|
||||
|
||||
// If columns are the same, no conflict - accept the incoming update
|
||||
if (t.column === incoming.column) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// Columns differ - need to check columnMovedAt to resolve conflict
|
||||
const columnTimestampCompare = compareTimestamps(t.columnMovedAt, incoming.columnMovedAt);
|
||||
|
||||
// Edge case: current has columnMovedAt but incoming doesn't (legacy data)
|
||||
// Preserve the column information we have
|
||||
if (t.columnMovedAt && !incoming.columnMovedAt) {
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
// If current state has a newer columnMovedAt, reject the column change
|
||||
if (columnTimestampCompare > 0) {
|
||||
// Current state is newer - preserve column, merge other fields
|
||||
return { ...incoming, column: t.column, columnMovedAt: t.columnMovedAt };
|
||||
}
|
||||
|
||||
// Incoming has newer or equal columnMovedAt, accept the update
|
||||
return incoming;
|
||||
})
|
||||
);
|
||||
@@ -150,10 +104,13 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
};
|
||||
|
||||
const handleMerged = (e: MessageEvent) => {
|
||||
// Payload: { task, branch, merged, worktreeRemoved, branchDeleted, ... }
|
||||
// The task object has already been moved to 'done' by the server
|
||||
const { task }: { task: Task } = JSON.parse(e.data);
|
||||
const normalizedTask = normalizeTask(task);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
// Ensure column is 'done' since that's where merged tasks always go
|
||||
t.id === normalizedTask.id ? { ...normalizedTask, column: "done" as Column } : t
|
||||
)
|
||||
);
|
||||
@@ -224,6 +181,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
id: string,
|
||||
updates: { title?: string; description?: string; dependencies?: string[] }
|
||||
): Promise<Task> => {
|
||||
// Optimistic update: apply changes immediately
|
||||
const previousTask = tasksRef.current.find((t) => t.id === id);
|
||||
const optimisticTask = previousTask
|
||||
? { ...previousTask, ...updates, updatedAt: new Date().toISOString() }
|
||||
@@ -237,11 +195,13 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
|
||||
try {
|
||||
const updatedTask = normalizeTask(await api.updateTask(id, updates));
|
||||
// Replace with server response
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? updatedTask : t))
|
||||
);
|
||||
return updatedTask;
|
||||
} catch (err) {
|
||||
// Rollback on error: restore previous state
|
||||
if (previousTask) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === id ? previousTask : t))
|
||||
@@ -270,6 +230,7 @@ export function useTasks(options?: UseTasksOptions) {
|
||||
const archiveAllDone = useCallback(async (): Promise<Task[]> => {
|
||||
const archived = await api.archiveAllDone();
|
||||
const normalized = archived.map(normalizeTask);
|
||||
// Update local state by mapping over tasks and updating archived ones
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => {
|
||||
const updated = normalized.find((archived) => archived.id === t.id);
|
||||
|
||||
Reference in New Issue
Block a user