import { act, render, screen } from "@testing-library/react"; import * as React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PipelineState } from "../api/client"; import { LozengeFlyProvider, useLozengeFly } from "./LozengeFlyContext"; import { StagePanel } from "./StagePanel"; // ─── Helpers ────────────────────────────────────────────────────────────────── function makePipeline(overrides: Partial = {}): PipelineState { return { backlog: [], current: [], qa: [], merge: [], done: [], ...overrides, }; } /** A minimal roster element fixture that registers itself with the context. */ function RosterFixture({ agentName }: { agentName: string }) { const { registerRosterEl } = useLozengeFly(); const ref = React.useRef(null); React.useEffect(() => { const el = ref.current; if (el) registerRosterEl(agentName, el); return () => registerRosterEl(agentName, null); }, [agentName, registerRosterEl]); return ( ); } function Wrapper({ pipeline, children, }: { pipeline: PipelineState; children: React.ReactNode; }) { return ( {children} ); } // ─── Agent lozenge fixed intrinsic width ────────────────────────────────────── describe("AgentLozenge fixed intrinsic width", () => { it("has align-self: flex-start so it never stretches inside a flex column", () => { const items = [ { story_id: "74_width_test", name: "Width Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ]; const pipeline = makePipeline({ current: items }); const { container } = render( , ); const lozenge = container.querySelector( '[data-testid="slot-lozenge-74_width_test"]', ) as HTMLElement; expect(lozenge).toBeInTheDocument(); expect(lozenge.style.alignSelf).toBe("flex-start"); }); }); // ─── Fly-in: slot lozenge visibility ───────────────────────────────────────── describe("LozengeFlyProvider fly-in visibility", () => { beforeEach(() => { Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.restoreAllMocks(); }); it("slot lozenge starts hidden when a matching roster element exists", async () => { const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "74_hidden_test", name: "Hidden Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); const { rerender } = render( , ); // Rerender with the agent assigned await act(async () => { rerender( , ); }); const lozenge = screen.getByTestId("slot-lozenge-74_hidden_test"); // Hidden while fly-in is in progress expect(lozenge.style.opacity).toBe("0"); }); it("slot lozenge is visible when no roster element is registered", async () => { const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "74_no_roster", name: "No Roster", error: null, merge_failure: null, agent: { agent_name: "unknown-agent", model: null, status: "running", }, }, ], }); const { rerender } = render( {/* No RosterFixture for "unknown-agent" */} , ); await act(async () => { rerender( , ); }); const lozenge = screen.getByTestId("slot-lozenge-74_no_roster"); // Immediately visible because no fly-in animation is possible expect(lozenge.style.opacity).toBe("1"); }); }); // ─── Fly-in: flying clone in document.body portal ──────────────────────────── describe("LozengeFlyProvider fly-in clone", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("renders a fixed-position clone in document.body when fly-in triggers", async () => { const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "74_portal_test", name: "Portal Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ], }); const { rerender } = render( , ); await act(async () => { rerender( , ); vi.runAllTimers(); }); // Clone is in document.body (portal), not inside the component container const clone = document.body.querySelector( '[data-testid^="flying-lozenge-fly-in"]', ) as HTMLElement | null; expect(clone).not.toBeNull(); expect(clone?.style.position).toBe("fixed"); expect(Number(clone?.style.zIndex)).toBeGreaterThanOrEqual(9999); expect(clone?.style.pointerEvents).toBe("none"); }); it("clone is removed from document.body after 500 ms", async () => { const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "74_clone_remove", name: "Clone Remove", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); const { rerender } = render( , ); await act(async () => { rerender( , ); }); // Clone should exist before timeout const cloneBefore = document.body.querySelector( '[data-testid^="flying-lozenge-fly-in"]', ); expect(cloneBefore).not.toBeNull(); // Advance past the 500ms cleanup timeout await act(async () => { vi.advanceTimersByTime(600); }); const cloneAfter = document.body.querySelector( '[data-testid^="flying-lozenge-fly-in"]', ); expect(cloneAfter).toBeNull(); }); it("slot lozenge becomes visible (opacity 1) after 500 ms timeout", async () => { const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "74_reveal_test", name: "Reveal Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); const { rerender } = render( , ); await act(async () => { rerender( , ); }); // Initially hidden const lozenge = screen.getByTestId("slot-lozenge-74_reveal_test"); expect(lozenge.style.opacity).toBe("0"); // After 500ms the slot becomes visible await act(async () => { vi.advanceTimersByTime(600); }); expect(lozenge.style.opacity).toBe("1"); }); }); // ─── Fly-out animation ──────────────────────────────────────────────────────── describe("LozengeFlyProvider fly-out", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("creates a fly-out clone in document.body when agent is removed", async () => { const withAgent = makePipeline({ current: [ { story_id: "74_fly_out_test", name: "Fly Out Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, }, ], }); const { rerender } = render( , ); // Advance past initial fly-in animation to get a clean state await act(async () => { vi.advanceTimersByTime(600); }); // Remove the agent from the pipeline const noAgent = makePipeline({ current: [ { story_id: "74_fly_out_test", name: "Fly Out Test", error: null, merge_failure: null, agent: null, }, ], }); await act(async () => { rerender( , ); }); // A fly-out clone should now be in document.body const clone = document.body.querySelector( '[data-testid^="flying-lozenge-fly-out"]', ); expect(clone).not.toBeNull(); }); }); // ─── Idle vs active visual distinction ──────────────────────────────────────── describe("AgentLozenge idle vs active appearance", () => { it("running agent lozenge uses the green active color", () => { const items = [ { story_id: "74_running_color", name: "Running", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ]; const { container } = render( , ); const lozenge = container.querySelector( '[data-testid="slot-lozenge-74_running_color"]', ) as HTMLElement; expect(lozenge).toBeInTheDocument(); // Green: rgb(63, 185, 80) = #3fb950 expect(lozenge.style.color).toBe("rgb(63, 185, 80)"); }); it("pending agent lozenge uses the yellow pending color", () => { const items = [ { story_id: "74_pending_color", name: "Pending", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "pending" }, }, ]; const { container } = render( , ); const lozenge = container.querySelector( '[data-testid="slot-lozenge-74_pending_color"]', ) as HTMLElement; expect(lozenge).toBeInTheDocument(); // Yellow: rgb(227, 179, 65) = #e3b341 expect(lozenge.style.color).toBe("rgb(227, 179, 65)"); }); it("running lozenge has a pulsing dot child element", () => { const items = [ { story_id: "74_pulse_dot", name: "Pulse", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ]; const { container } = render( , ); const lozenge = container.querySelector( '[data-testid="slot-lozenge-74_pulse_dot"]', ) as HTMLElement; // The pulse dot is a child span with animation: pulse const dot = lozenge.querySelector("span"); expect(dot).not.toBeNull(); expect(dot?.style.animation).toContain("pulse"); }); }); // ─── hiddenRosterAgents: no-duplicate guarantee ─────────────────────────────── /** Reads hiddenRosterAgents from context and exposes it via a data attribute. */ function HiddenAgentsProbe() { const { hiddenRosterAgents } = useLozengeFly(); return (
); } describe("hiddenRosterAgents: assigned agents are absent from roster", () => { it("is empty when no agents are in the pipeline", () => { render( , ); const probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toBe(""); }); it("includes agent name when agent is assigned to a current story", () => { const pipeline = makePipeline({ current: [ { story_id: "85_assign_test", name: "Assign Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); render( , ); const probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toContain("coder-1"); }); it("excludes agent name when it has no assignment in the pipeline", () => { const pipeline = makePipeline({ current: [ { story_id: "85_no_agent", name: "No Agent", error: null, merge_failure: null, agent: null, }, ], }); render( , ); const probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toBe(""); }); it("updates to include agent when pipeline transitions from no-agent to assigned", async () => { const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "85_transition_test", name: "Transition", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); const { rerender } = render( , ); let probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toBe(""); await act(async () => { rerender( , ); }); probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toContain("coder-1"); }); }); describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("agent stays hidden in roster during fly-out (0–499 ms)", async () => { const withAgent = makePipeline({ current: [ { story_id: "85_flyout_hidden", name: "Fly-out Hidden", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "completed" }, }, ], }); const noAgent = makePipeline({ current: [ { story_id: "85_flyout_hidden", name: "Fly-out Hidden", error: null, merge_failure: null, agent: null, }, ], }); const { rerender } = render( , ); // Advance past the initial fly-in await act(async () => { vi.advanceTimersByTime(600); }); // Remove agent — fly-out starts await act(async () => { rerender( , ); }); // Agent should still be hidden (fly-out clone is in flight) const probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toContain("coder-1"); }); it("agent reappears in roster after fly-out clone lands (500 ms)", async () => { const withAgent = makePipeline({ current: [ { story_id: "85_flyout_reveal", name: "Fly-out Reveal", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "completed" }, }, ], }); const noAgent = makePipeline({ current: [ { story_id: "85_flyout_reveal", name: "Fly-out Reveal", error: null, merge_failure: null, agent: null, }, ], }); const { rerender } = render( , ); await act(async () => { vi.advanceTimersByTime(600); }); await act(async () => { rerender( , ); }); // Advance past fly-out animation await act(async () => { vi.advanceTimersByTime(600); }); // Agent should now be visible in roster const probe = screen.getByTestId("hidden-agents-probe"); expect(probe.dataset.hidden).toBe(""); }); }); // ─── Agent swap (name change) triggers both fly-out and fly-in ──────────── describe("LozengeFlyProvider agent swap (name change)", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("detects agent name change as both fly-out (old) and fly-in (new)", async () => { const withCoder1 = makePipeline({ current: [ { story_id: "109_swap_test", name: "Swap Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ], }); const withCoder2 = makePipeline({ current: [ { story_id: "109_swap_test", name: "Swap Test", error: null, merge_failure: null, agent: { agent_name: "coder-2", model: "haiku", status: "running" }, }, ], }); const { rerender } = render( , ); // Advance past initial fly-in await act(async () => { vi.advanceTimersByTime(600); }); // Swap agent: coder-1 → coder-2 await act(async () => { rerender( , ); }); // A fly-out clone for coder-1 should appear (old agent leaves) const flyOut = document.body.querySelector( '[data-testid^="flying-lozenge-fly-out"]', ); expect(flyOut).not.toBeNull(); // A fly-in clone for coder-2 should appear (new agent arrives) const flyIn = document.body.querySelector( '[data-testid^="flying-lozenge-fly-in"]', ); expect(flyIn).not.toBeNull(); }); }); // ─── Fly-out without a roster element (null rosterRect fallback) ────────── describe("LozengeFlyProvider fly-out without roster element", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 200, top: 100, right: 280, bottom: 120, width: 80, height: 20, x: 200, y: 100, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("fly-out still works when no roster element is registered (uses fallback coords)", async () => { const withAgent = makePipeline({ current: [ { story_id: "109_no_roster_flyout", name: "No Roster Flyout", error: null, merge_failure: null, agent: { agent_name: "orphan-agent", model: null, status: "completed", }, }, ], }); const noAgent = makePipeline({ current: [ { story_id: "109_no_roster_flyout", name: "No Roster Flyout", error: null, merge_failure: null, agent: null, }, ], }); const { rerender } = render( {/* No RosterFixture for orphan-agent */} , ); await act(async () => { vi.advanceTimersByTime(600); }); await act(async () => { rerender( , ); }); // Fly-out clone should still appear even without roster element const clone = document.body.querySelector( '[data-testid^="flying-lozenge-fly-out"]', ); expect(clone).not.toBeNull(); }); }); // ─── Flying clone renders in initial (non-flying) state ─────────────────── describe("FlyingLozengeClone initial non-flying render", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("clone has transition: none before rAF fires", async () => { // Collect rAF callbacks instead of firing them immediately const rafCallbacks: FrameRequestCallback[] = []; vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { rafCallbacks.push(cb); return rafCallbacks.length; }); const noPipeline = makePipeline(); const withAgent = makePipeline({ current: [ { story_id: "109_nontransition_test", name: "Non-transition Test", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); const { rerender } = render( , ); // Trigger fly-in but don't flush rAF callbacks await act(async () => { rerender( , ); }); // Clone should exist in its initial (non-flying) state const clone = document.body.querySelector( '[data-testid^="flying-lozenge-fly-in"]', ) as HTMLElement | null; expect(clone).not.toBeNull(); expect(clone?.style.transition).toBe("none"); // Now flush rAF callbacks to trigger the flying state await act(async () => { for (const cb of rafCallbacks) cb(0); rafCallbacks.length = 0; // Flush inner rAF callbacks too for (const cb of rafCallbacks) cb(0); }); }); }); // ─── Bug 137: Race condition on rapid pipeline updates ──────────────────── describe("Bug 137: no animation actions lost during rapid pipeline updates", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("rapid agent swap: first timeout does not prematurely reveal slot lozenge", async () => { const empty = makePipeline(); const withCoder1 = makePipeline({ current: [ { story_id: "137_rapid_swap", name: "Rapid Swap", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ], }); const withCoder2 = makePipeline({ current: [ { story_id: "137_rapid_swap", name: "Rapid Swap", error: null, merge_failure: null, agent: { agent_name: "coder-2", model: "haiku", status: "running" }, }, ], }); const { rerender } = render( , ); // First update: assign coder-1 → fly-in animation #1 starts await act(async () => { rerender( , ); }); // Slot should be hidden (fly-in in progress) const lozenge = screen.getByTestId("slot-lozenge-137_rapid_swap"); expect(lozenge.style.opacity).toBe("0"); // Rapid swap at 200ms: coder-1 → coder-2 (before first animation's 500ms timeout) await act(async () => { vi.advanceTimersByTime(200); }); await act(async () => { rerender( , ); }); // Slot should still be hidden (new fly-in for coder-2 is in progress) expect(lozenge.style.opacity).toBe("0"); // At 300ms after first animation started (500ms total from start), // the FIRST animation's timeout fires. It must NOT reveal the slot. await act(async () => { vi.advanceTimersByTime(300); }); // BUG: Without fix, the first timeout clears pendingFlyIns for this story, // revealing the slot while coder-2's fly-in is still in progress. expect(lozenge.style.opacity).toBe("0"); }); it("slot lozenge reveals correctly after the LAST animation completes", async () => { const empty = makePipeline(); const withCoder1 = makePipeline({ current: [ { story_id: "137_reveal_last", name: "Reveal Last", error: null, merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], }); const withCoder2 = makePipeline({ current: [ { story_id: "137_reveal_last", name: "Reveal Last", error: null, merge_failure: null, agent: { agent_name: "coder-2", model: null, status: "running" }, }, ], }); const { rerender } = render( , ); // First animation await act(async () => { rerender( , ); }); // Swap at 200ms await act(async () => { vi.advanceTimersByTime(200); }); await act(async () => { rerender( , ); }); const lozenge = screen.getByTestId("slot-lozenge-137_reveal_last"); // After the second animation's full 500ms, slot should reveal await act(async () => { vi.advanceTimersByTime(600); }); expect(lozenge.style.opacity).toBe("1"); }); }); describe("Bug 137: animations remain functional through sustained agent activity", () => { beforeEach(() => { vi.useFakeTimers(); Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ left: 100, top: 50, right: 180, bottom: 70, width: 80, height: 20, x: 100, y: 50, toJSON: () => ({}), }); vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => { cb(0); return 0; }); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it("fly-in still works after multiple rapid swaps have completed", async () => { const empty = makePipeline(); const makeWith = (agentName: string) => makePipeline({ current: [ { story_id: "137_sustained", name: "Sustained", error: null, merge_failure: null, agent: { agent_name: agentName, model: null, status: "running" }, }, ], }); const { rerender } = render( , ); // Rapid-fire: assign coder-1, then swap to coder-2 at 100ms const p1 = makeWith("coder-1"); await act(async () => { rerender( , ); }); await act(async () => { vi.advanceTimersByTime(100); }); const p2 = makeWith("coder-2"); await act(async () => { rerender( , ); }); // Let all animations complete await act(async () => { vi.advanceTimersByTime(1000); }); const lozenge = screen.getByTestId("slot-lozenge-137_sustained"); expect(lozenge.style.opacity).toBe("1"); // Now assign coder-3 — a fresh fly-in should still work const p3 = makeWith("coder-3"); await act(async () => { rerender( , ); }); // Slot should be hidden again for the new fly-in expect(lozenge.style.opacity).toBe("0"); // A flying clone should exist const clone = document.body.querySelector( '[data-testid^="flying-lozenge-fly-in"]', ); expect(clone).not.toBeNull(); // After animation completes, slot reveals await act(async () => { vi.advanceTimersByTime(600); }); expect(lozenge.style.opacity).toBe("1"); }); });