1import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts";
2import { Children, isValidElement, type ReactNode } from "react";
3import { describe, expect, it } from "vite-plus/test";
4
5import { PullRequestChecksPopover } from "./PullRequestChecksPopover";
6import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic";
7import { PullRequestRow } from "./PullRequestRow";
8import {
9 pullRequestChecksState,
10 pullRequestCheckStatusLabel,
11 summarizePullRequestChecks,
12} from "./pullRequestPresentation";
13
14function check(
15 status: PullRequestCheck["status"],
16 overrides: Partial<PullRequestCheck> = {},
17): PullRequestCheck {
18 return { name: `check-${status}`, status, description: null, url: null, ...overrides };
19}
20
21describe("pullRequestChecksState", () => {
22 it("lets a failure outrank a run still going, and reports nothing without checks", () => {
23 expect(pullRequestChecksState([check("success"), check("pending"), check("failure")])).toBe(
24 "failing",
25 );
26 expect(pullRequestChecksState([check("success"), check("cancelled")])).toBe("failing");
27 expect(pullRequestChecksState([check("success"), check("pending")])).toBe("pending");
28 expect(pullRequestChecksState([check("success"), check("action-required")])).toBe("pending");
29 expect(pullRequestChecksState([check("success")])).toBe("passing");
30 // Skipped and neutral are neither a pass nor a failure, so they are no verdict at all.
31 expect(pullRequestChecksState([check("skipped"), check("neutral")])).toBe(null);
32 expect(pullRequestChecksState([])).toBe(null);
33 });
34
35 it("names workflow approval instead of claiming every check passed", () => {
36 const workflow = check("action-required", {
37 url: "https://github.com/acme/web/actions/runs/42/job/7",
38 });
39 const manualGate = check("action-required", { url: "https://example.com/manual-gate" });
40 expect(pullRequestCheckStatusLabel(workflow)).toBe("Awaiting approval");
41 expect(pullRequestCheckStatusLabel(manualGate)).toBe("Awaiting action");
42 expect(summarizePullRequestChecks([check("success"), workflow])).toBe(
43 "1 workflow awaiting approval",
44 );
45 expect(summarizePullRequestChecks([check("failure"), workflow])).toBe("1 of 2 failing");
46 expect(summarizePullRequestChecks([check("success"), manualGate])).toBe(
47 "1 check awaiting action",
48 );
49 expect(summarizePullRequestChecks([workflow, manualGate])).toBe(
50 "1 workflow and 1 check awaiting action",
51 );
52 });
53});
54
55/**
56 * Every element of the tree the row returned, so a nested indicator can be looked for. The row
57 * hands its slots to the shared row lines as props rather than children, so every prop that
58 * holds an element is walked too.
59 */
60function flatten(node: ReactNode): ReadonlyArray<ReturnType<typeof Object>> {
61 const found: unknown[] = [];
62 for (const child of Children.toArray(node)) {
63 if (!isValidElement(child)) continue;
64 found.push(child);
65 for (const value of Object.values(child.props as Record<string, unknown>)) {
66 if (isValidElement(value)) found.push(...flatten(value));
67 else if (Array.isArray(value)) found.push(...flatten(value.filter(isValidElement)));
68 }
69 }
70 return found as ReadonlyArray<ReturnType<typeof Object>>;
71}
72
73function entry(overrides: Partial<EnvironmentPullRequestEntry>): EnvironmentPullRequestEntry {
74 return {
75 environmentId: "env-1" as EnvironmentId,
76 projectId: "project-1" as ProjectId,
77 provider: "github",
78 repository: "pingdotgg/t3code",
79 number: 1,
80 title: "Add the pull requests page",
81 url: "https://github.com/pingdotgg/t3code/pull/1",
82 author: null,
83 headBranch: "feat/page",
84 baseBranch: "main",
85 state: "open",
86 isDraft: false,
87 mergeability: "mergeable",
88 additions: 1,
89 deletions: 0,
90 createdAt: "2026-07-01T00:00:00Z",
91 updatedAt: "2026-07-02T00:00:00Z",
92 viewerReviewRequested: false,
93 labels: [],
94 ...overrides,
95 } as EnvironmentPullRequestEntry;
96}
97
98function row(overrides: Partial<EnvironmentPullRequestEntry>): ReactNode {
99 return PullRequestRow.type({
100 entry: entry(overrides),
101 selected: false,
102 showProjectTitle: false,
103 showProvider: false,
104 onSelect: () => {},
105 speedMode: false,
106 hostActions: new Set(),
107 onActed: () => {},
108 });
109}
110
111describe("PullRequestRow checks indicator", () => {
112 function indicators(node: ReactNode): number {
113 return flatten(node).filter(
114 (element) => (element as { type?: unknown }).type === PullRequestChecksPopover,
115 ).length;
116 }
117
118 it("shows the indicator only for a row the host reported a rollup for", () => {
119 expect(indicators(row({ checksState: "failing" }))).toBe(1);
120 expect(indicators(row({}))).toBe(0);
121 });
122});