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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9668-when-a-pull-lands-its-head-s-looks-are-k → main 2 commits fork 1 thread · 1 unresolved
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"; | ||
| 69 | 69 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 70 | 70 | import { writeCommitLooks } from "../checks/looks.ts"; |
| 71 | 71 | import type { LookVitals } from "../checks/summary.ts"; |
| 72 | import { keepAsMain } from "./main_looks.ts"; | |
| 72 | 73 | import { findChromium, isNoBrowser, takeLook, type Capture } from "./capture.ts"; |
| 73 | 74 | |
| 74 | 75 | const log = createLog("looks"); |
| @@ -277,6 +278,14 @@ export function startLooks(app: App, options: LooksOptions): LooksHandle { | ||
| 277 | 278 | const number = payload["number"]; |
| 278 | 279 | const record = getAppByRepo(app.db, event.repoId); |
| 279 | 280 | 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 { | |
| 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 | } | |
| 280 | 289 | forget(record.id, previewEnv(number)); |
| 281 | 290 | return; |
| 282 | 291 | } |
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 | }); |
Untested wiring: no test fails if this block is removed. Cover pull.merged (kept) and pull.closed (not kept) through the service.