Files
kandev/apps/web/components/runs/use-manual-trigger.test.ts
T
2026-08-10 22:37:07 +01:00

190 lines
5.9 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const mockTrigger = vi.fn();
vi.mock("@/lib/api/domains/automation-api", () => ({
triggerAutomation: (...args: unknown[]) => mockTrigger(...args),
}));
const toastInfo = vi.fn();
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock("sonner", () => ({
toast: {
info: (...args: unknown[]) => toastInfo(...args),
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));
import { useManualTrigger } from "./use-manual-trigger";
const AUTOMATION_ID = "automation-1";
afterEach(() => {
vi.clearAllMocks();
});
describe("useManualTrigger", () => {
it("fires the automation it was given", async () => {
mockTrigger.mockResolvedValue({ triggered: true });
const onFired = vi.fn();
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, onFired));
await act(async () => {
await result.current.runNow();
});
expect(mockTrigger).toHaveBeenCalledWith(AUTOMATION_ID);
expect(toastSuccess).toHaveBeenCalledWith("Triggered");
});
it("refreshes so the new run is visible without a reload", async () => {
mockTrigger.mockResolvedValue({ triggered: true });
const onFired = vi.fn();
vi.useFakeTimers();
try {
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, onFired));
await act(async () => {
await result.current.runNow();
});
// The first read goes out immediately.
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(onFired).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});
// The orchestrator writes the automation_runs row on its own goroutine, so the
// trigger response can land before the run exists to be read.
describe("useManualTrigger retries", () => {
// The orchestrator writes the automation_runs row on its own goroutine, so
// the trigger response can land first. One read would then find nothing —
// and with no open run the page stops polling, so "Triggered" sits above an
// empty list until the user reloads by hand.
it("keeps looking for the run the fire promised", async () => {
mockTrigger.mockResolvedValue({ triggered: true });
const onFired = vi.fn();
vi.useFakeTimers();
try {
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, onFired));
await act(async () => {
await result.current.runNow();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
const immediate = onFired.mock.calls.length;
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(onFired.mock.calls.length).toBeGreaterThan(immediate);
} finally {
vi.useRealTimers();
}
});
it("stops retrying once the page is gone", async () => {
mockTrigger.mockResolvedValue({ triggered: true });
const onFired = vi.fn();
vi.useFakeTimers();
try {
const { result, unmount } = renderHook(() => useManualTrigger(AUTOMATION_ID, onFired));
await act(async () => {
await result.current.runNow();
});
unmount();
onFired.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(5_000);
});
expect(onFired).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("reports a skip as a skip, with its reason", async () => {
// The cap turning a request away is the case that made the old trigger feel
// broken: success was reported and nothing ran.
mockTrigger.mockResolvedValue({ triggered: false, skipped: true, reason: "already running" });
const onFired = vi.fn();
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, onFired));
await act(async () => {
await result.current.runNow();
});
expect(toastInfo).toHaveBeenCalledWith("Skipped; already running");
expect(toastSuccess).not.toHaveBeenCalled();
// Nothing started, so there is nothing new to show.
expect(onFired).not.toHaveBeenCalled();
});
it("still says it was skipped when the backend gives no reason", async () => {
mockTrigger.mockResolvedValue({ triggered: false, skipped: true });
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, vi.fn()));
await act(async () => {
await result.current.runNow();
});
expect(toastInfo).toHaveBeenCalledWith("Skipped; already running");
});
it("surfaces a failure instead of leaving the click silent", async () => {
mockTrigger.mockRejectedValue(new Error("workspace not authorized"));
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, vi.fn()));
await act(async () => {
await result.current.runNow();
});
expect(toastError).toHaveBeenCalledWith("workspace not authorized");
await waitFor(() => expect(result.current.triggering).toBe(false));
});
it("does not fire twice while the first request is in flight", async () => {
let release: (value: unknown) => void = () => {};
mockTrigger.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const { result } = renderHook(() => useManualTrigger(AUTOMATION_ID, vi.fn()));
act(() => {
void result.current.runNow();
});
await waitFor(() => expect(result.current.triggering).toBe(true));
await act(async () => {
await result.current.runNow();
release({ triggered: true });
});
expect(mockTrigger).toHaveBeenCalledTimes(1);
});
it("does nothing without an automation id", async () => {
const { result } = renderHook(() => useManualTrigger("", vi.fn()));
await act(async () => {
await result.current.runNow();
});
expect(mockTrigger).not.toHaveBeenCalled();
});
});