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

Merged opened this

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

Merged

mason/forge:mason/8974-a-forge-preview-s-looks-always-include-t → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/8974-a-forge-preview-s-looks-always-include-t was merged into main as a70a895.

Review

Sign in to review this change.
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
1313 there that `app.looks` does not name is a manifest error. The forge names
1414 `/`, `/flow` and `/talk`.
1515
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
1624 **The preview has an owner to be.** When the platform says
1725 `FORGE_APP_ENV=preview`, the forge seeds a site admin, `preview-admin`, and
1826 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";
6262 import { frontPicks } from "../core/front.ts";
6363 import { getRepoById } from "../core/repos.ts";
6464 import { getUserById } from "../core/users.ts";
65 import { ownerLooksFor } from "../spawn/preview-looks.ts";
6566 import { thumbnail } from "../design/image/image.ts";
6667 import { decodePng, encodePng, pngHeader } from "../design/image/png.ts";
6768 import { createLog } from "../kernel/log.ts";
@@ -348,11 +349,20 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
348349 const status = runtime.envStatus(record, item.env);
349350 if (status?.state !== "running" || status.commitOid !== item.commitOid)
350351 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 ];
351361 const wanted = missing(
352362 item.appId,
353363 item.env,
354364 item.commitOid,
355 status.looks,
365 pages,
356366 item.swept === true,
357367 );
358368 if (wanted.length === 0) return;
@@ -380,6 +390,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
380390 const asNobody = (kind: LookKind, page: string): boolean =>
381391 !sealed &&
382392 !(kind !== "thumb" && ownerPages.has(page)) &&
393 !ownerLooks.some((l) => l.page === page && l.kind === kind) &&
383394 (everyone || (kind === "thumb" && record.access === "public"));
384395 const owner = getUserById(app.db, repo.ownerId);
385396 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";
3434 import { createUser, getUserByName, type User } from "../core/users.ts";
3535 import { err, ok, type Result } from "../kernel/result.ts";
3636 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";
3747
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;
4848 /** The seeded wave's version and headline, which the owner's /waves look shows. */
4949 export const PREVIEW_WAVE = "0.1.0";
5050 export const PREVIEW_WAVE_HEADLINE = "The sample says hello";
@@ -295,7 +295,7 @@ function seedMilestone(app: App, milestone: Issue, holding: readonly Issue[]): R
295295
296296 /** The wave for /waves: `waves/<version>.md` on the default branch, naming the seeded pull. */
297297 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}`;
299299 const text = [
300300 "---",
301301 `version: ${PREVIEW_WAVE}`,
test/looks-owner.test.ts +75 −6
@@ -34,8 +34,18 @@ import { ok } from "../src/kernel/result.ts";
3434 import type { Capture, LookInput } from "../src/looks/capture.ts";
3535 import { startLooks, type LooksHandle } from "../src/looks/service.ts";
3636 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";
3847 import { PREVIEW_CONFIG_FILES } from "../src/spawn/preview-config.ts";
48 import { ownerLooksFor } from "../src/spawn/preview-looks.ts";
3949 import { localSource } from "../src/view/local.ts";
4050 import { isFormConfig, parseConfig } from "../src/web/render/config-format.ts";
4151 import { quietLogs } from "./helpers/globals.ts";
@@ -267,8 +277,8 @@ describe("a preview hears its parent's admin as the preview admin", () => {
267277
268278 describe("the forge's own owner looks, over the preview seed", () => {
269279 // 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;
272282 const KEY = "the-parents-identity-key";
273283 let dataDir: string;
274284 let app: App;
@@ -297,10 +307,10 @@ describe("the forge's own owner looks, over the preview seed", () => {
297307 return res.text();
298308 };
299309
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", () => {
301311 const manifest = parseManifest(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8"), undefined);
302312 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}`)) {
304314 expect(manifest.value.looks).toContain(page);
305315 expect(manifest.value.looksAsOwner).toContain(page);
306316 }
@@ -359,7 +369,7 @@ describe("looks of a public app", () => {
359369
360370 beforeAll(async () => {
361371 dataDir = await mkdtemp(join(tmpdir(), "forge-looks-owner-"));
362 app = createApp({ dataDir, port: 0 });
372 app = createApp({ dataDir, port: 0, forgeRepo: "alice/forge" });
363373 servers = await serve(app);
364374 runtime = new AppsRuntime(app, {
365375 domain: "apps.test",
@@ -452,4 +462,63 @@ describe("looks of a public app", () => {
452462 expect(grantOf("/", 390)?.who).toBeUndefined();
453463 expect(grantOf("/", 1280)?.who).toBeUndefined();
454464 }, 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 });
455524 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs