1import { describe, expect, it } from "vite-plus/test";
2import {
3 EnvironmentId,
4 SourceControlProviderKind,
5 type PullRequestListEntry,
6 type PullRequestListResult,
7} from "@t3tools/contracts";
8import {
9 appendPullRequestListPage,
10 DEFAULT_PR_LIST_CONTROLS,
11 filterPullRequestList,
12 mergePullRequestListEntries,
13 mergeEnvironmentPullRequestPages,
14 nextPullRequestListInput,
15 pullRequestListFacets,
16 pullRequestListHead,
17 pullRequestListReviewStatus,
18 pullRequestLabelColor,
19 scopedPullRequestListKey,
20 pullRequestListRowsAreEqual,
21 withoutScratchProjectScope,
22} from "./pullRequestList";
23
24function entry(number: number, patch: Partial<PullRequestListEntry> = {}): PullRequestListEntry {
25 return {
26 number,
27 provider: SourceControlProviderKind.make("github"),
28 host: "github.com",
29 repository: "org/repo",
30 projectId: "project" as PullRequestListEntry["projectId"],
31 projectTitle: "Project",
32 title: `Change ${number}`,
33 url: `https://github.com/org/repo/pull/${number}`,
34 author: { login: "octocat", name: null, avatarUrl: null },
35 headBranch: `branch-${number}`,
36 baseBranch: "main",
37 state: "open",
38 isDraft: false,
39 mergeability: "unknown",
40 additions: 0,
41 deletions: 0,
42 createdAt: "2026-10-01T00:00:00Z",
43 updatedAt: `2026-10-${String(number).padStart(2, "0")}T00:00:00Z`,
44 observedAt: 1,
45 viewerReviewRequested: false,
46 labels: [],
47 ...patch,
48 };
49}
50function page(
51 entries: readonly PullRequestListEntry[],
52 nextCursors: PullRequestListResult["nextCursors"] = {},
53): PullRequestListResult {
54 return {
55 entries,
56 nextCursors,
57 truncated: Object.keys(nextCursors).length > 0,
58 viewers: { "github.com": "octocat" },
59 providers: [],
60 errors: [],
61 };
62}
63
64describe("recycled PR list rows", () => {
65 const scopedEntry = (patch: Partial<PullRequestListEntry> = {}) => ({
66 ...entry(1, patch),
67 environmentId: EnvironmentId.make("env-one"),
68 });
69
70 it("keeps an unchanged row through provider refreshes and newly allocated labels and authors", () => {
71 const labels = [{ name: "bug", color: "ff0000" }];
72 expect(
73 pullRequestListRowsAreEqual(
74 scopedEntry({ labels }),
75 scopedEntry({ labels: labels.map((label) => ({ ...label })), observedAt: 20 }),
76 ),
77 ).toBe(true);
78 });
79
80 it.each([
81 { title: "New title" },
82 { additions: 8 },
83 { deletions: 4 },
84 { checksState: "failing" as const },
85 { reviewDecision: "approved" as const },
86 { viewerReviewRequested: true },
87 { isDraft: true },
88 { state: "closed" as const },
89 { author: { login: "another", name: null, avatarUrl: null } },
90 { author: { login: "octocat", name: null, avatarUrl: "https://example.com/avatar.png" } },
91 { labels: [{ name: "bug", color: "ff0000" }] },
92 { stack: { number: 1, position: 2, size: 3, base: "main" } },
93 ])("updates the row when visible status or quick actions change: %j", (patch) => {
94 expect(pullRequestListRowsAreEqual(scopedEntry(), scopedEntry(patch))).toBe(false);
95 });
96
97 it("updates navigation and commands when a duplicate moves to another environment", () => {
98 const original = scopedEntry();
99 expect(
100 pullRequestListRowsAreEqual(original, {
101 ...original,
102 environmentId: EnvironmentId.make("env-two"),
103 }),
104 ).toBe(false);
105 });
106});
107
108describe("mobile PR list pagination", () => {
109 it("continues only the repositories that have more, preserving the query and filters", () => {
110 const first = pullRequestListHead(
111 null,
112 page([entry(3)], { "github.com org/repo": "opaque-boundary" }),
113 );
114 expect(
115 nextPullRequestListInput(first, {
116 state: "open",
117 query: "fix",
118 filters: { draft: "hide" },
119 limit: 30,
120 }),
121 ).toEqual({
122 state: "open",
123 query: "fix",
124 filters: { draft: "hide" },
125 limit: 30,
126 cursors: { "github.com org/repo": "opaque-boundary" },
127 });
128 const next = appendPullRequestListPage(first, page([entry(2), entry(1)]), {
129 state: "open",
130 cursors: first.nextCursors,
131 });
132 expect(next.entries.map((row) => row.number)).toEqual([3, 2, 1]);
133 expect(nextPullRequestListInput(next, { state: "open" })).toBeNull();
134 });
135 it("deduplicates page boundaries and never overwrites a fresh row with an older slice", () => {
136 const current = entry(2, { title: "Retitled", observedAt: 20 });
137 expect(
138 mergePullRequestListEntries([current], [entry(2, { observedAt: 10 }), entry(1)]),
139 ).toEqual([current, entry(1)]);
140 expect(
141 mergePullRequestListEntries([entry(1)], [entry(2, { host: "enterprise.test" }), entry(2)]),
142 ).toHaveLength(3);
143 });
144 it("refreshes the head without dropping older loaded pages or rewinding their cursor", () => {
145 const first = pullRequestListHead(null, page([entry(3)], { repo: "first" }));
146 const continued = appendPullRequestListPage(first, page([entry(2)], { repo: "second" }), {
147 state: "open",
148 cursors: first.nextCursors,
149 });
150 const updated = pullRequestListHead(
151 continued,
152 page([entry(4), entry(3, { title: "New title", observedAt: 2 })], { repo: "first-new" }),
153 );
154 expect(updated.entries.map((row) => row.number)).toEqual([4, 3, 2]);
155 expect(updated.entries[1]?.title).toBe("New title");
156 expect(updated.nextCursors).toEqual({ repo: "second" });
157 expect(pullRequestListHead(updated, updated.head)).toBe(updated);
158 });
159 it("can grow legacy listings that provide no cursor, without exceeding the wire limit", () => {
160 const first = pullRequestListHead(null, { ...page([entry(1)]), truncated: true });
161 expect(nextPullRequestListInput(first, { state: "all" })?.limit).toBe(60);
162 expect(nextPullRequestListInput({ ...first, limit: 490 }, { state: "all" })?.limit).toBe(500);
163 expect(nextPullRequestListInput({ ...first, limit: 500 }, { state: "all" })).toBeNull();
164 });
165});
166
167describe("all-environment PR list", () => {
168 const cups = EnvironmentId.make("cups");
169 const mbp = EnvironmentId.make("mbp");
170 it("combines independent pages, collapses duplicate PRs, and keeps their source for navigation", () => {
171 const first = pullRequestListHead(null, page([entry(3), entry(1)], { repo: "cups-next" }));
172 const second = pullRequestListHead(null, page([entry(2), entry(1)], { repo: "mbp-next" }));
173 const merged = mergeEnvironmentPullRequestPages([
174 { environmentId: cups, pages: first },
175 { environmentId: mbp, pages: second },
176 ]);
177 const rows = filterPullRequestList(
178 merged.entries,
179 DEFAULT_PR_LIST_CONTROLS,
180 merged.viewers,
181 "",
182 merged.localSearchHosts,
183 );
184 expect(rows.map((row) => [row.number, row.environmentId])).toEqual([
185 [3, cups],
186 [2, mbp],
187 [1, cups],
188 ]);
189 expect(scopedPullRequestListKey(rows[1]!)).toBe("mbp:github.com:org/repo:2");
190 expect(nextPullRequestListInput(first, { state: "open" })?.cursors).toEqual({
191 repo: "cups-next",
192 });
193 expect(nextPullRequestListInput(second, { state: "open" })?.cursors).toEqual({
194 repo: "mbp-next",
195 });
196 const continued = appendPullRequestListPage(first, page([entry(4)]), {
197 state: "open",
198 cursors: first.nextCursors,
199 });
200 expect(nextPullRequestListInput(continued, { state: "open" })).toBeNull();
201 expect(nextPullRequestListInput(second, { state: "open" })?.cursors).toEqual({
202 repo: "mbp-next",
203 });
204 });
205 it("uses the viewer account and search support from each environment before deduplicating", () => {
206 const remote = {
207 ...page([entry(1, { author: { login: "other", name: null, avatarUrl: null } })]),
208 viewers: { "github.com": "other" },
209 };
210 const local = page([entry(1), entry(2)]);
211 const merged = mergeEnvironmentPullRequestPages([
212 { environmentId: cups, pages: pullRequestListHead(null, remote) },
213 { environmentId: mbp, pages: pullRequestListHead(null, local) },
214 ]);
215 const byMe = filterPullRequestList(
216 merged.entries,
217 { ...DEFAULT_PR_LIST_CONTROLS, involvement: "authored" },
218 merged.viewers,
219 "",
220 merged.localSearchHosts,
221 );
222 expect(byMe.map((row) => [row.number, row.environmentId])).toEqual([
223 [2, mbp],
224 [1, cups],
225 ]);
226 const localOnlySearch = new Set([`${cups} github.com`]);
227 const searched = filterPullRequestList(
228 merged.entries,
229 DEFAULT_PR_LIST_CONTROLS,
230 merged.viewers,
231 "body match",
232 localOnlySearch,
233 );
234 expect(searched.map((row) => [row.number, row.environmentId])).toEqual([
235 [2, mbp],
236 [1, mbp],
237 ]);
238 });
239 it("scopes a project to its owning environment even if another has the same project id", () => {
240 const merged = mergeEnvironmentPullRequestPages([
241 { environmentId: cups, pages: pullRequestListHead(null, page([entry(1)])) },
242 { environmentId: mbp, pages: pullRequestListHead(null, page([entry(2)])) },
243 ]);
244 expect(
245 filterPullRequestList(
246 merged.entries,
247 { ...DEFAULT_PR_LIST_CONTROLS, projectId: "project", projectEnvironmentId: mbp },
248 merged.viewers,
249 "",
250 merged.localSearchHosts,
251 ).map((row) => row.environmentId),
252 ).toEqual([mbp]);
253 });
254});
255
256describe("mobile PR scan and filters", () => {
257 it("shows review verdicts separately from checks and prioritizes changes requested", () => {
258 expect(
259 pullRequestListReviewStatus({
260 reviewDecision: "changes-requested",
261 viewerReviewRequested: true,
262 })?.accessibleLabel,
263 ).toBe("Changes requested");
264 expect(
265 pullRequestListReviewStatus({ reviewDecision: "approved", viewerReviewRequested: false })
266 ?.label,
267 ).toBe("Approved");
268 expect(pullRequestListReviewStatus({ viewerReviewRequested: true })?.label).toBe("Your review");
269 expect(pullRequestListReviewStatus({ viewerReviewRequested: false })).toBeNull();
270 });
271 it("resolves authored and author:me per host rather than mixing account identities", () => {
272 const rows = [entry(1), entry(2, { host: "enterprise.test" })];
273 const viewers = { "github.com": "Octocat", "enterprise.test": "someone-else" };
274 expect(
275 filterPullRequestList(
276 rows,
277 { ...DEFAULT_PR_LIST_CONTROLS, involvement: "authored" },
278 viewers,
279 "",
280 new Set(),
281 ).map((row) => row.number),
282 ).toEqual([1]);
283 expect(
284 filterPullRequestList(
285 rows,
286 { ...DEFAULT_PR_LIST_CONTROLS, filters: { author: "me" } },
287 viewers,
288 "",
289 new Set(),
290 ).map((row) => row.number),
291 ).toEqual([1]);
292 });
293 it("combines label groups, exclusions, draft and review filters", () => {
294 const rows = [
295 entry(1, {
296 labels: [
297 { name: "size:XS", color: null },
298 { name: "bug", color: null },
299 ],
300 reviewDecision: "approved",
301 }),
302 entry(2, { isDraft: true, labels: [{ name: "bug", color: null }] }),
303 entry(3, { labels: [{ name: "feature", color: null }] }),
304 ];
305 const filters = {
306 draft: "hide" as const,
307 review: "approved" as const,
308 labels: [["SIZE:XS", "size:S"], ["bug"]],
309 excludedLabels: ["feature"],
310 };
311 expect(
312 filterPullRequestList(rows, { ...DEFAULT_PR_LIST_CONTROLS, filters }, {}, "", new Set()).map(
313 (row) => row.number,
314 ),
315 ).toEqual([1]);
316 });
317 it("does not label unsupported review summaries as reviewed or match text locally on host-search results", () => {
318 expect(
319 filterPullRequestList(
320 [entry(1)],
321 { ...DEFAULT_PR_LIST_CONTROLS, filters: { review: "approved" } },
322 {},
323 "body text",
324 new Set(),
325 ),
326 ).toHaveLength(1);
327 expect(
328 filterPullRequestList(
329 [entry(1)],
330 DEFAULT_PR_LIST_CONTROLS,
331 {},
332 "body text",
333 new Set(["github.com"]),
334 ),
335 ).toHaveLength(0);
336 });
337 it("lists a shared repository's row from a connected environment over an offline one", () => {
338 const rows = [
339 { ...entry(1), environmentId: "laptop" },
340 { ...entry(1), environmentId: "server" },
341 ];
342 const pick = (connected: ReadonlySet<string>) =>
343 filterPullRequestList(rows, DEFAULT_PR_LIST_CONTROLS, {}, "", new Set(), connected).map(
344 (row) => row.environmentId,
345 );
346 expect(pick(new Set(["server"]))).toEqual(["server"]);
347 expect(pick(new Set(["laptop", "server"]))).toEqual(["laptop"]);
348 expect(pick(new Set())).toEqual(["laptop"]);
349 });
350 it("sorts loaded rows and builds unique author and label options", () => {
351 const rows = [
352 entry(1, { labels: [{ name: "bug", color: null }] }),
353 entry(2, {
354 labels: [
355 { name: "bug", color: null },
356 { name: "feature", color: null },
357 ],
358 }),
359 ];
360 expect(
361 filterPullRequestList(
362 rows,
363 { ...DEFAULT_PR_LIST_CONTROLS, sort: "oldest" },
364 {},
365 "",
366 new Set(),
367 ).map((row) => row.number),
368 ).toEqual([1, 2]);
369 expect(pullRequestListFacets(rows)).toEqual({
370 authors: ["octocat"],
371 labels: ["bug", "feature"],
372 });
373 expect(pullRequestLabelColor("ffab00")).toBe("#ffab00");
374 expect(pullRequestLabelColor("not-a-color")).toBeNull();
375 });
376});
377
378describe("withoutScratchProjectScope", () => {
379 const projects = [
380 { id: "repo", environmentId: "env-a" },
381 { id: "scratch", environmentId: "env-a", isScratch: true as const },
382 ];
383
384 it("reads a scope saved on the Scratch folder as all projects", () => {
385 const controls = withoutScratchProjectScope(
386 { ...DEFAULT_PR_LIST_CONTROLS, projectId: "scratch", projectEnvironmentId: "env-a" },
387 projects,
388 );
389 expect(controls.projectId).toBeUndefined();
390 expect(controls.projectEnvironmentId).toBeUndefined();
391 });
392
393 it("keeps a repository scope, and one whose projects have not arrived yet", () => {
394 const repository = {
395 ...DEFAULT_PR_LIST_CONTROLS,
396 projectId: "repo",
397 projectEnvironmentId: "env-a",
398 };
399 expect(withoutScratchProjectScope(repository, projects)).toBe(repository);
400 const pending = { ...repository, projectId: "scratch" };
401 expect(withoutScratchProjectScope(pending, [])).toBe(pending);
402 });
403
404 it("keeps another server's project that shares the Scratch folder's id", () => {
405 const elsewhere = {
406 ...DEFAULT_PR_LIST_CONTROLS,
407 projectId: "scratch",
408 projectEnvironmentId: "env-b",
409 };
410 expect(withoutScratchProjectScope(elsewhere, projects)).toBe(elsewhere);
411 });
412});