Files
multica/packages/views/issues/components/issues-page.test.tsx
Multica Eve 40f9ecdd56 MUL-5202: unify Issue Query across List, Board, and Swimlane (#5820)
* MUL-5202: migrate status issue surfaces to table query

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: unify grouped issue surfaces

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: cover move safety boundaries

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: preserve server swimlane semantics

Co-authored-by: multica-agent <github@multica.ai>

* MUL-5202: keep grouped surface facets exact

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-23 15:58:25 +08:00

830 lines
27 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@multica/core/types";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enIssues from "../../locales/en/issues.json";
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
// Mock @multica/core/auth
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
(selector?: any) => {
const state = { user: mockAuthUser, isAuthenticated: true };
return selector ? selector(state) : state;
},
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
),
registerAuthStore: vi.fn(),
createAuthStore: vi.fn(),
}));
// Mock @multica/core/paths — after the URL-driven workspace refactor,
// useCurrentWorkspace derives from the workspace slug in URL Context. Tests
// don't mount a real route, so we short-circuit to a fixed fixture.
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
useCurrentWorkspace: () => ({ id: "ws-1", name: "Test WS", slug: "test" }),
useWorkspacePaths: () => actual.paths.workspace("test"),
};
});
// Mock @multica/views/navigation (AppLink + useNavigation)
vi.mock("../../navigation", () => ({
AppLink: ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
useNavigation: () => ({ push: vi.fn(), pathname: "/issues" }),
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock workspace avatar
vi.mock("../../workspace/workspace-avatar", () => ({
WorkspaceAvatar: ({ name }: { name: string }) => <span data-testid="workspace-avatar">{name.charAt(0)}</span>,
}));
// Mock api (queries use api internally)
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
const mockListGroupedIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ groups: [] }));
const mockListIssueTableGroups = vi.hoisted(() =>
vi.fn(async (request: any) => {
if (request.group.kind !== "assignee") {
return {
query_fingerprint: "test",
total: 0,
groups: [],
next_cursor: null,
};
}
const response = await mockListGroupedIssues();
return {
query_fingerprint: "test",
total: response.groups.reduce(
(total: number, group: any) => total + group.total,
0,
),
groups: response.groups.map((group: any) => ({
key: group.id,
value: {
kind: "assignee" as const,
actor:
group.assignee_type && group.assignee_id
? { type: group.assignee_type, id: group.assignee_id }
: null,
},
count: group.total,
})),
next_cursor: null,
};
}),
);
const mockListIssueTableRows = vi.hoisted(() =>
vi.fn(async (request: any) => {
if (request.group.kind === "assignee") {
const response = await mockListGroupedIssues();
const group = response.groups.find(
(candidate: any) => candidate.id === request.group_key,
);
const issues = group?.issues ?? [];
return {
query_fingerprint: "test",
group_key: request.group_key,
parent_id: null,
total: 0,
rows: issues.map((issue: Issue) => ({
issue,
direct_child_count: 0,
})),
branch_total: issues.length,
next_cursor: null,
};
}
const status = request.group_key?.replace(/^status:/, "");
const response = await mockListIssues({
status,
limit: 50,
offset: 0,
...(request.query.scope.assignee_types
? { assignee_types: request.query.scope.assignee_types }
: {}),
});
return {
query_fingerprint: "test",
group_key: request.group_key,
parent_id: null,
total: 0,
rows: response.issues.map((issue: Issue) => ({
issue,
direct_child_count: 0,
})),
branch_total: response.issues.length,
next_cursor: null,
};
}),
);
const mockListIssueTableFacets = vi.hoisted(() =>
vi.fn(async (request: any) => {
const statuses = [
"backlog",
"todo",
"in_progress",
"in_review",
"done",
"blocked",
"cancelled",
];
const groups = await Promise.all(
statuses.map(async (status) => ({
status,
response: await mockListIssues({
status,
limit: 50,
offset: 0,
...(request.query.scope.assignee_types
? { assignee_types: request.query.scope.assignee_types }
: {}),
}),
})),
);
return {
query_fingerprint: "test",
total: groups.reduce((sum, group) => sum + group.response.issues.length, 0),
facets: request.facets.map((facet: any) => ({
...facet,
values:
facet.kind === "status"
? groups
.filter((group) => group.response.issues.length > 0)
.map((group) => ({
key: group.status,
count: group.response.issues.length,
}))
: [],
})),
};
}),
);
const mockListMembers = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@test.com",
avatar_url: null,
},
]),
);
const mockListAgents = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "agent-1",
workspace_id: "ws-1",
name: "Agent One",
description: "",
instructions: "",
status: "idle",
runtime_id: null,
owner_id: "user-1",
avatar_url: null,
visibility: "workspace",
archived_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
]),
);
const mockListSquads = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "squad-1",
workspace_id: "ws-1",
name: "Squad One",
description: "",
instructions: "",
avatar_url: null,
leader_id: "agent-1",
creator_id: "user-1",
archived_at: null,
archived_by: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
]),
);
vi.mock("@multica/core/api", () => ({
api: {
getBaseUrl: () => "http://127.0.0.1:8080",
listIssues: (...args: any[]) => mockListIssues(...args),
listGroupedIssues: (...args: any[]) => mockListGroupedIssues(...args),
listIssueTableGroups: (request: any) => mockListIssueTableGroups(request),
listIssueTableRows: (request: any) => mockListIssueTableRows(request),
listIssueTableFacets: (request: any) => mockListIssueTableFacets(request),
updateIssue: vi.fn(),
listMembers: (...args: any[]) => mockListMembers(...args),
listAgents: (...args: any[]) => mockListAgents(...args),
listSquads: (...args: any[]) => mockListSquads(...args),
},
getApi: () => ({
listIssues: (...args: any[]) => mockListIssues(...args),
listGroupedIssues: (...args: any[]) => mockListGroupedIssues(...args),
listIssueTableGroups: (request: any) => mockListIssueTableGroups(request),
listIssueTableRows: (request: any) => mockListIssueTableRows(request),
listIssueTableFacets: (request: any) => mockListIssueTableFacets(request),
updateIssue: vi.fn(),
listMembers: (...args: any[]) => mockListMembers(...args),
listAgents: (...args: any[]) => mockListAgents(...args),
listSquads: (...args: any[]) => mockListSquads(...args),
}),
setApiInstance: vi.fn(),
}));
// Mock issue config
vi.mock("@multica/core/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-info", hoverBg: "hover:bg-info/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
PRIORITY_CONFIG: {
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
},
}));
// Mock view store
const mockViewState = {
viewMode: "board" as "board" | "list",
grouping: "status" as "status" | "assignee",
statusFilters: [] as string[],
priorityFilters: [] as string[],
assigneeFilters: [] as { type: string; id: string }[],
includeNoAssignee: false,
creatorFilters: [] as { type: string; id: string }[],
projectFilters: [] as string[],
includeNoProject: false,
labelFilters: [] as string[],
propertyFilters: {} as Record<string, string[]>,
cardPropertyIds: [] as string[],
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
tableColumns: [
{ key: "title", width: 360 },
{ key: "status", width: 150 },
{ key: "priority", width: 130 },
{ key: "assignee", width: 180 },
{ key: "due_date", width: 140 },
{ key: "labels", width: 220 },
],
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
setGrouping: vi.fn(),
toggleStatusFilter: vi.fn(),
togglePriorityFilter: vi.fn(),
toggleAssigneeFilter: vi.fn(),
toggleNoAssignee: vi.fn(),
toggleCreatorFilter: vi.fn(),
toggleProjectFilter: vi.fn(),
toggleNoProject: vi.fn(),
toggleLabelFilter: vi.fn(),
togglePropertyFilter: vi.fn(),
toggleCardPropertyId: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),
setSortBy: vi.fn(),
setSortDirection: vi.fn(),
toggleCardProperty: vi.fn(),
toggleListCollapsed: vi.fn(),
};
vi.mock("@multica/core/issues/stores/view-store", () => ({
useClearFiltersOnWorkspaceChange: () => {},
PROPERTY_VIEW_PREFIX: "property:",
propertyIdFromViewKey: (key: string) =>
key.startsWith("property:") ? key.slice("property:".length) : null,
viewStorePersistOptions: () => ({ name: "test", storage: undefined, partialize: (s: any) => s }),
mergeViewStatePersisted: (_p: unknown, c: any) => c,
viewStoreSlice: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
),
createIssueViewStore: () => ({
getState: () => mockViewState,
setState: vi.fn(),
subscribe: vi.fn(),
}),
SORT_OPTIONS: [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
],
GROUPING_OPTIONS: [
{ value: "status", label: "Status" },
{ value: "assignee", label: "Assignee" },
],
CARD_PROPERTY_OPTIONS: [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
{ key: "project", label: "Project" },
{ key: "labels", label: "Labels" },
{ key: "childProgress", label: "Sub-issue progress" },
],
}));
vi.mock("@multica/core/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
let mockScope = "all";
vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({
useIssuesScopeStore: Object.assign(
(selector?: any) => {
const state = { scope: mockScope, setScope: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ scope: mockScope, setScope: vi.fn() }) },
),
}));
vi.mock("@multica/core/issues/stores/selection-store", () => ({
useIssueSelectionStore: Object.assign(
(selector?: any) => {
const state = { selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() };
return selector ? selector(state) : state;
},
{ getState: () => ({ selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() }) },
),
}));
vi.mock("@multica/core/issues/stores/recent-issues-store", () => ({
useRecentIssuesStore: Object.assign(
(selector?: any) => {
const state = { byWorkspace: {}, recordVisit: vi.fn(), pruneWorkspaces: vi.fn() };
return selector ? selector(state) : state;
},
{
getState: () => ({
byWorkspace: {},
recordVisit: vi.fn(),
pruneWorkspaces: vi.fn(),
}),
},
),
selectRecentIssues: () => () => [],
}));
vi.mock("@multica/core/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
{ getState: () => ({ open: vi.fn() }) },
),
}));
// Mock sonner toast
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Mock dnd-kit
vi.mock("@dnd-kit/core", () => {
// Real dnd-kit useDroppable returns a referentially stable setNodeRef
// (memoized internally). BoardColumn merges it with a state-setting
// callback ref for Virtuoso's customScrollParent, so a fresh function each
// render would loop the ref detach/reattach. Model the stable identity.
const stableSetNodeRef = () => {};
return {
DndContext: ({ children }: any) => children,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: stableSetNodeRef, isOver: false }),
pointerWithin: vi.fn(),
closestCenter: vi.fn(),
};
});
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: any) => children,
verticalListSortingStrategy: {},
arrayMove: vi.fn(),
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => undefined } },
}));
// Mock @base-ui/react/accordion (used by ListView)
vi.mock("@base-ui/react/accordion", () => ({
Accordion: Object.assign(
({ children }: any) => <div>{children}</div>,
{
Root: ({ children }: any) => <div>{children}</div>,
Item: ({ children }: any) => <div>{children}</div>,
Header: ({ children }: any) => <div>{children}</div>,
Trigger: ({ children }: any) => <button>{children}</button>,
Panel: ({ children }: any) => <div>{children}</div>,
},
),
}));
// Mock react-virtuoso: jsdom has no layout, so the real Virtuoso computes a
// 0-height viewport and renders nothing (and throws on its resize plumbing).
// Render every item inline so the virtualized board columns expose their
// cards to the DOM, matching the non-virtualized behavior these tests assert.
vi.mock("react-virtuoso", () => ({
Virtuoso: ({ data, itemContent, components }: any) => (
<div data-testid="virtuoso-mock">
{(data ?? []).map((item: any, i: number) => (
<div key={i}>{itemContent(i, item)}</div>
))}
{components?.Footer ? <components.Footer /> : null}
</div>
),
}));
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const issueDefaults = {
parent_issue_id: null,
project_id: null,
position: 0,
stage: null,
metadata: {},
properties: {},
};
const mockIssues: Issue[] = [
{
...issueDefaults,
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement auth",
description: "Add JWT authentication",
status: "todo",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-2",
workspace_id: "ws-1",
number: 2,
identifier: "TES-2",
title: "Design landing page",
description: null,
status: "in_progress",
priority: "medium",
assignee_type: "agent",
assignee_id: "agent-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: "2026-02-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-3",
workspace_id: "ws-1",
number: 3,
identifier: "TES-3",
title: "Write tests",
description: null,
status: "backlog",
priority: "low",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-4",
workspace_id: "ws-1",
number: 4,
identifier: "TES-4",
title: "Squad task",
description: null,
status: "todo",
priority: "medium",
assignee_type: "squad",
assignee_id: "squad-1",
creator_type: "member",
creator_id: "user-1",
start_date: null,
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function mockAssigneeGroups(issues: Issue[]) {
const groups = new Map<string, { assignee_type: Issue["assignee_type"]; assignee_id: string | null; issues: Issue[] }>();
for (const issue of issues) {
const id =
issue.assignee_type && issue.assignee_id
? `assignee:${issue.assignee_type}:${issue.assignee_id}`
: "assignee:unassigned";
if (!groups.has(id)) {
groups.set(id, {
assignee_type: issue.assignee_type,
assignee_id: issue.assignee_id,
issues: [],
});
}
groups.get(id)!.issues.push(issue);
}
return {
groups: [...groups.entries()].map(([id, group]) => ({
id,
assignee_type: group.assignee_type,
assignee_id: group.assignee_id,
issues: group.issues,
total: group.issues.length,
})),
};
}
// ---------------------------------------------------------------------------
// Import component under test (after mocks)
// ---------------------------------------------------------------------------
import { IssuesPage } from "./issues-page";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderWithQuery(ui: React.ReactElement) {
const qc = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={qc}>
{ui}
</QueryClientProvider>
</I18nProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("IssuesPage (shared)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"IntersectionObserver",
class {
private callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe(target: Element) {
this.callback(
[{ isIntersecting: true, target } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
root = null;
rootMargin = "0px";
thresholds = [0];
},
);
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockListGroupedIssues.mockResolvedValue({ groups: [] });
mockViewState.viewMode = "board";
mockViewState.grouping = "status";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
mockScope = "all";
});
it("shows loading skeletons initially", () => {
renderWithQuery(<IssuesPage />);
expect(
screen.getAllByRole("generic").some((el) => el.getAttribute("data-slot") === "skeleton"),
).toBe(true);
});
it("renders issue titles after data loads", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board column headers", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
await screen.findByText("Backlog");
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
});
it("groups board columns by assignee", async () => {
mockViewState.grouping = "assignee";
mockListGroupedIssues.mockResolvedValue(mockAssigneeGroups(mockIssues));
renderWithQuery(<IssuesPage />);
// "Test User" renders both as the assignee group header and on the
// assignee chip of each card grouped under that header, so a unique
// match is not guaranteed.
await screen.findAllByText("Test User");
expect(screen.getAllByText("Agent One").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Squad One").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("No assignee")).toBeInTheDocument();
});
it("uses table group/row branches instead of the legacy status sweep", async () => {
mockViewState.grouping = "assignee";
mockListGroupedIssues.mockResolvedValue(mockAssigneeGroups(mockIssues));
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(mockListIssueTableGroups).toHaveBeenCalledWith(
expect.objectContaining({
group: { kind: "assignee" },
page: { limit: 100, cursor: null },
}),
);
expect(mockListIssueTableRows).toHaveBeenCalledWith(
expect.objectContaining({
group: { kind: "assignee" },
page: { limit: 50, cursor: null },
}),
);
expect(mockListIssues).not.toHaveBeenCalled();
});
it("shows the 'Issues' section header without a workspace prefix", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
total: mockIssues.filter((i) => i.status === params?.status).length,
}),
);
renderWithQuery(<IssuesPage />);
await screen.findByText("Issues");
// The list header is now `icon + title`, matching the other list pages.
// The workspace/org name is no longer rendered as a breadcrumb prefix.
expect(screen.queryByText("Test WS")).not.toBeInTheDocument();
});
it("shows empty state when there are no issues", async () => {
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
renderWithQuery(<IssuesPage />);
await screen.findByText("No issues yet");
expect(screen.getByText("Create an issue to get started.")).toBeInTheDocument();
});
it("shows scope tab buttons", async () => {
renderWithQuery(<IssuesPage />);
expect(await screen.findAllByText("All")).not.toHaveLength(0);
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
// The Members/Agents tabs filter server-side via assignee_types (the same
// param the grouped endpoint takes), so the mock mirrors the server's
// WHERE clause instead of a client-side post-filter.
function mockListIssuesHonoringAssigneeTypes() {
mockListIssues.mockImplementation((params: any) => {
const matches = mockIssues.filter(
(i) =>
i.status === params?.status &&
(!params?.assignee_types ||
(i.assignee_type !== null &&
params.assignee_types.includes(i.assignee_type))),
);
return Promise.resolve({ issues: matches, total: matches.length });
});
}
it("agents scope includes squad-assigned issues", async () => {
mockScope = "agents";
mockViewState.viewMode = "list";
mockListIssuesHonoringAssigneeTypes();
renderWithQuery(<IssuesPage />);
// Squad task and agent task should be visible
await screen.findByText("Design landing page");
expect(screen.getByText("Squad task")).toBeInTheDocument();
// Member task should NOT be visible
expect(screen.queryByText("Implement auth")).not.toBeInTheDocument();
expect(mockListIssues).toHaveBeenCalledWith(
expect.objectContaining({ assignee_types: ["agent", "squad"] }),
);
});
it("members scope excludes squad-assigned issues", async () => {
mockScope = "members";
mockViewState.viewMode = "list";
mockListIssuesHonoringAssigneeTypes();
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(screen.queryByText("Squad task")).not.toBeInTheDocument();
expect(screen.queryByText("Design landing page")).not.toBeInTheDocument();
expect(mockListIssues).toHaveBeenCalledWith(
expect.objectContaining({ assignee_types: ["member"] }),
);
});
});