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

A pull's looks show main's picture beside the head's, so before and after are on every pull with no one attaching them #9717

Merged opened this

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

Merged

mason/forge:mason/9669-a-pull-s-looks-show-main-s-picture-besid → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/9669-a-pull-s-looks-show-main-s-picture-besid was merged into main as 1bff6d0.

Review

Sign in to review this change.
3 files changed +128 −38
src/web/pages/looks.ts +36 −36
@@ -163,7 +163,7 @@ export function lookCard(
163163 );
164164 const labelled = pages.length > 1;
165165 // What main looks like, to set each page beside: only what this viewer may fetch.
166 const mainLooks = looksForEnv(ctx.app.db, record.id, "production").filter(
166 const mainLooks = looksForEnv(ctx.app.db, record.id, "production", "any").filter(
167167 (l) => !isAttached(l) && mayFetchLook(ctx, repo, record, l),
168168 );
169169 const picture = (
@@ -207,14 +207,13 @@ export function lookCard(
207207 </p>
208208 </section>`;
209209 const moved = shown.commitOid !== headOid;
210 // Beside the same page at the same width and theme: a width main lacks is left out.
210211 const mainOfPage = mainLooks.filter((l) => l.page === page);
211 const beforePhone = pictured(mainOfPage, "phone");
212 const beforeDesktop = pictured(mainOfPage, "desktop");
213 // Beside the same page at the same width: a width main lacks is left out.
214 const beforeShown = [
215 phone === undefined ? undefined : beforePhone,
216 desktop === undefined ? undefined : beforeDesktop,
217 ].filter((l): l is Look => l !== undefined);
212 const beforeOf = (theme: Look["theme"], headKinds: readonly Look["kind"][]): Look[] =>
outdated

...have indexed 0/1 as phone/desktop is opaque; use two named parameters.

213 headKinds
214 .map((kind) => pictured(mainOfPage.filter((l) => l.theme === theme), kind))
215 .filter((l): l is Look => l !== undefined);
216 const beforeShown = beforeOf("light", [phone, desktop].filter((l) => l !== undefined).map((l) => l.kind));
218217 // Only the dark pictures of the commit the caption names: after a push
219218 // the light pair is retaken first, and an old head's dark pair must not
220219 // sit under the new head's words.
@@ -223,38 +222,39 @@ export function lookCard(
223222 );
224223 const darkPhone = pictured(darkOfPage, "phone");
225224 const darkDesktop = pictured(darkOfPage, "desktop");
225 // Main's picture beside the head's; where main has none, the head stands alone.
226 const compare = (before: readonly Look[], after: readonly Look[]): Html =>
227 before.length === 0
228 ? html`<div class="looks">
229 ${after.map((l) => picture(l, l.kind, page))}
230 </div>`
231 : html`<div class="looks" data-compare>
232 <div>
233 <p class="muted small">main</p>
234 <div class="looks">
235 ${before.map((l) => picture(l, l.kind, page, "main"))}
236 </div>
237 <p class="muted small">
238 at ${sha(before[0]!.commitOid, `${base}/commit/${before[0]!.commitOid}`)} ·
239 ${time(before[0]!.createdAt)}
240 </p>
241 </div>
242 <div>
243 <p class="muted small">this pull</p>
244 <div class="looks">
245 ${after.map((l) => picture(l, l.kind, page))}
246 </div>
247 </div>
248 </div>`;
249 const lightAfter = [phone, desktop].filter((l): l is Look => l !== undefined);
250 const darkAfter = [darkPhone, darkDesktop].filter((l): l is Look => l !== undefined);
226251 return html`<section class="stack stack-tight">
227252 ${label}
228 <div class="looks" data-compare>
229 <div>
230 <p class="muted small">Before · main</p>
231 ${beforeShown.length === 0
232 ? html`<p class="muted small">
233 Main has no look of ${page} yet.
234 </p>`
235 : html`<div class="looks">
236 ${beforeShown.map((l) => picture(l, l.kind, page, "main"))}
237 </div>
238 <p class="muted small">
239 at ${sha(beforeShown[0]!.commitOid, `${base}/commit/${beforeShown[0]!.commitOid}`)} ·
240 ${time(beforeShown[0]!.createdAt)}
241 </p>`}
242 </div>
243 <div>
244 <p class="muted small">After · #${pull.number}</p>
245 <div class="looks">
246 ${when(phone, (l) => picture(l, "phone", page))}
247 ${when(desktop, (l) => picture(l, "desktop", page))}
248 </div>
249 </div>
250 </div>
251 ${when(darkPhone ?? darkDesktop, () =>
253 ${compare(beforeShown, lightAfter)}
254 ${when(darkAfter.length > 0, () =>
252255 disclosure(
253256 "In the dark",
254 html`<div class="looks">
255 ${when(darkPhone, (l) => picture(l, "phone", page))}
256 ${when(darkDesktop, (l) => picture(l, "desktop", page))}
257 </div>`,
257 compare(beforeOf("dark", darkAfter.map((l) => l.kind)), darkAfter),
258258 { small: true },
259259 ),
260260 )}
test/looks.test.ts +2 −2
@@ -1235,14 +1235,14 @@ describe("the look", () => {
12351235 const map = group("/map");
12361236 expect(map).toContain(`/looks/production/${mainOid}/map-phone.png?v=`);
12371237 expect(map).toContain(`/looks/${env}/${oid}/map-phone.png?v=`);
1238 expect(map.indexOf("Before")).toBeLessThan(map.indexOf("After"));
1238 expect(map.indexOf(">main<")).toBeLessThan(map.indexOf(">this pull<"));
12391239 expect(map.indexOf(`/looks/production/`)).toBeLessThan(
12401240 map.indexOf(`/looks/${env}/`),
12411241 );
12421242 expect(map).not.toContain("has no look");
12431243 expect(map).toContain(`/commit/${mainOid}`);
12441244 const solo = group("/solo");
1245 expect(solo).toContain("Main has no look of /solo yet.");
1245 expect(solo).not.toContain(">main<");
12461246 expect(solo).not.toContain("/looks/production/");
12471247 });
12481248
test/pull-looks-pair.test.ts added +90
@@ -0,0 +1,90 @@
1 /** The pull page sets main's look beside the head's, per page, width and theme; where main has none the head stands alone (#9669). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openPull } from "../src/actions/pulls.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { previewEnv } from "../src/apps/data.ts";
10 import { createApp as insertApp } from "../src/core/apps.ts";
11 import { recordLook } from "../src/core/looks.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let page = "";
22 const MAIN = "b".repeat(40);
23 const HEAD = "c".repeat(40);
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-pull-pair-"));
27 app = createApp({ dataDir, port: 0 });
28 const made = await createUser(app.db, {
29 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
30 password: "a-good-passphrase", adminIfFirst: true,
31 });
32 if (!made.ok) throw new Error(made.error);
33 const r = await createRepo(app, made.value, { name: "garden", visibility: "public" });
34 if (!r.ok) throw new Error(String(r.error));
35 await commitToBranch(app, made.value, r.value, { message: "seed", files: [{ path: "README.md", content: "# g\n" }] });
36 await commitToBranch(app, made.value, r.value, { branch: "one", base: "main", message: "one", files: [{ path: "one.md", content: "x\n" }] });
37 const o = await openPull(app, made.value, r.value, { title: "One", body: "", baseBranch: "main", headBranch: "one" });
38 if (!o.ok) throw new Error(String(o.error));
39 const a = insertApp(app.db, { repoId: r.value.id, slug: "garden", access: "public" });
40 if (!a.ok) throw new Error(a.error);
41 const take = (env: string, commitOid: string, extra: Pick<Parameters<typeof recordLook>[1], "kind" | "theme">) =>
42 recordLook(app.db, { appId: a.value.id, env, commitOid, path: "looks/x.png", takenAs: "nobody", ...extra });
43 const pr = previewEnv(o.value.number);
44 take("production", MAIN, { kind: "phone" });
45 take("production", MAIN, { kind: "phone", theme: "dark" });
46 take(pr, HEAD, { kind: "phone" });
47 take(pr, HEAD, { kind: "phone", theme: "dark" });
48 take(pr, HEAD, { kind: "desktop" }); // main has no desktop look
49 servers = await serve(app);
50 page = await (await fetch(`${servers.url}/alice/garden/pulls/${o.value.number}`)).text();
51 }, 30_000);
52
53 afterAll(async () => {
54 servers.stop();
55 app.close();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 /** The `data-compare` blocks, in page order: light first, then dark. */
60 const blocks = () => page.split("data-compare").slice(1);
61
62 test("each block puts main's picture under main and the head's under this pull, light and dark apart", () => {
outdated

Assertions are page-wide toContain; they don't show main sits under "main" and the head under "this pull", nor that dark is paired. Slice by data-compare and check order per block.

63 const found = blocks();
64 expect(found.length).toBe(2);
65 const [light, dark] = found as [string, string];
66 for (const [block, file] of [[light, "phone.png"], [dark, "phone-dark.png"]] as const) {
67 const cut = block.indexOf(">this pull<");
68 expect(block.indexOf(">main<")).toBeGreaterThan(-1);
69 expect(cut).toBeGreaterThan(block.indexOf(">main<"));
70 const before = block.slice(0, cut);
71 const after = block.slice(cut);
72 expect(before).toContain(`/looks/production/${MAIN}/${file}`);
73 expect(before).not.toContain(HEAD);
74 expect(after).toContain(`/${HEAD}/${file}`);
75 expect(after).not.toContain(MAIN);
76 }
77 });
78
79 test("an unpaired look stands alone, and every image keeps its size", () => {
80 expect(page).toContain(`/${HEAD}/desktop.png`);
81 expect(page).not.toContain(`/production/${MAIN}/desktop`);
82 // Main has no desktop look, so none is set beside it: it sits only in the head's column.
83 const [light, dark] = blocks() as [string, string];
84 const cut = light.indexOf(">this pull<");
85 expect(light.slice(0, cut)).not.toContain("desktop");
86 expect(light.slice(cut)).toContain(`/${HEAD}/desktop.png`);
87 expect(dark).not.toContain("desktop");
88 for (const img of page.match(/<img[^>]*looks\/[^>]*>/g) ?? [])
89 expect(img).toMatch(/width="\d+"[\s\S]*height="\d+"/);
90 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs