Files
hikari-desktop/src/lib/stores/taskLoop.test.ts
T
hikari e6e9f7ae59
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m39s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
feat: productivity suite — task loop, workflow, theming, docs & more (#197)
## Summary

A large productivity-focused feature branch delivering a suite of improvements across automation, project management, theming, performance, and documentation.

### Features

- **Guided Project Workflow** (#189) — Four-phase workflow panel (Discuss → Plan → Execute → Verify) to keep projects structured from idea to completion
- **Automated Task Loop** (#179) — Per-task conversation orchestration with wave-based parallel execution, blocked-task detection, and concurrency control
- **Wave-Based Parallel Execution** (#191) — Tasks run in dependency-aware waves with configurable concurrency; independent tasks execute in parallel
- **Auto-Commit After Task Completion** (#192) — Task Loop optionally commits after each completed task so progress is never lost
- **PRD Creator** (#180) — AI-assisted PRD and task list panel that outputs `hikari-tasks.json` for the Task Loop to consume
- **Project Context Panel** (#188) — Persistent `PROJECT.md`, `REQUIREMENTS.md`, `ROADMAP.md`, and `STATE.md` files injected into Claude's context automatically
- **Codebase Mapper** (#190) — Generates a `CODEBASE.md` architectural summary so Claude always understands the project structure
- **Community Preset Themes** (#181) — Six built-in community themes: Dracula, Catppuccin Mocha, Nord, Solarized Dark, Gruvbox Dark, and Rosé Pine
- **In-App Changelog Panel** (#193) — Fetches release notes from GitHub at runtime and displays them inside the app
- **Full Embedded Documentation** (#196) — Replaced the single-page help modal with a 12-page paginated docs browser featuring a sidebar TOC, prev/next navigation, keyboard navigation (arrow keys, `?` shortcut), and comprehensive coverage of every feature

### Performance & Fixes

- **Lazy Loading & Virtualisation** (#194) — Virtual windowing for conversation history, markdown memoisation, and debounced search for smooth rendering of large sessions
- **Ctrl+C Copy Fix** (#195) — `Ctrl+C` now copies selected text as expected; interrupt-Claude behaviour only fires when no text is selected

### UX

- Back-to-workflow button in PRD Creator and Task Loop panels for easy navigation
- Navigation icon cluster replaced with a single clean dropdown menu

## Closes

Closes #179
Closes #180
Closes #181
Closes #188
Closes #189
Closes #190
Closes #191
Closes #192
Closes #193
Closes #194
Closes #195
Closes #196

---

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #197
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-03-07 03:08:33 -08:00

326 lines
10 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
findNextPendingIndex,
countByStatus,
buildTaskPrompt,
buildAutoCommitPrompt,
normalizeToUnixPath,
isTaskBlocked,
getReadyTasks,
computeWaves,
type TaskLoopTask,
} from "./taskLoop";
const makeTask = (
id: string,
status: TaskLoopTask["status"] = "pending",
dependsOn?: string[]
): TaskLoopTask => ({
id,
title: `Task ${id}`,
prompt: `Do the thing for ${id}`,
priority: "medium",
status,
dependsOn,
});
describe("findNextPendingIndex", () => {
it("returns -1 for an empty list", () => {
expect(findNextPendingIndex([])).toBe(-1);
});
it("returns 0 when the first task is pending", () => {
const tasks = [makeTask("1", "pending"), makeTask("2", "pending")];
expect(findNextPendingIndex(tasks)).toBe(0);
});
it("skips completed and failed tasks to find the next pending", () => {
const tasks = [
makeTask("1", "completed"),
makeTask("2", "failed"),
makeTask("3", "pending"),
makeTask("4", "pending"),
];
expect(findNextPendingIndex(tasks)).toBe(2);
});
it("returns -1 when all tasks are completed", () => {
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
expect(findNextPendingIndex(tasks)).toBe(-1);
});
it("skips running tasks", () => {
const tasks = [makeTask("1", "running"), makeTask("2", "pending")];
expect(findNextPendingIndex(tasks)).toBe(1);
});
});
describe("countByStatus", () => {
it("returns 0 for an empty list", () => {
expect(countByStatus([], "pending")).toBe(0);
});
it("counts only tasks with the specified status", () => {
const tasks = [
makeTask("1", "pending"),
makeTask("2", "completed"),
makeTask("3", "pending"),
makeTask("4", "failed"),
];
expect(countByStatus(tasks, "pending")).toBe(2);
expect(countByStatus(tasks, "completed")).toBe(1);
expect(countByStatus(tasks, "failed")).toBe(1);
expect(countByStatus(tasks, "running")).toBe(0);
});
it("counts all tasks when all have the same status", () => {
const tasks = [makeTask("1", "completed"), makeTask("2", "completed")];
expect(countByStatus(tasks, "completed")).toBe(2);
});
});
describe("buildTaskPrompt", () => {
it("includes the task number and total", () => {
const task = makeTask("1");
const result = buildTaskPrompt(task, 1, 5);
expect(result).toContain("1/5");
});
it("includes the task title", () => {
const task = makeTask("abc");
const result = buildTaskPrompt(task, 2, 3);
expect(result).toContain("Task abc");
});
it("includes the task prompt", () => {
const task = makeTask("x");
const result = buildTaskPrompt(task, 1, 1);
expect(result).toContain("Do the thing for x");
});
it("labels the output as an automated task loop entry", () => {
const task = makeTask("1");
const result = buildTaskPrompt(task, 1, 1);
expect(result).toContain("Automated Task Loop");
});
});
describe("buildAutoCommitPrompt", () => {
const task = makeTask("task-1");
it("includes the git add and commit commands", () => {
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
expect(result).toContain("git add -A");
expect(result).toContain("git commit -m");
});
it("uses the provided prefix in the commit message", () => {
const result = buildAutoCommitPrompt(task, "fix", false, "2026-03-07T12:00:00");
expect(result).toContain("fix:");
});
it("includes the task title in the commit message", () => {
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
expect(result).toContain("Task task-1");
});
it("includes the task id in the commit body", () => {
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
expect(result).toContain("task-1");
});
it("includes the session timestamp in the commit body", () => {
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
expect(result).toContain("2026-03-07T12:00:00");
});
it("does not include SUMMARY.md instructions when includeSummary is false", () => {
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
expect(result).not.toContain("SUMMARY.md");
});
it("includes SUMMARY.md instructions when includeSummary is true", () => {
const result = buildAutoCommitPrompt(task, "feat", true, "2026-03-07T12:00:00");
expect(result).toContain("SUMMARY.md");
});
it("mentions non-blocking failure handling", () => {
const result = buildAutoCommitPrompt(task, "feat", false, "2026-03-07T12:00:00");
expect(result).toContain("do not retry");
});
it("escapes double quotes in the task title", () => {
const quotedTask = makeTask("q1");
quotedTask.title = 'Fix "quoted" title';
const result = buildAutoCommitPrompt(quotedTask, "fix", false, "2026-03-07T12:00:00");
expect(result).toContain('\\"quoted\\"');
});
});
describe("normalizeToUnixPath", () => {
it("converts a WSL UNC path with wsl.localhost to a Unix path", () => {
expect(
normalizeToUnixPath("\\\\wsl.localhost\\Ubuntu\\home\\naomi\\code\\temp\\file.json")
).toBe("/home/naomi/code/temp/file.json");
});
it("converts a WSL UNC path with wsl$ (legacy) to a Unix path", () => {
expect(normalizeToUnixPath("\\\\wsl$\\Ubuntu\\home\\naomi\\file.json")).toBe(
"/home/naomi/file.json"
);
});
it("handles forward-slash UNC paths produced by some tools", () => {
expect(normalizeToUnixPath("//wsl.localhost/Ubuntu/home/naomi/file.json")).toBe(
"/home/naomi/file.json"
);
});
it("leaves a plain Unix path unchanged", () => {
expect(normalizeToUnixPath("/home/naomi/code/temp/file.json")).toBe(
"/home/naomi/code/temp/file.json"
);
});
it("leaves an empty string unchanged", () => {
expect(normalizeToUnixPath("")).toBe("");
});
});
describe("isTaskBlocked", () => {
it("returns false when dependsOn is empty", () => {
const task = makeTask("a", "pending", []);
expect(isTaskBlocked(task, [task])).toBe(false);
});
it("returns false when dependsOn is undefined", () => {
const task = makeTask("a", "pending");
expect(isTaskBlocked(task, [task])).toBe(false);
});
it("returns false when all dependencies are completed", () => {
const dep = makeTask("dep", "completed");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(false);
});
it("returns true when a dependency has failed", () => {
const dep = makeTask("dep", "failed");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(true);
});
it("returns true when a dependency is blocked", () => {
const dep = makeTask("dep", "blocked");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(true);
});
it("returns false when a dependency is still pending (not yet failed)", () => {
const dep = makeTask("dep", "pending");
const task = makeTask("a", "pending", ["dep"]);
expect(isTaskBlocked(task, [dep, task])).toBe(false);
});
it("returns false when dependency ID does not exist in task list", () => {
const task = makeTask("a", "pending", ["nonexistent"]);
expect(isTaskBlocked(task, [task])).toBe(false);
});
});
describe("getReadyTasks", () => {
it("returns empty array when task list is empty", () => {
expect(getReadyTasks([], 3)).toEqual([]);
});
it("returns all pending tasks with no deps when under limit", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
expect(getReadyTasks(tasks, 5)).toEqual([0, 1, 2]);
});
it("respects the concurrency limit", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
expect(getReadyTasks(tasks, 2)).toEqual([0, 1]);
});
it("skips tasks whose dependencies are not completed", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending", ["a"])];
// b depends on a which is pending, not completed — so only a is ready
expect(getReadyTasks(tasks, 5)).toEqual([0]);
});
it("includes task when all its dependencies are completed", () => {
const tasks = [makeTask("a", "completed"), makeTask("b", "pending", ["a"])];
expect(getReadyTasks(tasks, 5)).toEqual([1]);
});
it("skips running, completed, failed, and blocked tasks", () => {
const tasks = [
makeTask("a", "running"),
makeTask("b", "completed"),
makeTask("c", "failed"),
makeTask("d", "blocked"),
makeTask("e", "pending"),
];
expect(getReadyTasks(tasks, 5)).toEqual([4]);
});
it("returns empty when limit is 0", () => {
const tasks = [makeTask("a", "pending")];
expect(getReadyTasks(tasks, 0)).toEqual([]);
});
});
describe("computeWaves", () => {
it("returns empty array for empty task list", () => {
expect(computeWaves([])).toEqual([]);
});
it("puts all independent tasks in a single wave", () => {
const tasks = [makeTask("a", "pending"), makeTask("b", "pending"), makeTask("c", "pending")];
expect(computeWaves(tasks)).toEqual([[0, 1, 2]]);
});
it("creates one wave per task for a linear chain", () => {
const tasks = [
makeTask("a", "pending"),
makeTask("b", "pending", ["a"]),
makeTask("c", "pending", ["b"]),
];
expect(computeWaves(tasks)).toEqual([[0], [1], [2]]);
});
it("handles diamond dependency: A → B,C → D", () => {
// A has no deps, B and C depend on A, D depends on B and C
const tasks = [
makeTask("a", "pending"),
makeTask("b", "pending", ["a"]),
makeTask("c", "pending", ["a"]),
makeTask("d", "pending", ["b", "c"]),
];
const waves = computeWaves(tasks);
expect(waves).toHaveLength(3);
expect(waves[0]).toEqual([0]);
expect(waves[1]).toEqual([1, 2]);
expect(waves[2]).toEqual([3]);
});
it("groups circular dependencies into a final overflow wave", () => {
// a→b, b→a — circular; c has no deps so goes in wave 0
const tasks = [
makeTask("a", "pending", ["b"]),
makeTask("b", "pending", ["a"]),
makeTask("c", "pending"),
];
const waves = computeWaves(tasks);
// c goes in wave 0, then a+b get dumped in overflow
expect(waves[0]).toEqual([2]);
expect(waves[1]).toEqual([0, 1]);
});
it("ignores unknown dependency IDs (treats them as satisfied)", () => {
const tasks = [makeTask("a", "pending", ["nonexistent"])];
expect(computeWaves(tasks)).toEqual([[0]]);
});
});