Public: anyone can read this; the crew can change it.
test/checks.test.ts runs in under 10 s, every test under 1 s, proving what it proves today #10251
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10245-test-checks-test-ts-runs-in-under-10-s-e → main 1 commit fork
1 file
changed +159
−110
test/checks.test.ts +159 −110
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 8 | import { existsSync, mkdirSync } from "node:fs"; | |
| 8 | import { existsSync, mkdirSync, writeFileSync } from "node:fs"; | |
| 9 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| @@ -57,6 +57,7 @@ const checkJson = (run: string, extra = ""): string => | ||
| 57 | 57 | |
| 58 | 58 | describe("cancelRunsForPull: a landed or closed pull leaves nothing in the lane", () => { |
| 59 | 59 | test("queued and running checks and queued deploys of that pull are cancelled, building deploys and other pulls kept", async () => { |
| 60 | // Invariant: cancelRunsForPull cancels that pull's queued and running checks and queued deploys, and nothing else. | |
| 60 | 61 | const dir = await mkdtemp(join(tmpdir(), "forge-cancel-")); |
| 61 | 62 | const db = createApp({ dataDir: dir, port: 0 }); |
| 62 | 63 | try { |
| @@ -398,7 +399,7 @@ describe("checks", () => { | ||
| 398 | 399 | |
| 399 | 400 | async function settled( |
| 400 | 401 | where: (run: Run) => boolean, |
| 401 | timeoutMs = 30_000, | |
| 402 | timeoutMs = 4_000, | |
| 402 | 403 | ): Promise<Run> { |
| 403 | 404 | const deadline = Date.now() + timeoutMs; |
| 404 | 405 | while (Date.now() < deadline) { |
| @@ -406,11 +407,45 @@ describe("checks", () => { | ||
| 406 | 407 | where, |
| 407 | 408 | ); |
| 408 | 409 | if (run && (run.state === "ok" || run.state === "failed")) return run; |
| 409 | await Bun.sleep(50); | |
| 410 | await Bun.sleep(10); | |
| 410 | 411 | } |
| 411 | 412 | throw new Error("check did not settle"); |
| 412 | 413 | } |
| 413 | 414 | |
| 415 | /** Waits until `done` holds, polling the database; a check's own pace, not a timer's. */ | |
| 416 | async function until(done: () => boolean, what: string): Promise<void> { | |
| 417 | const deadline = Date.now() + 4_000; | |
| 418 | while (!done()) { | |
| 419 | if (Date.now() > deadline) throw new Error(`never came: ${what}`); | |
| 420 | await Bun.sleep(10); | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | /** | |
| 425 | * A check command that holds its slot until the test lets it go | |
| 426 | * (`release`), then runs `then`: the test is the clock, so no wait is | |
| 427 | * guessed at. Never released, it runs until it is cut off. | |
| 428 | */ | |
| 429 | const held = (name: string, then = "test -f OK.md"): string => | |
| 430 | `while [ ! -f '${dataDir}/go-${name}' ]; do sleep 0.01; done; ${then}`; | |
| 431 | const release = (name: string): void => | |
| 432 | writeFileSync(join(dataDir, `go-${name}`), ""); | |
| 433 | ||
| 434 | /** Waits until a fork pull's new head is announced on the bus as `pull.pushed`. */ | |
| 435 | const headAnnounced = (pull: Pull, headOid: string): Promise<void> => | |
| 436 | until( | |
| 437 | () => | |
| 438 | listEvents(app.db, { repoId: repo.id, kind: "pull.pushed" }).some( | |
| 439 | (e) => e.payload["number"] === pull.number && e.payload["headOid"] === headOid, | |
| 440 | ), | |
| 441 | "the pull's new head announced", | |
| 442 | ); | |
| 443 | ||
| 444 | const checksOf = (commitOid: string): Run[] => | |
| 445 | listRuns(app.db, repo.id, { kind: "check", limit: 50 }).filter( | |
| 446 | (r) => r.commitOid === commitOid, | |
| 447 | ); | |
| 448 | ||
| 414 | 449 | beforeAll(async () => { |
| 415 | 450 | dataDir = await mkdtemp(join(tmpdir(), "forge-checks-")); |
| 416 | 451 | app = createApp({ dataDir, port: 0 }); |
| @@ -452,16 +487,25 @@ describe("checks", () => { | ||
| 452 | 487 | }); |
| 453 | 488 | |
| 454 | 489 | test("a commit that declares no check is not checked", async () => { |
| 490 | // Invariant: a push whose commit declares no check queues no run; the next push that declares one is the only run. | |
| 455 | 491 | const seeded = await commitToBranch(app, alice, repo, { |
| 456 | 492 | message: "Start", |
| 457 | 493 | files: [{ path: "README.md", content: "# lib\n" }], |
| 458 | 494 | }); |
| 459 | 495 | expect(seeded.ok).toBe(true); |
| 460 | await Bun.sleep(300); | |
| 461 | expect(listRuns(app.db, repo.id, { kind: "check" })).toHaveLength(0); | |
| 496 | // The push after it declares a check: once that one settles, the | |
| 497 | // first push's handler (begun earlier, doing no more) has answered too. | |
| 498 | const declared = await commitToBranch(app, alice, repo, { | |
| 499 | message: "Declare a passing check", | |
| 500 | files: [{ path: "forge.json", content: checkJson("true # first") }], | |
| 501 | }); | |
| 502 | if (!declared.ok) throw new Error(JSON.stringify(declared.error)); | |
| 503 | const run = await settled((r) => r.commitOid === declared.value.commitOid); | |
| 504 | expect(listRuns(app.db, repo.id, { kind: "check" }).map((r) => r.id)).toEqual([run.id]); | |
| 462 | 505 | }); |
| 463 | 506 | |
| 464 | 507 | test("the default branch is checked on push, with the log kept", async () => { |
| 508 | // Invariant: a push to the default branch that declares a check runs it, env main, reason push, its commands in the log. | |
| 465 | 509 | const declared = await commitToBranch(app, alice, repo, { |
| 466 | 510 | message: "Declare a check", |
| 467 | 511 | files: [ |
| @@ -480,6 +524,7 @@ describe("checks", () => { | ||
| 480 | 524 | }); |
| 481 | 525 | |
| 482 | 526 | test("a check's children hold no cells of their own: the check's cell is the boundary", async () => { |
| 527 | // Invariant: a check's command sees FORGE_CELLS=off. | |
| 483 | 528 | // A forge the tests boot inside a check cell must not wrap its apps in |
| 484 | 529 | // nested cells (they never answer in time); it is told so in its env. |
| 485 | 530 | const declared = await commitToBranch(app, alice, repo, { |
| @@ -502,6 +547,7 @@ describe("checks", () => { | ||
| 502 | 547 | }); |
| 503 | 548 | |
| 504 | 549 | test("a pull is checked when it opens and each time its branch moves; a failure names the exit", async () => { |
| 550 | // Invariant: a branch heading no pull is not checked; opening a pull checks its head with the base's check, and each push to it again. | |
| 505 | 551 | // A pull is checked by the base's check, not its own. |
| 506 | 552 | const gate = await commitToBranch(app, alice, repo, { |
| 507 | 553 | message: "The base's check", |
| @@ -514,13 +560,6 @@ describe("checks", () => { | ||
| 514 | 560 | files: [{ path: "a.txt", content: "a\n" }], |
| 515 | 561 | }); |
| 516 | 562 | if (!branched.ok) throw new Error(JSON.stringify(branched.error)); |
| 517 | // A branch that heads no pull is nobody's to check yet. | |
| 518 | await Bun.sleep(300); | |
| 519 | expect( | |
| 520 | listRuns(app.db, repo.id, { kind: "check" }).some( | |
| 521 | (r) => r.commitOid === branched.value.commitOid, | |
| 522 | ), | |
| 523 | ).toBe(false); | |
| 524 | 563 | const opened = await openPull(app, alice, repo, { |
| 525 | 564 | title: "Feature", |
| 526 | 565 | baseBranch: "main", |
| @@ -534,6 +573,9 @@ describe("checks", () => { | ||
| 534 | 573 | expect(failed.state).toBe("failed"); |
| 535 | 574 | expect(failed.env).toBe(`pull-${pull.number}`); |
| 536 | 575 | expect(failed.reason).toBe("pull"); |
| 576 | // A branch that heads no pull is nobody's to check: its push, handled | |
| 577 | // before the pull opened, left no run beside the pull's own. | |
| 578 | expect(checksOf(branched.value.commitOid).map((r) => r.id)).toEqual([failed.id]); | |
| 537 | 579 | expect(failed.summary).toBe("check failed: check exited with code 1"); |
| 538 | 580 | // `test -f` is not `bun test`: its output is not this shape, so nothing |
| 539 | 581 | // is guessed at it. |
| @@ -584,9 +626,10 @@ describe("checks", () => { | ||
| 584 | 626 | ); |
| 585 | 627 | expect(passed.state).toBe("ok"); |
| 586 | 628 | expect(passed.reason).toBe("push"); |
| 587 | }, 60_000); | |
| 629 | }); | |
| 588 | 630 | |
| 589 | 631 | test("a bun test failure in the check step is summarised: how many passed, how many failed, and their names", async () => { |
| 632 | // Invariant: a red `bun test` check's summary and detail carry the counts and the failing names. | |
| 590 | 633 | const gate = await commitToBranch(app, alice, repo, { |
| 591 | 634 | message: "The base's check runs bun test", |
| 592 | 635 | files: [ |
| @@ -630,9 +673,10 @@ describe("checks", () => { | ||
| 630 | 673 | expect(failed.summary).toBe( |
| 631 | 674 | 'check failed: 2 of 3 tests — "first failing test" and "second failing test"', |
| 632 | 675 | ); |
| 633 | }, 30_000); | |
| 676 | }); | |
| 634 | 677 | |
| 635 | 678 | test("a reader's fork pull is checked at the base's snapshot once a writer approves each head", async () => { |
| 679 | // Invariant: a reader's fork pull runs nothing until a writer approves, and each new head waits for its own approval. | |
| 636 | 680 | const gate = await commitToBranch(app, alice, repo, { |
| 637 | 681 | message: "The base's check passes", |
| 638 | 682 | files: [{ path: "forge.json", content: checkJson("true") }], |
| @@ -672,13 +716,10 @@ describe("checks", () => { | ||
| 672 | 716 | files: [{ path: "FORK.md", content: "hi again\n" }], |
| 673 | 717 | }); |
| 674 | 718 | if (!again.ok) throw new Error(JSON.stringify(again.error)); |
| 675 | // A new head waits for its own approval. | |
| 676 | await Bun.sleep(300); | |
| 677 | expect( | |
| 678 | listRuns(app.db, repo.id, { kind: "check" }).some( | |
| 679 | (r) => r.issueId === pull.id && r.commitOid === again.value.commitOid, | |
| 680 | ), | |
| 681 | ).toBe(false); | |
| 719 | // The pull follows the fork's branch on its own clock: approve the new | |
| 720 | // head only once its move was announced and answered (the bus delivers | |
| 721 | // in step, so the push's handler has met the approval guard by then). | |
| 722 | await headAnnounced(pull, again.value.commitOid); | |
| 682 | 723 | const reapproved = await submitReview(app, alice, repo, pull, { |
| 683 | 724 | state: "approved", |
| 684 | 725 | }); |
| @@ -687,9 +728,14 @@ describe("checks", () => { | ||
| 687 | 728 | (r) => r.issueId === pull.id && r.commitOid === again.value.commitOid, |
| 688 | 729 | ); |
| 689 | 730 | expect(second.state).toBe("ok"); |
| 690 | }, 60_000); | |
| 731 | // A new head waits for its own approval: the push, handled before the | |
| 732 | // approval was given, ran nothing; the one check is the approval's. | |
| 733 | expect(second.reason).toBe("approved"); | |
| 734 | expect(checksOf(again.value.commitOid).map((r) => r.id)).toEqual([second.id]); | |
| 735 | }); | |
| 691 | 736 | |
| 692 | 737 | test("approving a head whose check is already green queues no second check (#4391)", async () => { |
| 738 | // Invariant: a second approval of a head already green on the same base queues nothing. | |
| 693 | 739 | const based = await commitToBranch(app, alice, repo, { |
| 694 | 740 | message: "The base's check passes", |
| 695 | 741 | files: [{ path: "forge.json", content: checkJson("true") }], |
| @@ -729,11 +775,24 @@ describe("checks", () => { | ||
| 729 | 775 | body: "Still good.", |
| 730 | 776 | }); |
| 731 | 777 | if (!again.ok) throw new Error(String(again.error)); |
| 732 | await Bun.sleep(300); | |
| 778 | // The fork moves on and its new head is approved: once that check | |
| 779 | // settles, the second approval's handler (begun earlier, doing no | |
| 780 | // more) has answered too. | |
| 781 | const next = await commitToBranch(app, bob, fork, { | |
| 782 | branch: "green-then-approved", | |
| 783 | message: "After the second look", | |
| 784 | files: [{ path: "GREEN.md", content: "y\n" }], | |
| 785 | }); | |
| 786 | if (!next.ok) throw new Error(JSON.stringify(next.error)); | |
| 787 | await headAnnounced(pull, next.value.commitOid); | |
| 788 | const third = await submitReview(app, alice, repo, pull, { state: "approved" }); | |
| 789 | if (!third.ok) throw new Error(String(third.error)); | |
| 790 | await settled((r) => r.issueId === pull.id && r.commitOid === next.value.commitOid); | |
| 733 | 791 | expect(mine().map((r) => r.id)).toEqual([first.id]); |
| 734 | }, 60_000); | |
| 792 | }); | |
| 735 | 793 | |
| 736 | 794 | test("a commit already proven green is not run again; the re-request answers with the earlier verdict (#1795)", async () => { |
| 795 | // Invariant: a re-requested check of a commit already green answers with that verdict without running the command. | |
| 737 | 796 | const made = await commitToBranch(app, alice, repo, { |
| 738 | 797 | message: "Proven once", |
| 739 | 798 | files: [ |
| @@ -750,14 +809,13 @@ describe("checks", () => { | ||
| 750 | 809 | expect(second.state).toBe("ok"); |
| 751 | 810 | expect(second.summary).toBe(`already proven green by ${first.id}`); |
| 752 | 811 | expect(readRunLog(dataDir, second.id).lines.join("\n")).not.toContain("ran-once"); |
| 753 | }, 60_000); | |
| 812 | }); | |
| 754 | 813 | |
| 755 | 814 | test("a newer commit supersedes a check still waiting for the same environment", async () => { |
| 815 | // Invariant: a queued check is cancelled as superseded when a newer commit asks for the same environment. | |
| 756 | 816 | const slow = await commitToBranch(app, alice, repo, { |
| 757 | 817 | message: "A slow check", |
| 758 | files: [ | |
| 759 | { path: "forge.json", content: checkJson("sleep 0.5 && test -f OK.md") }, | |
| 760 | ], | |
| 818 | files: [{ path: "forge.json", content: checkJson(held("supersede")) }], | |
| 761 | 819 | }); |
| 762 | 820 | if (!slow.ok) throw new Error(JSON.stringify(slow.error)); |
| 763 | 821 | // While that one holds the only slot, two more arrive: the first of |
| @@ -774,26 +832,28 @@ describe("checks", () => { | ||
| 774 | 832 | files: [{ path: "TWO.md", content: "2\n" }], |
| 775 | 833 | }); |
| 776 | 834 | if (!two.ok) throw new Error(JSON.stringify(two.error)); |
| 835 | await until(() => checksOf(two.value.commitOid).length > 0, "two queued"); | |
| 777 | 836 | const three = await commitToBranch(app, alice, repo, { |
| 778 | 837 | message: "Three", |
| 779 | 838 | files: [{ path: "THREE.md", content: "3\n" }], |
| 780 | 839 | }); |
| 781 | 840 | if (!three.ok) throw new Error(JSON.stringify(three.error)); |
| 841 | await until(() => checksOf(three.value.commitOid).length > 0, "three queued"); | |
| 842 | release("supersede"); | |
| 782 | 843 | const last = await settled((r) => r.commitOid === three.value.commitOid); |
| 783 | 844 | expect(last.state).toBe("ok"); |
| 784 | const middle = listRuns(app.db, repo.id, { kind: "check" }).find( | |
| 785 | (r) => r.commitOid === two.value.commitOid, | |
| 786 | ); | |
| 845 | const middle = checksOf(two.value.commitOid)[0]; | |
| 787 | 846 | expect(middle?.state).toBe("cancelled"); |
| 788 | 847 | expect(middle?.summary).toContain("superseded by"); |
| 789 | }, 60_000); | |
| 848 | }); | |
| 790 | 849 | |
| 791 | 850 | test("a new head on a pull stops its running check for the old head, superseded not failed", async () => { |
| 851 | // Invariant: a pull's new head cuts off the running check of its old head, which ends cancelled as superseded. | |
| 792 | 852 | const slow = await commitToBranch(app, alice, repo, { |
| 793 | 853 | branch: "supersede", |
| 794 | message: "A very slow check", | |
| 854 | message: "A check that never ends on its own", | |
| 795 | 855 | files: [ |
| 796 | { path: "forge.json", content: checkJson("sleep 30") }, | |
| 856 | { path: "forge.json", content: checkJson(held("never")) }, | |
| 797 | 857 | { path: "S.md", content: "s\n" }, |
| 798 | 858 | ], |
| 799 | 859 | }); |
| @@ -813,11 +873,7 @@ describe("checks", () => { | ||
| 813 | 873 | opened.value.id, |
| 814 | 874 | ); |
| 815 | 875 | if (!old) throw new Error("no run"); |
| 816 | const began = Date.now(); | |
| 817 | while (getRun(app.db, old.id)?.state !== "running") { | |
| 818 | if (Date.now() - began > 20_000) throw new Error("never started"); | |
| 819 | await Bun.sleep(50); | |
| 820 | } | |
| 876 | await until(() => getRun(app.db, old.id)?.state === "running", "old head running"); | |
| 821 | 877 | const next = await commitToBranch(app, alice, repo, { |
| 822 | 878 | branch: "supersede", |
| 823 | 879 | message: "Fast", |
| @@ -831,20 +887,20 @@ describe("checks", () => { | ||
| 831 | 887 | const stopped = getRun(app.db, old.id); |
| 832 | 888 | expect(stopped?.state).toBe("cancelled"); |
| 833 | 889 | expect(stopped?.summary).toContain("superseded by"); |
| 834 | // Well short of the 30 s the old check would have taken. | |
| 835 | expect(Date.now() - began).toBeLessThan(25_000); | |
| 836 | }, 60_000); | |
| 890 | // Stopped, not only marked: the old check, which never ends on its own, | |
| 891 | // was cut off and wrote its end. | |
| 892 | await until( | |
| 893 | () => readRunLog(dataDir, old.id).lines.join("\n").includes("superseded"), | |
| 894 | "the old head's check cut off", | |
| 895 | ); | |
| 896 | }); | |
| 837 | 897 | |
| 838 | 898 | for (const ending of ["merge", "close"] as const) |
| 839 | 899 | test(`${ending === "merge" ? "merging" : "closing"} a pull leaves no queued or running run for it`, async () => { |
| 900 | // Invariant: a pull merged or closed has its queued checks cancelled with that reason. | |
| 840 | 901 | const blocker = await commitToBranch(app, alice, repo, { |
| 841 | 902 | message: "Hold the lane", |
| 842 | files: [ | |
| 843 | { | |
| 844 | path: "forge.json", | |
| 845 | content: checkJson(`sleep 0.5 && test -f OK.md # ${ending}`), | |
| 846 | }, | |
| 847 | ], | |
| 903 | files: [{ path: "forge.json", content: checkJson(held(`lane-${ending}`)) }], | |
| 848 | 904 | }); |
| 849 | 905 | if (!blocker.ok) throw new Error(JSON.stringify(blocker.error)); |
| 850 | 906 | await checks.request(repo, "lane", blocker.value.commitOid, "push"); |
| @@ -865,12 +921,7 @@ describe("checks", () => { | ||
| 865 | 921 | listRuns(app.db, repo.id, { kind: "check" }).filter( |
| 866 | 922 | (r) => r.issueId === pull.id, |
| 867 | 923 | ); |
| 868 | const until = async (done: () => boolean): Promise<void> => { | |
| 869 | const deadline = Date.now() + 10_000; | |
| 870 | while (!done() && Date.now() < deadline) await Bun.sleep(25); | |
| 871 | if (!done()) throw new Error(`never came: still waiting while !done()`); | |
| 872 | }; | |
| 873 | await until(() => mine().length > 0); | |
| 924 | await until(() => mine().length > 0, "the pull's check queued"); | |
| 874 | 925 | expect(mine().some((r) => r.state === "queued")).toBe(true); |
| 875 | 926 | if (ending === "merge") { |
| 876 | 927 | const landed = await mergePull(app, alice, repo, pull, "merge"); |
| @@ -879,7 +930,7 @@ describe("checks", () => { | ||
| 879 | 930 | const closed = setIssueState(app, alice, repo, pull, "closed"); |
| 880 | 931 | if (!closed.ok) throw new Error(String(closed.error)); |
| 881 | 932 | } |
| 882 | await until(() => mine().every((r) => r.state !== "queued")); | |
| 933 | await until(() => mine().every((r) => r.state !== "queued"), "the pull's check gone"); | |
| 883 | 934 | expect(mine().filter((r) => r.state === "queued")).toEqual([]); |
| 884 | 935 | expect( |
| 885 | 936 | mine().some( |
| @@ -887,7 +938,13 @@ describe("checks", () => { | ||
| 887 | 938 | r.summary === (ending === "merge" ? "pull landed" : "pull closed"), |
| 888 | 939 | ), |
| 889 | 940 | ).toBe(true); |
| 890 | }, 60_000); | |
| 941 | // The lane is let go, and left empty for the next test. | |
| 942 | release(`lane-${ending}`); | |
| 943 | await until( | |
| 944 | () => checksOf(blocker.value.commitOid).every((r) => r.state !== "queued" && r.state !== "running"), | |
| 945 | "the lane's holder done", | |
| 946 | ); | |
| 947 | }); | |
| 891 | 948 | |
| 892 | 949 | test("a queued gate keeps the last free slot from pull checks", () => { |
| 893 | 950 | // One slot free, a gate queued: the pull check passes, the gate starts. |
| @@ -947,6 +1004,7 @@ describe("checks", () => { | ||
| 947 | 1004 | }); |
| 948 | 1005 | |
| 949 | 1006 | test("when a slot frees, the newest of several queued checks runs first", async () => { |
| 1007 | // Invariant: of several checks queued behind a held slot, the newest starts first. | |
| 950 | 1008 | // Three checks, on three environments so none supersedes another. Their |
| 951 | 1009 | // commits are written before the slot is taken: written after, on a |
| 952 | 1010 | // loaded machine they can outlast the holder, and the oldest then finds |
| @@ -972,18 +1030,18 @@ describe("checks", () => { | ||
| 972 | 1030 | |
| 973 | 1031 | // A slow check on its own environment holds the only slot, and the |
| 974 | 1032 | // three queue behind it — oldest requested first. |
| 975 | const held = await commitToBranch(app, alice, repo, { | |
| 1033 | const holding = await commitToBranch(app, alice, repo, { | |
| 976 | 1034 | branch: "order-hold", |
| 977 | 1035 | message: "Hold the slot", |
| 978 | 1036 | files: [ |
| 979 | { path: "forge.json", content: checkJson("sleep 0.5 && test -f OK.md") }, | |
| 1037 | { path: "forge.json", content: checkJson(held("order")) }, | |
| 980 | 1038 | ], |
| 981 | 1039 | }); |
| 982 | if (!held.ok) throw new Error(JSON.stringify(held.error)); | |
| 1040 | if (!holding.ok) throw new Error(JSON.stringify(holding.error)); | |
| 983 | 1041 | const holder = await checks.request( |
| 984 | 1042 | repo, |
| 985 | 1043 | "order-hold", |
| 986 | held.value.commitOid, | |
| 1044 | holding.value.commitOid, | |
| 987 | 1045 | "push", |
| 988 | 1046 | ); |
| 989 | 1047 | expect(holder).toBeDefined(); |
| @@ -1011,6 +1069,7 @@ describe("checks", () => { | ||
| 1011 | 1069 | expect(middleRun?.state).toBe("queued"); |
| 1012 | 1070 | expect(newestRun?.state).toBe("queued"); |
| 1013 | 1071 | |
| 1072 | release("order"); | |
| 1014 | 1073 | await settled((r) => r.id === newestRun!.id); |
| 1015 | 1074 | await settled((r) => r.id === middleRun!.id); |
| 1016 | 1075 | await settled((r) => r.id === oldestRun!.id); |
| @@ -1020,32 +1079,19 @@ describe("checks", () => { | ||
| 1020 | 1079 | Date.parse(runs.find((r) => r.id === id)!.startedAt!); |
| 1021 | 1080 | expect(startedAt(newestRun!.id)).toBeLessThan(startedAt(middleRun!.id)); |
| 1022 | 1081 | expect(startedAt(middleRun!.id)).toBeLessThan(startedAt(oldestRun!.id)); |
| 1023 | }, 30_000); | |
| 1082 | }); | |
| 1024 | 1083 | |
| 1025 | 1084 | test("a check in flight when the forge stops is queued again, and the next forge runs it", async () => { |
| 1026 | const slow = await commitToBranch(app, alice, repo, { | |
| 1027 | message: "A slower check", | |
| 1028 | files: [ | |
| 1029 | { | |
| 1030 | path: "forge.json", | |
| 1031 | // Hangs the first time it runs, passes the second: no wait to tune. | |
| 1032 | content: checkJson( | |
| 1033 | `if [ -f '${dataDir}/ran-once' ]; then test -f OK.md; else touch '${dataDir}/ran-once'; sleep 60; fi`, | |
| 1034 | ), | |
| 1035 | }, | |
| 1036 | ], | |
| 1037 | }); | |
| 1038 | if (!slow.ok) throw new Error(JSON.stringify(slow.error)); | |
| 1039 | const oid = slow.value.commitOid; | |
| 1040 | const deadline = Date.now() + 20_000; | |
| 1041 | let cut: Run | undefined; | |
| 1042 | while (Date.now() < deadline && cut?.state !== "running") { | |
| 1043 | cut = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 1044 | (r) => r.commitOid === oid, | |
| 1045 | ); | |
| 1046 | await Bun.sleep(50); | |
| 1047 | } | |
| 1048 | expect(cut?.state).toBe("running"); | |
| 1085 | // Invariant: stopping the service cancels a running check as queued again, attempt + 1, and the next service runs it to the end. | |
| 1086 | // Hangs the first time it runs, passes the second: no wait to tune. | |
| 1087 | const id = await slowCheck( | |
| 1088 | `if [ -f '${dataDir}/ran-once' ]; then test -f OK.md; else touch '${dataDir}/ran-once'; ${held("never")}; fi`, | |
| 1089 | "A slower check", | |
| 1090 | ); | |
| 1091 | // Running is said before the command begins: stop only once it has. | |
| 1092 | await until(() => existsSync(join(dataDir, "ran-once")), "the command begun"); | |
| 1093 | const oid = getRun(app.db, id)!.commitOid; | |
| 1094 | const cut = getRun(app.db, id); | |
| 1049 | 1095 | |
| 1050 | 1096 | // This forge stops — a release — with the check under way. |
| 1051 | 1097 | await checks.stop(); |
| @@ -1060,7 +1106,7 @@ describe("checks", () => { | ||
| 1060 | 1106 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); |
| 1061 | 1107 | const done = await settled((r) => r.id === again!.id); |
| 1062 | 1108 | expect(done.state).toBe("ok"); |
| 1063 | }, 60_000); | |
| 1109 | }); | |
| 1064 | 1110 | |
| 1065 | 1111 | async function slowCheck(command: string, message: string): Promise<string> { |
| 1066 | 1112 | const made = await commitToBranch(app, alice, repo, { |
| @@ -1069,27 +1115,24 @@ describe("checks", () => { | ||
| 1069 | 1115 | }); |
| 1070 | 1116 | if (!made.ok) throw new Error(JSON.stringify(made.error)); |
| 1071 | 1117 | const oid = made.value.commitOid; |
| 1072 | const deadline = Date.now() + 20_000; | |
| 1073 | let found: Run | undefined; | |
| 1074 | while (Date.now() < deadline && found?.state !== "running") { | |
| 1075 | found = listRuns(app.db, repo.id, { kind: "check", limit: 50 }).find( | |
| 1076 | (r) => r.commitOid === oid, | |
| 1077 | ); | |
| 1078 | await Bun.sleep(50); | |
| 1079 | } | |
| 1080 | expect(found?.state).toBe("running"); | |
| 1081 | return found!.id; | |
| 1118 | await until(() => checksOf(oid)[0]?.state === "running", `${message} running`); | |
| 1119 | return checksOf(oid)[0]!.id; | |
| 1082 | 1120 | } |
| 1083 | 1121 | |
| 1084 | 1122 | test("a roll waits for a running check to finish, within its budget, instead of throwing it away", async () => { |
| 1123 | // Invariant: stop with a drain budget lets a running check finish to its verdict, leaving nothing queued. | |
| 1085 | 1124 | await checks.stop(); |
| 1086 | 1125 | checks = startChecks(app, { |
| 1087 | 1126 | forgeUrl: servers.url, |
| 1088 | 1127 | concurrency: 1, |
| 1089 | 1128 | drainMs: 30_000, |
| 1090 | 1129 | }); |
| 1091 | const id = await slowCheck("sleep 0.5 && test -f OK.md # drained", "A drained check"); | |
| 1092 | await checks.stop(); | |
| 1130 | const id = await slowCheck(held("drained"), "A drained check"); | |
| 1131 | // Let go only once the stop is under way: a stop that did not wait | |
| 1132 | // would have cut it off already. | |
| 1133 | const stopping = checks.stop(); | |
| 1134 | release("drained"); | |
| 1135 | await stopping; | |
| 1093 | 1136 | const run = getRun(app.db, id); |
| 1094 | 1137 | expect(run?.state).toBe("ok"); |
| 1095 | 1138 | expect( |
| @@ -1098,16 +1141,17 @@ describe("checks", () => { | ||
| 1098 | 1141 | ), |
| 1099 | 1142 | ).toBe(false); |
| 1100 | 1143 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); |
| 1101 | }, 60_000); | |
| 1144 | }); | |
| 1102 | 1145 | |
| 1103 | 1146 | test("a person's stop cuts a running check off at once, and queues it again", async () => { |
| 1147 | // Invariant: stop without drain cancels a running check at once as queued again, and the next service runs it. | |
| 1104 | 1148 | await checks.stop(); |
| 1105 | 1149 | checks = startChecks(app, { |
| 1106 | 1150 | forgeUrl: servers.url, |
| 1107 | 1151 | concurrency: 1, |
| 1108 | 1152 | drainMs: 30_000, |
| 1109 | 1153 | }); |
| 1110 | const id = await slowCheck("sleep 0.5 && test -f OK.md # cut off", "A cut-off check"); | |
| 1154 | const id = await slowCheck(held("cut"), "A cut-off check"); | |
| 1111 | 1155 | await checks.stop({ drain: false }); |
| 1112 | 1156 | const run = getRun(app.db, id); |
| 1113 | 1157 | expect(run?.state).toBe("cancelled"); |
| @@ -1116,26 +1160,27 @@ describe("checks", () => { | ||
| 1116 | 1160 | (r) => r.commitOid === run?.commitOid && r.state === "queued", |
| 1117 | 1161 | ); |
| 1118 | 1162 | expect(again).toBeDefined(); |
| 1163 | release("cut"); | |
| 1119 | 1164 | checks = startChecks(app, { forgeUrl: servers.url, concurrency: 1 }); |
| 1120 | 1165 | const done = await settled((r) => r.id === again?.id); |
| 1121 | 1166 | expect(done.state).toBe("ok"); |
| 1122 | }, 60_000); | |
| 1167 | }); | |
| 1123 | 1168 | |
| 1124 | 1169 | test("a check whose pull lands while it runs ends as superseded, not failed", async () => { |
| 1125 | const id = await slowCheck("sleep 0.5 && false", "A check overtaken"); | |
| 1170 | // Invariant: a running check whose row is cancelled under it logs superseded with the reason, never failed, even when its command fails. | |
| 1171 | const id = await slowCheck(held("overtaken", "false"), "A check overtaken"); | |
| 1126 | 1172 | cancelRun(app.db, id, "pull landed"); |
| 1127 | const deadline = Date.now() + 20_000; | |
| 1128 | let lines = ""; | |
| 1129 | while (Date.now() < deadline && !lines.includes("superseded")) { | |
| 1130 | await Bun.sleep(100); | |
| 1131 | lines = readRunLog(dataDir, id).lines.join("\n"); | |
| 1132 | } | |
| 1173 | release("overtaken"); | |
| 1174 | const log = (): string => readRunLog(dataDir, id).lines.join("\n"); | |
| 1175 | await until(() => log().includes("superseded"), "superseded in the log"); | |
| 1176 | const lines = log(); | |
| 1133 | 1177 | expect(lines).toContain("superseded: pull landed"); |
| 1134 | 1178 | expect(lines).not.toContain("failed:"); |
| 1135 | 1179 | expect(getRun(app.db, id)?.state).toBe("cancelled"); |
| 1136 | }, 60_000); | |
| 1180 | }); | |
| 1137 | 1181 | |
| 1138 | 1182 | test("a check that is declared wrong fails with the reason", async () => { |
| 1183 | // Invariant: a malformed check declaration fails the run with the parse reason. | |
| 1139 | 1184 | const wrong = await commitToBranch(app, alice, repo, { |
| 1140 | 1185 | message: "A wrong check", |
| 1141 | 1186 | files: [{ path: "forge.json", content: '{"check": "bun test"}\n' }], |
| @@ -1147,7 +1192,9 @@ describe("checks", () => { | ||
| 1147 | 1192 | "check failed: forge.json: check must be an object", |
| 1148 | 1193 | ); |
| 1149 | 1194 | }); |
| 1195 | ||
| 1150 | 1196 | test("an open pull whose head was never checked is checked when the service boots", async () => { |
| 1197 | // Invariant: a booting service checks the head of an open pull that has no check, with reason boot. | |
| 1151 | 1198 | await checks.stop(); |
| 1152 | 1199 | // The pull is checked by the base's check, so the base must ask for it. |
| 1153 | 1200 | const gate = await commitToBranch(app, alice, repo, { |
| @@ -1184,7 +1231,7 @@ describe("checks", () => { | ||
| 1184 | 1231 | const done = await settled((r) => r.commitOid === opened.value.headOid); |
| 1185 | 1232 | expect(done.reason).toBe("boot"); |
| 1186 | 1233 | expect(done.state).toBe("ok"); |
| 1187 | }, 60_000); | |
| 1234 | }); | |
| 1188 | 1235 | }); |
| 1189 | 1236 | |
| 1190 | 1237 | describe("a check its forge died under", () => { |
| @@ -1335,6 +1382,7 @@ describe("a check its forge died under", () => { | ||
| 1335 | 1382 | |
| 1336 | 1383 | describe("startChecks: a booting forge removes only checkouts nobody is running (#1884)", () => { |
| 1337 | 1384 | test("a live run's checkout survives, a lapsed run's and a stranger's go", async () => { |
| 1385 | // Invariant: the boot sweep removes checkouts of lapsed or unknown runs and keeps a live run's. | |
| 1338 | 1386 | const dir = await mkdtemp(join(tmpdir(), "forge-sweep-")); |
| 1339 | 1387 | const db = createApp({ dataDir: dir, port: 0 }); |
| 1340 | 1388 | try { |
| @@ -1377,6 +1425,7 @@ describe("startChecks: a booting forge removes only checkouts nobody is running | ||
| 1377 | 1425 | |
| 1378 | 1426 | describe("checks: a finished shard is announced as the runner writes it (#2889)", () => { |
| 1379 | 1427 | test("each shard-done line the check prints puts one run.shard on the bus, and nothing else does", async () => { |
| 1428 | // Invariant: each `shard N/M done` line a check prints emits one run.shard event for that run. | |
| 1380 | 1429 | const dir = await mkdtemp(join(tmpdir(), "forge-shards-")); |
| 1381 | 1430 | const shardApp = createApp({ dataDir: dir, port: 0 }); |
| 1382 | 1431 | const shardServers = await serve(shardApp); |
| @@ -1412,7 +1461,7 @@ describe("checks: a finished shard is announced as the runner writes it (#2889)" | ||
| 1412 | 1461 | while (Date.now() < deadline) { |
| 1413 | 1462 | run = listRuns(shardApp.db, made.value.id, { kind: "check", limit: 5 })[0]; |
| 1414 | 1463 | if (run && (run.state === "ok" || run.state === "failed")) break; |
| 1415 | await Bun.sleep(50); | |
| 1464 | await Bun.sleep(10); | |
| 1416 | 1465 | } |
| 1417 | 1466 | expect(run?.state).toBe("ok"); |
| 1418 | 1467 | const shards = listEvents(shardApp.db, { repoId: made.value.id, kind: "run.shard" }); |