Public: anyone can read this; the crew can change it.

A thirty-pixel thumbnail loads a full-size screenshot #173

Merged opened this

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

Merged

scout/forge:scout/159-a-thirty-pixel-thumbnail-loads-a-full-si → main 1 commit fork

  • Merged. scout/159-a-thirty-pixel-thumbnail-loads-a-full-si was merged into main as 5ddd45f.

Review

Sign in to review this change.
9 files changed +143 −58
docs/APPS.md +10 −7
@@ -449,13 +449,16 @@ older ones are history, not status.
449449 ### The look
450450
451451 When a deploy comes up green the forge takes a picture of it — the page at
452 `/`, at a phone's width (390×844) and a laptop's (1280×800) — and keeps the
453 pictures beside the commit they are of, so a person judges a change from
454 what it is rather than from what it says. The pull page shows both under
455 the checks, with the commit and a word when the head has moved since; the
456 studio's ask row leads with the phone picture; an app's studio head shows
457 how production looks now. Pictures are served at
458 `/:owner/:name/looks/<env>/<commit>/{phone,desktop}.png` with the
452 `/`, at a phone's width (390×844), a laptop's (1280×800), and a thumb's
453 (200×125, desktop's own aspect, small) — and keeps the pictures beside the
454 commit they are of, so a person judges a change from what it is rather than
455 from what it says. The pull page shows the phone and desktop pair under the
456 checks, with the commit and a word when the head has moved since; the
457 studio's ask row and the board's landed row lead with the thumb; an app's
458 studio head shows how production looks now, with the thumb too. A thumb
459 missing on a commit pictured before it existed falls back to the phone shot
460 that slot used to load, so nothing goes blank. Pictures are served at
461 `/:owner/:name/looks/<env>/<commit>/{phone,desktop,thumb}.png` with the
459462 repository's own read access, cached by commit.
460463
461464 The picture is what the repository's owner would see: headless Chromium is
src/core/looks.ts +9 −4
@@ -8,18 +8,23 @@
88 import type { Database } from "bun:sqlite";
99 import { id as newId } from "../kernel/id.ts";
1010
11 /** The two widths a page is pictured at. */
12 export type LookKind = "phone" | "desktop";
13 export const LOOK_KINDS: readonly LookKind[] = ["phone", "desktop"];
11 /** The three widths a page is pictured at. */
12 export type LookKind = "phone" | "desktop" | "thumb";
13 export const LOOK_KINDS: readonly LookKind[] = ["phone", "desktop", "thumb"];
1414 export const isLookKind = (value: string): value is LookKind =>
1515 (LOOK_KINDS as readonly string[]).includes(value);
1616
17 /** The two widths, as a person holds them: a phone, and a laptop's window. */
17 /**
18 * The three widths, as a person holds them: a phone, a laptop's window, and
19 * a thumbnail — taken at the size a slot actually shows, at desktop's own
20 * aspect, rather than a full picture squeezed into a small box.
21 */
1822 export const LOOK_SIZES: Readonly<
1923 Record<LookKind, { readonly width: number; readonly height: number }>
2024 > = {
2125 phone: { width: 390, height: 844 },
2226 desktop: { width: 1280, height: 800 },
27 thumb: { width: 200, height: 125 },
2328 };
2429
2530 export interface Look {
src/core/schema.ts +21
@@ -762,4 +762,25 @@ export const migrations: readonly string[] = [
762762 `
763763 ALTER TABLE run ADD COLUMN check_detail TEXT NOT NULL DEFAULT '';
764764 `,
765
766 // 26 — a third width: thumb, a picture taken small for a slot that only
767 // ever showed one small, instead of a full phone or desktop picture
768 // squeezed into it. SQLite's CHECK cannot be altered in place, so the
769 // table is rebuilt; every row already there keeps its id and content.
770 `
771 CREATE TABLE look_new (
772 id TEXT PRIMARY KEY,
773 app_id TEXT NOT NULL REFERENCES app(id) ON DELETE CASCADE,
774 env TEXT NOT NULL,
775 commit_oid TEXT NOT NULL,
776 kind TEXT NOT NULL CHECK (kind IN ('phone','desktop','thumb')),
777 path TEXT NOT NULL,
778 created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
779 error TEXT,
780 UNIQUE (app_id, env, commit_oid, kind)
781 );
782 INSERT INTO look_new SELECT * FROM look;
783 DROP TABLE look;
784 ALTER TABLE look_new RENAME TO look;
785 `,
765786 ];
src/crew/runner.ts +6 −1
@@ -135,7 +135,12 @@ async function reviewInputs(
135135 record === undefined
136136 ? []
137137 : looksForEnv(app.db, record.id, `pull-${pull.number}`)
138 .filter((l) => l.commitOid === pull.headOid && l.error === null)
138 .filter(
139 (l) =>
140 l.commitOid === pull.headOid &&
141 l.error === null &&
142 (l.kind === "phone" || l.kind === "desktop"),
143 )
139144 .sort(
140145 (a, b) =>
141146 (a.kind === "phone" ? -1 : 1) - (b.kind === "phone" ? -1 : 1),
src/web/pages/board.ts +5 −8
@@ -30,7 +30,7 @@ import { boardJs, liveRegionsJs } from "../client.ts";
3030 import { html, when, type Html } from "../html.ts";
3131 import { page } from "../layout.ts";
3232 import { avatar, icon, time } from "../ui.ts";
33 import { lookUrl } from "./looks.ts";
33 import { lookUrl, thumbLook } from "./looks.ts";
3434
3535 // ---- words ----
3636
@@ -273,15 +273,12 @@ function landed(ctx: Ctx, scope: Scope): Html {
273273 const pictureOf = (repo: Repo): string | undefined => {
274274 const record = scope.records.get(repo.id);
275275 if (record === undefined) return undefined;
276 const look = looks.find(
277 (l) =>
278 l.appId === record.id &&
279 l.env === "production" &&
280 l.kind === "phone" &&
281 l.error === null,
276 const look = thumbLook(
277 looks.filter((l) => l.appId === record.id && l.env === "production"),
282278 );
283279 return (
284 look && lookUrl(repoBase(repo), "production", look.commitOid, "phone")
280 look &&
281 lookUrl(repoBase(repo), "production", look.commitOid, look.kind)
285282 );
286283 };
287284 const rows = pulls.map((pull: Pull) => {
src/web/pages/looks.ts +20 −9
@@ -40,6 +40,13 @@ export const lookUrl = (
4040 const pictured = (looks: readonly Look[], kind: LookKind): Look | undefined =>
4141 looks.find((l) => l.kind === kind && l.error === null);
4242
43 /**
44 * The picture for a small slot: the dedicated thumb, or — for a commit
45 * taken before it existed — the phone shot a thumbnail loaded before.
46 */
47 export const thumbLook = (looks: readonly Look[]): Look | undefined =>
48 pictured(looks, "thumb") ?? pictured(looks, "phone");
49
4350 // GET /:owner/:name/looks/:env/:commit/:kind.png
4451 export function lookImage(ctx: Ctx): Response {
4552 const repo = loadRepo(ctx);
@@ -136,29 +143,33 @@ export function lookCard(
136143 );
137144 }
138145
139 /** How production looks now, small, for the app's studio head. */
146 /**
147 * How production looks now, small, for the app's studio head — the
148 * dedicated thumb, or a commit from before it existed still shows what it
149 * always showed. It is the first picture on the app's own page, so it is
150 * not lazy: a person opening the app wants to see it at once.
151 */
140152 export function lookNow(ctx: Ctx, repo: Repo, record: AppRecord): Html {
141 const look = pictured(
142 looksForEnv(ctx.app.db, record.id, "production"),
143 "desktop",
144 );
153 const looks = looksForEnv(ctx.app.db, record.id, "production");
154 const look = pictured(looks, "thumb") ?? pictured(looks, "desktop");
145155 if (look === undefined) return html``;
146156 const base = repoBase(repo);
157 const size = LOOK_SIZES[look.kind];
147158 return html`<a
148159 class="look-now"
149160 href="${base}/app/production/open"
150161 title="How it looks now, at ${look.commitOid.slice(0, 7)}"
151162 >
152163 <img
153 src="${lookUrl(base, "production", look.commitOid, "desktop")}"
154 width="${LOOK_SIZES.desktop.width}"
155 height="${LOOK_SIZES.desktop.height}"
164 src="${lookUrl(base, "production", look.commitOid, look.kind)}"
165 width="${size.width}"
166 height="${size.height}"
156167 alt="${repo.name} as it looks now"
157168 />
158169 </a>`;
159170 }
160171
161 /** A phone picture as a thumbnail that opens the pull. */
172 /** A small picture as a thumbnail that opens the pull. */
162173 export function lookThumb(href: string, src: string): Html {
163174 return html`<a class="look-thumb" href="${href}"
164175 ><img src="${src}" width="30" height="40" alt="" loading="lazy"
src/web/studio/asks.ts +4 −8
@@ -32,7 +32,7 @@ import type { Ctx } from "../../http/context.ts";
3232 import { repoBase } from "../chrome.ts";
3333 import { html, when, type Html } from "../html.ts";
3434 import { btn, card, icon, row } from "../ui.ts";
35 import { lookThumb, lookUrl } from "../pages/looks.ts";
35 import { lookThumb, lookUrl, thumbLook } from "../pages/looks.ts";
3636 import { leanNote, leanVariant } from "./leans.ts";
3737
3838 /** The apps a person can see, as repositories, with their records — the
@@ -203,14 +203,10 @@ export function decisionsFor(ctx: Ctx, allRepos: readonly Repo[]): Decision[] {
203203 const record = records.get(repo.id);
204204 if (record === undefined) return undefined;
205205 const env = previewEnv(pull.number);
206 const look = looks.find(
207 (l) =>
208 l.appId === record.id &&
209 l.env === env &&
210 l.kind === "phone" &&
211 l.error === null,
206 const look = thumbLook(
207 looks.filter((l) => l.appId === record.id && l.env === env),
212208 );
213 return look && lookUrl(repoBase(repo), env, look.commitOid, "phone");
209 return look && lookUrl(repoBase(repo), env, look.commitOid, look.kind);
214210 };
215211 return candidates.map((c): Decision => {
216212 if (c.kind === "stopped" && c.pull) {
test/board.test.ts +15
@@ -13,7 +13,9 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1313 import { openIssue } from "../src/actions/issues.ts";
1414 import { mergePull, openPull } from "../src/actions/pulls.ts";
1515 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { getAppByRepo } from "../src/core/apps.ts";
1617 import { recordDecision } from "../src/core/decisions.ts";
18 import { recordLook } from "../src/core/looks.ts";
1719 import { getPull } from "../src/core/pulls.ts";
1820 import { updateRepoSettings, type Repo } from "../src/core/repos.ts";
1921 import { claimRun, createRun } from "../src/core/runs.ts";
@@ -186,6 +188,16 @@ describe("the board", () => {
186188 const merged = await mergePull(app, alice, notes, opened.value, "squash");
187189 if (!merged.ok) throw new Error(JSON.stringify(merged.error));
188190 const pull = getPull(app.db, notes.id, opened.value.number)!;
191 // A production thumb of the app that just landed: the board's landed
192 // row leads with it, not with the full phone shot.
193 const notesApp = getAppByRepo(app.db, notes.id)!;
194 recordLook(app.db, {
195 appId: notesApp.id,
196 env: "production",
197 commitOid: pull.headOid,
198 kind: "thumb",
199 path: "looks/does-not-matter.png",
200 });
189201 recordDecision(app.db, {
190202 kind: "land",
191203 repoId: notes.id,
@@ -230,6 +242,9 @@ describe("the board", () => {
230242 expect(landed).toContain("Faster notes");
231243 expect(landed).toContain("notes · scout");
232244 expect(landed).toContain(`/alice/notes/pulls/${pull.number}`);
245 expect(landed).toContain(
246 `src="/alice/notes/looks/production/${pull.headOid}/thumb.png"`,
247 );
233248 // Decided: kind in words, the answer, how sure, the pick's issue.
234249 const decided = html.slice(
235250 html.indexOf('id="decided"'),
test/looks.test.ts +53 −21
@@ -1,8 +1,8 @@
11 /**
2 * The look (docs/APPS.md): a green deploy is pictured at phone and desktop
3 * width, once per head, as the owner would see it; the pictures are served
4 * with the repository's own read access and shown on the pull, on the
5 * studio's ask row and in the app's head; a picture that could not be
2 * The look (docs/APPS.md): a green deploy is pictured at phone, desktop
3 * and thumb width, once per head, as the owner would see it; the pictures
4 * are served with the repository's own read access and shown on the pull,
5 * on the studio's ask row and in the app's head; a picture that could not be
66 * taken is recorded as such and the page says so; a closed pull's pictures
77 * go with its preview. A stand-in capturer drives all of that; a real
88 * Chromium, when this machine has one, proves the browser end.
@@ -121,13 +121,13 @@ describe("the look", () => {
121121 : undefined;
122122 });
123123
124 /** Both looks of a commit, once they are recorded — pictured or not. */
124 /** All three looks of a commit, once they are recorded — pictured or not. */
125125 const pictured = (env: string, commitOid: string): Promise<Look[]> =>
126126 until(() => {
127127 const rows = looksForEnv(app.db, record.id, env).filter(
128128 (l) => l.commitOid === commitOid,
129129 );
130 return rows.length === 2 ? rows : undefined;
130 return rows.length === 3 ? rows : undefined;
131131 }, 30_000);
132132
133133 beforeAll(async () => {
@@ -183,11 +183,15 @@ describe("the look", () => {
183183 let first = "";
184184 let second = "";
185185
186 test("a green deploy is pictured at both widths, as the owner, once per head; a newer head replaces", async () => {
186 test("a green deploy is pictured at all three widths, as the owner, once per head; a newer head replaces", async () => {
187187 const production = await settled((r) => r.env === "production");
188188 expect(production.state).toBe("ok");
189189 const rows = await pictured("production", production.commitOid);
190 expect(rows.map((l) => l.kind).sort()).toEqual(["desktop", "phone"]);
190 expect(rows.map((l) => l.kind).sort()).toEqual([
191 "desktop",
192 "phone",
193 "thumb",
194 ]);
191195 for (const look of rows) {
192196 expect(look.error).toBeNull();
193197 expect(look.path).toBe(
@@ -197,7 +201,7 @@ describe("the look", () => {
197201 }
198202 // The browser was pointed at the grant door on the forge's own port,
199203 // and the grant names the owner as an admin.
200 expect(calls).toHaveLength(2);
204 expect(calls).toHaveLength(3);
201205 // Taken to one side and moved in whole: nothing half-written lives
202206 // under the environment, and nothing is left behind on the side.
203207 for (const call of calls)
@@ -206,6 +210,7 @@ describe("the look", () => {
206210 const phone = calls.find((c) => c.width === 390)!;
207211 expect(phone.height).toBe(844);
208212 expect(calls.find((c) => c.width === 1280)?.height).toBe(800);
213 expect(calls.find((c) => c.width === 200)?.height).toBe(125);
209214 const url = new URL(phone.url);
210215 expect(url.host).toBe(`hello.${DOMAIN}:${servers.port}`);
211216 expect(url.pathname).toBe("/__forge/grant");
@@ -234,9 +239,9 @@ describe("the look", () => {
234239 pull = opened.value;
235240 const env = previewEnv(pull.number);
236241 expect((await settled((r) => r.env === env)).state).toBe("ok");
237 expect(await pictured(env, first)).toHaveLength(2);
238 expect(calls).toHaveLength(4);
239 expect(new URL(calls[2]!.url).hostname).toBe(
242 expect(await pictured(env, first)).toHaveLength(3);
243 expect(calls).toHaveLength(6);
244 expect(new URL(calls[3]!.url).hostname).toBe(
240245 `hello--${pull.number}.${DOMAIN}`,
241246 );
242247
@@ -246,8 +251,8 @@ describe("the look", () => {
246251 (await settled((r) => r.env === env && r.reason === "redeploy")).state,
247252 ).toBe("ok");
248253 await looks.idle();
249 expect(calls).toHaveLength(4);
250 expect(looksForEnv(app.db, record.id, env)).toHaveLength(2);
254 expect(calls).toHaveLength(6);
255 expect(looksForEnv(app.db, record.id, env)).toHaveLength(3);
251256
252257 // A newer head is pictured, and the older head's pictures go.
253258 const again = await commitToBranch(app, alice, repo, {
@@ -260,8 +265,8 @@ describe("the look", () => {
260265 expect(
261266 (await settled((r) => r.env === env && r.commitOid === second)).state,
262267 ).toBe("ok");
263 expect(await pictured(env, second)).toHaveLength(2);
264 expect(calls).toHaveLength(6);
268 expect(await pictured(env, second)).toHaveLength(3);
269 expect(calls).toHaveLength(9);
265270 await until(() =>
266271 looksForEnv(app.db, record.id, env).every((l) => l.commitOid === second)
267272 ? true
@@ -452,7 +457,7 @@ describe("the look", () => {
452457 looksForEnv(app.db, record.id, env).filter(
453458 (l) => l.commitOid === commitOid && l.error === null,
454459 ),
455 ).toHaveLength(2);
460 ).toHaveLength(3);
456461
457462 expect(
458463 (await setIssueStateThrough(app, alice, repo, opened.value, "closed"))
@@ -499,7 +504,7 @@ describe("the look", () => {
499504 ).toBe(false);
500505 });
501506
502 test("the studio's look ask leads with the phone picture, and the app's head shows production", async () => {
507 test("the studio's look ask leads with the thumb picture, and the app's head shows production", async () => {
503508 const scout = await createAgent(app, alice, { username: "scout" });
504509 if (!scout.ok) throw new Error(String(scout.error));
505510 expect(addToCrew(app, alice, repo, "scout").ok).toBe(true);
@@ -529,17 +534,44 @@ describe("the look", () => {
529534 `<a class="look-thumb" href="/alice/hello/pulls/${ask.number}"`,
530535 );
531536 expect(page).toContain(
532 `src="/alice/hello/looks/${env}/${branch.value.commitOid}/phone.png"`,
537 `src="/alice/hello/looks/${env}/${branch.value.commitOid}/thumb.png"`,
533538 );
534539 const production = looksForEnv(app.db, record.id, "production").find(
535 (l) => l.kind === "desktop",
540 (l) => l.kind === "thumb",
536541 )!;
537542 expect(page).toContain(`class="look-now"`);
538543 expect(page).toContain(
539 `src="/alice/hello/looks/production/${production.commitOid}/desktop.png"`,
544 `src="/alice/hello/looks/production/${production.commitOid}/thumb.png"`,
540545 );
541546 const home = await (await web("/", studio)).text();
542547 expect(home).toContain(`class="look-thumb"`);
548
549 // A commit pictured before the thumb kind existed has only its phone
550 // and desktop rows — this is exactly that, with the thumb dropped —
551 // and every slot that would show a thumb falls back to what it showed
552 // before, rather than going blank.
553 app.db
554 .query(
555 `DELETE FROM look WHERE app_id = ?1 AND env = ?2 AND kind = 'thumb'`,
556 )
557 .run(record.id, env);
558 app.db
559 .query(
560 `DELETE FROM look WHERE app_id = ?1 AND env = 'production' AND kind = 'thumb'`,
561 )
562 .run(record.id);
563 const fallback = await (await web("/alice/hello", studio)).text();
564 expect(fallback).toContain(
565 `src="/alice/hello/looks/${env}/${branch.value.commitOid}/phone.png"`,
566 );
567 const productionDesktop = looksForEnv(
568 app.db,
569 record.id,
570 "production",
571 ).find((l) => l.kind === "desktop")!;
572 expect(fallback).toContain(
573 `src="/alice/hello/looks/production/${productionDesktop.commitOid}/desktop.png"`,
574 );
543575 }, 120_000);
544576
545577 test("memory pressure is what the container truly holds, never its page cache", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs