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

Merged opened this

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

Merged

scout/forge:scout/1855-owner-name-waves-lists-a-repository-s-wa → main 6 commits fork 5 threads · 5 unresolved

  • Merged. scout/1855-owner-name-waves-lists-a-repository-s-wa was merged into main as f031e2b.

Review

Sign in to review this change.
11 files changed +526 −0
README.md +3
@@ -914,6 +914,9 @@ work here.
914914 not already in an earlier wave. The headline and highlights are `TODO`
915915 placeholders; no model is called. It refuses, writing nothing, when an
916916 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.
917920
918921 - **Accounts & access** — register/sign in, personal access tokens with coarse
919922 scopes (ro/rw/admin), public and private repositories.
scripts/gate.ts +3
@@ -27,6 +27,8 @@ export type Exclusion = {
2727 };
2828
2929 export const GATE: readonly string[] = [
30 "test/waves-page.test.ts",
31 "test/waves-order.test.ts",
3032 "test/domain-drill.test.ts",
3133 "test/conflicts.test.ts",
3234 "test/lands-once.test.ts",
@@ -68,6 +70,7 @@ export const GATE: readonly string[] = [
6870 "test/protection.test.ts",
6971 "test/tui-limbs.test.ts",
7072 "test/tui-onyou.test.ts",
73 "test/tui-waves.test.ts",
7174 "test/tui-deploys.test.ts",
7275 "test/tui-access.test.ts",
7376 "test/tui-decider.test.ts",
src/http/server.ts +2
@@ -168,6 +168,7 @@ import {
168168 branchDelete,
169169 branchesPage,
170170 } from "../web/pages/branches.ts";
171 import { wavesPage } from "../web/pages/waves.ts";
171172 import {
172173 contributeSubmit,
173174 deleteFilePage,
@@ -1135,6 +1136,7 @@ export function buildRouter(): Router<Handler> {
11351136 // The repository's activity, for a feed reader.
11361137 router.add("GET", "/:owner/:name/activity.atom", activityAtom);
11371138 router.add("GET", "/:owner/:name/branches", branchesPage);
1139 router.add("GET", "/:owner/:name/waves", wavesPage);
11381140 router.add("POST", "/:owner/:name/branches", branchCreate);
11391141 router.add("POST", "/:owner/:name/branches/:branch/delete", branchDelete);
11401142
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
outdated

"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 before slice(0, MAX_FILES), so the cut can drop the newest waves. Treat a non-numeric name as sorting after every version.

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: 1.0.0-rc.2 sorts ahead of 1.0.0-rc.10. Compare identifiers dot by dot, numerically when both are digits (semver §11), and unit-test this function directly.

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 (repo === undefined), this says the repository has no waves/ directory. That is not known, and the header comment promises the screen "says so" instead. Tracked in #2076, together with Enter opening the wave file.

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";
118118 import { forkScreen } from "./newrepo.ts";
119119 import { pullScreen } from "./pull.ts";
120120 import { branchesScreen } from "./refs.ts";
121 import { wavesScreen } from "./waves.ts";
121122 import {
122123 hostOf,
123124 knownDefaultRef,
@@ -1287,6 +1288,7 @@ export function workspaceScreen(
12871288 binding(["y"], "copy path"),
12881289 binding(["i", "p", "C"], "issues / pulls / commits"),
12891290 binding(["I"], "about this repository"),
1291 binding(["V"], "waves"),
12901292 ...(repo === undefined ? [] : [binding(["d"], "production deploys")]),
12911293 ...(repo !== undefined && admin()
12921294 ? [binding(["R"], `decisions by ${(getRepoById(f.db, repo.id) ?? repo).decisionsBy} (cycle)`)]
@@ -1606,6 +1608,10 @@ export function workspaceScreen(
16061608 ctx.push(commitsScreen(f, ctx, src, ref));
16071609 return true;
16081610 }
1611 if (plain && e.key === "V") {
1612 ctx.push(wavesScreen(f, ctx, src));
1613 return true;
1614 }
16091615 if (plain && e.key === "d" && repo !== undefined) {
16101616 ctx.push(deploysScreen(f, ctx, repo));
16111617 return true;
src/web/pages/settings.ts +1
@@ -315,6 +315,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
315315 Opens a pull that adds the next <code>waves/&lt;version&gt;.md</code>,
316316 listing every pull merged since the last wave. The headline and
317317 highlights are left as TODO for you to write.
318 <a href="${base}/waves">See the waves so far</a>.
318319 </p>
319320 <div class="form-actions">${btn("Cut a wave", { variant: "primary" })}</div>
320321 </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: slice(0, MAX_FILES) runs on git's tree order, before the version sort. Git orders names as plain strings, so 0.10.0.md comes before 0.9.0.md. Past 300 files, the waves cut off are arbitrary and can include the newest one. Sort before truncating, or say on the page that the list was cut.

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("&lt;script&gt;alert(1)&lt;/script&gt; &amp; 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs