Files
kandev/apps/web/components/automations/runs-section.test.tsx
T
2026-09-21 18:22:29 +01:00

370 lines
12 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AutomationRun } from "@/lib/types/automation";
const mockUseAutomationRuns = vi.fn();
vi.mock("@/hooks/domains/settings/use-automation-runs", () => ({
useAutomationRuns: (...args: unknown[]) => mockUseAutomationRuns(...args),
}));
const RUN_ARCHIVED = "run-archived";
const RUN_CANCELLED = "run-cancelled";
const ARCHIVED = "Archived";
const CANCELLED = "Cancelled";
const RUN_OUTCOME = "run-outcome";
const RUN_OUTCOME_REASON = "run-outcome-reason";
const mockPush = vi.fn();
vi.mock("@/lib/routing/client-router", () => ({
useRouter: () => ({ push: mockPush }),
}));
import { RunsSection } from "./runs-section";
function mkRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
return {
id: "run-1",
automation_id: "auto-1",
trigger_id: "trig-1",
trigger_type: "scheduled",
task_id: "task-1",
status: "task_created",
dedup_key: "",
trigger_data: {},
error_message: "",
created_at: new Date().toISOString(),
...overrides,
};
}
type RunsSectionHook = {
runs: AutomationRun[];
loading: boolean;
deleting: boolean;
refresh: () => void;
deleteRun: (id: string) => void;
deleteAllRuns: (runIds?: string[]) => void;
};
function setup(
runs: AutomationRun[],
overrides: Partial<RunsSectionHook> = {},
): ReturnType<typeof render> {
mockUseAutomationRuns.mockReturnValue({
runs,
loading: false,
deleting: false,
refresh: vi.fn(),
deleteRun: vi.fn(),
deleteAllRuns: vi.fn(),
...overrides,
});
const view = render(<RunsSection automationId="auto-1" workspaceId="ws-1" />);
// The runs table only renders once the "Recent Runs" disclosure is expanded.
// The heading is count-aware ("Recent Run (1)" / "Recent Runs (2)"), so the
// matcher must tolerate both forms rather than pinning the plural.
fireEvent.click(screen.getByText(/Recent Runs?\s*\(/));
return view;
}
describe("RunsSection status badges", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// The status filter chips carry the same words as the badges, so these
// assertions read the badge inside the run's own row rather than any element
// with that text.
const badgeOf = (runId: string) =>
screen.getByTestId(`run-row-${runId}`).querySelector("[data-slot='badge']")?.textContent ?? "";
it("labels an archived-task run as Archived, not Cancelled", () => {
setup([mkRun({ id: RUN_ARCHIVED, status: "archived" })]);
expect(badgeOf(RUN_ARCHIVED)).toBe(ARCHIVED);
});
it("labels a genuinely cancelled-task run as Cancelled", () => {
setup([mkRun({ id: RUN_CANCELLED, status: "cancelled" })]);
expect(badgeOf(RUN_CANCELLED)).toBe(CANCELLED);
});
it("distinguishes archived and cancelled runs side by side", () => {
setup([
mkRun({ id: RUN_ARCHIVED, status: "archived" }),
mkRun({ id: RUN_CANCELLED, status: "cancelled" }),
]);
expect(badgeOf(RUN_ARCHIVED)).toBe(ARCHIVED);
expect(badgeOf(RUN_CANCELLED)).toBe(CANCELLED);
});
});
describe("RunsSection run log", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("links a run to its task even in run mode", () => {
// Run mode keeps the task off the board. That is not a reason to withhold
// the only route to what the run said — previously these rows were inert.
setup([mkRun({ id: "run-x", task_id: "task-hidden", status: "succeeded" })]);
fireEvent.click(screen.getByTestId("run-row-run-x"));
expect(mockPush).toHaveBeenCalledWith("/t/task-hidden");
});
it("does not link a run that never produced a task", () => {
setup([mkRun({ id: "run-skipped", task_id: "", status: "skipped" })]);
fireEvent.click(screen.getByTestId("run-row-run-skipped"));
expect(mockPush).not.toHaveBeenCalled();
});
it("shows what the run actually said", () => {
setup([
mkRun({ id: "run-s", status: "succeeded", summary: "Sweep complete across all 32 specs." }),
]);
expect(screen.getByTestId(RUN_OUTCOME).textContent).toContain("Sweep complete");
});
it("prefers the error over the summary when a run failed", () => {
setup([
mkRun({
id: "run-f",
status: "skipped",
error_message: "max_concurrent_runs=1 reached",
summary: "irrelevant",
}),
]);
expect(screen.getByTestId(RUN_OUTCOME).textContent).toContain("max_concurrent_runs=1");
});
it("can filter down to skipped runs, which are otherwise silent", () => {
setup([
mkRun({ id: "run-ok", status: "succeeded", summary: "all good" }),
mkRun({ id: "run-skip", status: "skipped", error_message: "max_concurrent_runs=1 reached" }),
]);
fireEvent.click(screen.getByTestId("run-filter-skipped"));
expect(screen.getByTestId("run-row-run-skip")).toBeTruthy();
expect(screen.queryByTestId("run-row-run-ok")).toBeNull();
});
it("offers no filter for a status this automation has never produced", () => {
setup([mkRun({ id: "run-ok", status: "succeeded" })]);
expect(screen.queryByTestId("run-filter-failed")).toBeNull();
expect(screen.getByTestId("run-filter-succeeded")).toBeTruthy();
});
});
describe("RunsSection outcome reason suffix", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("appends no suffix when a run has an error and no disposition reasons", () => {
setup([mkRun({ id: "run-err", status: "failed", error_message: "dispatch failed" })]);
expect(screen.getByTestId(RUN_OUTCOME).textContent).toContain("dispatch failed");
expect(screen.queryByTestId(RUN_OUTCOME_REASON)).toBeNull();
});
it("appends the repository reason as a muted suffix after the summary", () => {
setup([
mkRun({
id: "run-repo",
status: "triggered",
summary: "Alert admitted",
repository_reason: "repository_none_configured",
}),
]);
const outcome = screen.getByTestId(RUN_OUTCOME);
expect(outcome.textContent).toContain("Alert admitted");
const reason = screen.getByTestId(RUN_OUTCOME_REASON);
expect(reason.textContent).toContain("No repository is configured for this automation");
});
it("renders the repository reason before the dedup reason when both are present", () => {
setup([
mkRun({
id: "run-both",
status: "triggered",
summary: "Alert admitted",
repository_reason: "repository_none_configured",
dedup_reason: "dedup_unresolved",
}),
]);
const reason = screen.getByTestId(RUN_OUTCOME_REASON).textContent ?? "";
const repoIndex = reason.indexOf("No repository is configured for this automation");
const dedupIndex = reason.indexOf("Duplicate check did not resolve a value for this delivery");
expect(repoIndex).toBeGreaterThanOrEqual(0);
expect(dedupIndex).toBeGreaterThan(repoIndex);
});
it("never renders dedup_not_configured, leaving a bare dash with no stray separator", () => {
setup([
mkRun({
id: "run-not-configured",
status: "triggered",
summary: "",
error_message: "",
dedup_reason: "dedup_not_configured",
}),
]);
expect(screen.getByTestId(RUN_OUTCOME).textContent).toBe("-");
expect(screen.queryByTestId(RUN_OUTCOME_REASON)).toBeNull();
});
});
const DELETE_ALL_BTN = "delete-all-runs";
const DELETE_ALL_CONFIRM = "delete-all-runs-confirm";
const FILTER_SKIPPED = "run-filter-skipped";
describe("RunsSection delete-all scope", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("renders delete-all in the rightmost table header cell, not beside the heading", () => {
setup([mkRun({ id: "run-solo", status: "skipped" })]);
const button = screen.getByTestId(DELETE_ALL_BTN);
const headerRow = button.closest("thead")?.querySelector("tr");
expect(headerRow).toBeTruthy();
const cells = headerRow!.querySelectorAll("th");
// Last header cell holds the control, aligned with the per-row delete
// buttons in the same column.
expect(cells[cells.length - 1].querySelector(`[data-testid="${DELETE_ALL_BTN}"]`)).toBeTruthy();
// It no longer lives in the section header beside Refresh.
expect(screen.getByTestId(DELETE_ALL_BTN).closest("thead")).toBeTruthy();
// Unfiltered: the accessible name matches the full scope.
expect(screen.getByTestId(DELETE_ALL_BTN).getAttribute("title")).toBe("Delete all runs");
});
it("deletes only the runs in the active status view", () => {
const deleteAllRuns = vi.fn();
setup(
[
mkRun({ id: "run-pass", status: "succeeded" }),
mkRun({ id: "run-skip-1", status: "skipped" }),
mkRun({ id: "run-skip-2", status: "skipped" }),
],
{ deleteAllRuns },
);
fireEvent.click(screen.getByTestId(FILTER_SKIPPED));
fireEvent.click(screen.getByTestId(DELETE_ALL_BTN));
fireEvent.click(screen.getByTestId(DELETE_ALL_CONFIRM));
expect(deleteAllRuns).toHaveBeenCalledWith(["run-skip-1", "run-skip-2"]);
});
it("deletes every run from the All view with no id scope", () => {
const deleteAllRuns = vi.fn();
setup(
[mkRun({ id: "run-a", status: "succeeded" }), mkRun({ id: "run-b", status: "skipped" })],
{ deleteAllRuns },
);
fireEvent.click(screen.getByTestId(DELETE_ALL_BTN));
fireEvent.click(screen.getByTestId(DELETE_ALL_CONFIRM));
expect(deleteAllRuns).toHaveBeenCalledWith();
});
it("hides delete-all when the active filter's runs are gone", () => {
const { rerender } = setup([
mkRun({ id: "run-temp", status: "skipped" }),
mkRun({ id: "run-stay", status: "succeeded" }),
]);
fireEvent.click(screen.getByTestId(FILTER_SKIPPED));
expect(screen.getByTestId(DELETE_ALL_BTN)).toBeTruthy();
// The runs refresh and the skipped one disappears server-side while the
// filter is still active — the empty view must not offer delete-all.
mockUseAutomationRuns.mockReturnValue({
runs: [mkRun({ id: "run-stay", status: "succeeded" })],
loading: false,
refresh: vi.fn(),
deleteRun: vi.fn(),
deleteAllRuns: vi.fn(),
});
rerender(<RunsSection automationId="auto-1" workspaceId="ws-1" />);
expect(screen.queryByTestId(DELETE_ALL_BTN)).toBeNull();
});
it("disables the delete controls while a delete is in flight", () => {
setup(
[mkRun({ id: "run-a", status: "succeeded" }), mkRun({ id: "run-b", status: "skipped" })],
{ deleting: true },
);
expect((screen.getByTestId(DELETE_ALL_BTN) as HTMLButtonElement).disabled).toBe(true);
expect((screen.getAllByTestId("delete-run")[0] as HTMLButtonElement).disabled).toBe(true);
});
it("names the active status in the confirmation dialog", () => {
setup([
mkRun({ id: "run-stay", status: "succeeded" }),
mkRun({ id: "run-filtered", status: "skipped" }),
]);
fireEvent.click(screen.getByTestId(FILTER_SKIPPED));
fireEvent.click(screen.getByTestId(DELETE_ALL_BTN));
// The button's accessible name names the active status, matching the
// dialog copy.
expect(screen.getByTestId(DELETE_ALL_BTN).getAttribute("title")).toBe(
"Delete all Skipped runs",
);
expect(
screen.getByText(
"This will permanently remove the Skipped runs shown in this view and their associated tasks. This cannot be undone.",
),
).toBeTruthy();
});
});
describe("RunsSection derived statuses", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("can filter to archived and cancelled runs", () => {
// Both are displayed statuses derived at read time, so leaving them out of
// the filters made those runs visible but unreachable.
setup([
mkRun({ id: "run-ok", status: "succeeded" }),
mkRun({ id: "run-arch", status: "archived" }),
mkRun({ id: "run-canc", status: "cancelled" }),
]);
fireEvent.click(screen.getByTestId("run-filter-archived"));
expect(screen.getByTestId("run-row-run-arch")).toBeTruthy();
expect(screen.queryByTestId("run-row-run-ok")).toBeNull();
fireEvent.click(screen.getByTestId("run-filter-cancelled"));
expect(screen.getByTestId("run-row-run-canc")).toBeTruthy();
expect(screen.queryByTestId("run-row-run-arch")).toBeNull();
});
});