g1t/services/deployments/src/retries.test.ts
| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { commitMissing, missingCommitMessage, retryDecision, type PastBuild } from "./retries.ts"; |
| 5 | |
| 6 | const HOUR = 60 * 60 * 1000; |
| 7 | const NOW = Date.parse("2026-10-07T12:00:00Z"); |
| 8 | const GIT = |
| 9 | "the commit could not be checked out: git -c failed: fatal: reference is not a tree: d2ca224ba0fea430dcc9933cda14d8aa9fa94b4b"; |
| 10 | |
| 11 | const build = (hoursAgo: number, over: Partial<PastBuild> = {}): PastBuild => ({ |
| 12 | status: "failed", |
| 13 | error: "The build failed: npm run build exited 1.", |
| 14 | commit_sha: "d2ca224", |
| 15 | created_at: new Date(NOW - hoursAgo * HOUR).toISOString(), |
| 16 | ...over, |
| 17 | }); |
| 18 | |
| 19 | test("git's words for a commit that is gone are recognised, and only those", () => { |
| 20 | assert.equal(commitMissing(GIT), true); |
| 21 | assert.equal(commitMissing("fatal: remote error: upload-pack: not our ref d2ca224"), true); |
| 22 | assert.equal(commitMissing(missingCommitMessage({ kind: "preview", number: 1 })), true); |
| 23 | assert.equal(commitMissing("The build failed: npm run build exited 1."), false); |
| 24 | assert.equal(commitMissing(null), false); |
| 25 | }); |
| 26 | |
| 27 | test("a commit that is gone says so plainly, naming the pull request", () => { |
| 28 | assert.equal( |
| 29 | missingCommitMessage({ kind: "preview", number: 1 }), |
| 30 | "This pull request's commit no longer exists. Push again, or close pull request #1.", |
| 31 | ); |
| 32 | assert.match(missingCommitMessage({ kind: "preview", number: null }), /branch's commit no longer exists/); |
| 33 | assert.match(missingCommitMessage({ kind: "production", number: null }), /default branch/); |
| 34 | }); |
| 35 | |
| 36 | test("nothing built yet, or the last build went up long ago: build", () => { |
| 37 | assert.deepEqual(retryDecision([], NOW, HOUR, { backoff: true }), { kind: "build" }); |
| 38 | assert.deepEqual(retryDecision([build(3, { status: "ready", error: null })], NOW, HOUR, { backoff: true }), { kind: "build" }); |
| 39 | }); |
| 40 | |
| 41 | test("a commit that is gone is never tried again, with or without backoff", () => { |
| 42 | const history = [build(30, { error: GIT })]; |
| 43 | assert.deepEqual(retryDecision(history, NOW, HOUR, { backoff: true }), { kind: "stop", reason: "missing_commit" }); |
| 44 | assert.deepEqual(retryDecision(history, NOW, HOUR), { kind: "stop", reason: "missing_commit" }); |
| 45 | }); |
| 46 | |
| 47 | test("each identical failure doubles the wait", () => { |
| 48 | // One failure: an hour. |
| 49 | assert.equal(retryDecision([build(0.5)], NOW, HOUR, { backoff: true }).kind, "wait"); |
| 50 | assert.equal(retryDecision([build(1)], NOW, HOUR, { backoff: true }).kind, "build"); |
| 51 | // Two in a row: two hours after the last. |
| 52 | const two = [build(1.5), build(3)]; |
| 53 | assert.deepEqual(retryDecision(two, NOW, HOUR, { backoff: true }), { kind: "wait", until: NOW + 0.5 * HOUR }); |
| 54 | assert.equal(retryDecision([build(2), build(4)], NOW, HOUR, { backoff: true }).kind, "build"); |
| 55 | }); |
| 56 | |
| 57 | test("three identical failures in a row stop it", () => { |
| 58 | const history = [build(10), build(12), build(14)]; |
| 59 | assert.deepEqual(retryDecision(history, NOW, HOUR, { backoff: true }), { kind: "stop", reason: "failing" }); |
| 60 | assert.deepEqual(retryDecision(history, NOW, HOUR), { kind: "stop", reason: "failing" }); |
| 61 | }); |
| 62 | |
| 63 | test("a different error or commit starts the count again", () => { |
| 64 | const otherError = [build(10), build(12), build(14, { error: "Cloudflare did not take the app: 500" })]; |
| 65 | assert.equal(retryDecision(otherError, NOW, HOUR, { backoff: true }).kind, "build"); |
| 66 | const otherCommit = [build(10), build(12, { commit_sha: "8e500b1" }), build(14, { commit_sha: "8e500b1" })]; |
| 67 | assert.equal(retryDecision(otherCommit, NOW, HOUR, { backoff: true }).kind, "build"); |
| 68 | // A build that went up in between ends the run. |
| 69 | const recovered = [build(10), build(12, { status: "ready", error: null }), build(14)]; |
| 70 | assert.equal(retryDecision(recovered, NOW, HOUR, { backoff: true }).kind, "build"); |
| 71 | }); |
| 72 | |
| 73 | test("a refused or skipped rebuild waits the base delay with backoff, not without", () => { |
| 74 | const skipped = [build(0.5, { status: "skipped", error: "Over the limit." })]; |
| 75 | assert.equal(retryDecision(skipped, NOW, HOUR, { backoff: true }).kind, "wait"); |
| 76 | assert.equal(retryDecision(skipped, NOW, HOUR).kind, "build"); |
| 77 | }); |