|
1 | 1 | import { afterEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import { clearGitHubResponseCacheForTest, githubRateLimitAdmissionKeyForInstallation, latestGitHubRestRateLimitObservation } from "../../src/github/client"; |
3 | | -import { extractPreviewUrl, getPreviewBuildState } from "../../src/review/visual/preview-url"; |
| 3 | +import { extractPreviewUrl, findPreviewUrlFromPrComments, getPreviewBuildState } from "../../src/review/visual/preview-url"; |
| 4 | + |
| 5 | +/** GitHub's `Link` header for a page that advertises a next page (the exact shape findAcrossPages walks). */ |
| 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"'; |
| 7 | +const REPO = { owner: "o", repo: "r" }; |
| 8 | +const isPage2 = (input: RequestInfo | URL) => /[?&]page=2\b/.test(String(input)); |
4 | 9 |
|
5 | 10 | afterEach(() => { |
6 | 11 | clearGitHubResponseCacheForTest(); |
@@ -47,6 +52,122 @@ describe("preview-url GitHub reads", () => { |
47 | 52 | }); |
48 | 53 | }); |
49 | 54 |
|
| 55 | +describe("preview-url pagination (#7450)", () => { |
| 56 | + it("findPreviewUrlFromPrComments follows Link: rel=next and finds the bot comment on page 2", async () => { |
| 57 | + const page1 = Array.from({ length: 100 }, (_v, i) => ({ user: { login: `user${i}` }, body: "just chatter" })); |
| 58 | + const page2 = [{ user: { login: "cloudflare-workers-and-pages[bot]" }, body: "Preview ready: https://pr-9.app.workers.dev/route" }]; |
| 59 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => |
| 60 | + isPage2(input) ? Response.json(page2) : Response.json(page1, { headers: { link: NEXT_LINK } }), |
| 61 | + ); |
| 62 | + vi.stubGlobal("fetch", fetchMock); |
| 63 | + |
| 64 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 9 })).resolves.toBe("https://pr-9.app.workers.dev"); |
| 65 | + expect(fetchMock).toHaveBeenCalledTimes(2); |
| 66 | + expect(String(fetchMock.mock.calls[0]![0])).toContain("/issues/9/comments?per_page=100"); |
| 67 | + expect(String(fetchMock.mock.calls[0]![0])).not.toContain("&page="); // page 1 stays the bare pre-pagination read |
| 68 | + expect(String(fetchMock.mock.calls[1]![0])).toContain("&page=2"); |
| 69 | + }); |
| 70 | + |
| 71 | + it("findPreviewUrlFromPrComments stops as soon as the bot comment is found, without fetching further pages", async () => { |
| 72 | + const fetchMock = vi.fn(async () => |
| 73 | + Response.json([{ user: { login: "cloudflare-workers-and-pages[bot]" }, body: "https://pr-1.app.workers.dev" }], { headers: { link: NEXT_LINK } }), |
| 74 | + ); |
| 75 | + vi.stubGlobal("fetch", fetchMock); |
| 76 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 1 })).resolves.toBe("https://pr-1.app.workers.dev"); |
| 77 | + expect(fetchMock).toHaveBeenCalledTimes(1); // early exit despite the advertised next page |
| 78 | + }); |
| 79 | + |
| 80 | + it("findPreviewUrlFromPrComments returns null when no bot comment exists and there is no next page", async () => { |
| 81 | + vi.stubGlobal("fetch", async () => Response.json([{ user: { login: "someone" }, body: "hi" }])); |
| 82 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 2 })).resolves.toBeNull(); |
| 83 | + }); |
| 84 | + |
| 85 | + it("findPreviewUrlFromPrComments treats a non-array comments payload as empty", async () => { |
| 86 | + vi.stubGlobal("fetch", async () => Response.json({ message: "unexpected shape" })); |
| 87 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 5 })).resolves.toBeNull(); |
| 88 | + }); |
| 89 | + |
| 90 | + it("findPreviewUrlFromPrComments degrades to null when a later-page fetch fails, never throwing", async () => { |
| 91 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { |
| 92 | + if (isPage2(input)) throw new Error("network down"); |
| 93 | + return Response.json([{ user: { login: "x" }, body: "hi" }], { headers: { link: NEXT_LINK } }); |
| 94 | + }); |
| 95 | + vi.stubGlobal("fetch", fetchMock); |
| 96 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 3 })).resolves.toBeNull(); |
| 97 | + expect(fetchMock).toHaveBeenCalledTimes(2); |
| 98 | + }); |
| 99 | + |
| 100 | + it("findPreviewUrlFromPrComments is bounded: a pathological always-Link:next response can't loop unboundedly", async () => { |
| 101 | + const fetchMock = vi.fn(async () => Response.json([{ user: { login: "x" }, body: "hi" }], { headers: { link: NEXT_LINK } })); |
| 102 | + vi.stubGlobal("fetch", fetchMock); |
| 103 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 4 })).resolves.toBeNull(); |
| 104 | + expect(fetchMock).toHaveBeenCalledTimes(10); // PREVIEW_LIST_MAX_PAGES |
| 105 | + }); |
| 106 | + |
| 107 | + it("findPreviewUrlFromPrComments skips a user-less comment and a bot comment with no preview link, then returns the real one", async () => { |
| 108 | + // Order matters: the scan reverses each page (newest first), so the url-bearing bot comment (index 0) is |
| 109 | + // examined LAST -- the user-less comment and the link-less bot comment are examined first. |
| 110 | + vi.stubGlobal("fetch", async () => |
| 111 | + Response.json([ |
| 112 | + { user: { login: "cloudflare-workers-and-pages[bot]" }, body: "Preview: https://pr-7.app.workers.dev" }, |
| 113 | + { user: { login: "cloudflare-workers-and-pages[bot]" }, body: "build started, no link yet" }, // bot, no URL -> if(url) is false |
| 114 | + { body: "a comment with no user object at all" }, // user absent -> `c.user?.login ?? ""` is "" |
| 115 | + ]), |
| 116 | + ); |
| 117 | + await expect(findPreviewUrlFromPrComments({ token: "t", repo: REPO, prNumber: 7 })).resolves.toBe("https://pr-7.app.workers.dev"); |
| 118 | + }); |
| 119 | + |
| 120 | + it("getPreviewBuildState ignores a nameless check-run and still classifies the Workers Builds one", async () => { |
| 121 | + vi.stubGlobal("fetch", async () => |
| 122 | + Response.json({ |
| 123 | + check_runs: [ |
| 124 | + { status: "completed", conclusion: "success" }, // no name -> `r.name ?? ""` -> regex miss |
| 125 | + { name: "Cloudflare Workers Builds", status: "completed", conclusion: "success" }, |
| 126 | + ], |
| 127 | + }), |
| 128 | + ); |
| 129 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "nameless" })).resolves.toBe("succeeded"); |
| 130 | + }); |
| 131 | + |
| 132 | + it("getPreviewBuildState follows Link: rel=next and finds the Workers Builds check on page 2", async () => { |
| 133 | + const page1 = { check_runs: Array.from({ length: 100 }, () => ({ name: "unit tests", status: "completed", conclusion: "success" })) }; |
| 134 | + const page2 = { check_runs: [{ name: "Cloudflare Workers Builds", status: "in_progress" }] }; |
| 135 | + const fetchMock = vi.fn(async (input: RequestInfo | URL) => |
| 136 | + isPage2(input) ? Response.json(page2) : Response.json(page1, { headers: { link: NEXT_LINK } }), |
| 137 | + ); |
| 138 | + vi.stubGlobal("fetch", fetchMock); |
| 139 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "abc" })).resolves.toBe("building"); |
| 140 | + expect(fetchMock).toHaveBeenCalledTimes(2); |
| 141 | + }); |
| 142 | + |
| 143 | + it("getPreviewBuildState classifies a completed Workers Builds check as succeeded or failed", async () => { |
| 144 | + vi.stubGlobal("fetch", async () => Response.json({ check_runs: [{ name: "cloudflare pages", status: "completed", conclusion: "success" }] })); |
| 145 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "s1" })).resolves.toBe("succeeded"); |
| 146 | + vi.stubGlobal("fetch", async () => Response.json({ check_runs: [{ name: "cloudflare pages", status: "completed", conclusion: "failure" }] })); |
| 147 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "s2" })).resolves.toBe("failed"); |
| 148 | + }); |
| 149 | + |
| 150 | + it("getPreviewBuildState treats a payload without a check_runs array as absent", async () => { |
| 151 | + vi.stubGlobal("fetch", async () => Response.json({})); |
| 152 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "s3" })).resolves.toBe("absent"); |
| 153 | + }); |
| 154 | + |
| 155 | + it("getPreviewBuildState is bounded and degrades to absent on a later-page failure", async () => { |
| 156 | + const spin = vi.fn(async () => Response.json({ check_runs: [] }, { headers: { link: NEXT_LINK } })); |
| 157 | + vi.stubGlobal("fetch", spin); |
| 158 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "spin" })).resolves.toBe("absent"); |
| 159 | + expect(spin).toHaveBeenCalledTimes(10); // PREVIEW_LIST_MAX_PAGES |
| 160 | + |
| 161 | + const failLater = vi.fn(async (input: RequestInfo | URL) => { |
| 162 | + if (isPage2(input)) throw new Error("boom"); |
| 163 | + return Response.json({ check_runs: [] }, { headers: { link: NEXT_LINK } }); |
| 164 | + }); |
| 165 | + vi.stubGlobal("fetch", failLater); |
| 166 | + await expect(getPreviewBuildState({ token: "t", repo: REPO, sha: "fail" })).resolves.toBe("absent"); |
| 167 | + expect(failLater).toHaveBeenCalledTimes(2); |
| 168 | + }); |
| 169 | +}); |
| 170 | + |
50 | 171 | describe("extractPreviewUrl", () => { |
51 | 172 | it.each([ |
52 | 173 | ["null", null], |
|
0 commit comments