chore: consolidate remaining tests into __tests__/ dirs and refactor mocks

Mostly mechanical cleanup left over from the earlier test-consolidation pass:
- Update import paths to ../../ for mocks now that test files moved deeper
- Simplify mock setup (drop usePluginUiSlots inline mock, etc.)
- Move engine ipc + runtimes tests into __tests__/ subdirs
- Move dashboard utils tests into __tests__/ subdir
- Refresh fusion-plugin-hermes-runtime/dist artifacts

build-exe.test.ts: spawn-import fix from a parallel branch (resolved during
worktree merge of the CSS extraction work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-24 19:38:40 -07:00
parent f11800f2c6
commit 6685d832fa
35 changed files with 11381 additions and 12232 deletions

View File

@@ -0,0 +1,512 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getAgentHealthStatus, getAgentHealthColorVar } from "../agentHealth";
import type { Agent } from "../../api";
// Mock Date.now to get deterministic elapsed time calculations
const FIXED_NOW = new Date("2026-04-10T12:00:00.000Z").getTime();
type AgentHealthInput = Pick<
Agent,
"state" | "lastHeartbeatAt" | "lastError" | "pauseReason" | "runtimeConfig" | "metadata" | "name" | "role" | "taskId"
>;
function makeAgent(overrides: Partial<AgentHealthInput> = {}): AgentHealthInput {
return {
name: "Test Agent",
role: "executor",
state: "idle",
taskId: undefined,
metadata: {},
lastHeartbeatAt: undefined,
lastError: undefined,
pauseReason: undefined,
runtimeConfig: undefined,
...overrides,
};
}
describe("getAgentHealthStatus", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FIXED_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// ── Terminal states ──────────────────────────────────────────────────────
describe("terminated state", () => {
it('returns "Terminated" for terminated agents', () => {
const agent = makeAgent({ state: "terminated" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Terminated");
expect(status.stateDerived).toBe(true);
expect(status.color).toBe("var(--state-error-text)");
});
it("ignores heartbeat data for terminated agents", () => {
const agent = makeAgent({
state: "terminated",
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Terminated");
expect(status.stateDerived).toBe(true);
});
});
describe("error state", () => {
it('returns "Error" for error agents without lastError', () => {
const agent = makeAgent({ state: "error" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Error");
expect(status.stateDerived).toBe(true);
expect(status.color).toBe("var(--state-error-text)");
});
it("uses lastError as label when available", () => {
const agent = makeAgent({ state: "error", lastError: "Agent crashed" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Agent crashed");
expect(status.stateDerived).toBe(false);
});
it("ignores heartbeat data for error agents", () => {
const agent = makeAgent({
state: "error",
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Error");
expect(status.stateDerived).toBe(true);
});
});
describe("paused state", () => {
it('returns "Paused" for paused agents without pauseReason', () => {
const agent = makeAgent({ state: "paused" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Paused");
expect(status.stateDerived).toBe(true);
expect(status.color).toBe("var(--state-paused-text)");
});
it("includes pauseReason in label when available", () => {
const agent = makeAgent({ state: "paused", pauseReason: "User requested" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Paused: User requested");
expect(status.stateDerived).toBe(false);
});
it("ignores heartbeat data for paused agents", () => {
const agent = makeAgent({
state: "paused",
lastHeartbeatAt: new Date(FIXED_NOW - 1000).toISOString(),
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Paused");
expect(status.stateDerived).toBe(true);
});
});
describe("running state", () => {
it('returns "Running" for running agents', () => {
const agent = makeAgent({ state: "running" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Running");
expect(status.stateDerived).toBe(true);
expect(status.color).toBe("var(--state-active-text)");
});
it("ignores heartbeat data for running agents", () => {
const agent = makeAgent({
state: "running",
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(), // 100s ago - would be "unresponsive" without this
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Running");
expect(status.stateDerived).toBe(true);
});
});
// Heartbeat scheduling is driven by agent.state on the server; there is no
// separate "disabled" UI concept anymore. Non-task-worker agents with a
// legacy `runtimeConfig.enabled === false` on disk are rendered by state
// just like any other agent.
describe("task worker health classification", () => {
it('returns "Running" for metadata-marked task workers with disabled heartbeat', () => {
const agent = makeAgent({
name: "executor-FN-1661",
role: "executor",
state: "active",
taskId: "FN-1661",
metadata: {
agentKind: "task-worker",
taskWorker: true,
managedBy: "task-executor",
},
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(),
runtimeConfig: { enabled: false, heartbeatTimeoutMs: 60_000 },
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Running");
expect(status.stateDerived).toBe(true);
expect(status.color).toBe("var(--state-active-text)");
});
it('returns "Running" for legacy executor-* task workers with stale heartbeat', () => {
const agent = makeAgent({
name: "executor-FN-1661",
role: "executor",
state: "active",
taskId: "FN-1661",
lastHeartbeatAt: new Date(FIXED_NOW - 1_000_000).toISOString(),
runtimeConfig: { heartbeatTimeoutMs: 30_000 },
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Running");
expect(status.stateDerived).toBe(true);
expect(status.color).toBe("var(--state-active-text)");
});
it('ignores legacy runtimeConfig.enabled=false on non-task-worker agents', () => {
const agent = makeAgent({
name: "Reviewer",
role: "reviewer",
state: "active",
runtimeConfig: { enabled: false },
});
const status = getAgentHealthStatus(agent);
// No persisted heartbeat, no lastHeartbeatAt → Starting... not Disabled.
expect(status.label).toBe("Starting...");
});
});
// ── No heartbeat data ──────────────────────────────────────────────────────
describe("no heartbeat data", () => {
it('returns "Starting..." for active agents with no lastHeartbeatAt', () => {
const agent = makeAgent({ state: "active" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Starting...");
expect(status.stateDerived).toBe(false);
expect(status.color).toBe("var(--text-secondary)");
});
it('returns "Idle" for non-active agents with no lastHeartbeatAt', () => {
const agent = makeAgent({ state: "idle" });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Idle");
expect(status.stateDerived).toBe(false);
expect(status.color).toBe("var(--text-secondary)");
});
it('returns "Idle" for terminated agents without heartbeat (edge case)', () => {
// Although terminated state takes precedence, testing the fallback
const agent = makeAgent({ state: "idle", lastHeartbeatAt: undefined });
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Idle");
expect(status.stateDerived).toBe(false);
});
});
// ── Healthy vs Unresponsive ───────────────────────────────────────────────
describe("heartbeat freshness", () => {
it('returns "Healthy" when heartbeat is fresh (within timeout) with periodic heartbeat', () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(), // 30s ago, well within 60s timeout
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
expect(status.color).toBe("var(--state-active-text)");
});
it('returns "Healthy" when heartbeat is exactly at the timeout boundary with periodic heartbeat', () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // exactly 60s ago
runtimeConfig: { heartbeatIntervalMs: 30_000 }, // periodic heartbeat configured
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
});
it('returns "Unresponsive" when heartbeat exceeds the timeout with periodic heartbeat', () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 12 * 60 * 1000 - 1).toISOString(), // just over 12 minutes ago
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Unresponsive");
expect(status.stateDerived).toBe(false);
expect(status.color).toBe("var(--state-error-text)");
});
it("ignores heartbeatTimeoutMs — that's the per-run work budget, not freshness", () => {
// 30s interval → staleness threshold = max(60s floor, 60s) = 60s. A
// 45s-old heartbeat is healthy regardless of what heartbeatTimeoutMs says.
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 45_000).toISOString(),
runtimeConfig: { heartbeatIntervalMs: 30_000, heartbeatTimeoutMs: 30_000 },
});
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
});
});
// ── Agents without explicit heartbeatIntervalMs ───────────────────────────
//
// Agents that never had an interval persisted still get the server-side
// default interval (1h), so they render Healthy within ~2h of the last
// heartbeat and tip into Unresponsive beyond that.
describe("agents without explicit heartbeatIntervalMs", () => {
it('returns "Healthy" within the default-interval grace window', () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 60_000).toISOString(), // 1m ago
runtimeConfig: {}, // no interval — falls back to 1h default
});
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
});
it('returns "Unresponsive" once elapsed exceeds 2× the default 1h interval', () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 3 * 3_600_000).toISOString(), // 3h ago
runtimeConfig: {},
});
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
});
it("clamps invalid intervals (0/negative) to the dashboard minimum (5m)", () => {
// 0 clamp to 300000ms (5m minimum) → threshold = max(300000 × 2, 60000) = 600000ms (10 minutes).
// A heartbeat 11 minutes old is stale.
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 660_000).toISOString(), // 11 minutes ago
runtimeConfig: { heartbeatIntervalMs: 0 },
});
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
});
});
// ── Staleness floor ───────────────────────────────────────────────────────
//
// Short intervals get a 60s floor so the UI doesn't flicker between
// Healthy and Unresponsive every tick for second-level heartbeats.
describe("staleness floor", () => {
it("holds Healthy below the 60s floor even for sub-minute intervals", () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
runtimeConfig: { heartbeatIntervalMs: 10_000 },
});
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
});
it("tips to Unresponsive past the floor", () => {
// 6 minute interval → threshold = max(6 × 60s × 2, 60s floor) = max(12 min, 1 min) = 12 minutes.
// A heartbeat 13 minutes old exceeds the 12-minute threshold.
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
});
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
});
});
describe("stateDerived semantics", () => {
it.each([
{
name: "paused without reason",
agent: makeAgent({ state: "paused" }),
expectedLabel: "Paused",
expectedStateDerived: true,
},
{
name: "paused with reason",
agent: makeAgent({ state: "paused", pauseReason: "Backoff" }),
expectedLabel: "Paused: Backoff",
expectedStateDerived: false,
},
{
name: "running",
agent: makeAgent({ state: "running" }),
expectedLabel: "Running",
expectedStateDerived: true,
},
{
name: "error without lastError",
agent: makeAgent({ state: "error" }),
expectedLabel: "Error",
expectedStateDerived: true,
},
{
name: "error with lastError",
agent: makeAgent({ state: "error", lastError: "OOM" }),
expectedLabel: "OOM",
expectedStateDerived: false,
},
{
name: "terminated",
agent: makeAgent({ state: "terminated" }),
expectedLabel: "Terminated",
expectedStateDerived: true,
},
{
name: "healthy",
agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 10_000).toISOString() }),
expectedLabel: "Healthy",
expectedStateDerived: false,
},
{
name: "unresponsive",
agent: makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
runtimeConfig: { heartbeatIntervalMs: 6 * 60 * 1000 }, // 6 minute interval
}),
expectedLabel: "Unresponsive",
expectedStateDerived: false,
},
{
name: "idle",
agent: makeAgent({ state: "idle", lastHeartbeatAt: undefined }),
expectedLabel: "Idle",
expectedStateDerived: false,
},
{
name: "starting",
agent: makeAgent({ state: "active", lastHeartbeatAt: undefined }),
expectedLabel: "Starting...",
expectedStateDerived: false,
},
])("sets stateDerived correctly for $name", ({ agent, expectedLabel, expectedStateDerived }) => {
const status = getAgentHealthStatus(agent);
expect(status.label).toBe(expectedLabel);
expect(status.stateDerived).toBe(expectedStateDerived);
});
});
// ── Edge cases ─────────────────────────────────────────────────────────────
describe("edge cases", () => {
it("handles null runtimeConfig gracefully", () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
runtimeConfig: null as unknown as undefined,
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
});
it("handles empty runtimeConfig object", () => {
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString(),
runtimeConfig: {},
});
const status = getAgentHealthStatus(agent);
expect(status.label).toBe("Healthy");
expect(status.stateDerived).toBe(false);
});
it("100s stale heartbeat with no explicit interval → Healthy (default 1h applies)", () => {
// 1h default interval → 2h threshold, so 100s is well within range.
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 100_000).toISOString(),
runtimeConfig: { heartbeatTimeoutMs: 120_000 }, // no heartbeatIntervalMs
});
expect(getAgentHealthStatus(agent).label).toBe("Healthy");
});
it("ignores runtimeConfig.enabled and uses interval-based staleness", () => {
// 6 minute interval → 12 minute threshold. 13 minutes elapsed is stale regardless of any
// legacy enabled flag or per-run timeout.
const agent = makeAgent({
state: "active",
lastHeartbeatAt: new Date(FIXED_NOW - 13 * 60 * 1000).toISOString(), // 13 minutes ago
runtimeConfig: { enabled: true, heartbeatIntervalMs: 6 * 60 * 1000, heartbeatTimeoutMs: 120_000 },
});
expect(getAgentHealthStatus(agent).label).toBe("Unresponsive");
});
it("returns consistent icons for all states", () => {
const testCases: Array<{ agent: ReturnType<typeof makeAgent>; expectedIconType: string }> = [
{ agent: makeAgent({ state: "terminated" }), expectedIconType: "Square" },
{ agent: makeAgent({ state: "error" }), expectedIconType: "Activity" },
{ agent: makeAgent({ state: "paused" }), expectedIconType: "Pause" },
{ agent: makeAgent({ state: "running" }), expectedIconType: "Activity" },
{ agent: makeAgent({ state: "idle" }), expectedIconType: "Bot" },
// state=active + no lastHeartbeatAt → "Starting..." → Bot icon
{ agent: makeAgent({ state: "active", runtimeConfig: { enabled: false } }), expectedIconType: "Bot" },
{
agent: makeAgent({
name: "executor-FN-1661",
role: "executor",
state: "active",
taskId: "FN-1661",
metadata: { agentKind: "task-worker" },
runtimeConfig: { enabled: false },
}),
expectedIconType: "Activity",
},
// Active with recent heartbeat should show "Healthy" (Heart icon)
{ agent: makeAgent({ state: "active", lastHeartbeatAt: new Date(FIXED_NOW - 30_000).toISOString() }), expectedIconType: "Heart" },
];
testCases.forEach(({ agent, expectedIconType }) => {
const status = getAgentHealthStatus(agent);
// lucide icons expose their component on the JSX element's `type`
const iconElement = status.icon as JSX.Element & {
type?: {
displayName?: string;
name?: string;
};
};
const iconType = iconElement.type?.displayName ?? iconElement.type?.name;
expect(iconType).toBe(expectedIconType);
});
});
});
});
describe("getAgentHealthColorVar", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(FIXED_NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("extracts CSS variable name from health status color", () => {
const agent = makeAgent({ state: "terminated" });
const colorVar = getAgentHealthColorVar(agent);
expect(colorVar).toBe("--state-error-text");
});
it("returns full color for non-variable colors (fallback)", () => {
// This shouldn't happen in practice, but testing the fallback
const agent = makeAgent({ state: "terminated" });
const status = getAgentHealthStatus(agent);
// The function should return the variable name in var() format
expect(getAgentHealthColorVar(agent)).toBe(status.color.replace(/var\((--[^)]+)\)/, "$1"));
});
});

View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from "vitest";
import {
HEARTBEAT_INTERVAL_PRESETS,
MIN_HEARTBEAT_INTERVAL_MS,
DEFAULT_HEARTBEAT_INTERVAL_MS,
formatHeartbeatInterval,
resolveHeartbeatIntervalMs,
getHeartbeatIntervalOptions,
} from "../heartbeatIntervals";
describe("HEARTBEAT_INTERVAL_PRESETS", () => {
it("starts at 5 minutes (300000ms)", () => {
expect(HEARTBEAT_INTERVAL_PRESETS[0].value).toBe(300000);
expect(HEARTBEAT_INTERVAL_PRESETS[0].label).toBe("5m");
});
it("includes 48h preset", () => {
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "48h");
expect(preset).toBeDefined();
expect(preset?.value).toBe(172800000);
});
it("includes 72h preset", () => {
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "72h");
expect(preset).toBeDefined();
expect(preset?.value).toBe(259200000);
});
it("includes 1w preset", () => {
const preset = HEARTBEAT_INTERVAL_PRESETS.find((p) => p.label === "1w");
expect(preset).toBeDefined();
expect(preset?.value).toBe(604800000);
});
it("does not include any presets below 5 minutes", () => {
const allBelow5m = HEARTBEAT_INTERVAL_PRESETS.filter((p) => p.value < 300000);
expect(allBelow5m).toHaveLength(0);
});
it("is sorted in ascending order by value", () => {
for (let i = 1; i < HEARTBEAT_INTERVAL_PRESETS.length; i++) {
expect(HEARTBEAT_INTERVAL_PRESETS[i].value).toBeGreaterThan(
HEARTBEAT_INTERVAL_PRESETS[i - 1].value,
);
}
});
});
describe("MIN_HEARTBEAT_INTERVAL_MS", () => {
it("is 5 minutes (300000ms)", () => {
expect(MIN_HEARTBEAT_INTERVAL_MS).toBe(300000);
});
});
describe("formatHeartbeatInterval", () => {
it("formats milliseconds below 1000", () => {
expect(formatHeartbeatInterval(500)).toBe("500ms");
});
it("formats seconds", () => {
expect(formatHeartbeatInterval(1000)).toBe("1s");
expect(formatHeartbeatInterval(30000)).toBe("30s");
expect(formatHeartbeatInterval(45000)).toBe("45s");
});
it("formats minutes", () => {
expect(formatHeartbeatInterval(60000)).toBe("1m");
expect(formatHeartbeatInterval(300000)).toBe("5m");
expect(formatHeartbeatInterval(2700000)).toBe("45m");
});
it("formats hours", () => {
expect(formatHeartbeatInterval(3600000)).toBe("1h");
expect(formatHeartbeatInterval(7200000)).toBe("2h");
expect(formatHeartbeatInterval(43200000)).toBe("12h");
});
it("formats days", () => {
expect(formatHeartbeatInterval(86400000)).toBe("1d");
expect(formatHeartbeatInterval(172800000)).toBe("2d");
expect(formatHeartbeatInterval(432000000)).toBe("5d");
});
it("formats weeks", () => {
expect(formatHeartbeatInterval(604800000)).toBe("1w");
expect(formatHeartbeatInterval(1209600000)).toBe("2w");
});
});
describe("resolveHeartbeatIntervalMs", () => {
it("returns default for non-number input", () => {
expect(resolveHeartbeatIntervalMs(undefined)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
expect(resolveHeartbeatIntervalMs(null)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
expect(resolveHeartbeatIntervalMs("300000")).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
expect(resolveHeartbeatIntervalMs({})).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
expect(resolveHeartbeatIntervalMs([])).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
});
it("returns default for NaN or Infinity", () => {
expect(resolveHeartbeatIntervalMs(NaN)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
expect(resolveHeartbeatIntervalMs(Infinity)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
expect(resolveHeartbeatIntervalMs(-Infinity)).toBe(DEFAULT_HEARTBEAT_INTERVAL_MS);
});
it("clamps values below 5 minutes to 5 minutes", () => {
expect(resolveHeartbeatIntervalMs(0)).toBe(300000);
expect(resolveHeartbeatIntervalMs(1000)).toBe(300000);
expect(resolveHeartbeatIntervalMs(60000)).toBe(300000);
expect(resolveHeartbeatIntervalMs(299999)).toBe(300000);
});
it("returns exact value for valid intervals >= 5 minutes", () => {
expect(resolveHeartbeatIntervalMs(300000)).toBe(300000);
expect(resolveHeartbeatIntervalMs(600000)).toBe(600000);
expect(resolveHeartbeatIntervalMs(3600000)).toBe(3600000);
expect(resolveHeartbeatIntervalMs(172800000)).toBe(172800000);
});
it("rounds floating point values", () => {
expect(resolveHeartbeatIntervalMs(300001.7)).toBe(300002);
expect(resolveHeartbeatIntervalMs(300001.3)).toBe(300001);
});
it("clamps negative values to minimum", () => {
expect(resolveHeartbeatIntervalMs(-1)).toBe(300000);
expect(resolveHeartbeatIntervalMs(-60000)).toBe(300000);
});
describe("legacy sub-5m values resolve to 5m", () => {
it("1s legacy value resolves to 5m", () => {
expect(resolveHeartbeatIntervalMs(1000)).toBe(300000);
});
it("5s legacy value resolves to 5m", () => {
expect(resolveHeartbeatIntervalMs(5000)).toBe(300000);
});
it("10s legacy value resolves to 5m", () => {
expect(resolveHeartbeatIntervalMs(10000)).toBe(300000);
});
it("30s legacy value resolves to 5m", () => {
expect(resolveHeartbeatIntervalMs(30000)).toBe(300000);
});
it("1m legacy value resolves to 5m", () => {
expect(resolveHeartbeatIntervalMs(60000)).toBe(300000);
});
});
});
describe("getHeartbeatIntervalOptions", () => {
it("returns all presets when interval matches a preset", () => {
const options = getHeartbeatIntervalOptions(300000);
expect(options).toEqual([...HEARTBEAT_INTERVAL_PRESETS]);
});
it("adds custom option when interval does not match any preset", () => {
const options = getHeartbeatIntervalOptions(650000);
// Should have all presets plus a custom option
expect(options.length).toBe(HEARTBEAT_INTERVAL_PRESETS.length + 1);
// The custom option should be added and sorted in by value
const customOption = options.find((o) => o.label.includes("(custom)"));
expect(customOption?.value).toBe(650000);
expect(customOption?.label).toBe("11m (custom)");
});
it("sorts custom option into correct position by value", () => {
// 48h is a preset, so no custom option added
const optionsWithPreset = getHeartbeatIntervalOptions(172800000);
expect(optionsWithPreset.length).toBe(HEARTBEAT_INTERVAL_PRESETS.length);
expect(optionsWithPreset).toEqual([...HEARTBEAT_INTERVAL_PRESETS]);
});
it("sorts custom option after 1w when custom value exceeds 1w", () => {
// 500h is not a preset, should be added and sorted after 1w
const options = getHeartbeatIntervalOptions(500 * 3600000);
const customOption = options.find((o) => o.label.includes("(custom)"));
expect(customOption).toBeDefined();
// Custom option should be inserted at the end since 500h > 1w
const customIndex = options.findIndex((o) => o.label.includes("(custom)"));
expect(options[customIndex - 1].label).toBe("1w");
});
it("handles custom intervals below the minimum", () => {
// Even if a legacy custom value is below 5m, getHeartbeatIntervalOptions
// should include it in the options (the resolver clamps when consuming)
const options = getHeartbeatIntervalOptions(30000); // 30s - no longer a preset
const customOption = options.find((o) => o.value === 30000);
expect(customOption).toBeDefined();
expect(customOption?.label).toBe("30s (custom)");
});
});

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import { highlightDiff } from "../highlightDiff";
import React from "react";
type ElProps = { className?: string; children?: React.ReactNode };
const propsOf = (el: React.ReactNode): ElProps =>
(el as React.ReactElement<ElProps>).props as ElProps;
const typeOf = (el: React.ReactNode) =>
(el as React.ReactElement).type;
describe("highlightDiff", () => {
it("applies diff-add class to added lines starting with +", () => {
const result = highlightDiff("+hello world");
expect(result).toHaveLength(1);
expect(typeOf(result[0])).toBe("span");
expect(propsOf(result[0]).className).toBe("diff-add");
expect(propsOf(result[0]).children).toBe("+hello world\n");
});
it("applies diff-del class to removed lines starting with -", () => {
const result = highlightDiff("-world");
expect(result).toHaveLength(1);
expect(typeOf(result[0])).toBe("span");
expect(propsOf(result[0]).className).toBe("diff-del");
expect(propsOf(result[0]).children).toBe("-world\n");
});
it("applies diff-hunk class to hunk headers starting with @@", () => {
const result = highlightDiff("@@ -1,5 +1,6 @@ function");
expect(result).toHaveLength(1);
expect(typeOf(result[0])).toBe("span");
expect(propsOf(result[0]).className).toBe("diff-hunk");
expect(propsOf(result[0]).children).toBe("@@ -1,5 +1,6 @@ function\n");
});
it("does not apply special class to context lines", () => {
const result = highlightDiff(" context line");
expect(result).toHaveLength(1);
// Context lines should be returned as plain text fragments
expect(typeOf(result[0])).toBe(React.Fragment);
expect(propsOf(result[0]).children).toBe(" context line\n");
});
it("does not apply diff-add class to +++ lines", () => {
const result = highlightDiff("+++ b/file.ts");
expect(result).toHaveLength(1);
expect(typeOf(result[0])).toBe(React.Fragment);
expect(propsOf(result[0]).children).toBe("+++ b/file.ts\n");
});
it("does not apply diff-del class to --- lines", () => {
const result = highlightDiff("--- a/file.ts");
expect(result).toHaveLength(1);
expect(typeOf(result[0])).toBe(React.Fragment);
expect(propsOf(result[0]).children).toBe("--- a/file.ts\n");
});
it("renders multiple lines correctly with different classes", () => {
const diff = `diff --git a/file.ts b/file.ts
--- a/file.ts
+++ b/file.ts
@@ -1,3 +1,4 @@
context line
+added line
-deleted line
another context`;
const result = highlightDiff(diff);
expect(result).toHaveLength(8);
// Line 0: diff --git - plain fragment
expect(typeOf(result[0])).toBe(React.Fragment);
// Line 1: --- a/file.ts - plain fragment (not diff-del)
expect(typeOf(result[1])).toBe(React.Fragment);
// Line 2: +++ b/file.ts - plain fragment (not diff-add)
expect(typeOf(result[2])).toBe(React.Fragment);
// Line 3: @@ hunk header - diff-hunk
expect(typeOf(result[3])).toBe("span");
expect(propsOf(result[3]).className).toBe("diff-hunk");
// Line 4: context - plain fragment
expect(typeOf(result[4])).toBe(React.Fragment);
// Line 5: +added - diff-add
expect(typeOf(result[5])).toBe("span");
expect(propsOf(result[5]).className).toBe("diff-add");
// Line 6: -deleted - diff-del
expect(typeOf(result[6])).toBe("span");
expect(propsOf(result[6]).className).toBe("diff-del");
// Line 7: another context - plain fragment
expect(typeOf(result[7])).toBe(React.Fragment);
});
it("renders empty diff without errors", () => {
const result = highlightDiff("");
expect(result).toHaveLength(1);
// Empty string becomes single element with empty line
expect(typeOf(result[0])).toBe(React.Fragment);
expect(propsOf(result[0]).children).toBe("\n");
});
it("handles single line without newline", () => {
const result = highlightDiff("+single line");
expect(result).toHaveLength(1);
expect(typeOf(result[0])).toBe("span");
expect(propsOf(result[0]).className).toBe("diff-add");
expect(propsOf(result[0]).children).toBe("+single line\n");
});
it("handles diff header lines correctly", () => {
const diff = `diff --git a/src/index.ts b/src/index.ts
index 1234567..abcdefg 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -10,6 +10,7 @@ export`;
const result = highlightDiff(diff);
// 5 lines total (split by \n)
expect(result).toHaveLength(5);
// All header lines should be plain fragments, not diff-add/diff-del
expect(typeOf(result[0])).toBe(React.Fragment);
expect(typeOf(result[1])).toBe(React.Fragment);
expect(typeOf(result[2])).toBe(React.Fragment); // --- a/src/index.ts
expect(typeOf(result[3])).toBe(React.Fragment); // +++ b/src/index.ts
expect(typeOf(result[4])).toBe("span");
expect(propsOf(result[4]).className).toBe("diff-hunk");
});
});

View File

@@ -0,0 +1,364 @@
import { describe, it, expect } from "vitest";
import { filterModels } from "../modelFilter";
import type { ModelInfo } from "../../api";
/**
* Model filter utility tests
*
* Tests for filtering AI models by provider, ID, or name.
*/
function createModel(
provider: string,
id: string,
name: string,
reasoning = false,
contextWindow = 128000,
): ModelInfo {
return { provider, id, name, reasoning, contextWindow };
}
describe("filterModels", () => {
const models: ModelInfo[] = [
createModel("anthropic", "claude-sonnet-4-5", "Claude Sonnet 4.5"),
createModel("anthropic", "claude-opus-4", "Claude Opus 4", true),
createModel("openai", "gpt-4o", "GPT-4o"),
createModel("openai", "gpt-4o-mini", "GPT-4o Mini"),
createModel("google", "gemini-pro", "Gemini Pro"),
createModel("ollama", "llama3.1", "Llama 3.1"),
];
it("returns all models when filter is empty string", () => {
expect(filterModels(models, "")).toEqual(models);
});
it("returns all models when filter is whitespace-only", () => {
expect(filterModels(models, " ")).toEqual(models);
expect(filterModels(models, " \t \n ")).toEqual(models);
});
it("filters by provider (case-insensitive)", () => {
const result = filterModels(models, "anthropic");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
expect(result.map((m) => m.id)).toContain("claude-opus-4");
});
it("filters by provider (uppercase)", () => {
const result = filterModels(models, "ANTHROPIC");
expect(result).toHaveLength(2);
});
it("filters by provider (mixed case)", () => {
const result = filterModels(models, "OpenAI");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("gpt-4o");
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
});
it("filters by model ID (case-insensitive, matches exact ID)", () => {
// Using unique ID "opus" that doesn't appear in other models
const result = filterModels(models, "opus");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-opus-4");
});
it("filters by partial model ID (substring matching)", () => {
const result = filterModels(models, "claude");
expect(result).toHaveLength(2);
expect(result.map((m) => m.provider)).toContain("anthropic");
});
it("filters by model name (case-insensitive)", () => {
const result = filterModels(models, "sonnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("filters by model name (partial match)", () => {
// "opus" appears in "Claude Opus 4" name
const result = filterModels(models, "opus");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-opus-4");
});
it("handles multi-word filters with AND logic", () => {
// "anthropic" AND "sonnet" should match only Claude Sonnet
const result = filterModels(models, "anthropic sonnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("handles multi-word filters with multiple matches", () => {
// "gpt" should match both gpt-4o and gpt-4o-mini
const result = filterModels(models, "gpt 4o");
expect(result).toHaveLength(2);
});
it("handles partial matches across multiple fields", () => {
// "pro" matches "Gemini Pro" in name
const result = filterModels(models, "pro");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("gemini-pro");
});
it("returns empty array when no matches", () => {
const result = filterModels(models, "nonexistent");
expect(result).toEqual([]);
});
it("returns empty array for non-matching multi-word filter", () => {
// "anthropic" AND "nonexistent" should match nothing
const result = filterModels(models, "anthropic nonexistent");
expect(result).toEqual([]);
});
it("handles empty model array", () => {
expect(filterModels([], "")).toEqual([]);
expect(filterModels([], "test")).toEqual([]);
});
it("handles single model array", () => {
const singleModel = [models[0]];
expect(filterModels(singleModel, "")).toEqual(singleModel);
expect(filterModels(singleModel, "anthropic")).toEqual(singleModel);
expect(filterModels(singleModel, "openai")).toEqual([]);
});
it("is case-insensitive across all fields", () => {
// Mix of cases should all work
expect(filterModels(models, "CLAUDE")).toHaveLength(2);
expect(filterModels(models, "GPT-4O")).toHaveLength(2);
expect(filterModels(models, "GEMINI")).toHaveLength(1);
expect(filterModels(models, "OPUS")).toHaveLength(1);
});
it("matches model ID with special characters", () => {
const modelsWithSpecial = [
createModel("anthropic", "claude-3.5-sonnet", "Claude 3.5 Sonnet"),
createModel("openai", "gpt-4-turbo-preview", "GPT-4 Turbo"),
];
expect(filterModels(modelsWithSpecial, "3.5")).toHaveLength(1);
expect(filterModels(modelsWithSpecial, "turbo-preview")).toHaveLength(1);
});
it("handles leading and trailing whitespace in filter", () => {
const result = filterModels(models, " anthropic ");
expect(result).toHaveLength(2);
});
it("handles multiple spaces between terms", () => {
const result = filterModels(models, "anthropic sonnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("matches substring anywhere in provider, id, or name", () => {
// "ai" appears in "openai" provider
const result = filterModels(models, "ai");
expect(result.map((m) => m.provider)).toContain("openai");
// "ll" appears in "ollama" provider and "llama" id
const resultLl = filterModels(models, "ll");
expect(resultLl.map((m) => m.id)).toContain("llama3.1");
});
// --- Fuzzy matching: separator-insensitive ---
describe("separator-insensitive matching", () => {
it("matches when search omits hyphens from model ID", () => {
// "gpt4o" should match "gpt-4o" (hyphen omitted)
const result = filterModels(models, "gpt4o");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("gpt-4o");
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
});
it("matches when search omits dots from model ID", () => {
const modelsWithDots = [
createModel("ollama", "llama3.1", "Llama 3.1"),
];
// "llama31" should match "llama3.1" (dot omitted)
expect(filterModels(modelsWithDots, "llama31")).toHaveLength(1);
});
it("matches when search omits underscores", () => {
const modelsWithUnderscores = [
createModel("test", "my_model_v2", "My Model V2"),
];
expect(filterModels(modelsWithUnderscores, "mymodelv2")).toHaveLength(1);
});
it("matches when search uses different separators than the model ID", () => {
// Searching with hyphen where the ID uses dot should still match
const result = filterModels(models, "gpt-4o");
expect(result).toHaveLength(2);
});
});
// --- Fuzzy matching: typo tolerance ---
describe("typo-tolerant matching", () => {
it("matches with single character deletion (sonet → sonnet)", () => {
const result = filterModels(models, "sonet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("matches with single character insertion", () => {
// "sonnnet" (extra n) should still match "sonnet"
const result = filterModels(models, "sonnnet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("matches with single character substitution", () => {
// "gemeno" → one substitution from "gemini" is too far, but "gemini" is close
// "gemini" with 'n' instead of 'i' at end → "geminj" should match
// Actually let's use a clear case: "gemino" (o instead of i) matches "gemini"
const result = filterModels(models, "gemino");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("gemini-pro");
});
it("matches with adjacent transposition", () => {
// "opneai" (transposed n and e) should match "openai"
const result = filterModels(models, "opneai");
expect(result).toHaveLength(2); // Both openai models
});
it("does not apply typo tolerance to very short terms (≤ 3 chars)", () => {
// "xai" should NOT match "openai" via typo tolerance (edit distance 1)
// because the term is only 3 chars — fuzzy matching requires ≥ 4 chars
const result = filterModels(models, "xai");
// "xai" is not a substring, not a subsequence of any single token
expect(result).toEqual([]);
});
it("preserves multi-term AND logic with typo-tolerant terms", () => {
// "anthropic sonet" → "anthropic" matches exactly, "sonet" fuzzy-matches "sonnet"
const result = filterModels(models, "anthropic sonet");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-sonnet-4-5");
});
it("does not match when both terms are required but only one fuzzy-matches", () => {
// "google sonet" → "google" matches, "sonet" doesn't match any google model
const result = filterModels(models, "google sonet");
expect(result).toEqual([]);
});
});
// --- Fuzzy matching: subsequence (non-contiguous) ---
describe("subsequence matching", () => {
it("matches non-contiguous characters (cld → claude)", () => {
const result = filterModels(models, "cld");
// "cld" is a subsequence of "claude" (token), should match all claude models
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
expect(result.map((m) => m.id)).toContain("claude-opus-4");
});
it("matches non-contiguous characters in model name", () => {
// "gmi" is a subsequence of "gemini" (g-e-m-i-n-i → g(0), m(2), i(3))
// It's also a subsequence of "gpt4omini" (g(0), m(5), i(6))
const result = filterModels(models, "gmi");
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toContain("gemini-pro");
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
});
it("does not apply subsequence matching for very short terms (< 3 chars)", () => {
// "op" is 2 chars, so subsequence matching does NOT apply (min 3).
// However, "op" IS a substring: it appears in "anthropic" ("anthr**op**ic")
// and in "openai" ("**op**enai"), so it matches all 4 models from those providers.
const result = filterModels(models, "op");
expect(result).toHaveLength(4);
expect(result.map((m) => m.id)).toContain("claude-sonnet-4-5");
expect(result.map((m) => m.id)).toContain("claude-opus-4");
expect(result.map((m) => m.id)).toContain("gpt-4o");
expect(result.map((m) => m.id)).toContain("gpt-4o-mini");
});
it("requires all characters in order for subsequence", () => {
// "dcl" is NOT a subsequence of "claude" (d before c, but "dcl" reversed)
const result = filterModels(models, "dcl");
expect(result).toEqual([]);
});
it("subsequence only matches within individual tokens, not across fields", () => {
// "ops" should NOT match by picking 'o' from one field and 'ps' from another
// It should only match if it's a subsequence of a single token
// "ops" as subsequence of "claudeopus4" → o at index 6, p at index 7, s at index 9 → TRUE
// So it DOES match the opus model because it's a subsequence of the token "claudeopus4"
const result = filterModels(models, "ops");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("claude-opus-4");
});
it("subsequence does not match across space-separated tokens", () => {
// "cpo" picking c from "claude", p from provider "anthropic", o from "4"
// should NOT match because subsequence is checked per-token
// "cpo" is NOT a subsequence of any single token
const result = filterModels(models, "cpo");
expect(result).toEqual([]);
});
});
// --- Fuzzy matching: negative tests (no over-matching) ---
describe("negative fuzzy matching (no over-matching)", () => {
it("returns empty array for clearly irrelevant input", () => {
expect(filterModels(models, "xyz")).toEqual([]);
expect(filterModels(models, "banana")).toEqual([]);
expect(filterModels(models, "zzzzz")).toEqual([]);
});
it("does not fuzzy-match unrelated providers", () => {
// "googel" is close to "google" (edit distance 1) but NOT to "openai" or "anthropic"
const result = filterModels(models, "googel");
expect(result).toHaveLength(1);
expect(result[0].provider).toBe("google");
});
it("does not fuzzy-match when edit distance exceeds tolerance", () => {
// "gpt5o" has edit distance 2 from "gpt4o" (4→5 substitution + different letter)
// Actually edit distance is 1 (just 4→5). Let's use a clear 2-distance case.
// "gpt99" has edit distance ≥ 2 from "gpt4o" (two substitutions: 4→9, o→9)
expect(filterModels(models, "gpt99")).toEqual([]);
});
it("does not fuzzy-match very different words", () => {
// "elephant" should not match anything despite fuzzy matching
expect(filterModels(models, "elephant")).toEqual([]);
});
it("multi-term AND with one non-matching term returns empty", () => {
// Even if "sonet" fuzzy-matches, adding "elephant" should return empty
expect(filterModels(models, "sonet elephant")).toEqual([]);
});
});
// --- Fuzzy matching: result ordering stability ---
describe("result ordering", () => {
it("preserves input-array order (no fuzzy-score re-sorting)", () => {
// All claude models should appear in their original array order
const result = filterModels(models, "claude");
expect(result.map((m) => m.id)).toEqual([
"claude-sonnet-4-5",
"claude-opus-4",
]);
});
it("preserves input-array order with fuzzy matches", () => {
const result = filterModels(models, "gpt4o");
expect(result.map((m) => m.id)).toEqual([
"gpt-4o",
"gpt-4o-mini",
]);
});
});
});

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import type { ModelPreset } from "@fusion/core";
import {
applyPresetToSelection,
generatePresetId,
generateUniquePresetId,
getPresetByName,
getRecommendedPresetForSize,
validatePresetId,
} from "../modelPresets";
const presets: ModelPreset[] = [
{
id: "budget",
name: "Budget",
executorProvider: "openai",
executorModelId: "gpt-4o-mini",
validatorProvider: "openai",
validatorModelId: "gpt-4o-mini",
},
{
id: "complex",
name: "Complex",
executorProvider: "anthropic",
executorModelId: "claude-sonnet-4-5",
},
];
describe("modelPresets utils", () => {
it("finds presets by case-insensitive display name", () => {
expect(getPresetByName(presets, "budget")).toEqual(presets[0]);
expect(getPresetByName(presets, " COMPLEX ")).toEqual(presets[1]);
expect(getPresetByName(presets, "missing")).toBeUndefined();
});
it("applies a preset to dropdown selection values", () => {
expect(applyPresetToSelection(presets[0])).toEqual({
executorValue: "openai/gpt-4o-mini",
validatorValue: "openai/gpt-4o-mini",
});
expect(applyPresetToSelection(undefined)).toEqual({
executorValue: "",
validatorValue: "",
});
});
it("recommends the mapped preset for a task size", () => {
expect(
getRecommendedPresetForSize("S", { S: "budget", M: "complex" }, presets),
).toEqual(presets[0]);
expect(
getRecommendedPresetForSize("L", { S: "budget", M: "complex" }, presets),
).toBeUndefined();
expect(getRecommendedPresetForSize(undefined, { S: "budget" }, presets)).toBeUndefined();
});
it("validates preset ids", () => {
expect(validatePresetId("budget")).toBe(true);
expect(validatePresetId("budget_v2")).toBe(true);
expect(validatePresetId("budget-v2")).toBe(true);
expect(validatePresetId("")).toBe(false);
expect(validatePresetId("has spaces")).toBe(false);
expect(validatePresetId("invalid!char")).toBe(false);
expect(validatePresetId("a".repeat(33))).toBe(false);
});
it("generates slug-friendly preset ids", () => {
expect(generatePresetId("Budget")).toBe("budget");
expect(generatePresetId(" Normal Mode ")).toBe("normal-mode");
expect(generatePresetId("Complex / Reviewer")).toBe("complex-reviewer");
expect(generatePresetId("!!!")).toBe("preset");
expect(generatePresetId("a".repeat(40))).toBe("a".repeat(32));
});
describe("generateUniquePresetId", () => {
it("returns the base slug when no collision", () => {
// "standard" is not in the presets fixture
expect(generateUniquePresetId("Standard", presets)).toBe("standard");
});
it("returns base slug when existing list is empty", () => {
expect(generateUniquePresetId("Budget", [])).toBe("budget");
});
it("appends suffix when base slug is already taken", () => {
// "budget" is already used in presets, so should get "budget-1"
expect(generateUniquePresetId("Budget", presets)).toBe("budget-1");
// "complex" is also taken, so should get "complex-1"
expect(generateUniquePresetId("Complex", presets)).toBe("complex-1");
});
it("increments suffix until finding a free id", () => {
const crowded: ModelPreset[] = [
{ id: "budget", name: "Budget" },
{ id: "budget-1", name: "Budget Copy" },
{ id: "budget-2", name: "Budget Copy 2" },
];
expect(generateUniquePresetId("Budget", crowded)).toBe("budget-3");
});
it("truncates base slug to leave room for suffix", () => {
const longName = "a".repeat(40);
const existing: ModelPreset[] = [
{ id: generatePresetId(longName), name: longName },
];
const result = generateUniquePresetId(longName, existing);
// baseId is 32 a's, collision → truncate to 28 a's + "-1" = 30 chars
expect(result).toBe(`${"a".repeat(28)}-1`);
expect(result.length).toBeLessThanOrEqual(32);
expect(validatePresetId(result)).toBe(true);
});
it("handles fallback 'preset' slug collisions", () => {
const existing: ModelPreset[] = [
{ id: "preset", name: "!!!" },
];
expect(generateUniquePresetId("!!!", existing)).toBe("preset-1");
});
});
});

View File

@@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
isProjectRoutedToNode,
getProjectsForNode,
getProjectCountForNode,
getUnassignedProjectCount,
} from "../nodeProjectAssignment";
import type { NodeInfo, ProjectInfo } from "../../api";
function makeNode(overrides: Partial<NodeInfo> = {}): NodeInfo {
return {
id: "node-1",
name: "Test Node",
type: "local",
status: "online",
maxConcurrent: 2,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "proj-1",
name: "Project One",
path: "/workspace/project-one",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
describe("nodeProjectAssignment", () => {
describe("isProjectRoutedToNode", () => {
describe("local node", () => {
const localNode = makeNode({ id: "local-1", type: "local" });
it("returns true for projects explicitly assigned to this local node", () => {
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
});
it("returns true for unassigned projects (nodeId undefined)", () => {
const project = makeProject({ id: "proj-1", nodeId: undefined });
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
});
it("returns true for unassigned projects (nodeId null)", () => {
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
expect(isProjectRoutedToNode(project, localNode)).toBe(true);
});
it("returns false for projects assigned to other nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "other-node" });
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
});
it("returns false for projects assigned to remote nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
expect(isProjectRoutedToNode(project, localNode)).toBe(false);
});
});
describe("remote node", () => {
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
it("returns true for projects explicitly assigned to this remote node", () => {
const project = makeProject({ id: "proj-1", nodeId: "remote-1" });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(true);
});
it("returns false for unassigned projects (nodeId undefined)", () => {
const project = makeProject({ id: "proj-1", nodeId: undefined });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
it("returns false for unassigned projects (nodeId null)", () => {
const project = makeProject({ id: "proj-1", nodeId: null as unknown as string });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
it("returns false for projects assigned to local nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "local-1" });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
it("returns false for projects assigned to other remote nodes", () => {
const project = makeProject({ id: "proj-1", nodeId: "other-remote" });
expect(isProjectRoutedToNode(project, remoteNode)).toBe(false);
});
});
});
describe("getProjectsForNode", () => {
it("returns all projects routed to a local node (including unassigned)", () => {
const localNode = makeNode({ id: "local-1", type: "local" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "local-1" }), // assigned to this local node
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
makeProject({ id: "proj-3", nodeId: "other-local" }), // assigned to different local node
makeProject({ id: "proj-4", nodeId: "remote-1" }), // assigned to remote
];
const result = getProjectsForNode(projects, localNode);
expect(result.map((p) => p.id)).toEqual(["proj-1", "proj-2"]);
});
it("returns only explicitly assigned projects for a remote node", () => {
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "remote-1" }), // assigned to this remote node
makeProject({ id: "proj-2", nodeId: undefined }), // unassigned
makeProject({ id: "proj-3", nodeId: "local-1" }), // assigned to local
makeProject({ id: "proj-4", nodeId: "other-remote" }), // assigned to other remote
];
const result = getProjectsForNode(projects, remoteNode);
expect(result.map((p) => p.id)).toEqual(["proj-1"]);
});
});
describe("getProjectCountForNode", () => {
it("returns correct count for local node (includes unassigned)", () => {
const localNode = makeNode({ id: "local-1", type: "local" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "local-1" }),
makeProject({ id: "proj-2", nodeId: undefined }),
makeProject({ id: "proj-3", nodeId: undefined }),
];
expect(getProjectCountForNode(projects, localNode)).toBe(3);
});
it("returns correct count for remote node (explicit only)", () => {
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "remote-1" }),
makeProject({ id: "proj-2", nodeId: "remote-1" }),
makeProject({ id: "proj-3", nodeId: undefined }),
];
expect(getProjectCountForNode(projects, remoteNode)).toBe(2);
});
it("returns 0 when no projects are routed to the node", () => {
const remoteNode = makeNode({ id: "remote-1", type: "remote" });
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "local-1" }),
makeProject({ id: "proj-2", nodeId: undefined }),
];
expect(getProjectCountForNode(projects, remoteNode)).toBe(0);
});
});
describe("getUnassignedProjectCount", () => {
it("counts projects without nodeId", () => {
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: undefined }),
makeProject({ id: "proj-2", nodeId: null as unknown as string }),
makeProject({ id: "proj-3", nodeId: "local-1" }),
];
expect(getUnassignedProjectCount(projects)).toBe(2);
});
it("returns 0 when all projects are assigned", () => {
const projects: ProjectInfo[] = [
makeProject({ id: "proj-1", nodeId: "local-1" }),
makeProject({ id: "proj-2", nodeId: "remote-1" }),
];
expect(getUnassignedProjectCount(projects)).toBe(0);
});
it("returns 0 for empty array", () => {
expect(getUnassignedProjectCount([])).toBe(0);
});
});
});

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
GLOBAL_STORAGE_KEYS,
PROJECT_STORAGE_KEYS,
getScopedItem,
removeScopedItem,
scopedKey,
setScopedItem,
} from "../projectStorage";
describe("projectStorage", () => {
beforeEach(() => {
localStorage.clear();
});
describe("scopedKey", () => {
it("returns scoped key when projectId is provided", () => {
expect(scopedKey("kb-dashboard-list-columns", "proj-abc")).toBe(
"kb:proj-abc:kb-dashboard-list-columns",
);
});
it("returns base key unchanged when projectId is undefined", () => {
expect(scopedKey("kb-dashboard-list-columns", undefined)).toBe("kb-dashboard-list-columns");
});
it("returns base key unchanged when projectId is omitted", () => {
expect(scopedKey("kb-dashboard-list-columns")).toBe("kb-dashboard-list-columns");
});
it("returns base key unchanged when projectId is empty", () => {
expect(scopedKey("kb-dashboard-list-columns", "")).toBe("kb-dashboard-list-columns");
});
it("returns base key unchanged when projectId is null", () => {
expect(scopedKey("kb-dashboard-list-columns", null as any)).toBe("kb-dashboard-list-columns");
});
});
it("uses scoped keys for get/set/remove with projectId", () => {
setScopedItem("kb-dashboard-list-columns", "value", "proj-abc");
expect(localStorage.getItem("kb:proj-abc:kb-dashboard-list-columns")).toBe("value");
expect(getScopedItem("kb-dashboard-list-columns", "proj-abc")).toBe("value");
removeScopedItem("kb-dashboard-list-columns", "proj-abc");
expect(localStorage.getItem("kb:proj-abc:kb-dashboard-list-columns")).toBeNull();
});
it("uses unscoped keys for get/set/remove without projectId", () => {
setScopedItem("kb-dashboard-list-columns", "value");
expect(localStorage.getItem("kb-dashboard-list-columns")).toBe("value");
expect(getScopedItem("kb-dashboard-list-columns")).toBe("value");
removeScopedItem("kb-dashboard-list-columns");
expect(localStorage.getItem("kb-dashboard-list-columns")).toBeNull();
});
it("includes all global storage keys", () => {
expect(GLOBAL_STORAGE_KEYS).toEqual(
expect.arrayContaining([
"kb-dashboard-theme-mode",
"kb-dashboard-color-theme",
"kb-dashboard-view-mode",
"kb-dashboard-current-project",
"kb-dashboard-recent-projects",
]),
);
expect(GLOBAL_STORAGE_KEYS).toHaveLength(5);
});
it("includes all project-scoped storage keys", () => {
expect(PROJECT_STORAGE_KEYS).toEqual(
expect.arrayContaining([
"kb-dashboard-task-view",
"kb-dashboard-list-columns",
"kb-dashboard-hide-done",
"kb-dashboard-list-collapsed",
"kb-dashboard-selected-tasks",
"kb-quick-entry-text",
"kb-inline-create-text",
"fn-agent-view",
"fn-agent-tree-expanded",
"kb-terminal-tabs",
"kb-planning-last-description",
"kb-subtask-last-description",
"kb-mission-last-goal",
"kb-usage-view-mode",
"kb-chat-active-session",
]),
);
expect(PROJECT_STORAGE_KEYS).toHaveLength(15);
});
it("has no overlap between global and project-scoped keys", () => {
const globalSet = new Set(GLOBAL_STORAGE_KEYS);
const overlap = PROJECT_STORAGE_KEYS.filter((key) => globalSet.has(key));
expect(overlap).toEqual([]);
});
});

View File

@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isTaskStuck, countStuckTasks } from "../taskStuck";
import type { Task } from "@fusion/core";
const createTask = (overrides: Partial<Task> = {}): Task =>
({
id: "FN-001",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Task;
describe("isTaskStuck", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns false when timeout is undefined (disabled)", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, undefined)).toBe(false);
});
it("returns false when timeout is 0", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, 0)).toBe(false);
});
it("returns false when timeout is negative", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, -1)).toBe(false);
});
it("returns false for non-in-progress tasks", () => {
const task = createTask({ column: "todo", updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for failed in-progress tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ status: "failed", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for stuck-killed in-progress tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ status: "stuck-killed", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for recent in-progress tasks within timeout", () => {
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
const task = createTask({ updatedAt: recent });
expect(isTaskStuck(task, 600000)).toBe(false); // 10 minute timeout
});
it("returns true for stale in-progress tasks exceeding timeout", () => {
const stale = new Date(Date.now() - 600001).toISOString(); // just over 10 minutes
const task = createTask({ updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(true);
});
it("returns false for malformed updatedAt", () => {
const task = createTask({ updatedAt: "not-a-date" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for empty updatedAt", () => {
const task = createTask({ updatedAt: "" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("handles tasks in triage column", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ column: "triage", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("handles tasks in done column", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ column: "done", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns true exactly at timeout boundary (greater than)", () => {
const boundary = new Date(Date.now() - 600001).toISOString();
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(true);
});
it("returns false exactly at timeout boundary (equal)", () => {
const boundary = new Date(Date.now() - 600000).toISOString();
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(false);
});
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
it("uses dataAsOfMs instead of Date.now() when provided", () => {
// Task updatedAt is 11 minutes ago
const taskUpdatedAt = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const task = createTask({ updatedAt: taskUpdatedAt });
// dataAsOfMs is 5 minutes ago (task was fresh 5 minutes ago)
const dataAsOfMs = Date.now() - 5 * 60 * 1000;
// 10 minute timeout
// With dataAsOfMs: 5 min - 11 min = -6 min < 10 min → NOT stuck
// Without dataAsOfMs: 0 min - 11 min = -11 min > 10 min → stuck
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("falls back to Date.now() when dataAsOfMs is undefined", () => {
// Task updatedAt is 5 minutes ago
const taskUpdatedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const task = createTask({ updatedAt: taskUpdatedAt });
// Without dataAsOfMs, should use Date.now() → NOT stuck (within 10 min timeout)
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("correctly identifies a task that would be stuck with Date.now() but not with dataAsOfMs", () => {
// Scenario: Tab was in background for 20 minutes
// Task was updated 10 minutes ago (relative to dataAsOfMs)
// dataAsOfMs represents "10 minutes ago" (when we fetched fresh data)
// Date.now() is "now" (20 minutes after the fetch)
//
// This simulates the background tab scenario:
// - User opened tab at T=0, fetched tasks
// - Tab went to background at T=0
// - User came back at T=20
// - dataAsOfMs = T=0 (when we last had fresh data)
// - Task was updated at T=-10 (10 minutes before fetch)
// - task.updatedAt represents T=-10
//
// Check: dataAsOfMs - updatedAt = 0 - (-10) = 10 min < 10 min timeout → NOT stuck
// Without dataAsOfMs: Date.now() - updatedAt = 20 - (-10) = 30 min > 10 min → STUCK (false positive!)
// In fake timers, we set Date.now() to a fixed point
// Let's say Date.now() = 1000 (representing "now")
// dataAsOfMs = 0 (representing 20 minutes before "now" in fake time)
// task.updatedAt = -600 (representing 10 minutes before dataAsOfMs)
vi.setSystemTime(new Date(1000)); // Date.now() = 1000
const dataAsOfMs = 0; // 20 minutes before Date.now() in this scenario
const taskUpdatedAt = new Date(-600000).toISOString(); // 10 minutes before dataAsOfMs
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: 0 - (-600000) = 600000ms = 10 min = timeout → NOT stuck (boundary)
// Without dataAsOfMs: 1000 - (-600000) = 601000ms > 10 min → STUCK
// The key test: with dataAsOfMs it should NOT be stuck even though Date.now() would say it is
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("prevents false positive when tab was in background", () => {
// Simulate: Tab in background, data fetched 15 min ago
// Task.updatedAt is 12 min ago (stale from server perspective)
// taskStuckTimeoutMs = 10 min
// With fresh data (15 min ago): 15 - 12 = 3 min < 10 min → NOT stuck
// With stale Date.now(): 0 - 12 = 12 min > 10 min → STUCK (FALSE POSITIVE)
vi.setSystemTime(new Date(0)); // Date.now() = 0
const dataAsOfMs = -900000; // 15 minutes ago (in fake time)
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago (in fake time)
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
// Without dataAsOfMs: 0 - (-720000) = 720000ms = 12 min > 10 min → STUCK
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("correctly identifies genuinely stuck tasks even with dataAsOfMs", () => {
// Task really is stuck: updatedAt is 15 min ago, timeout is 10 min
// With dataAsOfMs of 2 min ago: 2 - 15 = -13 min < 10 min → NOT stuck (hmm, this is a problem)
// Actually, dataAsOfMs should represent when we last got FRESH data from the server
// If dataAsOfMs = 2 min ago and task.updatedAt = 15 min ago, the task was stale
// even when we fetched it, because 2 - 15 = -13 min > 10 min timeout
vi.setSystemTime(new Date(0));
const dataAsOfMs = -120000; // 2 minutes ago
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(true);
});
});
});
describe("countStuckTasks", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns 0 when timeout is undefined", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [createTask({ updatedAt: stale })];
expect(countStuckTasks(tasks, undefined)).toBe(0);
});
it("returns 0 when timeout is 0", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [createTask({ updatedAt: stale })];
expect(countStuckTasks(tasks, 0)).toBe(0);
});
it("counts only stuck tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const recent = new Date(Date.now() - 300000).toISOString();
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }), // stuck
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
createTask({ id: "FN-004", status: "failed", updatedAt: stale }), // terminal status
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
];
expect(countStuckTasks(tasks, 600000)).toBe(1);
});
it("returns 0 for empty task list", () => {
expect(countStuckTasks([], 600000)).toBe(0);
});
it("counts multiple stuck tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }),
createTask({ id: "FN-002", updatedAt: stale }),
];
expect(countStuckTasks(tasks, 600000)).toBe(2);
});
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
it("passes dataAsOfMs through to isTaskStuck", () => {
// Task would be stuck with Date.now() but not with dataAsOfMs
vi.setSystemTime(new Date(0));
const dataAsOfMs = -900000; // 15 minutes ago
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(0);
});
it("counts tasks that are genuinely stuck even with dataAsOfMs", () => {
vi.setSystemTime(new Date(0));
const dataAsOfMs = -120000; // 2 minutes ago
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(1);
});
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import { truncateMiddle } from "../truncatePath";
describe("truncateMiddle", () => {
it("returns empty string unchanged", () => {
expect(truncateMiddle("")).toBe("");
});
it("returns short paths unchanged", () => {
expect(truncateMiddle("src/index.ts")).toBe("src/index.ts");
});
it("returns paths at exactly maxLength unchanged", () => {
const path = "a".repeat(60);
expect(truncateMiddle(path, 60)).toBe(path);
});
it("returns paths shorter than maxLength unchanged", () => {
const path = "a".repeat(59);
expect(truncateMiddle(path, 60)).toBe(path);
});
it("truncates a long path from the middle", () => {
const path = "packages/dashboard/app/components/TaskChangesTab.tsx";
const result = truncateMiddle(path, 30);
expect(result).toContain("...");
expect(result.length).toBeLessThanOrEqual(30);
// Filename should be preserved
expect(result.endsWith("TaskChangesTab.tsx")).toBe(true);
});
it("preserves the full path when under maxLength", () => {
const path = "src/components/Button.tsx";
expect(truncateMiddle(path, 60)).toBe(path);
});
it("truncates paths with no separator from the end", () => {
const path = "verylongfilenamewithoutseparators.txt";
const result = truncateMiddle(path, 20);
expect(result).toContain("...");
expect(result.length).toBeLessThanOrEqual(20);
});
it("handles maxLength of 4 (minimum for ellipsis + 1 char)", () => {
const path = "src/components/deeply/nested/file.ts";
const result = truncateMiddle(path, 4);
expect(result.length).toBeLessThanOrEqual(4);
expect(result).toContain("...");
});
it("handles maxLength smaller than 4 gracefully", () => {
const path = "src/components/file.ts";
const result = truncateMiddle(path, 3);
expect(result.length).toBeLessThanOrEqual(3);
});
it("uses default maxLength of 60", () => {
// 61 chars — should truncate
const path = "packages/dashboard/app/components/VeryLongComponentNameGoesHere.tsx";
// path is 73 chars
const result = truncateMiddle(path);
expect(result.length).toBeLessThanOrEqual(60);
expect(result).toContain("...");
});
it("preserves filename when path is deeply nested", () => {
const path = "a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/file.ts";
const result = truncateMiddle(path, 25);
expect(result.endsWith("file.ts")).toBe(true);
expect(result).toContain("...");
expect(result.length).toBeLessThanOrEqual(25);
});
it("handles single-segment paths", () => {
const result = truncateMiddle("verylongfilename.tsx", 15);
expect(result.length).toBeLessThanOrEqual(15);
expect(result).toContain("...");
});
it("handles a path where the filename itself is longer than maxLength", () => {
const path = "ExtremelyLongFileNameThatExceedsTheMaximumLength.tsx";
const result = truncateMiddle(path, 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toContain("...");
});
it("preserves start portion when truncating", () => {
const path = "packages/dashboard/app/components/TaskChangesTab.tsx";
const result = truncateMiddle(path, 35);
expect(result.startsWith("packages")).toBe(true);
expect(result).toContain("...");
expect(result.endsWith("TaskChangesTab.tsx")).toBe(true);
});
it("works with paths that have dots but no slashes", () => {
const result = truncateMiddle("config.local.development.json", 20);
expect(result.length).toBeLessThanOrEqual(20);
expect(result).toContain("...");
});
it("handles exactly the boundary case where path is maxLength+1", () => {
const path = "a".repeat(61);
const result = truncateMiddle(path, 60);
expect(result.length).toBeLessThanOrEqual(60);
});
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from "vitest";
import { groupByWorktree, getWorktreeLabel } from "../worktreeGrouping";
import type { Task } from "@fusion/core";
function makeTask(overrides: Partial<Task> & { id: string }): Task {
return {
description: "",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("getWorktreeLabel", () => {
it("extracts last path segment", () => {
expect(getWorktreeLabel(".worktrees/FN-001")).toBe("FN-001");
expect(getWorktreeLabel("/path/to/kb/kb-001")).toBe("kb-001");
});
it("extracts humanized worktree names", () => {
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
});
});
describe("groupByWorktree", () => {
it("groups active in-progress tasks by worktree", () => {
const t1 = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const t2 = makeTask({ id: "FN-002", worktree: ".worktrees/quiet-robin" });
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe("swift-falcon");
expect(groups[0].activeTasks).toEqual([t1]);
expect(groups[1].label).toBe("quiet-robin");
expect(groups[1].activeTasks).toEqual([t2]);
});
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const queued = makeTask({
id: "FN-002",
column: "todo",
dependencies: [],
});
const groups = groupByWorktree([active], [active, queued], 2);
// Worktree group should have no queued tasks
const worktreeGroup = groups.find((g) => g.label === "swift-falcon");
expect(worktreeGroup).toBeDefined();
expect(worktreeGroup!.queuedTasks).toEqual([]);
// Up Next should contain the queued task
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toEqual([queued]);
expect(upNext!.activeTasks).toEqual([]);
});
it("does not create Up Next group when there are no eligible queued tasks", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const groups = groupByWorktree([active], [active], 2);
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
});
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const blocked = makeTask({
id: "FN-002",
column: "todo",
dependencies: ["FN-003"], // KB-003 doesn't exist or isn't done
});
const groups = groupByWorktree([active], [active, blocked], 2);
expect(groups.find((g) => g.label === "Up Next")).toBeUndefined();
});
it("respects maxConcurrent limit on queued tasks shown", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const q1 = makeTask({ id: "FN-010", column: "todo" });
const q2 = makeTask({ id: "FN-011", column: "todo" });
const q3 = makeTask({ id: "FN-012", column: "todo" });
const groups = groupByWorktree([active], [active, q1, q2, q3], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toHaveLength(2);
});
it("places unassigned in-progress tasks in Unassigned group", () => {
const unassigned = makeTask({ id: "FN-001" }); // no worktree
const groups = groupByWorktree([unassigned], [unassigned], 2);
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe("Unassigned");
expect(groups[0].activeTasks).toEqual([unassigned]);
});
it("excludes paused todo tasks from Up Next", () => {
const active = makeTask({ id: "FN-001", worktree: ".worktrees/swift-falcon" });
const paused = makeTask({
id: "FN-002",
column: "todo",
dependencies: [],
paused: true,
});
const normal = makeTask({
id: "FN-003",
column: "todo",
dependencies: [],
});
const groups = groupByWorktree([active], [active, paused, normal], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks.map((t) => t.id)).toEqual(["FN-003"]);
expect(upNext!.queuedTasks.map((t) => t.id)).not.toContain("FN-002");
});
it("queued tasks with satisfied deps appear in Up Next", () => {
const done = makeTask({ id: "FN-001", column: "done" });
const queued = makeTask({
id: "FN-002",
column: "todo",
dependencies: ["FN-001"],
});
const groups = groupByWorktree([], [done, queued], 2);
const upNext = groups.find((g) => g.label === "Up Next");
expect(upNext).toBeDefined();
expect(upNext!.queuedTasks).toEqual([queued]);
});
});