feat(KB-048): add collapsible list sections to dashboard
- Add section expansion state management with localStorage persistence - Update section headers with chevron toggle controls - Implement conditional task row rendering based on section state - Add Expand All / Collapse All toolbar controls - Add CSS styles for chevron rotation animation and section headers - Add comprehensive tests for collapsible section behavior
This commit is contained in:
@@ -12,6 +12,7 @@ function makeMockStore() {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
stopWatching: vi.fn(),
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
@@ -19,6 +20,8 @@ function makeMockStore() {
|
||||
pollIntervalMs: 60_000,
|
||||
}),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn().mockResolvedValue({ column: "in-review", paused: false }),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
}),
|
||||
@@ -613,3 +616,424 @@ describe("runDashboard — enginePaused (soft pause)", () => {
|
||||
expect(mergedIds).toContain("KB-EP2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — --paused flag", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
const engine = await import("@kb/engine");
|
||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
Promise.resolve({ merged: true }),
|
||||
);
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
);
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => {
|
||||
await runDashboard(0, { open: false, paused: true });
|
||||
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||
});
|
||||
|
||||
it("logs a message when starting in paused mode", async () => {
|
||||
await runDashboard(0, { open: false, paused: true });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"[engine] Starting in paused mode — automation disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT set enginePaused when paused option is absent", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// updateSettings should not be called with enginePaused during normal startup
|
||||
const enginePausedCalls = mockStore.updateSettings.mock.calls.filter(
|
||||
(call: any[]) => call[0]?.enginePaused !== undefined,
|
||||
);
|
||||
expect(enginePausedCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does NOT log paused message when starting normally", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
const pausedMessageCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => args[0] === "[engine] Starting in paused mode — automation disabled",
|
||||
);
|
||||
expect(pausedMessageCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — --paused flag", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
const engine = await import("@kb/engine");
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
() => ({ resumeOrphaned: vi.fn().mockResolvedValue(undefined) }),
|
||||
);
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls store.updateSettings({ enginePaused: true }) when paused: true is passed", async () => {
|
||||
await runDashboard(0, { open: false, paused: true });
|
||||
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT call store.updateSettings when paused flag is absent", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
expect(mockStore.updateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs paused mode message when starting with paused: true", async () => {
|
||||
await runDashboard(0, { open: false, paused: true });
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"[engine] Starting in paused mode — automation disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT log paused mode message when paused flag is absent", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
const pausedMessageCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("paused mode"),
|
||||
);
|
||||
expect(pausedMessageCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — --dev mode", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
const engine = await import("@kb/engine");
|
||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
Promise.resolve({ merged: true }),
|
||||
);
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
);
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does NOT start TriageProcessor in dev mode", async () => {
|
||||
const { TriageProcessor } = await import("@kb/engine");
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
expect(TriageProcessor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT start TaskExecutor in dev mode", async () => {
|
||||
const { TaskExecutor } = await import("@kb/engine");
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
expect(TaskExecutor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT start Scheduler in dev mode", async () => {
|
||||
const { Scheduler } = await import("@kb/engine");
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
expect(Scheduler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts the server correctly in dev mode", async () => {
|
||||
const { createServer } = await import("@kb/dashboard");
|
||||
await runDashboard(4040, { open: false, dev: true });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Server should have been created and listen called
|
||||
expect(createServer).toHaveBeenCalled();
|
||||
expect(mockListen).toHaveBeenCalledWith(4040);
|
||||
|
||||
// Banner should show the port
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("http://localhost:4040"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows 'AI engine: disabled (dev mode)' in dev mode", async () => {
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should show disabled message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("✗ disabled (dev mode)"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT show triage/scheduler details in dev mode", async () => {
|
||||
await runDashboard(0, { open: false, dev: true });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should NOT show triage/scheduler details
|
||||
const triageCall = consoleSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("• triage"),
|
||||
);
|
||||
const schedulerCall = consoleSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("• scheduler"),
|
||||
);
|
||||
expect(triageCall).toBeUndefined();
|
||||
expect(schedulerCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts all engine components when dev is false (default)", async () => {
|
||||
const { TriageProcessor, TaskExecutor, Scheduler } = await import("@kb/engine");
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
expect(TriageProcessor).toHaveBeenCalled();
|
||||
expect(TaskExecutor).toHaveBeenCalled();
|
||||
expect(Scheduler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows 'AI engine: ✓ active' when not in dev mode", async () => {
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Wait for async 'listening' event
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should show active message
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("✓ active"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDashboard — merge conflict retry logic", () => {
|
||||
let mockStore: ReturnType<typeof makeMockStore>;
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedExecutorOpts = undefined;
|
||||
vi.clearAllMocks();
|
||||
mockStore = makeMockStore();
|
||||
const { TaskStore } = await import("@kb/core");
|
||||
(TaskStore as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
|
||||
|
||||
// Default mock store.getTask implementation
|
||||
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 0,
|
||||
}));
|
||||
|
||||
const engine = await import("@kb/engine");
|
||||
(engine.aiMergeTask as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
Promise.resolve({ merged: true }),
|
||||
);
|
||||
(engine.TaskExecutor as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(_store: unknown, _cwd: unknown, opts: unknown) => {
|
||||
capturedExecutorOpts = opts as Record<string, unknown>;
|
||||
return { resumeOrphaned: vi.fn().mockResolvedValue(undefined) };
|
||||
},
|
||||
);
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("increments mergeRetries and re-enqueues on conflict error", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
// Simulate merge failure with conflict
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Merge conflict detected in package-lock.json"),
|
||||
);
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-RETRY", column: "in-review", paused: false },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
// Wait for retry scheduling
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should have incremented mergeRetries
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"KB-RETRY",
|
||||
expect.objectContaining({ mergeRetries: 1 }),
|
||||
);
|
||||
|
||||
// Should log retry attempt
|
||||
const retryLog = consoleSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("retry 1/3"),
|
||||
);
|
||||
expect(retryLog).toBeDefined();
|
||||
});
|
||||
|
||||
it("gives up after max retries (3) exceeded", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Merge conflict detected"),
|
||||
);
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
// Task already has 3 retries
|
||||
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 3,
|
||||
}));
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-MAX", column: "in-review", paused: false, mergeRetries: 3 },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should log max retries exceeded
|
||||
const maxRetryLog = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
typeof call[0] === "string" && call[0].includes("max retries (3) exceeded"),
|
||||
);
|
||||
expect(maxRetryLog).toBeDefined();
|
||||
|
||||
// Should reset mergeRetries on the task
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"KB-MAX",
|
||||
expect.objectContaining({ status: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips retry when autoResolveConflicts is disabled", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("Merge conflict detected"),
|
||||
);
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: false, // Disabled
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-NO-AUTO", column: "in-review", paused: false },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// Should log that auto-resolve is disabled
|
||||
const disabledLog = consoleSpy.mock.calls.find(
|
||||
(call) =>
|
||||
typeof call[0] === "string" &&
|
||||
call[0].includes("autoResolveConflicts disabled"),
|
||||
);
|
||||
expect(disabledLog).toBeDefined();
|
||||
});
|
||||
|
||||
it("clears mergeRetries on successful merge after retries", async () => {
|
||||
const { aiMergeTask } = await import("@kb/engine");
|
||||
|
||||
(aiMergeTask as ReturnType<typeof vi.fn>).mockResolvedValue({ merged: true });
|
||||
|
||||
mockStore.getSettings.mockResolvedValue({
|
||||
maxConcurrent: 1,
|
||||
maxWorktrees: 2,
|
||||
autoMerge: true,
|
||||
autoResolveConflicts: true,
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
});
|
||||
|
||||
// Task had previous retries
|
||||
mockStore.getTask = vi.fn().mockImplementation(async (id: string) => ({
|
||||
id,
|
||||
column: "in-review",
|
||||
paused: false,
|
||||
mergeRetries: 2,
|
||||
}));
|
||||
|
||||
mockStore.listTasks.mockResolvedValue([
|
||||
{ id: "KB-SUCCESS", column: "in-review", paused: false, mergeRetries: 2 },
|
||||
]);
|
||||
|
||||
await runDashboard(0, { open: false });
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// Should clear mergeRetries on success
|
||||
expect(mockStore.updateTask).toHaveBeenCalledWith(
|
||||
"KB-SUCCESS",
|
||||
expect.objectContaining({ mergeRetries: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user