Public: anyone can read this; the crew can change it.
OG designs for the map and the audit #2030
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/454-og-designs-for-the-map-and-the-audit → main 11 commits fork 3 threads · 3 unresolved
README.md +20 −1
| @@ -738,6 +738,25 @@ takes back (`supersedes`). `/asked` shows "said again, first on …", "takes bac | ||
| 738 | 738 | social cards. They show only what an anonymous reader can open; anything |
| 739 | 739 | private, sealed or unknown is the same 404. |
| 740 | 740 | |
| 741 | Two of them carry a figure beside the words: | |
| 742 | ||
| 743 | - **Map** — `/og/map/owner~name.svg`: the repository's dependency graph as a | |
| 744 | thumbnail, laid out exactly as `/owner/name/map` lays it out (ready green, | |
| 745 | working solid accent, blocked grey, waiting/held purple, parked amber, | |
| 746 | deferred and done faint), the critical path in the accent; beside it the | |
| 747 | open count, the critical path's length and ends, and the next issue a free | |
| 748 | hand would take; along the foot `ready · working · blocked · waiting · done`. | |
| 749 | Drawn from what a signed-out reader sees: no sealed issue, no run on one. | |
| 750 | - **Audit** — `/og/audit.svg`: the policy score as a ring, the share of apps | |
| 751 | that hold (sound `contract.json` and no disagreeing probe; an app with no | |
| 752 | contract does not hold — 3 sound, one disobeying, one unread is 2 of 4, | |
| 753 | 50%), with the top three findings worst first. Public repositories' apps only, | |
| 754 | as `/audit` shows them signed out. | |
| 755 | ||
| 756 | The map page (public repositories) and `/audit` name their `.png` as | |
| 757 | `og:image` with `twitter:card=summary_large_image`; every other page keeps the | |
| 758 | forge icon. | |
| 759 | ||
| 741 | 760 | ## Develop |
| 742 | 761 | |
| 743 | 762 | sh |
| @@ -1051,7 +1070,7 @@ as kind `choose`. | ||
| 1051 | 1070 | |
| 1052 | 1071 | ### OG cards as PNG |
| 1053 | 1072 | |
| 1054 | `/og/{repo,app,issue,pull,run}/<ref>.png` is the same public card as the `.svg`, | |
| 1073 | `/og/{repo,app,issue,pull,run,map}/<ref>.png` and `/og/audit.png` are the same public cards as the `.svg`, | |
| 1055 | 1074 | rasterized (1200×630) for the link previews that refuse SVG. Same refs, same |
| 1056 | 1075 | 404s, same ETag-per-inputs (suffixed `-png`). Each PNG is rendered once and kept |
| 1057 | 1076 | under `$FORGE_APP_DATA/og-png/<sha256 of the inputs>.png`; the cache holds at |
scripts/gate.ts +1
| @@ -75,6 +75,7 @@ export const GATE: readonly string[] = [ | ||
| 75 | 75 | "test/og-card.test.ts", |
| 76 | 76 | "test/og-route.test.ts", |
| 77 | 77 | "test/og-png.test.ts", |
| 78 | "test/og-designs.test.ts", | |
| 78 | 79 | "test/router.test.ts", |
| 79 | 80 | "test/health.test.ts", |
| 80 | 81 | "test/client-address.test.ts", |
src/http/server.ts +4
| @@ -1086,11 +1086,15 @@ export function buildRouter(): Router<Handler> { | ||
| 1086 | 1086 | router.add("GET", "/og/issue/:ref.svg", ogCard("issue")); |
| 1087 | 1087 | router.add("GET", "/og/pull/:ref.svg", ogCard("pull")); |
| 1088 | 1088 | router.add("GET", "/og/run/:ref.svg", ogCard("run")); |
| 1089 | router.add("GET", "/og/map/:ref.svg", ogCard("map")); | |
| 1090 | router.add("GET", "/og/audit.svg", ogCard("audit")); | |
| 1089 | 1091 | router.add("GET", "/og/repo/:ref.png", ogCard("repo", "png")); |
| 1090 | 1092 | router.add("GET", "/og/app/:ref.png", ogCard("app", "png")); |
| 1091 | 1093 | router.add("GET", "/og/issue/:ref.png", ogCard("issue", "png")); |
| 1092 | 1094 | router.add("GET", "/og/pull/:ref.png", ogCard("pull", "png")); |
| 1093 | 1095 | router.add("GET", "/og/run/:ref.png", ogCard("run", "png")); |
| 1096 | router.add("GET", "/og/map/:ref.png", ogCard("map", "png")); | |
| 1097 | router.add("GET", "/og/audit.png", ogCard("audit", "png")); | |
| 1094 | 1098 | // The pictures of an environment, read like the repository. |
| 1095 | 1099 | router.add("GET", "/:owner/:name/looks/:env/:commit/:kind.png", lookImage); |
| 1096 | 1100 |
src/web/layout.ts +21 −5
| @@ -62,6 +62,12 @@ export interface PageOptions { | ||
| 62 | 62 | * are unchanged; only the main column lets go of `--page`. |
| 63 | 63 | */ |
| 64 | 64 | wide?: boolean; |
| 65 | /** | |
| 66 | * This page's link-preview card, a same-origin path such as | |
| 67 | * `/og/audit.png` (`pages/og.ts`). Only for a page whose card answers a | |
| 68 | * signed-out unfurler; absent, the forge's icon stands in. | |
| 69 | */ | |
| 70 | ogImage?: string; | |
| 65 | 71 | /** This page's feed, advertised in the head so a reader can find it. */ |
| 66 | 72 | feed?: { href: string; title: string }; |
| 67 | 73 | /** Breadcrumb segments rendered in the topbar, e.g. owner / repo. */ |
| @@ -595,11 +601,21 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 595 | 601 | />` |
| 596 | 602 | : null |
| 597 | 603 | } |
| 598 | <meta | |
| 599 | property="og:image" | |
| 600 | content="${ctx.url.origin}/icons/forge-512.png" | |
| 601 | /> | |
| 602 | <meta name="twitter:card" content="summary" /> | |
| 604 | ${ | |
| 605 | options.ogImage !== undefined && options.ogImage.startsWith("/og/") | |
| 606 | ? html`<meta | |
| 607 | property="og:image" | |
| 608 | content="${ctx.url.origin}${options.ogImage}" | |
| 609 | /> | |
| 610 | <meta property="og:image:width" content="1200" /> | |
| 611 | <meta property="og:image:height" content="630" /> | |
| 612 | <meta name="twitter:card" content="summary_large_image" />` | |
| 613 | : html`<meta | |
| 614 | property="og:image" | |
| 615 | content="${ctx.url.origin}/icons/forge-512.png" | |
| 616 | /> | |
| 617 | <meta name="twitter:card" content="summary" />` | |
| 618 | } | |
| 603 | 619 | <link rel="manifest" href="/manifest.webmanifest" /> |
| 604 | 620 | ${links.map( |
| 605 | 621 | (l) => |
src/web/og/audit.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** | |
| 2 | * The audit's card: the policy score as a ring, and the top findings. | |
| 3 | * | |
| 4 | * The score is the share of apps that hold: an app holds when its | |
| 5 | * `contract.json` reads sound and its doors did not disagree with its table | |
| 6 | * the last time the forge knocked on them (an app never knocked on has not | |
| 7 | * disagreed). An app with no contract does not hold — it is unread, and an | |
| 8 | * audit that leaves out what it cannot read reads as a clean bill. So 3 | |
| 9 | * sound apps of which one disobeys, plus one unread, is 2 of 4: 50%. | |
| 10 | * | |
| 11 | * Findings, worst first, three at most: an app that disobeys its table; a | |
| 12 | * contract that does not read; an app with doors open to anyone, the most | |
| 13 | * open first; and the unread apps, together on one line. | |
| 14 | * | |
| 15 | * Plain data in (`AuditRow`, read by the route as nobody), a card out. | |
| 16 | */ | |
| 17 | ||
| 18 | import type { CardInput } from "./card.ts"; | |
| 19 | import { scoreTone, type ScoreFigure } from "./score.ts"; | |
| 20 | ||
| 21 | const MAX_FINDINGS = 3; | |
| 22 | ||
| 23 | const plural = (n: number, one: string, many = `${one}s`): string => (n === 1 ? one : many); | |
| 24 | ||
| 25 | /** One app as the audit read it. */ | |
| 26 | export interface AuditRow { | |
| 27 | /** `owner/name`. */ | |
| 28 | readonly name: string; | |
| 29 | readonly standing: "sound" | "unsound" | "unread"; | |
| 30 | /** Doors the floor — anyone who can reach the app — may open. */ | |
| 31 | readonly open: number; | |
| 32 | /** Why an unsound contract does not read. */ | |
| 33 | readonly problems: readonly string[]; | |
| 34 | /** The last probe found the doors disagreeing with the table. */ | |
| 35 | readonly disobeys: boolean; | |
| 36 | } | |
| 37 | ||
| 38 | export const holds = (r: AuditRow): boolean => r.standing === "sound" && !r.disobeys; | |
| 39 | ||
| 40 | /** The findings, worst first. */ | |
| 41 | export function findings(rows: readonly AuditRow[]): string[] { | |
| 42 | const out: string[] = []; | |
| 43 | for (const r of rows) if (r.standing === "sound" && r.disobeys) out.push(`${r.name} disobeys its contract`); | |
| 44 | for (const r of rows) | |
| 45 | if (r.standing === "unsound") | |
| 46 | out.push(`${r.name}: unsound${r.problems[0] === undefined ? "" : ` — ${r.problems[0]}`}`); | |
| 47 | for (const r of rows | |
| 48 | .filter((r) => r.standing === "sound" && r.open > 0) | |
| 49 | .toSorted((a, b) => b.open - a.open || a.name.localeCompare(b.name))) | |
| 50 | out.push(`${r.name}: ${r.open} ${plural(r.open, "door")} open to anyone`); | |
| 51 | const unread = rows.filter((r) => r.standing === "unread"); | |
| 52 | if (unread.length === 1) out.push(`${unread[0]!.name} has no contract.json`); | |
| 53 | else if (unread.length > 1) out.push(`${unread.length} apps have no contract.json`); | |
| 54 | return out; | |
| 55 | } | |
| 56 | ||
| 57 | /** The audit's card from the apps as read. */ | |
| 58 | export function auditInput(rows: readonly AuditRow[]): CardInput { | |
| 59 | const of = rows.length; | |
| 60 | if (of === 0) | |
| 61 | return { | |
| 62 | kind: "audit", | |
| 63 | title: "Audit", | |
| 64 | lines: ["No public apps to audit yet.", "An app's policy shows here once it has a contract."], | |
| 65 | }; | |
| 66 | const held = rows.filter(holds).length; | |
| 67 | const sound = rows.filter((r) => r.standing === "sound").length; | |
| 68 | const unsound = rows.filter((r) => r.standing === "unsound").length; | |
| 69 | const unread = rows.filter((r) => r.standing === "unread").length; | |
| 70 | const open = rows.reduce((n, r) => n + (r.standing === "sound" ? r.open : 0), 0); | |
| 71 | const found = findings(rows); | |
| 72 | const figure: ScoreFigure = { | |
| 73 | kind: "score", | |
| 74 | held, | |
| 75 | of, | |
| 76 | caption: of === 1 ? `${held} of 1 app holds` : `${held} of ${of} apps hold`, | |
| 77 | tone: scoreTone(held, of), | |
| 78 | }; | |
| 79 | return { | |
| 80 | kind: "audit", | |
| 81 | title: "Audit", | |
| 82 | lines: found.length === 0 ? ["Every app holds its contract."] : found.slice(0, MAX_FINDINGS), | |
| 83 | stat: [ | |
| 84 | `${sound} read`, | |
| 85 | `${open} ${plural(open, "door")} open`, | |
| 86 | `${unsound} unsound`, | |
| 87 | `${unread} unread`, | |
| 88 | ].join(" · "), | |
| 89 | figure, | |
| 90 | }; | |
| 91 | } |
src/web/og/card.ts +43 −39
| @@ -3,6 +3,10 @@ | ||
| 3 | 3 | * 1200×630 SVG string in the studio's dark theme tokens. No I/O, no |
| 4 | 4 | * dependencies: the OG route and the OG designs fill this in. |
| 5 | 5 | * |
| 6 | * A card may carry a figure — the map's graph (`graph.ts`) or the audit's | |
| 7 | * score (`score.ts`) — drawn in a box on the right; the words then keep to | |
| 8 | * the left column, a little smaller, so the two never overlap. | |
| 9 | * | |
| 6 | 10 | * Every text is stripped of characters XML cannot carry, clamped to the |
| 7 | 11 | * card's width by an estimated advance (system fonts are unmeasurable here, |
| 8 | 12 | * so the estimate is deliberately wide), ended with an ellipsis, and only |
| @@ -10,18 +14,26 @@ | ||
| 10 | 14 | */ |
| 11 | 15 | |
| 12 | 16 | import { THEMES } from "../../design/themes.ts"; |
| 17 | import { graphSvg, type GraphFigure } from "./graph.ts"; | |
| 18 | import { clean, escapeXml, fit } from "./xml.ts"; | |
| 19 | import { scoreSvg, type ScoreFigure } from "./score.ts"; | |
| 13 | 20 | |
| 14 | 21 | export const CARD_WIDTH = 1200; |
| 15 | 22 | export const CARD_HEIGHT = 630; |
| 16 | 23 | |
| 17 | export const CARD_KINDS = ["repo", "issue", "pull", "run", "app", "page"] as const; | |
| 24 | export const CARD_KINDS = ["repo", "issue", "pull", "run", "app", "map", "audit", "page"] as const; | |
| 18 | 25 | export type CardKind = (typeof CARD_KINDS)[number]; |
| 19 | 26 | |
| 27 | export type Figure = GraphFigure | ScoreFigure; | |
| 28 | ||
| 29 | export { escapeXml, fit }; | |
| 30 | ||
| 20 | 31 | export interface CardInput { |
| 21 | 32 | kind: CardKind; |
| 22 | 33 | title: string; |
| 23 | 34 | lines?: readonly string[]; |
| 24 | 35 | stat?: string; |
| 36 | figure?: Figure; | |
| 25 | 37 | } |
| 26 | 38 | |
| 27 | 39 | const T = THEMES.dark.tokens; |
| @@ -30,10 +42,12 @@ const MONO = `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monos | ||
| 30 | 42 | |
| 31 | 43 | const PAD = 80; |
| 32 | 44 | const INNER = CARD_WIDTH - PAD * 2; |
| 45 | /** With a figure: the words' column, and the figure's box to its right. */ | |
| 46 | const COLUMN = 620; | |
| 47 | export const FIGURE_BOX = { x: 760, y: 150, w: 360, h: 330 } as const; | |
| 33 | 48 | const MAX_LINES = 3; |
| 34 | 49 | const TITLE_LINES = 2; |
| 35 | /** Widest average glyph advance, in em, for the sans stack (bold is wider). */ | |
| 36 | const EM = 0.62; | |
| 50 | /** Widest average glyph advance, in em, for the bold sans: wider than the regular face. */ | |
| 37 | 51 | const EM_BOLD = 0.68; |
| 38 | 52 | const EM_MONO = 0.62; |
| 39 | 53 | |
| @@ -43,37 +57,14 @@ const KIND_COLOR: Record<CardKind, string> = { | ||
| 43 | 57 | pull: T.merged, |
| 44 | 58 | run: T.primary, |
| 45 | 59 | app: T.attn, |
| 60 | map: T.accent, | |
| 61 | audit: T.success, | |
| 46 | 62 | page: T.primary, |
| 47 | 63 | }; |
| 48 | 64 | |
| 49 | /** Drop what XML 1.0 forbids (controls, lone surrogates); fold whitespace. */ | |
| 50 | function clean(s: unknown): string { | |
| 51 | return String(s ?? "") | |
| 52 | .replace(/\s+/g, " ") | |
| 53 | .replace(/[^\u0009\u000A\u000D -\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "") | |
| 54 | .trim(); | |
| 55 | } | |
| 56 | ||
| 57 | export function escapeXml(s: string): string { | |
| 58 | return s | |
| 59 | .replace(/&/g, "&") | |
| 60 | .replace(/</g, "<") | |
| 61 | .replace(/>/g, ">") | |
| 62 | .replace(/"/g, """) | |
| 63 | .replace(/'/g, "'"); | |
| 64 | } | |
| 65 | ||
| 66 | /** Clamp to `width` px at `size` px type; exported for the tests. */ | |
| 67 | export function fit(text: string, size: number, width: number, em = EM): string { | |
| 68 | const max = Math.max(1, Math.floor(width / (size * em))); | |
| 69 | const chars = Array.from(clean(text)); | |
| 70 | if (chars.length <= max) return chars.join(""); | |
| 71 | return chars.slice(0, max - 1).join("").trimEnd() + "…"; | |
| 72 | } | |
| 73 | ||
| 74 | 65 | /** Break a title into at most `n` lines of `max` chars; the last is clamped. */ |
| 75 | function wrap(text: string, size: number, n: number, em: number): string[] { | |
| 76 | const max = Math.max(1, Math.floor(INNER / (size * em))); | |
| 66 | function wrap(text: string, size: number, n: number, em: number, width = INNER): string[] { | |
| 67 | const max = Math.max(1, Math.floor(width / (size * em))); | |
| 77 | 68 | const words = clean(text).split(" ").filter(Boolean); |
| 78 | 69 | const rows: string[] = []; |
| 79 | 70 | let cur = ""; |
| @@ -98,10 +89,10 @@ function wrap(text: string, size: number, n: number, em: number): string[] { | ||
| 98 | 89 | const rest = i < words.length || rows.join(" ").length < clean(text).length; |
| 99 | 90 | if (rest && rows.length) { |
| 100 | 91 | const last = rows.length - 1; |
| 101 | rows[last] = fit(rows[last]! + "…", size, INNER, em); | |
| 92 | rows[last] = fit(rows[last]! + "…", size, width, em); | |
| 102 | 93 | if (!rows[last]!.endsWith("…")) rows[last] += "…"; |
| 103 | 94 | } |
| 104 | return rows.map((r) => fit(r, size, INNER, em)); | |
| 95 | return rows.map((r) => fit(r, size, width, em)); | |
| 105 | 96 | } |
| 106 | 97 | |
| 107 | 98 | function text( |
| @@ -115,6 +106,14 @@ function text( | ||
| 115 | 106 | return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}"${extra}>${escapeXml(body)}</text>`; |
| 116 | 107 | } |
| 117 | 108 | |
| 109 | /** The figure's SVG, or nothing for an absent or unknown one. */ | |
| 110 | function drawFigure(figure: unknown): string { | |
| 111 | const f = figure as Figure | undefined; | |
| 112 | if (f?.kind === "graph") return graphSvg(f, FIGURE_BOX); | |
| 113 | if (f?.kind === "score") return scoreSvg(f, FIGURE_BOX); | |
| 114 | return ""; | |
| 115 | } | |
| 116 | ||
| 118 | 117 | export function renderCard(input: CardInput): string { |
| 119 | 118 | const kind: CardKind = CARD_KINDS.includes(input?.kind) ? input.kind : "page"; |
| 120 | 119 | const color = KIND_COLOR[kind]; |
| @@ -135,24 +134,29 @@ export function renderCard(input: CardInput): string { | ||
| 135 | 134 | text(CARD_WIDTH - PAD, PAD + 24, 24, color, label, ` font-weight="700" text-anchor="end" letter-spacing="2"`), |
| 136 | 135 | ); |
| 137 | 136 | |
| 138 | // Title: up to two lines, 64px bold. | |
| 139 | const tsize = 64; | |
| 140 | const title = wrap(input?.title ?? "", tsize, TITLE_LINES, EM_BOLD); | |
| 137 | const figure = drawFigure(input?.figure); | |
| 138 | const column = figure ? COLUMN : INNER; | |
| 139 | if (figure) out.push(figure); | |
| 140 | ||
| 141 | // Title: up to two lines, 64px bold (56px beside a figure). | |
| 142 | const tsize = figure ? 56 : 64; | |
| 143 | const title = wrap(input?.title ?? "", tsize, TITLE_LINES, EM_BOLD, column); | |
| 141 | 144 | let y = 270; |
| 142 | 145 | for (const row of title) { |
| 143 | 146 | out.push(text(PAD, y, tsize, T.fg, row, ` font-weight="700"`)); |
| 144 | y += 78; | |
| 147 | y += tsize + 14; | |
| 145 | 148 | } |
| 146 | 149 | |
| 147 | // Supporting lines, 30px muted. | |
| 150 | // Supporting lines, 30px muted (26px beside a figure). | |
| 148 | 151 | const lines = (Array.isArray(input?.lines) ? input.lines : []) |
| 149 | 152 | .map(clean) |
| 150 | 153 | .filter(Boolean) |
| 151 | 154 | .slice(0, MAX_LINES); |
| 155 | const lsize = figure ? 26 : 30; | |
| 152 | 156 | y = Math.max(y + 10, 360); |
| 153 | 157 | for (const l of lines) { |
| 154 | out.push(text(PAD, y, 30, T["muted-fg"], fit(l, 30, INNER))); | |
| 155 | y += 44; | |
| 158 | out.push(text(PAD, y, lsize, T["muted-fg"], fit(l, lsize, column))); | |
| 159 | y += lsize + 14; | |
| 156 | 160 | } |
| 157 | 161 | |
| 158 | 162 | // Footer rule and stat. |
src/web/og/graph.ts added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | /** | |
| 2 | * The map as a thumbnail: the same layout the map page draws (`layout` in | |
| 3 | * `web/pages/map.ts` — a card one level below its deepest blocker, long | |
| 4 | * edges routed down the gutters), shrunk into a box, with the titles left | |
| 5 | * out because nobody reads 9 px type in a chat bubble. What is left is the | |
| 6 | * shape of the plan: each card filled by its word, and the critical path — | |
| 7 | * its cards and its edges — in the accent. | |
| 8 | * | |
| 9 | * ready success, outlined working accent, solid | |
| 10 | * blocked grey, filled waiting merged, outlined | |
| 11 | * held merged, dashed parked attn, outlined | |
| 12 | * deferred faint, dashed done faint, outlined | |
| 13 | * | |
| 14 | * The proportions are the page's own (a 196 × 68 card, 28 across and 44 | |
| 15 | * down between them), so the thumbnail is the page seen from far away. | |
| 16 | * Plain data in, an SVG fragment out. | |
| 17 | */ | |
| 18 | ||
| 19 | import { THEMES } from "../../design/themes.ts"; | |
| 20 | import type { Word } from "../pages/map.ts"; | |
| 21 | ||
| 22 | const T = THEMES.dark.tokens; | |
| 23 | ||
| 24 | export interface Box { | |
| 25 | readonly x: number; | |
| 26 | readonly y: number; | |
| 27 | readonly w: number; | |
| 28 | readonly h: number; | |
| 29 | } | |
| 30 | ||
| 31 | export interface GraphNode { | |
| 32 | readonly level: number; | |
| 33 | readonly slot: number; | |
| 34 | readonly word: Word; | |
| 35 | readonly critical: boolean; | |
| 36 | } | |
| 37 | ||
| 38 | /** A spot a long edge passes through: a gutter at a level, shared by `lanes` lines. */ | |
| 39 | export interface GraphVia { | |
| 40 | readonly level: number; | |
| 41 | readonly gutter: number; | |
| 42 | readonly lane: number; | |
| 43 | readonly lanes: number; | |
| 44 | } | |
| 45 | ||
| 46 | export interface GraphEdge { | |
| 47 | /** Indices into `nodes`: the blocker, then what it holds up. */ | |
| 48 | readonly from: number; | |
| 49 | readonly to: number; | |
| 50 | readonly via: readonly GraphVia[]; | |
| 51 | readonly critical: boolean; | |
| 52 | /** Either end is closed: drawn faint. */ | |
| 53 | readonly done: boolean; | |
| 54 | } | |
| 55 | ||
| 56 | export interface GraphFigure { | |
| 57 | readonly kind: "graph"; | |
| 58 | /** The widest level, in slots. */ | |
| 59 | readonly width: number; | |
| 60 | readonly levels: number; | |
| 61 | readonly nodes: readonly GraphNode[]; | |
| 62 | readonly edges: readonly GraphEdge[]; | |
| 63 | } | |
| 64 | ||
| 65 | const W = 196; | |
| 66 | const H = 68; | |
| 67 | const GX = 28; | |
| 68 | const GY = 44; | |
| 69 | const LANE = 6; | |
| 70 | /** No bigger than this, so a plan of two cards is not two slabs. */ | |
| 71 | const MAX_SCALE = 0.62; | |
| 72 | ||
| 73 | /** Two decimals, and never `NaN` into an attribute. */ | |
| 74 | export function px(n: number): string { | |
| 75 | return Number.isFinite(n) ? String(Math.round(n * 100) / 100) : "0"; | |
| 76 | } | |
| 77 | ||
| 78 | /** The scale that fits a `width` × `levels` grid in the box: the smaller ratio, capped. */ | |
| 79 | export function graphScale(width: number, levels: number, box: Box): number { | |
| 80 | const w = Math.max(1, width) * (W + GX) - GX; | |
| 81 | const h = Math.max(1, levels) * (H + GY) - GY; | |
| 82 | return Math.min(box.w / w, box.h / h, MAX_SCALE); | |
| 83 | } | |
| 84 | ||
| 85 | function look(word: Word): { fill: string; stroke: string; dash?: boolean } { | |
| 86 | switch (word) { | |
| 87 | case "ready": | |
| 88 | return { fill: T.card, stroke: T.success }; | |
| 89 | case "working": | |
| 90 | return { fill: T.accent, stroke: T.accent }; | |
| 91 | case "blocked": | |
| 92 | return { fill: T.border, stroke: T["border-strong"] }; | |
| 93 | case "waiting": | |
| 94 | return { fill: T.card, stroke: T.merged }; | |
| 95 | case "held": | |
| 96 | return { fill: T.card, stroke: T.merged, dash: true }; | |
| 97 | case "parked": | |
| 98 | return { fill: T.card, stroke: T.attn }; | |
| 99 | case "deferred": | |
| 100 | return { fill: T.bg, stroke: T["border-strong"], dash: true }; | |
| 101 | default: | |
| 102 | return { fill: T.bg, stroke: T.border }; | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | export function graphSvg(fig: GraphFigure, box: Box): string { | |
| 107 | const nodes = Array.isArray(fig.nodes) ? fig.nodes : []; | |
| 108 | if (nodes.length === 0) return ""; | |
| 109 | const s = graphScale(fig.width, fig.levels, box); | |
| 110 | const dw = (Math.max(1, fig.width) * (W + GX) - GX) * s; | |
| 111 | const dh = (Math.max(1, fig.levels) * (H + GY) - GY) * s; | |
| 112 | // Centred in the box: a short plan floats in the middle, a tall one fills it. | |
| 113 | const ox = box.x + (box.w - dw) / 2; | |
| 114 | const oy = box.y + Math.max(0, (box.h - dh) / 2); | |
| 115 | const x = (slot: number): number => ox + slot * (W + GX) * s; | |
| 116 | const y = (level: number): number => oy + level * (H + GY) * s; | |
| 117 | const gx = (v: GraphVia): number => | |
| 118 | x(v.gutter) - (GX / 2) * s + (v.lane - (v.lanes - 1) / 2) * LANE * s; | |
| 119 | const w = W * s; | |
| 120 | const h = H * s; | |
| 121 | const line = Math.max(1.5, Math.min(3, 4 * s)); | |
| 122 | ||
| 123 | const edges = (Array.isArray(fig.edges) ? fig.edges : []) | |
| 124 | // The critical path last, so it is drawn over what it crosses. | |
| 125 | .toSorted((a, b) => Number(a.critical) - Number(b.critical)) | |
| 126 | .map((e) => { | |
| 127 | const from = nodes[e.from]; | |
| 128 | const to = nodes[e.to]; | |
| 129 | if (from === undefined || to === undefined) return ""; | |
| 130 | let cx = x(from.slot) + w / 2; | |
| 131 | let cy = y(from.level) + h; | |
| 132 | let d = `M${px(cx)} ${px(cy)}`; | |
| 133 | const hop = (nx: number, ny: number): void => { | |
| 134 | const bend = (ny - cy) / 2; | |
| 135 | d += `C${px(cx)} ${px(cy + bend)} ${px(nx)} ${px(ny - bend)} ${px(nx)} ${px(ny)}`; | |
| 136 | cx = nx; | |
| 137 | cy = ny; | |
| 138 | }; | |
| 139 | for (const v of e.via) { | |
| 140 | const vx = gx(v); | |
| 141 | hop(vx, y(v.level)); | |
| 142 | cy = y(v.level) + h; | |
| 143 | d += `L${px(vx)} ${px(cy)}`; | |
| 144 | } | |
| 145 | hop(x(to.slot) + w / 2, y(to.level)); | |
| 146 | const stroke = e.critical ? T.accent : e.done ? T.border : T["border-strong"]; | |
| 147 | return `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${px(e.critical ? line * 1.6 : line)}"/>`; | |
| 148 | }) | |
| 149 | .join(""); | |
| 150 | ||
| 151 | const cards = nodes | |
| 152 | .map((n) => { | |
| 153 | const c = look(n.word); | |
| 154 | const stroke = n.critical && n.word !== "working" ? T.accent : c.stroke; | |
| 155 | const sw = n.critical ? line * 1.6 : line; | |
| 156 | const dash = c.dash && !n.critical ? ` stroke-dasharray="${px(sw * 3)} ${px(sw * 2)}"` : ""; | |
| 157 | return `<rect x="${px(x(n.slot))}" y="${px(y(n.level))}" width="${px(w)}" height="${px(h)}" rx="${px(Math.min(10, 8 * s + 1))}" fill="${c.fill}" stroke="${stroke}" stroke-width="${px(sw)}"${dash}/>`; | |
| 158 | }) | |
| 159 | .join(""); | |
| 160 | ||
| 161 | return `<g>${edges}${cards}</g>`; | |
| 162 | } |
src/web/og/map.ts added +125
| @@ -0,0 +1,125 @@ | ||
| 1 | /** | |
| 2 | * The map's card: the plan as a thumbnail, and the words counted. | |
| 3 | * | |
| 4 | * It reads what the map page reads — the graph, the ready list, the | |
| 5 | * critical path, the crew's runs, the pulls that would close an issue — | |
| 6 | * through the same functions (`web/pages/map.ts`), so the picture and the | |
| 7 | * page cannot disagree on a word. The route hands it a graph read as | |
| 8 | * nobody (`pages/og.ts`); from there on it is plain data in, a card out. | |
| 9 | */ | |
| 10 | ||
| 11 | import { criticalPath, readyNow, type Graph } from "../../core/dependencies.ts"; | |
| 12 | import { countWords, layout, wordFor, type Marks, type Word } from "../pages/map.ts"; | |
| 13 | import type { CardInput } from "./card.ts"; | |
| 14 | import type { GraphEdge, GraphFigure, GraphNode } from "./graph.ts"; | |
| 15 | ||
| 16 | /** Unlinked issues are drawn only when nothing is linked, and then this many rows. */ | |
| 17 | const ISLAND_ROWS = 4; | |
| 18 | ||
| 19 | const plural = (n: number, one: string, many = `${one}s`): string => (n === 1 ? one : many); | |
| 20 | ||
| 21 | /** | |
| 22 | * The thumbnail's data from a graph: the chains when there are any (the | |
| 23 | * unlinked issues are counted, not drawn — a grid of loose cards says | |
| 24 | * nothing at this size), otherwise the first few rows of loose ones. | |
| 25 | */ | |
| 26 | export function graphFigure( | |
| 27 | graph: Graph, | |
| 28 | words: ReadonlyMap<string, Word>, | |
| 29 | critical: readonly string[], | |
| 30 | ): GraphFigure | undefined { | |
| 31 | const placed = layout(graph); | |
| 32 | const chained = placed.islandFrom > 0; | |
| 33 | const drawn = placed.nodes.filter((n) => (chained ? !n.island : n.level < ISLAND_ROWS)); | |
| 34 | if (drawn.length === 0) return undefined; | |
| 35 | const index = new Map(drawn.map((n, i) => [n.issue.id, i])); | |
| 36 | const onPath = new Set(critical); | |
| 37 | const chain = new Set<string>(); | |
| 38 | for (let i = 0; i + 1 < critical.length; i++) chain.add(`${critical[i]}>${critical[i + 1]}`); | |
| 39 | const via = new Map(placed.routes.map((r) => [`${r.blockedById}>${r.issueId}`, r.via])); | |
| 40 | const nodes: GraphNode[] = drawn.map((n) => ({ | |
| 41 | level: n.level, | |
| 42 | slot: n.slot, | |
| 43 | word: words.get(n.issue.id) ?? "blocked", | |
| 44 | critical: onPath.has(n.issue.id), | |
| 45 | })); | |
| 46 | const edges: GraphEdge[] = []; | |
| 47 | for (const e of graph.edges) { | |
| 48 | const from = index.get(e.blockedById); | |
| 49 | const to = index.get(e.issueId); | |
| 50 | if (from === undefined || to === undefined) continue; | |
| 51 | const key = `${e.blockedById}>${e.issueId}`; | |
| 52 | edges.push({ | |
| 53 | from, | |
| 54 | to, | |
| 55 | via: (via.get(key) ?? []).map((v) => ({ | |
| 56 | level: v.level, | |
| 57 | gutter: v.gutter, | |
| 58 | lane: v.lane, | |
| 59 | lanes: v.lanes, | |
| 60 | })), | |
| 61 | critical: chain.has(key), | |
| 62 | done: nodes[from]!.word === "done" || nodes[to]!.word === "done", | |
| 63 | }); | |
| 64 | } | |
| 65 | return { | |
| 66 | kind: "graph", | |
| 67 | width: Math.max(...nodes.map((n) => n.slot)) + 1, | |
| 68 | levels: Math.max(...nodes.map((n) => n.level)) + 1, | |
| 69 | nodes, | |
| 70 | edges, | |
| 71 | }; | |
| 72 | } | |
| 73 | ||
| 74 | /** The lines under the title: how much is open, how long the chain still to go is, what is next. */ | |
| 75 | export function mapLines( | |
| 76 | open: number, | |
| 77 | path: readonly { number: number }[], | |
| 78 | next: { number: number; title: string } | undefined, | |
| 79 | ): string[] { | |
| 80 | if (open === 0) return ["Nothing open: every issue on the map is done."]; | |
| 81 | const lines = [`${open} open ${plural(open, "issue")}`]; | |
| 82 | lines.push( | |
| 83 | path.length > 1 | |
| 84 | ? `Critical path: ${path.length} issues, #${path[0]!.number} → #${path.at(-1)!.number}` | |
| 85 | : "None waits on another", | |
| 86 | ); | |
| 87 | if (next !== undefined) lines.push(`Next: #${next.number} ${next.title}`); | |
| 88 | return lines; | |
| 89 | } | |
| 90 | ||
| 91 | /** The counts along the foot: the four words that move, and what is done. */ | |
| 92 | export function mapStat(counts: Record<Word, number>): string { | |
| 93 | const parts = [ | |
| 94 | `${counts.ready} ready`, | |
| 95 | `${counts.working} working`, | |
| 96 | `${counts.blocked} blocked`, | |
| 97 | ]; | |
| 98 | const waiting = counts.waiting + counts.held; | |
| 99 | if (waiting > 0) parts.push(`${waiting} waiting`); | |
| 100 | parts.push(`${counts.done} done`); | |
| 101 | return parts.join(" · "); | |
| 102 | } | |
| 103 | ||
| 104 | /** A repository's map card, from its graph as nobody reads it and its marks. */ | |
| 105 | export function mapInput(title: string, graph: Graph, marks: Marks): CardInput { | |
| 106 | const ready = readyNow(graph); | |
| 107 | const path = criticalPath(graph); | |
| 108 | const readySet = new Set(ready.map((r) => r.issue.id)); | |
| 109 | const words = new Map(graph.issues.map((i) => [i.id, wordFor(i, readySet, marks)])); | |
| 110 | const open = graph.issues.filter((i) => i.state === "open").length; | |
| 111 | const figure = graphFigure( | |
| 112 | graph, | |
| 113 | words, | |
| 114 | path.map((i) => i.id), | |
| 115 | ); | |
| 116 | // Next is what a free hand would take: ready, and nobody on it yet. | |
| 117 | const next = ready.find((r) => words.get(r.issue.id) === "ready")?.issue; | |
| 118 | return { | |
| 119 | kind: "map", | |
| 120 | title, | |
| 121 | lines: mapLines(open, path, next), | |
| 122 | stat: mapStat(countWords([...words.values()])), | |
| 123 | ...(figure === undefined ? {} : { figure }), | |
| 124 | }; | |
| 125 | } |
src/web/og/score.ts added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | /** | |
| 2 | * A score as a ring: the share of a whole, the arc filled clockwise from | |
| 3 | * twelve o'clock, the number in the middle and what it counts underneath. | |
| 4 | * The audit's card draws its policy score with it (`audit.ts`). | |
| 5 | * | |
| 6 | * The arc is one circle with a dash as long as the share of its | |
| 7 | * circumference — no path arithmetic, so 0 and 100 are exact. | |
| 8 | */ | |
| 9 | ||
| 10 | import { THEMES } from "../../design/themes.ts"; | |
| 11 | import { escapeXml, fit } from "./xml.ts"; | |
| 12 | import { px, type Box } from "./graph.ts"; | |
| 13 | ||
| 14 | const T = THEMES.dark.tokens; | |
| 15 | ||
| 16 | export type ScoreTone = "success" | "attn" | "danger"; | |
| 17 | ||
| 18 | export interface ScoreFigure { | |
| 19 | readonly kind: "score"; | |
| 20 | /** What is counted towards the score, and out of how many. */ | |
| 21 | readonly held: number; | |
| 22 | readonly of: number; | |
| 23 | /** Under the ring: what the number counts. */ | |
| 24 | readonly caption: string; | |
| 25 | readonly tone: ScoreTone; | |
| 26 | } | |
| 27 | ||
| 28 | /** The score as a whole percent: held / of × 100, rounded; nothing to count is 0. */ | |
| 29 | export function percent(held: number, of: number): number { | |
| 30 | if (!(of > 0) || !Number.isFinite(held)) return 0; | |
| 31 | return Math.round((Math.min(Math.max(held, 0), of) / of) * 100); | |
| 32 | } | |
| 33 | ||
| 34 | /** 100% is success, half or more is attention, below half is danger. */ | |
| 35 | export function scoreTone(held: number, of: number): ScoreTone { | |
| 36 | const p = percent(held, of); | |
| 37 | return p === 100 ? "success" : p >= 50 ? "attn" : "danger"; | |
| 38 | } | |
| 39 | ||
| 40 | const STROKE = 26; | |
| 41 | const CAPTION = 24; | |
| 42 | /** Digits and "%" in the bold sans run wide: 0.8 em each keeps the number off the stroke. */ | |
| 43 | const EM_NUMBER = 0.8; | |
| 44 | ||
| 45 | export function scoreSvg(fig: ScoreFigure, box: Box): string { | |
| 46 | const r = Math.min(box.w, box.h - 60) / 2 - STROKE / 2; | |
| 47 | const cx = box.x + box.w / 2; | |
| 48 | const cy = box.y + r + STROKE / 2; | |
| 49 | const counted = fig.of > 0; | |
| 50 | const share = counted ? Math.min(Math.max(fig.held, 0), fig.of) / fig.of : 0; | |
| 51 | const circ = 2 * Math.PI * r; | |
| 52 | const label = counted ? `${percent(fig.held, fig.of)}%` : "—"; | |
| 53 | const color = T[fig.tone] ?? T.attn; | |
| 54 | // The number fills the ring's middle with a stroke's width to spare each side. | |
| 55 | const hole = 2 * r - STROKE * 3; | |
| 56 | const size = Math.min(84, Math.floor(hole / (label.length * EM_NUMBER))); | |
| 57 | return [ | |
| 58 | `<circle cx="${px(cx)}" cy="${px(cy)}" r="${px(r)}" fill="none" stroke="${T.border}" stroke-width="${STROKE}"/>`, | |
| 59 | share <= 0 | |
| 60 | ? "" | |
| 61 | : `<circle cx="${px(cx)}" cy="${px(cy)}" r="${px(r)}" fill="none" stroke="${color}" stroke-width="${STROKE}" stroke-dasharray="${px(circ * share)} ${px(circ)}" transform="rotate(-90 ${px(cx)} ${px(cy)})"/>`, | |
| 62 | `<text x="${px(cx)}" y="${px(cy + size * 0.36)}" font-size="${size}" font-weight="700" text-anchor="middle" fill="${counted ? T.fg : T["muted-fg"]}">${escapeXml(label)}</text>`, | |
| 63 | `<text x="${px(cx)}" y="${px(cy + r + STROKE / 2 + 40)}" font-size="${CAPTION}" text-anchor="middle" fill="${T["muted-fg"]}">${escapeXml(fit(fig.caption, CAPTION, box.w))}</text>`, | |
| 64 | ].join(""); | |
| 65 | } |
src/web/og/xml.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * Text into an SVG card: strip what XML cannot carry, clamp to a width by | |
| 3 | * an estimated advance, and escape. Shared by the card (`card.ts`) and the | |
| 4 | * figures drawn on it, so every string on a card goes through one door. | |
| 5 | */ | |
| 6 | ||
| 7 | /** Widest average glyph advance, in em, for the sans stack (bold is wider). */ | |
| 8 | export const EM = 0.62; | |
| 9 | ||
| 10 | /** Drop what XML 1.0 forbids (controls, lone surrogates); fold whitespace. */ | |
| 11 | export function clean(s: unknown): string { | |
| 12 | return String(s ?? "") | |
| 13 | .replace(/\s+/g, " ") | |
| 14 | .replace(/[^\u0009\u000A\u000D -\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/gu, "") | |
| 15 | .trim(); | |
| 16 | } | |
| 17 | ||
| 18 | export function escapeXml(s: string): string { | |
| 19 | return s | |
| 20 | .replace(/&/g, "&") | |
| 21 | .replace(/</g, "<") | |
| 22 | .replace(/>/g, ">") | |
| 23 | .replace(/"/g, """) | |
| 24 | .replace(/'/g, "'"); | |
| 25 | } | |
| 26 | ||
| 27 | /** Clamp to `width` px at `size` px type; exported for the tests. */ | |
| 28 | export function fit(text: string, size: number, width: number, em = EM): string { | |
| 29 | const max = Math.max(1, Math.floor(width / (size * em))); | |
| 30 | const chars = Array.from(clean(text)); | |
| 31 | if (chars.length <= max) return chars.join(""); | |
| 32 | return chars.slice(0, max - 1).join("").trimEnd() + "…"; | |
| 33 | } |
src/web/pages/audit.ts +1
| @@ -89,6 +89,7 @@ export async function auditPage(ctx: Ctx): Promise<Response> { | ||
| 89 | 89 | title: "Audit", |
| 90 | 90 | description: |
| 91 | 91 | "Who can do what, across every app, read from each one's contract.", |
| 92 | ogImage: "/og/audit.png", | |
| 92 | 93 | body: html`<div class="page-head"> |
| 93 | 94 | <h1>Audit</h1> |
| 94 | 95 | <span class="spacer"></span> |
src/web/pages/map.ts +4
| @@ -1495,6 +1495,10 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1495 | 1495 | ctx, |
| 1496 | 1496 | title: `Map · ${repo.name}`, |
| 1497 | 1497 | crumbs: crumbsFor(repo), |
| 1498 | // The card shows what a signed-out reader sees: only a public repo has one. | |
| 1499 | ...(repo.visibility === "public" | |
| 1500 | ? { ogImage: `/og/map/${repo.ownerName}~${repo.name}.png` } | |
Nit: this names the card whenever |
||
| 1501 | : {}), | |
| 1498 | 1502 | palette: repoPalette(ctx, repo), |
| 1499 | 1503 | body, |
| 1500 | 1504 | styleCss: mapCss, |
src/web/pages/og.ts +70 −11
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | /** |
| 2 | * The social card on the web: GET /og/{repo,app,issue,pull,run}/:ref.svg. | |
| 2 | * The social card on the web: GET /og/{repo,app,issue,pull,run,map}/:ref.svg | |
| 3 | * and GET /og/audit.svg. | |
| 3 | 4 | * Public by design — it is what a link preview fetches without a session — |
| 4 | 5 | * so it says only what an anonymous reader may already see: a public repo |
| 5 | 6 | * (`owner~name`), the public app of one (its slug), or an issue, pull or run |
| 6 | 7 | * of a public repo (`owner~name~number`, `owner~name~runid`) that an |
| 7 | * anonymous reader could open (not sealed, runs readable). Anything else, or a | |
| 8 | * anonymous reader could open (not sealed, runs readable); a public repo's | |
| 9 | * map (`owner~name`) drawn from only the issues and runs it may see; or the | |
| 10 | * audit of the public apps. Anything else, or a | |
| 8 | 11 | * ref that is not shaped like one, is the same 404. The ETag is the sha256 |
| 9 | 12 | * of the card's inputs, so a changed repo is a new card and an unchanged |
| 10 | 13 | * one is a 304. The `.png` twin is the same card rasterized once per input |
| @@ -16,22 +19,29 @@ import { createHash } from "node:crypto"; | ||
| 16 | 19 | import { join } from "node:path"; |
| 17 | 20 | import type { App } from "../../app.ts"; |
| 18 | 21 | import { getRepoRun } from "../../actions/runs.ts"; |
| 19 | import { getAppBySlug, isSlug } from "../../core/apps.ts"; | |
| 22 | import { appsForRepos, getAppBySlug, isSlug } from "../../core/apps.ts"; | |
| 23 | import { audit } from "../../contract/audit.ts"; | |
| 24 | import { latestProbes } from "../../contract/probes.ts"; | |
| 25 | import { listActiveRuns } from "../../core/runs.ts"; | |
| 20 | 26 | import { |
| 21 | 27 | canRead, |
| 22 | 28 | countsForRepos, |
| 23 | 29 | getRepo, |
| 24 | 30 | getRepoById, |
| 31 | listReposVisibleTo, | |
| 25 | 32 | type Repo, |
| 26 | 33 | } from "../../core/repos.ts"; |
| 27 | 34 | import { dependenciesOf, repoGraph, readyNow } from "../../core/dependencies.ts"; |
| 28 | 35 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 29 | 36 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| 30 | import { mayView } from "../../core/sealed.ts"; | |
| 37 | import { mayView, mayViewId, sealedScope } from "../../core/sealed.ts"; | |
| 31 | 38 | import { isSafeName } from "../../git/guard.ts"; |
| 32 | 39 | import type { Ctx } from "../../http/context.ts"; |
| 33 | 40 | import { notFound } from "../../http/respond.ts"; |
| 41 | import { auditInput, type AuditRow } from "../og/audit.ts"; | |
| 34 | 42 | import { renderCard, type CardInput } from "../og/card.ts"; |
| 43 | import { mapInput } from "../og/map.ts"; | |
| 44 | import { marksFor } from "./map.ts"; | |
| 35 | 45 | import { Busy, pngCache, type PngCache } from "../og/png-cache.ts"; |
| 36 | 46 | import { rasterizer } from "../og/raster.ts"; |
| 37 | 47 | |
| @@ -93,7 +103,10 @@ function waiting(ctx: Ctx, repo: Repo, issue: Issue): string { | ||
| 93 | 103 | return `waits on ${shown.join(", ")}${more > 0 ? ` +${more}` : ""}`; |
| 94 | 104 | } |
| 95 | 105 | |
| 96 | export type OgKind = "repo" | "app" | "issue" | "pull" | "run"; | |
| 106 | export type OgKind = "repo" | "app" | "issue" | "pull" | "run" | "map" | "audit"; | |
| 107 | ||
| 108 | /** The audit page's own bound. */ | |
| 109 | const AUDIT_LIMIT = 200; | |
| 97 | 110 | |
| 98 | 111 | const RUN_ID = /^[0-9a-z]{1,40}$/; |
| 99 | 112 | |
| @@ -146,15 +159,61 @@ function runInput(ctx: Ctx, ref: string): CardInput | undefined { | ||
| 146 | 159 | }; |
| 147 | 160 | } |
| 148 | 161 | |
| 149 | function resolve(ctx: Ctx, kind: OgKind, ref: string): CardInput | undefined { | |
| 162 | /** `owner~name` → its public repo, or nothing. */ | |
| 163 | function twoPart(ctx: Ctx, ref: string): Repo | undefined { | |
| 164 | const [owner, name, extra] = ref.split("~"); | |
| 165 | if (extra !== undefined || !owner || !name) return undefined; | |
| 166 | if (!isSafeName(owner) || !isSafeName(name)) return undefined; | |
| 167 | return publicRepo(ctx, getRepo(ctx.app.db, owner, name)); | |
| 168 | } | |
| 169 | ||
| 170 | /** The map as the map page draws it for someone signed out. */ | |
| 171 | function mapCardInput(ctx: Ctx, ref: string): CardInput | undefined { | |
| 172 | const repo = twoPart(ctx, ref); | |
| 173 | if (!repo) return undefined; | |
| 174 | const db = ctx.app.db; | |
| 175 | const graph = repoGraph(db, repo.id, sealedScope(db, repo, undefined)); | |
| 176 | // A run on a sealed report is not shown to who may not see the report. | |
| 177 | const runs = listActiveRuns(db, [repo.id]).filter((r) => | |
| 178 | mayViewId(db, repo, r.issueId, undefined), | |
| 179 | ); | |
| 180 | return mapInput(`${repo.ownerName}/${repo.name}`, graph, marksFor(db, repo.id, graph, runs)); | |
| 181 | } | |
| 182 | ||
| 183 | /** The public apps, read the way the audit page reads them for someone signed out. */ | |
| 184 | async function auditCardInput(ctx: Ctx): Promise<CardInput> { | |
| 185 | const db = ctx.app.db; | |
| 186 | const repos = listReposVisibleTo(db, undefined, { limit: AUDIT_LIMIT }); | |
| 187 | const records = appsForRepos( | |
| 188 | db, | |
| 189 | repos.map((r) => r.id), | |
| 190 | ); | |
| 191 | const apps = repos.filter((r) => records.has(r.id)); | |
| 192 | const audited = await Promise.all(apps.map((repo) => audit(ctx.app.reposRoot, repo))); | |
Low (cost): every request to |
||
| 193 | const appId = (repoId: string): string => records.get(repoId)!.id; | |
| 194 | const probed = latestProbes( | |
| 195 | db, | |
| 196 | audited.filter((a) => a.standing.kind === "sound").map((a) => appId(a.repo.id)), | |
| 197 | ); | |
| 198 | const rows: AuditRow[] = audited.map((a) => ({ | |
| 199 | name: `${a.repo.ownerName}/${a.repo.name}`, | |
| 200 | standing: a.standing.kind, | |
| 201 | open: a.standing.kind === "sound" ? a.standing.open.length : 0, | |
| 202 | problems: a.standing.kind === "unsound" ? a.standing.problems : [], | |
| 203 | disobeys: | |
| 204 | a.standing.kind === "sound" && probed.get(appId(a.repo.id))?.verdict === "mismatch", | |
| 205 | })); | |
| 206 | return auditInput(rows); | |
| 207 | } | |
| 208 | ||
| 209 | async function resolve(ctx: Ctx, kind: OgKind, ref: string): Promise<CardInput | undefined> { | |
| 150 | 210 | const db = ctx.app.db; |
| 151 | 211 | if (kind === "issue" || kind === "pull") return threadInput(ctx, kind, ref); |
| 152 | 212 | if (kind === "run") return runInput(ctx, ref); |
| 213 | if (kind === "map") return mapCardInput(ctx, ref); | |
| 214 | if (kind === "audit") return ref === "" ? auditCardInput(ctx) : undefined; | |
| 153 | 215 | if (kind === "repo") { |
| 154 | const [owner, name, extra] = ref.split("~"); | |
| 155 | if (extra !== undefined || !owner || !name) return undefined; | |
| 156 | if (!isSafeName(owner) || !isSafeName(name)) return undefined; | |
| 157 | const repo = publicRepo(ctx, getRepo(db, owner, name)); | |
| 216 | const repo = twoPart(ctx, ref); | |
| 158 | 217 | return repo && repoInput(ctx, repo, "repo"); |
| 159 | 218 | } |
| 160 | 219 | if (!isSlug(ref)) return undefined; |
| @@ -181,7 +240,7 @@ function cacheFor(app: App): PngCache { | ||
| 181 | 240 | |
| 182 | 241 | export function ogCard(kind: OgKind, format: "svg" | "png" = "svg") { |
| 183 | 242 | return async (ctx: Ctx): Promise<Response> => { |
| 184 | const input = resolve(ctx, kind, ctx.params.ref ?? ""); | |
| 243 | const input = await resolve(ctx, kind, ctx.params.ref ?? ""); | |
| 185 | 244 | if (!input) return notFound(); |
| 186 | 245 | const etag = `"${createHash("sha256").update(JSON.stringify(input)).digest("hex")}${format === "png" ? "-png" : ""}"`; |
| 187 | 246 | const headers = { |
test/og-designs.test.ts added +353
| @@ -0,0 +1,353 @@ | ||
| 1 | /** | |
| 2 | * The map's and the audit's link-preview cards (src/web/og/map.ts, | |
| 3 | * audit.ts, graph.ts, score.ts): the thumbnail's scale and the policy | |
| 4 | * score match a hand calculation, the words and the critical path are the | |
| 5 | * map page's own, the findings come worst first — and over HTTP, a signed-out | |
| 6 | * unfurler is served only what a signed-out reader sees: no sealed issue | |
| 7 | * in a public plan, no private repository's plan or policy. | |
| 8 | */ | |
| 9 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 10 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 11 | import { tmpdir } from "node:os"; | |
| 12 | import { join } from "node:path"; | |
| 13 | import { createApp as createForge, type App } from "../src/app.ts"; | |
| 14 | import { createRepo } from "../src/actions/repos.ts"; | |
| 15 | import { createApp } from "../src/core/apps.ts"; | |
| 16 | import { addDependency, type Graph } from "../src/core/dependencies.ts"; | |
| 17 | import { createIssue, type Issue } from "../src/core/issues.ts"; | |
| 18 | import { getRepo } from "../src/core/repos.ts"; | |
| 19 | import { createUser } from "../src/core/users.ts"; | |
| 20 | import { THEMES } from "../src/design/themes.ts"; | |
| 21 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 22 | import { NO_MARKS, type Marks } from "../src/web/pages/map.ts"; | |
| 23 | import { auditInput, findings, type AuditRow } from "../src/web/og/audit.ts"; | |
| 24 | import { FIGURE_BOX, renderCard } from "../src/web/og/card.ts"; | |
| 25 | import { graphScale, graphSvg } from "../src/web/og/graph.ts"; | |
| 26 | import { graphFigure, mapInput, mapLines, mapStat } from "../src/web/og/map.ts"; | |
| 27 | import { percent, scoreSvg, scoreTone } from "../src/web/og/score.ts"; | |
| 28 | ||
| 29 | const T = THEMES.dark.tokens; | |
| 30 | ||
| 31 | const issue = (n: number, state: "open" | "closed" = "open"): Issue => | |
| 32 | ({ | |
| 33 | id: `i${n}`, | |
| 34 | repoId: "r", | |
| 35 | number: n, | |
| 36 | title: `Issue ${n}`, | |
| 37 | state, | |
| 38 | kind: "issue", | |
| 39 | createdAt: `2026-01-${String(n).padStart(2, "0")}T00:00:00.000Z`, | |
| 40 | }) as unknown as Issue; | |
| 41 | const edge = (blocker: number, blocked: number) => ({ | |
| 42 | issueId: `i${blocked}`, | |
| 43 | blockedById: `i${blocker}`, | |
| 44 | }); | |
| 45 | const graphOf = (issues: Issue[], edges: Graph["edges"]): Graph => ({ | |
| 46 | issues, | |
| 47 | edges, | |
| 48 | held: [], | |
| 49 | unadopted: new Set(), | |
| 50 | structural: new Set(), | |
| 51 | deferred: new Map(), | |
| 52 | }); | |
| 53 | ||
| 54 | function wellFormed(svg: string): void { | |
| 55 | expect(svg.startsWith("<svg")).toBe(true); | |
| 56 | expect(svg.endsWith("</svg>")).toBe(true); | |
| 57 | expect(svg).not.toContain("NaN"); | |
| 58 | expect(svg).not.toContain("undefined"); | |
| 59 | } | |
| 60 | ||
| 61 | describe("the map's thumbnail", () => { | |
| 62 | test("its scale is the smaller of the two fits, capped", () => { | |
| 63 | // 4 slots × 5 levels at the page's proportions (196 × 68, gaps 28 / 44): | |
| 64 | // 4·224 − 28 = 868 wide, 5·112 − 44 = 516 tall. The box is 360 × 330: | |
| 65 | // 360 / 868 = 0.41475…, 330 / 516 = 0.63953… — the width decides. | |
| 66 | expect(FIGURE_BOX).toEqual({ x: 760, y: 150, w: 360, h: 330 }); | |
| 67 | expect(graphScale(4, 5, FIGURE_BOX)).toBeCloseTo(360 / 868, 9); | |
| 68 | // 1 × 9: 196 wide, 9·112 − 44 = 964 tall; 330 / 964 = 0.34232 decides. | |
| 69 | expect(graphScale(1, 9, FIGURE_BOX)).toBeCloseTo(330 / 964, 9); | |
| 70 | // One card would be 1.8× the page; it is held to 0.62. | |
| 71 | expect(graphScale(1, 1, FIGURE_BOX)).toBe(0.62); | |
| 72 | }); | |
| 73 | ||
| 74 | // 1 → 3 → 5, 2 (closed) → 3, 4 → 5, and 6 on no edge. | |
| 75 | const graph = graphOf( | |
| 76 | [issue(1), issue(2, "closed"), issue(3), issue(4), issue(5), issue(6)], | |
| 77 | [edge(1, 3), edge(2, 3), edge(3, 5), edge(4, 5)], | |
| 78 | ); | |
| 79 | const marks: Marks = { | |
| 80 | ...NO_MARKS, | |
| 81 | crew: new Map([["i1", "running"]]), | |
| 82 | waiting: new Set(["i4"]), | |
| 83 | }; | |
| 84 | ||
| 85 | test("the card counts the page's words and draws the chain in the accent", () => { | |
| 86 | const card = mapInput("alice/engine", graph, marks); | |
| 87 | expect(card.kind).toBe("map"); | |
| 88 | expect(card.title).toBe("alice/engine"); | |
| 89 | // 1 working; 4 waiting on a pull; 6 ready (on no edge); 3 and 5 blocked; 2 done. | |
| 90 | expect(card.stat).toBe("1 ready · 1 working · 2 blocked · 1 waiting · 1 done"); | |
| 91 | // Five open; the longest open chain is 1 → 3 → 5. Next skips 1, which | |
| 92 | // the crew is on, and 4, which a pull is for: 6 is what a free hand takes. | |
| 93 | expect(card.lines).toEqual(["5 open issues", "Critical path: 3 issues, #1 → #5", "Next: #6 Issue 6"]); | |
| 94 | const fig = card.figure; | |
| 95 | if (fig?.kind !== "graph") throw new Error("no graph"); | |
| 96 | // Only the chains are drawn: 6, on no edge, is counted, not drawn. | |
| 97 | expect(fig.nodes.length).toBe(5); | |
| 98 | expect(fig.levels).toBe(3); | |
| 99 | expect(fig.nodes.filter((n) => n.critical).length).toBe(3); | |
| 100 | expect(fig.edges.filter((e) => e.critical).length).toBe(2); | |
| 101 | expect(fig.edges.filter((e) => e.done).length).toBe(1); | |
| 102 | expect(fig.nodes.map((n) => n.word).toSorted()).toEqual(["blocked", "blocked", "done", "waiting", "working"]); | |
| 103 | const svg = renderCard(card); | |
| 104 | wellFormed(svg); | |
| 105 | // Five cards in the figure, the two critical edges in the accent. | |
| 106 | const figure = svg.slice(svg.indexOf("<g>"), svg.indexOf("</g>")); | |
| 107 | expect(figure.match(/<rect /g)?.length).toBe(5); | |
| 108 | expect(figure.match(new RegExp(`<path [^>]*stroke="${T.accent}"`, "g"))?.length).toBe(2); | |
| 109 | expect(svg).toContain(">MAP<"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("with nothing linked, the loose issues are drawn, four rows at most", () => { | |
| 113 | const loose = graphOf( | |
| 114 | Array.from({ length: 40 }, (_, i) => issue(i + 1)), | |
| 115 | [], | |
| 116 | ); | |
| 117 | const words = new Map(loose.issues.map((i) => [i.id, "ready" as const])); | |
| 118 | const fig = graphFigure(loose, words, [])!; | |
| 119 | expect(fig.levels).toBe(4); | |
| 120 | expect(fig.nodes.length).toBe(4 * fig.width); | |
| 121 | expect(fig.nodes.length).toBeLessThan(40); | |
| 122 | expect(graphFigure(graphOf([], []), new Map(), [])).toBeUndefined(); | |
| 123 | expect(graphSvg({ kind: "graph", width: 0, levels: 0, nodes: [], edges: [] }, FIGURE_BOX)).toBe(""); | |
| 124 | }); | |
| 125 | ||
| 126 | test("the lines say what is open, the chain still to go, and what is next", () => { | |
| 127 | expect(mapLines(0, [], undefined)).toEqual(["Nothing open: every issue on the map is done."]); | |
| 128 | expect(mapLines(1, [], { number: 4, title: "Ship it" })).toEqual([ | |
| 129 | "1 open issue", | |
| 130 | "None waits on another", | |
| 131 | "Next: #4 Ship it", | |
| 132 | ]); | |
| 133 | const empty = mapInput("alice/empty", graphOf([], []), NO_MARKS); | |
| 134 | expect(empty.figure).toBeUndefined(); | |
| 135 | expect(empty.stat).toBe("0 ready · 0 working · 0 blocked · 0 done"); | |
| 136 | wellFormed(renderCard(empty)); | |
| 137 | }); | |
| 138 | ||
| 139 | test("a held pull counts as waiting; deferred and held cards are dashed", () => { | |
| 140 | expect( | |
| 141 | mapStat({ ready: 2, working: 0, blocked: 1, waiting: 1, held: 2, parked: 0, deferred: 0, done: 3 }), | |
| 142 | ).toBe("2 ready · 0 working · 1 blocked · 3 waiting · 3 done"); | |
| 143 | const svg = graphSvg( | |
| 144 | { | |
| 145 | kind: "graph", | |
| 146 | width: 2, | |
| 147 | levels: 1, | |
| 148 | nodes: [ | |
| 149 | { level: 0, slot: 0, word: "held", critical: false }, | |
| 150 | { level: 0, slot: 1, word: "deferred", critical: false }, | |
| 151 | ], | |
| 152 | edges: [], | |
| 153 | }, | |
| 154 | FIGURE_BOX, | |
| 155 | ); | |
| 156 | expect(svg.match(/stroke-dasharray/g)?.length).toBe(2); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a title can not break out of the card", () => { | |
| 160 | const svg = renderCard(mapInput(`</text><script>x</script>`, graph, marks)); | |
| 161 | expect(svg).not.toContain("<script>"); | |
| 162 | expect(svg).toContain("</text>"); | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | describe("the audit's score", () => { | |
| 167 | test("percent is held / of, rounded, and nothing to count is 0", () => { | |
| 168 | expect(percent(2, 4)).toBe(50); | |
| 169 | expect(percent(1, 3)).toBe(33); // 33.33… | |
| 170 | expect(percent(2, 3)).toBe(67); // 66.67… | |
| 171 | expect(percent(5, 4)).toBe(100); | |
| 172 | expect(percent(-1, 4)).toBe(0); | |
| 173 | expect(percent(0, 0)).toBe(0); | |
| 174 | expect(scoreTone(4, 4)).toBe("success"); | |
| 175 | expect(scoreTone(1, 2)).toBe("attn"); | |
| 176 | expect(scoreTone(1, 3)).toBe("danger"); | |
| 177 | }); | |
| 178 | ||
| 179 | test("the ring's arc is the share of its circumference", () => { | |
| 180 | const svg = scoreSvg({ kind: "score", held: 1, of: 4, caption: "c", tone: "danger" }, FIGURE_BOX); | |
| 181 | // r = min(360, 330 − 60) / 2 − 26 / 2 = 135 − 13 = 122; 2π·122 = 766.55… | |
| 182 | const circ = 2 * Math.PI * 122; | |
| 183 | expect(svg).toContain(`r="122"`); | |
| 184 | expect(svg).toContain(`stroke-dasharray="${Math.round((circ / 4) * 100) / 100} ${Math.round(circ * 100) / 100}"`); | |
| 185 | expect(svg).toContain(">25%<"); | |
| 186 | expect(svg).toContain(`stroke="${T.danger}"`); | |
| 187 | // Nothing held: no arc at all, only the track. | |
| 188 | const none = scoreSvg({ kind: "score", held: 0, of: 4, caption: "c", tone: "danger" }, FIGURE_BOX); | |
| 189 | expect(none.match(/<circle/g)?.length).toBe(1); | |
| 190 | }); | |
| 191 | }); | |
| 192 | ||
| 193 | describe("the audit's findings", () => { | |
| 194 | const row = (name: string, over: Partial<AuditRow> = {}): AuditRow => ({ | |
| 195 | name, | |
| 196 | standing: "sound", | |
| 197 | open: 0, | |
| 198 | problems: [], | |
| 199 | disobeys: false, | |
| 200 | ...over, | |
| 201 | }); | |
| 202 | ||
| 203 | test("worst first: disobeys, unsound, most open, then the unread together", () => { | |
| 204 | const rows = [ | |
| 205 | row("a/few", { open: 1 }), | |
| 206 | row("a/none", { standing: "unread" }), | |
| 207 | row("a/many", { open: 3 }), | |
| 208 | row("a/broken", { standing: "unsound", problems: ["roles is not a list"] }), | |
| 209 | row("a/liar", { disobeys: true }), | |
| 210 | row("a/none2", { standing: "unread" }), | |
| 211 | ]; | |
| 212 | expect(findings(rows)).toEqual([ | |
| 213 | "a/liar disobeys its contract", | |
| 214 | "a/broken: unsound — roles is not a list", | |
| 215 | "a/many: 3 doors open to anyone", | |
| 216 | "a/few: 1 door open to anyone", | |
| 217 | "2 apps have no contract.json", | |
| 218 | ]); | |
| 219 | }); | |
| 220 | ||
| 221 | test("the score holds only sound apps that obey: 3 sound, one disobeying, one unread is 2 of 4", () => { | |
| 222 | const card = auditInput([ | |
| 223 | row("a/one"), | |
| 224 | row("a/two", { open: 2 }), | |
| 225 | row("a/three", { disobeys: true }), | |
| 226 | row("a/four", { standing: "unread" }), | |
| 227 | ]); | |
| 228 | expect(card.kind).toBe("audit"); | |
| 229 | expect(card.figure).toEqual({ kind: "score", held: 2, of: 4, caption: "2 of 4 apps hold", tone: "attn" }); | |
| 230 | // Three findings at most: the disobeying app, the open doors, the unread one. | |
| 231 | expect(card.lines).toEqual([ | |
| 232 | "a/three disobeys its contract", | |
| 233 | "a/two: 2 doors open to anyone", | |
| 234 | "a/four has no contract.json", | |
| 235 | ]); | |
| 236 | expect(card.stat).toBe("3 read · 2 doors open · 0 unsound · 1 unread"); | |
| 237 | const svg = renderCard(card); | |
| 238 | wellFormed(svg); | |
| 239 | expect(svg).toContain(">50%<"); | |
| 240 | expect(svg).toContain(">AUDIT<"); | |
| 241 | }); | |
| 242 | ||
| 243 | test("every app holding says so; no apps draws no ring", () => { | |
| 244 | expect(auditInput([row("a/one")]).lines).toEqual(["Every app holds its contract."]); | |
| 245 | expect(auditInput([row("a/one")]).figure).toMatchObject({ caption: "1 of 1 app holds", tone: "success" }); | |
| 246 | const empty = auditInput([]); | |
| 247 | expect(empty.figure).toBeUndefined(); | |
| 248 | expect(renderCard(empty)).not.toContain("<circle"); | |
| 249 | }); | |
| 250 | }); | |
| 251 | ||
| 252 | describe("over HTTP", () => { | |
| 253 | let dataDir: string; | |
| 254 | let app: App; | |
| 255 | let servers: Servers; | |
| 256 | let base: string; | |
| 257 | ||
| 258 | beforeAll(async () => { | |
| 259 | dataDir = await mkdtemp(join(tmpdir(), "forge-og-designs-")); | |
| 260 | app = createForge({ dataDir, port: 0 }); | |
| 261 | servers = await serve(app); | |
| 262 | base = servers.url; | |
| 263 | const u = await createUser(app.db, { | |
| 264 | trusted: true, | |
| 265 | username: "alice", | |
| 266 | email: "[email protected]", | |
| 267 | password: "alice-passphrase", | |
| 268 | adminIfFirst: true, | |
| 269 | }); | |
| 270 | if (!u.ok) throw new Error(u.error); | |
| 271 | for (const [name, visibility, access] of [ | |
| 272 | ["shown", "public", "public"], | |
| 273 | ["hidden", "private", "public"], | |
| 274 | ["quiet", "public", "private"], | |
| 275 | ] as const) { | |
| 276 | const made = await createRepo(app, u.value, { name, visibility }); | |
| 277 | if (!made.ok) throw new Error(String(made.error)); | |
| 278 | const rec = createApp(app.db, { repoId: made.value.id, slug: `${name}-app`, access }); | |
| 279 | if (!rec.ok) throw new Error(rec.error); | |
| 280 | } | |
| 281 | const mk = (repo: string, title: string, sealed = false): Issue => { | |
| 282 | const r = getRepo(app.db, "alice", repo)!; | |
| 283 | const i = createIssue(app.db, { repoId: r.id, authorId: u.value.id, title, kind: "issue", sealed }); | |
| 284 | if (!i.ok) throw new Error(String(i.error)); | |
| 285 | return i.value; | |
| 286 | }; | |
| 287 | // shown: #1 → #2, and #3, sealed, waits on #2 too. | |
| 288 | const one = mk("shown", "First"); | |
| 289 | const two = mk("shown", "Second"); | |
| 290 | const three = mk("shown", "A sealed report", true); | |
| 291 | addDependency(app.db, two, one, u.value.id); | |
| 292 | addDependency(app.db, three, two, u.value.id); | |
| 293 | const h1 = mk("hidden", "Private one"); | |
| 294 | const h2 = mk("hidden", "Private two"); | |
| 295 | addDependency(app.db, h2, h1, u.value.id); | |
| 296 | }); | |
| 297 | ||
| 298 | afterAll(async () => { | |
| 299 | await servers.stop(); | |
| 300 | await rm(dataDir, { recursive: true, force: true }); | |
| 301 | }); | |
| 302 | ||
| 303 | const get = (path: string, headers: Record<string, string> = {}) => fetch(base + path, { headers }); | |
| 304 | ||
| 305 | test("a public repo's map is a card of only what a signed-out reader sees", async () => { | |
| 306 | const res = await get("/og/map/alice~shown.svg"); | |
| 307 | expect(res.status).toBe(200); | |
| 308 | expect(res.headers.get("content-type")).toContain("image/svg+xml"); | |
| 309 | expect(res.headers.get("cache-control")).toBe("public, max-age=300"); | |
| 310 | expect(res.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 311 | expect(res.headers.get("etag")).toMatch(/^"[0-9a-f]{64}"$/); | |
| 312 | const svg = await res.text(); | |
| 313 | wellFormed(svg); | |
| 314 | // The sealed #3 is neither counted nor drawn: two open, two cards. | |
| 315 | expect(svg).toContain(">2 open issues<"); | |
| 316 | expect(svg).toContain("1 ready · 0 working · 1 blocked · 0 done"); | |
| 317 | expect(svg).not.toContain("sealed"); | |
| 318 | const figure = svg.slice(svg.indexOf("<g>"), svg.indexOf("</g>")); | |
| 319 | expect(figure.match(/<rect /g)?.length).toBe(2); | |
| 320 | const again = await get("/og/map/alice~shown.svg", { "if-none-match": res.headers.get("etag")! }); | |
| 321 | expect(again.status).toBe(304); | |
| 322 | }); | |
| 323 | ||
| 324 | test("a private repo's map, an unknown one and a malformed ref are the same 404", async () => { | |
| 325 | for (const ref of ["alice~hidden", "alice~nothing", "alice", "a~b~c", "..%2Fx"]) | |
| 326 | expect((await get(`/og/map/${ref}.svg`)).status).toBe(404); | |
| 327 | }); | |
| 328 | ||
| 329 | test("the audit reads the apps of public repos only, as the page does for someone signed out", async () => { | |
| 330 | const res = await get("/og/audit.svg"); | |
| 331 | expect(res.status).toBe(200); | |
| 332 | const svg = await res.text(); | |
| 333 | wellFormed(svg); | |
| 334 | // shown and quiet are public repos (a contract.json is as public as its | |
| 335 | // repo, whoever may reach the app); hidden's repo is private. Neither has | |
| 336 | // a contract, so 0 of 2 hold. | |
| 337 | expect(svg).toContain("2 apps have no contract.json"); | |
| 338 | expect(svg).toContain(">0%<"); | |
| 339 | expect(svg).toContain("0 of 2 apps hold"); | |
| 340 | expect(svg).not.toContain("hidden"); | |
| 341 | }); | |
| 342 | ||
| 343 | test("the map page names its card only when the repo is public; the audit page always", async () => { | |
| 344 | const page = await (await get("/alice/shown/map")).text(); | |
| 345 | expect(page).toContain(`content="${base}/og/map/alice~shown.png"`); | |
| 346 | expect(page).toContain(`content="summary_large_image"`); | |
| 347 | const audit = await (await get("/audit")).text(); | |
| 348 | expect(audit).toContain(`content="${base}/og/audit.png"`); | |
| 349 | const home = await (await get("/")).text(); | |
| 350 | expect(home).toContain("/icons/forge-512.png"); | |
| 351 | expect(home).not.toContain("summary_large_image"); | |
| 352 | }); | |
| 353 | }); |
EMis imported but never used; it only appears in the comment onEM_BOLD. Import{ clean, escapeXml, fit }only.