Public: anyone can read this; the crew can change it.
A forge preview's Looks always include the seeded pull page, repository head, home with pins and /flow, at desk and phone width #9000
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8974-a-forge-preview-s-looks-always-include-t → main 3 commits fork 1 thread · 1 unresolved
5 files
changed +160
−18
docs/features/preview-owner-looks.md +8
| @@ -13,6 +13,14 @@ every thumb too. Pictures taken as the owner are the writers' alone | ||
| 13 | 13 | there that `app.looks` does not name is a manifest error. The forge names |
| 14 | 14 | `/`, `/flow` and `/talk`. |
| 15 | 15 | |
| 16 | **Four looks every forge preview takes.** Whatever `forge.json` lists, a | |
| 17 | preview of the forge's own repository (`FORGE_REPO`) is pictured as its | |
| 18 | owner at desk and phone width on home with its pins, /flow, | |
| 19 | `/preview-admin/sample` and the seeded pull's page: the named list | |
| 20 | `PREVIEW_OWNER_LOOKS` in `src/spawn/preview-looks.ts`, which the looks | |
| 21 | service adds to the manifest's pages (#8974). Production, and every other | |
| 22 | app, take only what their manifest lists. | |
| 23 | ||
| 16 | 24 | **The preview has an owner to be.** When the platform says |
| 17 | 25 | `FORGE_APP_ENV=preview`, the forge seeds a site admin, `preview-admin`, and |
| 18 | 26 | a small world for it once (`src/spawn/preview-seed.ts`): a public repository |
src/looks/service.ts +12 −1
| @@ -62,6 +62,7 @@ import { getPull } from "../core/pulls.ts"; | ||
| 62 | 62 | import { frontPicks } from "../core/front.ts"; |
| 63 | 63 | import { getRepoById } from "../core/repos.ts"; |
| 64 | 64 | import { getUserById } from "../core/users.ts"; |
| 65 | import { ownerLooksFor } from "../spawn/preview-looks.ts"; | |
| 65 | 66 | import { thumbnail } from "../design/image/image.ts"; |
| 66 | 67 | import { decodePng, encodePng, pngHeader } from "../design/image/png.ts"; |
| 67 | 68 | import { createLog } from "../kernel/log.ts"; |
| @@ -348,11 +349,20 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 348 | 349 | const status = runtime.envStatus(record, item.env); |
| 349 | 350 | if (status?.state !== "running" || status.commitOid !== item.commitOid) |
| 350 | 351 | return; |
| 352 | // A preview of the forge itself always takes its named owner looks | |
| 353 | // (`spawn/preview-looks.ts`), whatever its forge.json lists (#8974). | |
| 354 | const ownerLooks = ownerLooksFor(app.config.forgeRepo, repo, item.env); | |
| 355 | const pages = [ | |
| 356 | ...new Set([ | |
| 357 | ...(status.looks ?? [DEFAULT_PAGE]), | |
| 358 | ...ownerLooks.map((l) => l.page), | |
| 359 | ]), | |
| 360 | ]; | |
| 351 | 361 | const wanted = missing( |
| 352 | 362 | item.appId, |
| 353 | 363 | item.env, |
| 354 | 364 | item.commitOid, |
| 355 | status.looks, | |
| 365 | pages, | |
| 356 | 366 | item.swept === true, |
| 357 | 367 | ); |
| 358 | 368 | if (wanted.length === 0) return; |
| @@ -380,6 +390,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 380 | 390 | const asNobody = (kind: LookKind, page: string): boolean => |
| 381 | 391 | !sealed && |
| 382 | 392 | !(kind !== "thumb" && ownerPages.has(page)) && |
| 393 | !ownerLooks.some((l) => l.page === page && l.kind === kind) && | |
| 383 | 394 | (everyone || (kind === "thumb" && record.access === "public")); |
| 384 | 395 | const owner = getUserById(app.db, repo.ownerId); |
| 385 | 396 | const asOwner: GrantWho | undefined = |
src/spawn/preview-looks.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * The looks every preview of the forge takes as its owner (#8974): home | |
| 3 | * with its pins, /flow, the seeded repository's head and its pull's page, | |
| 4 | * each at desk and phone width. The looks service adds them to whatever | |
| 5 | * forge.json lists for a preview of the forge's own repository | |
| 6 | * (`FORGE_REPO`), so no edit of forge.json can drop one. | |
| 7 | * | |
| 8 | * Plain data and no imports beyond a type: the seed (`preview-seed.ts`) | |
| 9 | * makes what these pages show, and the looks service reads the list. | |
| 10 | */ | |
| 11 | ||
| 12 | import type { LookKind } from "../core/looks.ts"; | |
| 13 | ||
| 14 | /** The preview-only site admin's name. */ | |
| 15 | export const PREVIEW_ADMIN = "preview-admin"; | |
| 16 | /** The repository the admin's pages are about. */ | |
| 17 | export const PREVIEW_REPO = "sample"; | |
| 18 | /** | |
| 19 | * The seeded pull's number: two issues come first and share its count. | |
| 20 | * PREVIEW_OWNER_LOOKS names its page, so the seed refuses any other | |
| 21 | * number rather than leave that look on a 404. | |
| 22 | */ | |
| 23 | export const PREVIEW_PULL = 3; | |
| 24 | /** The seeded repository's head, and its pull's page. */ | |
| 25 | export const PREVIEW_REPO_HOME = `/${PREVIEW_ADMIN}/${PREVIEW_REPO}`; | |
| 26 | export const PREVIEW_PULL_PAGE = `${PREVIEW_REPO_HOME}/pulls/${String(PREVIEW_PULL)}`; | |
| 27 | ||
| 28 | export interface OwnerLook { | |
| 29 | readonly page: string; | |
| 30 | readonly kind: LookKind; | |
| 31 | } | |
| 32 | ||
| 33 | /** The named list: each page at desk and phone, taken as the owner. */ | |
| 34 | export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | |
| 35 | "/", | |
| 36 | "/flow", | |
| 37 | PREVIEW_REPO_HOME, | |
| 38 | PREVIEW_PULL_PAGE, | |
| 39 | ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind }))); | |
| 40 | ||
| 41 | /** | |
| 42 | * The owner looks a picture of `repo`'s `env` must include: the named list | |
| 43 | * for a preview of the forge's own repository, none for anything else — | |
| 44 | * production is not seeded, and another app has no such pages. | |
| 45 | */ | |
| 46 | export function ownerLooksFor( | |
| 47 | forgeRepo: string | undefined, | |
| 48 | repo: { readonly ownerName: string; readonly name: string }, | |
| 49 | env: string, | |
| 50 | ): readonly OwnerLook[] { | |
| 51 | return env !== "production" && forgeRepo !== undefined && forgeRepo === `${repo.ownerName}/${repo.name}` | |
| 52 | ? PREVIEW_OWNER_LOOKS | |
| 53 | : []; | |
| 54 | } |
src/spawn/preview-seed.ts +11 −11
| @@ -34,17 +34,17 @@ import { getUserSetting, setUserSetting } from "../core/settings.ts"; | ||
| 34 | 34 | import { createUser, getUserByName, type User } from "../core/users.ts"; |
| 35 | 35 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 36 | 36 | import { PREVIEW_CONFIG_FILES } from "./preview-config.ts"; |
| 37 | import { PREVIEW_ADMIN, PREVIEW_PULL, PREVIEW_PULL_PAGE, PREVIEW_REPO } from "./preview-looks.ts"; | |
| 38 | ||
| 39 | export { | |
| 40 | PREVIEW_ADMIN, | |
| 41 | PREVIEW_OWNER_LOOKS, | |
| 42 | PREVIEW_PULL, | |
| 43 | PREVIEW_PULL_PAGE, | |
| 44 | PREVIEW_REPO, | |
| 45 | PREVIEW_REPO_HOME, | |
| 46 | } from "./preview-looks.ts"; | |
| 37 | 47 | |
| 38 | /** The preview-only site admin's name. */ | |
| 39 | export const PREVIEW_ADMIN = "preview-admin"; | |
| 40 | /** The repository the admin's pages are about. */ | |
| 41 | export const PREVIEW_REPO = "sample"; | |
| 42 | /** | |
| 43 | * The seeded pull's number: two issues come first and share its count. | |
| 44 | * forge.json `app.looksAsOwner` names its page, so the seed refuses any | |
| 45 | * other number rather than leave that look on a 404. | |
| 46 | */ | |
| 47 | export const PREVIEW_PULL = 3; | |
| 48 | 48 | /** The seeded wave's version and headline, which the owner's /waves look shows. */ |
| 49 | 49 | export const PREVIEW_WAVE = "0.1.0"; |
| 50 | 50 | export const PREVIEW_WAVE_HEADLINE = "The sample says hello"; |
| @@ -295,7 +295,7 @@ function seedMilestone(app: App, milestone: Issue, holding: readonly Issue[]): R | ||
| 295 | 295 | |
| 296 | 296 | /** The wave for /waves: `waves/<version>.md` on the default branch, naming the seeded pull. */ |
| 297 | 297 | async function seedWave(app: App, admin: User, repo: Repo, pullTitle: string): Promise<Result<void, string>> { |
| 298 | const url = `https://preview.invalid/${PREVIEW_ADMIN}/${PREVIEW_REPO}/pulls/${String(PREVIEW_PULL)}`; | |
| 298 | const url = `https://preview.invalid${PREVIEW_PULL_PAGE}`; | |
| 299 | 299 | const text = [ |
| 300 | 300 | "---", |
| 301 | 301 | `version: ${PREVIEW_WAVE}`, |
test/looks-owner.test.ts +75 −6
| @@ -34,8 +34,18 @@ import { ok } from "../src/kernel/result.ts"; | ||
| 34 | 34 | import type { Capture, LookInput } from "../src/looks/capture.ts"; |
| 35 | 35 | import { startLooks, type LooksHandle } from "../src/looks/service.ts"; |
| 36 | 36 | import { previewIdentityKey } from "../src/spawn/preview-owner.ts"; |
| 37 | import { PREVIEW_ADMIN, PREVIEW_PULL, PREVIEW_REPO, PREVIEW_WAVE, PREVIEW_WAVE_HEADLINE, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 37 | import { | |
| 38 | PREVIEW_ADMIN, | |
| 39 | PREVIEW_PULL, | |
| 40 | PREVIEW_PULL_PAGE, | |
| 41 | PREVIEW_REPO, | |
| 42 | PREVIEW_REPO_HOME, | |
| 43 | PREVIEW_WAVE, | |
| 44 | PREVIEW_WAVE_HEADLINE, | |
| 45 | seedPreview, | |
| 46 | } from "../src/spawn/preview-seed.ts"; | |
| 38 | 47 | import { PREVIEW_CONFIG_FILES } from "../src/spawn/preview-config.ts"; |
| 48 | import { ownerLooksFor } from "../src/spawn/preview-looks.ts"; | |
| 39 | 49 | import { localSource } from "../src/view/local.ts"; |
| 40 | 50 | import { isFormConfig, parseConfig } from "../src/web/render/config-format.ts"; |
| 41 | 51 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -267,8 +277,8 @@ describe("a preview hears its parent's admin as the preview admin", () => { | ||
| 267 | 277 | |
| 268 | 278 | describe("the forge's own owner looks, over the preview seed", () => { |
| 269 | 279 | // Each page forge.json takes as the owner, and what shows it is not blank (#8865). |
| 270 | const repoHome = `/${PREVIEW_ADMIN}/${PREVIEW_REPO}`; | |
| 271 | const pullPage = `${repoHome}/pulls/${String(PREVIEW_PULL)}`; | |
| 280 | const repoHome = PREVIEW_REPO_HOME; | |
| 281 | const pullPage = PREVIEW_PULL_PAGE; | |
| 272 | 282 | const KEY = "the-parents-identity-key"; |
| 273 | 283 | let dataDir: string; |
| 274 | 284 | let app: App; |
| @@ -297,10 +307,10 @@ describe("the forge's own owner looks, over the preview seed", () => { | ||
| 297 | 307 | return res.text(); |
| 298 | 308 | }; |
| 299 | 309 | |
| 300 | test("forge.json takes home, /flow, the seeded repository and its pull as the owner, desk and phone", () => { | |
| 310 | test("forge.json takes the seed's other pages as the owner, and the seed's pull is the one the named list pictures", () => { | |
| 301 | 311 | const manifest = parseManifest(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8"), undefined); |
| 302 | 312 | if (!manifest.ok) throw new Error(JSON.stringify(manifest.error)); |
| 303 | for (const page of ["/", "/flow", repoHome, pullPage, `${repoHome}/waves`, `${repoHome}/milestones`]) { | |
| 313 | for (const page of ["next", "bets", "waves", "milestones"].map((p) => `${repoHome}/${p}`)) { | |
| 304 | 314 | expect(manifest.value.looks).toContain(page); |
| 305 | 315 | expect(manifest.value.looksAsOwner).toContain(page); |
| 306 | 316 | } |
| @@ -359,7 +369,7 @@ describe("looks of a public app", () => { | ||
| 359 | 369 | |
| 360 | 370 | beforeAll(async () => { |
| 361 | 371 | dataDir = await mkdtemp(join(tmpdir(), "forge-looks-owner-")); |
| 362 | app = createApp({ dataDir, port: 0 }); | |
| 372 | app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" }); | |
| 363 | 373 | servers = await serve(app); |
| 364 | 374 | runtime = new AppsRuntime(app, { |
| 365 | 375 | domain: "apps.test", |
| @@ -452,4 +462,63 @@ describe("looks of a public app", () => { | ||
| 452 | 462 | expect(grantOf("/", 390)?.who).toBeUndefined(); |
| 453 | 463 | expect(grantOf("/", 1280)?.who).toBeUndefined(); |
| 454 | 464 | }, 120_000); |
| 465 | ||
| 466 | test("a preview of the forge's own repository takes home, /flow, the repository's head and the pull as the owner at desk and phone, whatever forge.json lists", async () => { | |
| 467 | const made = await newApp(app, alice, { name: "forge", visibility: "public" }); | |
| 468 | if (!made.ok) throw new Error(String(made.error)); | |
| 469 | const { repo, app: record } = made.value; | |
| 470 | const branch = await commitToBranch(app, alice, repo, { | |
| 471 | branch: "pages", | |
| 472 | base: repo.defaultBranch, | |
| 473 | message: "List only home", | |
| 474 | files: [ | |
| 475 | { | |
| 476 | path: "forge.json", | |
| 477 | content: JSON.stringify({ app: { run: "bun run src/server.ts", health: "/healthz", looks: ["/"] } }), | |
| 478 | }, | |
| 479 | ], | |
| 480 | }); | |
| 481 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 482 | const opened = await openPull(app, alice, repo, { title: "Pages", baseBranch: repo.defaultBranch, headBranch: "pages" }); | |
| 483 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 484 | const env = previewEnv(opened.value.number); | |
| 485 | const commit = branch.value.commitOid; | |
| 486 | const pages = ["/", "/flow", PREVIEW_REPO_HOME, PREVIEW_PULL_PAGE]; | |
| 487 | const deadline = Date.now() + 90_000; | |
| 488 | const light = () => | |
| 489 | looksForEnv(app.db, record.id, env).filter((l) => l.commitOid === commit && l.theme === "light"); | |
| 490 | while (light().length < pages.length * 3 && Date.now() < deadline) await Bun.sleep(100); | |
| 491 | await looks.idle(); | |
| 492 | const rows = light(); | |
| 493 | for (const page of pages) { | |
| 494 | for (const [kind, width] of [["desktop", 1280], ["phone", 390]] as const) { | |
| 495 | expect({ page, kind, takenAs: rows.find((l) => l.page === page && l.kind === kind)?.takenAs }).toEqual({ | |
| 496 | page, | |
| 497 | kind, | |
| 498 | takenAs: "owner", | |
| 499 | }); | |
| 500 | const call = calls.find( | |
| 501 | (c) => | |
| 502 | c.url.includes(`forge--${opened.value.number}.`) && | |
| 503 | new URL(c.url).searchParams.get("to") === page && | |
| 504 | c.width === width && | |
| 505 | c.dark !== true && | |
| 506 | !c.out.includes("thumb"), | |
| 507 | ); | |
| 508 | expect(call).toBeDefined(); | |
| 509 | const grant = readGrant(dataDir, new URL(call!.url).searchParams.get("t") ?? "", { app: record, env }); | |
| 510 | expect(grant?.who).toEqual({ id: alice.id, name: "alice", role: "admin" }); | |
| 511 | } | |
| 512 | // The thumb is what a stranger sees, still. | |
| 513 | expect(rows.find((l) => l.page === page && l.kind === "thumb")?.takenAs).toBe("nobody"); | |
| 514 | } | |
| 515 | }, 120_000); | |
| 516 | ||
| 517 | test("production of the forge, and a preview of any other repository, add none", () => { | |
| 518 | const forge = { ownerName: "alice", name: "forge" }; | |
| 519 | expect(ownerLooksFor("alice/forge", forge, "production")).toEqual([]); | |
| 520 | expect(ownerLooksFor("alice/forge", { ownerName: "alice", name: "plaza" }, previewEnv(1))).toEqual([]); | |
| 521 | expect(ownerLooksFor(undefined, forge, previewEnv(1))).toEqual([]); | |
| 522 | expect(ownerLooksFor("alice/forge", forge, previewEnv(1)).length).toBeGreaterThan(0); | |
| 523 | }); | |
| 455 | 524 | }); |
new above
offline