moadim 3.9.0

Loop engine for AI agents — routines over REST, MCP, and a built-in web UI
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FleetRunSummary } from "../../api/hooks";
import { computeDigest, loadOrSeedLastSeen, saveLastSeen } from "./visitDigest";
import { SinceLastVisit } from "./SinceLastVisit";

function run(routine_id: string, finished: number | null, status: FleetRunSummary["status"]): FleetRunSummary {
  return {
    routine_id,
    routine_title: routine_id.toUpperCase(),
    workbench: `wb-${routine_id}-${finished ?? "live"}`,
    started_at: (finished ?? 10_000) - 10,
    started_at_local: "",
    finished_at: finished,
    finished_at_local: "",
    status,
    exit_code: null,
  };
}

const SINCE = 1_000;

describe("computeDigest", () => {
  it("counts only runs finished after the baseline", () => {
    const runs = [run("a", 2_000, "success"), run("b", null, "running"), run("a", 500, "failed")];
    expect(computeDigest(runs, SINCE, 300)).toEqual({ failed: 0, succeeded: 1, routines: [], truncated: false });
  });

  it("groups failures per routine, still-failing first, and marks recoveries", () => {
    const runs = [
      run("ok", 3_000, "success"),
      run("bad", 2_500, "failed"),
      run("ok", 2_000, "failed"),
      run("bad", 1_500, "failed"),
      run("ok", 1_200, "failed"),
    ];
    const d = computeDigest(runs, SINCE, 300);
    expect(d.failed).toBe(4);
    expect(d.succeeded).toBe(1);
    expect(d.routines.map((r) => [r.routineId, r.failures, r.recovered, r.latestFailure.finished_at])).toEqual([
      ["bad", 2, false, 2_500],
      ["ok", 2, true, 2_000],
    ]);
  });

  it("orders same-state routines by newest failure", () => {
    const d = computeDigest([run("x", 2_000, "failed"), run("y", 3_000, "failed")], SINCE, 300);
    expect(d.routines.map((r) => r.routineId)).toEqual(["y", "x"]);
  });

  it("flags truncation when the capped fetch never reaches the baseline", () => {
    expect(computeDigest([run("a", 2_000, "success")], SINCE, 1).truncated).toBe(true);
    expect(computeDigest([run("a", 900, "success")], SINCE, 1).truncated).toBe(false);
    expect(computeDigest([], SINCE, 0).truncated).toBe(false);
  });
});

describe("last-seen storage", () => {
  afterEach(() => {
    localStorage.clear();
    vi.restoreAllMocks();
  });

  it("seeds a first visit with now, then reads the stored value", () => {
    expect(loadOrSeedLastSeen(123)).toBe(123);
    expect(loadOrSeedLastSeen(999)).toBe(123);
    saveLastSeen(456);
    expect(loadOrSeedLastSeen(999)).toBe(456);
  });

  it("falls back to now when storage throws", () => {
    vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
      throw new Error("blocked");
    });
    vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
      throw new Error("blocked");
    });
    expect(loadOrSeedLastSeen(77)).toBe(77);
    expect(() => saveLastSeen(1)).not.toThrow();
  });
});

describe("SinceLastVisit", () => {
  const renderDigest = (runs: FleetRunSummary[], onMarkSeen = () => {}) =>
    render(
      <MemoryRouter>
        <SinceLastVisit digest={computeDigest(runs, SINCE, 1)} sinceSecs={SINCE} onMarkSeen={onMarkSeen} />
      </MemoryRouter>,
    );

  it("says nothing new and hides the acknowledge button when empty", () => {
    renderDigest([run("a", 500, "success")]);
    expect(screen.getByText("No runs finished since your last visit.")).toBeTruthy();
    expect(screen.queryByRole("button", { name: "Mark as seen" })).toBeNull();
  });

  it("lists failing and recovered routines with run links and acknowledges", () => {
    const onMarkSeen = vi.fn();
    renderDigest([run("ok", 3_000, "success"), run("bad", 2_500, "failed"), run("ok", 2_000, "failed")], onMarkSeen);
    expect(screen.getByText("2 failed")).toBeTruthy();
    expect(screen.getByText(/older runs not loaded/)).toBeTruthy();
    expect(screen.getByText("FAILING")).toBeTruthy();
    expect(screen.getByText("RECOVERED")).toBeTruthy();
    expect(screen.getByRole("link", { name: "BAD" }).getAttribute("href")).toBe("/routines?history=bad");
    const runLinks = screen.getAllByRole("link").filter((l) => l.getAttribute("href")?.startsWith("/runs/"));
    expect(runLinks.map((l) => l.getAttribute("href"))).toEqual(["/runs/bad/wb-bad-2500", "/runs/ok/wb-ok-2000"]);
    fireEvent.click(screen.getByRole("button", { name: "Mark as seen" }));
    expect(onMarkSeen).toHaveBeenCalledOnce();
  });

  it("shows counts without a table when only successes finished", () => {
    renderDigest([run("a", 2_000, "success"), run("b", 900, "success")]);
    expect(screen.getByText("0 failed")).toBeTruthy();
    expect(screen.queryByRole("table")).toBeNull();
  });
});