Public: anyone can read this; the crew can change it.

/changelog lists the last 30 releases, finished from pull #7013's branch with a private forge repository kept off the page #7120

Merged opened this

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

Merged

scout/forge:scout/7101-changelog-lists-the-last-30-releases-fin → main 8 commits fork 1 thread · 1 unresolved

  • Merged. scout/7101-changelog-lists-the-last-30-releases-fin was merged into main as 7f7da4c.

Review

Sign in to review this change.
14 files changed +403 −2
README.md +2
@@ -404,3 +404,5 @@ This page is the front door. Everything else is a file of its own under
404404
405405 - [`docs/DESIGN.md`](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`.
406406 - [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written.
407
408 `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`).
docs/design/audit.md +2 −1
@@ -33,7 +33,7 @@ their own vocabulary instead of composing the named one.
3333
3434 ## Finding 2 — the pages, and what each is for
3535
36 There are 62 top-level pages, one entry each: every static `GET` route in `src/http/server.ts`
36 There are 63 top-level pages, one entry each: every static `GET` route in `src/http/server.ts`
3737 that returns a page (`test/design-audit.test.ts` holds this list to the router,
3838 both ways). Each entry reads `route` — module · what it is for · what
3939 duplicates it · what breaks `docs/DESIGN.md`. "Breaks" counts colour literals
@@ -62,6 +62,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
6262 - `/map` — `pages/map.ts` (1,806 lines) · the dependency map of work across the forge · `/board`, issue dependency lists, `/:owner/:name/map` · colours 3, sizes 12: the largest private vocabulary.
6363 - `/audit` — `pages/audit.ts` · every app's contract, who can do what · the app page's one-line policy · none seen. Names the audit of apps, not this document.
6464 - `/releases` — `pages/releases.ts` · the forge's kept releases and rollback · `/settings/backups` (both are "go back") · none seen.
65 - `/changelog` — `pages/changelog.ts` · the last 30 releases with their dates and pulls, public · `/releases` (the signed-in kept releases) · none seen.
6566 - `/contribute` — `pages/contribute.ts` · what this forge is and where outside work is welcome, open to anyone · `/llms.txt`, MCP `contribute` tool · none seen.
6667 - `/search` — `pages/search.ts` · find across what I can read · the palette (`palette.ts`) · none seen.
6768 - `/-/confirm` — `pages/search.ts` (`confirmPage`) · the "are you sure" step for a destructive action · none · none seen.
docs/features/changelog.md added +5
@@ -0,0 +1,5 @@
1 # Changelog
2
3 `GET /changelog` is public. It lists the last 30 `Release <version>: …` commits on the default branch of the forge's own repository (`FORGE_REPO`), newest first. Each has a heading anchored `#<version>`, its date, and one link per pull named in the subject (`title (#N);title (#N)`). When `FORGE_REPO` is unset or its repository is private, the page answers 200 with "No releases yet". The footer links to it.
4
5 One bounded `git log` read, cached per head oid; Server-Timing names `releases` and `render`.
forge.json +1 −1
@@ -3,7 +3,7 @@
33 "api.anthropic.com"
44 ],
55 "app": {
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/review", "/search", "/new", "/audit", "/releases", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"],
6 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/boards", "/asked", "/review", "/search", "/new", "/audit", "/releases", "/changelog", "/apps", "/migrate", "/map", "/system", "/contribute", "/resilience", "/settings"],
77 "run": "bun run src/main.ts",
88 "install": "",
99 "health": "/healthz",
src/core/reserved.ts +1
@@ -121,6 +121,7 @@ export const SYSTEM_NAMES: readonly string[] = [
121121 "git",
122122 "ssh",
123123 "status",
124 "changelog",
124125 "docs",
125126 "open-platform",
126127 ];
src/git/read.ts +3
@@ -492,6 +492,8 @@ export interface LogOptions {
492492 skip?: number;
493493 /** Restrict to commits touching this tree path. */
494494 path?: string;
495 /** Only commits whose message matches this extended regular expression. */
496 grep?: string;
495497 }
496498
497499 /** When `oid` was committed, as an ISO time; undefined when git cannot say. */
@@ -536,6 +538,7 @@ export async function commitLog(
536538 `--format=${LOG_FORMAT}`,
537539 `--max-count=${options.limit ?? 50}`,
538540 `--skip=${options.skip ?? 0}`,
541 ...(options.grep === undefined ? [] : ["--extended-regexp", `--grep=${options.grep}`]),
539542 rev,
540543 "--",
541544 ];
src/http/server.ts +2
@@ -159,6 +159,7 @@ import {
159159 backupsTest,
160160 domainsMoved,
161161 } from "../web/pages/backups.ts";
162 import { changelogPage } from "../web/pages/changelog.ts";
162163 import { releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
163164 import {
164165 cloudAdd,
@@ -775,6 +776,7 @@ export function buildRouter(): Router<Handler> {
775776 router.add("GET", "/new", newRepoPage);
776777 router.add("GET", "/audit", auditPage);
777778 router.add("GET", "/releases", releasesPage);
779 router.add("GET", "/changelog", changelogPage);
778780 router.add("POST", "/releases/:sha/rollback", rollbackSubmit);
779781 router.add("GET", "/audit.csv", auditCsv);
780782 router.add("POST", "/audit/:owner/:name/probe", auditProbeSubmit);
src/release/changelog.ts added +58
@@ -0,0 +1,58 @@
1 /**
2 * The changelog's data: the last 30 `Release <version>: …` commits on the
3 * forge's own default branch, newest first, each with the pulls its subject
4 * names (`core/release_subject.ts` reads the subject). One bounded log read,
5 * kept per head oid so a request between releases costs only a `rev-parse`.
6 * Only a public `FORGE_REPO` is read.
7 */
8
9 import type { App } from "../app.ts";
10 import { getRepo, type Repo } from "../core/repos.ts";
11 import { parseReleaseSubject, type ReleasePull } from "../core/release_subject.ts";
12 import { runGit } from "../git/env.ts";
13 import { commitLog } from "../git/read.ts";
14 import { repoDir } from "../git/repos.ts";
15
16 export const CHANGELOG_LIMIT = 30;
17
18 export interface ReleaseEntry {
19 readonly version: string;
20 readonly date: string;
21 readonly pulls: readonly ReleasePull[];
22 }
23
24 const cache = new Map<string, { oid: string; entries: ReleaseEntry[] }>();
25
26 /** The newest releases on `branch`, read once per head oid. */
27 export async function readChangelog(dir: string, branch: string): Promise<ReleaseEntry[]> {
28 const head = await runGit(["rev-parse", "--verify", "-q", `refs/heads/${branch}`], { cwd: dir });

Low: this spawns a git rev-parse on every public request, before the cache is checked. It is fine at today's scale. If this page gets hot, read the head from the forge's own ref state or keep the oid for a few seconds.

29 if (!head.ok) return [];
30 const cached = cache.get(dir);
31 if (cached?.oid === head.value) return cached.entries;
32 const log = await commitLog(dir, head.value, { limit: CHANGELOG_LIMIT, grep: "^Release [0-9]+\\.[0-9]+\\.[0-9]+: " });
33 if (!log.ok) return [];
34 const entries: ReleaseEntry[] = [];
35 for (const commit of log.value) {
36 const release = parseReleaseSubject(commit.subject);
37 if (release !== undefined) entries.push({ version: release.version, date: commit.date, pulls: release.pulls });
38 }
39 cache.set(dir, { oid: head.value, entries });
40 return entries;
41 }
42
43 /** The forge's own repository, when it is public: the only one the changelog reads. */
44 function publicForgeRepo(app: App): Repo | undefined {
45 const [owner = "", name = ""] = (app.config.forgeRepo ?? "").split("/");
46 const repo = owner === "" || name === "" ? undefined : getRepo(app.db, owner, name);
47 return repo !== undefined && repo.visibility === "public" ? repo : undefined;
48 }
49
50 /** What the page and the terminal both show: the repository and its newest releases. */
51 export async function forgeChangelog(
52 app: App,
53 ): Promise<{ repo: Repo; entries: ReleaseEntry[] } | undefined> {
54 const repo = publicForgeRepo(app);
55 if (repo === undefined) return undefined;
56 const entries = await readChangelog(repoDir(app.reposRoot, repo.ownerName, repo.name), repo.defaultBranch);
57 return { repo, entries };
58 }
src/tui/forge.ts +8
@@ -72,6 +72,7 @@ import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts";
7272 import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts";
7373 import { queuesFor, systemFor } from "../system/telemetry.ts";
7474 import { live as liveMachine } from "../system/live.ts";
75 import { changelogScreen } from "./screens/changelog.ts";
7576 import { releasesScreen } from "./screens/releases.ts";
7677 import { mayRead } from "../release/gate.ts";
7778 import { fileHoldsPrompt } from "./screens/fileholds.ts";
@@ -345,6 +346,13 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
345346 hint: "each protection green, red or untested",
346347 run: () => ctx.push(resilienceScreen(ctx, resilienceLines(session.app.config.dataDir))),
347348 });
349 // The last releases with their pulls, as /changelog shows them: for anyone.
350 commands.push({
351 id: "changelog",
352 title: "Changelog",
353 hint: "the last 30 releases and their pulls",
354 run: () => ctx.push(changelogScreen(session, ctx)),
355 });
348356 // The live release and the kept ones, as /releases gates them: a site admin or a reader of FORGE_REPO.
349357 if (mayRead(session.app, session.user))
350358 commands.push({
src/tui/screens/changelog.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The changelog, the terminal's /changelog: the same `forgeChangelog` read the
3 * page makes, for anyone, a guest included. Each release is a row: version,
4 * date and the pulls it carried; Enter opens its first pull.
5 */
6
7 import { forgeChangelog, type ReleaseEntry } from "../../release/changelog.ts";
8 import { sourceFor } from "../../view/source.ts";
9 import type { Ctx, Screen } from "../app/app.ts";
10 import { binding } from "../app/keys.ts";
11 import type { Session } from "../session.ts";
12 import { emptyState } from "../ui/widgets.ts";
13 import { pullScreen } from "./pull.ts";
14 import { ListState, ago, drawList, metaOf, type ListRow } from "./common.ts";
15
16 export function changelogRows(entries: readonly ReleaseEntry[]): ListRow[] {
17 return entries.map((e) => ({
18 title: e.version,
19 meta: metaOf(
20 ago(e.date),
21 `${e.pulls.length} ${e.pulls.length === 1 ? "pull" : "pulls"}`,
22 e.pulls.map((p) => `#${p.number} ${p.title}`.trim()).join("; "),
23 ),
24 }));
25 }
26
27 export function changelogScreen(f: Session, ctx: Ctx): Screen {
28 const state = new ListState();
29 let entries: ReleaseEntry[] = [];
30 let loaded = false;
31 let repoRef: { owner: string; name: string } | undefined;
32 return {
33 title: "Changelog",
34 keys: [binding(["j", "k"], "move"), binding(["enter"], "open first pull"), binding(["esc", "q"], "close")],
35 async load() {
36 const read = await forgeChangelog(f.app);
37 entries = read?.entries ?? [];
38 repoRef = read === undefined ? undefined : { owner: read.repo.ownerName, name: read.repo.name };
39 state.clamp(entries.length);
40 loaded = true;
41 ctx.invalidate();
42 },
43 render(area, s) {
44 const c = ctx.theme.c;
45 if (entries.length === 0) {
46 emptyState(s, area, c, {
47 title: loaded ? "No releases yet" : "Reading…",
48 body: "Each release lands here with its pulls.",
49 });
50 return;
51 }
52 drawList(s, area, c, state, changelogRows(entries));
53 },
54 key(e) {
55 if (e.key === "esc" || e.key === "q") ctx.pop();
56 else if (e.key === "enter") {
57 const first = entries[state.index]?.pulls[0];
58 const opened = first === undefined || repoRef === undefined ? undefined : sourceFor(f.app, f.user, repoRef);
59 if (first !== undefined && opened?.ok) ctx.push(pullScreen(f, ctx, opened.value, first.number));
60 } else state.key(e, entries.length);
61 return true;
62 },
63 };
64 }
src/web/layout.ts +1
@@ -518,6 +518,7 @@ function footer(ctx: Ctx): Html {
518518 >${reduced}</span
519519 >`}
520520 <span class="spacer"></span>
521 <a href="/changelog">Changelog</a>
521522 ${ctx.auth.user !== undefined && ctx.auth.user.kind !== "org"
522523 ? html`<a class="btn" data-variant="link" data-size="sm" href="/#composer">Tell the crew</a>`
523524 : ""}
src/web/pages/changelog.ts added +42
@@ -0,0 +1,42 @@
1 /**
2 * The changelog page: the releases `release/changelog.ts` reads, as rows
3 * (version, date, and the pulls each carried). Public and server-rendered.
4 */
5
6 import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts";
7 import type { Ctx } from "../../http/context.ts";
8 import { forgeChangelog } from "../../release/changelog.ts";
9 import { html } from "../html.ts";
10 import { page } from "../layout.ts";
11 import { empty, listCard, row, time } from "../ui.ts";
12
13 // GET /changelog
14 export async function changelogPage(ctx: Ctx): Promise<Response> {
15 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
16 const read = await timePhaseAsync(ctx.request, "releases", () => forgeChangelog(ctx.app));
17 const rendering = performance.now();
18 const repo = read?.repo;
19 const entries = read?.entries ?? [];
20 const rows = entries.map((e) =>
21 row({
22 id: e.version,
23 href: `#${e.version}`,
24 title: e.version,
25 trail: time(e.date),
26 meta: html`${e.pulls.length} ${e.pulls.length === 1 ? "pull" : "pulls"}`,
27 children: e.pulls.map((p) =>
28 row({
29 href: `/${repo?.ownerName}/${repo?.name}/pulls/${p.number}`,
30 title: p.title,
31 trail: html`#${p.number}`,
32 }),
33 ),
34 }),
35 );
36 const body = html`<div class="page-head"><h1>Changelog</h1></div>${entries.length === 0
37 ? empty({ title: "No releases yet", note: "Each release lands here with its pulls." })
38 : listCard(rows)}`;
39 const response = page({ ctx, title: "Changelog", body });
40 notePhase(ctx.request, "render", performance.now() - rendering);
41 return response;
42 }
test/changelog.test.ts added +113
@@ -0,0 +1,113 @@
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 { getRepo } from "../src/core/repos.ts";
8 import { createUser, type User } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10
11 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
12 if (!r.ok) throw new Error(JSON.stringify(r.error));
13 return r.value;
14 };
15
16 describe("changelog page", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let alice: User;
21
22 const commit = async (repo: Awaited<ReturnType<typeof createRepo>> extends infer R ? (R extends { ok: true; value: infer V } ? V : never) : never, message: string, n: number) =>
23 must(await commitToBranch(app, alice, repo, { message, files: [{ path: `f${n}.txt`, content: `${n}\n` }] }));
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-changelog-"));
27 app = createApp({ dataDir, port: 0 });
28 servers = await serve(app);
29 alice = must(
30 await createUser(app.db, {
31 origin: "seed",
32 trusted: true,
33 username: "alice",
34 email: "[email protected]",
35 password: "a-good-passphrase",
36 adminIfFirst: true,
37 }),
38 );
39 const repo = must(await createRepo(app, alice, { name: "forge", visibility: "public" }));
40 app.config.forgeRepo = "alice/forge";
41 await commit(repo, "Release 0.1.0: First <b>thing</b> (#1)", 1);
42 await commit(repo, "unrelated work", 2);
43 await commit(repo, "Release 0.2.0: Second (#2);Third (#3)", 3);
44 await commit(repo, "Release 0.3.0: Fourth (#4)", 4);
45 }, 60_000);
46
47 afterAll(async () => {
48 servers.stop();
49 app.close();
50 await rm(dataDir, { recursive: true, force: true });
51 });
52
53 test("lists releases newest first with anchors, dates and escaped pull links, to anyone", async () => {
54 const res = await fetch(`${servers.url}/changelog`);
55 expect(res.status).toBe(200);
56 expect(res.headers.get("server-timing")).toContain("releases");
57 const body = await res.text();
58 const at = (s: string) => body.indexOf(s);
59 expect(at('id="0.3.0"')).toBeGreaterThan(-1);
60 expect(at('id="0.3.0"')).toBeLessThan(at('id="0.2.0"'));
61 expect(at('id="0.2.0"')).toBeLessThan(at('id="0.1.0"'));
62 expect(body).toContain('href="/alice/forge/pulls/2"');
63 expect(body).toContain('href="/alice/forge/pulls/3"');
64 expect(body).toContain("<time");
65 expect(body).toContain("First &lt;b&gt;thing&lt;/b&gt;");
66 expect(body).not.toContain("<b>thing</b>");
67 expect(body).not.toContain("unrelated work");
68 });
69
70 test("the footer links to it", async () => {
71 const body = await (await fetch(`${servers.url}/changelog`)).text();
72 expect(body).toContain('href="/changelog"');
73 });
74
75 test("with no forge repository it answers 200 and says there are no releases", async () => {
76 const kept = app.config.forgeRepo;
77 delete app.config.forgeRepo;
78 const res = await fetch(`${servers.url}/changelog`);
79 if (kept !== undefined) app.config.forgeRepo = kept;
80 expect(res.status).toBe(200);
81 expect(await res.text()).toContain("No releases yet");
82 });
83
84 test("a new release shows after the head moves", async () => {
85 const repo = getRepo(app.db, "alice", "forge")!;
86 await commit(repo, "Release 0.4.0: Fifth (#5)", 5);
87 const body = await (await fetch(`${servers.url}/changelog`)).text();
88 expect(body.indexOf('id="0.4.0"')).toBeGreaterThan(-1);
89 });
90
91 test("a private forge repository shows the empty state, with no version or pull link", async () => {
92 app.db.run("UPDATE repo SET visibility = 'private' WHERE name = 'forge'");
93 try {
94 const body = await (await fetch(`${servers.url}/changelog`)).text();
95 expect(body).toContain("No releases yet");
96 expect(body).not.toContain('id="0.');
97 expect(body).not.toContain("/alice/forge/pulls/");
98 } finally {
99 app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'");
100 }
101 });
102
103 test("only well-formed release subjects count, and the page shows exactly 30", async () => {
104 const repo = getRepo(app.db, "alice", "forge")!;
105 await commit(repo, "Release 9: not a version (#90)", 90);
106 for (let n = 0; n < 31; n++) await commit(repo, `Release 1.0.${n}: Thing ${n} (#${100 + n})`, 100 + n);
107 const body = await (await fetch(`${servers.url}/changelog`)).text();
108 expect(body.match(/<[a-z]+ [^>]*id="\d+\.\d+\.\d+"/g)?.length).toBe(30);
109 expect(body).toContain('id="1.0.30"');
110 expect(body).not.toContain('id="1.0.0"');
111 expect(body).not.toContain("not a version");
112 }, 60_000);
113 });
test/tui-changelog.test.ts added +101
@@ -0,0 +1,101 @@
1 /** The changelog in the terminal: the palette offers it to a guest, and the screen lists the page's releases. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { openPull } from "../src/actions/pulls.ts";
7 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
11 import type { Ctx } from "../src/tui/app/app.ts";
12 import { changelogScreen } from "../src/tui/screens/changelog.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { settleApp } from "./helpers/tui.ts";
16
17 quietLogs();
18
19 let dir: string;
20 let app: App;
21 const guest = { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test" };
22
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-tui-changelog-"));
25 app = createApp({ dataDir: dir, port: 0 });
26 const made = await createUser(app.db, {
27 origin: "seed",
28 trusted: true,
29 username: "alice",
30 email: "[email protected]",
31 password: "a-good-passphrase",
32 adminIfFirst: true,
33 });
34 if (!made.ok) throw new Error(made.error);
35 const repo = await createRepo(app, made.value, { name: "forge", visibility: "public" });
36 if (!repo.ok) throw new Error(JSON.stringify(repo.error));
37 app.config.forgeRepo = "alice/forge";
38 for (const [n, message] of ["Release 0.1.0: Older (#1)", "Release 0.2.0: Newer thing (#2)"].entries()) {
39 const done = await commitToBranch(app, made.value, repo.value, {
40 message,
41 files: [{ path: `f${n}.txt`, content: `${n}\n` }],
42 });
43 if (!done.ok) throw new Error(JSON.stringify(done.error));
44 }
45 // The pulls the releases name, so Enter has a pull to open: #1 then #2.
46 for (const [n, title] of ["Older", "Newer thing"].entries()) {
47 const branch = `p${n + 1}`;
48 const done = await commitToBranch(app, made.value, repo.value, {
49 message: title,
50 branch,
51 files: [{ path: `p${n}.txt`, content: `${n}\n` }],
52 });
53 if (!done.ok) throw new Error(JSON.stringify(done.error));
54 const pull = await openPull(app, made.value, repo.value, { title, baseBranch: repo.value.defaultBranch, headBranch: branch });
55 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
56 }
57 }, 60_000);
58
59 afterAll(async () => {
60 app.close();
61 await rm(dir, { recursive: true, force: true });
62 });
63
64 test("a guest is offered the changelog and sees the releases newest first", async () => {
65 expect(forgeCommands(new Session(app, guest))({} as Ctx).some((c) => c.id === "changelog")).toBe(true);
66 const terminal = headlessTerminal(120, 30);
67 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
68 tui.start();
69 tui.ctx.push(changelogScreen(session, tui.ctx));
70 await settleApp(tui);
71 const text = tui.screen.toText();
72 terminal.feed("\r");
73 await settleApp(tui);
74 const opened = tui.screen.toText();
75 tui.stop?.();
76 // Enter opens the first pull of the newest release: pull #2's own screen.
77 expect(opened).not.toBe(text);
78 expect(opened).toContain("Changelog › #2");
79 expect(opened).not.toContain("0.1.0");
80 expect(text.indexOf("0.2.0")).toBeGreaterThan(-1);
81 expect(text.indexOf("0.2.0")).toBeLessThan(text.indexOf("0.1.0"));
82 expect(text).toContain("#2 Newer thing");
83 });
84
85 test("a private forge repository leaves the terminal's screen empty", async () => {
86 app.db.run("UPDATE repo SET visibility = 'private' WHERE name = 'forge'");
87 const terminal = headlessTerminal(120, 30);
88 const { app: tui, session } = createForgeApp(app, guest, { terminal, exitHooks: false });
89 try {
90 tui.start();
91 tui.ctx.push(changelogScreen(session, tui.ctx));
92 await settleApp(tui);
93 const text = tui.screen.toText();
94 tui.stop?.();
95 expect(text).toContain("No releases yet");
96 expect(text).not.toContain("0.2.0");
97 expect(text).not.toContain("#2");
98 } finally {
99 app.db.run("UPDATE repo SET visibility = 'public' WHERE name = 'forge'");
100 }
101 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs