Public: anyone can read this; the crew can change it.
One test proves the Fork tree test on the web: from a fork's page you see its parent and children, choose how it follows, and offer a change up in one action #7785
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7756-one-test-proves-the-fork-tree-test-on-th → main 2 commits fork 1 thread · 1 unresolved
1 file
changed +183
−0
test/fork-tree-web.test.ts added +183
| @@ -0,0 +1,183 @@ | ||
| 1 | /** | |
| 2 | * #7756: the Fork tree test, on the web, on one forge. A person looks at a | |
| 3 | * fork's page and sees its parent and its child as a tree, chooses how it | |
| 4 | * follows, and offers its own commit up in one POST from the offer page. | |
| 5 | * | |
| 6 | * The mode step is covered only for the forge's own code: the follow modes | |
| 7 | * exist nowhere else, so the middle fork is made that repository by hand. | |
| 8 | * A plain in-forge fork offers no mode choice; that step is missing, and is | |
| 9 | * #7796. | |
| 10 | */ | |
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 16 | import { setRole } from "../src/actions/roles.ts"; | |
| 17 | import { createApp, type App } from "../src/app.ts"; | |
| 18 | import { getRepo, type Repo } from "../src/core/repos.ts"; | |
| 19 | import { createUser, type User } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { forgeLinkOf } from "../src/spawn/chain.ts"; | |
| 22 | import { quietLogs } from "./helpers/globals.ts"; | |
| 23 | ||
| 24 | quietLogs(); | |
| 25 | ||
| 26 | let dataDir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let ann: User; | |
| 30 | let bea: User; | |
| 31 | let cal: User; | |
| 32 | let middle: Repo; | |
| 33 | ||
| 34 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 35 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 36 | return r.value; | |
| 37 | }; | |
| 38 | ||
| 39 | async function mk(username: string, first: boolean): Promise<User> { | |
| 40 | return must( | |
| 41 | await createUser(app.db, { | |
| 42 | origin: "seed", | |
| 43 | trusted: true, | |
| 44 | username, | |
| 45 | email: `${username}@forge.local`, | |
| 46 | password: "a-good-passphrase", | |
| 47 | adminIfFirst: first, | |
| 48 | }), | |
| 49 | ); | |
| 50 | } | |
| 51 | ||
| 52 | const signIn = async (username: string): Promise<string> => | |
| 53 | ( | |
| 54 | ( | |
| 55 | await fetch(`${servers.url}/signin`, { | |
| 56 | method: "POST", | |
| 57 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 58 | redirect: "manual", | |
| 59 | }) | |
| 60 | ).headers.get("set-cookie") ?? "" | |
| 61 | ).split(";")[0]!; | |
| 62 | ||
| 63 | const get = (path: string, cookie: string) => | |
| 64 | fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 65 | ||
| 66 | const post = (path: string, cookie: string, body: Record<string, string>, origin = servers.url) => | |
| 67 | fetch(`${servers.url}${path}`, { | |
| 68 | method: "POST", | |
| 69 | headers: { cookie, origin }, | |
| 70 | body: new URLSearchParams(body), | |
| 71 | redirect: "manual", | |
| 72 | }); | |
| 73 | ||
| 74 | beforeAll(async () => { | |
| 75 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-tree-web-")); | |
| 76 | app = createApp({ dataDir, port: 0 }); | |
| 77 | servers = await serve(app); | |
| 78 | ann = await mk("ann", true); | |
| 79 | bea = await mk("bea", false); | |
| 80 | cal = await mk("cal", false); | |
| 81 | for (const name of ["bea", "cal"]) must(setRole(app, ann, name, "member")); | |
| 82 | const original = must(await createRepo(app, ann, { name: "yard", visibility: "public" })); | |
| 83 | must( | |
| 84 | await commitToBranch(app, ann, original, { | |
| 85 | message: "first", | |
| 86 | files: [{ path: "a.txt", content: "a\n" }], | |
| 87 | }), | |
| 88 | ); | |
| 89 | middle = must(await forkRepo(app, bea, original)); | |
| 90 | must(await forkRepo(app, cal, middle)); | |
| 91 | // The middle fork carries the forge's own code, so its following page offers the modes. | |
| 92 | await Bun.write( | |
| 93 | join(dataDir, "parent.json"), | |
| 94 | JSON.stringify({ | |
| 95 | forgeCode: { | |
| 96 | repo: "bea/yard", | |
| 97 | upstream: "ann/yard", | |
| 98 | mode: "tell-me", | |
| 99 | tip: "a".repeat(40), | |
| 100 | chain: [{ repo: "ann/yard", forge: "", mode: null, tip: "a".repeat(40), ahead: null }], | |
| 101 | }, | |
| 102 | }), | |
| 103 | ); | |
| 104 | }, 60_000); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | servers?.stop(); | |
| 108 | app?.close(); | |
| 109 | await rm(dataDir, { recursive: true, force: true }); | |
| 110 | }); | |
| 111 | ||
| 112 | describe("the fork tree, on one forge", () => { | |
| 113 | test("the middle fork's page names its parent, and one tap on its forks shows the tree", async () => { | |
| 114 | const cookie = await signIn("bea"); | |
| 115 | const home = await (await get("/bea/yard", cookie)).text(); | |
| 116 | expect(home).toContain('id="following"'); | |
| 117 | expect(home).toContain('href="/ann/yard"'); | |
| 118 | expect(home).toContain('href="/bea/yard/forks"'); | |
| 119 | ||
| 120 | const res = await get("/bea/yard/forks", cookie); | |
| 121 | expect(res.status).toBe(200); | |
| 122 | const body = await res.text(); | |
| 123 | expect(body).toContain('aria-label="Forks, by who follows whom"'); | |
| 124 | expect(body).toContain("you are here"); | |
| 125 | // Root, middle and child appear in that order, each linked. | |
| 126 | const tree = body.slice(body.indexOf('aria-label="Forks, by who follows whom"')); | |
| 127 | const at = ["/ann/yard", "/bea/yard", "/cal/yard"].map((h) => tree.indexOf(`href="${h}"`)); | |
| 128 | expect(at[0]).toBeGreaterThan(-1); | |
| 129 | expect(at[0]).toBeLessThan(at[1]!); | |
| 130 | expect(at[1]).toBeLessThan(at[2]!); | |
| 131 | // "you are here" marks bea's entry, and neither the root's nor the child's. | |
| 132 | const mark = tree.indexOf("you are here"); | |
| 133 | expect(mark).toBeGreaterThan(at[1]!); | |
| 134 | expect(mark).toBeLessThan(at[2]!); | |
| 135 | expect(tree.indexOf("you are here", mark + 1)).toBe(-1); | |
| 136 | }); | |
| 137 | ||
| 138 | test("the following page offers the modes; posting one changes it, and the page shows it", async () => { | |
| 139 | const cookie = await signIn("bea"); | |
| 140 | const before = await (await get("/bea/yard/following", cookie)).text(); | |
| 141 | expect(before).toContain('name="mode" value="tell-me"'); | |
| 142 | expect(before).toContain('name="mode" value="take-updates"'); | |
| 143 | expect(before).toMatch(/value="tell-me"\s+checked/); | |
| 144 | ||
| 145 | const cross = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }, "https://evil.test"); | |
| 146 | expect(cross.status).toBe(403); | |
| 147 | expect((await forgeLinkOf(app))?.mode).toBe("tell-me"); | |
| 148 | ||
| 149 | const changed = await post("/bea/yard/following/mode", cookie, { mode: "take-updates" }); | |
| 150 | expect(changed.status).toBe(303); | |
| 151 | expect(changed.headers.get("location")).toBe("/bea/yard/following"); | |
| 152 | expect((await forgeLinkOf(app))?.mode).toBe("take-updates"); | |
| 153 | ||
| 154 | const after = await (await get("/bea/yard/following", cookie)).text(); | |
| 155 | expect(after).toMatch(/value="take-updates"\s+checked/); | |
| 156 | expect(after).not.toMatch(/value="tell-me"\s+checked/); | |
| 157 | }); | |
| 158 | ||
| 159 | test("one POST from the offer page opens exactly one pull on the parent", async () => { | |
| 160 | const cookie = await signIn("bea"); | |
| 161 | const fork = getRepo(app.db, "bea", "yard")!; | |
| 162 | must( | |
| 163 | await commitToBranch(app, bea, fork, { | |
| 164 | message: "mine", | |
| 165 | files: [{ path: "mine.txt", content: "m\n" }], | |
| 166 | }), | |
| 167 | ); | |
| 168 | // The door is the Following card's "Propose upstream" form: read its | |
| 169 | // action and fields off the page, then send exactly that. | |
| 170 | const home = await (await get("/bea/yard", cookie)).text(); | |
| 171 | const formTag = home.match(/<form method="post" action="([^"]+)">\s*<input type="hidden" name="do" value="propose"/); | |
| 172 | expect(formTag).not.toBeNull(); | |
| 173 | expect(home).toContain("Propose upstream"); | |
| 174 | const sent = await post(formTag![1]!, cookie, { do: "propose" }); | |
| 175 | expect(sent.status).toBe(303); | |
| 176 | expect(sent.headers.get("location")).toBe("/ann/yard/pulls/1"); | |
| 177 | ||
| 178 | const list = await (await get("/ann/yard/pulls", await signIn("ann"))).text(); | |
| 179 | expect(new Set(list.match(/\/ann\/yard\/pulls\/\d+"/g)).size).toBe(1); | |
| 180 | expect(await (await get("/bea/yard", cookie)).text()).toContain("See the upstream proposal"); | |
| 181 | expect(await (await get("/ann/yard/pulls/2", cookie)).status).toBe(404); | |
| 182 | }); | |
| 183 | }); |
new above
offline
Stray
awaitaround.status(a number). Drop it:expect((await get(...)).status).toBe(404).