// @vitest-environment jsdom
import { act as reactAct } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import type { Issue } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueRow } from "./IssueRow";
import { StatusIcon } from "./StatusIcon";
vi.mock("@/lib/router", () => ({
Link: ({
children,
className,
disableIssueQuicklook: _disableIssueQuicklook,
issuePrefetch,
...props
}: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean; issuePrefetch?: Issue | null }) => (
{children}
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
if (typeof reactAct === "function") {
reactAct(callback);
return;
}
flushSync(callback);
}
function createIssue(overrides: Partial = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Inbox item",
description: null,
status: "todo",
priority: "medium",
reviewPolicy: null,
assigneeAgentId: null,
assigneeUserId: null,
responsibleUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1,
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,
isUnreadForMe: false,
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
describe("IssueRow", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
it("renders the list status glyph at md (16px)", () => {
const root = createRoot(container);
act(() => {
root.render();
});
// jsdom 30 does not value-match a CSS attribute selector against a
// mixed-case SVG attribute name, so `svg[viewBox="0 0 24 24"]` matches
// nothing. Select the glyph SVGs by attribute presence, then compare the
// viewBox value with getAttribute to keep the exact-value assertion.
const glyphs = Array.from(container.querySelectorAll("svg[viewBox]")).filter(
(svg) => svg.getAttribute("viewBox") === "0 0 24 24",
);
expect(glyphs.length).toBeGreaterThan(0);
glyphs.forEach((glyph) => {
expect(glyph.getAttribute("width")).toBe("16");
expect(glyph.getAttribute("height")).toBe("16");
});
act(() => {
root.unmount();
});
});
it("uses stable canonical identifier and timestamp columns at the trailing edge", () => {
const root = createRoot(container);
act(() => {
root.render(
Live}
actions={}
trailingMeta="Updated now"
/>,
);
});
const row = container.querySelector('[data-slot="task-row"]');
const leading = row?.querySelector('[data-slot="task-row-leading"]');
const title = row?.querySelector('[data-slot="task-row-title"]');
const metadata = row?.querySelector('[data-slot="task-row-metadata"]');
const identifier = row?.querySelector('[data-slot="task-row-identifier"]');
const timestamp = row?.querySelector('[data-slot="task-row-timestamp"]');
const actions = row?.querySelector('[data-slot="task-row-actions"]');
const link = row?.querySelector('[data-inbox-issue-link]');
expect(leading?.querySelector("svg")).not.toBeNull();
expect(title?.textContent).toContain("Canonical task");
expect(metadata?.textContent).toBe("Live");
expect(identifier?.textContent).toBe("PAP-42");
expect(timestamp?.textContent).toBe("Updated now");
expect(actions?.textContent).toBe("More");
expect(identifier?.className).toContain("w-20");
expect(timestamp?.className).toContain("w-24");
if (!link || !metadata || !identifier || !timestamp || !actions) throw new Error("Expected canonical task row slots");
expect(link.contains(actions)).toBe(false);
expect(metadata.compareDocumentPosition(actions) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(actions.compareDocumentPosition(identifier) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(identifier.compareDocumentPosition(timestamp) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(timestamp.nextElementSibling).toBeNull();
act(() => root.unmount());
});
it("keeps the canonical archive action within the shared task-row height", () => {
const root = createRoot(container);
act(() => {
root.render(
undefined}
/>,
);
});
const archiveButton = container.querySelector('button[aria-label="Archive"]');
expect(archiveButton?.className).toContain("h-5");
expect(archiveButton?.className).toContain("py-0");
expect(archiveButton?.className).not.toContain("py-1");
act(() => root.unmount());
});
it("preserves the legacy archive action density", () => {
const root = createRoot(container);
act(() => {
root.render( undefined} />);
});
const archiveButton = container.querySelector('button[aria-label="Archive"]');
expect(archiveButton?.className).toContain("py-1");
expect(archiveButton?.className).not.toContain("h-5");
act(() => root.unmount());
});
it("emphasizes unread canonical titles and overlays the accessible mark-read control", () => {
const root = createRoot(container);
const onMarkRead = vi.fn();
act(() => {
root.render(
,
);
});
const row = container.querySelector('[data-slot="task-row"]');
const title = row?.querySelector('[data-slot="task-row-title"]');
const unreadSlot = row?.querySelector('[data-testid="issue-row-unread-slot"]');
const markReadButton = unreadSlot?.querySelector('button[aria-label="Mark as read"]');
expect(row?.getAttribute("data-unread")).toBe("true");
expect(title?.className).toContain("font-semibold");
expect(unreadSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
expect(markReadButton).not.toBeNull();
expect(markReadButton?.closest("a")).toBeNull();
act(() => markReadButton?.click());
expect(onMarkRead).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
it("keeps read and unread rows aligned while allowing a smaller plain-row gutter", () => {
const root = createRoot(container);
act(() => {
root.render(
<>
>,
);
});
const rows = Array.from(container.querySelectorAll('[data-slot="task-row"]'));
const unreadSlot = rows[0]?.querySelector('[data-testid="issue-row-unread-slot"]');
expect(rows).toHaveLength(3);
expect(rows[0]?.className).toBe(rows[2]?.className);
expect(rows[1]?.className).toContain("pl-2 sm:pl-4");
expect(unreadSlot).not.toBeNull();
expect(unreadSlot?.className).toContain("absolute");
expect(unreadSlot?.querySelector('button[aria-label="Mark as read"]')).toBeNull();
expect(rows[1]?.querySelector('[data-testid="issue-row-unread-slot"]')).toBeNull();
act(() => root.unmount());
});
it("preserves task-tree indentation slots in the canonical layout", () => {
const root = createRoot(container);
act(() => {
root.render(
Expand}
/>,
);
});
expect(container.querySelectorAll('[data-slot="task-row-tree-guide"]')).toHaveLength(2);
expect(container.querySelector('[data-slot="task-row-leading"]')?.textContent).toContain("Expand");
for (const connector of container.querySelectorAll('[data-slot="task-row-tree-connector"]')) {
expect(connector.className).toContain("left-7");
}
act(() => root.unmount());
});
it("keeps editable row controls keyboard-accessible and outside the navigation link", () => {
const root = createRoot(container);
act(() => {
root.render(
undefined} />}
/>,
);
});
const link = container.querySelector("[data-inbox-issue-link]");
const statusButton = container.querySelector(
'button[aria-label="Change status (current: Todo)"]',
);
expect(link).not.toBeNull();
expect(statusButton).not.toBeNull();
expect(statusButton?.tabIndex).toBe(0);
expect(link?.contains(statusButton)).toBe(false);
act(() => {
root.unmount();
});
});
it("suppresses accent hover styling when the row is selected", () => {
const root = createRoot(container);
const issue = createIssue();
act(() => {
root.render();
});
// The hover wash lives on the ROOT row band (not the overlay link) so the
// tint paints behind the content. Selected rows suppress the accent hover.
const row = container.firstElementChild as HTMLElement | null;
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(row).not.toBeNull();
expect(row?.className).toContain("hover:bg-transparent");
expect(row?.className).not.toContain("hover:bg-accent/50");
// The overlay link no longer carries the hover wash.
expect(link?.className ?? "").not.toContain("hover:bg-transparent");
expect(link?.className ?? "").not.toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
it("neutralizes selected status and unread dot accents", () => {
const root = createRoot(container);
act(() => {
root.render();
});
const markReadButton = container.querySelector('button[aria-label="Mark as read"]');
const unreadDot = markReadButton?.querySelector("span");
// Selected rows neutralize the status glyph to muted via `!`-important
// utilities, which override the glyph's inline colour var. The glyph is an
//