Public: anyone can read this; the crew can change it.
Every look is a picture of the app's home page, whatever the change touched #295
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/264-every-look-is-a-picture-of-the-app-s-hom → main 1 commit fork
12 files
changed +381
−82
docs/APPS.md +23 −15
| @@ -153,7 +153,8 @@ The repository says how to run itself, in `forge.json` at its root: | ||
| 153 | 153 | "test": "bun test", |
| 154 | 154 | "health": "/", |
| 155 | 155 | "ready": 30, |
| 156 | "tty": "bun run src/tty.ts" | |
| 156 | "tty": "bun run src/tty.ts", | |
| 157 | "looks": ["/", "/board"] | |
| 157 | 158 | } |
| 158 | 159 | } |
| 159 | 160 | |
| @@ -162,9 +163,10 @@ Only `run` is required, and even it defaults: a `package.json` with a `start` | ||
| 162 | 163 | script is enough. `install` defaults to `bun install` when a `package.json` |
| 163 | 164 | exists and to nothing otherwise; `test` defaults to nothing; `health` is the |
| 164 | 165 | path polled until it answers; `ready` is the number of seconds to wait for |
| 165 | that before the deploy fails; `tty` is the app's terminal interface (below). The file is read from the commit being | |
| 166 | deployed, never from the database — a pull that changes how the app runs is | |
| 167 | previewed the way it will run. | |
| 166 | that before the deploy fails; `tty` is the app's terminal interface (below); | |
| 167 | `looks` names the pages worth a picture (below), and defaults to `["/"]`. The | |
| 168 | file is read from the commit being deployed, never from the database — a | |
| 169 | pull that changes how the app runs is previewed the way it will run. | |
| 168 | 170 | |
| 169 | 171 | The forge starts the app with: |
| 170 | 172 | |
| @@ -453,17 +455,23 @@ older ones are history, not status. | ||
| 453 | 455 | |
| 454 | 456 | ### The look |
| 455 | 457 | |
| 456 | When a deploy comes up green the forge takes a picture of it — the page at | |
| 457 | `/`, at a phone's width (390×844), a laptop's (1280×800), and a thumb's | |
| 458 | (200×125, desktop's own aspect, small) — and keeps the pictures beside the | |
| 459 | commit they are of, so a person judges a change from what it is rather than | |
| 460 | from what it says. The pull page shows the phone and desktop pair under the | |
| 461 | checks, with the commit and a word when the head has moved since; the | |
| 462 | studio's ask row and the board's landed row lead with the thumb; an app's | |
| 463 | studio head shows how production looks now, with the thumb too. A thumb | |
| 464 | missing on a commit pictured before it existed falls back to the phone shot | |
| 465 | that slot used to load, so nothing goes blank. Pictures are served at | |
| 466 | `/:owner/:name/looks/<env>/<commit>/{phone,desktop,thumb}.png` with the | |
| 458 | When a deploy comes up green the forge takes a picture of it — every page | |
| 459 | `app.looks` names, `/` alone when it names none — at a phone's width | |
| 460 | (390×844), a laptop's (1280×800), and a thumb's (200×125, desktop's own | |
| 461 | aspect, small), and keeps the pictures beside the commit they are of, so a | |
| 462 | person judges a change from what it is rather than from what it says. | |
| 463 | `app.looks` may name at most three pages: a browser start per picture per | |
| 464 | width is the heaviest thing the forge does, and more is a manifest error | |
| 465 | rather than a silent, slower deploy. The pull page shows the phone and | |
| 466 | desktop pair under the checks, with the commit and a word when the head has | |
| 467 | moved since; more than one page gets one group per page, each labelled by | |
| 468 | its path, since a page that failed keeps its last good picture while another | |
| 469 | moves on. The studio's ask row, the board's landed row, and an app's studio | |
| 470 | head always show the home page's own picture, whatever else `app.looks` | |
| 471 | names — the thumb, or (before it existed on that commit) the phone shot that | |
| 472 | slot used to load, so nothing goes blank. Pictures are served at | |
| 473 | `/:owner/:name/looks/<env>/<commit>/<kind>.png` for the home page, or | |
| 474 | `/:owner/:name/looks/<env>/<commit>/<page>-<kind>.png` for another, with the | |
| 467 | 475 | repository's own read access, cached by commit. |
| 468 | 476 | |
| 469 | 477 | The picture is what the repository's owner would see: headless Chromium is |
src/apps/manifest.ts +52
| @@ -29,6 +29,12 @@ export interface AppManifest { | ||
| 29 | 29 | * environment's own variables. `null` means the app has none. |
| 30 | 30 | */ |
| 31 | 31 | readonly tty: string | null; |
| 32 | /** | |
| 33 | * The pages worth a look: each pictured at every width, once a deploy | |
| 34 | * comes up green. `["/"]` when the manifest says nothing, exactly as | |
| 35 | * before this field existed. | |
| 36 | */ | |
| 37 | readonly looks: readonly string[]; | |
| 32 | 38 | } |
| 33 | 39 | |
| 34 | 40 | export type ManifestError = |
| @@ -40,6 +46,10 @@ export type ManifestError = | ||
| 40 | 46 | const READY_DEFAULT = 30; |
| 41 | 47 | const READY_MAX = 600; |
| 42 | 48 | const COMMAND_MAX = 1_000; |
| 49 | /** More than this many pages is a manifest error: a browser start per | |
| 50 | * picture per width is the heaviest thing the forge does. */ | |
| 51 | const LOOKS_MAX = 3; | |
| 52 | const LOOK_PATH_MAX = 200; | |
| 43 | 53 | |
| 44 | 54 | /** |
| 45 | 55 | * Resolve a manifest from the two files that can declare one. `forgeJson` |
| @@ -64,6 +74,7 @@ export function parseManifest( | ||
| 64 | 74 | health: "/", |
| 65 | 75 | ready: READY_DEFAULT, |
| 66 | 76 | tty: null, |
| 77 | looks: ["/"], | |
| 67 | 78 | }); |
| 68 | 79 | } |
| 69 | 80 | |
| @@ -104,6 +115,8 @@ export function parseManifest( | ||
| 104 | 115 | return invalid( |
| 105 | 116 | `forge.json: app.ready must be a whole number of seconds, 1–${READY_MAX}`, |
| 106 | 117 | ); |
| 118 | const looks = looksField(app); | |
| 119 | if (!looks.ok) return looks; | |
| 107 | 120 | |
| 108 | 121 | return ok({ |
| 109 | 122 | run: runCommand, |
| @@ -119,9 +132,48 @@ export function parseManifest( | ||
| 119 | 132 | health: health ?? "/", |
| 120 | 133 | ready: ready ?? READY_DEFAULT, |
| 121 | 134 | tty: tty.value || null, |
| 135 | looks: looks.value, | |
| 122 | 136 | }); |
| 123 | 137 | } |
| 124 | 138 | |
| 139 | /** | |
| 140 | * `app.looks`: the pages worth a picture. Absent is `["/"]`, exactly as | |
| 141 | * before this field existed. Validated like any other boundary input — a | |
| 142 | * path beginning with `/`, no scheme, no host, no `..`, no control bytes, | |
| 143 | * capped in length — and capped in count, because a browser start per | |
| 144 | * picture per width is the heaviest thing the forge does. | |
| 145 | */ | |
| 146 | function looksField(app: Json): Result<readonly string[], ManifestError> { | |
| 147 | const value = app["looks"]; | |
| 148 | if (value === undefined) return ok(["/"]); | |
| 149 | if (!Array.isArray(value)) | |
| 150 | return invalid("forge.json: app.looks must be an array of paths"); | |
| 151 | if (value.length > LOOKS_MAX) | |
| 152 | return invalid(`forge.json: app.looks may name at most ${LOOKS_MAX} paths`); | |
| 153 | if (value.length === 0) return ok(["/"]); | |
| 154 | const paths: string[] = []; | |
| 155 | for (const item of value) { | |
| 156 | if (typeof item !== "string") | |
| 157 | return invalid("forge.json: app.looks entries must be strings"); | |
| 158 | if (item.length > LOOK_PATH_MAX) | |
| 159 | return invalid( | |
| 160 | `forge.json: app.looks entries must be at most ${LOOK_PATH_MAX} characters`, | |
| 161 | ); | |
| 162 | if (!item.startsWith("/") || item.startsWith("//")) | |
| 163 | return invalid( | |
| 164 | "forge.json: app.looks entries must be a path starting with / (no scheme, no host)", | |
| 165 | ); | |
| 166 | if (item.includes("..")) | |
| 167 | return invalid("forge.json: app.looks entries must not contain .."); | |
| 168 | if (/[\x00-\x1f\x7f]/.test(item)) | |
| 169 | return invalid( | |
| 170 | "forge.json: app.looks entries must not contain control characters", | |
| 171 | ); | |
| 172 | paths.push(item); | |
| 173 | } | |
| 174 | return ok(paths); | |
| 175 | } | |
| 176 | ||
| 125 | 177 | /** |
| 126 | 178 | * How a repository checks a change: `check` in `forge.json`, beside or |
| 127 | 179 | * instead of `app`. Run on every push a pull or the default branch takes, |
src/apps/runtime.ts +8
| @@ -116,6 +116,12 @@ export interface EnvStatus { | ||
| 116 | 116 | * that has not started has not said either way. |
| 117 | 117 | */ |
| 118 | 118 | readonly tty?: string | null | undefined; |
| 119 | /** | |
| 120 | * The pages `app.looks` names worth a picture, once known live — `["/"]` | |
| 121 | * when its `forge.json` says nothing. Unset while never live, the same | |
| 122 | * as `tty`. | |
| 123 | */ | |
| 124 | readonly looks?: readonly string[] | undefined; | |
| 119 | 125 | /** |
| 120 | 126 | * A deploy newer than the one serving that never came up — set only |
| 121 | 127 | * while an older deployment keeps answering in its place. A later |
| @@ -311,6 +317,7 @@ export class AppsRuntime { | ||
| 311 | 317 | servingAt: state.live.servingAt, |
| 312 | 318 | createdAt: state.live.createdAt, |
| 313 | 319 | tty: state.live.manifest.tty, |
| 320 | looks: state.live.manifest.looks, | |
| 314 | 321 | ...(state.live.deployStartedAt === null |
| 315 | 322 | ? {} |
| 316 | 323 | : { deployStartedAt: state.live.deployStartedAt }), |
| @@ -346,6 +353,7 @@ export class AppsRuntime { | ||
| 346 | 353 | servingAt: state.live.servingAt, |
| 347 | 354 | createdAt: state.live.createdAt, |
| 348 | 355 | tty: state.live.manifest.tty, |
| 356 | looks: state.live.manifest.looks, | |
| 349 | 357 | ...(state.live.deployStartedAt === null |
| 350 | 358 | ? {} |
| 351 | 359 | : { deployStartedAt: state.live.deployStartedAt }), |
src/core/looks.ts +38 −7
| @@ -27,11 +27,36 @@ export const LOOK_SIZES: Readonly< | ||
| 27 | 27 | thumb: { width: 200, height: 125 }, |
| 28 | 28 | }; |
| 29 | 29 | |
| 30 | /** A look with no page recorded — every row before `app.looks` existed. */ | |
| 31 | export const DEFAULT_PAGE = "/"; | |
| 32 | ||
| 33 | /** | |
| 34 | * The page flattened to a filename/URL fragment: the home page is empty | |
| 35 | * (so it names nothing, below), any other path has its slashes joined | |
| 36 | * with `-`. Compared against, never parsed back — the database's `page` | |
| 37 | * column is truth. | |
| 38 | */ | |
| 39 | const pageSlug = (page: string): string => | |
| 40 | page === DEFAULT_PAGE ? "" : page.slice(1).replace(/\//g, "-"); | |
| 41 | ||
| 42 | /** | |
| 43 | * The fragment one picture is named by, on disk and in its URL: the page | |
| 44 | * and the kind, or — for the home page — the kind alone, exactly as | |
| 45 | * before `app.looks` existed, so two pages of one commit never collide | |
| 46 | * and old names keep meaning what they always meant. | |
| 47 | */ | |
| 48 | export const lookSlot = (page: string, kind: LookKind): string => { | |
| 49 | const slug = pageSlug(page); | |
| 50 | return slug === "" ? kind : `${slug}-${kind}`; | |
| 51 | }; | |
| 52 | ||
| 30 | 53 | export interface Look { |
| 31 | 54 | readonly id: string; |
| 32 | 55 | readonly appId: string; |
| 33 | 56 | readonly env: string; |
| 34 | 57 | readonly commitOid: string; |
| 58 | /** The page pictured — `forge.json`'s `app.looks`, `/` when unset. */ | |
| 59 | readonly page: string; | |
| 35 | 60 | readonly kind: LookKind; |
| 36 | 61 | /** Where the picture is, relative to the data directory; empty on an error. */ |
| 37 | 62 | readonly path: string; |
| @@ -45,6 +70,7 @@ interface Row { | ||
| 45 | 70 | app_id: string; |
| 46 | 71 | env: string; |
| 47 | 72 | commit_oid: string; |
| 73 | page: string; | |
| 48 | 74 | kind: LookKind; |
| 49 | 75 | path: string; |
| 50 | 76 | created_at: string; |
| @@ -56,6 +82,7 @@ const toLook = (r: Row): Look => ({ | ||
| 56 | 82 | appId: r.app_id, |
| 57 | 83 | env: r.env, |
| 58 | 84 | commitOid: r.commit_oid, |
| 85 | page: r.page, | |
| 59 | 86 | kind: r.kind, |
| 60 | 87 | path: r.path, |
| 61 | 88 | createdAt: r.created_at, |
| @@ -66,6 +93,7 @@ export interface RecordLookInput { | ||
| 66 | 93 | readonly appId: string; |
| 67 | 94 | readonly env: string; |
| 68 | 95 | readonly commitOid: string; |
| 96 | readonly page?: string | undefined; | |
| 69 | 97 | readonly kind: LookKind; |
| 70 | 98 | readonly path: string; |
| 71 | 99 | readonly error?: string | undefined; |
| @@ -73,10 +101,11 @@ export interface RecordLookInput { | ||
| 73 | 101 | |
| 74 | 102 | /** Record a picture, or the reason there is none. A retake replaces. */ |
| 75 | 103 | export function recordLook(db: Database, input: RecordLookInput): Look { |
| 104 | const page = input.page ?? DEFAULT_PAGE; | |
| 76 | 105 | db.query( |
| 77 | `INSERT INTO look (id, app_id, env, commit_oid, kind, path, error) | |
| 78 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) | |
| 79 | ON CONFLICT (app_id, env, commit_oid, kind) DO UPDATE SET | |
| 106 | `INSERT INTO look (id, app_id, env, commit_oid, page, kind, path, error) | |
| 107 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) | |
| 108 | ON CONFLICT (app_id, env, commit_oid, page, kind) DO UPDATE SET | |
| 80 | 109 | path = excluded.path, error = excluded.error, |
| 81 | 110 | created_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, |
| 82 | 111 | ).run( |
| @@ -84,11 +113,12 @@ export function recordLook(db: Database, input: RecordLookInput): Look { | ||
| 84 | 113 | input.appId, |
| 85 | 114 | input.env, |
| 86 | 115 | input.commitOid, |
| 116 | page, | |
| 87 | 117 | input.kind, |
| 88 | 118 | input.path, |
| 89 | 119 | input.error ?? null, |
| 90 | 120 | ); |
| 91 | return getLook(db, input.appId, input.env, input.commitOid, input.kind)!; | |
| 121 | return getLook(db, input.appId, input.env, input.commitOid, page, input.kind)!; | |
| 92 | 122 | } |
| 93 | 123 | |
| 94 | 124 | export function getLook( |
| @@ -96,14 +126,15 @@ export function getLook( | ||
| 96 | 126 | appId: string, |
| 97 | 127 | env: string, |
| 98 | 128 | commitOid: string, |
| 129 | page: string, | |
| 99 | 130 | kind: LookKind, |
| 100 | 131 | ): Look | undefined { |
| 101 | 132 | const row = db |
| 102 | 133 | .query< |
| 103 | 134 | Row, |
| 104 | [string, string, string, string] | |
| 105 | >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 AND commit_oid = ?3 AND kind = ?4`) | |
| 106 | .get(appId, env, commitOid, kind); | |
| 135 | [string, string, string, string, string] | |
| 136 | >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 AND commit_oid = ?3 AND page = ?4 AND kind = ?5`) | |
| 137 | .get(appId, env, commitOid, page, kind); | |
| 107 | 138 | return row ? toLook(row) : undefined; |
| 108 | 139 | } |
| 109 | 140 |
src/core/schema.ts +25
| @@ -792,4 +792,29 @@ export const migrations: readonly string[] = [ | ||
| 792 | 792 | ALTER TABLE pull ADD COLUMN reverts_issue_id TEXT REFERENCES issue(id) ON DELETE SET NULL; |
| 793 | 793 | CREATE INDEX pull_reverts ON pull(reverts_issue_id) WHERE reverts_issue_id IS NOT NULL; |
| 794 | 794 | `, |
| 795 | ||
| 796 | // 28 — a look knows which page it is of: `app.looks` in `forge.json` can | |
| 797 | // name more than the home page. `page` defaults to `/`, so every row | |
| 798 | // taken before this migration reads as exactly what it always was — a | |
| 799 | // picture of the home page. The unique constraint gains `page` so two | |
| 800 | // pages of one commit never collide; SQLite cannot alter a constraint in | |
| 801 | // place, so the table is rebuilt, as `look_new` was before it. | |
| 802 | ` | |
| 803 | CREATE TABLE look_new ( | |
| 804 | id TEXT PRIMARY KEY, | |
| 805 | app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE, | |
| 806 | env TEXT NOT NULL, | |
| 807 | commit_oid TEXT NOT NULL, | |
| 808 | page TEXT NOT NULL DEFAULT '/', | |
| 809 | kind TEXT NOT NULL CHECK (kind IN ('phone','desktop','thumb')), | |
| 810 | path TEXT NOT NULL, | |
| 811 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), | |
| 812 | error TEXT, | |
| 813 | UNIQUE (app_id, env, commit_oid, page, kind) | |
| 814 | ); | |
| 815 | INSERT INTO look_new (id, app_id, env, commit_oid, kind, path, created_at, error) | |
| 816 | SELECT id, app_id, env, commit_oid, kind, path, created_at, error FROM look; | |
| 817 | DROP TABLE look; | |
| 818 | ALTER TABLE look_new RENAME TO look; | |
| 819 | `, | |
| 795 | 820 | ]; |
src/looks/service.ts +18 −7
| @@ -32,8 +32,10 @@ import { getAppById, getAppByRepo, listApps } from "../core/apps.ts"; | ||
| 32 | 32 | import type { ForgeEvent } from "../core/events.ts"; |
| 33 | 33 | import { |
| 34 | 34 | deleteLooks, |
| 35 | DEFAULT_PAGE, | |
| 35 | 36 | LOOK_KINDS, |
| 36 | 37 | LOOK_SIZES, |
| 38 | lookSlot, | |
| 37 | 39 | looksForEnv, |
| 38 | 40 | recordLook, |
| 39 | 41 | type LookKind, |
| @@ -125,8 +127,10 @@ export const lookPath = ( | ||
| 125 | 127 | appId: string, |
| 126 | 128 | env: EnvName, |
| 127 | 129 | commitOid: string, |
| 130 | page: string, | |
| 128 | 131 | kind: LookKind, |
| 129 | ): string => join("looks", appId, env, `${commitOid}-${kind}.png`); | |
| 132 | ): string => | |
| 133 | join("looks", appId, env, `${commitOid}-${lookSlot(page, kind)}.png`); | |
| 130 | 134 | |
| 131 | 135 | interface Pending { |
| 132 | 136 | readonly appId: string; |
| @@ -270,12 +274,17 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 270 | 274 | const status = runtime.envStatus(record, item.env); |
| 271 | 275 | if (status?.state !== "running" || status.commitOid !== item.commitOid) |
| 272 | 276 | return; |
| 277 | const pages = status.looks ?? [DEFAULT_PAGE]; | |
| 273 | 278 | const have = new Set( |
| 274 | 279 | looksForEnv(app.db, item.appId, item.env) |
| 275 | 280 | .filter((l) => l.commitOid === item.commitOid && l.error === null) |
| 276 | .map((l) => l.kind), | |
| 281 | .map((l) => lookSlot(l.page, l.kind)), | |
| 282 | ); | |
| 283 | const wanted = pages.flatMap((page) => | |
| 284 | LOOK_KINDS.filter((kind) => !have.has(lookSlot(page, kind))).map( | |
| 285 | (kind) => ({ page, kind }), | |
| 286 | ), | |
| 277 | 287 | ); |
| 278 | const wanted = LOOK_KINDS.filter((kind) => !have.has(kind)); | |
| 279 | 288 | if (wanted.length === 0) return; |
| 280 | 289 | // The picture is what the owner sees: the app renders for a signed-in |
| 281 | 290 | // admin, exactly as when they open it from the forge. |
| @@ -286,7 +295,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 286 | 295 | : { id: owner.id, name: owner.username, role: "admin" }; |
| 287 | 296 | const host = runtime.hostFor(record.slug, item.env); |
| 288 | 297 | let allOk = true; |
| 289 | for (const kind of wanted) { | |
| 298 | for (const { page, kind } of wanted) { | |
| 290 | 299 | if (stopped) return; |
| 291 | 300 | const token = signGrant( |
| 292 | 301 | dataDir, |
| @@ -295,8 +304,8 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 295 | 304 | Date.now(), |
| 296 | 305 | who, |
| 297 | 306 | ); |
| 298 | const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent("/")}`; | |
| 299 | const path = lookPath(item.appId, item.env, item.commitOid, kind); | |
| 307 | const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`; | |
| 308 | const path = lookPath(item.appId, item.env, item.commitOid, page, kind); | |
| 300 | 309 | const out = join(dataDir, path); |
| 301 | 310 | // Taken to one side and moved in whole. A pull that closes while its |
| 302 | 311 | // picture is being taken sweeps its environment's folder away, and a |
| @@ -304,7 +313,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 304 | 313 | // give; this way the sweep and the picture never meet. |
| 305 | 314 | const staged = join( |
| 306 | 315 | stagingRoot(dataDir), |
| 307 | `${item.appId}-${item.env}-${item.commitOid}-${kind}.png`, | |
| 316 | `${item.appId}-${item.env}-${item.commitOid}-${lookSlot(page, kind)}.png`, | |
| 308 | 317 | ); |
| 309 | 318 | mkdirSync(dirname(staged), { recursive: true }); |
| 310 | 319 | const taken = await capture({ |
| @@ -317,6 +326,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 317 | 326 | appId: item.appId, |
| 318 | 327 | env: item.env, |
| 319 | 328 | commitOid: item.commitOid, |
| 329 | page, | |
| 320 | 330 | }; |
| 321 | 331 | if (taken.ok) { |
| 322 | 332 | // The environment moved on while the picture was taken: it shows |
| @@ -339,6 +349,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 339 | 349 | log.warn("no picture", { |
| 340 | 350 | app: record.slug, |
| 341 | 351 | env: item.env, |
| 352 | page, | |
| 342 | 353 | kind, |
| 343 | 354 | error: taken.error, |
| 344 | 355 | }); |
src/web/pages/board.ts +1 −1
| @@ -283,7 +283,7 @@ function landed(ctx: Ctx, scope: Scope): Html { | ||
| 283 | 283 | ); |
| 284 | 284 | return ( |
| 285 | 285 | look && |
| 286 | lookUrl(repoBase(repo), "production", look.commitOid, look.kind) | |
| 286 | lookUrl(repoBase(repo), "production", look.commitOid, look.page, look.kind) | |
| 287 | 287 | ); |
| 288 | 288 | }; |
| 289 | 289 | const rows = pulls.map((pull: Pull) => { |
src/web/pages/looks.ts +84 −49
| @@ -13,9 +13,9 @@ import { isEnvName, previewEnv, type EnvName } from "../../apps/data.ts"; | ||
| 13 | 13 | import type { AppRecord } from "../../core/apps.ts"; |
| 14 | 14 | import { getAppByRepo } from "../../core/apps.ts"; |
| 15 | 15 | import { |
| 16 | getLook, | |
| 17 | isLookKind, | |
| 16 | DEFAULT_PAGE, | |
| 18 | 17 | LOOK_SIZES, |
| 18 | lookSlot, | |
| 19 | 19 | looksForEnv, |
| 20 | 20 | type Look, |
| 21 | 21 | type LookKind, |
| @@ -28,13 +28,16 @@ import { loadRepo, repoBase } from "../chrome.ts"; | ||
| 28 | 28 | import { html, when, type Html } from "../html.ts"; |
| 29 | 29 | import { card, icon, sha, time } from "../ui.ts"; |
| 30 | 30 | |
| 31 | /** Where a picture is served. */ | |
| 31 | /** Where a picture is served. Named by page and kind, unless the page is | |
| 32 | * the home page, when it is the kind alone — exactly the URL from before | |
| 33 | * `app.looks` existed. */ | |
| 32 | 34 | export const lookUrl = ( |
| 33 | 35 | base: string, |
| 34 | 36 | env: EnvName, |
| 35 | 37 | commitOid: string, |
| 38 | page: string, | |
| 36 | 39 | kind: LookKind, |
| 37 | ): string => `${base}/looks/${env}/${commitOid}/${kind}.png`; | |
| 40 | ): string => `${base}/looks/${env}/${commitOid}/${lookSlot(page, kind)}.png`; | |
| 38 | 41 | |
| 39 | 42 | /** The newest picture at one width, when there is one. */ |
| 40 | 43 | const pictured = (looks: readonly Look[], kind: LookKind): Look | undefined => |
| @@ -43,26 +46,29 @@ const pictured = (looks: readonly Look[], kind: LookKind): Look | undefined => | ||
| 43 | 46 | /** |
| 44 | 47 | * The picture for a small slot: the dedicated thumb, or — for a commit |
| 45 | 48 | * taken before it existed — the phone shot a thumbnail loaded before. |
| 49 | * Always the home page: a slot this small has room for one picture of an | |
| 50 | * app, and the home page is the one everyone means by it. | |
| 46 | 51 | */ |
| 47 | export const thumbLook = (looks: readonly Look[]): Look | undefined => | |
| 48 | pictured(looks, "thumb") ?? pictured(looks, "phone"); | |
| 52 | export const thumbLook = (looks: readonly Look[]): Look | undefined => { | |
| 53 | const home = looks.filter((l) => l.page === DEFAULT_PAGE); | |
| 54 | return pictured(home, "thumb") ?? pictured(home, "phone"); | |
| 55 | }; | |
| 49 | 56 | |
| 50 | // GET /:owner/:name/looks/:env/:commit/:kind.png | |
| 57 | // GET /:owner/:name/looks/:env/:commit/:kind.png — the last segment is | |
| 58 | // really a slot (`lookSlot`): the kind alone for the home page, the page | |
| 59 | // and the kind for any other. | |
| 51 | 60 | export function lookImage(ctx: Ctx): Response { |
| 52 | 61 | const repo = loadRepo(ctx); |
| 53 | 62 | const env = ctx.params.env ?? ""; |
| 54 | 63 | const commit = ctx.params.commit ?? ""; |
| 55 | const kind = ctx.params.kind ?? ""; | |
| 56 | if ( | |
| 57 | !repo || | |
| 58 | !isEnvName(env) || | |
| 59 | !/^[0-9a-f]{40}$/.test(commit) || | |
| 60 | !isLookKind(kind) | |
| 61 | ) | |
| 64 | const slot = ctx.params.kind ?? ""; | |
| 65 | if (!repo || !isEnvName(env) || !/^[0-9a-f]{40}$/.test(commit) || slot === "") | |
| 62 | 66 | return notFound(); |
| 63 | 67 | const record = getAppByRepo(ctx.app.db, repo.id); |
| 64 | 68 | if (!record) return notFound(); |
| 65 | const look = getLook(ctx.app.db, record.id, env, commit, kind); | |
| 69 | const look = looksForEnv(ctx.app.db, record.id, env).find( | |
| 70 | (l) => l.commitOid === commit && lookSlot(l.page, l.kind) === slot, | |
| 71 | ); | |
| 66 | 72 | if (look === undefined || look.error !== null) return notFound(); |
| 67 | 73 | const path = join(ctx.app.config.dataDir, look.path); |
| 68 | 74 | if (!existsSync(path)) return notFound(); |
| @@ -78,7 +84,10 @@ export function lookImage(ctx: Ctx): Response { | ||
| 78 | 84 | /** |
| 79 | 85 | * The Look card on a pull: phone and desktop side by side, the commit |
| 80 | 86 | * they are of, and a word when the head has moved since — or, honestly, |
| 81 | * why there is no picture. Nothing once the preview is gone. | |
| 87 | * why there is no picture. More than one page in `app.looks` gets one | |
| 88 | * group per page, each labelled by its path, since each can be at a | |
| 89 | * different commit (a page that failed keeps its last good picture while | |
| 90 | * another moves on). Nothing once the preview is gone. | |
| 82 | 91 | */ |
| 83 | 92 | export function lookCard( |
| 84 | 93 | ctx: Ctx, |
| @@ -90,56 +99,80 @@ export function lookCard( | ||
| 90 | 99 | const base = repoBase(repo); |
| 91 | 100 | const env = previewEnv(pull.number); |
| 92 | 101 | const looks = looksForEnv(ctx.app.db, record.id, env); |
| 93 | const phone = pictured(looks, "phone"); | |
| 94 | const desktop = pictured(looks, "desktop"); | |
| 95 | const failed = looks.find((l) => l.commitOid === headOid && l.error !== null); | |
| 96 | 102 | const head = html`${icon("eye")} |
| 97 | 103 | <h2>Look</h2>`; |
| 98 | const shown = phone ?? desktop; | |
| 99 | if (shown === undefined) { | |
| 104 | if (looks.length === 0) { | |
| 100 | 105 | if (pull.state !== "open" || pull.mergedAt !== null) return html``; |
| 101 | 106 | return card( |
| 102 | 107 | html`<p class="muted small"> |
| 103 | ${failed === undefined | |
| 104 | ? "No picture yet. One is taken when the preview comes up green." | |
| 105 | : html`No picture of ${sha(headOid)}: ${failed.error}.`} | |
| 108 | No picture yet. One is taken when the preview comes up green. | |
| 106 | 109 | </p>`, |
| 107 | 110 | { head, extra: "look-card" }, |
| 108 | 111 | ); |
| 109 | 112 | } |
| 110 | const moved = shown.commitOid !== headOid; | |
| 111 | const picture = (look: Look, kind: LookKind): Html => { | |
| 112 | const url = lookUrl(base, env, look.commitOid, kind); | |
| 113 | // Home first, then the rest in the order the manifest is likeliest to | |
| 114 | // read them — alphabetically, since the row order says nothing about it. | |
| 115 | const pages = [...new Set(looks.map((l) => l.page))].sort((a, b) => | |
| 116 | a === DEFAULT_PAGE ? -1 : b === DEFAULT_PAGE ? 1 : a.localeCompare(b), | |
| 117 | ); | |
| 118 | const labelled = pages.length > 1; | |
| 119 | const picture = (look: Look, kind: LookKind, page: string): Html => { | |
| 120 | const url = lookUrl(base, env, look.commitOid, page, kind); | |
| 113 | 121 | return html`<a class="look look-${kind}" href="${url}"> |
| 114 | 122 | <img |
| 115 | 123 | src="${url}" |
| 116 | 124 | width="${LOOK_SIZES[kind].width}" |
| 117 | 125 | height="${LOOK_SIZES[kind].height}" |
| 118 | alt="${repo.name} #${pull.number} on a ${kind}" | |
| 126 | alt="${repo.name}${page === DEFAULT_PAGE | |
| 127 | ? "" | |
| 128 | : ` ${page}`} #${pull.number} on a ${kind}" | |
| 119 | 129 | loading="lazy" |
| 120 | 130 | /> |
| 121 | 131 | </a>`; |
| 122 | 132 | }; |
| 133 | const group = (page: string): Html => { | |
| 134 | const looksOfPage = looks.filter((l) => l.page === page); | |
| 135 | const phone = pictured(looksOfPage, "phone"); | |
| 136 | const desktop = pictured(looksOfPage, "desktop"); | |
| 137 | const failed = looksOfPage.find( | |
| 138 | (l) => l.commitOid === headOid && l.error !== null, | |
| 139 | ); | |
| 140 | const label = when(labelled, () => | |
| 141 | html`<p class="muted small look-page">${page}</p>`, | |
| 142 | ); | |
| 143 | const shown = phone ?? desktop; | |
| 144 | if (shown === undefined) | |
| 145 | return html`<div class="look-group"> | |
| 146 | ${label} | |
| 147 | <p class="muted small"> | |
| 148 | ${failed === undefined | |
| 149 | ? "No picture yet. One is taken when the preview comes up green." | |
| 150 | : html`No picture of ${sha(headOid)}: ${failed.error}.`} | |
| 151 | </p> | |
| 152 | </div>`; | |
| 153 | const moved = shown.commitOid !== headOid; | |
| 154 | return html`<div class="look-group"> | |
| 155 | ${label} | |
| 156 | <div class="look-pair"> | |
| 157 | ${when(phone, (l) => picture(l, "phone", page))} | |
| 158 | ${when(desktop, (l) => picture(l, "desktop", page))} | |
| 159 | </div> | |
| 160 | <span class="muted small look-of" | |
| 161 | >at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} · | |
| 162 | ${time(shown.createdAt)}${when( | |
| 163 | moved, | |
| 164 | () => | |
| 165 | html` · the head has moved | |
| 166 | since${failed === undefined | |
| 167 | ? html`; a new picture follows its deploy` | |
| 168 | : html`; no picture of ${sha(headOid)}: ${failed.error}`}`, | |
| 169 | )}</span | |
| 170 | > | |
| 171 | </div>`; | |
| 172 | }; | |
| 123 | 173 | return card( |
| 124 | html`<div class="look-pair"> | |
| 125 | ${when(phone, (l) => picture(l, "phone"))} | |
| 126 | ${when(desktop, (l) => picture(l, "desktop"))} | |
| 127 | </div>`, | |
| 128 | { | |
| 129 | head: html`${head} | |
| 130 | <span class="muted small look-of" | |
| 131 | >at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} · | |
| 132 | ${time(shown.createdAt)}${when( | |
| 133 | moved, | |
| 134 | () => | |
| 135 | html` · the head has moved | |
| 136 | since${failed === undefined | |
| 137 | ? html`; a new picture follows its deploy` | |
| 138 | : html`; no picture of ${sha(headOid)}: ${failed.error}`}`, | |
| 139 | )}</span | |
| 140 | >`, | |
| 141 | extra: "look-card", | |
| 142 | }, | |
| 174 | html`${pages.map(group)}`, | |
| 175 | { head, extra: "look-card" }, | |
| 143 | 176 | ); |
| 144 | 177 | } |
| 145 | 178 | |
| @@ -150,7 +183,9 @@ export function lookCard( | ||
| 150 | 183 | * not lazy: a person opening the app wants to see it at once. |
| 151 | 184 | */ |
| 152 | 185 | export function lookNow(ctx: Ctx, repo: Repo, record: AppRecord): Html { |
| 153 | const looks = looksForEnv(ctx.app.db, record.id, "production"); | |
| 186 | const looks = looksForEnv(ctx.app.db, record.id, "production").filter( | |
| 187 | (l) => l.page === DEFAULT_PAGE, | |
| 188 | ); | |
| 154 | 189 | const look = pictured(looks, "thumb") ?? pictured(looks, "desktop"); |
| 155 | 190 | if (look === undefined) return html``; |
| 156 | 191 | const base = repoBase(repo); |
| @@ -161,7 +196,7 @@ export function lookNow(ctx: Ctx, repo: Repo, record: AppRecord): Html { | ||
| 161 | 196 | title="How it looks now, at ${look.commitOid.slice(0, 7)}" |
| 162 | 197 | > |
| 163 | 198 | <img |
| 164 | src="${lookUrl(base, "production", look.commitOid, look.kind)}" | |
| 199 | src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, look.kind)}" | |
| 165 | 200 | width="${size.width}" |
| 166 | 201 | height="${size.height}" |
| 167 | 202 | alt="${repo.name} as it looks now" |
src/web/pages/site.ts +3 −2
| @@ -19,7 +19,7 @@ import { | ||
| 19 | 19 | listOpenAgentPullsVisibleTo, |
| 20 | 20 | type Issue, |
| 21 | 21 | } from "../../core/issues.ts"; |
| 22 | import { LOOK_SIZES, looksForApps } from "../../core/looks.ts"; | |
| 22 | import { DEFAULT_PAGE, LOOK_SIZES, looksForApps } from "../../core/looks.ts"; | |
| 23 | 23 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 24 | 24 | import { listPins, pinnedRepoIds } from "../../core/pins.ts"; |
| 25 | 25 | import { |
| @@ -557,6 +557,7 @@ function studioLanding(ctx: Ctx): Html { | ||
| 557 | 557 | (l) => |
| 558 | 558 | l.appId === record.id && |
| 559 | 559 | l.env === "production" && |
| 560 | l.page === DEFAULT_PAGE && | |
| 560 | 561 | l.kind === "desktop" && |
| 561 | 562 | l.error === null, |
| 562 | 563 | ); |
| @@ -565,7 +566,7 @@ function studioLanding(ctx: Ctx): Html { | ||
| 565 | 566 | ${look |
| 566 | 567 | ? html`<img |
| 567 | 568 | class="app-tile-look" |
| 568 | src="${lookUrl(base, "production", look.commitOid, "desktop")}" | |
| 569 | src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, "desktop")}" | |
| 569 | 570 | width="${LOOK_SIZES.desktop.width}" |
| 570 | 571 | height="${LOOK_SIZES.desktop.height}" |
| 571 | 572 | alt="" |
src/web/studio/asks.ts +3 −1
| @@ -239,7 +239,9 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] { | ||
| 239 | 239 | const look = thumbLook( |
| 240 | 240 | looks.filter((l) => l.appId === record.id && l.env === env), |
| 241 | 241 | ); |
| 242 | return look && lookUrl(repoBase(repo), env, look.commitOid, look.kind); | |
| 242 | return ( | |
| 243 | look && lookUrl(repoBase(repo), env, look.commitOid, look.page, look.kind) | |
| 244 | ); | |
| 243 | 245 | }; |
| 244 | 246 | return candidates.map((c): Decision => { |
| 245 | 247 | if (c.kind === "stopped" && c.pull) { |
test/apps-adversarial.test.ts +51
| @@ -303,6 +303,57 @@ describe("manifest: parseManifest edge cases", () => { | ||
| 303 | 303 | expect(result.ok).toBe(false); |
| 304 | 304 | if (!result.ok) expect(result.error.kind).toBe("not_an_app"); |
| 305 | 305 | }); |
| 306 | ||
| 307 | test("app.looks absent behaves exactly as today: the home page alone", () => { | |
| 308 | const result = parseManifest(JSON.stringify({ app: { run: "x" } }), undefined); | |
| 309 | expect(result.ok).toBe(true); | |
| 310 | if (result.ok) expect(result.value.looks).toEqual(["/"]); | |
| 311 | }); | |
| 312 | ||
| 313 | test("app.looks names up to three pages", () => { | |
| 314 | const result = parseManifest( | |
| 315 | JSON.stringify({ app: { run: "x", looks: ["/", "/board"] } }), | |
| 316 | undefined, | |
| 317 | ); | |
| 318 | expect(result.ok).toBe(true); | |
| 319 | if (result.ok) expect(result.value.looks).toEqual(["/", "/board"]); | |
| 320 | }); | |
| 321 | ||
| 322 | test("app.looks with more than three paths is refused", () => { | |
| 323 | const result = parseManifest( | |
| 324 | JSON.stringify({ | |
| 325 | app: { run: "x", looks: ["/", "/a", "/b", "/c"] }, | |
| 326 | }), | |
| 327 | undefined, | |
| 328 | ); | |
| 329 | expect(result.ok).toBe(false); | |
| 330 | if (!result.ok) expect(result.error.kind).toBe("invalid"); | |
| 331 | }); | |
| 332 | ||
| 333 | test("app.looks entries must be a path: no scheme, no host, no .., no control bytes", () => { | |
| 334 | for (const bad of [ | |
| 335 | "board", | |
| 336 | "//evil.example/board", | |
| 337 | "https://evil.example/board", | |
| 338 | "/../secret", | |
| 339 | "/board\u0000", | |
| 340 | "/board\n", | |
| 341 | ]) { | |
| 342 | const result = parseManifest( | |
| 343 | JSON.stringify({ app: { run: "x", looks: [bad] } }), | |
| 344 | undefined, | |
| 345 | ); | |
| 346 | expect(result.ok).toBe(false); | |
| 347 | } | |
| 348 | }); | |
| 349 | ||
| 350 | test("an over-long app.looks entry is refused", () => { | |
| 351 | const result = parseManifest( | |
| 352 | JSON.stringify({ app: { run: "x", looks: [`/${"a".repeat(300)}`] } }), | |
| 353 | undefined, | |
| 354 | ); | |
| 355 | expect(result.ok).toBe(false); | |
| 356 | }); | |
| 306 | 357 | }); |
| 307 | 358 | |
| 308 | 359 | describe("run log: redaction, truncation, and the listener seam", () => { |
test/looks.test.ts +75
| @@ -574,6 +574,81 @@ describe("the look", () => { | ||
| 574 | 574 | ); |
| 575 | 575 | }, 120_000); |
| 576 | 576 | |
| 577 | test("app.looks naming two pages pictures both, distinctly, and the pull page groups them", async () => { | |
| 578 | const branch = await commitToBranch(app, alice, repo, { | |
| 579 | branch: "two-pages", | |
| 580 | base: repo.defaultBranch, | |
| 581 | message: "Look at two pages", | |
| 582 | files: [ | |
| 583 | { | |
| 584 | path: "forge.json", | |
| 585 | content: `${JSON.stringify( | |
| 586 | { | |
| 587 | app: { | |
| 588 | run: "bun run src/server.ts", | |
| 589 | test: "bun test", | |
| 590 | tty: "bun run src/tty.ts", | |
| 591 | looks: ["/", "/board"], | |
| 592 | }, | |
| 593 | }, | |
| 594 | null, | |
| 595 | 2, | |
| 596 | )}\n`, | |
| 597 | }, | |
| 598 | ], | |
| 599 | }); | |
| 600 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 601 | const opened = await openPull(app, alice, repo, { | |
| 602 | title: "Look at two pages", | |
| 603 | baseBranch: repo.defaultBranch, | |
| 604 | headBranch: "two-pages", | |
| 605 | }); | |
| 606 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 607 | const env = `pull-${opened.value.number}`; | |
| 608 | const commitOid = branch.value.commitOid; | |
| 609 | expect((await settled((r) => r.env === env)).state).toBe("ok"); | |
| 610 | ||
| 611 | const rows = await until(() => { | |
| 612 | const found = looksForEnv(app.db, record.id, env).filter( | |
| 613 | (l) => l.commitOid === commitOid, | |
| 614 | ); | |
| 615 | return found.length === 6 ? found : undefined; | |
| 616 | }, 30_000); | |
| 617 | const home = rows.filter((l) => l.page === "/"); | |
| 618 | const board = rows.filter((l) => l.page === "/board"); | |
| 619 | expect(home).toHaveLength(3); | |
| 620 | expect(board).toHaveLength(3); | |
| 621 | // Two pages of one commit never collide on disk. | |
| 622 | expect(new Set(rows.map((l) => l.path)).size).toBe(6); | |
| 623 | const boardPhone = board.find((l) => l.kind === "phone")!; | |
| 624 | expect(boardPhone.path).toBe( | |
| 625 | `looks/${record.id}/${env}/${commitOid}-board-phone.png`, | |
| 626 | ); | |
| 627 | const homePhone = home.find((l) => l.kind === "phone")!; | |
| 628 | expect(homePhone.path).toBe( | |
| 629 | `looks/${record.id}/${env}/${commitOid}-phone.png`, | |
| 630 | ); | |
| 631 | // The browser was pointed at each page in turn. | |
| 632 | const boardCall = calls.find( | |
| 633 | (c) => new URL(c.url).searchParams.get("to") === "/board", | |
| 634 | ); | |
| 635 | expect(boardCall).toBeDefined(); | |
| 636 | ||
| 637 | const page = await ( | |
| 638 | await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie) | |
| 639 | ).text(); | |
| 640 | expect(page).toContain(`<p class="muted small look-page">/</p>`); | |
| 641 | expect(page).toContain(`<p class="muted small look-page">/board</p>`); | |
| 642 | expect(page).toContain( | |
| 643 | `src="/alice/hello/looks/${env}/${commitOid}/board-phone.png"`, | |
| 644 | ); | |
| 645 | expect(page).toContain( | |
| 646 | `src="/alice/hello/looks/${env}/${commitOid}/phone.png"`, | |
| 647 | ); | |
| 648 | expect(page).toContain(`alt="hello /board #${opened.value.number} on a phone"`); | |
| 649 | expect(page).toContain(`alt="hello #${opened.value.number} on a phone"`); | |
| 650 | }, 120_000); | |
| 651 | ||
| 577 | 652 | test("memory pressure is what the container truly holds, never its page cache", async () => { |
| 578 | 653 | const { cgroupMemoryPressure, memoryPressure } = |
| 579 | 654 | await import("../src/looks/service.ts"); |
new above
offline