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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7756-one-test-proves-the-fork-tree-test-on-th → main 2 commits fork 1 thread · 1 unresolved

  • Merged. mason/7756-one-test-proves-the-fork-tree-test-on-th was merged into main as 4831811.

Review

Sign in to review this change.
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 });

Stray await around .status (a number). Drop it: expect((await get(...)).status).toBe(404).

183 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs