Public: anyone can read this; the crew can change it.
App and pull OG cards embed the latest look screenshot #2275
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2049-app-and-pull-og-cards-embed-the-latest-l → main 1 commit fork
8 files
changed +355
−11
README.md +10
| @@ -920,6 +920,16 @@ takes back (`supersedes`). `/asked` shows "said again, first on …", "takes bac | ||
| 920 | 920 | social cards. They show only what an anonymous reader can open; anything |
| 921 | 921 | private, sealed or unknown is the same 404. |
| 922 | 922 | |
| 923 | An app card carries the app's latest look (the production thumb, or a desktop | |
| 924 | or phone shot taken as nobody) beside the words; a pull card carries the | |
| 925 | production look as "before" and the pull's preview look as "after" (the | |
| 926 | preview alone if production has none, nothing if the pull has no look). Only | |
| 927 | a look an anonymous reader may fetch is used, of a public app and never a | |
| 928 | sealed pull's. The picture is decoded, reduced to fit 480×300 and embedded as | |
| 929 | a data URI (no dependency; the forge's own PNG codec), and the look's id and | |
| 930 | taking name the card, so a new look is a new card. A look whose file cannot be | |
| 931 | read leaves the card as it was. | |
| 932 | ||
| 923 | 933 | Two of them carry a figure beside the words: |
| 924 | 934 | |
| 925 | 935 | - **Map** — `/og/map/owner~name.svg`: the repository's dependency graph as a |
src/web/og/card.ts +57 −1
| @@ -7,6 +7,12 @@ | ||
| 7 | 7 | * score (`score.ts`) — drawn in a box on the right; the words then keep to |
| 8 | 8 | * the left column, a little smaller, so the two never overlap. |
| 9 | 9 | * |
| 10 | * A card may instead carry pictures (a look, or a before and after pair) as | |
| 11 | * data URIs, in the same box; the words keep to the left column as with a | |
| 12 | * figure. A picture is drawn only if it is a base64 PNG data URI of | |
| 13 | * reasonable size, and its `id` — not its bytes — is what names the card | |
| 14 | * (`signature`), so a new look is a new card. | |
| 15 | * | |
| 10 | 16 | * Every text is stripped of characters XML cannot carry, clamped to the |
| 11 | 17 | * card's width by an estimated advance (system fonts are unmeasurable here, |
| 12 | 18 | * so the estimate is deliberately wide), ended with an ellipsis, and only |
| @@ -28,12 +34,26 @@ export type Figure = GraphFigure | ScoreFigure; | ||
| 28 | 34 | |
| 29 | 35 | export { escapeXml, fit }; |
| 30 | 36 | |
| 37 | /** A picture on a card: `id` names it, `src` is the data URI, `path` where it is read from. */ | |
| 38 | export interface CardImage { | |
| 39 | id: string; | |
| 40 | label?: string; | |
| 41 | path?: string; | |
| 42 | src?: string; | |
| 43 | } | |
| 44 | ||
| 31 | 45 | export interface CardInput { |
| 32 | 46 | kind: CardKind; |
| 33 | 47 | title: string; |
| 34 | 48 | lines?: readonly string[]; |
| 35 | 49 | stat?: string; |
| 36 | 50 | figure?: Figure; |
| 51 | images?: readonly CardImage[]; | |
| 52 | } | |
| 53 | ||
| 54 | /** What names a card: its input, less the bytes of its pictures (their ids stand for them). */ | |
| 55 | export function signature(input: CardInput): string { | |
| 56 | return JSON.stringify(input, (key, value) => (key === "src" || key === "path" ? undefined : value)); | |
| 37 | 57 | } |
| 38 | 58 | |
| 39 | 59 | const T = THEMES.dark.tokens; |
| @@ -114,6 +134,42 @@ function drawFigure(figure: unknown): string { | ||
| 114 | 134 | return ""; |
| 115 | 135 | } |
| 116 | 136 | |
| 137 | const DATA_PNG = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/; | |
| 138 | /** A picture longer than this (as a URI) is not drawn. */ | |
| 139 | const MAX_URI = 400_000; | |
| 140 | const MAX_IMAGES = 2; | |
| 141 | export const IMAGE_BOX = { x: 740, y: 125, w: 380, h: 380 } as const; | |
| 142 | const LABEL_SIZE = 20; | |
| 143 | ||
| 144 | /** The pictures' SVG, or nothing when there is no usable one. */ | |
| 145 | function drawImages(images: unknown): string { | |
| 146 | const usable = (Array.isArray(images) ? images : []) | |
| 147 | .filter( | |
| 148 | (i): i is CardImage => | |
| 149 | typeof i?.src === "string" && i.src.length <= MAX_URI && DATA_PNG.test(i.src), | |
| 150 | ) | |
| 151 | .slice(0, MAX_IMAGES); | |
| 152 | if (usable.length === 0) return ""; | |
| 153 | const { x, y, w, h } = IMAGE_BOX; | |
| 154 | const gap = 14; | |
| 155 | const labelled = usable.length > 1; | |
| 156 | const slot = labelled ? Math.floor((h - gap) / usable.length) - LABEL_SIZE - 6 : 240; | |
| 157 | const out: string[] = []; | |
| 158 | let top = labelled ? y : y + Math.floor((h - slot) / 2) - 40; | |
| 159 | for (const image of usable) { | |
| 160 | if (labelled) { | |
| 161 | out.push(text(x, top + LABEL_SIZE, LABEL_SIZE, T["muted-fg"], clean(image.label), ` font-weight="700" letter-spacing="2"`)); | |
| 162 | top += LABEL_SIZE + 6; | |
| 163 | } | |
| 164 | out.push( | |
| 165 | `<rect x="${x - 1}" y="${top - 1}" width="${w + 2}" height="${slot + 2}" rx="6" fill="${T.border}"/>`, | |
| 166 | `<image href="${image.src}" x="${x}" y="${top}" width="${w}" height="${slot}" preserveAspectRatio="xMidYMid meet"/>`, | |
| 167 | ); | |
| 168 | top += slot + gap; | |
| 169 | } | |
| 170 | return out.join(""); | |
| 171 | } | |
| 172 | ||
| 117 | 173 | export function renderCard(input: CardInput): string { |
| 118 | 174 | const kind: CardKind = CARD_KINDS.includes(input?.kind) ? input.kind : "page"; |
| 119 | 175 | const color = KIND_COLOR[kind]; |
| @@ -134,7 +190,7 @@ export function renderCard(input: CardInput): string { | ||
| 134 | 190 | text(CARD_WIDTH - PAD, PAD + 24, 24, color, label, ` font-weight="700" text-anchor="end" letter-spacing="2"`), |
| 135 | 191 | ); |
| 136 | 192 | |
| 137 | const figure = drawFigure(input?.figure); | |
| 193 | const figure = drawFigure(input?.figure) || drawImages(input?.images); | |
| 138 | 194 | const column = figure ? COLUMN : INNER; |
| 139 | 195 | if (figure) out.push(figure); |
| 140 | 196 |
src/web/og/looks.ts added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | /** | |
| 2 | * Which looks a card may carry. A card is public, so it carries only a | |
| 3 | * picture an anonymous reader could fetch from the look route: taken as | |
| 4 | * nobody (or a current thumb), of the home page, of a public app, and not | |
| 5 | * of a sealed pull's preview. Returns plain `CardImage`s naming files; | |
| 6 | * `embedShots` reads them once the card is known to be wanted. | |
| 7 | */ | |
| 8 | ||
| 9 | import { join } from "node:path"; | |
| 10 | import { previewEnv } from "../../apps/data.ts"; | |
| 11 | import type { AppRecord } from "../../core/apps.ts"; | |
| 12 | import { DEFAULT_PAGE, looksForEnv, lookVersion, type Look } from "../../core/looks.ts"; | |
| 13 | import { sealedPreview } from "../../core/sealed.ts"; | |
| 14 | import { keepLook } from "../../looks/service.ts"; | |
| 15 | import type { App } from "../../app.ts"; | |
| 16 | import type { CardImage, CardInput } from "./card.ts"; | |
| 17 | import { shotUri } from "./shot.ts"; | |
| 18 | ||
| 19 | /** The newest home-page picture of an environment an anonymous reader may see. */ | |
| 20 | function visibleLook(app: App, record: AppRecord, repoId: string, env: string): Look | undefined { | |
| 21 | if (record.access !== "public" || sealedPreview(app.db, repoId, env)) return undefined; | |
| 22 | const home = looksForEnv(app.db, record.id, env).filter( | |
| 23 | (l) => l.page === DEFAULT_PAGE && l.error === null && l.path !== "", | |
| 24 | ); | |
| 25 | const thumb = home.find((l) => l.kind === "thumb" && keepLook(app.config.dataDir, l)); | |
| 26 | const taken = (kind: Look["kind"]) => home.find((l) => l.kind === kind && l.takenAs === "nobody"); | |
| 27 | return thumb ?? taken("desktop") ?? taken("phone"); | |
| 28 | } | |
| 29 | ||
| 30 | const imageOf = (app: App, look: Look, label?: string): CardImage => ({ | |
| 31 | id: `${look.id}.${lookVersion(look)}`, | |
| 32 | path: join(app.config.dataDir, look.path), | |
| 33 | ...(label ? { label } : {}), | |
| 34 | }); | |
| 35 | ||
| 36 | /** The app's latest look, as a list of none or one. */ | |
| 37 | export function appImages(app: App, record: AppRecord, repoId: string): CardImage[] { | |
| 38 | const look = visibleLook(app, record, repoId, "production"); | |
| 39 | return look ? [imageOf(app, look)] : []; | |
| 40 | } | |
| 41 | ||
| 42 | /** A pull's preview look, after what production looked like before it; none without the former. */ | |
| 43 | export function pullImages(app: App, record: AppRecord, repoId: string, number: number): CardImage[] { | |
| 44 | const after = visibleLook(app, record, repoId, previewEnv(number)); | |
| 45 | if (!after) return []; | |
| 46 | const before = visibleLook(app, record, repoId, "production"); | |
| 47 | return before | |
| 48 | ? [imageOf(app, before, "before"), imageOf(app, after, "after")] | |
| 49 | : [imageOf(app, after)]; | |
| 50 | } | |
| 51 | ||
| 52 | /** The input with its pictures read and reduced; one that cannot be read is left out. */ | |
| 53 | export function embedShots(input: CardInput): CardInput { | |
| 54 | if (!input.images?.length) return input; | |
| 55 | const images: CardImage[] = []; | |
| 56 | for (const image of input.images) { | |
| 57 | const src = image.path === undefined ? undefined : shotUri(image.path); | |
| 58 | if (src) images.push({ ...image, src }); | |
| 59 | } | |
| 60 | return { ...input, images }; | |
| 61 | } |
src/web/og/png-cache.ts +3 −3
| @@ -10,19 +10,19 @@ | ||
| 10 | 10 | import { createHash } from "node:crypto"; |
| 11 | 11 | import { mkdir, readdir, rename, stat, unlink, utimes, writeFile } from "node:fs/promises"; |
| 12 | 12 | import { join } from "node:path"; |
| 13 | import type { CardInput } from "./card.ts"; | |
| 13 | import { signature, type CardInput } from "./card.ts"; | |
| 14 | 14 | import type { Rasterizer } from "./raster.ts"; |
| 15 | 15 | |
| 16 | 16 | export const OG_PNG_CAP = 512; |
| 17 | 17 | export const OG_PNG_QUEUE = 4; |
| 18 | 18 | /** Bump when the card's look or the fonts change: old files are then unreachable. */ |
| 19 | const LOOK = "1"; | |
| 19 | const LOOK = "2"; | |
| 20 | 20 | |
| 21 | 21 | /** Thrown when too many cards wait; working as designed, not a failure. */ |
| 22 | 22 | export class Busy extends Error {} |
| 23 | 23 | |
| 24 | 24 | export function cardKey(input: CardInput): string { |
| 25 | return createHash("sha256").update(`${LOOK}\n${JSON.stringify(input)}`).digest("hex"); | |
| 25 | return createHash("sha256").update(`${LOOK}\n${signature(input)}`).digest("hex"); | |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | export interface PngCache { |
src/web/og/shot.ts added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | /** | |
| 2 | * A look, made small enough to sit on a card: the picture is decoded, | |
| 3 | * reduced to fit `SHOT_MAX`, re-encoded and returned as a data URI, so the | |
| 4 | * card stays one self-contained SVG. A file that is missing, unreadable or | |
| 5 | * not a PNG is no picture (undefined), never an error: the card is then the | |
| 6 | * card it would have been without one. | |
| 7 | */ | |
| 8 | ||
| 9 | import { readFileSync } from "node:fs"; | |
| 10 | import { thumbnail } from "../../design/image/image.ts"; | |
| 11 | import { decodePng, encodePng } from "../../design/image/png.ts"; | |
| 12 | ||
| 13 | /** What a picture is reduced to fit, in pixels: twice the box it is drawn in. */ | |
| 14 | export const SHOT_MAX = { width: 480, height: 300 } as const; | |
| 15 | /** A file past this is not read at all. */ | |
| 16 | const MAX_FILE = 4 * 1024 * 1024; | |
| 17 | ||
| 18 | export function shotUri(path: string): string | undefined { | |
| 19 | try { | |
| 20 | const bytes = new Uint8Array(readFileSync(path)); | |
| 21 | if (bytes.length > MAX_FILE) return undefined; | |
| 22 | const decoded = decodePng(bytes); | |
| 23 | if (!decoded.ok) return undefined; | |
| 24 | const { width, height, rgba } = decoded.value; | |
| 25 | const small = thumbnail(rgba, width, height, SHOT_MAX.width, SHOT_MAX.height); | |
| 26 | const png = encodePng(small.width, small.height, small.rgba); | |
| 27 | return `data:image/png;base64,${Buffer.from(png).toString("base64")}`; | |
| 28 | } catch { | |
| 29 | return undefined; | |
| 30 | } | |
| 31 | } |
src/web/pages/og.ts +14 −6
| @@ -21,7 +21,7 @@ import type { App } from "../../app.ts"; | ||
| 21 | 21 | import type { Run } from "../../core/runs.ts"; |
| 22 | 22 | import { getUserById } from "../../core/users.ts"; |
| 23 | 23 | import { getRepoRun } from "../../actions/runs.ts"; |
| 24 | import { appsForRepos, getAppBySlug, isSlug } from "../../core/apps.ts"; | |
| 24 | import { appsForRepos, getAppByRepo, getAppBySlug, isSlug } from "../../core/apps.ts"; | |
| 25 | 25 | import { audit } from "../../contract/audit.ts"; |
| 26 | 26 | import { latestProbes } from "../../contract/probes.ts"; |
| 27 | 27 | import { checkSummary, listActiveRuns } from "../../core/runs.ts"; |
| @@ -42,7 +42,8 @@ import { isSafeName } from "../../git/guard.ts"; | ||
| 42 | 42 | import type { Ctx } from "../../http/context.ts"; |
| 43 | 43 | import { notFound } from "../../http/respond.ts"; |
| 44 | 44 | import { auditInput, type AuditRow } from "../og/audit.ts"; |
| 45 | import { renderCard, type CardInput } from "../og/card.ts"; | |
| 45 | import { renderCard, signature, type CardInput } from "../og/card.ts"; | |
| 46 | import { appImages, embedShots, pullImages } from "../og/looks.ts"; | |
| 46 | 47 | import { mapInput } from "../og/map.ts"; |
| 47 | 48 | import { marksFor } from "./map.ts"; |
| 48 | 49 | import { Busy, pngCache, type PngCache } from "../og/png-cache.ts"; |
| @@ -207,7 +208,12 @@ function threadInput(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined | ||
| 207 | 208 | kind === "pull" ? getPull(db, at.repo.id, n) : getIssue(db, at.repo.id, n); |
| 208 | 209 | if (!item || item.kind !== kind || !mayView(db, at.repo, item, undefined)) |
| 209 | 210 | return undefined; |
| 210 | if (kind === "pull") return pullInput(ctx, at.repo, item as Pull); | |
| 211 | if (kind === "pull") { | |
| 212 | const input = pullInput(ctx, at.repo, item as Pull); | |
| 213 | const record = getAppByRepo(db, at.repo.id); | |
| 214 | const images = record ? pullImages(ctx.app, record, at.repo.id, n) : []; | |
| 215 | return images.length ? { ...input, images } : input; | |
| 216 | } | |
| 211 | 217 | return { |
| 212 | 218 | kind, |
| 213 | 219 | title: item.title, |
| @@ -324,8 +330,10 @@ async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | | ||
| 324 | 330 | if (!repo) return undefined; |
| 325 | 331 | const live = liveState(ctx, repo); |
| 326 | 332 | const base = repoInput(ctx, repo, "app"); |
| 333 | const images = appImages(ctx.app, record, repo.id); | |
| 327 | 334 | return { |
| 328 | 335 | ...base, |
| 336 | ...(images.length ? { images } : {}), | |
| 329 | 337 | title: record.slug, |
| 330 | 338 | lines: [ |
| 331 | 339 | ...(base.lines ?? []), |
| @@ -354,7 +362,7 @@ export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") { | ||
| 354 | 362 | return async (ctx: Ctx): Promise<Response> => { |
| 355 | 363 | const input = await resolve(ctx, kind, ctx.params.ref ?? ""); |
| 356 | 364 | if (!input) return notFound(); |
| 357 | const etag = `"${createHash("sha256").update(JSON.stringify(input)).digest("hex")}${format === "png" ? "-png" : ""}"`; | |
| 365 | const etag = `"${createHash("sha256").update(signature(input)).digest("hex")}${format === "png" ? "-png" : ""}"`; | |
| 358 | 366 | const headers = { |
| 359 | 367 | etag, |
| 360 | 368 | "cache-control": "public, max-age=300", |
| @@ -362,9 +370,9 @@ export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") { | ||
| 362 | 370 | }; |
| 363 | 371 | if (ctx.request.headers.get("if-none-match") === etag) |
| 364 | 372 | return new Response(null, { status: 304, headers }); |
| 365 | if (format === "svg") return new Response(renderCard(input), { headers }); | |
| 373 | if (format === "svg") return new Response(renderCard(embedShots(input)), { headers }); | |
| 366 | 374 | try { |
| 367 | return new Response(await cacheFor(ctx.app).get(input), { headers }); | |
| 375 | return new Response(await cacheFor(ctx.app).get(embedShots(input)), { headers }); | |
| 368 | 376 | } catch (error) { |
| 369 | 377 | if (!(error instanceof Busy)) |
| 370 | 378 | ctx.app.log.error("og card could not be rasterized", { kind, error: String(error) }); |
test/og-card.test.ts +49 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, test } from "bun:test"; |
| 2 | import { CARD_KINDS, escapeXml, fit, renderCard } from "../src/web/og/card.ts"; | |
| 2 | import { CARD_KINDS, escapeXml, fit, renderCard, signature } from "../src/web/og/card.ts"; | |
| 3 | 3 | |
| 4 | 4 | /** Minimal well-formedness check: balanced tags, no stray `<`/`&` in text. */ |
| 5 | 5 | function wellFormed(svg: string): void { |
| @@ -72,3 +72,51 @@ describe("og card", () => { | ||
| 72 | 72 | expect(escapeXml(`<&>"'`)).toBe("<&>"'"); |
| 73 | 73 | }); |
| 74 | 74 | }); |
| 75 | ||
| 76 | describe("og card pictures", () => { | |
| 77 | const src = "data:image/png;base64,iVBORw0KGgo="; | |
| 78 | const base = { kind: "app", title: "peek", lines: ["a line"], stat: "1 issues" } as const; | |
| 79 | ||
| 80 | test("a picture is drawn beside the words, which keep to their column", () => { | |
| 81 | const plain = renderCard(base); | |
| 82 | const svg = renderCard({ ...base, images: [{ id: "a", src }] }); | |
| 83 | wellFormed(svg); | |
| 84 | expect(plain).not.toContain("<image"); | |
| 85 | expect(svg).toContain(`<image href="${src}"`); | |
| 86 | expect(svg).toContain("peek"); | |
| 87 | }); | |
| 88 | ||
| 89 | test("two pictures are labelled before and after", () => { | |
| 90 | const svg = renderCard({ | |
| 91 | ...base, | |
| 92 | images: [ | |
| 93 | { id: "a", src, label: "before" }, | |
| 94 | { id: "b", src, label: "after" }, | |
| 95 | ], | |
| 96 | }); | |
| 97 | wellFormed(svg); | |
| 98 | expect(svg.match(/<image /g)).toHaveLength(2); | |
| 99 | expect(svg).toContain(">before<"); | |
| 100 | expect(svg).toContain(">after<"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("only a base64 PNG data URI is drawn", () => { | |
| 104 | for (const bad of [ | |
| 105 | "https://example.com/x.png", | |
| 106 | 'data:image/png;base64,AAA" onload="x', | |
| 107 | "data:image/svg+xml;base64,AAAA", | |
| 108 | "data:image/png;base64," + "A".repeat(500_000), | |
| 109 | "", | |
| 110 | ]) { | |
| 111 | const svg = renderCard({ ...base, images: [{ id: "a", src: bad }] }); | |
| 112 | expect(svg).toBe(renderCard(base)); | |
| 113 | } | |
| 114 | }); | |
| 115 | ||
| 116 | test("the signature names a picture by id, not by bytes", () => { | |
| 117 | const a = signature({ ...base, images: [{ id: "a", src, path: "/x" }] }); | |
| 118 | expect(signature({ ...base, images: [{ id: "a", src: "other", path: "/y" }] })).toBe(a); | |
| 119 | expect(signature({ ...base, images: [{ id: "b", src }] })).not.toBe(a); | |
| 120 | expect(a).not.toBe(signature(base)); | |
| 121 | }); | |
| 122 | }); |
test/og-png.test.ts +130
| @@ -1,10 +1,15 @@ | ||
| 1 | 1 | import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; |
| 2 | import { mkdirSync, rmSync, writeFileSync } from "node:fs"; | |
| 2 | 3 | import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; |
| 3 | 4 | import { tmpdir } from "node:os"; |
| 4 | 5 | import { join } from "node:path"; |
| 5 | 6 | import { createApp as createForge, type App } from "../src/app.ts"; |
| 6 | 7 | import { createRepo } from "../src/actions/repos.ts"; |
| 8 | import { createApp } from "../src/core/apps.ts"; | |
| 9 | import { createIssue } from "../src/core/issues.ts"; | |
| 10 | import { recordLook } from "../src/core/looks.ts"; | |
| 7 | 11 | import { createUser } from "../src/core/users.ts"; |
| 12 | import { encodePng } from "../src/design/image/png.ts"; | |
| 8 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 14 | import { renderCard } from "../src/web/og/card.ts"; |
| 10 | 15 | import { cardKey, pngCache } from "../src/web/og/png-cache.ts"; |
| @@ -171,3 +176,128 @@ describe("og png", () => { | ||
| 171 | 176 | } |
| 172 | 177 | }); |
| 173 | 178 | }); |
| 179 | ||
| 180 | describe("og cards with a look", () => { | |
| 181 | let dataDir: string; | |
| 182 | let app: App; | |
| 183 | let servers: Servers; | |
| 184 | let base: string; | |
| 185 | let appId = ""; | |
| 186 | let owner: { id: string }; | |
| 187 | ||
| 188 | /** A 1280×800 page-like picture: bands and blocks, so it compresses as a screenshot does. */ | |
| 189 | const screenshot = (hue: number): Uint8Array => { | |
| 190 | const w = 1280; | |
| 191 | const h = 800; | |
| 192 | const rgba = new Uint8Array(w * h * 4); | |
| 193 | for (let y = 0; y < h; y++) | |
| 194 | for (let x = 0; x < w; x++) { | |
| 195 | const o = (y * w + x) * 4; | |
| 196 | const block = ((x >> 5) + (y >> 5)) % 3; | |
| 197 | rgba[o] = (hue + block * 60 + (y >> 3)) & 255; | |
| 198 | rgba[o + 1] = 40 + block * 50; | |
| 199 | rgba[o + 2] = 200 - (x >> 3); | |
| 200 | rgba[o + 3] = 255; | |
| 201 | } | |
| 202 | return encodePng(w, h, rgba); | |
| 203 | }; | |
| 204 | ||
| 205 | const look = (env: string, kind: "thumb" | "desktop", takenAs: "owner" | "nobody", hue: number) => { | |
| 206 | const path = join("looks", appId, env, `${hue}-${kind}.png`); | |
| 207 | mkdirSync(join(dataDir, "looks", appId, env), { recursive: true }); | |
| 208 | writeFileSync(join(dataDir, path), screenshot(hue)); | |
| 209 | return recordLook(app.db, { appId, env, commitOid: "a".repeat(40), kind, path, takenAs }); | |
| 210 | }; | |
| 211 | ||
| 212 | beforeAll(async () => { | |
| 213 | dataDir = await mkdtemp(join(tmpdir(), "forge-ogshot-")); | |
| 214 | app = createForge({ dataDir, port: 0 }); | |
| 215 | servers = await serve(app); | |
| 216 | base = servers.url; | |
| 217 | const u = await createUser(app.db, { | |
| 218 | trusted: true, | |
| 219 | username: "alice", | |
| 220 | email: "[email protected]", | |
| 221 | password: "alice-passphrase", | |
| 222 | adminIfFirst: true, | |
| 223 | }); | |
| 224 | if (!u.ok) throw new Error(u.error); | |
| 225 | owner = u.value; | |
| 226 | const made = await createRepo(app, u.value, { name: "peek", visibility: "public" }); | |
| 227 | if (!made.ok) throw new Error(String(made.error)); | |
| 228 | const rec = createApp(app.db, { repoId: made.value.id, slug: "peek-app", access: "public" }); | |
| 229 | if (!rec.ok) throw new Error(rec.error); | |
| 230 | appId = rec.value.id; | |
| 231 | const issue = createIssue(app.db, { | |
| 232 | repoId: made.value.id, | |
| 233 | authorId: u.value.id, | |
| 234 | title: "A pull with looks", | |
| 235 | kind: "pull", | |
| 236 | }); | |
| 237 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 238 | app.db | |
| 239 | .query( | |
| 240 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) | |
| 241 | VALUES (?1, 'main', 'topic', '', '', ?2)`, | |
| 242 | ) | |
| 243 | .run(issue.value.id, made.value.id); | |
| 244 | }); | |
| 245 | ||
| 246 | afterAll(async () => { | |
| 247 | await servers.stop(); | |
| 248 | await rm(dataDir, { recursive: true, force: true }); | |
| 249 | }); | |
| 250 | ||
| 251 | const images = (svg: string) => svg.match(/<image /g)?.length ?? 0; | |
| 252 | const text = async (path: string) => (await fetch(`${base}${path}`)).text(); | |
| 253 | ||
| 254 | test("without a look the app card is the card it was", async () => { | |
| 255 | expect(images(await text("/og/app/peek-app.svg"))).toBe(0); | |
| 256 | }); | |
| 257 | ||
| 258 | test("a look taken as the owner is not on the card; one taken as nobody is, and a new look is a new card", async () => { | |
| 259 | look("production", "desktop", "owner", 1); | |
| 260 | expect(images(await text("/og/app/peek-app.svg"))).toBe(0); | |
| 261 | look("production", "desktop", "nobody", 2); | |
| 262 | const first = await fetch(`${base}/og/app/peek-app.svg`); | |
| 263 | expect(images(await first.text())).toBe(1); | |
| 264 | const etag = first.headers.get("etag")!; | |
| 265 | expect((await fetch(`${base}/og/app/peek-app.svg`, { headers: { "if-none-match": etag } })).status).toBe(304); | |
| 266 | ||
| 267 | // A retake of the same slot is a new taking, hence a new card. | |
| 268 | await Bun.sleep(5); | |
| 269 | look("production", "desktop", "nobody", 3); | |
| 270 | const second = await fetch(`${base}/og/app/peek-app.svg`); | |
| 271 | expect(second.headers.get("etag")).not.toBe(etag); | |
| 272 | await second.text(); | |
| 273 | }); | |
| 274 | ||
| 275 | test("the PNG twin carries the picture and stays small", async () => { | |
| 276 | const bare = await fetch(`${base}/og/repo/alice~peek.png`); | |
| 277 | const res = await fetch(`${base}/og/app/peek-app.png`); | |
| 278 | expect(res.status).toBe(200); | |
| 279 | const bytes = new Uint8Array(await res.arrayBuffer()); | |
| 280 | expect([...bytes.slice(0, 4)]).toEqual(PNG); | |
| 281 | expect(bytes.length).toBeLessThan(300 * 1024); | |
| 282 | // The picture's colour is in the right half of the card; the plain card's is not. | |
| 283 | expect(bytes.length).toBeGreaterThan(new Uint8Array(await bare.arrayBuffer()).length); | |
| 284 | }); | |
| 285 | ||
| 286 | test("a pull card carries before and after, and a sealed or absent look nothing", async () => { | |
| 287 | expect(images(await text("/og/pull/alice~peek~1.svg"))).toBe(0); | |
| 288 | look("pull-1", "desktop", "nobody", 4); | |
| 289 | const svg = await text("/og/pull/alice~peek~1.svg"); | |
| 290 | expect(images(svg)).toBe(2); | |
| 291 | expect(svg).toContain(">before<"); | |
| 292 | expect(svg).toContain(">after<"); | |
| 293 | app.db.query("UPDATE issue SET sealed = 1 WHERE kind = 'pull'").run(); | |
| 294 | expect((await fetch(`${base}/og/pull/alice~peek~1.svg`)).status).toBe(404); | |
| 295 | }); | |
| 296 | ||
| 297 | test("a look whose file is gone leaves the plain card", async () => { | |
| 298 | app.db.query("DELETE FROM look").run(); | |
| 299 | look("production", "desktop", "nobody", 5); | |
| 300 | rmSync(join(dataDir, "looks", appId, "production", "5-desktop.png")); | |
| 301 | expect(images(await text("/og/app/peek-app.svg"))).toBe(0); | |
| 302 | }); | |
| 303 | }); |
new above
offline