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

When a pull lands, its head's looks are kept as main's looks for each page, width and theme #9672

Merged opened this

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

Merged

scout/forge:scout/9668-when-a-pull-lands-its-head-s-looks-are-k → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/9668-when-a-pull-lands-its-head-s-looks-are-k was merged into main as a5f9713.

Review

Sign in to review this change.
3 files changed +191 −0
src/looks/main_looks.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * Main's looks: when a pull lands, the pictures taken at its head become
3 * main's current look for each page, kind and theme. A later landing
4 * replaces them page by page; a page only an earlier pull photographed
5 * keeps that pull's picture. Only main's latest is kept, never every head.
6 *
7 * A copy, not a reference: the pull's environment folder is swept when it
8 * closes. The copy is synchronous on the event path: fine for a few PNGs, so it must not grow. Files under `main-looks/<app id>/`, outside `looks/` so the
9 * environment sweeps never reach them; the file is the record.
10 */
11
12 import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
13 import { join } from "node:path";
14 import type { Database } from "bun:sqlite";
15 import {
16 DEFAULT_THEME,
17 looksForEnv,
18 type Look,
19 type LookKind,
20 type LookTheme,
21 } from "../core/looks.ts";
22
23 export const mainLooksRoot = (dataDir: string): string =>
24 join(dataDir, "main-looks");
25
26 /** The id is the forge's own, but a path is built from it: refuse anything else. */
27 const SAFE = /^[A-Za-z0-9_-]+$/;
28
29 const fileFor = (
30 dataDir: string,
31 appId: string,
32 page: string,
33 kind: LookKind,
34 theme: LookTheme,
35 ): string | undefined =>
36 SAFE.test(appId)
37 ? join(
38 mainLooksRoot(dataDir),
39 appId,
40 `${encodeURIComponent(page)}.${kind}.${theme}.png`,
41 )
42 : undefined;
43
44 /** Main's look for a page, kind and theme: the file's path, or nothing. */
45 export function mainLook(
46 dataDir: string,
47 appId: string,
48 page: string,
49 kind: LookKind,
50 theme: LookTheme = DEFAULT_THEME,
51 ): { readonly path: string } | undefined {
52 const path = fileFor(dataDir, appId, page, kind, theme);
53 return path !== undefined && existsSync(path) ? { path } : undefined;
54 }
55
56 /**
57 * Keep what an environment's pictures show as main's looks. Call before
58 * the environment is swept. Returns how many pictures were kept.
59 */
60 export function keepAsMain(
61 db: Database,
62 dataDir: string,
63 appId: string,
64 env: string,
65 ): number {
66 // Newest first: the first of each slot is the head's.
67 const seen = new Set<string>();
68 let kept = 0;
69 for (const look of looksForEnv(db, appId, env, "any") as Look[]) {
70 if (look.error !== null || look.attachedBy !== null || look.path === "")
71 continue;
72 const slot = `${look.page}\0${look.kind}\0${look.theme}`;
73 if (seen.has(slot)) continue;
74 seen.add(slot);
75 const from = join(dataDir, look.path);
76 const to = fileFor(dataDir, appId, look.page, look.kind, look.theme);
77 if (to === undefined || !existsSync(from)) continue;
78 mkdirSync(join(to, ".."), { recursive: true });
79 const tmp = `${to}.tmp`;
80 copyFileSync(from, tmp);
81 renameSync(tmp, to);
82 kept++;
83 }
84 return kept;
85 }
src/looks/service.ts +9
@@ -69,6 +69,7 @@ import { createLog } from "../kernel/log.ts";
6969 import { err, ok, type Result } from "../kernel/result.ts";
7070 import { writeCommitLooks } from "../checks/looks.ts";
7171 import type { LookVitals } from "../checks/summary.ts";
72 import { keepAsMain } from "./main_looks.ts";
7273 import { findChromium, isNoBrowser, takeLook, type Capture } from "./capture.ts";
7374
7475 const log = createLog("looks");
@@ -277,6 +278,14 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle {
277278 const number = payload["number"];
278279 const record = getAppByRepo(app.db, event.repoId);
279280 if (typeof number !== "number" || !record) return;
281 // Landed: its head's looks become main's before the sweep.
282 if (event.kind === "pull.merged") {
283 try {

Untested wiring: no test fails if this block is removed. Cover pull.merged (kept) and pull.closed (not kept) through the service.

284 keepAsMain(app.db, dataDir, record.id, previewEnv(number));
285 } catch (error) {
286 log.warn("main's looks not kept", { error: String(error) });
287 }
288 }
280289 forget(record.id, previewEnv(number));
281290 return;
282291 }
test/main-looks.test.ts added +97
@@ -0,0 +1,97 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3 import { tmpdir } from "node:os";
4 import { dirname, join } from "node:path";
5 import { createApp } from "../src/app.ts";
6 import { recordLook } from "../src/core/looks.ts";
7 import { keepAsMain, mainLook } from "../src/looks/main_looks.ts";
8
9 let dir: string;
10 beforeAll(() => {
11 dir = mkdtempSync(join(tmpdir(), "mainlooks-"));
12 });
13 afterAll(() => rmSync(dir, { recursive: true, force: true }));
14
15 const db = () => {
16 const d = createApp({ dataDir: dir, port: 0 }).db;
17 d.run("PRAGMA foreign_keys = OFF");
18 return d;
19 };
20
21 function take(d: ReturnType<typeof db>, env: string, page: string, kind: "phone" | "desktop", content: string) {
22 const path = join("looks", "app1", env, `c-${encodeURIComponent(page)}-${kind}.png`);
23 mkdirSync(dirname(join(dir, path)), { recursive: true });
24 writeFileSync(join(dir, path), content);
25 recordLook(d, { appId: "app1", env, commitOid: "c", page, kind, path });
26 }
27
28 test("the second landing replaces the first; a page only the first took keeps it", () => {
29 const d = db();
30 take(d, "pull-1", "/", "phone", "first-home");
31 take(d, "pull-1", "/about", "phone", "first-about");
32 expect(keepAsMain(d, dir, "app1", "pull-1")).toBe(2);
33 // the pull's folder is swept
34 rmSync(join(dir, "looks"), { recursive: true, force: true });
35 take(d, "pull-2", "/", "phone", "second-home");
36 expect(keepAsMain(d, dir, "app1", "pull-2")).toBe(1);
37 rmSync(join(dir, "looks"), { recursive: true, force: true });
38
39 expect(readFileSync(mainLook(dir, "app1", "/", "phone")!.path, "utf8")).toBe("second-home");
40 expect(readFileSync(mainLook(dir, "app1", "/about", "phone")!.path, "utf8")).toBe("first-about");
41 expect(mainLook(dir, "app1", "/", "desktop")).toBeUndefined();
42 expect(mainLook(dir, "app1", "/", "phone", "dark")).toBeUndefined();
43 expect(mainLook(dir, "../x", "/", "phone")).toBeUndefined();
44 expect(existsSync(join(dir, "main-looks"))).toBe(true);
45 });
46
47 // The wiring: the looks service, handed pull.merged / pull.closed.
48 import { newApp } from "../src/actions/apps.ts";
49 import { previewEnv } from "../src/apps/data.ts";
50 import { createUser } from "../src/core/users.ts";
51 import { err } from "../src/kernel/result.ts";
52 import { startLooks } from "../src/looks/service.ts";
53
54 async function wired() {
55 const wdir = mkdtempSync(join(tmpdir(), "mainlooks-wired-"));
56 const app = createApp({ dataDir: wdir, port: 0 });
57 const u = await createUser(app.db, {
58 origin: "seed",
59 trusted: true,
60 username: "alice",
61 email: "[email protected]",
62 password: "a-good-passphrase",
63 adminIfFirst: true,
64 });
65 if (!u.ok) throw new Error(u.error);
66 const made = await newApp(app, u.value, { name: "hello", visibility: "private" });
67 if (!made.ok) throw new Error(String(made.error));
68 const handle = startLooks(app, {
69 localUrl: "http://127.0.0.1:1",
70 capture: async () => err("no capture in this test"),
71 });
72 const appId = made.value.app.id;
73 const env = previewEnv(7);
74 const path = join("looks", appId, env, "c-phone.png");
75 mkdirSync(dirname(join(wdir, path)), { recursive: true });
76 writeFileSync(join(wdir, path), "head-look");
77 recordLook(app.db, { appId, env, commitOid: "c", kind: "phone", path });
78 return { app, wdir, handle, appId, repoId: made.value.repo.id };
79 }
80
81 test("a pull.merged event keeps the head's look as main's, then the sweep takes the folder", async () => {
82 const w = await wired();
83 w.app.bus.append({ kind: "pull.merged", repoId: w.repoId, payload: { number: 7 } });
84 const kept = mainLook(w.wdir, w.appId, "/", "phone");
85 expect(kept).toBeDefined();
86 expect(readFileSync(kept!.path, "utf8")).toBe("head-look");
87 expect(existsSync(join(w.wdir, "looks", w.appId, previewEnv(7)))).toBe(false);
88 await w.handle.stop();
89 });
90
91 test("a pull.closed event keeps nothing", async () => {
92 const w = await wired();
93 w.app.bus.append({ kind: "pull.closed", repoId: w.repoId, payload: { number: 7 } });
94 expect(mainLook(w.wdir, w.appId, "/", "phone")).toBeUndefined();
95 expect(existsSync(join(w.wdir, "looks", w.appId, previewEnv(7)))).toBe(false);
96 await w.handle.stop();
97 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs