Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion apps/loopover-miner-ui/src/portfolio-queue-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,18 @@ const doneItem: PortfolioQueueActionItem = {
status: "done",
};

function manyActionItems(count: number): PortfolioQueueActionItem[] {
return Array.from({ length: count }, (_, index) => ({
apiBaseUrl: "https://github.com/ghapi",
repoFullName: `acme/repo-${String(index).padStart(2, "0")}`,
identifier: `issue:${index}`,
// Alternating status so the default sort (in_progress first) still leaves enough rows for page 2.
status: index % 2 === 0 ? ("in_progress" as const) : ("done" as const),
}));
}

describe("PortfolioQueueActionsSection (#4857)", () => {
it("renders a content-shaped skeleton before the first result arrives", () => {
it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6511, #6831)", () => {
// #6511: StateBoundary renders the skeleton INSTEAD of a loading title, so the old
// "Loading actionable queue items…" text is intentionally gone; assert the placeholder instead.
render(
Expand All @@ -54,7 +64,9 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
onRequeue={() => undefined}
/>,
);
expect(screen.getByRole("status", { name: /loading actionable queue items/i })).toBeTruthy();
expect(screen.getByTestId("queue-actions-skeleton")).toBeTruthy();
expect(screen.queryByText("Loading actionable queue items…")).toBeNull();
// Shaped like the real content, not one generic bar: the real table is not rendered yet.
expect(screen.queryByRole("table")).toBeNull();
});
Expand Down Expand Up @@ -108,6 +120,65 @@ describe("PortfolioQueueActionsSection (#4857)", () => {
expect(onRequeue).toHaveBeenCalledWith(doneItem);
});

it("does not paginate the queue-actions table at or below 20 rows (#6831)", () => {
const items = manyActionItems(20);
render(
<PortfolioQueueActionsSection
result={{ ok: true, items }}
actionResult={null}
pending={false}
onRelease={() => undefined}
onRequeue={() => undefined}
/>,
);
expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull();
expect(screen.getByText("acme/repo-00")).toBeTruthy();
expect(screen.getByText("acme/repo-19")).toBeTruthy();
});

it("paginates the queue-actions table client-side above 20 rows (#6831)", () => {
const items = manyActionItems(45);
render(
<PortfolioQueueActionsSection
result={{ ok: true, items }}
actionResult={null}
pending={false}
onRelease={() => undefined}
onRequeue={() => undefined}
/>,
);
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
// Sorted in_progress first (even indices by name): page 1 ends at repo-38; repo-40 is the 21st.
expect(screen.getByText("acme/repo-00")).toBeTruthy();
expect(screen.queryByText("acme/repo-40")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "2" }));
expect(screen.getByText("acme/repo-40")).toBeTruthy();
expect(screen.queryByText("acme/repo-00")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
expect(screen.getByText("acme/repo-00")).toBeTruthy();
fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
expect(screen.getByText("acme/repo-40")).toBeTruthy();
});

it("breaks same-status/same-repo ties by identifier ascending (#6831)", () => {
const sameRepoDone: PortfolioQueueActionItem[] = [
{ ...doneItem, identifier: "issue:20" },
{ ...doneItem, identifier: "issue:7" },
];
render(
<PortfolioQueueActionsSection
result={{ ok: true, items: sameRepoDone }}
actionResult={null}
pending={false}
onRelease={() => undefined}
onRequeue={() => undefined}
/>,
);
const rows = screen.getAllByRole("row");
expect(rows[1]?.textContent).toContain("issue:20");
expect(rows[2]?.textContent).toContain("issue:7");
});

it("disables action buttons while an action is pending", () => {
render(
<PortfolioQueueActionsSection
Expand Down Expand Up @@ -159,6 +230,24 @@ describe("PortfolioPage queue actions (#4857)", () => {
await waitFor(() => expect(releaseItem).toHaveBeenCalledWith(inProgressItem));
});

it("wires requeue through the injected action for done rows (#6831)", async () => {
const requeueItem = vi.fn(async () => ({
ok: true as const,
entry: { repoFullName: "acme/widgets", identifier: "issue:7", status: "queued" },
}));
render(
<PortfolioPage
loadPortfolioQueue={loadPortfolioQueue}
loadPortfolioQueueItems={async () => ({ ok: true as const, items: [doneItem] })}
requeueItem={requeueItem}
pollIntervalMs={60_000}
/>,
);
await waitFor(() => expect(screen.getByRole("button", { name: "Requeue" })).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Requeue" }));
await waitFor(() => expect(requeueItem).toHaveBeenCalledWith(doneItem));
});

it("REGRESSION (#6090): a failing release action renders the error and does not re-fetch items as if it succeeded", async () => {
const loadPortfolioQueueItems = vi.fn(async () => ({ ok: true as const, items: [inProgressItem] }));
const releaseItem = vi.fn(async (): Promise<PortfolioQueueActionResult> => ({
Expand Down
76 changes: 74 additions & 2 deletions apps/loopover-miner-ui/src/portfolio-queue.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import {
fetchPortfolioQueue,
PORTFOLIO_QUEUE_API_PATH,
type PortfolioQueueResult,
type PortfolioQueueSummary,
type PortfolioRepoSummary,
} from "./lib/portfolio-queue";
import { PortfolioPage, PortfolioQueueView } from "./routes/portfolio";
import { handlePortfolioQueueRequest, type PortfolioQueueApiDeps } from "../vite-portfolio-queue-api";
Expand Down Expand Up @@ -70,6 +71,15 @@ describe("emptyPortfolioQueueSummary (#4306)", () => {
});
});

function manyRepos(count: number): PortfolioRepoSummary[] {
// Descending totals so the default sort (total desc) puts repo-00 first — mirrors ledgers' manyEventTypes.
return Array.from({ length: count }, (_, index) => ({
repoFullName: `acme/repo-${String(index).padStart(2, "0")}`,
byStatus: { queued: count - index, in_progress: 0, done: 0 },
total: count - index,
}));
}

describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => {
it("renders one card per status with the aggregated global counts", () => {
render(<PortfolioQueueView result={{ ok: true, summary: fixtureSummary }} />);
Expand All @@ -88,6 +98,65 @@ describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => {
expect(screen.getAllByRole("row")).toHaveLength(3);
});

it("renders a queue-by-status chart via the ui-kit ChartContainer (#6831)", () => {
render(<PortfolioQueueView result={{ ok: true, summary: fixtureSummary }} />);
expect(screen.getByLabelText("Queue by status chart")).toBeTruthy();
});

it("does not paginate the per-repo table at or below 20 rows (#6831)", () => {
const repos = manyRepos(20);
const summary: PortfolioQueueSummary = {
total: 20,
byStatus: { queued: 20, in_progress: 0, done: 0 },
repos,
oldestQueuedAgeMs: null,
};
render(<PortfolioQueueView result={{ ok: true, summary }} />);
expect(screen.queryByRole("navigation", { name: /pagination/i })).toBeNull();
expect(screen.getByText("acme/repo-00")).toBeTruthy();
expect(screen.getByText("acme/repo-19")).toBeTruthy();
});

it("paginates the per-repo table client-side above 20 rows, sorted by total desc (#6831)", () => {
const repos = manyRepos(45);
const summary: PortfolioQueueSummary = {
total: 45,
byStatus: { queued: 45, in_progress: 0, done: 0 },
repos,
oldestQueuedAgeMs: null,
};
render(<PortfolioQueueView result={{ ok: true, summary }} />);
expect(screen.getByRole("navigation", { name: /pagination/i })).toBeTruthy();
// Highest total first: repo-00 has total 45, repo-20 has total 25 — only the first page is visible.
expect(screen.getByText("acme/repo-00")).toBeTruthy();
expect(screen.queryByText("acme/repo-20")).toBeNull();
fireEvent.click(screen.getByRole("link", { name: "2" }));
expect(screen.getByText("acme/repo-20")).toBeTruthy();
expect(screen.queryByText("acme/repo-00")).toBeNull();
// Previous / Next buttons also advance the page (covers both onClick arms).
fireEvent.click(screen.getByRole("link", { name: /go to previous page/i }));
expect(screen.getByText("acme/repo-00")).toBeTruthy();
fireEvent.click(screen.getByRole("link", { name: /go to next page/i }));
expect(screen.getByText("acme/repo-20")).toBeTruthy();
});

it("breaks equal-total ties by repo name ascending (#6831)", () => {
const summary: PortfolioQueueSummary = {
total: 4,
byStatus: { queued: 4, in_progress: 0, done: 0 },
repos: [
{ repoFullName: "acme/zeta", byStatus: { queued: 2, in_progress: 0, done: 0 }, total: 2 },
{ repoFullName: "acme/alpha", byStatus: { queued: 2, in_progress: 0, done: 0 }, total: 2 },
],
oldestQueuedAgeMs: null,
};
render(<PortfolioQueueView result={{ ok: true, summary }} />);
const rows = screen.getAllByRole("row");
// header + alpha then zeta
expect(rows[1]?.textContent).toContain("acme/alpha");
expect(rows[2]?.textContent).toContain("acme/zeta");
});

it("renders the fresh-install empty state without erroring", () => {
render(<PortfolioQueueView result={{ ok: true, summary: emptyPortfolioQueueSummary() }} />);
// #6511: asserted as the exact sentence, not a loose regex -- the whole original string is the EmptyState
Expand All @@ -105,11 +174,14 @@ describe("PortfolioQueueView (#4306, per-repo detail added by #4846)", () => {
expect(screen.getByRole("alert").textContent).toContain("connection refused");
});

it("renders a content-shaped skeleton before the first result arrives", () => {
it("renders a content-shaped loading skeleton (role=status), not the old flat loading text (#6511, #6831)", () => {
// #6511: StateBoundary renders the skeleton INSTEAD of a loading title, so the old
// "Loading local portfolio queue…" text is intentionally gone; assert the placeholder instead.
// #6831: skeleton also announces via role=status (matching ledgers/run-history) and includes a chart-shaped bar.
render(<PortfolioQueueView result={null} />);
expect(screen.getByRole("status", { name: /loading local portfolio queue/i })).toBeTruthy();
expect(screen.getByTestId("portfolio-queue-skeleton")).toBeTruthy();
expect(screen.queryByText("Loading local portfolio queue…")).toBeNull();
// Shaped like the real content, not one generic bar: the real table is not rendered yet.
expect(screen.queryByRole("table")).toBeNull();
});
Expand Down
Loading
Loading