Public: anyone can read this; the crew can change it.
Every preview of the forge shows the seeded pull page, repository head, home with pins and /flow, looked at as the owner #8875
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8865-every-preview-of-the-forge-shows-the-see → main 2 commits fork
6 files
changed +101
−8
docs/design/areas/pull.md +2
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | # Pull |
| 2 | 2 | |
| 3 | Every forge pull's preview carries desk and phone looks of this page as the owner, over the preview seed's `/preview-admin/sample/pulls/3` (#8865). | |
| 4 | ||
| 3 | 5 | A pull is one change waiting on a person: read it, see its check, ship it or say what is off (#8773). |
| 4 | 6 | |
| 5 | 7 | At rest: the state is a dot (`.dot`, `role=img`, one `aria-label`, no `title`), with the word written beside it once the pull is settled. The check is one muted line. The decision is a plain block holding the page's one primary action (Ship); Comment steps down while it is there. The path line carries "Next: …" in the same paragraph. The where strip is left to the repository's home. |
docs/design/areas/repo-head.md +2
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | # Repository head |
| 2 | 2 | |
| 3 | Every forge pull's preview carries desk and phone looks of this page as the owner, over the preview seed's `/preview-admin/sample` (#8865). | |
| 4 | ||
| 3 | 5 | The head of a repository page names the repository and what you can do with it. `scopeHeader()` in `src/web/chrome.ts` draws it; the rules live in `src/web/style/content.ts`. |
| 4 | 6 | |
| 5 | 7 | - `.repo-title` — the head's `owner / name` line: the owner muted, the slash faint, the name in full weight. It is an `h1` on a repository's own pages and a `div` where the page has its own `h1`. |
forge.json +2 −2
| @@ -3,8 +3,8 @@ | ||
| 3 | 3 | "api.anthropic.com" |
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/repos", "/map", "/system", "/talk", "/contribute", "/flow", "/resilience", "/settings"], | |
| 7 | "looksAsOwner": ["/", "/flow", "/talk"], | |
| 6 | "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/search", "/new", "/audit", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/contribute", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3"], | |
| 7 | "looksAsOwner": ["/", "/flow", "/talk", "/preview-admin/sample", "/preview-admin/sample/pulls/3"], | |
| 8 | 8 | "run": "bun run src/main.ts", |
| 9 | 9 | "install": "", |
| 10 | 10 | "health": "/healthz", |
src/spawn/preview-seed.ts +7
| @@ -30,6 +30,12 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 30 | 30 | export const PREVIEW_ADMIN = "preview-admin"; |
| 31 | 31 | /** The repository the admin's pages are about. */ |
| 32 | 32 | export const PREVIEW_REPO = "sample"; |
| 33 | /** | |
| 34 | * The seeded pull's number: two issues come first and share its count. | |
| 35 | * forge.json `app.looksAsOwner` names its page, so the seed refuses any | |
| 36 | * other number rather than leave that look on a 404. | |
| 37 | */ | |
| 38 | export const PREVIEW_PULL = 3; | |
| 33 | 39 | /** The board whose panels are pinned to home. */ |
| 34 | 40 | const PREVIEW_BOARD = "preview"; |
| 35 | 41 | const SEEDED = "preview_seeded"; |
| @@ -122,6 +128,7 @@ async function seedWorld(app: App, admin: User): Promise<Result<void, string>> { | ||
| 122 | 128 | headBranch: "greeting", |
| 123 | 129 | }); |
| 124 | 130 | if (!pull.ok) return err(`the pull: ${String(pull.error)}`); |
| 131 | if (pull.value.number !== PREVIEW_PULL) return err(`the pull is #${String(pull.value.number)}, not #${String(PREVIEW_PULL)}`); | |
| 125 | 132 | |
| 126 | 133 | // A flow week: agent runs that started and spent this week, and a valve |
| 127 | 134 | // turned part way, so /flow has a pipe to draw and one to turn. |
test/forge-looks.test.ts +18 −4
| @@ -52,6 +52,8 @@ const NOT_A_PAGE: Record<string, string> = { | ||
| 52 | 52 | "/apple-touch-icon.png": "an icon, not a page", |
| 53 | 53 | "/apple-touch-icon-precomposed.png": "an icon, not a page", |
| 54 | 54 | "/events": "a live event stream that never ends", |
| 55 | "/boards": "the list of boards; /board is pictured, and the slot went to the preview seed's repository as its owner (#8865); covered by test/boards-page.test.ts", | |
| 56 | "/changelog": "the merged changes in words; /releases is pictured, and the slot went to the preview seed's pull as its owner (#8865); covered by test/changelog.test.ts", | |
| 55 | 57 | "/migrate": "an import form, nothing to picture until a job runs; the look slot went to /repos", |
| 56 | 58 | }; |
| 57 | 59 | |
| @@ -87,8 +89,11 @@ const topLevel = () => | ||
| 87 | 89 | .patterns() |
| 88 | 90 | .filter((p) => p === "/" || /^\/[^/:*]+$/.test(p)); |
| 89 | 91 | |
| 90 | const declared = (): string[] => | |
| 91 | JSON.parse(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8")).app.looks; | |
| 92 | const manifest = (): { looks: string[]; looksAsOwner: string[] } => | |
| 93 | JSON.parse(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8")).app; | |
| 94 | const isTopLevel = (p: string) => p === "/" || /^\/[^/]+$/.test(p); | |
| 95 | /** The top-level pages; a deeper one is the preview seed's, taken as the owner (test/looks-owner.test.ts). */ | |
| 96 | const declared = (): string[] => manifest().looks.filter(isTopLevel); | |
| 92 | 97 | |
| 93 | 98 | const get = (path: string) => |
| 94 | 99 | fetch(`${servers.url}${path}`, { |
| @@ -114,6 +119,15 @@ test("every listed path answers 200 to an admin", async () => { | ||
| 114 | 119 | }); |
| 115 | 120 | |
| 116 | 121 | test("app.looks stays within 24 paths, each once", () => { |
| 117 | expect(declared().length).toBeLessThanOrEqual(24); | |
| 118 | expect(new Set(declared()).size).toBe(declared().length); | |
| 122 | const { looks } = manifest(); | |
| 123 | expect(looks.length).toBeLessThanOrEqual(24); | |
| 124 | expect(new Set(looks).size).toBe(looks.length); | |
| 125 | }); | |
| 126 | ||
| 127 | test("a look below the top level is the preview seed's, taken as the owner", () => { | |
| 128 | const { looks, looksAsOwner } = manifest(); | |
| 129 | for (const p of looks.filter((p) => !isTopLevel(p))) { | |
| 130 | expect(p.startsWith("/preview-admin/sample")).toBe(true); | |
| 131 | expect(looksAsOwner).toContain(p); | |
| 132 | } | |
| 119 | 133 | }); |
test/looks-owner.test.ts +70 −2
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 10 | 10 | import { createHmac } from "node:crypto"; |
| 11 | import { writeFileSync } from "node:fs"; | |
| 11 | import { readFileSync, writeFileSync } from "node:fs"; | |
| 12 | 12 | import { mkdtemp, rm } from "node:fs/promises"; |
| 13 | 13 | import { tmpdir } from "node:os"; |
| 14 | 14 | import { join } from "node:path"; |
| @@ -32,7 +32,7 @@ import { ok } from "../src/kernel/result.ts"; | ||
| 32 | 32 | import type { Capture, LookInput } from "../src/looks/capture.ts"; |
| 33 | 33 | import { startLooks, type LooksHandle } from "../src/looks/service.ts"; |
| 34 | 34 | import { previewIdentityKey } from "../src/spawn/preview-owner.ts"; |
| 35 | import { PREVIEW_ADMIN, PREVIEW_REPO, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 35 | import { PREVIEW_ADMIN, PREVIEW_PULL, PREVIEW_REPO, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 36 | 36 | import { quietLogs } from "./helpers/globals.ts"; |
| 37 | 37 | |
| 38 | 38 | quietLogs(); |
| @@ -184,6 +184,74 @@ describe("a preview hears its parent's admin as the preview admin", () => { | ||
| 184 | 184 | }, 30_000); |
| 185 | 185 | }); |
| 186 | 186 | |
| 187 | describe("the forge's own owner looks, over the preview seed", () => { | |
| 188 | // Each page forge.json takes as the owner, and what shows it is not blank (#8865). | |
| 189 | const repoHome = `/${PREVIEW_ADMIN}/${PREVIEW_REPO}`; | |
| 190 | const pullPage = `${repoHome}/pulls/${String(PREVIEW_PULL)}`; | |
| 191 | const KEY = "the-parents-identity-key"; | |
| 192 | let dataDir: string; | |
| 193 | let app: App; | |
| 194 | let s: Servers; | |
| 195 | ||
| 196 | beforeAll(async () => { | |
| 197 | dataDir = await mkdtemp(join(tmpdir(), "forge-owner-looks-")); | |
| 198 | app = createApp({ dataDir, port: 0, previewIdentityKey: KEY }); | |
| 199 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 200 | if (!seeded.ok) throw new Error(seeded.error); | |
| 201 | s = await serve(app); | |
| 202 | }, 30_000); | |
| 203 | afterAll(async () => { | |
| 204 | s.stop(); | |
| 205 | app.close(); | |
| 206 | await rm(dataDir, { recursive: true, force: true }); | |
| 207 | }); | |
| 208 | ||
| 209 | const asOwner = async (path: string) => { | |
| 210 | const said = `u1|trevato|admin|${String(Date.now() + 60_000)}`; | |
| 211 | const res = await fetch(`${s.url}${path}`, { | |
| 212 | headers: { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }, | |
| 213 | redirect: "manual", | |
| 214 | }); | |
| 215 | expect(res.status).toBe(200); | |
| 216 | return res.text(); | |
| 217 | }; | |
| 218 | ||
| 219 | test("forge.json takes home, /flow, the seeded repository and its pull as the owner, desk and phone", () => { | |
| 220 | const manifest = parseManifest(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8"), undefined); | |
| 221 | if (!manifest.ok) throw new Error(JSON.stringify(manifest.error)); | |
| 222 | for (const page of ["/", "/flow", repoHome, pullPage]) { | |
| 223 | expect(manifest.value.looks).toContain(page); | |
| 224 | expect(manifest.value.looksAsOwner).toContain(page); | |
| 225 | } | |
| 226 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_REPO)!; | |
| 227 | expect(listPulls(app.db, repo.id).map((p) => p.number)).toEqual([PREVIEW_PULL]); | |
| 228 | }); | |
| 229 | ||
| 230 | test("home holds the pins, and the fragment it fills them from draws them", async () => { | |
| 231 | expect(await asOwner("/")).toContain('data-refresh="/live/home/panels"'); | |
| 232 | const pins = await asOwner("/live/home/panels"); | |
| 233 | expect(pins).toContain('aria-label="Pinned panels"'); | |
| 234 | expect(pins).toContain("Runs by kind"); | |
| 235 | }); | |
| 236 | ||
| 237 | test("/flow draws a pipe", async () => { | |
| 238 | expect(await asOwner("/flow")).toContain('class="pipe '); | |
| 239 | }); | |
| 240 | ||
| 241 | test("the repository's home carries the head's title", async () => { | |
| 242 | const page = await asOwner(repoHome); | |
| 243 | expect(page).toContain('<h1 class="repo-title">'); | |
| 244 | expect(page).toContain(`href="${repoHome}">${PREVIEW_REPO}`); | |
| 245 | }); | |
| 246 | ||
| 247 | test("the pull's page carries the Ship decision", async () => { | |
| 248 | const page = await asOwner(pullPage); | |
| 249 | const decide = page.indexOf('id="decide"'); | |
| 250 | expect(decide).toBeGreaterThan(-1); | |
| 251 | expect(page.slice(decide, decide + 2000)).toMatch(/data-variant="primary"[^>]*>\s*Ship\s*</); | |
| 252 | }); | |
| 253 | }); | |
| 254 | ||
| 187 | 255 | describe("looks of a public app", () => { |
| 188 | 256 | let dataDir: string; |
| 189 | 257 | let app: App; |
new above
offline