Public: anyone can read this; the crew can change it.
An open chain page updates live when its forge is severed, through any door #2224
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2221-an-open-chain-page-updates-live-when-its → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +103
−0
src/actions/follow.ts +1
| @@ -54,6 +54,7 @@ export async function severForge( | ||
| 54 | 54 | if (!cut.ok) return err("not_following"); |
| 55 | 55 | app.bus.append({ |
| 56 | 56 | kind: "forge.severed", |
| 57 | repoId: repo.id, | |
| 57 | 58 | actorId: who.value.id, |
| 58 | 59 | payload: { repo: `${repo.ownerName}/${repo.name}`, at: cut.value }, |
| 59 | 60 | }); |
src/web/pages/chain.ts +9
| @@ -15,6 +15,9 @@ import { html, type Html } from "../html.ts"; | ||
| 15 | 15 | import { flashOrJson, page, redirect } from "../layout.ts"; |
| 16 | 16 | import { btn, card } from "../ui.ts"; |
| 17 | 17 | |
| 18 | /** The events that change what the chain page says. */ | |
| 19 | export const CHAIN_LIVE = "forge.severed"; | |
| 20 | ||
| 18 | 21 | const MODE_WORDS = { "tell-me": "tells when the parent moves", "take-updates": "takes updates" } as const; |
| 19 | 22 | |
| 20 | 23 | function lineRow(l: ChainLine): Html { |
| @@ -63,6 +66,11 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 63 | 66 | ${pageHead("Following", { |
| 64 | 67 | sub: "The forges this one's code descends from, root first. Numbers are as of the day each hop was made; the last is counted now.", |
| 65 | 68 | })} |
| 69 | <div | |
| 70 | id="live-chain" | |
| 71 | data-live="${CHAIN_LIVE}" | |
| 72 | data-live-repo="${repo.ownerName}/${repo.name}" | |
| 73 | > | |
| 66 | 74 | ${card( |
| 67 | 75 | html`<ul class="list lineage" aria-label="Forges, root first"> |
| 68 | 76 | ${view.lines.map(lineRow)} |
| @@ -91,6 +99,7 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 91 | 99 | { head: html`<h2>Sever</h2>` }, |
| 92 | 100 | ) |
| 93 | 101 | : null} |
| 102 | </div> | |
| 94 | 103 | </div>`, |
| 95 | 104 | }); |
| 96 | 105 | } |
test/following.test.ts +93
| @@ -12,6 +12,12 @@ import { parentUrlProblem, refreshIdle } from "../src/core/parent_releases.ts"; | ||
| 12 | 12 | import { Session } from "../src/tui/session.ts"; |
| 13 | 13 | import { releasesRow } from "../src/tui/screens/about.ts"; |
| 14 | 14 | import { createApp, type App } from "../src/app.ts"; |
| 15 | import { childDirOf, spawnForge } from "../src/actions/spawn.ts"; | |
| 16 | import { severForge } from "../src/actions/follow.ts"; | |
| 17 | import { createSession } from "../src/core/sessions.ts"; | |
| 18 | import { getRepo } from "../src/core/repos.ts"; | |
| 19 | import { commitFiles } from "../src/git/write.ts"; | |
| 20 | import { hostedPlacement } from "../src/spawn/placement.ts"; | |
| 15 | 21 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 16 | 22 | import { startChecks, type ChecksHandle } from "../src/checks/service.ts"; |
| 17 | 23 | import { mergePull, openPull } from "../src/actions/pulls.ts"; |
| @@ -1017,3 +1023,90 @@ describe("a parent URL", () => { | ||
| 1017 | 1023 | expect(parentUrlProblem("http://127.0.0.1:1", true)).toBeNull(); |
| 1018 | 1024 | }); |
| 1019 | 1025 | }); |
| 1026 | ||
| 1027 | describe("an open chain page hears a sever", () => { | |
| 1028 | let dir = ""; | |
| 1029 | let root: App; | |
| 1030 | let child: App; | |
| 1031 | let servers: Servers; | |
| 1032 | let cookie = ""; | |
| 1033 | ||
| 1034 | beforeAll(async () => { | |
| 1035 | dir = await mkdtemp(join(tmpdir(), "forge-chain-live-")); | |
| 1036 | root = createApp({ dataDir: dir, url: "https://root.test", port: 0, forgeRepo: "alice/forge-code" }); | |
| 1037 | const alice = await createUser(root.db, { trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 1038 | if (!alice.ok) throw new Error("user"); | |
| 1039 | const made = await createRepo(root, alice.value, { name: "forge-code", visibility: "public" }); | |
| 1040 | if (!made.ok) throw new Error("repo"); | |
| 1041 | const c = await commitFiles(repoDir(root.reposRoot, "alice", "forge-code"), { | |
| 1042 | branch: "main", | |
| 1043 | message: "forge v1", | |
| 1044 | files: [{ path: "README.md", content: "# forge\n" }], | |
| 1045 | author: { name: "a", email: "[email protected]" }, | |
| 1046 | }); | |
| 1047 | if (!c.ok) throw new Error("commit"); | |
| 1048 | const host = "root.test"; | |
| 1049 | const spawned = await spawnForge( | |
| 1050 | root, | |
| 1051 | alice.value, | |
| 1052 | { name: "kid", org: "kid", apps: [], carryBriefs: false, carrySubscriptions: false }, | |
| 1053 | { catalog: [], briefs: [], subscriptions: [] }, | |
| 1054 | { | |
| 1055 | placement: hostedPlacement({ host }), | |
| 1056 | request: new Request(`https://${host}/x`, { method: "POST", headers: { origin: `https://${host}` } }), | |
| 1057 | url: new URL(`https://${host}/x`), | |
| 1058 | }, | |
| 1059 | ); | |
| 1060 | if (!spawned.ok) throw new Error(JSON.stringify(spawned.error)); | |
| 1061 | child = createApp({ dataDir: childDirOf(root, spawned.value.bodyId), url: "https://kid.test", port: 0 }); | |
| 1062 | servers = await serve(child); | |
| 1063 | cookie = `forge_session=${createSession(child.db, getUserByName(child.db, "alice")!.id).token}`; | |
| 1064 | }, 60_000); | |
| 1065 | ||
| 1066 | afterAll(async () => { | |
| 1067 | servers.stop(); | |
| 1068 | child.close(); | |
| 1069 | root.close(); | |
| 1070 | await rm(dir, { recursive: true, force: true }); | |
| 1071 | }); | |
| 1072 | ||
| 1073 | const chainPage = async () => | |
| 1074 | (await (await fetch(`${servers.url}/kid/forge/following`, { headers: { cookie } })).text()).replace(/\s+/g, " "); | |
| 1075 | ||
| 1076 | test("the page carries a live region for forge.severed, scoped to its repository", async () => { | |
| 1077 | const page = await chainPage(); | |
| 1078 | expect(page).toContain('id="live-chain"'); | |
| 1079 | expect(page).toContain('data-live="forge.severed"'); | |
| 1080 | expect(page).toContain('data-live-repo="kid/forge"'); | |
| 1081 | expect(page).not.toContain("follows nothing since"); | |
| 1082 | }); | |
| 1083 | ||
| 1084 | test("a sever reaches the viewer's stream with the repo the region filters on, and a refetch shows it", async () => { | |
| 1085 | const ctl = new AbortController(); | |
| 1086 | const res = await fetch(`${servers.url}/events`, { | |
| 1087 | headers: { cookie }, | |
| 1088 | signal: ctl.signal, | |
| 1089 | }); | |
| 1090 | const reader = res.body!.getReader(); | |
| 1091 | const owner = getUserByName(child.db, "alice")!; | |
| 1092 | const cut = await severForge(child, owner, getRepo(child.db, "kid", "forge")!); | |
| 1093 | expect(cut.ok).toBe(true); | |
| 1094 | ||
| 1095 | let seen = ""; | |
| 1096 | const dec = new TextDecoder(); | |
| 1097 | while (!seen.includes('"forge.severed"')) { | |
| 1098 | const { value, done } = await reader.read(); | |
| 1099 | if (done) break; | |
| 1100 | seen += dec.decode(value); | |
| 1101 | } | |
| 1102 | ctl.abort(); | |
| 1103 | const line = seen.split("\n").find((l) => l.startsWith("data:") && l.includes("forge.severed"))!; | |
| 1104 | const event = JSON.parse(line.slice(5)); | |
| 1105 | expect(event.kind).toBe("forge.severed"); | |
| 1106 | expect(event.repo).toBe("kid/forge"); | |
| 1107 | ||
| 1108 | const page = await chainPage(); | |
| 1109 | expect(page).toContain("follows nothing since"); | |
| 1110 | expect(page).toContain('id="live-chain"'); | |
| 1111 | }); | |
| 1112 | }); |
new above
offline
CHAIN_LIVE only lists forge.severed. When the mode-change event exists, extend it here as a comma-separated list.