Public: anyone can read this; the crew can change it.
Pull #3151's check-cell memory line clears its five review comments and lands #3154
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3153-pull-3151-s-check-cell-memory-line-clear → main 10 commits fork
7 files
changed +397
−8
README.md +2
| @@ -221,6 +221,8 @@ The repository's settings page is one more door: a writer sees their proposal | ||
| 221 | 221 | tokens one to a line (child, origin, expires, last used) with Revoke, and a |
| 222 | 222 | form that mints one and shows its secret once; others see no section. |
| 223 | 223 | |
| 224 | The check cell's memory is on the same page under **Check cell**, and in the terminal on a repository's About card (`I`, for an admin): the limit the machine enforces, read from `forge.json` on the default branch. When `check.limits.memory` asks for more than the machine's ceiling it shows both numbers and where the cut comes from (`FORGE_CELL_MEMORY_MAX_MIB`, else a share of the pod's memory); otherwise one number. The ceiling is read once and kept (`src/apps/check-memory.ts`). | |
| 225 | ||
| 224 | 226 | - **MCP**: the `proposal_tokens` tool with `repo` and a `verb`: `mint` |
| 225 | 227 | (`child`, `origin`, optionally `childRepo`), `list` (the ones you minted for |
| 226 | 228 | the repository) or `revoke` (`id`). |
src/apps/check-memory.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * The check cell's memory in words: what forge.json asks for and what the | |
| 3 | * machine holds the cell to. One number when they agree; both, and where the | |
| 4 | * cut comes from, when forge.json asks for more. The settings page and the | |
| 5 | * terminal say the same sentence. | |
| 6 | */ | |
| 7 | ||
| 8 | import { readFileAt } from "./checkout.ts"; | |
| 9 | import { memoryCeilingSource, POD_MEMORY_FILE, POD_MEMORY_SHARE, resolveLimits, type Limits } from "./contain.ts"; | |
| 10 | import { parseCheck } from "./manifest.ts"; | |
| 11 | ||
| 12 | interface Held { | |
| 13 | readonly limits: Limits; | |
| 14 | readonly from: "env" | "pod" | "default"; | |
| 15 | } | |
| 16 | ||
| 17 | const held = new Map<string, Held>(); | |
| 18 | ||
| 19 | let podFile = POD_MEMORY_FILE; | |
| 20 | ||
| 21 | /** Read the pod's memory limit from `path` (a test's fixture); the returned function puts the real file back. */ | |
| 22 | export function usePodMemoryFile(path: string): () => void { | |
| 23 | podFile = path; | |
| 24 | return () => { | |
| 25 | podFile = POD_MEMORY_FILE; | |
| 26 | }; | |
| 27 | } | |
| 28 | ||
| 29 | /** | |
| 30 | * The limits a check cell gets for a declared memory. `resolveLimits` reads | |
| 31 | * the pod's cgroup file, so the answer is kept per (env, declaration) and the | |
| 32 | * file is read once, not on every request. | |
| 33 | */ | |
| 34 | function cellLimits(declared: number | undefined): Held { | |
| 35 | const env = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 36 | const key = `${podFile}|${env ?? ""}|${declared ?? ""}`; | |
| 37 | let kept = held.get(key); | |
| 38 | if (!kept) { | |
| 39 | kept = { | |
| 40 | limits: resolveLimits("step", declared === undefined ? undefined : { memoryMiB: declared }, podFile), | |
| 41 | from: memoryCeilingSource(env, podFile), | |
| 42 | }; | |
| 43 | if (held.size >= 64) held.clear(); | |
| 44 | held.set(key, kept); | |
| 45 | } | |
| 46 | return kept; | |
| 47 | } | |
| 48 | ||
| 49 | /** Where the ceiling a cut was held to comes from, in words. */ | |
| 50 | export function ceilingWords(from: "env" | "pod" | "default"): string { | |
| 51 | return from === "env" | |
| 52 | ? "the machine's ceiling, FORGE_CELL_MEMORY_MAX_MIB" | |
| 53 | : from === "pod" | |
| 54 | ? `${Math.round(POD_MEMORY_SHARE * 100)}% of the pod's memory.max` | |
| 55 | : "the machine's default ceiling; the pod states no memory limit"; | |
| 56 | } | |
| 57 | ||
| 58 | /** The sentence for a declared memory; with none declared it says the default holds. Only validated numbers enter it. */ | |
| 59 | export function checkMemoryWords(declared: number | undefined): string { | |
| 60 | const { limits: cell, from } = cellLimits(declared); | |
| 61 | if (declared === undefined) return `The check cell holds ${cell.memoryMiB} MiB (the default; forge.json declares none).`; | |
| 62 | if (cell.memorySource !== "ceiling") return `The check cell holds ${cell.memoryMiB} MiB.`; | |
| 63 | return `forge.json asks ${declared} MiB; the cell holds ${cell.memoryMiB} MiB (${ceilingWords(from)}).`; | |
| 64 | } | |
| 65 | ||
| 66 | /** What forge.json declared at a commit: `null` unreadable, `undefined` no check, else its memory (or none). */ | |
| 67 | type Declared = null | undefined | { readonly memoryMiB: number | undefined }; | |
| 68 | ||
| 69 | const declaredAt = new Map<string, Declared>(); | |
| 70 | ||
| 71 | /** | |
| 72 | * The sentence for the check forge.json declares at `oid`. `undefined` when | |
| 73 | * it declares none; `null` when it declares one that cannot be read. A commit | |
| 74 | * never changes, so what it declares is read from git once per (dir, oid). | |
| 75 | */ | |
| 76 | export async function checkMemoryAt( | |
| 77 | dir: string, | |
| 78 | oid: string | undefined, | |
| 79 | ): Promise<string | null | undefined> { | |
| 80 | if (oid === undefined) return undefined; | |
| 81 | const key = `${dir}|${oid}`; | |
| 82 | let declared = declaredAt.get(key); | |
| 83 | if (declared === undefined && !declaredAt.has(key)) { | |
| 84 | const [forgeJson, packageJson] = await Promise.all([ | |
| 85 | readFileAt(dir, oid, "forge.json"), | |
| 86 | readFileAt(dir, oid, "package.json"), | |
| 87 | ]); | |
| 88 | const check = parseCheck(forgeJson, packageJson); | |
| 89 | declared = !check.ok ? null : check.value === undefined ? undefined : { memoryMiB: check.value.limits.memoryMiB }; | |
| 90 | if (declaredAt.size >= 256) declaredAt.clear(); | |
| 91 | declaredAt.set(key, declared); | |
| 92 | } | |
| 93 | return declared === null ? null : declared === undefined ? undefined : checkMemoryWords(declared.memoryMiB); | |
| 94 | } |
src/apps/contain.ts +26 −7
| @@ -63,14 +63,17 @@ export const DEFAULT_LIMITS: Record<"app" | "step", Limits> = { | ||
| 63 | 63 | }; |
| 64 | 64 | |
| 65 | 65 | /** The most any declaration gets on this machine. */ |
| 66 | export function maxLimits(): Limits { | |
| 66 | export function maxLimits(podFile = POD_MEMORY_FILE): Limits { | |
| 67 | 67 | return { |
| 68 | memoryMiB: memoryCeiling(process.env["FORGE_CELL_MEMORY_MAX_MIB"]), | |
| 68 | memoryMiB: memoryCeiling(process.env["FORGE_CELL_MEMORY_MAX_MIB"], podFile), | |
| 69 | 69 | cpus: availableParallelism(), |
| 70 | 70 | diskMiB: positive(process.env["FORGE_CELL_DISK_MAX_MIB"]) ?? 16384, |
| 71 | 71 | }; |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | /** The pod's own memory limit, where cgroup v2 states it. */ | |
| 75 | export const POD_MEMORY_FILE = "/sys/fs/cgroup/memory.max"; | |
| 76 | ||
| 74 | 77 | /** The share of the pod's memory a single cell may be given. */ |
| 75 | 78 | export const POD_MEMORY_SHARE = 0.75; |
| 76 | 79 | |
| @@ -81,18 +84,33 @@ export const POD_MEMORY_SHARE = 0.75; | ||
| 81 | 84 | */ |
| 82 | 85 | export function memoryCeiling( |
| 83 | 86 | env: string | undefined, |
| 84 | podFile = "/sys/fs/cgroup/memory.max", | |
| 87 | podFile = POD_MEMORY_FILE, | |
| 85 | 88 | ): number { |
| 89 | return ceiling(env, podFile).mib; | |
| 90 | } | |
| 91 | ||
| 92 | /** Where the ceiling comes from: the variable, the pod's `memory.max`, or the fixed default. */ | |
| 93 | export function memoryCeilingSource( | |
| 94 | env: string | undefined, | |
| 95 | podFile = POD_MEMORY_FILE, | |
| 96 | ): "env" | "pod" | "default" { | |
| 97 | return ceiling(env, podFile).from; | |
| 98 | } | |
| 99 | ||
| 100 | function ceiling( | |
| 101 | env: string | undefined, | |
| 102 | podFile: string, | |
| 103 | ): { mib: number; from: "env" | "pod" | "default" } { | |
| 86 | 104 | const set = positive(env); |
| 87 | if (set !== undefined) return set; | |
| 105 | if (set !== undefined) return { mib: set, from: "env" }; | |
| 88 | 106 | try { |
| 89 | 107 | const bytes = Number(readFileSync(podFile, "utf8").trim()); |
| 90 | 108 | if (Number.isFinite(bytes) && bytes > 0) |
| 91 | return Math.max(1, Math.floor((bytes * POD_MEMORY_SHARE) / 1048576)); | |
| 109 | return { mib: Math.max(1, Math.floor((bytes * POD_MEMORY_SHARE) / 1048576)), from: "pod" }; | |
| 92 | 110 | } catch { |
| 93 | 111 | // no cgroup v2 here, or "max" (unlimited): the fixed default below |
| 94 | 112 | } |
| 95 | return 4096; | |
| 113 | return { mib: 4096, from: "default" }; | |
| 96 | 114 | } |
| 97 | 115 | |
| 98 | 116 | /** The line saying a declared memory limit is cut to the machine's ceiling; none when it is not. */ |
| @@ -122,9 +140,10 @@ function positive(raw: string | undefined): number | undefined { | ||
| 122 | 140 | export function resolveLimits( |
| 123 | 141 | kind: "app" | "step", |
| 124 | 142 | declared: DeclaredLimits | undefined, |
| 143 | podFile = POD_MEMORY_FILE, | |
| 125 | 144 | ): Limits { |
| 126 | 145 | const base = DEFAULT_LIMITS[kind]; |
| 127 | const max = maxLimits(); | |
| 146 | const max = maxLimits(podFile); | |
| 128 | 147 | const want = declared?.memoryMiB; |
| 129 | 148 | return { |
| 130 | 149 | memoryMiB: Math.min(want ?? base.memoryMiB, max.memoryMiB), |
src/tui/screens/about.ts +27 −1
| @@ -19,13 +19,16 @@ import { audienceFor } from "../../core/audience.ts"; | ||
| 19 | 19 | import { isPinned } from "../../core/pins.ts"; |
| 20 | 20 | import { parentReleases, releasesSentence } from "../../core/parent_releases.ts"; |
| 21 | 21 | import { running } from "../../kernel/version.ts"; |
| 22 | import { checkMemoryAt } from "../../apps/check-memory.ts"; | |
| 23 | import { repoDir } from "../../git/repos.ts"; | |
| 24 | import { listRefs } from "../../git/read.ts"; | |
| 22 | 25 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 23 | 26 | import { canAdmin, getRepoById, type Repo } from "../../core/repos.ts"; |
| 24 | 27 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 25 | 28 | import type { Ctx, Screen } from "../app/app.ts"; |
| 26 | 29 | import { binding } from "../app/keys.ts"; |
| 27 | 30 | import { style } from "../term/style.ts"; |
| 28 | import { truncate } from "../term/width.ts"; | |
| 31 | import { truncate, wrap } from "../term/width.ts"; | |
| 29 | 32 | import { badge, card } from "../ui/widgets.ts"; |
| 30 | 33 | import type { Session } from "../session.ts"; |
| 31 | 34 | import { PIN_MARK, ago, drawFacts, plural } from "./common.ts"; |
| @@ -49,11 +52,34 @@ export function aboutScreen( | ||
| 49 | 52 | let repo = localRepo(src); |
| 50 | 53 | const may = (): boolean => |
| 51 | 54 | repo !== undefined && f.user !== undefined && canAdmin(f.db, repo, f.user); |
| 55 | let cell: [string, string][] = []; | |
| 52 | 56 | const factRows = (): [string, string][] => [ |
| 53 | 57 | ...archivedRow(repo), |
| 54 | 58 | ...aboutFacts(f, src, facts, ref), |
| 59 | ...cell, | |
| 55 | 60 | ]; |
| 56 | 61 | let rows = factRows(); |
| 62 | // An admin also sees the check cell's memory, read once from forge.json. | |
| 63 | if (may() && repo !== undefined) { | |
| 64 | const at = repo; | |
| 65 | void (async () => { | |
| 66 | const refs = await listRefs(repoDir(f.app.reposRoot, at.ownerName, at.name), "refs/heads/"); | |
| 67 | const tip = refs.ok | |
| 68 | ? refs.value.find((r) => r.name === `refs/heads/${at.defaultBranch}`)?.oid | |
| 69 | : undefined; | |
| 70 | const words = await checkMemoryAt(repoDir(f.app.reposRoot, at.ownerName, at.name), tip); | |
| 71 | if (words === undefined) return; | |
| 72 | cell = wrap(words ?? "forge.json's check could not be read", 50).map( | |
| 73 | (line, i): [string, string] => [i === 0 ? "check cell" : "", line], | |
| 74 | ); | |
| 75 | rows = factRows(); | |
| 76 | ctx.invalidate(); | |
| 77 | })().catch(() => { | |
| 78 | cell = [["check cell", "forge.json's check could not be read"]]; | |
| 79 | rows = factRows(); | |
| 80 | ctx.invalidate(); | |
| 81 | }); | |
| 82 | } | |
| 57 | 83 | const cloneUrl = rows.find(([label]) => label.startsWith("clone"))?.[1] ?? ""; |
| 58 | 84 | return { |
| 59 | 85 | title: "About", |
src/web/pages/settings.ts +12
| @@ -105,6 +105,7 @@ import { | ||
| 105 | 105 | import { cutWaveFor } from "../../actions/cut_wave.ts"; |
| 106 | 106 | import { proposalTokensSection } from "./proposal-tokens.ts"; |
| 107 | 107 | import { secretsSection } from "./secrets.ts"; |
| 108 | import { checkMemoryAt } from "../../apps/check-memory.ts"; | |
| 108 | 109 | import { saveGrants } from "./subscriptions.ts"; |
| 109 | 110 | |
| 110 | 111 | type Section = |
| @@ -231,6 +232,10 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 231 | 232 | const branches = refs.ok |
| 232 | 233 | ? refs.value.map((r) => r.name.replace("refs/heads/", "")).sort() |
| 233 | 234 | : []; |
| 235 | const tip = refs.ok | |
| 236 | ? refs.value.find((r) => r.name === `refs/heads/${repo.defaultBranch}`)?.oid | |
| 237 | : undefined; | |
| 238 | const cellMemory = await checkMemoryAt(dir, tip); | |
| 234 | 239 | if (!branches.includes(repo.defaultBranch)) |
| 235 | 240 | branches.unshift(repo.defaultBranch); |
| 236 | 241 | |
| @@ -308,6 +313,13 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 308 | 313 | > |
| 309 | 314 | </label> |
| 310 | 315 | </div> |
| 316 | ${cellMemory === undefined | |
| 317 | ? null | |
| 318 | : html`<div class="field"><p class="hint" data-check-memory>${ | |
| 319 | cellMemory === null | |
| 320 | ? "forge.json's check could not be read, so no limit is shown." | |
| 321 | : cellMemory | |
| 322 | }</p></div>`} | |
| 311 | 323 | <div class="form-actions"> |
| 312 | 324 | ${btn("Save changes", { variant: "primary" })} |
| 313 | 325 | </div> |
test/settings-check-memory.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** #3135: the settings page shows the check cell's memory the machine enforces, beside forge.json's. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 9 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 10 | import { adminCookie } from "./helpers/admin.ts"; | |
| 11 | import { quietLogs } from "./helpers/globals.ts"; | |
| 12 | ||
| 13 | quietLogs(); | |
| 14 | ||
| 15 | let dir = ""; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let cookie = ""; | |
| 19 | const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 20 | ||
| 21 | const check = (limits: string) => | |
| 22 | `{"check":{"run":"true","limits":${limits}}}`; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-cellmem-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0 }); | |
| 28 | servers = await serve(app); | |
| 29 | cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "settings-password" }); | |
| 30 | }, 60_000); | |
| 31 | ||
| 32 | afterAll(async () => { | |
| 33 | if (saved === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 34 | else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = saved; | |
| 35 | servers.stop(); | |
| 36 | app.close(); | |
| 37 | await rm(dir, { recursive: true, force: true }); | |
| 38 | }); | |
| 39 | ||
| 40 | async function repoWith(name: string, forgeJson: string): Promise<string> { | |
| 41 | const res = await fetch(`${servers.url}/new`, { | |
| 42 | method: "POST", | |
| 43 | headers: { cookie }, | |
| 44 | body: new URLSearchParams({ name, visibility: "private", description: "x" }), | |
| 45 | redirect: "manual", | |
| 46 | }); | |
| 47 | expect(res.status).toBeLessThan(400); | |
| 48 | const { getRepo } = await import("../src/core/repos.ts"); | |
| 49 | const { getUserByName } = await import("../src/core/users.ts"); | |
| 50 | const owner = getUserByName(app.db, "olive"); | |
| 51 | const repo = getRepo(app.db, "olive", name); | |
| 52 | if (!owner || !repo) throw new Error("setup"); | |
| 53 | const c = await commitToBranch(app, owner, repo, { | |
| 54 | message: "forge.json", | |
| 55 | files: [{ path: "forge.json", content: forgeJson }], | |
| 56 | }); | |
| 57 | expect(c.ok).toBe(true); | |
| 58 | return await (await fetch(`${servers.url}/olive/${name}/settings`, { headers: { cookie } })).text(); | |
| 59 | } | |
| 60 | ||
| 61 | test("a declared limit above the ceiling shows both numbers and the source", async () => { | |
| 62 | const body = await repoWith("over", check('{"memory":8192}')); | |
| 63 | expect(body).toContain("forge.json asks 8192 MiB"); | |
| 64 | expect(body).toContain("4096 MiB"); | |
| 65 | expect(body).toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("a declared limit below the ceiling shows one number and no warning", async () => { | |
| 69 | const body = await repoWith("under", check('{"memory":1024}')); | |
| 70 | expect(body).toContain("1024 MiB"); | |
| 71 | expect(body).not.toContain("forge.json asks"); | |
| 72 | expect(body).not.toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 73 | }); | |
| 74 | ||
| 75 | test("with no ceiling set and nothing declared, the default is named", async () => { | |
| 76 | delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 77 | try { | |
| 78 | const body = await repoWith("plain", '{"check":{"run":"true"}}'); | |
| 79 | expect(body).toContain("2048 MiB (the default; forge.json declares none)"); | |
| 80 | expect(body).not.toContain("forge.json asks"); | |
| 81 | } finally { | |
| 82 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 83 | } | |
| 84 | }); | |
| 85 | ||
| 86 | test("an unreadable check says so instead of vanishing", async () => { | |
| 87 | const body = await repoWith("broken", '{"check":{"run":"true","limits":{"memory":"<b>x</b>"}}}'); | |
| 88 | expect(body).toContain("forge.json's check could not be read"); | |
| 89 | expect(body).not.toContain("<b>x</b>"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("with no variable set, a cut is blamed on the pod, not on the variable", async () => { | |
| 93 | delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 94 | const { usePodMemoryFile } = await import("../src/apps/check-memory.ts"); | |
| 95 | const { writeFile } = await import("node:fs/promises"); | |
| 96 | const pod = join(dir, "pod-memory.max"); | |
| 97 | await writeFile(pod, String(8192 * 1048576)); | |
| 98 | const restore = usePodMemoryFile(pod); | |
| 99 | try { | |
| 100 | const body = await repoWith("pod", check('{"memory":65536}')); | |
| 101 | expect(body).toContain("forge.json asks 65536 MiB"); | |
| 102 | expect(body).toContain("6144 MiB"); | |
| 103 | expect(body).toContain("75% of the pod's memory.max"); | |
| 104 | expect(body).not.toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 105 | } finally { | |
| 106 | restore(); | |
| 107 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 108 | } | |
| 109 | }); | |
| 110 | ||
| 111 | test("the ceiling's origin is named from the environment, then the pod file", async () => { | |
| 112 | const { memoryCeilingSource } = await import("../src/apps/contain.ts"); | |
| 113 | const { writeFile } = await import("node:fs/promises"); | |
| 114 | const pod = join(dir, "memory.max"); | |
| 115 | await writeFile(pod, String(8192 * 1048576)); | |
| 116 | expect(memoryCeilingSource("2048", pod)).toBe("env"); | |
| 117 | expect(memoryCeilingSource(undefined, pod)).toBe("pod"); | |
| 118 | expect(memoryCeilingSource(undefined, join(dir, "absent"))).toBe("default"); | |
| 119 | }); | |
| 120 | ||
| 121 | test("each origin has its own words", async () => { | |
| 122 | const { ceilingWords } = await import("../src/apps/check-memory.ts"); | |
| 123 | expect(ceilingWords("env")).toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 124 | expect(ceilingWords("pod")).toBe("75% of the pod's memory.max"); | |
| 125 | expect(ceilingWords("default")).not.toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 126 | }); | |
| 127 | ||
| 128 | test("what forge.json declares at a commit is read from git once", async () => { | |
| 129 | const { checkMemoryAt } = await import("../src/apps/check-memory.ts"); | |
| 130 | const { repoDir } = await import("../src/git/repos.ts"); | |
| 131 | const { listRefs } = await import("../src/git/read.ts"); | |
| 132 | await repoWith("kept", check('{"memory":1024}')); | |
| 133 | const at = repoDir(app.reposRoot, "olive", "kept"); | |
| 134 | const refs = await listRefs(at, "refs/heads/"); | |
| 135 | const oid = refs.ok ? refs.value.find((r) => r.name === "refs/heads/main")?.oid : undefined; | |
| 136 | expect(oid).toBeDefined(); | |
| 137 | const first = await checkMemoryAt(at, oid); | |
| 138 | expect(first).toContain("1024 MiB"); | |
| 139 | await rm(at, { recursive: true, force: true }); | |
| 140 | expect(await checkMemoryAt(at, oid)).toBe(first); | |
| 141 | }); |
test/tui-check-memory.test.ts added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | /** #3135: About, in the terminal, says the check cell's memory in the settings page's words. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, expect, spyOn, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 9 | import type { User } from "../src/core/users.ts"; | |
| 10 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 11 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 14 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | let dataDir = ""; | |
| 19 | let forge: Forge; | |
| 20 | let alice: User; | |
| 21 | const saved = process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 22 | ||
| 23 | beforeAll(async () => { | |
| 24 | process.env["FORGE_CELL_MEMORY_MAX_MIB"] = "4096"; | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-cellmem-")); | |
| 26 | forge = createApp({ dataDir, port: 0 }); | |
| 27 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 28 | if (!a.ok) throw new Error("signup failed"); | |
| 29 | alice = a.value; | |
| 30 | const made = await createRepo(forge, alice, { name: "beacon", visibility: "public" }); | |
| 31 | if (!made.ok) throw new Error("repo failed"); | |
| 32 | const c = await commitToBranch(forge, alice, made.value, { | |
| 33 | message: "forge.json", | |
| 34 | files: [{ path: "forge.json", content: '{"check":{"run":"true","limits":{"memory":8192}}}' }], | |
| 35 | }); | |
| 36 | if (!c.ok) throw new Error("commit failed"); | |
| 37 | }); | |
| 38 | ||
| 39 | afterAll(async () => { | |
| 40 | if (saved === undefined) delete process.env["FORGE_CELL_MEMORY_MAX_MIB"]; | |
| 41 | else process.env["FORGE_CELL_MEMORY_MAX_MIB"] = saved; | |
| 42 | await rm(dataDir, { recursive: true, force: true }); | |
| 43 | }); | |
| 44 | ||
| 45 | test("About shows the cut and its source to an admin", async () => { | |
| 46 | const terminal = headlessTerminal(160, 40); | |
| 47 | const { app } = createForgeApp( | |
| 48 | forge, | |
| 49 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, | |
| 50 | { terminal, exitHooks: false }, | |
| 51 | ); | |
| 52 | app.start(); | |
| 53 | const press = async (...keys: string[]) => { | |
| 54 | for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 55 | await settleApp(app); | |
| 56 | }; | |
| 57 | await settleApp(app); | |
| 58 | await press("/"); | |
| 59 | for (const ch of "beacon") await press(ch); | |
| 60 | await press("enter"); | |
| 61 | await press("enter"); | |
| 62 | await settleUntil(app, "alice/beacon"); | |
| 63 | await press("I"); | |
| 64 | await settleUntil(app, "forge.json asks 8192 MiB"); | |
| 65 | expect(app.screen.toText()).toContain("FORGE_CELL_MEMORY_MAX_MIB"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("About says the check could not be read when reading it rejects", async () => { | |
| 69 | const memory = await import("../src/apps/check-memory.ts"); | |
| 70 | const spy = spyOn(memory, "checkMemoryAt").mockRejectedValue(new Error("git is gone")); | |
| 71 | try { | |
| 72 | const terminal = headlessTerminal(160, 40); | |
| 73 | const { app } = createForgeApp( | |
| 74 | forge, | |
| 75 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, | |
| 76 | { terminal, exitHooks: false }, | |
| 77 | ); | |
| 78 | app.start(); | |
| 79 | const press = async (...keys: string[]) => { | |
| 80 | for (const key of keys) app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 81 | await settleApp(app); | |
| 82 | }; | |
| 83 | await settleApp(app); | |
| 84 | await press("/"); | |
| 85 | for (const ch of "beacon") await press(ch); | |
| 86 | await press("enter"); | |
| 87 | await press("enter"); | |
| 88 | await settleUntil(app, "alice/beacon"); | |
| 89 | await press("I"); | |
| 90 | await settleUntil(app, "could not be read"); | |
| 91 | expect(spy).toHaveBeenCalled(); | |
| 92 | } finally { | |
| 93 | spy.mockRestore(); | |
| 94 | } | |
| 95 | }); |
new above
offline