1import type {
2 MergebotDeploymentEnvironment,
3 MergebotDeployments,
4 MergebotMergedCommit,
5} from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import {
9 buildDeploymentTimeline,
10 deploymentActivity,
11 formatEta,
12 railCommitCount,
13 stagingOnlyCount,
14} from "./deployments.logic";
15
16const NOW = Date.parse("2026-10-15T12:00:00Z");
17
18function commit(sha: string, overrides: Partial<MergebotMergedCommit> = {}): MergebotMergedCommit {
19 return {
20 sha,
21 pullRequestNumber: null,
22 authorLogin: null,
23 authorName: null,
24 authorAvatarUrl: null,
25 message: `feat: ${sha}`,
26 mergedAt: "2026-10-15T11:00:00Z",
27 url: `https://github.com/acme/app/commit/${sha}`,
28 labels: [],
29 status: "default",
30 isProductionHead: false,
31 ...overrides,
32 };
33}
34
35function environment(
36 sha: string,
37 overrides: Partial<MergebotDeploymentEnvironment> = {},
38): MergebotDeploymentEnvironment {
39 return {
40 sha,
41 deployedSha: null,
42 status: "passed",
43 currentStep: null,
44 etaAt: null,
45 checksUrl: `https://github.com/acme/app/commit/${sha}/checks`,
46 ...overrides,
47 };
48}
49
50function deployments(overrides: Partial<MergebotDeployments> = {}): MergebotDeployments {
51 return {
52 repository: "acme/app",
53 url: "https://github.com/acme/app",
54 defaultBranch: "main",
55 productionBranch: "production",
56 stagingUrl: "https://staging.acme.dev",
57 productionUrl: "https://acme.dev",
58 staging: environment("c1"),
59 production: environment("c3"),
60 mergedCommits: [
61 commit("c1"),
62 commit("c2"),
63 commit("c3", { status: "recent", isProductionHead: true }),
64 commit("c4", { status: "recent" }),
65 ],
66 ...overrides,
67 };
68}
69
70function shape(items: ReturnType<typeof buildDeploymentTimeline>) {
71 return items.map((item) =>
72 item.kind === "marker"
73 ? `marker:${item.label}`
74 : `${item.commit.sha}${item.isDeployCandidate ? " (candidate)" : ""}`,
75 );
76}
77
78describe("stagingOnlyCount", () => {
79 it("counts commits live on staging but not production", () => {
80 expect(stagingOnlyCount(deployments())).toBe(2);
81 });
82
83 it("measures from the live staging sha while staging deploys", () => {
84 const value = deployments({
85 staging: environment("c1", { status: "pending", deployedSha: "c2" }),
86 });
87 expect(stagingOnlyCount(value)).toBe(1);
88 });
89
90 it("measures from the live production sha while production deploys", () => {
91 const value = deployments({
92 production: environment("c2", { status: "pending", deployedSha: "c3" }),
93 });
94 expect(stagingOnlyCount(value)).toBe(2);
95 });
96
97 it("is zero when environments agree or staging is absent", () => {
98 expect(stagingOnlyCount(deployments({ production: environment("c1") }))).toBe(0);
99 expect(stagingOnlyCount(deployments({ staging: null, stagingUrl: null }))).toBe(0);
100 });
101
102 it("falls back to the production branch head", () => {
103 expect(stagingOnlyCount(deployments({ production: null, productionUrl: null }))).toBe(2);
104 });
105});
106
107describe("buildDeploymentTimeline", () => {
108 it("places staging on top and production above its commit", () => {
109 expect(shape(buildDeploymentTimeline(deployments(), NOW))).toEqual([
110 "marker:on staging",
111 "c1 (candidate)",
112 "c2 (candidate)",
113 "marker:on production",
114 "c3",
115 "c4",
116 ]);
117 });
118
119 it("stacks both environments when they share a commit", () => {
120 const value = deployments({ production: environment("c1") });
121 expect(shape(buildDeploymentTimeline(value, NOW))).toEqual([
122 "marker:on staging + production",
123 "c1",
124 "c2",
125 "c3",
126 "c4",
127 ]);
128 });
129
130 it("shows an in-flight staging deploy with its step and ETA above the target commit", () => {
131 const value = deployments({
132 staging: environment("c1", {
133 status: "pending",
134 deployedSha: "c2",
135 currentStep: "deploy_client",
136 etaAt: "2026-10-15T12:02:00Z",
137 }),
138 });
139 const items = buildDeploymentTimeline(value, NOW);
140 expect(shape(items)).toEqual([
141 "marker:deploying to staging · deploy_client · ~2m left",
142 "c1",
143 "marker:on staging",
144 "c2 (candidate)",
145 "marker:on production",
146 "c3",
147 "c4",
148 ]);
149 const c1 = items.find((item) => item.kind === "commit" && item.commit.sha === "c1");
150 expect(c1?.kind === "commit" && c1.tone).toBe("deploying");
151 });
152
153 it("keeps production's live commit marked while it deploys a newer one", () => {
154 const value = deployments({
155 production: environment("c2", { status: "failed", deployedSha: "c3" }),
156 });
157 expect(shape(buildDeploymentTimeline(value, NOW))).toEqual([
158 "marker:on staging",
159 "c1 (candidate)",
160 "marker:failed deployment to production",
161 "c2",
162 "marker:on production",
163 "c3",
164 "c4",
165 ]);
166 });
167
168 it("marks unshipped work without staging and offers no deploys without a production branch", () => {
169 const withoutStaging = deployments({ staging: null, stagingUrl: null });
170 expect(shape(buildDeploymentTimeline(withoutStaging, NOW))).toEqual([
171 "marker:main (unshipped)",
172 "c1 (candidate)",
173 "c2 (candidate)",
174 "marker:on production",
175 "c3",
176 "c4",
177 ]);
178
179 const trunkOnly = deployments({
180 staging: null,
181 stagingUrl: null,
182 production: null,
183 productionUrl: null,
184 productionBranch: null,
185 });
186 expect(shape(buildDeploymentTimeline(trunkOnly, NOW))).toEqual([
187 "marker:main",
188 "c1",
189 "c2",
190 "c3",
191 "c4",
192 ]);
193 });
194
195 it("truncates commits without moving markers", () => {
196 expect(shape(buildDeploymentTimeline(deployments(), NOW, 2))).toEqual([
197 "marker:on staging",
198 "c1 (candidate)",
199 "c2 (candidate)",
200 ]);
201 });
202});
203
204describe("railCommitCount", () => {
205 it("reaches back to the oldest environment anchor", () => {
206 const mergedCommits = Array.from({ length: 12 }, (_, index) =>
207 commit(`c${index}`, { isProductionHead: index === 10 }),
208 );
209 expect(railCommitCount(deployments({ mergedCommits, production: environment("c10") }))).toBe(
210 11,
211 );
212 expect(railCommitCount(deployments({ mergedCommits: mergedCommits.slice(0, 5) }))).toBe(8);
213 });
214});
215
216describe("deploymentActivity", () => {
217 it("reports the worst configured environment", () => {
218 const deploying = deployments({ staging: environment("c1", { status: "pending" }) });
219 const failed = deployments({ production: environment("c3", { status: "failed" }) });
220 expect(deploymentActivity([deployments()])).toBe("healthy");
221 expect(deploymentActivity([deployments(), deploying])).toBe("deploying");
222 expect(deploymentActivity([deploying, failed])).toBe("failed");
223 });
224});
225
226describe("formatEta", () => {
227 it("rounds to minutes", () => {
228 expect(formatEta("2026-10-15T12:00:20Z", NOW)).toBe("<1m");
229 expect(formatEta("2026-10-15T12:02:10Z", NOW)).toBe("~2m");
230 expect(formatEta("2026-10-15T13:05:00Z", NOW)).toBe("~1h 5m");
231 });
232});