Public: anyone can read this; the crew can change it.
A forge preview takes owner looks of an app's page, so a pull touching it shows main beside head #9784
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9782-a-forge-preview-takes-owner-looks-of-an → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +81
−1
src/spawn/preview-looks.ts +10 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The looks every preview of the forge takes as its owner (#8974): home |
| 3 | 3 | * with its pins, /flow, the seeded repository's head, its pull's page and |
| 4 | * its Iterations page (#9182), the Later gate's proposal (#9212), its | |
| 4 | * its Iterations page (#9182), the seeded app's page (#9782), the Later gate's proposal (#9212), its | |
| 5 | 5 | * milestones (#9326) and its waves (#9325), each at desk and phone width. |
| 6 | 6 | * The looks service adds them to whatever forge.json lists for a preview |
| 7 | 7 | * of the forge's own repository (`FORGE_REPO`), so no edit of forge.json can |
| @@ -27,6 +27,14 @@ export const PREVIEW_PULL = 3; | ||
| 27 | 27 | export const PREVIEW_REPO_HOME = `/${PREVIEW_ADMIN}/${PREVIEW_REPO}`; |
| 28 | 28 | export const PREVIEW_PULL_PAGE = `${PREVIEW_REPO_HOME}/pulls/${String(PREVIEW_PULL)}`; |
| 29 | 29 | |
| 30 | /** | |
| 31 | * The seeded app: a second repository whose only app fact is its record, so | |
| 32 | * its home is the app page (#9782). Nothing runs: the page says so. The seed | |
| 33 | * refuses to finish unless that page answers 200. | |
| 34 | */ | |
| 35 | export const PREVIEW_APP = "hello"; | |
| 36 | export const PREVIEW_APP_PAGE = `/${PREVIEW_ADMIN}/${PREVIEW_APP}`; | |
| 37 | ||
| 30 | 38 | /** The Iterations page of the seeded fork family (#9182). */ |
| 31 | 39 | export const PREVIEW_ITERATIONS_PAGE = `${PREVIEW_REPO_HOME}/iterations`; |
| 32 | 40 | /** |
| @@ -104,6 +112,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 104 | 112 | "/", |
| 105 | 113 | "/flow", |
| 106 | 114 | PREVIEW_REPO_HOME, |
| 115 | PREVIEW_APP_PAGE, | |
| 107 | 116 | PREVIEW_PULL_PAGE, |
| 108 | 117 | PREVIEW_ITERATIONS_PAGE, |
| 109 | 118 | PREVIEW_HELD_PAGE, |
src/spawn/preview-seed.ts +37
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | import type { Ctx } from "../http/context.ts"; | |
| 18 | 19 | import type { Issue } from "../core/issues.ts"; |
| 19 | 20 | import { getRepo, type Repo } from "../core/repos.ts"; |
| 20 | 21 | import { pinPanel } from "../actions/board-pins.ts"; |
| @@ -26,6 +27,8 @@ import { mergePull, openPull } from "../actions/pulls.ts"; | ||
| 26 | 27 | import { commitToBranch, createRepo, forkRepo } from "../actions/repos.ts"; |
| 27 | 28 | import { reviewGate } from "../actions/gate_review.ts"; |
| 28 | 29 | import { linkIssue } from "../actions/dependencies.ts"; |
| 30 | import { createApp as createAppRecord } from "../core/apps.ts"; | |
| 31 | import { studioApp } from "../web/studio/app.ts"; | |
| 29 | 32 | import { THEMES_AT_REST } from "../web/pages/gate.ts"; |
| 30 | 33 | import { iterationsOf } from "../core/iterations.ts"; |
| 31 | 34 | import { turnValveFor } from "../actions/valves.ts"; |
| @@ -42,6 +45,8 @@ import { err, ok, type Result } from "../kernel/result.ts"; | ||
| 42 | 45 | import { PREVIEW_CONFIG_FILES } from "./preview-config.ts"; |
| 43 | 46 | import { |
| 44 | 47 | PREVIEW_ADMIN, |
| 48 | PREVIEW_APP, | |
| 49 | PREVIEW_APP_PAGE, | |
| 45 | 50 | PREVIEW_FORKS, |
| 46 | 51 | PREVIEW_HELD_PAGE, |
| 47 | 52 | PREVIEW_HELD_THEMES, |
| @@ -53,6 +58,8 @@ import { | ||
| 53 | 58 | |
| 54 | 59 | export { |
| 55 | 60 | PREVIEW_ADMIN, |
| 61 | PREVIEW_APP, | |
| 62 | PREVIEW_APP_PAGE, | |
| 56 | 63 | PREVIEW_FORKS, |
| 57 | 64 | PREVIEW_HELD_PAGE, |
| 58 | 65 | PREVIEW_HELD_THEMES, |
| @@ -244,6 +251,8 @@ async function seedWorld(app: App, admin: User): Promise<Result<void, string>> { | ||
| 244 | 251 | if (!family.ok) return family; |
| 245 | 252 | const gated = seedLaterGate(app, admin, repo.value); |
| 246 | 253 | if (!gated.ok) return gated; |
| 254 | const hosted = await seedApp(app, admin); | |
| 255 | if (!hosted.ok) return hosted; | |
| 247 | 256 | const valve = turnValveFor(app, admin, `${PREVIEW_ADMIN}/${PREVIEW_REPO}`, 60); |
| 248 | 257 | if (!valve.ok) return err(`the valve: ${valve.error}`); |
| 249 | 258 | |
| @@ -484,3 +493,31 @@ function seedLaterGate(app: App, admin: User, repo: Repo): Result<void, string> | ||
| 484 | 493 | return err(`${PREVIEW_HELD_PAGE} shows ${String(themes)} themes, not more than the ${String(THEMES_AT_REST)} drawn at rest`); |
| 485 | 494 | return ok(); |
| 486 | 495 | } |
| 496 | ||
| 497 | /** | |
| 498 | * One app, for the app page's look (#9782): a repository and its record, | |
| 499 | * nothing more. No process starts and no network is reached; the page says | |
| 500 | * the app is not running. Refused when the page would answer anything but 200. | |
| 501 | */ | |
| 502 | async function seedApp(app: App, admin: User): Promise<Result<void, string>> { | |
| 503 | const repo = await createRepo(app, admin, { | |
| 504 | name: PREVIEW_APP, | |
| 505 | visibility: "public", | |
| 506 | description: "A preview's sample app", | |
| 507 | }); | |
| 508 | if (!repo.ok) return err(`the sample app's repository: ${String(repo.error)}`); | |
| 509 | const first = await commitToBranch(app, admin, repo.value, { | |
| 510 | message: "Start", | |
| 511 | files: [{ path: "README.md", content: "# hello\n\nA preview's sample app.\n" }], | |
| 512 | }); | |
| 513 | if (!first.ok) return err(`the sample app's commit: ${JSON.stringify(first.error)}`); | |
| 514 | const record = createAppRecord(app.db, { repoId: repo.value.id, slug: PREVIEW_APP, access: "public" }); | |
| 515 | if (!record.ok) return err(`the sample app's record: ${record.error}`); | |
| 516 | const url = new URL(`https://preview.invalid${PREVIEW_APP_PAGE}`); | |
| 517 | const answer = await studioApp( | |
| 518 | { app, request: new Request(url.href), url, params: {}, auth: { user: admin }, theme: undefined } as Ctx, | |
| 519 | repo.value, | |
| 520 | record.value, | |
| 521 | ); | |
| 522 | return answer.status === 200 ? ok() : err(`${PREVIEW_APP_PAGE} would answer ${String(answer.status)}, not 200`); | |
| 523 | } | |
test/preview-app-look.test.ts added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | /** The preview seeds one app and takes its page at desk and phone as the owner (#9782). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { getAppByRepo } from "../src/core/apps.ts"; | |
| 8 | import { getRepo } from "../src/core/repos.ts"; | |
| 9 | import { PREVIEW_ADMIN, PREVIEW_APP, PREVIEW_APP_PAGE, PREVIEW_OWNER_LOOKS, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 10 | ||
| 11 | let dir: string; | |
| 12 | let app: App; | |
| 13 | beforeAll(async () => { | |
| 14 | dir = await mkdtemp(join(tmpdir(), "forge-preview-app-")); | |
| 15 | app = createApp({ dataDir: dir, port: 0 }); | |
| 16 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 17 | if (!seeded.ok) throw new Error(seeded.error); | |
| 18 | }, 60_000); | |
| 19 | afterAll(async () => { | |
| 20 | app.close(); | |
| 21 | await rm(dir, { recursive: true, force: true }); | |
| 22 | }); | |
| 23 | ||
| 24 | test("the named list holds the app's page at desktop and phone", () => { | |
| 25 | expect(PREVIEW_APP_PAGE).toBe(`/${PREVIEW_ADMIN}/${PREVIEW_APP}`); | |
| 26 | expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: PREVIEW_APP_PAGE, kind: "desktop" }); | |
| 27 | expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: PREVIEW_APP_PAGE, kind: "phone" }); | |
| 28 | }); | |
| 29 | ||
| 30 | test("the seed made one app, as a record only", () => { | |
| 31 | const repo = getRepo(app.db, PREVIEW_ADMIN, PREVIEW_APP)!; | |
| 32 | expect(getAppByRepo(app.db, repo.id)?.slug).toBe(PREVIEW_APP); | |
| 33 | expect(app.db.query<{ n: number }, []>("SELECT count(*) AS n FROM app").get()!.n).toBe(1); | |
| 34 | }); |
Hand-built Ctx cast with
as Ctxhides missing fields; build it through the same helper the router uses, and add a test that forces a non-200 to prove the seed refuses.