Public: anyone can read this; the crew can change it.
/:owner/:name/waves lists a repository's waves newest first, each line linking to its file #1879
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1855-owner-name-waves-lists-a-repository-s-wa → main 6 commits fork 5 threads · 5 unresolved
README.md +3
| @@ -914,6 +914,9 @@ work here. | ||
| 914 | 914 | not already in an earlier wave. The headline and highlights are `TODO` |
| 915 | 915 | placeholders; no model is called. It refuses, writing nothing, when an |
| 916 | 916 | earlier wave does not parse (naming the file) or nothing new has merged. |
| 917 | `/:owner/:name/waves` lists the waves on the default branch, newest version | |
| 918 | first, each linking to its file (linked from that settings card); in the | |
| 919 | terminal, `V` in a repository opens the same list. | |
| 917 | 920 | |
| 918 | 921 | - **Accounts & access** — register/sign in, personal access tokens with coarse |
| 919 | 922 | scopes (ro/rw/admin), public and private repositories. |
scripts/gate.ts +3
| @@ -27,6 +27,8 @@ export type Exclusion = { | ||
| 27 | 27 | }; |
| 28 | 28 | |
| 29 | 29 | export const GATE: readonly string[] = [ |
| 30 | "test/waves-page.test.ts", | |
| 31 | "test/waves-order.test.ts", | |
| 30 | 32 | "test/domain-drill.test.ts", |
| 31 | 33 | "test/conflicts.test.ts", |
| 32 | 34 | "test/lands-once.test.ts", |
| @@ -68,6 +70,7 @@ export const GATE: readonly string[] = [ | ||
| 68 | 70 | "test/protection.test.ts", |
| 69 | 71 | "test/tui-limbs.test.ts", |
| 70 | 72 | "test/tui-onyou.test.ts", |
| 73 | "test/tui-waves.test.ts", | |
| 71 | 74 | "test/tui-deploys.test.ts", |
| 72 | 75 | "test/tui-access.test.ts", |
| 73 | 76 | "test/tui-decider.test.ts", |
src/http/server.ts +2
| @@ -168,6 +168,7 @@ import { | ||
| 168 | 168 | branchDelete, |
| 169 | 169 | branchesPage, |
| 170 | 170 | } from "../web/pages/branches.ts"; |
| 171 | import { wavesPage } from "../web/pages/waves.ts"; | |
| 171 | 172 | import { |
| 172 | 173 | contributeSubmit, |
| 173 | 174 | deleteFilePage, |
| @@ -1135,6 +1136,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1135 | 1136 | // The repository's activity, for a feed reader. |
| 1136 | 1137 | router.add("GET", "/:owner/:name/activity.atom", activityAtom); |
| 1137 | 1138 | router.add("GET", "/:owner/:name/branches", branchesPage); |
| 1139 | router.add("GET", "/:owner/:name/waves", wavesPage); | |
| 1138 | 1140 | router.add("POST", "/:owner/:name/branches", branchCreate); |
| 1139 | 1141 | router.add("POST", "/:owner/:name/branches/:branch/delete", branchDelete); |
| 1140 | 1142 |
src/story/waves.ts added +104
| @@ -0,0 +1,104 @@ | ||
| 1 | /** | |
| 2 | * A repository's waves, read: `waves/*.md` on a branch, newest version first. | |
| 3 | * Parsing is `wave.ts`'s; a file that does not parse is listed with its | |
| 4 | * reason and breaks nothing. Both doors (the page and the terminal) read | |
| 5 | * through here; each applies its own visibility gate before calling. | |
| 6 | */ | |
| 7 | ||
| 8 | import { readerFor, listTree } from "../git/read.ts"; | |
| 9 | import { parseWave, type Wave } from "./wave.ts"; | |
| 10 | ||
| 11 | /** A page, not an archive: more files than this are not read. */ | |
| 12 | const MAX_FILES = 300; | |
| 13 | /** A wave is a few hundred lines; a larger file is not one. */ | |
| 14 | const MAX_BYTES = 256 * 1024; | |
| 15 | ||
| 16 | export type WaveEntry = | |
| 17 | | { ok: true; file: string; wave: Wave } | |
| 18 | | { ok: false; file: string; reason: string }; | |
| 19 | ||
| 20 | const VERSION = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?$/; | |
| 21 | ||
| 22 | type Version = { core: number[]; pre: string[] }; | |
| 23 | ||
| 24 | const parse = (s: string): Version | null => { | |
| 25 | const m = VERSION.exec(s); | |
| 26 | if (!m) return null; | |
| 27 | return { | |
| 28 | core: [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)], | |
| 29 | pre: m[4] === undefined ? [] : m[4].split("."), | |
| 30 | }; | |
| 31 | }; | |
| 32 | ||
| 33 | const DIGITS = /^\d+$/; | |
| 34 | ||
outdated
Pre-release tie-break by string: |
||
| 35 | /** Semver §11 for one pre-release identifier: numbers by value, below text. */ | |
| 36 | function identifier(a: string, b: string): number { | |
| 37 | const an = DIGITS.test(a); | |
| 38 | const bn = DIGITS.test(b); | |
| 39 | if (an && bn) return Number(a) - Number(b); | |
| 40 | if (an !== bn) return an ? -1 : 1; | |
| 41 | return a < b ? -1 : a > b ? 1 : 0; | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Higher version first; a release beats its own pre-release, pre-release | |
| 46 | * identifiers compare dot by dot. A name that is not a version sorts after | |
| 47 | * every version, and those among themselves by name, so the order is total. | |
| 48 | */ | |
| 49 | export function byVersionDesc(a: string, b: string): number { | |
| 50 | const x = parse(a); | |
| 51 | const y = parse(b); | |
| 52 | if (!x || !y) return x ? -1 : y ? 1 : a < b ? -1 : a > b ? 1 : 0; | |
| 53 | for (let i = 0; i < 3; i++) { | |
| 54 | const d = y.core[i]! - x.core[i]!; | |
| 55 | if (d !== 0) return d; | |
| 56 | } | |
| 57 | if (!x.pre.length || !y.pre.length) return x.pre.length ? 1 : y.pre.length ? -1 : 0; | |
| 58 | for (let i = 0; i < Math.min(x.pre.length, y.pre.length); i++) { | |
| 59 | const d = identifier(y.pre[i]!, x.pre[i]!); | |
| 60 | if (d !== 0) return d; | |
| 61 | } | |
| 62 | return y.pre.length - x.pre.length; | |
| 63 | } | |
| 64 | ||
| 65 | /** Every wave file on `branch`, parsed; good ones newest first, then the bad. */ | |
| 66 | export async function readWaves( | |
| 67 | dir: string, | |
| 68 | branch: string, | |
| 69 | ): Promise<WaveEntry[]> { | |
| 70 | const tree = await listTree(dir, branch, "waves"); | |
| 71 | if (!tree.ok) return []; | |
| 72 | const files = tree.value | |
| 73 | .filter((e) => e.type === "blob" && e.name.endsWith(".md")) | |
| 74 | // Git orders names as strings (`0.10.0` before `0.9.0`); the cut is made | |
| 75 | // on version order so the newest waves are the ones kept. | |
| 76 | .sort((x, y) => byVersionDesc(x.name.slice(0, -3), y.name.slice(0, -3))) | |
| 77 | .slice(0, MAX_FILES); | |
| 78 | const reader = readerFor(dir); | |
| 79 | const out: WaveEntry[] = []; | |
| 80 | for (const f of files) { | |
| 81 | const path = `waves/${f.name}`; | |
| 82 | if (f.size > MAX_BYTES) { | |
| 83 | out.push({ ok: false, file: f.name, reason: `${path}: file is too large` }); | |
| 84 | continue; | |
| 85 | } | |
| 86 | const obj = await reader.object(f.oid); | |
| 87 | if (!obj.ok) { | |
| 88 | out.push({ ok: false, file: f.name, reason: `${path}: unreadable` }); | |
| 89 | continue; | |
| 90 | } | |
| 91 | const text = new TextDecoder().decode(obj.value.body); | |
| 92 | const parsed = parseWave(path, text); | |
| 93 | out.push( | |
| 94 | parsed.ok | |
| 95 | ? { ok: true, file: f.name, wave: parsed.value } | |
| 96 | : { ok: false, file: f.name, reason: parsed.error.message }, | |
| 97 | ); | |
| 98 | } | |
| 99 | const good = out.flatMap((e) => (e.ok ? [e] : [])); | |
| 100 | const bad = out.flatMap((e) => (e.ok ? [] : [e])); | |
| 101 | good.sort((a, b) => byVersionDesc(a.wave.version, b.wave.version)); | |
| 102 | return [...good, ...bad]; | |
| 103 | } | |
| 104 |
src/tui/screens/waves.ts added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | /** | |
| 2 | * A repository's waves: the same rows, in the same order, as the web page, | |
| 3 | * read through `story/waves.ts`. Local repositories only — a live browse of | |
| 4 | * another forge has no `waves/` we can read here, and says so. | |
| 5 | */ | |
| 6 | ||
| 7 | import { canRead } from "../../core/repos.ts"; | |
| 8 | import { readWaves, type WaveEntry } from "../../story/waves.ts"; | |
| 9 | import type { RepoSource } from "../../view/source.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import { fill, len, split } from "../ui/layout.ts"; | |
| 15 | import { emptyState } from "../ui/widgets.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { ListState, drawList, plural, runAsync } from "./common.ts"; | |
| 18 | import { localRepo, sourceName, whereRows, whereStrip } from "./where.ts"; | |
| 19 | ||
| 20 | /** One line per wave, as the screen draws it. */ | |
| 21 | export function waveRows(entries: WaveEntry[]) { | |
| 22 | return entries.map((e) => | |
| 23 | e.ok | |
| 24 | ? { | |
| 25 | title: e.wave.headline, | |
| 26 | secondary: "", | |
| 27 | meta: `${e.wave.version} ${e.wave.date}`, | |
| 28 | } | |
| 29 | : { title: e.file, secondary: `Not a wave: ${e.reason}`, meta: "" }, | |
| 30 | ); | |
| 31 | } | |
| 32 | ||
| 33 | export function wavesScreen(f: Session, ctx: Ctx, src: RepoSource): Screen { | |
| 34 | const repo = localRepo(src); | |
| 35 | let entries: WaveEntry[] = []; | |
| 36 | let loaded = false; | |
| 37 | const state = new ListState(); | |
| 38 | ||
| 39 | const reload = async (): Promise<void> => { | |
| 40 | // The web's gate, not a weaker one: whoever reaches this screen, a | |
| 41 | // repository they cannot read has no waves to show. | |
| 42 | entries = | |
| 43 | repo !== undefined && canRead(f.db, repo, f.user) | |
| 44 | ? await readWaves(f.dir(repo), repo.defaultBranch) | |
| 45 | : []; | |
| 46 | loaded = true; | |
| 47 | ctx.invalidate(); | |
| 48 | }; | |
| 49 | ||
| 50 | return { | |
| 51 | title: "Waves", | |
| 52 | keys: [binding(["j", "k"], "move"), binding(["r"], "refresh")], | |
| 53 | load: reload, | |
| 54 | render(area, s) { | |
| 55 | const c = ctx.theme.c; | |
| 56 | const [strip, head, body] = split(area, "v", [ | |
| 57 | len(whereRows(src)), | |
| 58 | len(1), | |
| 59 | fill(1), | |
| 60 | ]); | |
| 61 | whereStrip(s, strip!, c, f, src); | |
| 62 | s.text( | |
| 63 | head!.x, | |
| 64 | head!.y, | |
| 65 | truncate( | |
| 66 | `${sourceName(src)} · ${plural(entries.length, "wave")}`, | |
| 67 | head!.w, | |
| 68 | ), | |
| 69 | style(c.mutedFg, c.bg), | |
| 70 | head!.x + head!.w, | |
| 71 | ); | |
| 72 | if (entries.length === 0) { | |
| 73 | emptyState(s, body!, c, { | |
| 74 | title: loaded ? "No waves yet" : "Reading…", | |
| 75 | body: loaded ? "This repository has no waves/ directory." : "", | |
For a live browse of another forge ( |
||
| 76 | }); | |
| 77 | return; | |
| 78 | } | |
| 79 | drawList(s, body!, c, state, waveRows(entries)); | |
| 80 | }, | |
| 81 | key(e) { | |
| 82 | if (state.key(e, entries.length)) return true; | |
| 83 | if (e.key === "r") { | |
| 84 | runAsync(ctx, reload); | |
| 85 | return true; | |
| 86 | } | |
| 87 | return false; | |
| 88 | }, | |
| 89 | }; | |
| 90 | } |
src/tui/screens/workspace.ts +6
| @@ -118,6 +118,7 @@ import { liveBindings, liveKey } from "./mirror.ts"; | ||
| 118 | 118 | import { forkScreen } from "./newrepo.ts"; |
| 119 | 119 | import { pullScreen } from "./pull.ts"; |
| 120 | 120 | import { branchesScreen } from "./refs.ts"; |
| 121 | import { wavesScreen } from "./waves.ts"; | |
| 121 | 122 | import { |
| 122 | 123 | hostOf, |
| 123 | 124 | knownDefaultRef, |
| @@ -1287,6 +1288,7 @@ export function workspaceScreen( | ||
| 1287 | 1288 | binding(["y"], "copy path"), |
| 1288 | 1289 | binding(["i", "p", "C"], "issues / pulls / commits"), |
| 1289 | 1290 | binding(["I"], "about this repository"), |
| 1291 | binding(["V"], "waves"), | |
| 1290 | 1292 | ...(repo === undefined ? [] : [binding(["d"], "production deploys")]), |
| 1291 | 1293 | ...(repo !== undefined && admin() |
| 1292 | 1294 | ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)] |
| @@ -1606,6 +1608,10 @@ export function workspaceScreen( | ||
| 1606 | 1608 | ctx.push(commitsScreen(f, ctx, src, ref)); |
| 1607 | 1609 | return true; |
| 1608 | 1610 | } |
| 1611 | if (plain && e.key === "V") { | |
| 1612 | ctx.push(wavesScreen(f, ctx, src)); | |
| 1613 | return true; | |
| 1614 | } | |
| 1609 | 1615 | if (plain && e.key === "d" && repo !== undefined) { |
| 1610 | 1616 | ctx.push(deploysScreen(f, ctx, repo)); |
| 1611 | 1617 | return true; |
src/web/pages/settings.ts +1
| @@ -315,6 +315,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 315 | 315 | Opens a pull that adds the next <code>waves/<version>.md</code>, |
| 316 | 316 | listing every pull merged since the last wave. The headline and |
| 317 | 317 | highlights are left as TODO for you to write. |
| 318 | <a href="${base}/waves">See the waves so far</a>. | |
| 318 | 319 | </p> |
| 319 | 320 | <div class="form-actions">${btn("Cut a wave", { variant: "primary" })}</div> |
| 320 | 321 | </form>`, |
src/web/pages/waves.ts added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | /** | |
| 2 | * A repository's waves, as a page: `waves/*.md` on the default branch, newest | |
| 3 | * version first, each line linking to its file. Reading is `story/waves.ts`'s. | |
| 4 | * | |
| 5 | * Visibility is the repository's: `loadRepo` answers an outsider exactly as | |
| 6 | * it does for any other page of a private repository. | |
| 7 | */ | |
| 8 | ||
| 9 | import { repoDir } from "../../git/repos.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { readWaves } from "../../story/waves.ts"; | |
| 12 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; | |
| 13 | import { html } from "../html.ts"; | |
| 14 | import { page } from "../layout.ts"; | |
| 15 | import { empty, listCard, row } from "../ui.ts"; | |
| 16 | import { localScope } from "../scope.ts"; | |
| 17 | ||
| 18 | // GET /:owner/:name/waves | |
| 19 | export async function wavesPage(ctx: Ctx): Promise<Response> { | |
| 20 | const repo = loadRepo(ctx); | |
| 21 | if (!repo) return notFoundPage(ctx); | |
| 22 | const scope = localScope(ctx, repo); | |
| 23 | const branch = repo.defaultBranch; | |
| 24 | const entries = await readWaves( | |
| 25 | repoDir(ctx.app.reposRoot, repo.ownerName, repo.name), | |
| 26 | branch, | |
| 27 | ); | |
| 28 | const fileHref = (file: string): string => | |
| 29 | `${repoBase(repo)}/blob/${encodeURIComponent(branch)}/waves/${encodeURIComponent(file)}`; | |
| 30 | ||
| 31 | const rows = entries.map((e) => | |
| 32 | e.ok | |
| 33 | ? row({ | |
| 34 | href: fileHref(e.file), | |
| 35 | title: e.wave.headline, | |
| 36 | meta: html`${e.wave.version} · ${e.wave.date}`, | |
| 37 | }) | |
| 38 | : row({ | |
| 39 | href: fileHref(e.file), | |
| 40 | title: e.file, | |
| 41 | meta: html`Not a wave: ${e.reason}`, | |
| 42 | }), | |
| 43 | ); | |
| 44 | ||
| 45 | const body = html`${scopeHeader(ctx, scope, "code", { compact: true })} | |
| 46 | <div class="mt"> | |
| 47 | ${entries.length === 0 | |
| 48 | ? empty({ title: "No waves yet", note: "This repository has no waves/ directory." }) | |
| 49 | : listCard(rows, { head: html`<h2>Waves</h2>` })} | |
| 50 | </div>`; | |
| 51 | ||
| 52 | return page({ | |
| 53 | ctx, | |
| 54 | title: `Waves · ${scope.facts.name}`, | |
| 55 | crumbs: scope.crumbs, | |
outdated
Low: |
||
| 56 | palette: scope.palette, | |
| 57 | body, | |
| 58 | }); | |
| 59 | } |
test/tui-waves.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { signUp } from "../src/actions/users.ts"; | |
| 7 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 8 | import type { Repo } from "../src/core/repos.ts"; | |
| 9 | import type { User } from "../src/core/users.ts"; | |
| 10 | import { writeWave, type Wave } from "../src/story/wave.ts"; | |
| 11 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 12 | import { wavesScreen } from "../src/tui/screens/waves.ts"; | |
| 13 | import { local } from "../src/tui/screens/where.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | import { settleApp } from "./helpers/tui.ts"; | |
| 17 | ||
| 18 | quietLogs(); | |
| 19 | ||
| 20 | const wave = (version: string, date: string, headline: string): Wave => ({ | |
| 21 | version, | |
| 22 | date, | |
| 23 | headline, | |
| 24 | highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: "because" })), | |
| 25 | pulls: [{ title: "#1 A pull", url: "https://forge.example/o/r/pulls/1" }], | |
| 26 | }); | |
| 27 | const text = (w: Wave): string => { | |
| 28 | const r = writeWave(w); | |
| 29 | if (!r.ok) throw r.error; | |
| 30 | return r.value; | |
| 31 | }; | |
| 32 | ||
| 33 | describe("waves in the terminal", () => { | |
| 34 | let dataDir: string; | |
| 35 | let forge: Forge; | |
| 36 | let alice: User; | |
| 37 | let bob: User; | |
| 38 | let pub: Repo; | |
| 39 | let priv: Repo; | |
| 40 | ||
| 41 | beforeAll(async () => { | |
| 42 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-waves-")); | |
| 43 | forge = createApp({ dataDir, port: 0 }); | |
| 44 | const a = await signUp(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 45 | const b = await signUp(forge, { username: "bob", email: "[email protected]", password: "a-long-passphrase" }); | |
| 46 | if (!a.ok || !b.ok) throw new Error("signup failed"); | |
| 47 | alice = a.value; | |
| 48 | bob = b.value; | |
| 49 | const p = await createRepo(forge, alice, { name: "pub", visibility: "public" }); | |
| 50 | const q = await createRepo(forge, alice, { name: "priv", visibility: "private" }); | |
| 51 | if (!p.ok || !q.ok) throw new Error("repo failed"); | |
| 52 | pub = p.value; | |
| 53 | priv = q.value; | |
| 54 | const put = (repo: Repo, files: { path: string; content: string }[]) => | |
| 55 | commitToBranch(forge, alice, repo, { message: "waves", files }); | |
| 56 | await put(pub, [ | |
| 57 | { path: "waves/0.9.0.md", content: text(wave("0.9.0", "2026-01-01", "Ninth")) }, | |
| 58 | { path: "waves/0.10.0.md", content: text(wave("0.10.0", "2026-02-01", "Tenth")) }, | |
| 59 | { path: "waves/broken.md", content: "no front matter\n" }, | |
| 60 | ]); | |
| 61 | await put(priv, [ | |
| 62 | { path: "waves/1.0.0.md", content: text(wave("1.0.0", "2026-03-01", "Secret launch")) }, | |
| 63 | ]); | |
| 64 | }); | |
| 65 | ||
| 66 | afterAll(async () => { | |
| 67 | forge.close(); | |
| 68 | await rm(dataDir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | const open = async (user: User, repo: Repo): Promise<{ text: string; stop(): void }> => { | |
| 72 | const terminal = headlessTerminal(120, 40); | |
| 73 | const { app, session } = createForgeApp( | |
| 74 | forge, | |
| 75 | { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user }, | |
| 76 | { terminal, exitHooks: false }, | |
| 77 | ); | |
| 78 | app.start(); | |
| 79 | await settleApp(app); | |
| 80 | app.push(wavesScreen(session, app.ctx, local(session, repo))); | |
| 81 | await settleApp(app); | |
| 82 | return { text: app.screen.toText(), stop: () => app.stop() }; | |
| 83 | }; | |
| 84 | ||
| 85 | test("lists the web page's rows in the same order, bad files last", async () => { | |
| 86 | const h = await open(alice, pub); | |
| 87 | h.stop(); | |
| 88 | const at = (s: string) => h.text.indexOf(s); | |
| 89 | expect(at("Tenth")).toBeGreaterThan(-1); | |
| 90 | expect(at("Tenth")).toBeLessThan(at("Ninth")); | |
| 91 | expect(at("Ninth")).toBeLessThan(at("broken.md")); | |
| 92 | expect(h.text).toContain("0.10.0"); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a reader who cannot read the repository sees no waves", async () => { | |
| 96 | const h = await open(bob, priv); | |
| 97 | h.stop(); | |
| 98 | expect(h.text).not.toContain("Secret launch"); | |
| 99 | expect(h.text).toContain("No waves yet"); | |
| 100 | }); | |
| 101 | }); |
test/waves-order.test.ts added +27
| @@ -0,0 +1,27 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { byVersionDesc } from "../src/story/waves.ts"; | |
| 3 | ||
| 4 | const newer = (a: string, b: string) => { | |
| 5 | expect(byVersionDesc(a, b)).toBeLessThan(0); | |
| 6 | expect(byVersionDesc(b, a)).toBeGreaterThan(0); | |
| 7 | }; | |
| 8 | ||
| 9 | describe("byVersionDesc", () => { | |
| 10 | test("compares numerically, not as strings", () => newer("0.10.0", "0.9.0")); | |
| 11 | test("a release beats its pre-release", () => newer("1.0.0", "1.0.0-rc.1")); | |
| 12 | test("pre-release identifiers compare numerically", () => { | |
| 13 | newer("1.0.0-rc.10", "1.0.0-rc.2"); | |
| 14 | newer("1.0.0-rc.1.1", "1.0.0-rc.1"); | |
| 15 | }); | |
| 16 | test("a name that is not a version sorts after every version", () => { | |
| 17 | newer("0.0.1", "broken"); | |
| 18 | newer("1.0.0-rc.1", "notes"); | |
| 19 | expect(byVersionDesc("broken", "broken")).toBe(0); | |
| 20 | }); | |
| 21 | test("the sort is total, so the cut keeps the newest", () => { | |
| 22 | const names = ["broken", "0.9.0", "notes", "0.10.0", "1.0.0-rc.2", "1.0.0-rc.10", "1.0.0"]; | |
| 23 | expect(names.sort(byVersionDesc).slice(0, 4)).toEqual([ | |
| 24 | "1.0.0", "1.0.0-rc.10", "1.0.0-rc.2", "0.10.0", | |
| 25 | ]); | |
| 26 | }); | |
| 27 | }); |
test/waves-page.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import type { Repo } from "../src/core/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createUser, type User } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { writeWave, type Wave } from "../src/story/wave.ts"; | |
| 12 | ||
| 13 | const PASSWORD = "a-good-passphrase"; | |
| 14 | ||
| 15 | const wave = (version: string, date: string, headline: string): Wave => ({ | |
| 16 | version, | |
| 17 | date, | |
| 18 | headline, | |
| 19 | highlights: [1, 2, 3].map((n) => ({ title: `Thing ${n}`, why: "because" })), | |
| 20 | pulls: [{ title: "#1 A pull", url: "https://forge.example/o/r/pulls/1" }], | |
| 21 | }); | |
| 22 | const text = (w: Wave): string => { | |
| 23 | const r = writeWave(w); | |
| 24 | if (!r.ok) throw r.error; | |
| 25 | return r.value; | |
| 26 | }; | |
| 27 | ||
| 28 | describe("waves page", () => { | |
| 29 | let dataDir: string; | |
| 30 | let app: App; | |
| 31 | let servers: Servers; | |
| 32 | let alice: User; | |
| 33 | let cookie = ""; | |
| 34 | let pub: Repo; | |
| 35 | let priv: Repo; | |
| 36 | let bare: Repo; | |
| 37 | ||
| 38 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 39 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 40 | return r.value; | |
| 41 | }; | |
| 42 | const put = async (repo: Repo, files: { path: string; content: string }[]) => | |
| 43 | must(await commitToBranch(app, alice, repo, { message: "waves", files })); | |
| 44 | const get = (path: string, withCookie = true) => | |
| 45 | fetch(`${servers.url}${path}`, { | |
| 46 | headers: withCookie ? { cookie } : {}, | |
| 47 | }); | |
| 48 | ||
| 49 | beforeAll(async () => { | |
| 50 | dataDir = await mkdtemp(join(tmpdir(), "forge-waves-")); | |
| 51 | app = createApp({ dataDir, port: 0 }); | |
| 52 | servers = await serve(app); | |
| 53 | alice = must( | |
| 54 | await createUser(app.db, { | |
| 55 | trusted: true, | |
| 56 | username: "alice", | |
| 57 | email: "[email protected]", | |
| 58 | password: PASSWORD, | |
| 59 | adminIfFirst: true, | |
| 60 | }), | |
| 61 | ); | |
| 62 | cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=dev`; | |
| 63 | pub = must(await createRepo(app, alice, { name: "pub", visibility: "public" })); | |
| 64 | priv = must(await createRepo(app, alice, { name: "priv", visibility: "private" })); | |
| 65 | bare = must(await createRepo(app, alice, { name: "bare", visibility: "public" })); | |
| 66 | await put(bare, [{ path: "README.md", content: "hi\n" }]); | |
| 67 | await put(pub, [ | |
| 68 | { path: "waves/0.9.0.md", content: text(wave("0.9.0", "2026-01-01", "Ninth")) }, | |
| 69 | { path: "waves/0.10.0.md", content: text(wave("0.10.0", "2026-02-01", "Tenth")) }, | |
| 70 | { path: "waves/0.2.0.md", content: text(wave("0.2.0", "2025-12-01", "<script>alert(1)</script> & Zeta")) }, | |
| 71 | { path: "waves/broken.md", content: "no front matter here\n" }, | |
| 72 | ]); | |
| 73 | await put(priv, [ | |
| 74 | { path: "waves/1.0.0.md", content: text(wave("1.0.0", "2026-03-01", "Secret launch")) }, | |
| 75 | ]); | |
| 76 | }, 60_000); | |
| 77 | ||
| 78 | afterAll(async () => { | |
| 79 | servers.stop(); | |
| 80 | app.close(); | |
| 81 | await rm(dataDir, { recursive: true, force: true }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("lists newest version first, numerically", async () => { | |
| 85 | const res = await get("/alice/pub/waves"); | |
| 86 | expect(res.status).toBe(200); | |
| 87 | const body = await res.text(); | |
| 88 | const at = (s: string) => body.indexOf(s); | |
| 89 | expect(at("Tenth")).toBeGreaterThan(-1); | |
| 90 | expect(at("Tenth")).toBeLessThan(at("Ninth")); | |
| 91 | expect(at("Ninth")).toBeLessThan(at("Zeta")); | |
| 92 | expect(body).toContain("0.10.0"); | |
| 93 | expect(body).toContain("2026-02-01"); | |
| 94 | expect(body).toContain("/alice/pub/blob/main/waves/0.10.0.md"); | |
| 95 | }); | |
| 96 | ||
| 97 | test("a headline with markup comes out inert", async () => { | |
| 98 | const body = await (await get("/alice/pub/waves")).text(); | |
| 99 | expect(body).not.toContain("<script>alert(1)</script>"); | |
| 100 | expect(body).toContain("<script>alert(1)</script> & Zeta"); | |
| 101 | }); | |
| 102 | ||
| 103 | test("an unparsable file is listed with its reason and the rest still shows", async () => { | |
| 104 | const body = await (await get("/alice/pub/waves")).text(); | |
| 105 | expect(body).toContain("broken.md"); | |
| 106 | expect(body).toContain("missing front matter"); | |
| 107 | expect(body).toContain("Tenth"); | |
| 108 | }); | |
| 109 | ||
| 110 | test("settings links to the list from the Cut a wave card", async () => { | |
| 111 | const body = await (await get("/alice/pub/settings")).text(); | |
| 112 | expect(body).toContain('href="/alice/pub/waves"'); | |
| 113 | }); | |
| 114 | ||
| 115 | test("no waves/ gets the empty state", async () => { | |
| 116 | const res = await get("/alice/bare/waves"); | |
| 117 | expect(res.status).toBe(200); | |
| 118 | expect(await res.text()).toContain("No waves yet"); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a private repository is refused to an outsider as any other page is", async () => { | |
| 122 | const waves = await get("/alice/priv/waves", false); | |
| 123 | const other = await get("/alice/priv/branches", false); | |
| 124 | expect(waves.status).toBe(other.status); | |
| 125 | expect(waves.status).toBe(404); | |
| 126 | expect(await waves.text()).not.toContain("Secret launch"); | |
| 127 | const owner = await get("/alice/priv/waves"); | |
| 128 | expect(await owner.text()).toContain("Secret launch"); | |
| 129 | }); | |
| 130 | }); |
"broken".split(".").map(Number)gives[NaN], so the comparator returns NaN for any non-version file name. Array.sort needs a consistent comparator, and this sort runs beforeslice(0, MAX_FILES), so the cut can drop the newest waves. Treat a non-numeric name as sorting after every version.