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

Merged opened this

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

Merged

scout/forge:scout/264-every-look-is-a-picture-of-the-app-s-hom → main 1 commit fork

  • Merged. scout/264-every-look-is-a-picture-of-the-app-s-hom was merged into main as 7845c46.

Review

Sign in to review this change.
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:
153153 "test": "bun test",
154154 "health": "/",
155155 "ready": 30,
156 "tty": "bun run src/tty.ts"
156 "tty": "bun run src/tty.ts",
157 "looks": ["/", "/board"]
157158 }
158159 }
159160 ```
@@ -162,9 +163,10 @@ Only `run` is required, and even it defaults: a `package.json` with a `start`
162163 script is enough. `install` defaults to `bun install` when a `package.json`
163164 exists and to nothing otherwise; `test` defaults to nothing; `health` is the
164165 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.
168170
169171 The forge starts the app with:
170172
@@ -453,17 +455,23 @@ older ones are history, not status.
453455
454456 ### The look
455457
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
467475 repository's own read access, cached by commit.
468476
469477 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 {
2929 * environment's own variables. `null` means the app has none.
3030 */
3131 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[];
3238 }
3339
3440 export type ManifestError =
@@ -40,6 +46,10 @@ export type ManifestError =
4046 const READY_DEFAULT = 30;
4147 const READY_MAX = 600;
4248 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;
4353
4454 /**
4555 * Resolve a manifest from the two files that can declare one. `forgeJson`
@@ -64,6 +74,7 @@ export function parseManifest(
6474 health: "/",
6575 ready: READY_DEFAULT,
6676 tty: null,
77 looks: ["/"],
6778 });
6879 }
6980
@@ -104,6 +115,8 @@ export function parseManifest(
104115 return invalid(
105116 `forge.json: app.ready must be a whole number of seconds, 1–${READY_MAX}`,
106117 );
118 const looks = looksField(app);
119 if (!looks.ok) return looks;
107120
108121 return ok({
109122 run: runCommand,
@@ -119,9 +132,48 @@ export function parseManifest(
119132 health: health ?? "/",
120133 ready: ready ?? READY_DEFAULT,
121134 tty: tty.value || null,
135 looks: looks.value,
122136 });
123137 }
124138
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
125177 /**
126178 * How a repository checks a change: `check` in `forge.json`, beside or
127179 * 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 {
116116 * that has not started has not said either way.
117117 */
118118 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;
119125 /**
120126 * A deploy newer than the one serving that never came up — set only
121127 * while an older deployment keeps answering in its place. A later
@@ -311,6 +317,7 @@ export class AppsRuntime {
311317 servingAt: state.live.servingAt,
312318 createdAt: state.live.createdAt,
313319 tty: state.live.manifest.tty,
320 looks: state.live.manifest.looks,
314321 ...(state.live.deployStartedAt === null
315322 ? {}
316323 : { deployStartedAt: state.live.deployStartedAt }),
@@ -346,6 +353,7 @@ export class AppsRuntime {
346353 servingAt: state.live.servingAt,
347354 createdAt: state.live.createdAt,
348355 tty: state.live.manifest.tty,
356 looks: state.live.manifest.looks,
349357 ...(state.live.deployStartedAt === null
350358 ? {}
351359 : { deployStartedAt: state.live.deployStartedAt }),
src/core/looks.ts +38 −7
@@ -27,11 +27,36 @@ export const LOOK_SIZES: Readonly<
2727 thumb: { width: 200, height: 125 },
2828 };
2929
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
3053 export interface Look {
3154 readonly id: string;
3255 readonly appId: string;
3356 readonly env: string;
3457 readonly commitOid: string;
58 /** The page pictured — `forge.json`'s `app.looks`, `/` when unset. */
59 readonly page: string;
3560 readonly kind: LookKind;
3661 /** Where the picture is, relative to the data directory; empty on an error. */
3762 readonly path: string;
@@ -45,6 +70,7 @@ interface Row {
4570 app_id: string;
4671 env: string;
4772 commit_oid: string;
73 page: string;
4874 kind: LookKind;
4975 path: string;
5076 created_at: string;
@@ -56,6 +82,7 @@ const toLook = (r: Row): Look => ({
5682 appId: r.app_id,
5783 env: r.env,
5884 commitOid: r.commit_oid,
85 page: r.page,
5986 kind: r.kind,
6087 path: r.path,
6188 createdAt: r.created_at,
@@ -66,6 +93,7 @@ export interface RecordLookInput {
6693 readonly appId: string;
6794 readonly env: string;
6895 readonly commitOid: string;
96 readonly page?: string | undefined;
6997 readonly kind: LookKind;
7098 readonly path: string;
7199 readonly error?: string | undefined;
@@ -73,10 +101,11 @@ export interface RecordLookInput {
73101
74102 /** Record a picture, or the reason there is none. A retake replaces. */
75103 export function recordLook(db: Database, input: RecordLookInput): Look {
104 const page = input.page ?? DEFAULT_PAGE;
76105 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
80109 path = excluded.path, error = excluded.error,
81110 created_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
82111 ).run(
@@ -84,11 +113,12 @@ export function recordLook(db: Database, input: RecordLookInput): Look {
84113 input.appId,
85114 input.env,
86115 input.commitOid,
116 page,
87117 input.kind,
88118 input.path,
89119 input.error ?? null,
90120 );
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)!;
92122 }
93123
94124 export function getLook(
@@ -96,14 +126,15 @@ export function getLook(
96126 appId: string,
97127 env: string,
98128 commitOid: string,
129 page: string,
99130 kind: LookKind,
100131 ): Look | undefined {
101132 const row = db
102133 .query<
103134 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);
107138 return row ? toLook(row) : undefined;
108139 }
109140
src/core/schema.ts +25
@@ -792,4 +792,29 @@ export const migrations: readonly string[] = [
792792 ALTER TABLE pull ADD COLUMN reverts_issue_id TEXT REFERENCES issue(id) ON DELETE SET NULL;
793793 CREATE INDEX pull_reverts ON pull(reverts_issue_id) WHERE reverts_issue_id IS NOT NULL;
794794 `,
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 `,
795820 ];
src/looks/service.ts +18 −7
@@ -32,8 +32,10 @@ import { getAppById, getAppByRepo, listApps } from "../core/apps.ts";
3232 import type { ForgeEvent } from "../core/events.ts";
3333 import {
3434 deleteLooks,
35 DEFAULT_PAGE,
3536 LOOK_KINDS,
3637 LOOK_SIZES,
38 lookSlot,
3739 looksForEnv,
3840 recordLook,
3941 type LookKind,
@@ -125,8 +127,10 @@ export const lookPath = (
125127 appId: string,
126128 env: EnvName,
127129 commitOid: string,
130 page: string,
128131 kind: LookKind,
129 ): string => join("looks", appId, env, `${commitOid}-${kind}.png`);
132 ): string =>
133 join("looks", appId, env, `${commitOid}-${lookSlot(page, kind)}.png`);
130134
131135 interface Pending {
132136 readonly appId: string;
@@ -270,12 +274,17 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
270274 const status = runtime.envStatus(record, item.env);
271275 if (status?.state !== "running" || status.commitOid !== item.commitOid)
272276 return;
277 const pages = status.looks ?? [DEFAULT_PAGE];
273278 const have = new Set(
274279 looksForEnv(app.db, item.appId, item.env)
275280 .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 ),
277287 );
278 const wanted = LOOK_KINDS.filter((kind) => !have.has(kind));
279288 if (wanted.length === 0) return;
280289 // The picture is what the owner sees: the app renders for a signed-in
281290 // admin, exactly as when they open it from the forge.
@@ -286,7 +295,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
286295 : { id: owner.id, name: owner.username, role: "admin" };
287296 const host = runtime.hostFor(record.slug, item.env);
288297 let allOk = true;
289 for (const kind of wanted) {
298 for (const { page, kind } of wanted) {
290299 if (stopped) return;
291300 const token = signGrant(
292301 dataDir,
@@ -295,8 +304,8 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
295304 Date.now(),
296305 who,
297306 );
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);
300309 const out = join(dataDir, path);
301310 // Taken to one side and moved in whole. A pull that closes while its
302311 // picture is being taken sweeps its environment's folder away, and a
@@ -304,7 +313,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
304313 // give; this way the sweep and the picture never meet.
305314 const staged = join(
306315 stagingRoot(dataDir),
307 `${item.appId}-${item.env}-${item.commitOid}-${kind}.png`,
316 `${item.appId}-${item.env}-${item.commitOid}-${lookSlot(page, kind)}.png`,
308317 );
309318 mkdirSync(dirname(staged), { recursive: true });
310319 const taken = await capture({
@@ -317,6 +326,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
317326 appId: item.appId,
318327 env: item.env,
319328 commitOid: item.commitOid,
329 page,
320330 };
321331 if (taken.ok) {
322332 // The environment moved on while the picture was taken: it shows
@@ -339,6 +349,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
339349 log.warn("no picture", {
340350 app: record.slug,
341351 env: item.env,
352 page,
342353 kind,
343354 error: taken.error,
344355 });
src/web/pages/board.ts +1 −1
@@ -283,7 +283,7 @@ function landed(ctx: Ctx, scope: Scope): Html {
283283 );
284284 return (
285285 look &&
286 lookUrl(repoBase(repo), "production", look.commitOid, look.kind)
286 lookUrl(repoBase(repo), "production", look.commitOid, look.page, look.kind)
287287 );
288288 };
289289 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";
1313 import type { AppRecord } from "../../core/apps.ts";
1414 import { getAppByRepo } from "../../core/apps.ts";
1515 import {
16 getLook,
17 isLookKind,
16 DEFAULT_PAGE,
1817 LOOK_SIZES,
18 lookSlot,
1919 looksForEnv,
2020 type Look,
2121 type LookKind,
@@ -28,13 +28,16 @@ import { loadRepo, repoBase } from "../chrome.ts";
2828 import { html, when, type Html } from "../html.ts";
2929 import { card, icon, sha, time } from "../ui.ts";
3030
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. */
3234 export const lookUrl = (
3335 base: string,
3436 env: EnvName,
3537 commitOid: string,
38 page: string,
3639 kind: LookKind,
37 ): string => `${base}/looks/${env}/${commitOid}/${kind}.png`;
40 ): string => `${base}/looks/${env}/${commitOid}/${lookSlot(page, kind)}.png`;
3841
3942 /** The newest picture at one width, when there is one. */
4043 const pictured = (looks: readonly Look[], kind: LookKind): Look | undefined =>
@@ -43,26 +46,29 @@ const pictured = (looks: readonly Look[], kind: LookKind): Look | undefined =>
4346 /**
4447 * The picture for a small slot: the dedicated thumb, or — for a commit
4548 * 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.
4651 */
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 };
4956
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.
5160 export function lookImage(ctx: Ctx): Response {
5261 const repo = loadRepo(ctx);
5362 const env = ctx.params.env ?? "";
5463 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 === "")
6266 return notFound();
6367 const record = getAppByRepo(ctx.app.db, repo.id);
6468 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 );
6672 if (look === undefined || look.error !== null) return notFound();
6773 const path = join(ctx.app.config.dataDir, look.path);
6874 if (!existsSync(path)) return notFound();
@@ -78,7 +84,10 @@ export function lookImage(ctx: Ctx): Response {
7884 /**
7985 * The Look card on a pull: phone and desktop side by side, the commit
8086 * 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.
8291 */
8392 export function lookCard(
8493 ctx: Ctx,
@@ -90,56 +99,80 @@ export function lookCard(
9099 const base = repoBase(repo);
91100 const env = previewEnv(pull.number);
92101 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);
96102 const head = html`${icon("eye")}
97103 <h2>Look</h2>`;
98 const shown = phone ?? desktop;
99 if (shown === undefined) {
104 if (looks.length === 0) {
100105 if (pull.state !== "open" || pull.mergedAt !== null) return html``;
101106 return card(
102107 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.
106109 </p>`,
107110 { head, extra: "look-card" },
108111 );
109112 }
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);
113121 return html`<a class="look look-${kind}" href="${url}">
114122 <img
115123 src="${url}"
116124 width="${LOOK_SIZES[kind].width}"
117125 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}"
119129 loading="lazy"
120130 />
121131 </a>`;
122132 };
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 };
123173 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" },
143176 );
144177 }
145178
@@ -150,7 +183,9 @@ export function lookCard(
150183 * not lazy: a person opening the app wants to see it at once.
151184 */
152185 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 );
154189 const look = pictured(looks, "thumb") ?? pictured(looks, "desktop");
155190 if (look === undefined) return html``;
156191 const base = repoBase(repo);
@@ -161,7 +196,7 @@ export function lookNow(ctx: Ctx, repo: Repo, record: AppRecord): Html {
161196 title="How it looks now, at ${look.commitOid.slice(0, 7)}"
162197 >
163198 <img
164 src="${lookUrl(base, "production", look.commitOid, look.kind)}"
199 src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, look.kind)}"
165200 width="${size.width}"
166201 height="${size.height}"
167202 alt="${repo.name} as it looks now"
src/web/pages/site.ts +3 −2
@@ -19,7 +19,7 @@ import {
1919 listOpenAgentPullsVisibleTo,
2020 type Issue,
2121 } from "../../core/issues.ts";
22 import { LOOK_SIZES, looksForApps } from "../../core/looks.ts";
22 import { DEFAULT_PAGE, LOOK_SIZES, looksForApps } from "../../core/looks.ts";
2323 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
2424 import { listPins, pinnedRepoIds } from "../../core/pins.ts";
2525 import {
@@ -557,6 +557,7 @@ function studioLanding(ctx: Ctx): Html {
557557 (l) =>
558558 l.appId === record.id &&
559559 l.env === "production" &&
560 l.page === DEFAULT_PAGE &&
560561 l.kind === "desktop" &&
561562 l.error === null,
562563 );
@@ -565,7 +566,7 @@ function studioLanding(ctx: Ctx): Html {
565566 ${look
566567 ? html`<img
567568 class="app-tile-look"
568 src="${lookUrl(base, "production", look.commitOid, "desktop")}"
569 src="${lookUrl(base, "production", look.commitOid, DEFAULT_PAGE, "desktop")}"
569570 width="${LOOK_SIZES.desktop.width}"
570571 height="${LOOK_SIZES.desktop.height}"
571572 alt=""
src/web/studio/asks.ts +3 −1
@@ -239,7 +239,9 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
239239 const look = thumbLook(
240240 looks.filter((l) => l.appId === record.id && l.env === env),
241241 );
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 );
243245 };
244246 return candidates.map((c): Decision => {
245247 if (c.kind === "stopped" && c.pull) {
test/apps-adversarial.test.ts +51
@@ -303,6 +303,57 @@ describe("manifest: parseManifest edge cases", () => {
303303 expect(result.ok).toBe(false);
304304 if (!result.ok) expect(result.error.kind).toBe("not_an_app");
305305 });
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 });
306357 });
307358
308359 describe("run log: redaction, truncation, and the listener seam", () => {
test/looks.test.ts +75
@@ -574,6 +574,81 @@ describe("the look", () => {
574574 );
575575 }, 120_000);
576576
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
577652 test("memory pressure is what the container truly holds, never its page cache", async () => {
578653 const { cgroupMemoryPressure, memoryPressure } =
579654 await import("../src/looks/service.ts");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs