From e116874432bf415264c27b85158926ee81d076c4 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 21:43:09 +0800 Subject: [PATCH 1/6] fix(review): paginate getLatestDeploymentStatus deployments and statuses (#7805) Reuse findAcrossPages for both GitHub list reads so preview URL discovery does not silently miss deployments or statuses beyond page 1. Closes #7805 Co-authored-by: Cursor --- src/review/visual/preview-url.ts | 84 ++++++++++++++++++++------------ test/unit/preview-url.test.ts | 52 +++++++++++++++++++- 2 files changed, 103 insertions(+), 33 deletions(-) diff --git a/src/review/visual/preview-url.ts b/src/review/visual/preview-url.ts index 3f3a18c985..0f9b44a1e4 100644 --- a/src/review/visual/preview-url.ts +++ b/src/review/visual/preview-url.ts @@ -100,14 +100,14 @@ async function findAcrossPages( firstPageUrl: string, init: GithubJsonInit, selectItems: (payload: unknown) => TItem[], - probe: (items: TItem[]) => TResult | null, + probe: (items: TItem[]) => TResult | null | Promise, ): Promise { for (let page = 1; page <= PREVIEW_LIST_MAX_PAGES; page += 1) { // Callers pass a `per_page=100` first-page URL; append the 1-based page cursor for page 2+ only (page 1 is // GitHub's default, so leaving it bare keeps that request byte-identical to the pre-pagination read). const url = page === 1 ? firstPageUrl : `${firstPageUrl}&page=${page}`; const { payload, link } = await githubJsonWithLink(url, init); - const found = probe(selectItems(payload)); + const found = await probe(selectItems(payload)); if (found !== null) return found; if (!hasNextPage(link)) return null; } @@ -138,13 +138,57 @@ export async function getLatestDeploymentStatus(params: { ? `ref=${encodeURIComponent(params.ref)}` : ""; if (!selector) return { url: null, failed: false }; - let deployments: Array<{ id?: number }>; - try { - deployments = await githubJson>(`${base}/deployments?${selector}&per_page=10`, { - token: params.token, - apiVersion: params.apiVersion, - rateLimitAdmissionKey: params.rateLimitAdmissionKey, + const opts = { token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey }; + type DeploymentStatus = { state?: string; environment_url?: string }; + const selectStatuses = (payload: unknown) => (Array.isArray(payload) ? (payload as DeploymentStatus[]) : []); + const probeStatusesForUrl = (statuses: DeploymentStatus[]) => { + for (const status of statuses) { + const ok = status.state === "success" || status.state === "in_progress"; + if (ok && status.environment_url) return status.environment_url; + } + return null; + }; + const fetchLatestDeploymentStatusState = async (deploymentId: number): Promise => { + try { + const statuses = await githubJson(`${base}/deployments/${deploymentId}/statuses?per_page=10`, opts); + return statuses[0]?.state; + } catch (error) { + console.log(JSON.stringify({ event: "deployment_status_error", deployment: deploymentId, message: String(error).slice(0, 200) })); + return undefined; + } + }; + const inspectDeploymentStatuses = async (deploymentId: number): Promise<{ url: string | null; latestState?: string }> => { + const url = await findAcrossPages( + `${base}/deployments/${deploymentId}/statuses?per_page=10`, + opts, + selectStatuses, + probeStatusesForUrl, + ).catch((error) => { + console.log(JSON.stringify({ event: "deployment_status_error", deployment: deploymentId, message: String(error).slice(0, 200) })); + return null; }); + if (url) return { url }; + return { url: null, latestState: await fetchLatestDeploymentStatusState(deploymentId) }; + }; + let sawFailure = false; + let sawPending = false; + try { + const url = await findAcrossPages<{ id?: number }, string>( + `${base}/deployments?${selector}&per_page=10`, + opts, + (payload) => (Array.isArray(payload) ? (payload as Array<{ id?: number }>) : []), + async (deployments) => { + for (const deployment of deployments) { + if (deployment.id == null) continue; + const { url: foundUrl, latestState } = await inspectDeploymentStatuses(deployment.id); + if (foundUrl) return foundUrl; + if (latestState === "failure" || latestState === "error") sawFailure = true; + else if (latestState === "in_progress" || latestState === "queued" || latestState === "pending") sawPending = true; + } + return null; + }, + ); + if (url) return { url, failed: false }; } catch (error) { // 404 → the ref genuinely has no deployments. Any other failure (403 missing scope, rate limit, 5xx) is // NOT "no preview"; report `error` so the caller keeps polling rather than showing a false terminal state. @@ -152,30 +196,6 @@ export async function getLatestDeploymentStatus(params: { console.log(JSON.stringify({ event: "deployment_lookup_error", repo: `${params.repo.owner}/${params.repo.repo}`, selector, message: String(error).slice(0, 200) })); return { url: null, failed: false, error: true }; } - const ids = deployments.map((d) => d.id).filter((id): id is number => id != null); - const statusLists = await Promise.all( - ids.map((id) => - githubJson>(`${base}/deployments/${id}/statuses?per_page=10`, { - token: params.token, - apiVersion: params.apiVersion, - rateLimitAdmissionKey: params.rateLimitAdmissionKey, - }).catch((error) => { - console.log(JSON.stringify({ event: "deployment_status_error", deployment: id, message: String(error).slice(0, 200) })); - return [] as Array<{ state?: string; environment_url?: string }>; - }), - ), - ); - let sawFailure = false; - let sawPending = false; - for (const statuses of statusLists) { - for (const status of statuses) { - const ok = status.state === "success" || status.state === "in_progress"; - if (ok && status.environment_url) return { url: status.environment_url, failed: false }; - } - const latest = statuses[0]?.state; - if (latest === "failure" || latest === "error") sawFailure = true; - else if (latest === "in_progress" || latest === "queued" || latest === "pending") sawPending = true; - } return { url: null, failed: sawFailure && !sawPending }; } diff --git a/test/unit/preview-url.test.ts b/test/unit/preview-url.test.ts index 19b91c8e8d..517d6cd642 100644 --- a/test/unit/preview-url.test.ts +++ b/test/unit/preview-url.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, latestGitHubRestRateLimitObservation } from "../../src/github/client"; -import { extractPreviewUrl, findPreviewUrlFromPrComments, getPreviewBuildState } from "../../src/review/visual/preview-url"; +import { extractPreviewUrl, findPreviewUrlFromPrComments, getLatestDeploymentStatus, getPreviewBuildState } from "../../src/review/visual/preview-url"; /** GitHub's `Link` header for a page that advertises a next page (the exact shape findAcrossPages walks). */ const NEXT_LINK = '; rel="next", ; rel="last"'; @@ -166,6 +166,56 @@ describe("preview-url pagination (#7450)", () => { await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "fail" })).resolves.toBe("absent"); expect(failLater).toHaveBeenCalledTimes(2); }); + + it("getLatestDeploymentStatus follows Link: rel=next on deployments and finds the preview URL on page 2 (#7805)", async () => { + const page1Deployments = Array.from({ length: 10 }, (_v, i) => ({ id: i + 1 })); + const page2Deployments = [{ id: 99 }]; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?") && url.includes("sha=abc")) { + return isPage2(input) + ? Response.json(page2Deployments) + : Response.json(page1Deployments, { headers: { link: NEXT_LINK } }); + } + if (url.includes("/deployments/99/statuses")) { + return Response.json([{ state: "success", environment_url: "https://pr-99.app.workers.dev" }]); + } + if (url.includes("/deployments/") && url.includes("/statuses")) { + return Response.json([{ state: "failure" }]); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "abc" })).resolves.toEqual({ + url: "https://pr-99.app.workers.dev", + failed: false, + }); + expect(fetchMock.mock.calls.some((c) => /\/deployments\?.*page=2/.test(String(c[0])))).toBe(true); + expect(String(fetchMock.mock.calls.find((c) => String(c[0]).includes("/deployments?"))![0])).not.toContain("&page="); + }); + + it("getLatestDeploymentStatus follows Link: rel=next on deployment statuses and finds environment_url on page 2 (#7805)", async () => { + const page1Statuses = Array.from({ length: 10 }, () => ({ state: "pending" })); + const page2Statuses = [{ state: "success", environment_url: "https://deep-status.app.workers.dev" }]; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) { + return Response.json([{ id: 7 }]); + } + if (url.includes("/deployments/7/statuses")) { + return isPage2(input) ? Response.json(page2Statuses) : Response.json(page1Statuses, { headers: { link: NEXT_LINK } }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "deep" })).resolves.toEqual({ + url: "https://deep-status.app.workers.dev", + failed: false, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); }); describe("extractPreviewUrl", () => { From ff2bea34368ce4f55363ef04036de3bc11dfcaf2 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 21:53:49 +0800 Subject: [PATCH 2/6] fix(review): capture latest deployment status from first statuses page only Avoid a redundant page-1 refetch after pagination and satisfy exactOptionalPropertyTypes for latestState. Co-authored-by: Cursor --- src/review/visual/preview-url.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/review/visual/preview-url.ts b/src/review/visual/preview-url.ts index 0f9b44a1e4..8acead866f 100644 --- a/src/review/visual/preview-url.ts +++ b/src/review/visual/preview-url.ts @@ -148,27 +148,26 @@ export async function getLatestDeploymentStatus(params: { } return null; }; - const fetchLatestDeploymentStatusState = async (deploymentId: number): Promise => { - try { - const statuses = await githubJson(`${base}/deployments/${deploymentId}/statuses?per_page=10`, opts); - return statuses[0]?.state; - } catch (error) { - console.log(JSON.stringify({ event: "deployment_status_error", deployment: deploymentId, message: String(error).slice(0, 200) })); - return undefined; - } - }; const inspectDeploymentStatuses = async (deploymentId: number): Promise<{ url: string | null; latestState?: string }> => { + let latestState: string | undefined; + let capturedLatest = false; const url = await findAcrossPages( `${base}/deployments/${deploymentId}/statuses?per_page=10`, opts, selectStatuses, - probeStatusesForUrl, + (statuses) => { + if (!capturedLatest) { + latestState = statuses[0]?.state; + capturedLatest = true; + } + return probeStatusesForUrl(statuses); + }, ).catch((error) => { console.log(JSON.stringify({ event: "deployment_status_error", deployment: deploymentId, message: String(error).slice(0, 200) })); return null; }); if (url) return { url }; - return { url: null, latestState: await fetchLatestDeploymentStatusState(deploymentId) }; + return latestState !== undefined ? { url: null, latestState } : { url: null }; }; let sawFailure = false; let sawPending = false; From 42f8f51e6c590cc371ea7033401ee7d4a6f30ffb Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 22:05:12 +0800 Subject: [PATCH 3/6] test(review): expect deployments + two status pages in pagination case Co-authored-by: Cursor --- test/unit/preview-url.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/preview-url.test.ts b/test/unit/preview-url.test.ts index 517d6cd642..9a50137e7c 100644 --- a/test/unit/preview-url.test.ts +++ b/test/unit/preview-url.test.ts @@ -214,7 +214,8 @@ describe("preview-url pagination (#7450)", () => { url: "https://deep-status.app.workers.dev", failed: false, }); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(3); // deployments list + statuses pages 1 and 2 + expect(fetchMock.mock.calls.some((c) => /\/deployments\/7\/statuses.*page=2/.test(String(c[0])))).toBe(true); }); }); From 44c577913e1554a3a882fadaf10a33261cba9e39 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 22:05:25 +0800 Subject: [PATCH 4/6] test(review): cover getLatestDeploymentStatus failure and error paths (#7805) Co-authored-by: Cursor --- test/unit/preview-url.test.ts | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/unit/preview-url.test.ts b/test/unit/preview-url.test.ts index 9a50137e7c..564dac74bd 100644 --- a/test/unit/preview-url.test.ts +++ b/test/unit/preview-url.test.ts @@ -217,6 +217,51 @@ describe("preview-url pagination (#7450)", () => { expect(fetchMock).toHaveBeenCalledTimes(3); // deployments list + statuses pages 1 and 2 expect(fetchMock.mock.calls.some((c) => /\/deployments\/7\/statuses.*page=2/.test(String(c[0])))).toBe(true); }); + + it("getLatestDeploymentStatus returns failed:true when the latest status errored and none are pending", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 1 }]); + return Response.json([{ state: "failure" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "fail" })).resolves.toEqual({ url: null, failed: true }); + }); + + it("getLatestDeploymentStatus keeps failed:false while a deployment is still pending", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 1 }]); + return Response.json([{ state: "pending" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "pending" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus treats a 404 deployments list as absent", async () => { + vi.stubGlobal("fetch", async () => Response.json({ message: "Not Found" }, { status: 404 })); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "missing" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus reports error:true on a non-404 deployment lookup failure", async () => { + vi.stubGlobal("fetch", async () => Response.json({ message: "rate limited" }, { status: 403 })); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "rl" })).resolves.toEqual({ url: null, failed: false, error: true }); + }); + + it("getLatestDeploymentStatus skips the GitHub read when neither sha nor ref is provided", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO })).resolves.toEqual({ url: null, failed: false }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("getLatestDeploymentStatus degrades when a deployment statuses fetch throws", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 1 }]); + throw new Error("status read down"); + }); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "status-down" })).resolves.toEqual({ url: null, failed: false }); + }); }); describe("extractPreviewUrl", () => { From 8dd5416a16df789f4966b6d57d17404da4b1eb97 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 22:36:53 +0800 Subject: [PATCH 5/6] test(review): cover remaining getLatestDeploymentStatus patch branches Co-authored-by: Cursor --- test/unit/preview-url.test.ts | 51 +++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/unit/preview-url.test.ts b/test/unit/preview-url.test.ts index 564dac74bd..47e1c99bed 100644 --- a/test/unit/preview-url.test.ts +++ b/test/unit/preview-url.test.ts @@ -262,6 +262,57 @@ describe("preview-url pagination (#7450)", () => { vi.stubGlobal("fetch", fetchMock); await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "status-down" })).resolves.toEqual({ url: null, failed: false }); }); + + it("getLatestDeploymentStatus skips deployments without an id and still finds a preview URL", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{}, { id: 2 }]); + return Response.json([{ state: "success", environment_url: "https://valid-id.app.workers.dev" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "skip-id" })).resolves.toEqual({ + url: "https://valid-id.app.workers.dev", + failed: false, + }); + }); + + it("getLatestDeploymentStatus accepts in_progress statuses with an environment_url", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 3 }]); + return Response.json([{ state: "in_progress", environment_url: "https://building.app.workers.dev" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "building" })).resolves.toEqual({ + url: "https://building.app.workers.dev", + failed: false, + }); + }); + + it("getLatestDeploymentStatus treats a latest error status as failed when nothing is pending", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 4 }]); + return Response.json([{ state: "error" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "error-state" })).resolves.toEqual({ url: null, failed: true }); + }); + + it("getLatestDeploymentStatus keeps failed:false when the latest status is still in_progress without a URL", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 5 }]); + return Response.json([{ state: "in_progress" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "in-progress" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus treats non-array deployment and status payloads as empty", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json({ message: "unexpected" }); + return Response.json({ message: "unexpected" }); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "shape" })).resolves.toEqual({ url: null, failed: false }); + }); }); describe("extractPreviewUrl", () => { From 6fb53ba58010a255b26d4d24b8a2b834263e46ce Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Tue, 21 Jul 2026 22:51:15 +0800 Subject: [PATCH 6/6] fix(review): align deployment pagination with findDeploymentUrl helper (#7805) Co-authored-by: Cursor --- src/review/visual/preview-url.ts | 66 +++++++++++++++----------------- test/unit/preview-url.test.ts | 63 ++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/src/review/visual/preview-url.ts b/src/review/visual/preview-url.ts index 8acead866f..99713fc1b0 100644 --- a/src/review/visual/preview-url.ts +++ b/src/review/visual/preview-url.ts @@ -139,55 +139,50 @@ export async function getLatestDeploymentStatus(params: { : ""; if (!selector) return { url: null, failed: false }; const opts = { token: params.token, apiVersion: params.apiVersion, rateLimitAdmissionKey: params.rateLimitAdmissionKey }; - type DeploymentStatus = { state?: string; environment_url?: string }; - const selectStatuses = (payload: unknown) => (Array.isArray(payload) ? (payload as DeploymentStatus[]) : []); - const probeStatusesForUrl = (statuses: DeploymentStatus[]) => { - for (const status of statuses) { - const ok = status.state === "success" || status.state === "in_progress"; - if (ok && status.environment_url) return status.environment_url; - } - return null; - }; - const inspectDeploymentStatuses = async (deploymentId: number): Promise<{ url: string | null; latestState?: string }> => { - let latestState: string | undefined; - let capturedLatest = false; - const url = await findAcrossPages( - `${base}/deployments/${deploymentId}/statuses?per_page=10`, + + // sawFailure/sawPending accumulate across every deployment (and every page of them), so the final + // failed-vs-still-coming verdict reflects all deployments, not just the first page (#7805). + let sawFailure = false; + let sawPending = false; + + // Scan one deployment's statuses across ALL pages (#7805): return its environment_url when a usable status is + // found, else null after recording whether its latest status looked failed/pending. + const findDeploymentUrl = (id: number): Promise => + findAcrossPages<{ state?: string; environment_url?: string }, string>( + `${base}/deployments/${id}/statuses?per_page=10`, opts, - selectStatuses, + (payload) => (Array.isArray(payload) ? (payload as Array<{ state?: string; environment_url?: string }>) : []), (statuses) => { - if (!capturedLatest) { - latestState = statuses[0]?.state; - capturedLatest = true; + for (const status of statuses) { + const ok = status.state === "success" || status.state === "in_progress"; + if (ok && status.environment_url) return status.environment_url; } - return probeStatusesForUrl(statuses); + // Only the first page's first entry is GitHub's "latest" status; later pages are older, so the + // failed/pending bookkeeping keys off statuses[0] exactly as the pre-pagination single-page read did. + const latest = statuses[0]?.state; + if (latest === "failure" || latest === "error") sawFailure = true; + else if (latest === "in_progress" || latest === "queued" || latest === "pending") sawPending = true; + return null; }, ).catch((error) => { - console.log(JSON.stringify({ event: "deployment_status_error", deployment: deploymentId, message: String(error).slice(0, 200) })); + console.log(JSON.stringify({ event: "deployment_status_error", deployment: id, message: String(error).slice(0, 200) })); return null; }); - if (url) return { url }; - return latestState !== undefined ? { url: null, latestState } : { url: null }; - }; - let sawFailure = false; - let sawPending = false; + + let url: string | null; try { - const url = await findAcrossPages<{ id?: number }, string>( + // Walk every page of deployments, and on each page scan each deployment's statuses; return the first usable + // environment_url found, letting findAcrossPages stop as soon as a page yields one. + url = await findAcrossPages<{ id?: number }, string>( `${base}/deployments?${selector}&per_page=10`, opts, (payload) => (Array.isArray(payload) ? (payload as Array<{ id?: number }>) : []), async (deployments) => { - for (const deployment of deployments) { - if (deployment.id == null) continue; - const { url: foundUrl, latestState } = await inspectDeploymentStatuses(deployment.id); - if (foundUrl) return foundUrl; - if (latestState === "failure" || latestState === "error") sawFailure = true; - else if (latestState === "in_progress" || latestState === "queued" || latestState === "pending") sawPending = true; - } - return null; + const ids = deployments.map((d) => d.id).filter((id): id is number => id != null); + const statusUrls = await Promise.all(ids.map((id) => findDeploymentUrl(id))); + return statusUrls.find((found): found is string => found !== null) ?? null; }, ); - if (url) return { url, failed: false }; } catch (error) { // 404 → the ref genuinely has no deployments. Any other failure (403 missing scope, rate limit, 5xx) is // NOT "no preview"; report `error` so the caller keeps polling rather than showing a false terminal state. @@ -195,6 +190,7 @@ export async function getLatestDeploymentStatus(params: { console.log(JSON.stringify({ event: "deployment_lookup_error", repo: `${params.repo.owner}/${params.repo.repo}`, selector, message: String(error).slice(0, 200) })); return { url: null, failed: false, error: true }; } + if (url !== null) return { url, failed: false }; return { url: null, failed: sawFailure && !sawPending }; } diff --git a/test/unit/preview-url.test.ts b/test/unit/preview-url.test.ts index 47e1c99bed..9232d66ae1 100644 --- a/test/unit/preview-url.test.ts +++ b/test/unit/preview-url.test.ts @@ -313,6 +313,69 @@ describe("preview-url pagination (#7450)", () => { }); await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "shape" })).resolves.toEqual({ url: null, failed: false }); }); + + it("getLatestDeploymentStatus keeps failed:false when one deployment failed but another is still pending", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 1 }, { id: 2 }]); + if (url.includes("/deployments/1/statuses")) return Response.json([{ state: "error" }]); + if (url.includes("/deployments/2/statuses")) return Response.json([{ state: "in_progress" }]); + return Response.json([]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "mixed" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus skips deployments without an id", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ notAnId: true }]); + return Response.json([]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "no-id" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus builds a ref-scoped deployments query when given a ref instead of a sha", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 8 }]); + if (url.includes("/deployments/8/statuses")) return Response.json([{ state: "success", environment_url: "https://ref.pages.dev/" }]); + return Response.json([]); + }); + vi.stubGlobal("fetch", fetchMock); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature-branch" })).resolves.toEqual({ + url: "https://ref.pages.dev/", + failed: false, + }); + expect(fetchMock.mock.calls.some((c) => String(c[0]).includes("/deployments?ref=feature-branch"))).toBe(true); + }); + + it("getLatestDeploymentStatus treats a non-array statuses payload for a deployment as empty", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 3 }]); + if (url.includes("/deployments/3/statuses")) return Response.json({ message: "unexpected non-array statuses shape" }); + return Response.json([]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "bad-statuses" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus keeps failed:false when the latest status is queued", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 6 }]); + return Response.json([{ state: "queued" }]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, ref: "feature/x" })).resolves.toEqual({ url: null, failed: false }); + }); + + it("getLatestDeploymentStatus treats an empty statuses page as absent without a latest state", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/deployments?")) return Response.json([{ id: 8 }]); + return Response.json([]); + }); + await expect(getLatestDeploymentStatus({ token: "t", repo: REPO, sha: "empty-statuses" })).resolves.toEqual({ url: null, failed: false }); + }); }); describe("extractPreviewUrl", () => {