| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { |
| 3 | EnvironmentId, |
| 4 | SourceControlProviderKind, |
| 5 | type PullRequestListEntry, |
| 6 | type PullRequestListResult, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { |
| 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 | |
| 24 | function 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 | } |
| 50 | function 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 | |
| 64 | describe("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 | |
| 108 | describe("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 | |
| 167 | describe("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 | |
| 256 | describe("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 | |
| 378 | describe("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 | }); |