// @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("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
//