import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { previewName } from "../../scripts/deployment.ts"; const mocks = vi.hoisted(() => ({ exec: vi.fn< ( command: string, args: string[], options?: { stdio?: string; env?: NodeJS.ProcessEnv } ) => string >(), write: vi.fn(), unlink: vi.fn(), rmdir: vi.fn(), fetch: vi.fn() })); vi.mock("node:child_process", () => ({ execFileSync: mocks.exec })); vi.mock("node:fs", () => ({ mkdirSync: vi.fn(), mkdtempSync: () => ".cloudflare/ci-secrets-test", writeFileSync: mocks.write, unlinkSync: mocks.unlink, rmdirSync: mocks.rmdir })); function cfCalls(command: string): unknown[][] { return mocks.exec.mock.calls.filter((call: unknown[]) => { const [, args] = call; return Array.isArray(args) && args[1] === command; }); } beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); vi.stubGlobal("fetch", mocks.fetch); mocks.fetch.mockResolvedValue(new Response(null, { status: 200 })); vi.stubEnv("CLOUDFLARE_API_TOKEN", "test-api-token"); vi.stubEnv("CLOUDFLARE_ACCOUNT_ID", "test-account"); vi.stubEnv("TMDB_READ_ACCESS_TOKEN", "test-tmdb-token"); vi.stubEnv("TANGLED_PIPELINE_KIND", "push"); vi.stubEnv("TANGLED_REF_TYPE", "branch"); vi.stubEnv("TANGLED_REF_NAME", "main"); vi.stubEnv("TANGLED_SHA", "branch-sha"); vi.stubEnv("CROSSCREDIT_CI_ACTION", ""); vi.stubEnv("TANGLED_PR_SOURCE_BRANCH", "feature/search"); vi.stubEnv("TANGLED_PR_TARGET_BRANCH", "main"); vi.stubEnv("TANGLED_PR_SOURCE_SHA", "branch-sha"); vi.stubEnv("TANGLED_COMMENT_APP_PASSWORD", ""); vi.spyOn(console, "info").mockImplementation(() => { // Keep expected CI progress messages out of test output. }); mocks.exec.mockImplementation((command: string, args: string[]) => { if (args[1] === "previews" && args[2] === "deploy") { return JSON.stringify({ type: "preview", version: 1, preview_name: previewName("feature/search"), preview_urls: ["https://pr-search-crosscredit.example.workers.dev"], deployment_urls: [] }); } if (command !== "git") { return "[]"; } if (args[0] === "ls-remote") { return "branch-sha\trefs/heads/main"; } return ""; }); }); afterEach(() => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("production CI deployment", () => { it.each(["schedule", "manual"])("rejects %s events", async (kind) => { vi.stubEnv("TANGLED_PIPELINE_KIND", kind); await expect(import("../../scripts/ci.ts")).rejects.toThrow("Expected a Tangled push to main"); expect(mocks.exec).not.toHaveBeenCalled(); }); it("rejects non-main branch pushes", async () => { vi.stubEnv("TANGLED_REF_NAME", "feature/search"); await expect(import("../../scripts/ci.ts")).rejects.toThrow("Expected a Tangled push to main"); expect(mocks.exec).not.toHaveBeenCalled(); }); it("checks and builds main before deploying production", async () => { await import("../../scripts/ci.ts"); expect( mocks.exec.mock.calls.filter(([command]) => command === "pnpm").map(([, args]) => args) ).toEqual([["check"], ["typecheck"], ["test"], ["build", "--mode", "production"]]); expect(cfCalls("deploy")).toHaveLength(1); expect(cfCalls("workers")).toHaveLength(0); expect(mocks.fetch).not.toHaveBeenCalled(); }); it("stops before building or publishing when a quality check fails", async () => { mocks.exec.mockImplementation(() => { throw new Error("check failed"); }); await expect(import("../../scripts/ci.ts")).rejects.toThrow("check failed"); expect(mocks.exec.mock.calls.map(([, args]) => args)).toEqual([["check"]]); expect(mocks.write).not.toHaveBeenCalled(); }); it("rejects an unknown CI action before running commands", async () => { vi.stubEnv("CROSSCREDIT_CI_ACTION", "typo"); await expect(import("../../scripts/ci.ts")).rejects.toThrow("Unknown CI action"); expect(mocks.exec).not.toHaveBeenCalled(); expect(mocks.fetch).not.toHaveBeenCalled(); }); it("skips deployment if main advances during the build", async () => { let reads = 0; mocks.exec.mockImplementation((command) => { if (command !== "git") { return ""; } reads += 1; return reads === 1 ? "branch-sha\trefs/heads/main" : "new-sha\trefs/heads/main"; }); await import("../../scripts/ci.ts"); expect(cfCalls("deploy")).toHaveLength(0); expect(mocks.write).not.toHaveBeenCalled(); }); it("does not deploy an old push over the branch's newer commit", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "push"); vi.stubEnv("TANGLED_REF_TYPE", "branch"); vi.stubEnv("TANGLED_REF_NAME", "main"); vi.stubEnv("TANGLED_SHA", "outdated-sha"); await import("../../scripts/ci.ts"); expect(cfCalls("deploy")).toHaveLength(0); expect(mocks.write).not.toHaveBeenCalled(); }); it("removes the temporary secrets file even when deploying fails", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "push"); vi.stubEnv("TANGLED_REF_TYPE", "branch"); vi.stubEnv("TANGLED_REF_NAME", "main"); vi.stubEnv("TANGLED_SHA", "branch-sha"); const original = mocks.exec.getMockImplementation(); mocks.exec.mockImplementation((command: string, args: string[]) => { if (args[1] === "deploy") { throw new Error("deployment failed"); } return original?.(command, args) ?? ""; }); await expect(import("../../scripts/ci.ts")).rejects.toThrow("deployment failed"); expect(mocks.write).toHaveBeenCalledWith( expect.any(String), JSON.stringify({ TMDB_READ_ACCESS_TOKEN: "test-tmdb-token" }), { mode: 0o600 } ); expect(mocks.unlink).toHaveBeenCalledWith( expect.stringMatching(/ci-secrets-test[/\\]secrets\.json$/) ); expect(mocks.rmdir).toHaveBeenCalledWith(".cloudflare/ci-secrets-test"); }); }); describe("native PR previews", () => { it("uploads a native preview of the existing Worker without publishing production or writing secrets", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); await import("../../scripts/ci.ts"); const build = mocks.exec.mock.calls.find( ([command, args]) => command === "pnpm" && args[0] === "build" ); expect(build?.[1]).toEqual(["build", "--mode", "preview"]); expect(build?.[2]?.env?.CLOUDFLARE_PREVIEW_BUILD).toBe("true"); expect(cfCalls("previews").map(([, args]) => args)).toEqual([ [ expect.any(String), "previews", "deploy", previewName("feature/search"), "--prebuilt", "--mode", "preview", "--worker", "crosscredit" ] ]); expect(cfCalls("deploy")).toHaveLength(0); expect(cfCalls("workers")).toHaveLength(0); expect(mocks.write).not.toHaveBeenCalled(); expect(mocks.fetch).not.toHaveBeenCalled(); }); it("does not try to comment when preview upload fails", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); const original = mocks.exec.getMockImplementation(); mocks.exec.mockImplementation((command, args, options) => { if (args[1] === "previews") { throw new Error("preview upload failed"); } return original?.(command, args, options) ?? ""; }); await expect(import("../../scripts/ci.ts")).rejects.toThrow("preview upload failed"); expect(mocks.fetch).not.toHaveBeenCalled(); }); it("deletes only the native source-branch preview on the cleanup workflow", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); vi.stubEnv("CROSSCREDIT_CI_ACTION", "delete-preview"); await import("../../scripts/ci.ts"); expect(mocks.exec).not.toHaveBeenCalled(); expect(mocks.fetch).toHaveBeenCalledOnce(); expect(mocks.fetch.mock.calls[0]?.[0]).toBe( `https://api.cloudflare.com/client/v4/accounts/test-account/workers/workers/crosscredit/previews/${previewName("feature/search")}` ); expect(mocks.fetch.mock.calls[0]?.[1]?.method).toBe("DELETE"); expect(mocks.fetch.mock.calls[0]?.[1]?.headers).toEqual({ Authorization: "Bearer test-api-token" }); }); it("accepts cleanup when the Preview is already absent", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); vi.stubEnv("CROSSCREDIT_CI_ACTION", "delete-preview"); mocks.fetch.mockResolvedValue( Response.json({ errors: [{ code: 10_025, message: "Preview not found" }] }, { status: 404 }) ); await import("../../scripts/ci.ts"); expect(mocks.fetch).toHaveBeenCalledOnce(); }); it.each([403, 404, 500])("keeps other cleanup failures visible (HTTP %s)", async (status) => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); vi.stubEnv("CROSSCREDIT_CI_ACTION", "delete-preview"); mocks.fetch.mockResolvedValue(Response.json({ errors: [{ code: 9999 }] }, { status })); await expect(import("../../scripts/ci.ts")).rejects.toThrow( `Preview deletion failed (HTTP ${status})` ); }); it.each(["", "delete-preview"])( "skips untrusted PR operations (%s) when secrets are withheld", async (action) => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); vi.stubEnv("CROSSCREDIT_CI_ACTION", action); vi.stubEnv("CLOUDFLARE_API_TOKEN", ""); await import("../../scripts/ci.ts"); expect(mocks.exec).not.toHaveBeenCalled(); expect(mocks.fetch).not.toHaveBeenCalled(); } ); it("rejects cleanup on a production push", async () => { vi.stubEnv("CROSSCREDIT_CI_ACTION", "delete-preview"); await expect(import("../../scripts/ci.ts")).rejects.toThrow("Expected a Tangled push"); expect(mocks.exec).not.toHaveBeenCalled(); }); it("skips superseded PR revisions", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); vi.stubEnv("TANGLED_PR_SOURCE_SHA", "older-sha"); await import("../../scripts/ci.ts"); expect(cfCalls("previews")).toHaveLength(0); }); it("rejects PRs targeting another branch", async () => { vi.stubEnv("TANGLED_PIPELINE_KIND", "pull_request"); vi.stubEnv("TANGLED_PR_TARGET_BRANCH", "release"); await expect(import("../../scripts/ci.ts")).rejects.toThrow("Expected a Tangled push"); expect(mocks.exec).not.toHaveBeenCalled(); }); });