Public: anyone can read this; the crew can change it.
test/fork-flow.test.ts runs in under 5 s, every test under 1 s, proving what it proves today #10257
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10250-test-fork-flow-test-ts-runs-in-under-5-s → main 2 commits fork
2 files
changed +85
−57
src/origins/fork-route.ts +8 −2
| @@ -56,6 +56,12 @@ export const forkDeadline: { ms: number } = { ms: FORK_DEADLINE_MS }; | ||
| 56 | 56 | /** Backoff between polls, in ms; the last value repeats until the deadline. */ |
| 57 | 57 | const FORK_BACKOFF = [250, 500, 1_000, 2_000, 3_000] as const; |
| 58 | 58 | |
| 59 | /** | |
| 60 | * The backoff a fork poll walks. Mutable like `forkDeadline`: a test that | |
| 61 | * proves the poll goes round shortens the steps and puts back what it found. | |
| 62 | */ | |
| 63 | export const forkBackoff: { ms: readonly number[] } = { ms: FORK_BACKOFF }; | |
| 64 | ||
| 59 | 65 | /** Pushing a branch into the fork: the same cap as any push through. */ |
| 60 | 66 | const PUSH_TIMEOUT_MS = 120_000; |
| 61 | 67 | |
| @@ -128,8 +134,8 @@ export async function awaitFork( | ||
| 128 | 134 | ? "origin_unreachable" |
| 129 | 135 | : "origin_refused", |
| 130 | 136 | ); |
| 131 | const wait = | |
| 132 | FORK_BACKOFF[Math.min(attempt, FORK_BACKOFF.length - 1)] ?? 3_000; | |
| 137 | const steps = forkBackoff.ms; | |
| 138 | const wait = steps[Math.min(attempt, steps.length - 1)] ?? 3_000; | |
| 133 | 139 | if (Date.now() + wait > deadline) return err("fork_timeout"); |
| 134 | 140 | await new Promise((resolve) => setTimeout(resolve, wait)); |
| 135 | 141 | } |
test/fork-flow.test.ts +77 −55
| @@ -1,11 +1,12 @@ | ||
| 1 | 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { serve, type Servers } from "../src/http/server.ts"; |
| 7 | 7 | import { listEvents } from "../src/core/events.ts"; |
| 8 | 8 | import { firstFetchRetry } from "../src/origins/mirror.ts"; |
| 9 | import { forkBackoff } from "../src/origins/fork-route.ts"; | |
| 9 | 10 | import { settled } from "../src/origins/jobs.ts"; |
| 10 | 11 | import { |
| 11 | 12 | mirrorAndWait, |
| @@ -122,13 +123,19 @@ describe("the fork flow", () => { | ||
| 122 | 123 | }); |
| 123 | 124 | } |
| 124 | 125 | |
| 125 | const fakeFor = (kind: "forgejo" | "github", repos: FakeState["repos"]) => { | |
| 126 | // The origin does not finish making a fork before it answers: a read of | |
| 127 | // the new repository 404s before it appears. This is GitHub's real | |
| 128 | // behaviour, and the reason `forkOrigin` polls at all. One 404 makes the | |
| 129 | // poll go round; the backoff between polls is shortened to 10 ms, since | |
| 130 | // what is proved is that it goes round, not how long it waits. | |
| 131 | const fakeFor = ( | |
| 132 | kind: "forgejo" | "github", | |
| 133 | repos: FakeState["repos"], | |
| 134 | forkDelayReads = 1, | |
| 135 | ) => { | |
| 126 | 136 | const state = newFakeState(kind, ORIGIN_LOGIN, originToken); |
| 127 | 137 | state.repos.push(...repos); |
| 128 | // The origin does not finish making a fork before it answers: two reads | |
| 129 | // of the new repository 404 before it appears. This is GitHub's real | |
| 130 | // behaviour, and the reason `forkOrigin` polls at all. | |
| 131 | state.forkDelayReads = 2; | |
| 138 | state.forkDelayReads = forkDelayReads; | |
| 132 | 139 | return serveFakeForge(state); |
| 133 | 140 | }; |
| 134 | 141 | |
| @@ -145,32 +152,68 @@ describe("the fork flow", () => { | ||
| 145 | 152 | return (await read<{ id: string }>(connected)).id; |
| 146 | 153 | }; |
| 147 | 154 | |
| 155 | const foundBackoff = forkBackoff.ms; | |
| 156 | ||
| 148 | 157 | beforeAll(async () => { |
| 158 | forkBackoff.ms = [10]; | |
| 149 | 159 | dataDir = await mkdtemp(join(tmpdir(), "forge-ff-")); |
| 150 | 160 | originDataDir = await mkdtemp(join(tmpdir(), "forge-ff-up-")); |
| 151 | 161 | |
| 162 | // Both forges are built at once: nothing on one waits for the other | |
| 163 | // until the origins are connected. | |
| 152 | 164 | // ---- the upstream forge: the git host behind every fake clone URL ---- |
| 153 | 165 | originApp = createApp({ dataDir: originDataDir, port: 0 }); |
| 154 | originServers = await serve(originApp); | |
| 155 | await mkAccount(originServers, UPSTREAM_OWNER); | |
| 156 | originToken = await mkAccount(originServers, ORIGIN_LOGIN); | |
| 166 | // ---- the local forge ---- | |
| 167 | app = createApp({ dataDir, port: 0 }); | |
| 168 | [originServers, servers] = await Promise.all([serve(originApp), serve(app)]); | |
| 169 | base = servers.url; | |
| 170 | [, originToken, aliceToken, bobToken] = await Promise.all([ | |
| 171 | mkAccount(originServers, UPSTREAM_OWNER), | |
| 172 | mkAccount(originServers, ORIGIN_LOGIN), | |
| 173 | mkAccount(servers, "alice"), | |
| 174 | mkAccount(servers, "bob"), | |
| 175 | ]); | |
| 157 | 176 | proveAll(originApp); |
| 158 | 177 | promote(originApp.db); |
| 178 | proveAll(app); | |
| 179 | promote(app.db); | |
| 159 | 180 | |
| 160 | await seedUpstream("gears"); | |
| 161 | await seedUpstream("widgets"); | |
| 162 | // A repo `me` owns outright — the "you can already push here" case. | |
| 163 | await seedUpstream("own", ORIGIN_LOGIN); | |
| 181 | await Promise.all([ | |
| 182 | seedUpstream("gears"), | |
| 183 | seedUpstream("widgets"), | |
| 184 | // A repo `me` owns outright — the "you can already push here" case. | |
| 185 | seedUpstream("own", ORIGIN_LOGIN), | |
| 186 | ]); | |
| 164 | 187 | |
| 165 | 188 | // `me` really forks both upstreams, so the fake's clone URL for the fork |
| 166 | 189 | // points at a repository that exists and that `me` may push to. |
| 167 | for (const name of ["gears", "widgets"]) { | |
| 168 | const forked = await upstream( | |
| 169 | `/api/v1/repos/${UPSTREAM_OWNER}/${name}/forks`, | |
| 170 | { as: ORIGIN_LOGIN, method: "POST", body: "{}" }, | |
| 171 | ); | |
| 172 | expect(forked.status).toBe(201); | |
| 173 | } | |
| 190 | const forks = await Promise.all( | |
| 191 | ["gears", "widgets"].map((name) => | |
| 192 | upstream(`/api/v1/repos/${UPSTREAM_OWNER}/${name}/forks`, { | |
| 193 | as: ORIGIN_LOGIN, | |
| 194 | method: "POST", | |
| 195 | body: "{}", | |
| 196 | }), | |
| 197 | ), | |
| 198 | ); | |
| 199 | for (const forked of forks) expect(forked.status).toBe(201); | |
| 200 | ||
| 201 | // The upstream forge stands in for the origin's git, not for its push | |
| 202 | // policy: GitHub runs no forge hooks. Each contribution pushes twice into | |
| 203 | // these three repositories, and each push would spawn two Bun processes | |
| 204 | // to ask the upstream forge's own rules, about 100 ms a push. A hook | |
| 205 | // that accepts keeps the pushes real and leaves the local forge's own | |
| 206 | // hooks, which this file does exercise, as they are. | |
| 207 | await Promise.all( | |
| 208 | ["gears", "widgets", "own"].flatMap((name) => | |
| 209 | ["pre-receive", "post-receive"].map((hook) => | |
| 210 | writeFile( | |
| 211 | join(originDataDir, "repos", ORIGIN_LOGIN, `${name}.git`, "hooks", hook), | |
| 212 | "#!/bin/sh\nexit 0\n", | |
| 213 | ), | |
| 214 | ), | |
| 215 | ), | |
| 216 | ); | |
| 174 | 217 | |
| 175 | 218 | // The upstream moves on AFTER the fork: a branch started from the fork's |
| 176 | 219 | // own copy of main would now be proposing a stale tree. |
| @@ -216,20 +259,14 @@ describe("the fork flow", () => { | ||
| 216 | 259 | }, |
| 217 | 260 | ]); |
| 218 | 261 | |
| 219 | // ---- the local forge ---- | |
| 220 | app = createApp({ dataDir, port: 0 }); | |
| 221 | servers = await serve(app); | |
| 222 | base = servers.url; | |
| 223 | aliceToken = await mkAccount(servers, "alice"); | |
| 224 | bobToken = await mkAccount(servers, "bob"); | |
| 225 | proveAll(app); | |
| 226 | promote(app.db); | |
| 227 | ||
| 228 | forgejoOrigin = await connect(forgejo); | |
| 229 | githubOrigin = await connect(github, "github"); | |
| 262 | [forgejoOrigin, githubOrigin] = await Promise.all([ | |
| 263 | connect(forgejo), | |
| 264 | connect(github, "github"), | |
| 265 | ]); | |
| 230 | 266 | }); |
| 231 | 267 | |
| 232 | 268 | afterAll(async () => { |
| 269 | forkBackoff.ms = foundBackoff; | |
| 233 | 270 | forgejo.stop(); |
| 234 | 271 | github.stop(); |
| 235 | 272 | servers.stop(); |
| @@ -375,29 +412,6 @@ describe("the fork flow", () => { | ||
| 375 | 412 | expect(pull?.pull?.headRepo).toBe(`${ORIGIN_LOGIN}/gears`); |
| 376 | 413 | }); |
| 377 | 414 | |
| 378 | test("a second contribution reuses the fork rather than making another", async () => { | |
| 379 | const before = forgejo.state.requests.filter( | |
| 380 | (r) => r.method === "POST" && r.path.endsWith("/gears/forks"), | |
| 381 | ).length; | |
| 382 | const response = await api( | |
| 383 | `/api/v1/user/origins/${forgejoOrigin}/browse/${UPSTREAM_OWNER}/gears/contribute`, | |
| 384 | { | |
| 385 | method: "POST", | |
| 386 | body: JSON.stringify({ | |
| 387 | branch: "second-go", | |
| 388 | message: "another", | |
| 389 | files: [{ path: "b.txt", content: "two\n" }], | |
| 390 | openPull: { title: "another" }, | |
| 391 | }), | |
| 392 | }, | |
| 393 | ); | |
| 394 | expect(response.status).toBe(201); | |
| 395 | const after = forgejo.state.requests.filter( | |
| 396 | (r) => r.method === "POST" && r.path.endsWith("/gears/forks"), | |
| 397 | ).length; | |
| 398 | expect(after).toBe(before); | |
| 399 | }); | |
| 400 | ||
| 401 | 415 | test("push rights mean no fork: the branch lands on the upstream itself", async () => { |
| 402 | 416 | const before = forgejo.state.requests.filter( |
| 403 | 417 | (r) => r.method === "POST" && r.path.endsWith("/forks"), |
| @@ -492,7 +506,7 @@ describe("the fork flow", () => { | ||
| 492 | 506 | |
| 493 | 507 | // ---- contributing from a mirrored upstream ---- |
| 494 | 508 | |
| 495 | test("the repo route contributes to the upstream a mirror names", async () => { | |
| 509 | test("the repo route contributes to the upstream a mirror names, through the fork already made", async () => { | |
| 496 | 510 | // Mirror the upstream itself, then contribute from its own page. |
| 497 | 511 | const job = await mirrorAndWait( |
| 498 | 512 | await api(`/api/v1/user/origins/${forgejoOrigin}/mirror`, { |
| @@ -503,6 +517,11 @@ describe("the fork flow", () => { | ||
| 503 | 517 | expect(job?.stage).toBe("done"); |
| 504 | 518 | const mirror = job!.repo!; |
| 505 | 519 | mirrorPath = `${mirror.ownerName}/${mirror.name}`; |
| 520 | const forksAsked = () => | |
| 521 | forgejo.state.requests.filter( | |
| 522 | (r) => r.method === "POST" && r.path.endsWith("/gears/forks"), | |
| 523 | ).length; | |
| 524 | const before = forksAsked(); | |
| 506 | 525 | |
| 507 | 526 | const response = await api( |
| 508 | 527 | `/api/v1/repos/${mirror.ownerName}/${mirror.name}/contribute`, |
| @@ -524,6 +543,9 @@ describe("the fork flow", () => { | ||
| 524 | 543 | }>(response); |
| 525 | 544 | expect(result.via).toBe("fork"); |
| 526 | 545 | expect(result.forkFullName).toBe(`${ORIGIN_LOGIN}/gears`); |
| 546 | // A second contribution to gears reuses the fork the first one made | |
| 547 | // rather than asking the origin for another. | |
| 548 | expect(forksAsked()).toBe(before); | |
| 527 | 549 | // The upstream IS mirrored here, so the pull came back as a local row. |
| 528 | 550 | expect(result.pull?.local?.number).toBe(result.pull!.number); |
| 529 | 551 |