返回 CodeWhale
computer-use-release.test.ts
根目录 / web / lib / computer-use-release.test.ts
1 import { afterEach, describe, expect, it, vi } from "vitest";
2 import { COMPUTER_USE_REPO, getComputerUseRelease, qualifiedComputerUseRelease } from "./computer-use-release";
3
4 const archive = "Codewhale-Computer-Use-0.3.0-macos-universal.zip";
5 const sha256 = "a".repeat(64);
6 const asset = (name: string, size: number) => ({ name, size, state: "uploaded",
7 browser_download_url: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${name}`, digest: `sha256:${sha256}` });
8 const fixture = () => ({ tag_name: "v0.3.0", draft: false, prerelease: false,
9 published_at: "2026-09-13T00:00:00Z", html_url: `${COMPUTER_USE_REPO}/releases/tag/v0.3.0`,
10 assets: [asset(archive, 80000000), asset("release.json", 500)] });
11 const receipt = () => ({ version: "0.3.0", platform: "macos", arch: "universal", archive,
12 sha256, size: 80000000, notarized: true });
13 const image = "Codewhale-Computer-Use-0.3.0-macos-universal.dmg";
14 const imageSha = "c".repeat(64);
15 const imageAsset = () => ({ ...asset(image, 81000000), digest: `sha256:${imageSha}` });
16 const imageReceipt = () => ({ ...receipt(), dmg: { archive: image, size: 81000000, sha256: imageSha, notarized: true } });
17
18 const API_LATEST = "https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest";
19 const WEB_RECEIPT = `${COMPUTER_USE_REPO}/releases/latest/download/release.json`;
20 const OBJECT_URL = "https://objects.githubusercontent.com/github-production-release-asset/1/release.json?X-Amz-Signature=x";
21 const status = (code: number) => new Response(null, { status: code });
22 const redirect = (location: string) => new Response(null, { status: 302, headers: { location } });
23 const stub = (...responses: unknown[]) => {
24 const fetcher = vi.fn();
25 for (const r of responses) {
26 if (r instanceof Error) fetcher.mockRejectedValueOnce(r); else fetcher.mockResolvedValueOnce(r);
27 }
28 vi.stubGlobal("fetch", fetcher);
29 return fetcher;
30 };
31
32 afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
33
34 describe("Computer Use download qualification", () => {
35 it("offers the exact archive when the release, receipt and GitHub digest agree", () => {
36 expect(qualifiedComputerUseRelease(fixture(), receipt())).toMatchObject({
37 status: "ready", version: "0.3.0", sha256, downloadUrl: asset(archive, 80000000).browser_download_url,
38 verification: "github-digest",
39 });
40 });
41 it.each([
42 { notarized: false }, { version: "0.2.2" }, { size: 1 }, { sha256: "b".repeat(64) },
43 { platform: "windows" }, { arch: "arm64" }, { archive: "unqualified.zip" },
44 ])("withholds mismatched or unqualified receipts: %j", change => {
45 expect(qualifiedComputerUseRelease(fixture(), { ...receipt(), ...change }).status).toBe("pending");
46 });
47 it("refuses drafts, prereleases, missing assets and foreign download URLs", () => {
48 const foreign = fixture(); foreign.assets[0].browser_download_url = "https://example.com/app.zip";
49 const duplicate = fixture(); duplicate.assets.push(duplicate.assets[0]);
50 const unsigned = fixture(); unsigned.assets[0].digest = "";
51 const tooLarge = fixture(); tooLarge.assets[0].size = 300 * 1024 * 1024;
52 for (const release of [{ ...fixture(), draft: true }, { ...fixture(), prerelease: true },
53 { ...fixture(), tag_name: "v0.3.0-rc1" }, { ...fixture(), assets: [] }, foreign, duplicate, unsigned, tooLarge]) {
54 expect(qualifiedComputerUseRelease(release, receipt()).status).toBe("pending");
55 }
56 });
57 it("loads only the canonical release and its matching receipt", async () => {
58 const fetcher = stub(Response.json(fixture()), Response.json(receipt()));
59 expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "github-digest" });
60 expect(fetcher.mock.calls.map(c => c[0])).toEqual([API_LATEST, `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`]);
61 });
62 it("sends the server-held token to the API exactly when one is passed, and never elsewhere", async () => {
63 const fetcher = stub(status(404), status(404), status(404));
64 await getComputerUseRelease("ghp_secret");
65 await getComputerUseRelease();
66 const headers = (call: number) => fetcher.mock.calls[call][1].headers as Record<string, string>;
67 expect(headers(0).Authorization).toBe("Bearer ghp_secret");
68 expect(headers(1)).not.toHaveProperty("Authorization");
69 await getComputerUseRelease("");
70 expect(headers(2)).not.toHaveProperty("Authorization");
71 });
72 it("reports no published installer when both the API and the release endpoint say so", async () => {
73 const fetcher = stub(status(404));
74 expect((await getComputerUseRelease()).status).toBe("pending");
75 expect(fetcher).toHaveBeenCalledTimes(1);
76 });
77 it("falls back to the release web endpoint when the API refuses, and stays honest when that fails too", async () => {
78 const error = vi.spyOn(console, "error").mockImplementation(() => {});
79 stub(status(403), status(404));
80 expect((await getComputerUseRelease()).status).toBe("pending");
81 expect(error).toHaveBeenCalledWith("computer-use release check", 403);
82 stub(status(503), new Error("offline"));
83 expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable");
84 stub(new Error("offline"), status(404));
85 expect((await getComputerUseRelease("ghp_secret")).status).toBe("pending");
86 expect(error).toHaveBeenCalledWith("computer-use release check failed", "offline");
87 stub(status(403), status(500));
88 expect((await getComputerUseRelease("ghp_secret")).status).toBe("unavailable");
89 expect(error.mock.calls.flat().join(" ")).not.toContain("ghp_");
90 });
91 it("qualifies the download from the receipt when the API is unreachable", async () => {
92 vi.spyOn(console, "error").mockImplementation(() => {});
93 const fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), redirect(OBJECT_URL));
94 expect(await getComputerUseRelease()).toEqual({
95 status: "ready", version: "0.3.0", sha256, size: 80000000, verification: "receipt",
96 url: `${COMPUTER_USE_REPO}/releases/tag/v0.3.0`,
97 downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`,
98 receiptUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/release.json`,
99 });
100 expect(fetcher.mock.calls.map(c => [c[0], c[1].method ?? "GET", c[1].redirect])).toEqual([
101 [API_LATEST, "GET", undefined], [WEB_RECEIPT, "GET", "manual"], [OBJECT_URL, "GET", "manual"],
102 [`${COMPUTER_USE_REPO}/releases/download/v0.3.0/${archive}`, "HEAD", "manual"],
103 ]);
104 expect(fetcher.mock.calls.every(c => !("Authorization" in (c[1].headers ?? {})))).toBe(true);
105 });
106 it("accepts a directly served archive and a permanent redirect for the receipt", async () => {
107 vi.spyOn(console, "error").mockImplementation(() => {});
108 stub(status(403), new Response(null, { status: 301, headers: { location: OBJECT_URL } }), Response.json(receipt()), status(200));
109 expect(await getComputerUseRelease()).toMatchObject({ status: "ready", verification: "receipt" });
110 });
111 it.each([
112 ["a disallowed host", redirect("https://example.com/release.json")],
113 ["a redirect without a location", new Response(null, { status: 302 })],
114 ["plain http", redirect("http://objects.githubusercontent.com/release.json")],
115 ])("refuses a receipt redirect onto %s", async (_label, hop) => {
116 vi.spyOn(console, "error").mockImplementation(() => {});
117 const fetcher = stub(status(403), hop, Response.json(receipt()));
118 expect((await getComputerUseRelease()).status).toBe("unavailable");
119 expect(fetcher).toHaveBeenCalledTimes(2);
120 });
121 it("withholds the fallback when the receipt is unqualified or the archive is not served", async () => {
122 vi.spyOn(console, "error").mockImplementation(() => {});
123 stub(status(403), redirect(OBJECT_URL), Response.json({ ...receipt(), notarized: false }));
124 expect((await getComputerUseRelease()).status).toBe("unavailable");
125 stub(status(403), redirect(OBJECT_URL), Response.json(receipt()), status(404));
126 expect((await getComputerUseRelease()).status).toBe("unavailable");
127 const fetcher = stub(status(403), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL), redirect(OBJECT_URL));
128 expect((await getComputerUseRelease()).status).toBe("unavailable");
129 expect(fetcher).toHaveBeenCalledTimes(5);
130 });
131 it("bounds malformed or oversized responses and does not fetch an unqualified receipt", async () => {
132 vi.spyOn(console, "error").mockImplementation(() => {});
133 const fetcher = stub(new Response("x".repeat(128 * 1024 + 1)), new Error("offline"),
134 Response.json({ ...fixture(), draft: true }));
135 expect((await getComputerUseRelease()).status).toBe("unavailable");
136 expect((await getComputerUseRelease()).status).toBe("pending");
137 expect(fetcher).toHaveBeenCalledTimes(3);
138 });
139 it("offers the disk image only when the receipt and GitHub's digest agree on it", () => {
140 const withImage = { ...fixture(), assets: [...fixture().assets, imageAsset()] };
141 expect(qualifiedComputerUseRelease(withImage, imageReceipt())).toMatchObject({
142 status: "ready", dmg: { downloadUrl: imageAsset().browser_download_url, size: 81000000, sha256: imageSha },
143 });
144 // A release without the image, a receipt without the entry, or a mismatch all fall back to the archive alone.
145 expect(qualifiedComputerUseRelease(fixture(), imageReceipt())).not.toHaveProperty("dmg");
146 expect(qualifiedComputerUseRelease(withImage, receipt())).not.toHaveProperty("dmg");
147 const mismatched = imageReceipt(); mismatched.dmg.sha256 = "d".repeat(64);
148 expect(qualifiedComputerUseRelease(withImage, mismatched)).toMatchObject({ status: "ready" });
149 expect(qualifiedComputerUseRelease(withImage, mismatched)).not.toHaveProperty("dmg");
150 const unnotarized = imageReceipt(); unnotarized.dmg.notarized = false;
151 expect(qualifiedComputerUseRelease(withImage, unnotarized)).not.toHaveProperty("dmg");
152 });
153 it("confirms the disk image is served before offering it from the receipt fallback", async () => {
154 vi.spyOn(console, "error").mockImplementation(() => {});
155 let fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(imageReceipt()), redirect(OBJECT_URL), redirect(OBJECT_URL));
156 expect(await getComputerUseRelease()).toMatchObject({
157 status: "ready", verification: "receipt",
158 dmg: { downloadUrl: `${COMPUTER_USE_REPO}/releases/download/v0.3.0/${image}`, size: 81000000, sha256: imageSha },
159 });
160 expect(fetcher.mock.calls[4][0]).toBe(`${COMPUTER_USE_REPO}/releases/download/v0.3.0/${image}`);
161 expect(fetcher.mock.calls[4][1].method).toBe("HEAD");
162 fetcher = stub(status(403), redirect(OBJECT_URL), Response.json(imageReceipt()), redirect(OBJECT_URL), status(404));
163 const withoutImage = await getComputerUseRelease();
164 expect(withoutImage).toMatchObject({ status: "ready", verification: "receipt" });
165 expect(withoutImage).not.toHaveProperty("dmg");
166 expect(fetcher).toHaveBeenCalledTimes(5);
167 });
168 it("keeps production builds offline without claiming that a release is available", async () => {
169 vi.stubEnv("NEXT_PHASE", "phase-production-build");
170 const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
171 expect((await getComputerUseRelease()).status).toBe("unavailable");
172 expect(fetcher).not.toHaveBeenCalled();
173 });
174 });
175
175 lines TYPESCRIPT