1import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
2import { ProjectId, type PullRequestStack, type ThreadPullRequestLink } from "@t3tools/contracts";
3import { describe, expect, it } from "vite-plus/test";
4import {
5 pullRequestLayerParams,
6 pullRequestStackMerge,
7 savedPullRequestStack,
8 singlePullRequestMergeAllowed,
9} from "./pullRequestStack";
10
11const GITHUB = sourceControlClients.get("github").changeRequest;
12
13const stack: PullRequestStack = {
14 id: "stack",
15 number: 10,
16 url: "https://github.com/acme/web/pull/10",
17 base: "main",
18 layers: [1, 2, 3].map((number) => ({
19 number,
20 headSha: `sha-${number}`,
21 headBranch: `layer-${number}`,
22 state: "open" as const,
23 isDraft: false,
24 })),
25};
26const reference = {
27 projectId: ProjectId.make("project"),
28 host: "github.com",
29 repository: "acme/web",
30 number: 2,
31};
32const link: ThreadPullRequestLink = {
33 host: "github.com",
34 repository: "acme/web",
35 number: 2,
36 url: "https://github.com/acme/web/pull/2",
37 source: "manual",
38 linkedAt: "2026-10-08T10:00:00Z",
39 snapshot: {
40 title: "Middle layer",
41 headBranch: "layer-2",
42 baseBranch: "layer-1",
43 state: "open",
44 isDraft: false,
45 updatedAt: null,
46 syncedAt: "2026-10-08T10:00:00Z",
47 },
48 stack: { ...stack, kind: "native" },
49};
50
51describe("mobile stack merge scope", () => {
52 it("merges only the prefix through the selected PR, preserving host order", () => {
53 const scope = pullRequestStackMerge(stack, 2, GITHUB);
54 expect(scope.position).toBe(2);
55 expect(scope.heads).toEqual([
56 { number: 1, headSha: "sha-1" },
57 { number: 2, headSha: "sha-2" },
58 ]);
59 expect(scope.reason).toBeNull();
60 expect(pullRequestStackMerge(stack, 3, GITHUB).heads).toHaveLength(3);
61 });
62 it("excludes already merged layers from the expected head revisions", () => {
63 const updated = {
64 ...stack,
65 layers: stack.layers.map((layer) =>
66 layer.number === 1 ? { ...layer, state: "merged" as const, headSha: undefined } : layer,
67 ),
68 };
69 expect(pullRequestStackMerge(updated, 2, GITHUB).heads).toEqual([
70 { number: 2, headSha: "sha-2" },
71 ]);
72 expect(pullRequestStackMerge(updated, 2, GITHUB).reason).toBeNull();
73 expect(pullRequestStackMerge(updated, 1, GITHUB).reason).toContain("no open layers");
74 });
75 it("blocks closed and draft dependencies, but permits them above the target", () => {
76 const closed = {
77 ...stack,
78 layers: stack.layers.map((layer) =>
79 layer.number === 2 ? { ...layer, state: "closed" as const } : layer,
80 ),
81 };
82 expect(pullRequestStackMerge(closed, 3, GITHUB).reason).toContain("Reopen");
83 expect(pullRequestStackMerge(closed, 1, GITHUB).reason).toBeNull();
84 const draft = {
85 ...stack,
86 layers: stack.layers.map((layer) =>
87 layer.number === 2 ? { ...layer, isDraft: true } : layer,
88 ),
89 };
90 expect(pullRequestStackMerge(draft, 3, GITHUB).reason).toContain("ready for review");
91 expect(pullRequestStackMerge(draft, 1, GITHUB).reason).toBeNull();
92 });
93 it("blocks missing revisions and a PR removed from the stack", () => {
94 const unknown = {
95 ...stack,
96 layers: stack.layers.map((layer) => ({ ...layer, headSha: undefined })),
97 };
98 expect(pullRequestStackMerge(unknown, 2, GITHUB).reason).toContain("latest revisions");
99 expect(pullRequestStackMerge(stack, 99, GITHUB).reason).toContain("no longer in the stack");
100 expect(
101 pullRequestStackMerge(stack, 99, sourceControlClients.get("gitlab").changeRequest).reason,
102 ).toBe("This merge request is no longer in the stack. Refresh it.");
103 });
104 it("waits for successful discovery before allowing a single PR merge on modern servers", () => {
105 expect(
106 singlePullRequestMergeAllowed({
107 supportsStackActions: true,
108 hasStack: false,
109 isFresh: false,
110 }),
111 ).toBe(false);
112 expect(
113 singlePullRequestMergeAllowed({ supportsStackActions: true, hasStack: false, isFresh: true }),
114 ).toBe(true);
115 expect(
116 singlePullRequestMergeAllowed({ supportsStackActions: true, hasStack: true, isFresh: true }),
117 ).toBe(false);
118 expect(
119 singlePullRequestMergeAllowed({
120 supportsStackActions: false,
121 hasStack: false,
122 isFresh: false,
123 }),
124 ).toBe(true);
125 });
126});
127
128describe("mobile stack layer navigation", () => {
129 it("opens another layer without the summary of the layer it came from", () => {
130 const route = {
131 environmentId: "environment",
132 projectId: "project",
133 repository: "acme/web",
134 host: "github.com",
135 number: 2,
136 initialSummary: { number: 2, url: "https://github.com/acme/web/pull/2" },
137 };
138 expect(pullRequestLayerParams(route, 3)).toEqual({
139 environmentId: "environment",
140 projectId: "project",
141 repository: "acme/web",
142 host: "github.com",
143 number: 3,
144 });
145 });
146});
147
148describe("mobile saved stack navigation", () => {
149 it("keeps all saved layers and enriches titles without carrying action revisions", () => {
150 const saved = savedPullRequestStack([link], reference);
151 expect(saved?.layers).toHaveLength(3);
152 expect(saved?.layers[1]).toMatchObject({ number: 2, title: "Middle layer", isDraft: false });
153 expect(saved?.layers.every((layer) => layer.headSha === undefined)).toBe(true);
154 expect(pullRequestStackMerge(saved!, 2, GITHUB).reason).toContain("latest revisions");
155 expect(savedPullRequestStack([link], { ...reference, number: 3 })?.number).toBe(10);
156 });
157 it("never borrows membership from another host or repository", () => {
158 expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull();
159 expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull();
160 expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull();
161 });
162 it("honors a newer removal rather than reviving an old stack", () => {
163 const removed = {
164 ...link,
165 stack: null,
166 snapshot: { ...link.snapshot!, syncedAt: "2026-10-08T11:00:00Z" },
167 };
168 expect(savedPullRequestStack([link, removed], reference)).toBeNull();
169 });
170});