1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { EnvironmentId, ProjectId, type PullRequestDetailView } from "@t3tools/contracts";
3import { act, type ReactNode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
8vi.mock("~/state/pullRequests", () => ({ pullRequestEnvironment: {} }));
9vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => vi.fn() }));
10vi.mock("./PullRequestMarkdown", () => ({
11 PullRequestMarkdown: ({ text }: { text: string }) => <p>{text}</p>,
12}));
13vi.mock("../ui/menu", () => ({
14 Menu: "div",
15 MenuCheckboxItem: "div",
16 MenuGroup: "div",
17 MenuGroupLabel: "div",
18 MenuPopup: "div",
19 MenuTrigger: "div",
20}));
21vi.mock("../ui/tooltip", () => ({
22 Tooltip: ({ children }: { children: ReactNode }) => children,
23 TooltipTrigger: ({ children }: { children: ReactNode }) => children,
24 TooltipPopup: () => null,
25}));
26
27import { PullRequestSummaryTab } from "./PullRequestSummaryTab";
28
29const detail: PullRequestDetailView = {
30 provider: SourceControlProviderKind.make("github"),
31 projectId: ProjectId.make("project"),
32 projectTitle: "Project",
33 workspaceRoot: "/workspace",
34 repository: "owner/repo",
35 number: 1,
36 title: "Test pull request",
37 body: "Original description",
38 url: "https://github.com/owner/repo/pull/1",
39 author: { login: "author", name: null, avatarUrl: null },
40 viewer: "author",
41 state: "open",
42 isDraft: false,
43 mergeability: "mergeable",
44 additions: 1,
45 deletions: 0,
46 changedFiles: 1,
47 headBranch: "feature",
48 baseBranch: "main",
49 createdAt: "2026-09-01T00:00:00Z",
50 updatedAt: "2026-09-01T00:00:00Z",
51 mergedAt: null,
52 closedAt: null,
53 reviewers: [],
54 labels: [],
55 checks: [{ name: "Unit tests", status: "success", description: null, url: null }],
56 comments: [],
57 commentCount: 0,
58 commentsTruncated: false,
59 reviewThreads: [],
60 commits: [],
61 mergeCapabilities: { merge: false, squash: false, rebase: false },
62 capabilities: {
63 diff: true,
64 comment: true,
65 search: true,
66 actions: [],
67 mergeMethods: [],
68 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
69 reviewers: { request: false, listCandidates: false },
70 edit: { changeRequest: true, comment: true },
71 },
72 viewerPermissions: {
73 actions: [],
74 comment: true,
75 resolve: false,
76 verdicts: [],
77 requestReviewers: false,
78 },
79};
80
81let renderer: ReactTestRenderer;
82beforeEach(() => {
83 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
84 const store = new Map<string, string>();
85 vi.stubGlobal(
86 "window",
87 Object.assign(new EventTarget(), {
88 localStorage: {
89 getItem: (key: string) => store.get(key) ?? null,
90 setItem: (key: string, value: string) => store.set(key, value),
91 removeItem: (key: string) => store.delete(key),
92 },
93 }),
94 );
95});
96afterEach(() => {
97 act(() => renderer?.unmount());
98 vi.unstubAllGlobals();
99});
100
101function render(value = detail) {
102 return (
103 <PullRequestSummaryTab
104 scrollerRef={() => {}}
105 environmentId={EnvironmentId.make("environment")}
106 threadRef={null}
107 reference={value}
108 detail={value}
109 activityPending={false}
110 activityError={null}
111 onRefresh={() => {}}
112 />
113 );
114}
115
116function heading(title: string) {
117 const section = renderer.root
118 .findAllByType("section")
119 .find((section) => section.props["aria-label"] === title)!;
120 return section
121 .findAllByType("button")
122 .find((button) => button.findAllByType("span").some((span) => span.children.includes(title)))!;
123}
124
125function click(title: string) {
126 act(() =>
127 heading(title).props.onClick({ nativeEvent: {}, preventDefault() {}, stopPropagation() {} }),
128 );
129}
130
131it("toggles checks from their heading and resets sections for another pull request", () => {
132 act(() => {
133 renderer = create(render());
134 });
135 expect(
136 renderer.root.findAllByType("span").some((span) => span.children.includes("Unit tests")),
137 ).toBe(false);
138 click("Checks");
139 expect(
140 renderer.root.findAllByType("span").some((span) => span.children.includes("Unit tests")),
141 ).toBe(true);
142 click("Checks");
143 expect(heading("Checks").props["aria-expanded"]).toBe(false);
144 click("Checks");
145 click("Description");
146 act(() =>
147 renderer.update(render({ ...detail, url: "https://github.com/owner/repo/pull/2", number: 2 })),
148 );
149 expect(heading("Checks").props["aria-expanded"]).toBe(false);
150 expect(heading("Description").props["aria-expanded"]).toBe(true);
151});
152
153it("keeps an unsaved description when collapsed and reopened", () => {
154 act(() => {
155 renderer = create(render());
156 });
157 act(() => renderer.root.findByProps({ "aria-label": "Edit description" }).props.onClick());
158 act(() =>
159 renderer.root.findByType("textarea").props.onChange({
160 target: { value: "Unsaved description" },
161 currentTarget: { value: "Unsaved description" },
162 nativeEvent: {},
163 }),
164 );
165 click("Description");
166 expect(heading("Description").props["aria-expanded"]).toBe(false);
167 click("Description");
168 expect(renderer.root.findByType("textarea").props.value).toBe("Unsaved description");
169});
170
171function texts() {
172 return renderer.root
173 .findAllByType("p")
174 .map((p) => p.children.join(""))
175 .filter((text) => text !== detail.body);
176}
177
178function filterItem(label: string) {
179 return renderer.root.find(
180 (node) => typeof node.props.onCheckedChange === "function" && node.children.includes(label),
181 );
182}
183
184it("hides bot and resolved comments until the filter shows them, and remembers the choice", () => {
185 const comment = (index: number, login: string, body: string, isBot = false) => ({
186 id: `comment-${index}`,
187 kind: "issue-comment" as const,
188 author: { login, name: null, avatarUrl: null, isBot },
189 body,
190 createdAt: `2026-09-01T00:00:${String(index).padStart(2, "0")}Z`,
191 url: null,
192 path: null,
193 reviewState: null,
194 });
195 const resolved = {
196 ...comment(13, "reviewer", "Resolved remark"),
197 kind: "review-comment" as const,
198 };
199 const value: PullRequestDetailView = {
200 ...detail,
201 commentCount: 14,
202 comments: [
203 ...Array.from({ length: 12 }, (_, index) =>
204 comment(index, "review-app", `Bot report ${index}`, true),
205 ),
206 comment(12, "human", "Human comment"),
207 resolved,
208 ],
209 reviewThreads: [
210 {
211 id: "thread",
212 path: "src/index.ts",
213 line: 1,
214 side: "right",
215 isResolved: true,
216 isOutdated: false,
217 comments: [resolved],
218 },
219 ],
220 };
221 act(() => {
222 renderer = create(render(value));
223 });
224 expect(texts()).toEqual(["Human comment"]);
225 expect(
226 renderer.root.findByProps({ "aria-label": "Filter comments" }).children.join(""),
227 ).toContain("13 hidden");
228
229 act(() => filterItem("Bot comments").props.onCheckedChange(true));
230 // Bots now share the human comment's pages: the ten most recent, the oldest left behind.
231 expect(texts().filter((text) => text.startsWith("Bot report"))).toHaveLength(9);
232 expect(texts()).toContain("Human comment");
233 expect(texts()).not.toContain("Bot report 0");
234
235 act(() => filterItem("Resolved or dismissed").props.onCheckedChange(true));
236 expect(
237 renderer.root.findAllByType("button").some((button) => button.children.includes("Resolved")),
238 ).toBe(true);
239
240 // Another pull request, or the same one opened again, keeps the reader's choice.
241 act(() => renderer.unmount());
242 act(() => {
243 renderer = create(render({ ...value, url: `${value.url}0`, number: 10 }));
244 });
245 expect(filterItem("Bot comments").props.checked).toBe(true);
246 expect(texts()).toContain("Bot report 11");
247});