// @vitest-environment happy-dom import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { automationIdentity } from "../../lib/automation-display"; import type { DispatchAutomationItem } from "../../lib/automations"; import AutomationsRoute from "./automations"; const automationA: DispatchAutomationItem = { id: "auto-a", name: "Nightly digest", path: "jobs/nightly-digest.md", owner: "user@example.com", appId: "dispatch", scope: "personal", enabled: true, triggerType: "schedule", schedule: "0 6 * * *", scheduleDescription: "Every day at 6am", lastStatus: "success", }; const automationB: DispatchAutomationItem = { id: "auto-b", name: "Error triage", path: "jobs/error-triage.md", owner: "user@example.com", appId: "dispatch", scope: "personal", enabled: true, triggerType: "event", event: "issue.created", lastStatus: "success", }; // Real @tanstack/react-query is not exercised here — the route reads its // list straight from useQuery, so the hook itself is stubbed with a fixed // two-item list rather than standing up a QueryClient + network mock. vi.mock("@tanstack/react-query", () => ({ useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { if (String(queryKey[0]).includes("automations")) { return { data: [automationA, automationB], isLoading: false, isError: false, error: null, }; } return { data: undefined, isLoading: false, isError: false, error: null }; }, useMutation: () => ({ mutate: vi.fn(), isPending: false, variables: undefined, }), useQueryClient: () => ({ cancelQueries: vi.fn(), getQueriesData: vi.fn(() => []), setQueriesData: vi.fn(), invalidateQueries: vi.fn(), }), })); vi.mock("@agent-native/core/client/hooks", () => ({ useChangeVersions: () => 0, })); vi.mock("@agent-native/core/client/agent-chat", () => ({ sendToAgentChat: vi.fn(), })); vi.mock("@agent-native/core/client/composer", () => ({ PromptComposer: () => null, })); vi.mock("../../components/dispatch-shell", () => ({ DispatchShell: ({ children }: { children: ReactNode }) => (
{children}
), })); vi.mock("../../components/automation-details-panel", () => ({ AutomationDetailsPanel: ({ automation, }: { automation: DispatchAutomationItem; }) =>
{automation.name}
, })); function LocationProbe() { const location = useLocation(); return (
{location.pathname + location.search}
); } function GoBackProbe() { const navigate = useNavigate(); return ( ); } describe("AutomationsRoute", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); function locationText() { return container.querySelector('[data-testid="location"]')?.textContent; } function findRowButton(name: string) { return [...container.querySelectorAll("button")].find((button) => button.textContent?.includes(name), ); } it("writes the selected automation into the URL so it survives reload and back navigation", async () => { await act(async () => { root.render( , ); }); expect(locationText()).toBe("/automations"); const row = findRowButton(automationA.name); expect(row).toBeTruthy(); await act(async () => { row?.click(); }); expect( container.querySelector('[data-testid="details-panel"]')?.textContent, ).toBe(automationA.name); // Selecting a row must push the identity into the address bar — a plain // local-state selection leaves the URL unchanged, which is exactly the // bug this test guards against (no way to link/reload/Back to a row). expect(locationText()).toBe( `/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`, ); const otherRow = findRowButton(automationB.name); await act(async () => { otherRow?.click(); }); expect(locationText()).toBe( `/automations?automationId=${encodeURIComponent(automationIdentity(automationB))}`, ); }); it("pushes a history entry per row selection so Back steps through prior selections", async () => { await act(async () => { root.render( , ); }); const goBack = container.querySelector( '[data-testid="go-back"]', ); expect(goBack).toBeTruthy(); await act(async () => { findRowButton(automationA.name)?.click(); }); expect(locationText()).toBe( `/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`, ); await act(async () => { findRowButton(automationB.name)?.click(); }); expect(locationText()).toBe( `/automations?automationId=${encodeURIComponent(automationIdentity(automationB))}`, ); // A row click is an explicit user selection, not URL canonicalization — // it must push a new history entry so Back steps back through the prior // selection (A) instead of skipping past the whole page's history in one // jump. Replacing on every click (the bug this guards against) collapses // all selections into a single entry. await act(async () => { goBack?.click(); }); expect(locationText()).toBe( `/automations?automationId=${encodeURIComponent(automationIdentity(automationA))}`, ); await act(async () => { goBack?.click(); }); expect(locationText()).toBe("/automations"); }); it("reopens the matching detail panel when automationId is already in the URL", async () => { const targetId = automationIdentity(automationB); await act(async () => { root.render( , ); }); expect( container.querySelector('[data-testid="details-panel"]')?.textContent, ).toBe(automationB.name); }); });