generated from nhcarrigan/template
e6e9f7ae59
## 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>
326 lines
10 KiB
TypeScript
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]]);
|
|
});
|
|
});
|