|
1 | 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, latestGitHubRestRateLimitObservation } from "../../src/github/client"; |
3 | | -import { extractPreviewUrl, findPreviewUrlFromPrComments, getPreviewBuildState } from "../../src/review/visual/preview-url"; |
| 3 | +import { extractPreviewUrl, findPreviewUrlFromPrComments, getLatestDeploymentStatus, getPreviewBuildState } from "../../src/review/visual/preview-url"; |
4 | 4 |
|
5 | 5 | /** GitHub's `Link` header for a page that advertises a next page (the exact shape findAcrossPages walks). */ |
6 | 6 | const NEXT_LINK = '<https://api.github.com/resource?per_page=100&page=99>; rel="next", <https://api.github.com/resource?per_page=100&page=99>; rel="last"'; |
@@ -166,6 +166,216 @@ describe("preview-url pagination (#7450)", () => { |
166 | 166 | await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "fail" })).resolves.toBe("absent"); |
167 | 167 | expect(failLater).toHaveBeenCalledTimes(2); |
168 | 168 | }); |
| 169 | + |
| 170 | + it("getLatestDeploymentStatus follows Link: rel=next on deployments and finds the preview URL on page 2 (#7805)", async () => { |
| 171 | + const page1Deployments = Array.from({ length: 10 }, (_v, i) => ({ id: i + 1 })); |
| 172 | + const page2Deployments = [{ id: 99 }]; |
| 173 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { |
| 174 | + const url = String(input); |
| 175 | + if (url.includes("/deployments?") && url.includes("sha=abc")) { |
| 176 | + return isPage2(input) |
| 177 | + ? Response.json(page2Deployments) |
| 178 | + : Response.json(page1Deployments, { headers: { link: NEXT_LINK } }); |
| 179 | + } |
| 180 | + if (url.includes("/deployments/99/statuses")) { |
| 181 | + return Response.json([{ state: "success", environment_url: "https://pr-99.app.workers.dev" }]); |
| 182 | + } |
| 183 | + if (url.includes("/deployments/") && url.includes("/statuses")) { |
| 184 | + return Response.json([{ state: "failure" }]); |
| 185 | + } |
| 186 | + throw new Error(`unexpected fetch: ${url}`); |
| 187 | + }); |
| 188 | + vi.stubGlobal("fetch", fetchMock); |
| 189 | + |
| 190 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({ |
| 191 | + url: "https://pr-99.app.workers.dev", |
| 192 | + failed: false, |
| 193 | + }); |
| 194 | + expect(fetchMock.mock.calls.some((c) => /\/deployments\?.*page=2/.test(String(c[0])))).toBe(true); |
| 195 | + expect(String(fetchMock.mock.calls.find((c) => String(c[0]).includes("/deployments?"))![0])).not.toContain("&page="); |
| 196 | + }); |
| 197 | + |
| 198 | + it("getLatestDeploymentStatus follows Link: rel=next on deployment statuses and finds environment_url on page 2 (#7805)", async () => { |
| 199 | + const page1Statuses = Array.from({ length: 10 }, () => ({ state: "pending" })); |
| 200 | + const page2Statuses = [{ state: "success", environment_url: "https://deep-status.app.workers.dev" }]; |
| 201 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { |
| 202 | + const url = String(input); |
| 203 | + if (url.includes("/deployments?")) { |
| 204 | + return Response.json([{ id: 7 }]); |
| 205 | + } |
| 206 | + if (url.includes("/deployments/7/statuses")) { |
| 207 | + return isPage2(input) ? Response.json(page2Statuses) : Response.json(page1Statuses, { headers: { link: NEXT_LINK } }); |
| 208 | + } |
| 209 | + throw new Error(`unexpected fetch: ${url}`); |
| 210 | + }); |
| 211 | + vi.stubGlobal("fetch", fetchMock); |
| 212 | + |
| 213 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "deep" })).resolves.toEqual({ |
| 214 | + url: "https://deep-status.app.workers.dev", |
| 215 | + failed: false, |
| 216 | + }); |
| 217 | + expect(fetchMock).toHaveBeenCalledTimes(3); // deployments list + statuses pages 1 and 2 |
| 218 | + expect(fetchMock.mock.calls.some((c) => /\/deployments\/7\/statuses.*page=2/.test(String(c[0])))).toBe(true); |
| 219 | + }); |
| 220 | + |
| 221 | + it("getLatestDeploymentStatus returns failed:true when the latest status errored and none are pending", async () => { |
| 222 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 223 | + const url = String(input); |
| 224 | + if (url.includes("/deployments?")) return Response.json([{ id: 1 }]); |
| 225 | + return Response.json([{ state: "failure" }]); |
| 226 | + }); |
| 227 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "fail" })).resolves.toEqual({ url: null, failed: true }); |
| 228 | + }); |
| 229 | + |
| 230 | + it("getLatestDeploymentStatus keeps failed:false while a deployment is still pending", async () => { |
| 231 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 232 | + const url = String(input); |
| 233 | + if (url.includes("/deployments?")) return Response.json([{ id: 1 }]); |
| 234 | + return Response.json([{ state: "pending" }]); |
| 235 | + }); |
| 236 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "pending" })).resolves.toEqual({ url: null, failed: false }); |
| 237 | + }); |
| 238 | + |
| 239 | + it("getLatestDeploymentStatus treats a 404 deployments list as absent", async () => { |
| 240 | + vi.stubGlobal("fetch", async () => Response.json({ message: "Not Found" }, { status: 404 })); |
| 241 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "missing" })).resolves.toEqual({ url: null, failed: false }); |
| 242 | + }); |
| 243 | + |
| 244 | + it("getLatestDeploymentStatus reports error:true on a non-404 deployment lookup failure", async () => { |
| 245 | + vi.stubGlobal("fetch", async () => Response.json({ message: "rate limited" }, { status: 403 })); |
| 246 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "rl" })).resolves.toEqual({ url: null, failed: false, error: true }); |
| 247 | + }); |
| 248 | + |
| 249 | + it("getLatestDeploymentStatus skips the GitHub read when neither sha nor ref is provided", async () => { |
| 250 | + const fetchMock = vi.fn(); |
| 251 | + vi.stubGlobal("fetch", fetchMock); |
| 252 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO })).resolves.toEqual({ url: null, failed: false }); |
| 253 | + expect(fetchMock).not.toHaveBeenCalled(); |
| 254 | + }); |
| 255 | + |
| 256 | + it("getLatestDeploymentStatus degrades when a deployment statuses fetch throws", async () => { |
| 257 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { |
| 258 | + const url = String(input); |
| 259 | + if (url.includes("/deployments?")) return Response.json([{ id: 1 }]); |
| 260 | + throw new Error("status read down"); |
| 261 | + }); |
| 262 | + vi.stubGlobal("fetch", fetchMock); |
| 263 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "status-down" })).resolves.toEqual({ url: null, failed: false }); |
| 264 | + }); |
| 265 | + |
| 266 | + it("getLatestDeploymentStatus skips deployments without an id and still finds a preview URL", async () => { |
| 267 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 268 | + const url = String(input); |
| 269 | + if (url.includes("/deployments?")) return Response.json([{}, { id: 2 }]); |
| 270 | + return Response.json([{ state: "success", environment_url: "https://valid-id.app.workers.dev" }]); |
| 271 | + }); |
| 272 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "skip-id" })).resolves.toEqual({ |
| 273 | + url: "https://valid-id.app.workers.dev", |
| 274 | + failed: false, |
| 275 | + }); |
| 276 | + }); |
| 277 | + |
| 278 | + it("getLatestDeploymentStatus accepts in_progress statuses with an environment_url", async () => { |
| 279 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 280 | + const url = String(input); |
| 281 | + if (url.includes("/deployments?")) return Response.json([{ id: 3 }]); |
| 282 | + return Response.json([{ state: "in_progress", environment_url: "https://building.app.workers.dev" }]); |
| 283 | + }); |
| 284 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "building" })).resolves.toEqual({ |
| 285 | + url: "https://building.app.workers.dev", |
| 286 | + failed: false, |
| 287 | + }); |
| 288 | + }); |
| 289 | + |
| 290 | + it("getLatestDeploymentStatus treats a latest error status as failed when nothing is pending", async () => { |
| 291 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 292 | + const url = String(input); |
| 293 | + if (url.includes("/deployments?")) return Response.json([{ id: 4 }]); |
| 294 | + return Response.json([{ state: "error" }]); |
| 295 | + }); |
| 296 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "error-state" })).resolves.toEqual({ url: null, failed: true }); |
| 297 | + }); |
| 298 | + |
| 299 | + it("getLatestDeploymentStatus keeps failed:false when the latest status is still in_progress without a URL", async () => { |
| 300 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 301 | + const url = String(input); |
| 302 | + if (url.includes("/deployments?")) return Response.json([{ id: 5 }]); |
| 303 | + return Response.json([{ state: "in_progress" }]); |
| 304 | + }); |
| 305 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "in-progress" })).resolves.toEqual({ url: null, failed: false }); |
| 306 | + }); |
| 307 | + |
| 308 | + it("getLatestDeploymentStatus treats non-array deployment and status payloads as empty", async () => { |
| 309 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 310 | + const url = String(input); |
| 311 | + if (url.includes("/deployments?")) return Response.json({ message: "unexpected" }); |
| 312 | + return Response.json({ message: "unexpected" }); |
| 313 | + }); |
| 314 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "shape" })).resolves.toEqual({ url: null, failed: false }); |
| 315 | + }); |
| 316 | + |
| 317 | + it("getLatestDeploymentStatus keeps failed:false when one deployment failed but another is still pending", async () => { |
| 318 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 319 | + const url = String(input); |
| 320 | + if (url.includes("/deployments?")) return Response.json([{ id: 1 }, { id: 2 }]); |
| 321 | + if (url.includes("/deployments/1/statuses")) return Response.json([{ state: "error" }]); |
| 322 | + if (url.includes("/deployments/2/statuses")) return Response.json([{ state: "in_progress" }]); |
| 323 | + return Response.json([]); |
| 324 | + }); |
| 325 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "mixed" })).resolves.toEqual({ url: null, failed: false }); |
| 326 | + }); |
| 327 | + |
| 328 | + it("getLatestDeploymentStatus skips deployments without an id", async () => { |
| 329 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 330 | + const url = String(input); |
| 331 | + if (url.includes("/deployments?")) return Response.json([{ notAnId: true }]); |
| 332 | + return Response.json([]); |
| 333 | + }); |
| 334 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "no-id" })).resolves.toEqual({ url: null, failed: false }); |
| 335 | + }); |
| 336 | + |
| 337 | + it("getLatestDeploymentStatus builds a ref-scoped deployments query when given a ref instead of a sha", async () => { |
| 338 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { |
| 339 | + const url = String(input); |
| 340 | + if (url.includes("/deployments?")) return Response.json([{ id: 8 }]); |
| 341 | + if (url.includes("/deployments/8/statuses")) return Response.json([{ state: "success", environment_url: "https://ref.pages.dev/" }]); |
| 342 | + return Response.json([]); |
| 343 | + }); |
| 344 | + vi.stubGlobal("fetch", fetchMock); |
| 345 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature-branch" })).resolves.toEqual({ |
| 346 | + url: "https://ref.pages.dev/", |
| 347 | + failed: false, |
| 348 | + }); |
| 349 | + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/deployments?ref=feature-branch"))).toBe(true); |
| 350 | + }); |
| 351 | + |
| 352 | + it("getLatestDeploymentStatus treats a non-array statuses payload for a deployment as empty", async () => { |
| 353 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 354 | + const url = String(input); |
| 355 | + if (url.includes("/deployments?")) return Response.json([{ id: 3 }]); |
| 356 | + if (url.includes("/deployments/3/statuses")) return Response.json({ message: "unexpected non-array statuses shape" }); |
| 357 | + return Response.json([]); |
| 358 | + }); |
| 359 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "bad-statuses" })).resolves.toEqual({ url: null, failed: false }); |
| 360 | + }); |
| 361 | + |
| 362 | + it("getLatestDeploymentStatus keeps failed:false when the latest status is queued", async () => { |
| 363 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 364 | + const url = String(input); |
| 365 | + if (url.includes("/deployments?")) return Response.json([{ id: 6 }]); |
| 366 | + return Response.json([{ state: "queued" }]); |
| 367 | + }); |
| 368 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature/x" })).resolves.toEqual({ url: null, failed: false }); |
| 369 | + }); |
| 370 | + |
| 371 | + it("getLatestDeploymentStatus treats an empty statuses page as absent without a latest state", async () => { |
| 372 | + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { |
| 373 | + const url = String(input); |
| 374 | + if (url.includes("/deployments?")) return Response.json([{ id: 8 }]); |
| 375 | + return Response.json([]); |
| 376 | + }); |
| 377 | + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "empty-statuses" })).resolves.toEqual({ url: null, failed: false }); |
| 378 | + }); |
169 | 379 | }); |
170 | 380 |
|
171 | 381 | describe("extractPreviewUrl", () => { |
|
0 commit comments