/** * @file Sessions.urlState.test.tsx * @description The Sessions list keeps its filters in the URL: one filter * change is one load at page 0, "Waiting" asks the server (`awaiting`) instead * of downloading every active session, and a stale response never overwrites * a newer one. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; import { api } from "../../lib/api"; import { Sessions } from "../Sessions"; import type { Session } from "../../lib/types"; function GoPlain() { const nav = useNavigate(); return ; } function Where() { const loc = useLocation(); return {loc.search}; } const page = (sessions: Partial[], total = sessions.length) => ({ sessions: sessions as Session[], total, limit: 10, offset: 0, }); beforeEach(() => { vi.restoreAllMocks(); vi.spyOn(api.sessions, "facets").mockResolvedValue({ cwds: [] } as never); vi.spyOn(api.remoteSources, "list").mockResolvedValue({ sources: [] } as never); vi.spyOn(api.run, "list").mockResolvedValue({ items: [] }); }); describe("Sessions URL state", () => { it("one filter click = one load at page 0; Waiting asks the server", async () => { const list = vi.spyOn(api.sessions, "list").mockResolvedValue(page([])); render( ); await waitFor(() => expect(list).toHaveBeenCalled()); expect(list.mock.calls[list.mock.calls.length - 1]![0]).toMatchObject({ offset: 30 }); list.mockClear(); fireEvent.click(screen.getByRole("button", { name: "Waiting" })); await waitFor(() => expect(list).toHaveBeenCalledTimes(1)); expect(list.mock.calls[0]![0]).toMatchObject({ status: "active", awaiting: true, offset: 0, limit: 10, }); expect(screen.getByTestId("where").textContent).toBe("?status=waiting"); }); it("drops a response that arrives after a newer one", async () => { let resolveOld!: (v: ReturnType) => void; const list = vi .spyOn(api.sessions, "list") .mockImplementationOnce(() => new Promise((r) => (resolveOld = r))) .mockResolvedValue( page([ { id: "new-1", name: "Fresh", status: "completed", started_at: "2026-01-01T00:00:00Z" }, ]) ); render( ); await waitFor(() => expect(list).toHaveBeenCalledTimes(1)); fireEvent.click(screen.getByRole("button", { name: "Completed" })); expect(await screen.findByText("Fresh")).toBeInTheDocument(); await act(async () => { resolveOld( page([ { id: "old-1", name: "Stale", status: "completed", started_at: "2026-01-01T00:00:00Z" }, ]) ); }); expect(screen.queryByText("Stale")).not.toBeInTheDocument(); }); it("an outside navigation that clears ?q= clears the box, and the old q is not written back", async () => { vi.spyOn(api.sessions, "list").mockResolvedValue(page([])); render( ); expect(screen.getByRole("textbox", { name: /search sessions/i })).toHaveValue("abc"); fireEvent.click(screen.getByText("go-plain")); await waitFor(() => expect(screen.getByRole("textbox", { name: /search sessions/i })).toHaveValue("") ); await act(() => new Promise((r) => setTimeout(r, 400))); expect(screen.getByTestId("where").textContent).toBe(""); }); it("a page past the end goes back to page 1", async () => { vi.spyOn(api.sessions, "list").mockResolvedValue(page([], 5)); render( ); await waitFor(() => expect(screen.getByTestId("where").textContent).toBe("")); }); });