Public: anyone can read this; the crew can change it.
test/prompts.test.ts and test/snapshot-destination.test.ts pass in any order and run in the gate #11100
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11097-test-prompts-test-ts-and-test-snapshot-d → main 4 commits fork 1 thread · 1 unresolved
3 files
changed +92
−36
scripts/gate.ts −10
| @@ -53,21 +53,11 @@ export const EXCLUDED: readonly Exclusion[] = [ | ||
| 53 | 53 | reason: "red alone under --randomize (2026-11-10): the first clean probe after closes the issue: verdict down, not ok", |
| 54 | 54 | issue: 11084, |
| 55 | 55 | }, |
| 56 | { | |
| 57 | file: "test/prompts.test.ts", | |
| 58 | reason: "red alone under --randomize (2026-11-10): 11 of 23 fail, tests read the prompts and Reels earlier tests made", | |
| 59 | issue: 11097, | |
| 60 | }, | |
| 61 | 56 | { |
| 62 | 57 | file: "test/rehearsal-real.test.ts", |
| 63 | 58 | reason: "green alone (3 of 3, 2026-11-10) but unproven under the gate's load, where on main@accaa65 the second real roll's old forge did not come up", |
| 64 | 59 | issue: 11098, |
| 65 | 60 | }, |
| 66 | { | |
| 67 | file: "test/snapshot-destination.test.ts", | |
| 68 | reason: "red alone under --randomize (2026-11-10): precedence and follow-the-saved-destination read a destination an earlier test saved", | |
| 69 | issue: 11097, | |
| 70 | }, | |
| 71 | 61 | ]; |
| 72 | 62 | |
| 73 | 63 | /** |
test/prompts.test.ts +85 −25
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * network — the real client is exercised against a fake `fetch`. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; | |
| 9 | 9 | import { mkdtemp, rm } from "node:fs/promises"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| @@ -366,7 +366,7 @@ describe("prompts from Instagram", () => { | ||
| 366 | 366 | let rwToken = ""; |
| 367 | 367 | let bobToken = ""; |
| 368 | 368 | let sourceId = ""; |
| 369 | const instagram = new FakeInstagram(); | |
| 369 | let instagram = new FakeInstagram(); | |
| 370 | 370 | let clock = new Date("2026-09-28T14:59:00Z"); |
| 371 | 371 | const tick = (iso: string) => { |
| 372 | 372 | clock = new Date(iso); |
| @@ -424,7 +424,47 @@ describe("prompts from Instagram", () => { | ||
| 424 | 424 | const source = () => getPromptSource(app.db, sourceId)!; |
| 425 | 425 | const openIssues = () => listIssues(app.db, repo.id, { limit: 50 }); |
| 426 | 426 | |
| 427 | beforeAll(async () => { | |
| 427 | /** Connect the account the way a person would, on the test's own story. */ | |
| 428 | const connect = async (atUtc = "15:00") => { | |
| 429 | const made = await api(PROMPTS, { | |
| 430 | method: "POST", | |
| 431 | body: JSON.stringify({ kind: "instagram", token: TOKEN, at: atUtc }), | |
| 432 | }); | |
| 433 | expect(made.status).toBe(201); | |
| 434 | sourceId = ((await made.json()) as { id: string }).id; | |
| 435 | // The API stamps the real date; the story is told on the test's clock. | |
| 436 | app.db | |
| 437 | .query( | |
| 438 | "UPDATE prompt_source SET token_refreshed_at = ?2, created_at = ?2 WHERE id = ?1", | |
| 439 | ) | |
| 440 | .run(sourceId, "2026-09-28T14:00:00.000Z"); | |
| 441 | }; | |
| 442 | const reelOne = () => { | |
| 443 | instagram.reel = reelOf("reel-1", "2026-09-27"); | |
| 444 | instagram.replies = [ | |
| 445 | comment("c2", "first!!", 2, at("2026-09-27T18:01:00+0000"), "quick_one"), | |
| 446 | comment( | |
| 447 | "c1", | |
| 448 | "Add a boss fight at the end of level 3\nIt feels empty right now.", | |
| 449 | 5, | |
| 450 | at("2026-09-27T18:05:00+0000"), | |
| 451 | "fan_one", | |
| 452 | ), | |
| 453 | comment("c3", "", 99, at("2026-09-27T18:00:00+0000"), "blank"), | |
| 454 | ]; | |
| 455 | }; | |
| 456 | /** An account connected whose first Reel has been told as #1. */ | |
| 457 | const firstTell = async () => { | |
| 458 | await connect(); | |
| 459 | reelOne(); | |
| 460 | await tick("2026-09-28T15:00:00Z"); | |
| 461 | expect(source().lastIssueNumber).toBe(1); | |
| 462 | }; | |
| 463 | ||
| 464 | beforeEach(async () => { | |
| 465 | instagram = new FakeInstagram(); | |
| 466 | clock = new Date("2026-09-28T14:59:00Z"); | |
| 467 | sourceId = ""; | |
| 428 | 468 | dataDir = await mkdtemp(join(tmpdir(), "forge-prompts-")); |
| 429 | 469 | app = createApp({ dataDir, port: 0 }); |
| 430 | 470 | servers = await serve(app); |
| @@ -468,7 +508,7 @@ describe("prompts from Instagram", () => { | ||
| 468 | 508 | expect(addToCrew(app, alice, repo, "scout").ok).toBe(true); |
| 469 | 509 | }); |
| 470 | 510 | |
| 471 | afterAll(async () => { | |
| 511 | afterEach(async () => { | |
| 472 | 512 | await routine.stop(); |
| 473 | 513 | servers.stop(); |
| 474 | 514 | app.close(); |
| @@ -586,18 +626,8 @@ describe("prompts from Instagram", () => { | ||
| 586 | 626 | }); |
| 587 | 627 | |
| 588 | 628 | test("nothing before the hour; once after it; not again the same day", async () => { |
| 589 | instagram.reel = reelOf("reel-1", "2026-09-27"); | |
| 590 | instagram.replies = [ | |
| 591 | comment("c2", "first!!", 2, at("2026-09-27T18:01:00+0000"), "quick_one"), | |
| 592 | comment( | |
| 593 | "c1", | |
| 594 | "Add a boss fight at the end of level 3\nIt feels empty right now.", | |
| 595 | 5, | |
| 596 | at("2026-09-27T18:05:00+0000"), | |
| 597 | "fan_one", | |
| 598 | ), | |
| 599 | comment("c3", "", 99, at("2026-09-27T18:00:00+0000"), "blank"), | |
| 600 | ]; | |
| 629 | await connect(); | |
| 630 | reelOne(); | |
| 601 | 631 | await tick("2026-09-28T14:59:00Z"); |
| 602 | 632 | expect(instagram.names()).toEqual([]); |
| 603 | 633 | expect(source().lastRanAt).toBeNull(); |
| @@ -645,6 +675,7 @@ describe("prompts from Instagram", () => { | ||
| 645 | 675 | }); |
| 646 | 676 | |
| 647 | 677 | test("the same Reel is never told twice", async () => { |
| 678 | await firstTell(); | |
| 648 | 679 | instagram.calls.length = 0; |
| 649 | 680 | await tick("2026-09-29T15:00:00Z"); |
| 650 | 681 | // The account is known, so `me` is not asked again; the Reel is known, |
| @@ -658,6 +689,7 @@ describe("prompts from Instagram", () => { | ||
| 658 | 689 | }); |
| 659 | 690 | |
| 660 | 691 | test("a Reel with no comments yet is looked at again tomorrow", async () => { |
| 692 | await firstTell(); | |
| 661 | 693 | instagram.calls.length = 0; |
| 662 | 694 | instagram.reel = reelOf("reel-2", "2026-09-29"); |
| 663 | 695 | instagram.replies = []; |
| @@ -691,6 +723,11 @@ describe("prompts from Instagram", () => { | ||
| 691 | 723 | }); |
| 692 | 724 | |
| 693 | 725 | test("no Reel at all is a sentence", async () => { |
| 726 | await firstTell(); | |
| 727 | instagram.reel = reelOf("reel-2", "2026-09-29"); | |
| 728 | instagram.replies = [comment("c9", "Heavier jump", 1, at("2026-09-30T09:00:00+0000"), "ana")]; | |
| 729 | await tick("2026-10-01T15:00:00Z"); | |
| 730 | expect(source().lastMediaId).toBe("reel-2"); | |
| 694 | 731 | instagram.reel = undefined; |
| 695 | 732 | await tick("2026-10-02T15:00:00Z"); |
| 696 | 733 | expect(source().lastSaid).toBe("No Reel yet."); |
| @@ -698,6 +735,7 @@ describe("prompts from Instagram", () => { | ||
| 698 | 735 | }); |
| 699 | 736 | |
| 700 | 737 | test("Instagram's refusals are sentences, never throws", async () => { |
| 738 | await firstTell(); | |
| 701 | 739 | instagram.failWith = new InstagramError( |
| 702 | 740 | 190, |
| 703 | 741 | "Error validating access token: Session has expired", |
| @@ -722,10 +760,11 @@ describe("prompts from Instagram", () => { | ||
| 722 | 760 | expect(source().lastSaid).toBe("The run failed: socket hang up."); |
| 723 | 761 | instagram.failWith = undefined; |
| 724 | 762 | // Nothing above told anybody anything. |
| 725 | expect(openIssues()).toHaveLength(2); | |
| 763 | expect(openIssues()).toHaveLength(1); | |
| 726 | 764 | }); |
| 727 | 765 | |
| 728 | 766 | test("a token older than fifty days is refreshed, and the new one is what runs from then on", async () => { |
| 767 | await connect(); | |
| 729 | 768 | const before = source().tokenRefreshedAt; |
| 730 | 769 | instagram.calls.length = 0; |
| 731 | 770 | instagram.reel = reelOf("reel-3", "2026-11-19"); |
| @@ -736,6 +775,7 @@ describe("prompts from Instagram", () => { | ||
| 736 | 775 | await tick("2026-11-20T15:00:00Z"); |
| 737 | 776 | expect(instagram.calls).toEqual([ |
| 738 | 777 | { call: "refresh", token: TOKEN }, |
| 778 | { call: "me", token: FRESH }, | |
| 739 | 779 | { call: "reel", token: FRESH }, |
| 740 | 780 | { call: "comments:reel-3", token: FRESH }, |
| 741 | 781 | ]); |
| @@ -745,7 +785,7 @@ describe("prompts from Instagram", () => { | ||
| 745 | 785 | ok: true, |
| 746 | 786 | value: FRESH, |
| 747 | 787 | }); |
| 748 | expect(source().lastIssueNumber).toBe(3); | |
| 788 | expect(source().lastIssueNumber).toBe(1); | |
| 749 | 789 | |
| 750 | 790 | // The next day: fresh, no refresh. |
| 751 | 791 | instagram.calls.length = 0; |
| @@ -763,7 +803,12 @@ describe("prompts from Instagram", () => { | ||
| 763 | 803 | }); |
| 764 | 804 | |
| 765 | 805 | test("Fetch now runs at once, whatever the hour, through the API", async () => { |
| 766 | // Already ran today; a new Reel is up. | |
| 806 | await connect(); | |
| 807 | clock = new Date("2026-11-21T15:00:00Z"); | |
| 808 | // A token pasted the day before: no refresh is due. | |
| 809 | app.db | |
| 810 | .query("UPDATE prompt_source SET token_refreshed_at = ?2 WHERE id = ?1") | |
| 811 | .run(sourceId, "2026-11-20T15:00:00.000Z"); | |
| 767 | 812 | instagram.calls.length = 0; |
| 768 | 813 | instagram.reel = reelOf("reel-4", "2026-11-21"); |
| 769 | 814 | instagram.replies = [ |
| @@ -779,12 +824,12 @@ describe("prompts from Instagram", () => { | ||
| 779 | 824 | account: string; |
| 780 | 825 | }; |
| 781 | 826 | expect(item.lastSaid).toBe( |
| 782 | 'Told the crew #4: "Co-op please" — @duo, 12 likes.', | |
| 827 | 'Told the crew #1: "Co-op please" — @duo, 12 likes.', | |
| 783 | 828 | ); |
| 784 | expect(item.lastIssueNumber).toBe(4); | |
| 829 | expect(item.lastIssueNumber).toBe(1); | |
| 785 | 830 | expect(item.account).toBe("gamehandle"); |
| 786 | expect(instagram.names()).toEqual(["reel", "comments:reel-4"]); | |
| 787 | expect(getIssue(app.db, repo.id, 4)?.title).toBe("Co-op please"); | |
| 831 | expect(instagram.names()).toEqual(["me", "reel", "comments:reel-4"]); | |
| 832 | expect(getIssue(app.db, repo.id, 1)?.title).toBe("Co-op please"); | |
| 788 | 833 | |
| 789 | 834 | const unknown = await api(`${PROMPTS}/nope/run`, { method: "POST" }); |
| 790 | 835 | expect(unknown.status).toBe(422); |
| @@ -794,6 +839,7 @@ describe("prompts from Instagram", () => { | ||
| 794 | 839 | }); |
| 795 | 840 | |
| 796 | 841 | test("admin only, like the domains: scope, then admin over the repository", async () => { |
| 842 | await connect(); | |
| 797 | 843 | expect((await api(PROMPTS, { token: rwToken })).status).toBe(403); |
| 798 | 844 | expect((await api(PROMPTS, { token: null })).status).toBe(401); |
| 799 | 845 | expect((await api(PROMPTS, { token: bobToken })).status).toBe(404); |
| @@ -875,12 +921,22 @@ describe("prompts from Instagram", () => { | ||
| 875 | 921 | }); |
| 876 | 922 | |
| 877 | 923 | test("the settings page: the card, the steps, the doors", async () => { |
| 924 | await connect(); | |
| 925 | clock = new Date("2026-11-21T15:00:00Z"); | |
| 926 | instagram.reel = reelOf("reel-4", "2026-11-21"); | |
| 927 | instagram.replies = [ | |
| 928 | comment("c30", "Co-op please", 12, at("2026-11-21T12:00:00+0000"), "duo"), | |
| 929 | ]; | |
| 930 | await routine.runNow(sourceId); | |
| 931 | app.db | |
| 932 | .query("UPDATE prompt_source SET token_refreshed_at = ?2 WHERE id = ?1") | |
| 933 | .run(sourceId, "2026-11-20T15:00:00.000Z"); | |
| 878 | 934 | const page = words(await (await web(aliceCookie, SETTINGS)).text()); |
| 879 | 935 | expect(page).toContain(">Prompts<"); |
| 880 | 936 | expect(page).toContain("<h2>Instagram</h2>"); |
| 881 | 937 | expect(page).toContain("@gamehandle"); |
| 882 | 938 | expect(page).toContain("every day at 15:00 UTC"); |
| 883 | expect(page).toContain("Told the crew #4"); | |
| 939 | expect(page).toContain("Told the crew #1"); | |
| 884 | 940 | expect(page).toContain("Token refreshed"); |
| 885 | 941 | expect(page).toContain("refreshes it again around 2027-01-09"); |
| 886 | 942 | expect(page).toContain("Fetch now"); |
| @@ -911,7 +967,7 @@ describe("prompts from Instagram", () => { | ||
| 911 | 967 | expect(fetched.status).toBe(303); |
| 912 | 968 | expect(fetched.headers.get("location")).toBe(SETTINGS); |
| 913 | 969 | expect(flashOf(fetched)).toContain( |
| 914 | "No new Reel since 2026-11-21; the last one was told as #4.", | |
| 970 | "No new Reel since 2026-11-21; the last one was told as #1.", | |
| 915 | 971 | ); |
| 916 | 972 | expect(instagram.names()).toEqual(["reel"]); |
| 917 | 973 | |
| @@ -960,6 +1016,7 @@ describe("prompts from Instagram", () => { | ||
| 960 | 1016 | }); |
| 961 | 1017 | |
| 962 | 1018 | test("the API removes, and says so once", async () => { |
| 1019 | await connect("09:30"); | |
| 963 | 1020 | const listed = (await (await api(PROMPTS)).json()) as { |
| 964 | 1021 | items: { id: string; atUtc: string; account: string }[]; |
| 965 | 1022 | }; |
| @@ -1010,6 +1067,7 @@ describe("prompts from Instagram", () => { | ||
| 1010 | 1067 | }); |
| 1011 | 1068 | |
| 1012 | 1069 | test("a sentence about an error never carries the token, even when Instagram's words quote the request", async () => { |
| 1070 | await connect(); | |
| 1013 | 1071 | instagram.failWith = new InstagramError( |
| 1014 | 1072 | 400, |
| 1015 | 1073 | `Bad request to https://graph.instagram.com/v25.0/me/media?fields=id&access_token=${TOKEN}`, |
| @@ -1031,6 +1089,7 @@ describe("prompts from Instagram", () => { | ||
| 1031 | 1089 | test("a run claims its row before any network work: two sweeps over one row tell the crew once", async () => { |
| 1032 | 1090 | // A second routine over the same database — two forges over one data |
| 1033 | 1091 | // directory, or a timer started twice — with a slow Instagram. |
| 1092 | await connect(); | |
| 1034 | 1093 | let release: (() => void) | undefined; |
| 1035 | 1094 | const slow = new FakeInstagram(); |
| 1036 | 1095 | slow.reel = reelOf("reel-8", "2026-10-11"); |
| @@ -1069,6 +1128,7 @@ describe("prompts from Instagram", () => { | ||
| 1069 | 1128 | }); |
| 1070 | 1129 | |
| 1071 | 1130 | test("stop waits for a run in flight, but not forever", async () => { |
| 1131 | await connect(); | |
| 1072 | 1132 | let hang: (() => void) | undefined; |
| 1073 | 1133 | const stuck: InstagramClient = { |
| 1074 | 1134 | me: (t) => instagram.me(t), |
test/snapshot-destination.test.ts +7 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * for admins and the keys are never shown again. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; | |
| 9 | 9 | import { mkdtempSync, rmSync } from "node:fs"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| 11 | 11 | import { join } from "node:path"; |
| @@ -62,6 +62,11 @@ describe("the snapshot destination", () => { | ||
| 62 | 62 | rmSync(root, { recursive: true, force: true }); |
| 63 | 63 | }); |
| 64 | 64 | |
| 65 | // Every test starts with nothing sealed and makes the destination it reads. | |
| 66 | beforeEach(() => { | |
| 67 | forgetDestination(app, admin); | |
| 68 | }); | |
| 69 | ||
| 65 | 70 | const sources = () => ({ |
| 66 | 71 | db: app.db, |
| 67 | 72 | dataDir: root, |
| @@ -93,6 +98,7 @@ describe("the snapshot destination", () => { | ||
| 93 | 98 | }); |
| 94 | 99 | |
| 95 | 100 | test("the service follows a saved or cleared destination without a restart", async () => { |
| 101 | expect(saveDestination(app, admin, input).ok).toBe(true); | |
| 96 | 102 | const svc = startSnapshots( |
| 97 | 103 | app, |
| 98 | 104 | () => resolveDestination({ ...sources(), env })?.store, |
Remove these four #261 entries (scaffold, ssh-app, ssh-port-taken, ssh): out of scope for #11097, and #11099 on main took them out of the quarantine because #261 is closed.