paperclip/ui/src/pages/Inbox.test.tsx

903 lines
28 KiB
TypeScript

// @vitest-environment jsdom
import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CompanyJoinRequest } from "../api/access";
const routerMock = vi.hoisted(() => ({
location: { pathname: "/", search: "", hash: "" },
navigate: vi.fn(),
}));
const apiMocks = vi.hoisted(() => ({
approvalsList: vi.fn(),
joinRequestsList: vi.fn(),
userDirectoryList: vi.fn(),
authSession: vi.fn(),
dashboardSummary: vi.fn(),
executionWorkspaceSummaries: vi.fn(),
issuesList: vi.fn(),
issuesCount: vi.fn(),
issueLabels: vi.fn(),
archiveFromInbox: vi.fn(),
unarchiveFromInbox: vi.fn(),
agentsList: vi.fn(),
heartbeatRunsList: vi.fn(),
liveRunsForCompany: vi.fn(),
experimentalSettings: vi.fn(),
projectsList: vi.fn(),
}));
vi.mock("../api/approvals", () => ({
approvalsApi: { list: apiMocks.approvalsList },
}));
vi.mock("../api/access", async () => {
const actual = await vi.importActual<typeof import("../api/access")>("../api/access");
return {
...actual,
accessApi: {
listJoinRequests: apiMocks.joinRequestsList,
listUserDirectory: apiMocks.userDirectoryList,
},
};
});
vi.mock("../api/auth", () => ({
authApi: { getSession: apiMocks.authSession },
}));
vi.mock("../api/dashboard", () => ({
dashboardApi: { summary: apiMocks.dashboardSummary },
}));
vi.mock("../api/execution-workspaces", () => ({
executionWorkspacesApi: { listSummaries: apiMocks.executionWorkspaceSummaries },
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: apiMocks.issuesList,
listCompact: apiMocks.issuesList,
count: apiMocks.issuesCount,
listLabels: apiMocks.issueLabels,
markRead: vi.fn(),
markUnread: vi.fn(),
archiveFromInbox: apiMocks.archiveFromInbox,
unarchiveFromInbox: apiMocks.unarchiveFromInbox,
},
}));
vi.mock("../api/agents", () => ({
agentsApi: { list: apiMocks.agentsList },
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: {
list: apiMocks.heartbeatRunsList,
liveRunsForCompany: apiMocks.liveRunsForCompany,
},
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: { getExperimental: apiMocks.experimentalSettings },
}));
vi.mock("../api/projects", () => ({
projectsApi: { list: apiMocks.projectsList },
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewIssue: vi.fn() }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
const generalSettingsMock = { keyboardShortcutsEnabled: false };
vi.mock("../context/GeneralSettingsContext", () => ({
useGeneralSettings: () => generalSettingsMock,
}));
vi.mock("../hooks/useInboxBadge", () => ({
useDismissedInboxAlerts: () => ({ dismissed: new Set(), dismiss: vi.fn() }),
useInboxDismissals: () => ({ dismissedAtByKey: new Map(), dismiss: vi.fn() }),
useReadInboxItems: () => ({
readItems: new Set(),
markRead: vi.fn(),
markUnread: vi.fn(),
}),
}));
import {
FailedRunInboxRow,
Inbox,
InboxGroupHeader,
InboxIssueMetaLeading,
InboxIssueTrailingColumns,
formatJoinRequestInboxLabel,
} from "./Inbox";
vi.mock("@/lib/router", () => ({
Link: ({ children, className, ...props }: ComponentProps<"a">) => (
<a className={className} {...props}>{children}</a>
),
useLocation: () => routerMock.location,
useNavigate: () => routerMock.navigate,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect.
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-904",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 904,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
}
function createJoinRequest(
overrides: Partial<CompanyJoinRequest> = {},
): CompanyJoinRequest {
return {
id: "join-1",
inviteId: "invite-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
requestIp: "127.0.0.1",
requestingUserId: "user-1",
requestEmailSnapshot: "joiner@example.com",
agentName: null,
adapterType: null,
capabilities: null,
agentDefaultsPayload: null,
claimSecretExpiresAt: null,
claimSecretConsumedAt: null,
createdAgentId: null,
approvedByUserId: null,
approvedAt: null,
rejectedByUserId: null,
rejectedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
requesterUser: {
id: "user-1",
name: "Jordan Example",
email: "joiner@example.com",
image: null,
},
approvedByUser: null,
rejectedByUser: null,
invite: null,
...overrides,
};
}
function resetInboxApiMocks() {
for (const mock of Object.values(apiMocks)) mock.mockReset();
routerMock.location.pathname = "/";
routerMock.location.search = "";
routerMock.location.hash = "";
routerMock.navigate.mockReset();
apiMocks.approvalsList.mockResolvedValue([]);
apiMocks.joinRequestsList.mockResolvedValue([]);
apiMocks.userDirectoryList.mockResolvedValue({ users: [] });
apiMocks.authSession.mockResolvedValue({
user: { id: "local-board" },
session: { userId: "local-board" },
});
apiMocks.dashboardSummary.mockResolvedValue({
agents: { error: 0 },
costs: { monthBudgetCents: 0, monthUtilizationPercent: 0 },
});
apiMocks.executionWorkspaceSummaries.mockResolvedValue([]);
apiMocks.issuesList.mockResolvedValue([]);
apiMocks.issuesCount.mockResolvedValue({ count: 0 });
apiMocks.issueLabels.mockResolvedValue([]);
apiMocks.archiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() });
apiMocks.unarchiveFromInbox.mockResolvedValue({ id: "issue-1", archivedAt: new Date() });
apiMocks.agentsList.mockResolvedValue([]);
apiMocks.heartbeatRunsList.mockResolvedValue([]);
apiMocks.liveRunsForCompany.mockResolvedValue([]);
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
apiMocks.projectsList.mockResolvedValue([]);
}
describe("Inbox toolbar", () => {
let container: HTMLDivElement;
beforeEach(() => {
resetInboxApiMocks();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("shows blocked toolbar controls on the Blocked tab", async () => {
routerMock.location.pathname = "/inbox/blocked";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
expect(container.querySelector('input[placeholder="Search inbox…"]')).not.toBeNull();
expect(container.querySelector('[data-testid="inbox-blocked-tab-badge"]')).toBeNull();
expect(container.querySelector('button[title="Filter"]')).not.toBeNull();
expect(container.querySelector('button[title="Group"]')).not.toBeNull();
expect(container.querySelector('button[title="Columns"]')).not.toBeNull();
expect(container.querySelector('button[title="Sort"]')).not.toBeNull();
expect(container.querySelector('button[title="Enable parent-child nesting"]')).toBeNull();
expect(container.textContent).not.toContain("Mark all as read");
act(() => {
root.unmount();
});
});
it("hides workspace grouping when isolated workspaces are disabled", async () => {
routerMock.location.pathname = "/inbox/mine";
apiMocks.experimentalSettings.mockResolvedValue({ enableIsolatedWorkspaces: false });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
const groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
expect(groupButton).not.toBeNull();
await act(async () => {
groupButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const groupOptions = Array.from(document.body.querySelectorAll("button")).map((button) => button.textContent);
expect(groupOptions).not.toContain("Workspace");
act(() => {
root.unmount();
});
});
it("requests live descendant summaries for issue rows", async () => {
routerMock.location.pathname = "/inbox/mine";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(apiMocks.issuesList).toHaveBeenCalledTimes(3);
});
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([
true,
true,
true,
]);
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.limit)).toEqual([
500,
500,
500,
]);
act(() => {
root.unmount();
});
});
it("paints row hover via CSS only, without moving React selection state", async () => {
routerMock.location.pathname = "/inbox/mine";
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(2);
});
const rows = container.querySelectorAll("[data-inbox-item]");
const linkOf = (row: Element): HTMLAnchorElement | null =>
row.querySelector("a[data-inbox-issue-link]");
// Nothing selected before hover — both rows show the hover-accent class.
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
// Hovering paints via CSS `:hover` only — it must NOT flip a row into the
// state-selected band (which would swap to hover:bg-transparent). Coupling
// hover to React state was the per-hover re-render storm behind the lag;
// scrubbing the list must not touch selection state. (Keyboard nav that
// continues from the hovered row is exercised in live/e2e verification —
// this unit mocks keyboardShortcutsEnabled off.)
await act(async () => {
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
});
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
expect(linkOf(rows[1]!)?.className).not.toContain("hover:bg-transparent");
act(() => {
root.unmount();
});
});
it("does not double-indent unread rows: the mark-read dot replaces the leading spacer", async () => {
routerMock.location.pathname = "/inbox/mine";
// Two sibling leaf rows, one unread and one read, so their leading columns
// are directly comparable.
const unread = createIssue({
id: "issue-unread",
identifier: "PAP-2001",
title: "Unread inbox row",
isUnreadForMe: true,
});
const read = createIssue({
id: "issue-read",
identifier: "PAP-2002",
title: "Read inbox row",
isUnreadForMe: false,
});
apiMocks.issuesList.mockResolvedValue([unread, read]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Unread inbox row");
expect(container.textContent).toContain("Read inbox row");
});
const rows = Array.from(container.querySelectorAll("[data-inbox-item]"));
const rowFor = (text: string) => rows.find((row) => row.textContent?.includes(text));
const linkOf = (row: Element) => row.querySelector<HTMLAnchorElement>("a[data-inbox-issue-link]");
const hasMarkReadDot = (row: Element) => !!row.querySelector('button[aria-label="Mark as read"]');
// The empty spacer that reserves the chevron column on read rows. Excludes
// the tree-guide span (`.self-stretch`), which only renders on nested rows.
const hasLeadingSpacer = (row: Element) =>
!!linkOf(row)?.querySelector("span.hidden.w-4.shrink-0.sm\\:block:not(.self-stretch)");
const unreadRow = rowFor("Unread inbox row")!;
const readRow = rowFor("Read inbox row")!;
// Unread rows carry the mark-read dot in the chevron column; rendering the
// spacer too would push the status icon + title one column further right
// than read rows (the bug this fix addresses).
expect(hasMarkReadDot(unreadRow)).toBe(true);
expect(hasLeadingSpacer(unreadRow)).toBe(false);
// Read rows have no dot, so they keep the spacer to hold that same column.
expect(hasMarkReadDot(readRow)).toBe(false);
expect(hasLeadingSpacer(readRow)).toBe(true);
act(() => {
root.unmount();
});
});
it("keeps hover→j/k selection in sync after the list reshapes (PAP-9679)", async () => {
routerMock.location.pathname = "/inbox/mine";
generalSettingsMock.keyboardShortcutsEnabled = true;
const issueA = createIssue({ id: "issue-a", identifier: "PAP-2001", title: "Sync row A" });
const issueB = createIssue({ id: "issue-b", identifier: "PAP-2002", title: "Sync row B" });
const issueC = createIssue({ id: "issue-c", identifier: "PAP-2003", title: "Sync row C" });
apiMocks.issuesList.mockResolvedValue([issueA, issueB, issueC]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
const linkOf = (row: Element): HTMLAnchorElement | null =>
row.querySelector("a[data-inbox-issue-link]");
// The keyboard-selected row swaps to `hover:bg-transparent`; find its index.
const selectedRowIndex = () =>
[...container.querySelectorAll("[data-inbox-item]")].findIndex((row) =>
linkOf(row)?.className.includes("hover:bg-transparent"),
);
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(3);
});
// Pointer physically moves, then hovers the middle row (index 1).
await act(async () => {
window.dispatchEvent(new MouseEvent("mousemove", { bubbles: true }));
const rows = container.querySelectorAll("[data-inbox-item]");
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
});
// A poll reshapes the list (row B's title changes → new nav array) before
// the keypress. This is what used to null the hovered index and strand
// j/k back at the top.
apiMocks.issuesList.mockResolvedValue([issueA, { ...issueB, title: "Sync row B (updated)" }, issueC]);
await act(async () => {
await queryClient.invalidateQueries();
});
await vi.waitFor(() => {
expect(container.textContent).toContain("Sync row B (updated)");
});
// j must continue from the hovered row (index 1) → index 2, not jump to
// the top of the list.
await act(async () => {
document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "j", bubbles: true }));
});
expect(selectedRowIndex()).toBe(2);
} finally {
generalSettingsMock.keyboardShortcutsEnabled = false;
act(() => {
root.unmount();
});
}
});
it("keeps other issue archive controls enabled while one archive is pending", async () => {
routerMock.location.pathname = "/inbox/mine";
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
const archiveA = createDeferred<{ id: string; archivedAt: Date }>();
apiMocks.archiveFromInbox.mockImplementation((id: string) =>
id === "issue-a" ? archiveA.promise : Promise.resolve({ id, archivedAt: new Date() }),
);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain("First inbox row");
expect(container.textContent).toContain("Second inbox row");
});
const initialArchiveButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Archive"]'),
);
expect(initialArchiveButtons.length).toBeGreaterThanOrEqual(2);
await act(async () => {
initialArchiveButtons[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await vi.waitFor(() => {
expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-a");
expect(container.textContent).not.toContain("First inbox row");
expect(container.textContent).toContain("Second inbox row");
});
const remainingArchiveButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Archive"]',
);
expect(remainingArchiveButton).not.toBeNull();
expect(remainingArchiveButton?.disabled).toBe(false);
await act(async () => {
remainingArchiveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await vi.waitFor(() => {
expect(apiMocks.archiveFromInbox).toHaveBeenCalledWith("issue-b");
});
await act(async () => {
archiveA.resolve({ id: "issue-a", archivedAt: new Date() });
});
act(() => {
root.unmount();
});
});
});
describe("FailedRunInboxRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("suppresses accent hover styling when selected", () => {
const root = createRoot(container);
const run = {
id: "run-1",
companyId: "company-1",
agentId: "agent-1",
responsibleUserId: null,
invocationSource: "assignment",
triggerDetail: null,
status: "failed",
error: "boom",
wakeupRequestId: null,
exitCode: null,
signal: null,
usageJson: null,
resultJson: null,
sessionIdBefore: null,
sessionIdAfter: null,
logStore: null,
logRef: null,
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
errorCode: null,
externalRunId: null,
processPid: null,
processGroupId: null,
processStartedAt: null,
retryOfRunId: null,
processLossRetryCount: 0,
livenessState: null,
livenessReason: null,
continuationAttempt: 0,
lastUsefulActionAt: null,
nextAction: null,
stdoutExcerpt: null,
stderrExcerpt: null,
contextSnapshot: null,
startedAt: new Date("2026-03-11T00:00:00.000Z"),
finishedAt: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
} as const;
act(() => {
root.render(
<FailedRunInboxRow
run={run}
issueById={new Map()}
agentName="Agent"
issueLinkState={null}
onDismiss={() => {}}
onRetry={() => {}}
isRetrying={false}
selected
/>,
);
});
const link = container.querySelector("a");
expect(link).not.toBeNull();
expect(link?.className).toContain("hover:bg-transparent");
expect(link?.className).not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
});
describe("InboxIssueMetaLeading", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("keeps status and live accents visible", () => {
const root = createRoot(container);
act(() => {
root.render(<InboxIssueMetaLeading issue={createIssue()} isLive />);
});
// The status glyph is an <svg> coloured from its --status-task-icon-* var.
const statusIcon = Array.from(container.querySelectorAll("svg")).find((svg) =>
(svg.getAttribute("style") ?? "").includes("--status-task-icon"),
);
const liveBadge = container.querySelector('span[class*="px-1.5"][class*="bg-blue-500/10"]');
const liveBadgeLabel = Array.from(container.querySelectorAll("span")).find(
// The pill chassis is a Badge (itself a span with textContent "Live");
// the label is the inner span without the rounded-full chassis class.
(node) => node.textContent === "Live" && node.className.includes("text-") && !node.className.includes("rounded-full"),
);
const liveDot = container.querySelector('span[class*="bg-blue-500"]');
const pulseRing = container.querySelector('span[class*="animate-pulse"]');
expect(statusIcon).not.toBeUndefined();
// Status accent stays visible — not neutralized to muted.
expect(statusIcon?.getAttribute("class") ?? "").not.toContain("!text-muted-foreground");
expect(liveBadge).not.toBeNull();
expect(liveBadge?.className).toContain("bg-blue-500/10");
expect(liveBadgeLabel).not.toBeNull();
expect(liveBadgeLabel?.className).toContain("text-blue-600");
expect(liveDot).not.toBeNull();
expect(pulseRing).not.toBeNull();
act(() => {
root.unmount();
});
});
});
describe("InboxIssueTrailingColumns", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders an empty tags cell when an issue has no labels", () => {
const root = createRoot(container);
act(() => {
root.render(
<InboxIssueTrailingColumns
issue={createIssue({ labels: [], labelIds: [] })}
columns={["labels"]}
projectName={null}
projectColor={null}
workspaceName={null}
assigneeName={null}
currentUserId={null}
parentIdentifier={null}
parentTitle={null}
/>,
);
});
expect(container.textContent).toBe("");
act(() => {
root.unmount();
});
});
it("leaves the workspace cell blank when no explicit workspace label should be shown", () => {
const root = createRoot(container);
act(() => {
root.render(
<InboxIssueTrailingColumns
issue={createIssue()}
columns={["workspace"]}
projectName={null}
projectColor={null}
workspaceName={null}
assigneeName={null}
currentUserId={null}
parentIdentifier={null}
parentTitle={null}
/>,
);
});
expect(container.textContent).toBe("");
act(() => {
root.unmount();
});
});
});
describe("formatJoinRequestInboxLabel", () => {
it("shows the human requester's name and email when available", () => {
expect(formatJoinRequestInboxLabel(createJoinRequest())).toBe(
"Jordan Example (joiner@example.com)",
);
});
it("falls back to the email snapshot when the requester profile is missing", () => {
expect(
formatJoinRequestInboxLabel(
createJoinRequest({
requesterUser: null,
requestEmailSnapshot: "snapshot@example.com",
requestingUserId: null,
}),
),
).toBe("snapshot@example.com");
});
});
describe("InboxGroupHeader", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("shows a left caret and expanded state for collapsible mobile headers", () => {
const root = createRoot(container);
act(() => {
root.render(<InboxGroupHeader label="Primary workspace (default)" collapsible collapsed={false} />);
});
const button = container.querySelector("button");
expect(button).not.toBeNull();
expect(button?.getAttribute("aria-expanded")).toBe("true");
expect(button?.textContent).toContain("Primary workspace (default)");
const caret = container.querySelector("svg");
expect(caret?.className.baseVal).toContain("rotate-90");
act(() => {
root.unmount();
});
});
it("keeps the caret collapsed when the mobile group is hidden", () => {
const root = createRoot(container);
act(() => {
root.render(<InboxGroupHeader label="Feature Branch" collapsible collapsed />);
});
const button = container.querySelector("button");
expect(button?.getAttribute("aria-expanded")).toBe("false");
const caret = container.querySelector("svg");
expect(caret?.className.baseVal).not.toContain("rotate-90");
act(() => {
root.unmount();
});
});
});