214 lines
8.2 KiB
TypeScript
214 lines
8.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import React, { act } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
|
import type { ComboRunModel } from "@/app/(dashboard)/dashboard/combos/live/comboFlowModel";
|
|
|
|
// ── Polyfill ResizeObserver (required by ReactFlow) ───────────────────────
|
|
|
|
beforeAll(() => {
|
|
globalThis.ResizeObserver = class {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
} as unknown as typeof ResizeObserver;
|
|
});
|
|
|
|
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
|
|
// Stub @xyflow/react so ReactFlow renders without canvas/DOM measurement APIs
|
|
vi.mock("@xyflow/react", async () => {
|
|
const actual = (await vi.importActual("@xyflow/react")) as Record<string, unknown>;
|
|
return {
|
|
...actual,
|
|
Handle: (_props: Record<string, unknown>) => null,
|
|
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
|
|
};
|
|
});
|
|
|
|
// Stub next/image to avoid Next.js internals in jsdom
|
|
vi.mock("next/image", () => ({
|
|
default: (props: Record<string, unknown>) =>
|
|
React.createElement("img", { src: props.src as string, alt: props.alt as string }),
|
|
}));
|
|
|
|
// ── Import after mocks ─────────────────────────────────────────────────────
|
|
|
|
const { ComboLiveStudio } = await import("@/app/(dashboard)/dashboard/combos/live/ComboLiveStudio");
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
const containers: HTMLElement[] = [];
|
|
|
|
function mount(ui: React.ReactElement): HTMLElement {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
containers.push(container);
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(ui);
|
|
});
|
|
return container;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
(
|
|
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
});
|
|
|
|
afterEach(() => {
|
|
while (containers.length > 0) {
|
|
containers.pop()?.remove();
|
|
}
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
// ── Sample run: 2 failures then 1 success ────────────────────────────────
|
|
|
|
const SAMPLE_RUN: ComboRunModel = {
|
|
comboName: "daily-cascade",
|
|
strategy: "priority",
|
|
targets: [
|
|
{
|
|
targetIndex: 0,
|
|
provider: "openai",
|
|
model: "gpt-4o",
|
|
state: "failed",
|
|
latencyMs: 1200,
|
|
error: "429 rate limit exceeded",
|
|
failKind: "rate-limit",
|
|
},
|
|
{
|
|
targetIndex: 1,
|
|
provider: "anthropic",
|
|
model: "claude-3-sonnet",
|
|
state: "failed",
|
|
latencyMs: 800,
|
|
error: "circuit open after 5 failures",
|
|
failKind: "circuit-open",
|
|
},
|
|
{
|
|
targetIndex: 2,
|
|
provider: "gemini",
|
|
model: "gemini-1.5-pro",
|
|
state: "succeeded",
|
|
latencyMs: 620,
|
|
},
|
|
],
|
|
outcome: "succeeded",
|
|
startedAt: 1718000000000,
|
|
finishedAt: 1718000003000,
|
|
};
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
describe("ComboLiveStudio", () => {
|
|
describe("with a static run prop", () => {
|
|
it("renders the studio wrapper", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the ReactFlow canvas (.react-flow)", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.querySelector(".react-flow")).toBeTruthy();
|
|
});
|
|
|
|
it("shows the combo name in the toolbar", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.textContent).toContain("daily-cascade");
|
|
});
|
|
|
|
it("shows the strategy in the toolbar", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.textContent).toContain("priority");
|
|
});
|
|
|
|
it("shows the outcome in the toolbar", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.querySelector("[data-testid='run-outcome']")?.textContent).toBe("succeeded");
|
|
});
|
|
|
|
it("shows provider names from target nodes", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
const text = container.textContent ?? "";
|
|
// Node internals rendered via ReactFlow in jsdom — at minimum the toolbar
|
|
// shows the combo name. Provider names appear in node elements if ReactFlow
|
|
// renders custom node interiors.
|
|
expect(text).toContain("daily-cascade");
|
|
});
|
|
|
|
it("does NOT render the combo selector when a run prop is supplied", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.querySelector("[data-testid='combo-selector']")).toBeNull();
|
|
});
|
|
|
|
it("renders the Single/Fleet toggle buttons", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} />);
|
|
expect(container.querySelector("[data-testid='mode-single']")).toBeTruthy();
|
|
expect(container.querySelector("[data-testid='mode-fleet']")).toBeTruthy();
|
|
});
|
|
|
|
it("does NOT show the disconnected banner when isConnected=true", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={true} />);
|
|
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeNull();
|
|
});
|
|
|
|
it("shows the disconnected banner when isConnected=false", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
|
|
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("empty state (no run, no events)", () => {
|
|
it("renders the empty state when run=null", () => {
|
|
const container = mount(<ComboLiveStudio run={null} />);
|
|
expect(container.querySelector("[data-testid='combo-live-studio-empty']")).toBeTruthy();
|
|
});
|
|
|
|
it("shows the empty state message", () => {
|
|
const container = mount(<ComboLiveStudio run={null} />);
|
|
expect(container.textContent).toContain("No combo run available");
|
|
});
|
|
|
|
it("shows the combo selector with no events", () => {
|
|
const container = mount(<ComboLiveStudio comboEvents={[]} />);
|
|
// No run prop → selector should appear
|
|
expect(container.querySelector("[data-testid='combo-selector']")).toBeTruthy();
|
|
});
|
|
|
|
it("renders the studio wrapper even when empty", () => {
|
|
const container = mount(<ComboLiveStudio />);
|
|
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("fleet mode", () => {
|
|
it("shows fleet overview after clicking Fleet toggle", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} comboEvents={[]} />);
|
|
const fleetBtn = container.querySelector(
|
|
"[data-testid='mode-fleet']"
|
|
) as HTMLButtonElement | null;
|
|
expect(fleetBtn).toBeTruthy();
|
|
act(() => {
|
|
fleetBtn!.click();
|
|
});
|
|
// Fleet panel renders (either FleetOverview or its empty state)
|
|
expect(container.querySelector("[data-testid='combo-live-studio']")).toBeTruthy();
|
|
// The .react-flow canvas should no longer be visible in fleet mode
|
|
expect(container.querySelector(".react-flow")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("disconnected state with a run", () => {
|
|
it("still renders the canvas when disconnected but run is provided", () => {
|
|
const container = mount(<ComboLiveStudio run={SAMPLE_RUN} isConnected={false} />);
|
|
// Canvas renders (graceful degrade — show last known state)
|
|
expect(container.querySelector(".react-flow")).toBeTruthy();
|
|
// Banner also shows
|
|
expect(container.querySelector("[data-testid='combo-disconnected-banner']")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|