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

A look pictures each page in the dark theme as well as the light #3114

Merged opened this

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

Merged

scout/forge:scout/2736-a-look-pictures-each-page-in-the-dark-th → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2736-a-look-pictures-each-page-in-the-dark-th was merged into main as 025f12c.

Review

Sign in to review this change.
8 files changed +265 −33
docs/APPS.md +11
@@ -806,6 +806,17 @@ slot used to load, so nothing goes blank. Pictures are served at
806806 `/:owner/:name/looks/<env>/<commit>/<page>-<kind>.png` for another, with the
807807 repository's own read access, cached by commit.
808808
809 Each page is also pictured in the dark (#2736): phone and desktop again, with
810 the browser's `prefers-color-scheme` forced to dark, kept in a slot of its own
811 (`phone-dark.png`, `board-desktop-dark.png`) with `theme` = `dark` on its row.
812 A look recorded before this has no theme and reads as light; the thumb stays
813 light, and every listing of looks means the light ones.
814 On the pull page the dark pair sits in a collapsed "In the dark" fold under the
815 light one (no script needed, and four tall pictures never stack open on a
816 phone), and only the dark pictures of the commit the caption names are shown.
817 The terminal pull screen shows no looks, light or dark: looks are a web page
818 thing.
819
809820 The phone and desktop pictures are what the repository's owner would see:
810821 headless Chromium is pointed at the forge's own listener with the app's
811822 hostname resolved to loopback, through the grant door, so the app renders for
src/actions/attach_look.ts +1
@@ -135,6 +135,7 @@ export function attachLook(
135135 commitOid: pull.headOid,
136136 page,
137137 kind,
138 theme: "light",
138139 path,
139140 createdAt: new Date().toISOString(),
140141 error: null,
src/core/looks.ts +63 −16
@@ -48,6 +48,18 @@ export const LOOK_WINDOWS: Readonly<
4848 */
4949 export const LEGACY_THUMB_WIDTH = 200;
5050
51 /**
52 * Which colour scheme a picture was taken in. Every look from before dark
53 * pictures existed is light; a dark one is taken with the page's
54 * `prefers-color-scheme` forced to dark.
55 */
56 export type LookTheme = "light" | "dark";
57 export const LOOK_THEMES: readonly LookTheme[] = ["light", "dark"];
58 export const DEFAULT_THEME: LookTheme = "light";
59
60 /** The kinds also pictured in the dark: a thumb is the desktop page reduced, and stays light. */
61 export const DARK_KINDS: readonly LookKind[] = ["phone", "desktop"];
62
5163 /** A look with no page recorded — every row before `app.looks` existed. */
5264 export const DEFAULT_PAGE = "/";
5365
@@ -66,9 +78,14 @@ const pageSlug = (page: string): string =>
6678 * before `app.looks` existed, so two pages of one commit never collide
6779 * and old names keep meaning what they always meant.
6880 */
69 export const lookSlot = (page: string, kind: LookKind): string => {
81 export const lookSlot = (
82 page: string,
83 kind: LookKind,
84 theme: LookTheme = DEFAULT_THEME,
85 ): string => {
7086 const slug = pageSlug(page);
71 return slug === "" ? kind : `${slug}-${kind}`;
87 const base = slug === "" ? kind : `${slug}-${kind}`;
88 return theme === "dark" ? `${base}-dark` : base;
7289 };
7390
7491 export interface Look {
@@ -79,6 +96,8 @@ export interface Look {
7996 /** The page pictured — `forge.json`'s `app.looks`, `/` when unset. */
8097 readonly page: string;
8198 readonly kind: LookKind;
99 /** The colour scheme it was taken in; light for every look before dark ones. */
100 readonly theme: LookTheme;
82101 /** Where the picture is, relative to the data directory; empty on an error. */
83102 readonly path: string;
84103 readonly createdAt: string;
@@ -99,6 +118,7 @@ interface Row {
99118 commit_oid: string;
100119 page: string;
101120 kind: LookKind;
121 theme: LookTheme;
102122 path: string;
103123 created_at: string;
104124 error: string | null;
@@ -107,13 +127,27 @@ interface Row {
107127 note: string | null;
108128 }
109129
130 /**
131 * The `page` column of a dark look. The table's unique key is (app, env,
132 * commit, page, kind) and a roll never rebuilds it (migrations are
133 * additive while an older process still serves), so a dark look is filed
134 * under a page no light one can have — pages start with `/` — and this
135 * module is the only place that knows.
136 */
137 const DARK_PREFIX = "dark:";
138 const stored = (page: string, theme: LookTheme): string =>
139 theme === "dark" ? `${DARK_PREFIX}${page}` : page;
140 const unstored = (page: string): string =>
141 page.startsWith(DARK_PREFIX) ? page.slice(DARK_PREFIX.length) : page;
142
110143 const toLook = (r: Row): Look => ({
111144 id: r.id,
112145 appId: r.app_id,
113146 env: r.env,
114147 commitOid: r.commit_oid,
115 page: r.page,
148 page: unstored(r.page),
116149 kind: r.kind,
150 theme: r.theme,
117151 path: r.path,
118152 createdAt: r.created_at,
119153 error: r.error,
@@ -128,6 +162,7 @@ export interface RecordLookInput {
128162 readonly commitOid: string;
129163 readonly page?: string | undefined;
130164 readonly kind: LookKind;
165 readonly theme?: LookTheme | undefined;
131166 readonly path: string;
132167 readonly error?: string | undefined;
133168 /** Who it was taken as; the owner unless said. */
@@ -140,9 +175,10 @@ export interface RecordLookInput {
140175 /** Record a picture, or the reason there is none. A retake replaces. */
141176 export function recordLook(db: Database, input: RecordLookInput): Look {
142177 const page = input.page ?? DEFAULT_PAGE;
178 const theme = input.theme ?? DEFAULT_THEME;
143179 db.query(
144 `INSERT INTO look (id, app_id, env, commit_oid, page, kind, path, error, taken_as, attached_by, note)
145 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
180 `INSERT INTO look (id, app_id, env, commit_oid, page, kind, path, error, taken_as, attached_by, note, theme)
181 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
146182 ON CONFLICT (app_id, env, commit_oid, page, kind) DO UPDATE SET
147183 path = excluded.path, error = excluded.error, taken_as = excluded.taken_as,
148184 created_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
@@ -151,15 +187,16 @@ export function recordLook(db: Database, input: RecordLookInput): Look {
151187 input.appId,
152188 input.env,
153189 input.commitOid,
154 page,
190 stored(page, theme),
155191 input.kind,
156192 input.path,
157193 input.error ?? null,
158194 input.takenAs ?? "owner",
159195 input.attachedBy ?? null,
160196 input.note ?? null,
197 theme,
161198 );
162 return getLook(db, input.appId, input.env, input.commitOid, page, input.kind)!;
199 return getLook(db, input.appId, input.env, input.commitOid, page, input.kind, theme)!;
163200 }
164201
165202 export function getLook(
@@ -169,28 +206,38 @@ export function getLook(
169206 commitOid: string,
170207 page: string,
171208 kind: LookKind,
209 theme: LookTheme = DEFAULT_THEME,
172210 ): Look | undefined {
173211 const row = db
174212 .query<
175213 Row,
176214 [string, string, string, string, string]
177215 >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 AND commit_oid = ?3 AND page = ?4 AND kind = ?5`)
178 .get(appId, env, commitOid, page, kind);
216 .get(appId, env, commitOid, stored(page, theme), kind);
179217 return row ? toLook(row) : undefined;
180218 }
181219
182 /** Every look of one environment, newest first. */
183 export function looksForEnv(db: Database, appId: string, env: string): Look[] {
220 /**
221 * Every look of one environment, newest first. The light ones unless
222 * `theme` says otherwise (`"any"` for both): what shows a page's picture
223 * has always meant the light one.
224 */
225 export function looksForEnv(
226 db: Database,
227 appId: string,
228 env: string,
229 theme: LookTheme | "any" = DEFAULT_THEME,
230 ): Look[] {
184231 return db
185232 .query<
186233 Row,
187 [string, string]
188 >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 ORDER BY created_at DESC, kind`)
189 .all(appId, env)
234 [string, string, string]
235 >(`SELECT * FROM look WHERE app_id = ?1 AND env = ?2 AND (?3 = 'any' OR theme = ?3) ORDER BY created_at DESC, kind, theme`)
236 .all(appId, env, theme)
190237 .map(toLook);
191238 }
192239
193 /** Every look of several apps in one read, newest first. */
240 /** Every light look of several apps in one read, newest first. */
194241 export function looksForApps(db: Database, appIds: readonly string[]): Look[] {
195242 const out: Look[] = [];
196243 const unique = [...new Set(appIds)];
@@ -199,7 +246,7 @@ export function looksForApps(db: Database, appIds: readonly string[]): Look[] {
199246 const batch = unique.slice(i, i + CHUNK);
200247 const rows = db
201248 .query<Row, string[]>(
202 `SELECT * FROM look WHERE app_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
249 `SELECT * FROM look WHERE theme = 'light' AND app_id IN (${batch.map((_, n) => `?${n + 1}`).join(",")})
203250 ORDER BY created_at DESC, kind`,
204251 )
205252 .all(...batch);
@@ -250,7 +297,7 @@ export const lookVersion = (look: Look): string =>
250297 * `app.looks` existed — and by which taking of it, so a browser that kept
251298 * the last one for a year asks for the new one. */
252299 export const lookUrl = (base: string, look: Look): string =>
253 `${base}/looks/${look.env}/${look.commitOid}/${lookSlot(look.page, look.kind)}.png?v=${lookVersion(look)}`;
300 `${base}/looks/${look.env}/${look.commitOid}/${lookSlot(look.page, look.kind, look.theme)}.png?v=${lookVersion(look)}`;
254301
255302 /** Whether a look was attached by a person, not taken by the forge. */
256303 export const isAttached = (look: Look): boolean => look.attachedBy !== null;
src/core/migrations/20261020110000-a-look-has-a-theme.ts added +9
@@ -0,0 +1,9 @@
1 // A look knows which colour scheme it was taken in (#2736). `theme`
2 // defaults to 'light', so every row from before dark pictures reads as
3 // exactly what it was. Additive, so an older process keeps serving across
4 // a roll: the unique key is left as it is, and `core/looks.ts` files a
5 // dark look under a page of its own.
6 export default `
7 ALTER TABLE look ADD COLUMN theme TEXT NOT NULL DEFAULT 'light'
8 CHECK (theme IN ('light','dark'));
9 `;
src/looks/capture.ts +8
@@ -49,6 +49,8 @@ export interface LookInput {
4949 readonly out: string;
5050 readonly width: number;
5151 readonly height: number;
52 /** Force the page's `prefers-color-scheme` to dark; light (the browser's own) when unset. */
53 readonly dark?: boolean | undefined;
5254 /** A browser that has no picture by then is killed; the look fails. */
5355 readonly timeoutMs: number;
5456 }
@@ -406,6 +408,12 @@ async function shoot(
406408 },
407409 sessionId,
408410 );
411 if (input.dark === true)
412 await dt.send(
413 "Emulation.setEmulatedMedia",
414 { features: [{ name: "prefers-color-scheme", value: "dark" }] },
415 sessionId,
416 );
409417 // Before navigation: the observer is in place before the page's first script.
410418 await dt.send(
411419 "Page.addScriptToEvaluateOnNewDocument",
src/looks/service.ts +22 −10
@@ -44,7 +44,11 @@ import {
4444 deleteLooks,
4545 DEFAULT_PAGE,
4646 LEGACY_THUMB_WIDTH,
47 DARK_KINDS,
48 DEFAULT_THEME,
4749 LOOK_KINDS,
50 LOOK_THEMES,
51 type LookTheme,
4852 LOOK_SIZES,
4953 LOOK_WINDOWS,
5054 lookSlot,
@@ -154,8 +158,9 @@ export const lookPath = (
154158 commitOid: string,
155159 page: string,
156160 kind: LookKind,
161 theme: LookTheme = DEFAULT_THEME,
157162 ): string =>
158 join("looks", appId, env, `${commitOid}-${lookSlot(page, kind)}.png`);
163 join("looks", appId, env, `${commitOid}-${lookSlot(page, kind, theme)}.png`);
159164
160165 /**
161166 * Whether a picture on record is one to keep. Every one is, except a
@@ -374,7 +379,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
374379 let allOk = true;
375380 // What each look's page recorded, for the check summary's vitals table.
376381 const vitals: LookVitals[] = [];
377 for (const { page, kind } of wanted) {
382 for (const { page, kind, theme } of wanted) {
378383 if (stopped) return;
379384 const token = signGrant(
380385 dataDir,
@@ -384,7 +389,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
384389 asNobody(kind) ? undefined : asOwner,
385390 );
386391 const url = `http://${host}:${port}/__forge/grant?t=${encodeURIComponent(token)}&to=${encodeURIComponent(page)}`;
387 const path = lookPath(item.appId, item.env, item.commitOid, page, kind);
392 const path = lookPath(item.appId, item.env, item.commitOid, page, kind, theme);
388393 const out = join(dataDir, path);
389394 // Taken to one side and moved in whole. A pull that closes while its
390395 // picture is being taken sweeps its environment's folder away, and a
@@ -392,17 +397,18 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
392397 // give; this way the sweep and the picture never meet.
393398 const staged = join(
394399 stagingRoot(dataDir),
395 `${item.appId}-${item.env}-${item.commitOid}-${lookSlot(page, kind)}.png`,
400 `${item.appId}-${item.env}-${item.commitOid}-${lookSlot(page, kind, theme)}.png`,
396401 );
397402 mkdirSync(dirname(staged), { recursive: true });
398403 const shot = await capture({
399404 url,
400405 out: staged,
401406 ...LOOK_WINDOWS[kind],
407 ...(theme === "dark" ? { dark: true } : {}),
402408 timeoutMs: CAPTURE_TIMEOUT_MS,
403409 });
404410 // The thumb is the desktop page again, as a stranger: it adds no row.
405 if (kind !== "thumb") {
411 if (kind !== "thumb" && theme === "light") {
406412 const look = page === "/" ? kind : `${kind} ${page}`;
407413 if (shot.ok) vitals.push({ look, entries: shot.value.entries });
408414 else if (isNoBrowser(shot.error))
@@ -415,6 +421,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
415421 env: item.env,
416422 commitOid: item.commitOid,
417423 page,
424 theme,
418425 };
419426 if (taken.ok) {
420427 // The environment moved on while the picture was taken: it shows
@@ -444,6 +451,7 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
444451 env: item.env,
445452 page,
446453 kind,
454 theme,
447455 error: taken.error,
448456 });
449457 recordLook(app.db, { ...row, kind, path: "", error: taken.error });
@@ -480,19 +488,23 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
480488 commitOid: string,
481489 pages: readonly string[] | undefined,
482490 failedCounts = false,
483 ): { page: string; kind: LookKind }[] {
491 ): { page: string; kind: LookKind; theme: LookTheme }[] {
484492 const have = new Set(
485 looksForEnv(app.db, appId, env)
493 looksForEnv(app.db, appId, env, "any")
486494 .filter(
487495 (l) =>
488496 l.commitOid === commitOid &&
489497 (keepLook(dataDir, l) || (failedCounts && l.error !== null)),
490498 )
491 .map((l) => lookSlot(l.page, l.kind)),
499 .map((l) => lookSlot(l.page, l.kind, l.theme)),
492500 );
493501 return (pages ?? [DEFAULT_PAGE]).flatMap((page) =>
494 LOOK_KINDS.filter((kind) => !have.has(lookSlot(page, kind))).map(
495 (kind) => ({ page, kind }),
502 LOOK_THEMES.flatMap((theme) =>
503 LOOK_KINDS.filter(
504 (kind) =>
505 (theme === "light" || DARK_KINDS.includes(kind)) &&
506 !have.has(lookSlot(page, kind, theme)),
507 ).map((kind) => ({ page, kind, theme })),
496508 ),
497509 );
498510 }
src/web/pages/looks.ts +25 −4
@@ -73,8 +73,8 @@ export function lookImage(ctx: Ctx): Response {
7373 return notFound();
7474 const record = getAppByRepo(ctx.app.db, repo.id);
7575 if (!record) return notFound();
76 const look = looksForEnv(ctx.app.db, record.id, env).find(
77 (l) => l.commitOid === commit && lookSlot(l.page, l.kind) === slot,
76 const look = looksForEnv(ctx.app.db, record.id, env, "any").find(
77 (l) => l.commitOid === commit && lookSlot(l.page, l.kind, l.theme) === slot,
7878 );
7979 if (look === undefined || look.error !== null) return notFound();
8080 if (!mayFetchLook(ctx, repo, record, look)) return notFound();
@@ -142,11 +142,14 @@ export function lookCard(
142142 // phone or desktop picture taken as nobody (a public app's), and no card
143143 // without one.
144144 const writer = canSeeOwnerFacts(ctx.app.db, repo, ctx.auth.user);
145 const looks = looksForEnv(ctx.app.db, record.id, env).filter(
145 const every = looksForEnv(ctx.app.db, record.id, env, "any").filter(
146146 (l) =>
147147 writer ||
148148 (l.kind !== "thumb" && l.error === null && mayFetchLook(ctx, repo, record, l)),
149149 );
150 // The dark pictures sit beside the light ones, never in place of them.
151 const dark = every.filter((l) => l.theme === "dark");
152 const looks = every.filter((l) => l.theme === "light");
150153 if (!writer && looks.length === 0) return html``;
151154 const attached = looks.filter(isAttached).reverse();
152155 const taken = looks.filter((l) => !isAttached(l));
@@ -169,6 +172,7 @@ export function lookCard(
169172 const labelled = pages.length > 1;
170173 const picture = (look: Look, kind: LookKind, page: string): Html => {
171174 const url = lookUrl(base, look);
175 const scheme = look.theme === "dark" ? " in the dark" : "";
172176 return html`<a class="look look-${kind}" href="${url}">
173177 <img
174178 src="${url}"
@@ -176,7 +180,7 @@ export function lookCard(
176180 height="${LOOK_SIZES[kind].height}"
177181 alt="${repo.name}${page === DEFAULT_PAGE
178182 ? ""
179 : ` ${page}`} #${pull.number} on a ${kind}"
183 : ` ${page}`} #${pull.number} on a ${kind}${scheme}"
180184 loading="lazy"
181185 />
182186 </a>`;
@@ -202,12 +206,29 @@ export function lookCard(
202206 </p>
203207 </div>`;
204208 const moved = shown.commitOid !== headOid;
209 // Only the dark pictures of the commit the caption names: after a push
outdated

Filter by shown.commitOid here, otherwise an old head's dark pictures sit under the new head's caption. Add a test.

210 // the light pair is retaken first, and an old head's dark pair must not
211 // sit under the new head's words.
212 const darkOfPage = dark.filter(
213 (l) => l.page === page && l.commitOid === shown.commitOid,
214 );
215 const darkPhone = pictured(darkOfPage, "phone");
216 const darkDesktop = pictured(darkOfPage, "desktop");
205217 return html`<div class="look-group">
206218 ${label}
outdated

Give this row a visible 'In the dark' label with existing tokens. look-dark has no style and no DESIGN.md entry.

207219 <div class="look-pair">
208220 ${when(phone, (l) => picture(l, "phone", page))}
209221 ${when(desktop, (l) => picture(l, "desktop", page))}
210222 </div>
223 ${when(darkPhone ?? darkDesktop, () =>
224 html`<details class="quiet-details">
225 <summary class="muted small">In the dark</summary>
226 <div class="look-pair">
227 ${when(darkPhone, (l) => picture(l, "phone", page))}
228 ${when(darkDesktop, (l) => picture(l, "desktop", page))}
229 </div>
230 </details>`,
231 )}
211232 <span class="muted small look-of"
212233 >at ${sha(shown.commitOid, `${base}/commit/${shown.commitOid}`)} ·
213234 ${time(shown.createdAt)}${when(
test/looks.test.ts +126 −3
@@ -24,8 +24,8 @@ import { readGrant } from "../src/apps/proxy.ts";
2424 import { AppsRuntime } from "../src/apps/runtime.ts";
2525 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
2626 import { listEvents } from "../src/core/events.ts";
27 import { looksForEnv, recordLook, type Look } from "../src/core/looks.ts";
28 import { encodePng } from "../src/design/image/png.ts";
27 import { lookSlot, lookUrl, looksForEnv, recordLook, type Look } from "../src/core/looks.ts";
28 import { decodePng, encodePng } from "../src/design/image/png.ts";
2929 import { setAppMembers } from "../src/core/app_members.ts";
3030 import { listPulls, type Pull } from "../src/core/pulls.ts";
3131 import { getRepo, type Repo } from "../src/core/repos.ts";
@@ -83,8 +83,10 @@ describe("the look", () => {
8383 // The stand-in browser: writes a picture, or falls over, as told.
8484 let mode: "ok" | "fail" | "nobrowser" = "ok";
8585 const calls: LookInput[] = [];
86 // The dark pictures, kept apart so what counts the light ones stays true.
87 const darkCalls: LookInput[] = [];
8688 const fake: Capture = async (input) => {
87 calls.push(input);
89 (input.dark === true ? darkCalls : calls).push(input);
8890 if (mode === "fail") return err("the browser fell over");
8991 if (mode === "nobrowser") return err(NO_BROWSER);
9092 writeFileSync(input.out, input.width === 1280 ? DESKTOP_PNG : PNG);
@@ -474,6 +476,31 @@ describe("the look", () => {
474476 mode = "ok";
475477 }, 120_000);
476478
479 test("a look recorded with no theme reads as light, and light and dark never share a slot", () => {
480 const base = {
481 appId: record.id,
482 env: "production",
483 commitOid: "a".repeat(40),
484 kind: "phone" as const,
485 path: "looks/x.png",
486 };
487 const old = recordLook(app.db, base);
488 expect(old.theme).toBe("light");
489 const dark = recordLook(app.db, { ...base, theme: "dark", path: "looks/x-dark.png" });
490 expect(dark.id).not.toBe(old.id);
491 expect(dark.theme).toBe("dark");
492 expect(lookSlot("/", "phone")).toBe("phone");
493 expect(lookSlot("/", "phone", "dark")).toBe("phone-dark");
494 expect(lookSlot("/board", "phone", "dark")).toBe("board-phone-dark");
495 const mine = (theme: "light" | "dark" | "any") =>
496 looksForEnv(app.db, record.id, "production", theme).filter(
497 (l) => l.commitOid === base.commitOid,
498 );
499 expect(mine("light").map((l) => l.path)).toEqual(["looks/x.png"]);
500 expect(mine("dark").map((l) => l.path)).toEqual(["looks/x-dark.png"]);
501 expect(mine("any")).toHaveLength(2);
502 });
503
477504 test("what a page recorded is kept as plain numbers, a missing type as an empty list", () => {
478505 expect(
479506 parseRecorded({
@@ -746,6 +773,65 @@ describe("the look", () => {
746773 `alt="hello /board #${opened.value.number} on a phone"`,
747774 );
748775 expect(page).toContain(`alt="hello #${opened.value.number} on a phone"`);
776
777 // Each page is also pictured in the dark, at phone and desktop, in a
778 // slot of its own: the same page and kind, a different file.
779 const dark = await until(() => {
780 const found = looksForEnv(app.db, record.id, env, "dark").filter(
781 (l) => l.commitOid === commitOid,
782 );
783 return found.length === 4 ? found : undefined;
784 }, 30_000);
785 expect(dark.every((l) => l.theme === "dark")).toBe(true);
786 expect(dark.map((l) => l.kind).sort()).toEqual(["desktop", "desktop", "phone", "phone"]);
787 expect(darkCalls.every((c) => c.dark === true)).toBe(true);
788 const darkPhone = dark.find((l) => l.page === "/" && l.kind === "phone")!;
789 expect(darkPhone.path).toBe(
790 `looks/${record.id}/${env}/${commitOid}-phone-dark.png`,
791 );
792 expect(darkPhone.path).not.toBe(homePhone.path);
793 expect(existsSync(join(dataDir, darkPhone.path))).toBe(true);
794 expect(existsSync(join(dataDir, homePhone.path))).toBe(true);
795 // The light listing is what every page has always shown: no dark row in it.
796 expect(rows.some((l) => l.theme === "dark")).toBe(false);
797 const served = await web(
798 `/alice/hello/looks/${env}/${commitOid}/phone-dark.png`,
799 aliceCookie,
800 );
801 expect(served.status).toBe(200);
802 expect(lookUrl("/alice/hello", darkPhone)).toContain("/phone-dark.png?v=");
803 // And it is on the pull page, as a link that works without script.
804 const darkPage = await (
805 await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie)
806 ).text();
807 expect(darkPage).toContain(
808 `<a class="look look-phone" href="/alice/hello/looks/${env}/${commitOid}/phone-dark.png?v=`,
809 );
810 expect(darkPage).toContain(
811 `src="/alice/hello/looks/${env}/${commitOid}/board-desktop-dark.png?v=`,
812 );
813 expect(darkPage).toContain(
814 `alt="hello /board #${opened.value.number} on a desktop in the dark"`,
815 );
816 // The dark pair is named, and folded away until asked for.
817 expect(darkPage).toMatch(
818 /<details class="quiet-details">\s*<summary class="muted small">In the dark<\/summary>/,
819 );
820 // An older head's dark picture never sits under the shown commit's caption.
821 const stale = "b".repeat(40);
822 recordLook(app.db, {
823 appId: record.id,
824 env,
825 commitOid: stale,
826 kind: "phone",
827 theme: "dark",
828 path: `looks/${record.id}/${env}/${stale}-phone-dark.png`,
829 });
830 const after = await (
831 await web(`/alice/hello/pulls/${opened.value.number}`, aliceCookie)
832 ).text();
833 expect(after).not.toContain(stale);
834 expect(after).toContain(`${commitOid}/phone-dark.png?v=`);
749835 }, 120_000);
750836
751837 test("the sweep pictures a running production missing one: a thumb from before thumbs were the page is retaken as a stranger; one that failed is not retried each sweep", async () => {
@@ -1105,4 +1191,41 @@ describe("the look", () => {
11051191 },
11061192 120_000,
11071193 );
1194 test.skipIf(chromium === undefined)(
1195 "a dark capture is truly dark: the same page, light and dark, differs in its pixels",
1196 async () => {
1197 const page = Bun.serve({
1198 port: 0,
1199 hostname: "127.0.0.1",
1200 fetch: () =>
1201 new Response(
1202 "<!doctype html><style>body{margin:0;background:#fff}@media (prefers-color-scheme: dark){body{background:#000}}</style><p>hi</p>",
1203 { headers: { "content-type": "text/html" } },
1204 ),
1205 });
1206 try {
1207 const corner = async (dark: boolean): Promise<number> => {
1208 const out = join(dataDir, `scheme-${dark ? "dark" : "light"}.png`);
1209 const taken = await takeLook({
1210 url: `http://127.0.0.1:${page.port}/`,
1211 out,
1212 width: 390,
1213 height: 844,
1214 dark,
1215 timeoutMs: 30_000,
1216 });
1217 expect(taken.ok).toBe(true);
1218 const image = decodePng(readFileSync(out));
1219 if (!image.ok) throw new Error(image.error);
1220 const last = image.value.rgba.length - 4;
1221 return image.value.rgba[last]!;
1222 };
1223 expect(await corner(false)).toBe(255);
1224 expect(await corner(true)).toBe(0);
1225 } finally {
1226 page.stop(true);
1227 }
1228 },
1229 120_000,
1230 );
11081231 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs