// @vitest-environment jsdom import { describe, it, expect } from "vitest"; import type { Agent, Approval, ActivityEvent, Project } from "@paperclipai/shared"; import { composeHomeStatus } from "./useAssistantHomeStatus"; // ─── Fixture factories ────────────────────────────────────────────────────── const NOW_MS = Date.parse("2026-04-11T12:00:00Z"); function makeAgent(overrides: Partial = {}): Agent { return { id: "agent-" + Math.random().toString(36).slice(2, 8), companyId: "co-1", name: "Agent", urlKey: "agent", role: "engineer", title: null, icon: null, status: "idle", reportsTo: null, capabilities: null, adapterType: "process", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, spentMonthlyCents: 0, pauseReason: null, pausedAt: null, permissions: { canCreateAgents: false }, lastHeartbeatAt: null, metadata: null, createdAt: new Date(NOW_MS), updatedAt: new Date(NOW_MS), ...overrides, } as Agent; } function makeApproval(overrides: Partial = {}): Approval { return { id: "gate-" + Math.random().toString(36).slice(2, 8), companyId: "co-1", type: "generic", requestedByAgentId: null, requestedByUserId: null, status: "pending", payload: {}, decisionNote: null, decidedByUserId: null, decidedAt: null, createdAt: new Date(NOW_MS), updatedAt: new Date(NOW_MS), ...overrides, } as Approval; } function makeActivity(overrides: Partial = {}): ActivityEvent { return { id: "act-" + Math.random().toString(36).slice(2, 8), companyId: "co-1", actorType: "agent", actorId: "agent-1", action: "project.completed", entityType: "project", entityId: "proj-a", agentId: null, runId: null, details: null, createdAt: new Date(NOW_MS - 60 * 60 * 1000), // 1h ago ...overrides, } as ActivityEvent; } function makeProject(overrides: Partial = {}): Project { return { id: "proj-" + Math.random().toString(36).slice(2, 8), companyId: "co-1", urlKey: "nexus-demo", goalId: null, goalIds: [], goals: [], name: "Demo", description: null, status: "active", leadAgentId: null, targetDate: null, color: null, pauseReason: null, pausedAt: null, executionWorkspacePolicy: null, codebase: { kind: "none" } as unknown as Project["codebase"], workspaces: [], primaryWorkspace: null, archivedAt: null, createdAt: new Date(NOW_MS), updatedAt: new Date(NOW_MS), ...overrides, } as Project; } function baseArgs() { return { nowMs: NOW_MS, companyPrefix: "NEX", maxStaleProjects: 3, maxRecentCompletions: 3, staleThresholdMs: 3 * 24 * 60 * 60 * 1000, recentWindowMs: 24 * 60 * 60 * 1000, }; } // ─── Tests ────────────────────────────────────────────────────────────────── describe("composeHomeStatus", () => { it("counts agents whose status is 'running' as active", () => { const agents = [ makeAgent({ status: "running" }), makeAgent({ status: "running" }), makeAgent({ status: "idle" }), makeAgent({ status: "paused" }), ]; const result = composeHomeStatus({ agents, approvals: [], activity: [], projects: [], ...baseArgs(), }); expect(result.activeAgents).toBe(2); }); it("includes only pending approvals as pending gates and resolves project names", () => { const proj = makeProject({ id: "proj-a", name: "nexus-design-migration" }); const approved = makeApproval({ status: "approved", payload: { projectId: "proj-a" } }); const pending1 = makeApproval({ status: "pending", payload: { projectId: "proj-a", title: "Phase 4 audit" }, }); const pending2 = makeApproval({ status: "pending", payload: {} }); const result = composeHomeStatus({ agents: [], approvals: [approved, pending1, pending2], activity: [], projects: [proj], ...baseArgs(), }); expect(result.pendingGates).toHaveLength(2); const first = result.pendingGates[0]!; expect(first.gateName).toBe("Phase 4 audit"); expect(first.projectName).toBe("nexus-design-migration"); expect(first.href).toBe(`/NEX/approvals/${pending1.id}`); // Second gate has no projectId → empty projectName, gateName falls back to type. expect(result.pendingGates[1]!.projectName).toBe(""); expect(result.pendingGates[1]!.gateName).toBe("generic"); }); it("surfaces recent completions within the recent window, newest first, capped", () => { const proj = makeProject({ id: "proj-a", name: "nexus-demo" }); const completedRecent = makeActivity({ id: "e1", action: "project.completed", entityId: "proj-a", details: { summary: "Phase 4 shipped" }, createdAt: new Date(NOW_MS - 2 * 60 * 60 * 1000), // 2h ago }); const completedOlder = makeActivity({ id: "e2", action: "task.finished", entityId: "proj-a", details: { summary: "Older one" }, createdAt: new Date(NOW_MS - 6 * 60 * 60 * 1000), // 6h ago }); const completedAncient = makeActivity({ id: "e3", action: "task.done", entityId: "proj-a", details: null, createdAt: new Date(NOW_MS - 48 * 60 * 60 * 1000), // 48h ago, outside window }); const notACompletion = makeActivity({ id: "e4", action: "project.started", entityId: "proj-a", createdAt: new Date(NOW_MS - 30 * 60 * 1000), }); const result = composeHomeStatus({ agents: [], approvals: [], activity: [completedRecent, completedOlder, completedAncient, notACompletion], projects: [proj], ...baseArgs(), }); expect(result.recentCompletions.map((c) => c.id)).toEqual(["e1", "e2"]); expect(result.recentCompletions[0]!.summary).toBe("Phase 4 shipped"); expect(result.recentCompletions[0]!.projectName).toBe("nexus-demo"); expect(result.recentCompletions[0]!.when).toBe("2h ago"); }); it("respects maxRecentCompletions cap", () => { const completions = Array.from({ length: 5 }).map((_, i) => makeActivity({ id: `c${i}`, action: "completed", createdAt: new Date(NOW_MS - (i + 1) * 60 * 1000), }), ); const result = composeHomeStatus({ agents: [], approvals: [], activity: completions, projects: [], ...baseArgs(), maxRecentCompletions: 2, }); expect(result.recentCompletions).toHaveLength(2); }); it("marks projects with no activity in staleThresholdMs as stale, oldest first", () => { const fresh = makeProject({ id: "fresh", name: "fresh", updatedAt: new Date(NOW_MS - 60 * 60 * 1000), // 1h ago }); const stale = makeProject({ id: "stale", name: "personal-finance", urlKey: "personal-finance", updatedAt: new Date(NOW_MS - 5 * 24 * 60 * 60 * 1000), // 5d ago }); const ancient = makeProject({ id: "ancient", name: "ancient", urlKey: "ancient", updatedAt: new Date(NOW_MS - 30 * 24 * 60 * 60 * 1000), // 30d ago }); const archived = makeProject({ id: "arch", name: "archived-project", archivedAt: new Date(NOW_MS - 60 * 24 * 60 * 60 * 1000), updatedAt: new Date(NOW_MS - 60 * 24 * 60 * 60 * 1000), }); const result = composeHomeStatus({ agents: [], approvals: [], activity: [], projects: [fresh, stale, ancient, archived], ...baseArgs(), }); // Ancient is stalest (oldest), so it should come first. expect(result.staleProjects.map((p) => p.id)).toEqual(["ancient", "stale"]); expect(result.staleProjects[0]!.lastActivity).toBe("30d ago"); expect(result.staleProjects[1]!.href).toBe("/NEX/projects/personal-finance"); }); it("falls back to absolute paths when companyPrefix is null", () => { const stale = makeProject({ id: "stale", name: "stale", urlKey: "stale-url", updatedAt: new Date(NOW_MS - 10 * 24 * 60 * 60 * 1000), }); const gate = makeApproval({ status: "pending", payload: {} }); const result = composeHomeStatus({ agents: [], approvals: [gate], activity: [], projects: [stale], ...baseArgs(), companyPrefix: null, }); expect(result.staleProjects[0]!.href).toBe("/projects/stale-url"); expect(result.pendingGates[0]!.href).toBe(`/approvals/${gate.id}`); expect(result.companyPrefix).toBeNull(); }); it("returns zero/empty values when no data is present", () => { const result = composeHomeStatus({ agents: [], approvals: [], activity: [], projects: [], ...baseArgs(), }); expect(result.activeAgents).toBe(0); expect(result.pendingGates).toEqual([]); expect(result.recentCompletions).toEqual([]); expect(result.staleProjects).toEqual([]); }); });