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:
512
packages/dashboard/app/utils/__tests__/agentHealth.test.tsx
Normal file
512
packages/dashboard/app/utils/__tests__/agentHealth.test.tsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
144
packages/dashboard/app/utils/__tests__/highlightDiff.test.ts
Normal file
144
packages/dashboard/app/utils/__tests__/highlightDiff.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
364
packages/dashboard/app/utils/__tests__/modelFilter.test.ts
Normal file
364
packages/dashboard/app/utils/__tests__/modelFilter.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
120
packages/dashboard/app/utils/__tests__/modelPresets.test.ts
Normal file
120
packages/dashboard/app/utils/__tests__/modelPresets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
103
packages/dashboard/app/utils/__tests__/projectStorage.test.ts
Normal file
103
packages/dashboard/app/utils/__tests__/projectStorage.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
270
packages/dashboard/app/utils/__tests__/taskStuck.test.ts
Normal file
270
packages/dashboard/app/utils/__tests__/taskStuck.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/dashboard/app/utils/__tests__/truncatePath.test.ts
Normal file
106
packages/dashboard/app/utils/__tests__/truncatePath.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
148
packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts
Normal file
148
packages/dashboard/app/utils/__tests__/worktreeGrouping.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user