Public: anyone can read this; the crew can change it.
Flake hunt and outside gate run from the forge on their schedule and file what the Forgejo jobs filed #9029
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9019-flake-hunt-and-outside-gate-run-from-the → main 8 commits fork
12 files
changed +555
−21
docs/DEVELOP.md +6 −2
| @@ -193,8 +193,12 @@ stop (a release) aborts the running step at once. The forge needs `FORGE_TOKEN` | ||
| 193 | 193 | hunt's author) and cells; a preview runs none, and a forge without either says |
| 194 | 194 | so in its log and runs none. A job whose slot passed while the forge was down |
| 195 | 195 | runs on waking; one restarted mid-run is not run twice (`nightly/<job>.json` |
| 196 | in `FORGE_APP_DATA`). What each last did is on Resilience and in the | |
| 197 | terminal's Resilience dialog (`docs/features/nightly.md`). | |
| 196 | in `FORGE_APP_DATA`). What each last did, and what it filed (`filed #12`, | |
| 197 | `closed #4`, `quarantined test/x.test.ts`), is on Resilience and in the | |
| 198 | terminal's Resilience dialog; the run's whole output is | |
| 199 | `/resilience/nightly/<job>` (`nightly/<job>.log`), the run every issue it | |
| 200 | files names, as the Forgejo jobs named their Actions run | |
| 201 | (`docs/features/nightly.md`). | |
| 198 | 202 | |
| 199 | 203 | **No unseen red outside the gate.** Nightly at 03:47 UTC, the forge itself |
| 200 | 204 | runs `bun scripts/flake-hunt.ts outside --file-issues`: every file in |
docs/features/nightly.md +15 −2
| @@ -9,7 +9,9 @@ Both are `scripts/flake-hunt.ts`, run in a cell on a checkout of the default bra | ||
| 9 | 9 | |
| 10 | 10 | ## Seeing it |
| 11 | 11 | |
| 12 | Resilience (admins) and the terminal's Resilience dialog say, per job, when it last ran and what it filed or why it did not run. The record is `FORGE_APP_DATA/nightly/<job>.json`. | |
| 12 | Resilience (admins) and the terminal's Resilience dialog say, per job, when it last ran and what it filed (`filed #12, closed #4, quarantined test/x.test.ts`, or `filed nothing`) or why it did not run. The record is `FORGE_APP_DATA/nightly/<job>.json`. What it filed is read from the hunt script's own lines (the tests it runs print to the hunt, not to the output), so it is what the default branch's script says it did: advisory, the issues are the truth. | |
| 13 | ||
| 14 | Each line links to the job's newest output, `/resilience/nightly/<job>` (admins): every step's command and what it printed, kept in `FORGE_APP_DATA/nightly/<job>.log`, replaced by the next run and cut at 1 MB. In the terminal's Resilience dialog `o` shows the tail of each. It is the run a `Flaky:` or `Red:` issue names (`FORGE_RUN_URL`), where the Forgejo jobs named their Actions run. | |
| 13 | 15 | |
| 14 | 16 | ## When it runs |
| 15 | 17 | |
| @@ -21,4 +23,15 @@ With a token and cells, a job runs when its slot comes. To run one now from a te | ||
| 21 | 23 | |
| 22 | 24 | ## What the Forgejo workflows were |
| 23 | 25 | |
| 24 | `flake-hunt.yml` and `outside-gate.yml` stay on the mirror until the forge's own run has filed one real result. Both dedupe on the open `Flaky:` and `Red:` issues, so they cannot double-file; the pull that deletes them carries one night's output from each side. `sync-env.yml` sealed the repository's Actions secrets into the platform's `forge-user-env` Secret, which fed the pod's environment (the crew's Claude credential, `FORGE_TOKEN`). The forge already holds its own secrets, so there is nothing to port: the Secret goes with the pod (`docs/PLATFORM.md`, rows 2 and 3). | |
| 26 | `flake-hunt.yml` and `outside-gate.yml` stay on the mirror until the forge's own run has filed one real result. Both dedupe on the open `Flaky:` and `Red:` issues the token's own user filed, so they cannot double-file while the forge's `FORGE_TOKEN` and the mirror's secret act as the same user (the Forgejo hunt's issues are Mason's); with two users, each files its own, which is the comparison in plain sight. The pull that deletes them (#9020) carries one night's output from each side. | |
| 27 | ||
| 28 | `sync-env.yml` sealed the repository's Actions secrets into the platform's `forge-user-env` Secret, which fed the pod's environment (the crew's Claude credential, `FORGE_TOKEN`). The forge already holds its own secrets, so there is nothing to port: the Secret goes with the pod (`docs/PLATFORM.md`, rows 2 and 3). | |
| 29 | ||
| 30 | ### Comparing one night | |
| 31 | ||
| 32 | For one date, set side by side: | |
| 33 | ||
| 34 | - **Forgejo:** the `flake-hunt` and `outside-gate` Actions runs on the mirror (each slice's log, the quarantine job's log). | |
| 35 | - **Forge:** `/resilience/nightly/flake-hunt` and `/resilience/nightly/outside-gate`, and the line above each: what it filed. | |
| 36 | ||
| 37 | They agree when the outside run lists the same files green and red, both open, update and close the same `Red:` issues (or one finds the other's already open), and the hunt's flaky files, which vary with the seed, are each filed once. A file red on one side and green on the other is the finding: the cell (no network but the forge and the registry, its CPU and memory caps) differs from the `isolated` runner. |
src/http/server.ts +2 −1
| @@ -25,7 +25,7 @@ import { prerenderComments } from "../web/prerender.ts"; | ||
| 25 | 25 | import { systemPage, systemStream } from "../web/pages/system.ts"; |
| 26 | 26 | import { contributePage } from "../web/pages/contribute.ts"; |
| 27 | 27 | import { pipelinePage } from "../web/pages/pipeline.ts"; |
| 28 | import { resiliencePage } from "../web/pages/resilience.ts"; | |
| 28 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; | |
| 29 | 29 | import { limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 30 | 30 | import { measureSubmit } from "./measure.ts"; |
| 31 | 31 | import { settingsRoutes } from "./settings_routes.ts"; |
| @@ -827,6 +827,7 @@ export function buildRouter(): Router<Handler> { | ||
| 827 | 827 | pinnedRoutes(router); |
| 828 | 828 | // Each THREATS.md protection against the last gate; literal, above /:owner. |
| 829 | 829 | router.add("GET", "/resilience", resiliencePage); |
| 830 | router.add("GET", "/resilience/nightly/:job", nightlyOutputPage); | |
| 830 | 831 | // A selection's spend and runs over time; literal, above /:owner. |
| 831 | 832 | router.add("GET", "/fleet/graph", fleetGraphPage); |
| 832 | 833 | router.add("GET", "/fleet/graph/live", fleetGraphLive); |
src/nightly/output.ts added +136
| @@ -0,0 +1,136 @@ | ||
| 1 | /** | |
| 2 | * What a nightly job printed, kept: the newest run's output of each job in | |
| 3 | * one file under `FORGE_APP_DATA/nightly`, so the `Flaky:` and `Red:` | |
| 4 | * issues it files can name the run (`runPath`) as the Forgejo jobs named | |
| 5 | * theirs, and one night here can be read beside one night there. Capped, | |
| 6 | * replaced by the next run, written in batches off the request path. | |
| 7 | * | |
| 8 | * `filedIn` reads back what the run filed from the lines | |
| 9 | * `scripts/flake-hunt.ts` prints when it does, and only from the hunt's own | |
| 10 | * steps (`readsFiled`): the hunt keeps the output of the tests it runs to | |
| 11 | * itself, so a test cannot print a line into the record. The record is | |
| 12 | * still advisory: it is what the default branch's script says it did, and | |
| 13 | * the issues themselves are the truth. Plain data. | |
| 14 | */ | |
| 15 | ||
| 16 | import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"; | |
| 17 | import { appendFile } from "node:fs/promises"; | |
| 18 | import { join } from "node:path"; | |
| 19 | import type { Step } from "./jobs.ts"; | |
| 20 | ||
| 21 | /** Past this, a run's output is cut with a line that says so. */ | |
| 22 | export const OUTPUT_CAP_BYTES = 1_000_000; | |
| 23 | /** Lines wait this long, or until this many bytes are waiting, before they are written. */ | |
| 24 | const FLUSH_MS = 1000; | |
| 25 | const FLUSH_BYTES = 64 * 1024; | |
| 26 | ||
| 27 | const fileOf = (dataDir: string, name: string): string => join(dataDir, "nightly", `${name}.log`); | |
| 28 | ||
| 29 | /** The page that shows a job's newest output: what an issue it files links to. */ | |
| 30 | export const runPath = (name: string): string => `/resilience/nightly/${name}`; | |
| 31 | ||
| 32 | export interface Output { | |
| 33 | /** Appends a line; it reaches the file within FLUSH_MS. */ | |
| 34 | readonly write: (line: string) => void; | |
| 35 | /** Writes what waits; the output is whole once it settles. */ | |
| 36 | readonly end: () => Promise<void>; | |
| 37 | } | |
| 38 | ||
| 39 | /** A fresh output file for `name`: the last night's is gone once this returns. */ | |
| 40 | export function startOutput(dataDir: string, name: string): Output { | |
| 41 | mkdirSync(join(dataDir, "nightly"), { recursive: true }); | |
| 42 | const file = fileOf(dataDir, name); | |
| 43 | writeFileSync(file, ""); | |
| 44 | let bytes = 0; | |
| 45 | let cut = false; | |
| 46 | let waiting = ""; | |
| 47 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 48 | // One write at a time, in order; a full disk loses output, never the run. | |
| 49 | let written: Promise<void> = Promise.resolve(); | |
| 50 | const flush = (): Promise<void> => { | |
| 51 | if (timer !== undefined) clearTimeout(timer); | |
| 52 | timer = undefined; | |
| 53 | if (waiting === "") return written; | |
| 54 | const text = waiting; | |
| 55 | waiting = ""; | |
| 56 | written = written.then(() => appendFile(file, text)).catch(() => undefined); | |
| 57 | return written; | |
| 58 | }; | |
| 59 | const add = (text: string): void => { | |
| 60 | waiting += text; | |
| 61 | if (waiting.length >= FLUSH_BYTES) void flush(); | |
| 62 | else if (timer === undefined) { | |
| 63 | timer = setTimeout(() => void flush(), FLUSH_MS); | |
| 64 | timer.unref?.(); | |
| 65 | } | |
| 66 | }; | |
| 67 | return { | |
| 68 | write: (line) => { | |
| 69 | if (cut) return; | |
| 70 | const text = `${line}\n`; | |
| 71 | bytes += Buffer.byteLength(text); | |
| 72 | if (bytes > OUTPUT_CAP_BYTES) { | |
| 73 | cut = true; | |
| 74 | add(`… cut at ${OUTPUT_CAP_BYTES} bytes\n`); | |
| 75 | return; | |
| 76 | } | |
| 77 | add(text); | |
| 78 | }, | |
| 79 | end: flush, | |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | /** The newest output of `name`, or undefined when it never ran here. */ | |
| 84 | export function readOutput(dataDir: string, name: string): string | undefined { | |
| 85 | try { | |
| 86 | return readFileSync(fileOf(dataDir, name), "utf8"); | |
| 87 | } catch { | |
| 88 | return undefined; | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | /** How far back `outputTail` reads: the tail never costs a whole capped file. */ | |
| 93 | const TAIL_BYTES = 16 * 1024; | |
| 94 | ||
| 95 | /** The last `count` lines of `name`'s newest output, or undefined when it never ran here. */ | |
| 96 | export function outputTail(dataDir: string, name: string, count: number): string[] | undefined { | |
| 97 | let fd: number; | |
| 98 | try { | |
| 99 | fd = openSync(fileOf(dataDir, name), "r"); | |
| 100 | } catch { | |
| 101 | return undefined; | |
| 102 | } | |
| 103 | try { | |
| 104 | const size = fstatSync(fd).size; | |
| 105 | const from = Math.max(0, size - TAIL_BYTES); | |
| 106 | const buf = Buffer.alloc(size - from); | |
| 107 | readSync(fd, buf, 0, buf.length, from); | |
| 108 | const lines = buf.toString("utf8").split("\n"); | |
| 109 | if (lines.at(-1) === "") lines.pop(); | |
| 110 | // A read from the middle of the file starts mid-line: that piece is dropped. | |
| 111 | if (from > 0) lines.shift(); | |
| 112 | return lines.slice(-count); | |
| 113 | } finally { | |
| 114 | closeSync(fd); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | /** Whether a step's output says what it filed: the hunt script's steps, never install. */ | |
| 119 | export const readsFiled = (step: Step): boolean => step.command.startsWith("bun scripts/flake-hunt.ts "); | |
| 120 | ||
| 121 | // ` filed #12 for test/x.test.ts`, ` filed #12`, ` updated #3 for …`, | |
| 122 | // ` closed #4 for …`, `quarantined …`: what flake-hunt.ts prints as it files. | |
| 123 | const FILED = /^\s*(filed|updated|closed) #(\d+)\b/; | |
| 124 | const QUARANTINED = /^quarantined (\S+)/; | |
| 125 | ||
| 126 | /** What a run filed, in the order it did, as short phrases: `filed #12`, `quarantined test/x.test.ts`. */ | |
| 127 | export function filedIn(lines: readonly string[]): string[] { | |
| 128 | const out: string[] = []; | |
| 129 | for (const line of lines) { | |
| 130 | const filed = FILED.exec(line); | |
| 131 | if (filed !== null) out.push(`${filed[1]} #${filed[2]}`); | |
| 132 | const moved = QUARANTINED.exec(line); | |
| 133 | if (moved !== null) out.push(`quarantined ${moved[1]}`); | |
| 134 | } | |
| 135 | return out; | |
| 136 | } |
src/nightly/run.ts +40 −7
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | * own, each step run in a cell (`apps/contain.ts`) that reaches the forge |
| 4 | 4 | * and the registry and nothing else, the step's output kept in the log. |
| 5 | 5 | * The token goes only to steps that report, as FORGE_TOKEN, which the hunt |
| 6 | * script already drops from the environment of the tests it runs. | |
| 6 | * script already drops from the environment of the tests it runs. The | |
| 7 | * output is kept (`output.ts`) and is the run the issues it files link to. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import { mkdirSync } from "node:fs"; |
| @@ -18,6 +19,7 @@ import { repoDir } from "../git/repos.ts"; | ||
| 18 | 19 | import { refTip } from "../git/read.ts"; |
| 19 | 20 | import { createLog } from "../kernel/log.ts"; |
| 20 | 21 | import type { Job } from "./jobs.ts"; |
| 22 | import { filedIn, readsFiled, runPath, startOutput } from "./output.ts"; | |
| 21 | 23 | |
| 22 | 24 | const log = createLog("nightly"); |
| 23 | 25 | |
| @@ -26,6 +28,8 @@ export interface Ran { | ||
| 26 | 28 | readonly oid: string; |
| 27 | 29 | /** The labels of the steps that failed. */ |
| 28 | 30 | readonly failed: readonly string[]; |
| 31 | /** What the hunt's steps filed, as `filedIn` reads it from their output: advisory. */ | |
| 32 | readonly filed: readonly string[]; | |
| 29 | 33 | } |
| 30 | 34 | |
| 31 | 35 | export async function runJob( |
| @@ -34,17 +38,38 @@ export async function runJob( | ||
| 34 | 38 | job: Job, |
| 35 | 39 | options: { readonly forgeUrl: string; readonly token: string; readonly signal: AbortSignal }, |
| 36 | 40 | ): Promise<Ran | { readonly error: string }> { |
| 41 | // Begun before anything can refuse, so the output shown is never an older night's. | |
| 42 | const output = startOutput(app.config.dataDir, job.name); | |
| 43 | try { | |
| 44 | return await runSteps(app, repo, job, options, output.write); | |
| 45 | } finally { | |
| 46 | await output.end(); | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | async function runSteps( | |
| 51 | app: App, | |
| 52 | repo: Repo, | |
| 53 | job: Job, | |
| 54 | options: { readonly forgeUrl: string; readonly token: string; readonly signal: AbortSignal }, | |
| 55 | keep: (line: string) => void, | |
| 56 | ): Promise<Ran | { readonly error: string }> { | |
| 57 | const refuse = (error: string): { readonly error: string } => { | |
| 58 | keep(`not run: ${error}`); | |
| 59 | return { error }; | |
| 60 | }; | |
| 37 | 61 | const bare = repoDir(app.reposRoot, repo.ownerName, repo.name); |
| 38 | 62 | const oid = await refTip(bare, `refs/heads/${repo.defaultBranch}`); |
| 39 | if (oid === undefined) return { error: `no ${repo.defaultBranch} to check out` }; | |
| 63 | if (oid === undefined) return refuse(`no ${repo.defaultBranch} to check out`); | |
| 64 | keep(`${job.name} on ${repo.ownerName}/${repo.name} ${repo.defaultBranch} at ${oid}`); | |
| 40 | 65 | const dir = join(app.config.dataDir, "nightly", `${job.name}-checkout`); |
| 41 | 66 | mkdirSync(join(app.config.dataDir, "nightly"), { recursive: true }); |
| 42 | 67 | const exported = await exportTree(bare, oid, dir); |
| 43 | if (!exported.ok) return { error: exported.error }; | |
| 68 | if (!exported.ok) return refuse(exported.error); | |
| 44 | 69 | |
| 45 | 70 | const cells = await cellsAvailable(); |
| 46 | 71 | // Repository code, run unattended with a token in its hands: never without a cell. |
| 47 | if (!cells.ok) return { error: `no cell to run it in: ${cells.reason}` }; | |
| 72 | if (!cells.ok) return refuse(`no cell to run it in: ${cells.reason}`); | |
| 48 | 73 | const cell: CellSpec = { |
| 49 | 74 | dir, |
| 50 | 75 | writable: [], |
| @@ -56,28 +81,36 @@ export async function runJob( | ||
| 56 | 81 | label: `nightly:${job.name}`, |
| 57 | 82 | }; |
| 58 | 83 | const failed: string[] = []; |
| 84 | const filed: string[] = []; | |
| 59 | 85 | for (const step of job.steps) { |
| 60 | 86 | if (options.signal.aborted) break; |
| 87 | const reads = readsFiled(step); | |
| 88 | const line = (text: string): void => { | |
| 89 | keep(text); | |
| 90 | if (reads) filed.push(...filedIn([text])); | |
| 91 | log.info(text, { job: job.name }); | |
| 92 | }; | |
| 61 | 93 | const env = childEnv({ |
| 62 | 94 | FORGE_API: `${options.forgeUrl}/api/v1`, |
| 63 | 95 | FORGE_REPO: `${repo.ownerName}/${repo.name}`, |
| 64 | FORGE_RUN_URL: `${options.forgeUrl}/${repo.ownerName}/${repo.name}`, | |
| 96 | FORGE_RUN_URL: `${options.forgeUrl}${runPath(job.name)}`, | |
| 65 | 97 | FORGE_CELL_CPUS: String(cell.limits.cpus), |
| 66 | 98 | ...(step.reports ? { FORGE_TOKEN: options.token } : {}), |
| 67 | 99 | }); |
| 68 | 100 | const ran = await runStep(step.label, step.command, { |
| 69 | 101 | cwd: dir, |
| 70 | 102 | env, |
| 71 | log: (line) => log.info(line, { job: job.name }), | |
| 103 | log: line, | |
| 72 | 104 | signal: options.signal, |
| 73 | 105 | timeoutMs: step.timeoutMs, |
| 74 | 106 | cell, |
| 75 | 107 | }); |
| 76 | 108 | if (ran.ok) continue; |
| 77 | 109 | log.warn("step failed", { job: job.name, step: step.label, error: ran.error }); |
| 110 | keep(`step failed: ${ran.error}`); | |
| 78 | 111 | failed.push(step.label); |
| 79 | 112 | // Nothing runs without its dependencies. |
| 80 | 113 | if (step.label === "install") break; |
| 81 | 114 | } |
| 82 | return { dir, oid, failed }; | |
| 115 | return { dir, oid, failed, filed }; | |
| 83 | 116 | } |
src/nightly/service.ts +3 −1
| @@ -56,8 +56,10 @@ function doing(app: App, options: { readonly forgeUrl: string; readonly token: s | ||
| 56 | 56 | }; |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | const says = (ran: Ran, branch: string | undefined): string => | |
| 59 | /** One sentence: whether the steps finished, what they filed, and the pull. */ | |
| 60 | export const says = (ran: Ran, branch: string | undefined): string => | |
| 60 | 61 | (ran.failed.length === 0 ? "every step finished" : `failed: ${ran.failed.join(", ")}`) + |
| 62 | (ran.filed.length === 0 ? "; filed nothing" : `; ${ran.filed.join(", ")}`) + | |
| 61 | 63 | (branch === undefined ? "." : `; quarantine pull on ${branch}.`); |
| 62 | 64 | |
| 63 | 65 | export function startNightly( |
src/tui/screens/resilience.ts +37 −1
| @@ -18,6 +18,10 @@ | ||
| 18 | 18 | * key, secret key — both keys typed masked; a blank keeps what was sealed). |
| 19 | 19 | * `t` tries the destination in effect, as `testDestination` says it. |
| 20 | 20 | * A key is never drawn, kept past the save, or put in a note. |
| 21 | * | |
| 22 | * `o`, a forge admin's as /resilience/nightly/<job> is, opens the tail of | |
| 23 | * each nightly job's newest output (`nightly/output.ts`): the run its | |
| 24 | * `Flaky:` and `Red:` issues link to. | |
| 21 | 25 | */ |
| 22 | 26 | |
| 23 | 27 | import { drillNow, mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts"; |
| @@ -31,6 +35,8 @@ import { | ||
| 31 | 35 | type DestinationInput, |
| 32 | 36 | } from "../../actions/destination.ts"; |
| 33 | 37 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| 38 | import { JOBS } from "../../nightly/jobs.ts"; | |
| 39 | import { outputTail } from "../../nightly/output.ts"; | |
| 34 | 40 | import { nightlyLines } from "../../nightly/says.ts"; |
| 35 | 41 | import { backupsLine } from "../../snapshot/backups_line.ts"; |
| 36 | 42 | import { backupVerdict } from "../../snapshot/bucket.ts"; |
| @@ -55,6 +61,25 @@ export function resilienceLines(dataDir: string): string[] { | ||
| 55 | 61 | return rows.flatMap(({ p, s }) => wrap(`${pad(s, 11)} ${p.text}`, INNER)); |
| 56 | 62 | } |
| 57 | 63 | |
| 64 | /** How many of a nightly job's last lines the output dialog shows. */ | |
| 65 | export const OUTPUT_TAIL = 12; | |
| 66 | ||
| 67 | /** Each nightly job's line and the tail of its newest output, as /resilience/nightly/<job> shows it whole. */ | |
| 68 | export function nightlyOutputLines(dataDir: string): string[] { | |
| 69 | const said = nightlyLines(dataDir); | |
| 70 | return JOBS.flatMap((job, i) => { | |
| 71 | const tail = outputTail(dataDir, job.name, OUTPUT_TAIL); | |
| 72 | return [ | |
| 73 | ...(i === 0 ? [] : [""]), | |
| 74 | ...wrap(said[i] ?? job.name, OUTPUT_INNER), | |
| 75 | ...(tail === undefined ? [" no output here yet: it has not run on this forge"] : tail.map((l) => ` ${l}`)), | |
| 76 | ]; | |
| 77 | }); | |
| 78 | } | |
| 79 | ||
| 80 | const OUTPUT_WIDTH = 100; | |
| 81 | const OUTPUT_INNER = OUTPUT_WIDTH - 6; | |
| 82 | ||
| 58 | 83 | /** The source of the destination in effect, in the Backups page's words. */ |
| 59 | 84 | const SOURCE: Record<string, string> = { |
| 60 | 85 | sealed: "sealed in this forge", |
| @@ -283,6 +308,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 283 | 308 | binding(["l"], "bring the mirror level now"), |
| 284 | 309 | binding(["r"], "run a restore drill"), |
| 285 | 310 | ]), |
| 311 | ...(mayAct() ? [binding(["o"], "nightly output")] : []), | |
| 286 | 312 | ...(mayChange() |
| 287 | 313 | ? [ |
| 288 | 314 | binding(["d"], "set destination"), |
| @@ -318,7 +344,17 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 318 | 344 | if (ask !== undefined && !busy && mayAct()) { |
| 319 | 345 | asking = ask; |
| 320 | 346 | said = ""; |
| 321 | } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 347 | } else if (e.key === "o" && mayAct()) | |
| 348 | ctx.push( | |
| 349 | confirmScreen(ctx, screen, { | |
| 350 | title: "Nightly output", | |
| 351 | body: nightlyOutputLines(f.app.config.dataDir), | |
| 352 | actions: [{ label: "Close" }], | |
| 353 | onChoose: () => undefined, | |
| 354 | width: OUTPUT_WIDTH, | |
| 355 | }), | |
| 356 | ); | |
| 357 | else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 322 | 358 | else if (!mayChange()) return true; |
| 323 | 359 | else if (e.key === "d") ctx.push(step(destination, 0, BLANK)); |
| 324 | 360 | else if (e.key === "x") ctx.push(forgetDest()); |
src/web/pages/resilience.ts +25 −2
| @@ -11,6 +11,8 @@ | ||
| 11 | 11 | import { readResilience } from "../../checks/resilience.ts"; |
| 12 | 12 | import type { Standing } from "../../core/threats.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { JOBS } from "../../nightly/jobs.ts"; | |
| 15 | import { readOutput, runPath } from "../../nightly/output.ts"; | |
| 14 | 16 | import { nightlyLines } from "../../nightly/says.ts"; |
| 15 | 17 | import { backupsLine } from "../../snapshot/backups_line.ts"; |
| 16 | 18 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| @@ -31,13 +33,34 @@ export function backupsLineHtml(ctx: Ctx): Html { | ||
| 31 | 33 | return html`<p id="backups-line">${line.says} <a href="/settings/backups">Backups</a> lists them.</p>`; |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | /** What the nightly jobs last did (`nightly/says.ts`): that a night ran, and what it filed. */ | |
| 36 | /** What the nightly jobs last did (`nightly/says.ts`): that a night ran, what it filed, and its output. */ | |
| 35 | 37 | export function nightlyHtml(ctx: Ctx): Html { |
| 38 | const lines = nightlyLines(ctx.app.config.dataDir); | |
| 36 | 39 | return html`<ul id="nightly" class="muted"> |
| 37 | ${nightlyLines(ctx.app.config.dataDir).map((line) => html`<li>${line}</li>`)} | |
| 40 | ${JOBS.map((job, i) => html`<li>${lines[i] ?? ""} <a href="${runPath(job.name)}">Output</a></li>`)} | |
| 38 | 41 | </ul>`; |
| 39 | 42 | } |
| 40 | 43 | |
| 44 | // GET /resilience/nightly/:job: the newest output of one nightly job, the | |
| 45 | // run its `Flaky:` and `Red:` issues link to. An admin's, as /resilience is. | |
| 46 | export function nightlyOutputPage(ctx: Ctx): Response { | |
| 47 | if (!ctx.auth.user) return signInFirst(ctx); | |
| 48 | if (!ctx.auth.user.isAdmin) return notFoundPage(ctx); | |
| 49 | const at = JOBS.findIndex((j) => j.name === ctx.params.job); | |
| 50 | const job = JOBS[at]; | |
| 51 | if (job === undefined) return notFoundPage(ctx); | |
| 52 | const line = nightlyLines(ctx.app.config.dataDir)[at] ?? ""; | |
| 53 | const output = readOutput(ctx.app.config.dataDir, job.name); | |
| 54 | const body = html`<div class="page-head"> | |
| 55 | <h1>${job.name}</h1> | |
| 56 | <p class="muted">${line} <a href="/resilience">Resilience</a></p> | |
| 57 | </div> | |
| 58 | ${output === undefined | |
| 59 | ? html`<p id="nightly-output" class="muted">No output here yet: it has not run on this forge.</p>` | |
| 60 | : html`<pre id="nightly-output" class="card mono" tabindex="0" aria-label="Output">${output}</pre>`}`; | |
| 61 | return page({ ctx, title: `${job.name} · Resilience`, body }); | |
| 62 | } | |
| 63 | ||
| 41 | 64 | // GET /resilience: a forge admin's, since live red lines name what to attack. |
| 42 | 65 | export function resiliencePage(ctx: Ctx): Response { |
| 43 | 66 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
test/nightly-output.test.ts added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { createApp, type App } from "../src/app.ts"; | |
| 6 | import { createUser } from "../src/core/users.ts"; | |
| 7 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 8 | import { JOBS } from "../src/nightly/jobs.ts"; | |
| 9 | import { filedIn, OUTPUT_CAP_BYTES, outputTail, readOutput, readsFiled, runPath, startOutput } from "../src/nightly/output.ts"; | |
| 10 | import { says } from "../src/nightly/service.ts"; | |
| 11 | import { writeLast } from "../src/nightly/stamps.ts"; | |
| 12 | ||
| 13 | describe("a nightly job's output", () => { | |
| 14 | test("what the hunt prints as it files is read back as what the run filed", () => { | |
| 15 | const lines = [ | |
| 16 | "$ bun scripts/flake-hunt.ts outside --file-issues", | |
| 17 | "red : test/a.test.ts", | |
| 18 | " filed #12 for test/a.test.ts", | |
| 19 | " updated #7 for test/b.test.ts", | |
| 20 | " closed #4 for test/c.test.ts", | |
| 21 | "FLAKY test/d.test.ts: red 1, green 4; first red seed 9", | |
| 22 | " filed #13", | |
| 23 | " already open as #5", | |
| 24 | "quarantined test/d.test.ts (#13)", | |
| 25 | "nothing to quarantine", | |
| 26 | ]; | |
| 27 | expect(filedIn(lines)).toEqual(["filed #12", "updated #7", "closed #4", "filed #13", "quarantined test/d.test.ts"]); | |
| 28 | }); | |
| 29 | ||
| 30 | test("the lines filedIn reads are the ones scripts/flake-hunt.ts prints", () => { | |
| 31 | const script = readFileSync(join(import.meta.dir, "..", "scripts", "flake-hunt.ts"), "utf8"); | |
| 32 | for (const printed of ["` filed #${made.number} for ", "` filed #${made.number}`", "` updated #${action.number} for ", "` closed #${action.number} for ", "`quarantined ${line}`"]) | |
| 33 | expect(script).toContain(printed); | |
| 34 | }); | |
| 35 | ||
| 36 | test("a run replaces the last one's output, and a run that prints too much is cut saying so", async () => { | |
| 37 | const dir = mkdtempSync(join(tmpdir(), "nightly-out-")); | |
| 38 | try { | |
| 39 | expect(readOutput(dir, "outside-gate")).toBeUndefined(); | |
| 40 | const first = startOutput(dir, "outside-gate"); | |
| 41 | first.write("old night"); | |
| 42 | await first.end(); | |
| 43 | const next = startOutput(dir, "outside-gate"); | |
| 44 | expect(readOutput(dir, "outside-gate")).toBe(""); | |
| 45 | next.write("new night"); | |
| 46 | await next.end(); | |
| 47 | expect(readOutput(dir, "outside-gate")).toBe("new night\n"); | |
| 48 | const big = "x".repeat(1000); | |
| 49 | for (let i = 0; i < OUTPUT_CAP_BYTES / 1000 + 10; i++) next.write(big); | |
| 50 | await next.end(); | |
| 51 | const kept = readOutput(dir, "outside-gate")!; | |
| 52 | expect(kept.length).toBeLessThan(OUTPUT_CAP_BYTES + 100); | |
| 53 | expect(kept.endsWith(`… cut at ${OUTPUT_CAP_BYTES} bytes\n`)).toBe(true); | |
| 54 | } finally { | |
| 55 | rmSync(dir, { recursive: true, force: true }); | |
| 56 | } | |
| 57 | }); | |
| 58 | ||
| 59 | test("lines are written in batches, not one write each, and in order", async () => { | |
| 60 | const dir = mkdtempSync(join(tmpdir(), "nightly-out-")); | |
| 61 | try { | |
| 62 | const out = startOutput(dir, "flake-hunt"); | |
| 63 | for (let i = 0; i < 100; i++) out.write(`line ${i}`); | |
| 64 | // Nothing has been written while the event loop was busy with the run. | |
| 65 | expect(readOutput(dir, "flake-hunt")).toBe(""); | |
| 66 | await out.end(); | |
| 67 | expect(readOutput(dir, "flake-hunt")).toBe(Array.from({ length: 100 }, (_, i) => `line ${i}\n`).join("")); | |
| 68 | } finally { | |
| 69 | rmSync(dir, { recursive: true, force: true }); | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | test("the tail is the last lines, read from the end of a long output", async () => { | |
| 74 | const dir = mkdtempSync(join(tmpdir(), "nightly-out-")); | |
| 75 | try { | |
| 76 | expect(outputTail(dir, "flake-hunt", 3)).toBeUndefined(); | |
| 77 | const out = startOutput(dir, "flake-hunt"); | |
| 78 | for (let i = 0; i < 5000; i++) out.write(`line ${i}`); | |
| 79 | await out.end(); | |
| 80 | expect(outputTail(dir, "flake-hunt", 3)).toEqual(["line 4997", "line 4998", "line 4999"]); | |
| 81 | const all = outputTail(dir, "flake-hunt", 100_000)!; | |
| 82 | // Only the end of the file is read, and the line it cut through is dropped. | |
| 83 | expect(all.length).toBeLessThan(5000); | |
| 84 | expect(all[0]).toMatch(/^line \d+$/); | |
| 85 | } finally { | |
| 86 | rmSync(dir, { recursive: true, force: true }); | |
| 87 | } | |
| 88 | }); | |
| 89 | ||
| 90 | test("only the hunt script's steps are read for what they filed, never install", () => { | |
| 91 | for (const job of JOBS) | |
| 92 | for (const step of job.steps) expect([step.label, readsFiled(step)]).toEqual([step.label, step.label !== "install"]); | |
| 93 | }); | |
| 94 | ||
| 95 | test("the record says what the run filed, or that it filed nothing", () => { | |
| 96 | expect(says({ dir: "x", oid: "y", failed: [], filed: [] }, undefined)).toBe("every step finished; filed nothing."); | |
| 97 | expect(says({ dir: "x", oid: "y", failed: ["hunt slice 2/20"], filed: ["filed #12", "quarantined test/a.test.ts"] }, "flake-hunt/2026-11-03")).toBe( | |
| 98 | "failed: hunt slice 2/20; filed #12, quarantined test/a.test.ts; quarantine pull on flake-hunt/2026-11-03.", | |
| 99 | ); | |
| 100 | }); | |
| 101 | }); | |
| 102 | ||
| 103 | describe("the page a nightly issue links to", () => { | |
| 104 | let dataDir: string; | |
| 105 | let app: App; | |
| 106 | let servers: Servers; | |
| 107 | ||
| 108 | const login = async (username: string): Promise<string> => { | |
| 109 | const res = await fetch(`${servers.url}/signin`, { | |
| 110 | method: "POST", | |
| 111 | redirect: "manual", | |
| 112 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 113 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 114 | }); | |
| 115 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 116 | }; | |
| 117 | ||
| 118 | beforeAll(async () => { | |
| 119 | dataDir = mkdtempSync(join(tmpdir(), "nightly-page-")); | |
| 120 | app = createApp({ dataDir, port: 0 }); | |
| 121 | servers = await serve(app); | |
| 122 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 123 | const u = await createUser(app.db, { | |
| 124 | origin: "seed", | |
| 125 | trusted: true, | |
| 126 | username, | |
| 127 | email: `${username}@forge.local`, | |
| 128 | password: "a-good-passphrase", | |
| 129 | adminIfFirst, | |
| 130 | }); | |
| 131 | if (!u.ok) throw new Error("users"); | |
| 132 | } | |
| 133 | const kept = startOutput(dataDir, "outside-gate"); | |
| 134 | kept.write("red : test/<b>x</b>.test.ts"); | |
| 135 | kept.write(" filed #12 for test/<b>x</b>.test.ts"); | |
| 136 | await kept.end(); | |
| 137 | writeLast(dataDir, "outside-gate", { | |
| 138 | slot: Date.parse("2026-11-03T03:47:00Z"), | |
| 139 | startedAt: Date.parse("2026-11-03T03:47:01Z"), | |
| 140 | finishedAt: Date.parse("2026-11-03T04:02:00Z"), | |
| 141 | says: "every step finished; filed #12.", | |
| 142 | }); | |
| 143 | }); | |
| 144 | ||
| 145 | afterAll(async () => { | |
| 146 | servers.stop(); | |
| 147 | app.close(); | |
| 148 | rmSync(dataDir, { recursive: true, force: true }); | |
| 149 | }); | |
| 150 | ||
| 151 | test("an admin reads the run's record and its output, escaped, and Resilience links to it", async () => { | |
| 152 | const cookie = await login("alice"); | |
| 153 | const res = await fetch(`${servers.url}${runPath("outside-gate")}`, { headers: { cookie } }); | |
| 154 | expect(res.status).toBe(200); | |
| 155 | const text = await res.text(); | |
| 156 | expect(text).toContain("every step finished; filed #12."); | |
| 157 | expect(text).toContain("filed #12 for test/<b>x</b>.test.ts"); | |
| 158 | expect(text).not.toContain("<b>x</b>"); | |
| 159 | const resilience = await (await fetch(`${servers.url}/resilience`, { headers: { cookie } })).text(); | |
| 160 | expect(resilience).toContain(`href="${runPath("outside-gate")}"`); | |
| 161 | expect(resilience).toContain(`href="${runPath("flake-hunt")}"`); | |
| 162 | }); | |
| 163 | ||
| 164 | test("a job that has not run says so", async () => { | |
| 165 | const res = await fetch(`${servers.url}${runPath("flake-hunt")}`, { headers: { cookie: await login("alice") } }); | |
| 166 | expect(res.status).toBe(200); | |
| 167 | expect(await res.text()).toContain("No output here yet"); | |
| 168 | }); | |
| 169 | ||
| 170 | test("anyone but an admin, and any job that is not one, is not found", async () => { | |
| 171 | expect((await fetch(`${servers.url}${runPath("outside-gate")}`, { headers: { cookie: await login("bob") } })).status).toBe(404); | |
| 172 | expect((await fetch(`${servers.url}${runPath("sync-env")}`, { headers: { cookie: await login("alice") } })).status).toBe(404); | |
| 173 | const anon = await fetch(`${servers.url}${runPath("outside-gate")}`, { redirect: "manual" }); | |
| 174 | expect(anon.status).not.toBe(200); | |
| 175 | expect(await anon.text()).not.toContain("filed #12"); | |
| 176 | }); | |
| 177 | }); |
test/nightly.test.ts +5 −5
| @@ -95,7 +95,7 @@ describe("the nightly service", () => { | ||
| 95 | 95 | const instant = { |
| 96 | 96 | run: async (job: { name: string }) => { |
| 97 | 97 | calls.push(job.name); |
| 98 | return { dir: "x", oid: "y", failed: [] as string[] }; | |
| 98 | return { dir: "x", oid: "y", failed: [] as string[], filed: [] as string[] }; | |
| 99 | 99 | }, |
| 100 | 100 | propose: async () => undefined, |
| 101 | 101 | }; |
| @@ -160,9 +160,9 @@ describe("the nightly service", () => { | ||
| 160 | 160 | const slow = { |
| 161 | 161 | run: (_job: unknown, signal: AbortSignal) => { |
| 162 | 162 | seen = signal; |
| 163 | return new Promise<{ dir: string; oid: string; failed: string[] }>((resolve) => { | |
| 164 | signal.addEventListener("abort", () => resolve({ dir: "x", oid: "y", failed: ["hunt slice 3/20"] })); | |
| 165 | release = () => resolve({ dir: "x", oid: "y", failed: [] }); | |
| 163 | return new Promise<{ dir: string; oid: string; failed: string[]; filed: string[] }>((resolve) => { | |
| 164 | signal.addEventListener("abort", () => resolve({ dir: "x", oid: "y", failed: ["hunt slice 3/20"], filed: [] })); | |
| 165 | release = () => resolve({ dir: "x", oid: "y", failed: [], filed: [] }); | |
| 166 | 166 | }); |
| 167 | 167 | }, |
| 168 | 168 | propose: async () => undefined, |
| @@ -195,7 +195,7 @@ describe("the nightly service", () => { | ||
| 195 | 195 | test("the hunt proposes after its steps, unless the quarantine itself failed; the outside run never does", async () => { |
| 196 | 196 | let proposed = 0; |
| 197 | 197 | const doing = (failed: string[]) => ({ |
| 198 | run: async () => ({ dir: "x", oid: "y", failed }), | |
| 198 | run: async () => ({ dir: "x", oid: "y", failed, filed: [] }), | |
| 199 | 199 | propose: async () => { |
| 200 | 200 | proposed++; |
| 201 | 201 | return "flake-hunt/2026-11-02"; |
test/security-floor-routes.test.ts +1
| @@ -108,6 +108,7 @@ const UNSEEDED: Record<string, string> = { | ||
| 108 | 108 | "/origins/:id/r/:owner/:name/raw/:ref/*path": ORIGIN, |
| 109 | 109 | "/origins/:id/r/:owner/:name/tree/:ref": ORIGIN, |
| 110 | 110 | "/origins/:id/r/:owner/:name/tree/:ref/*path": ORIGIN, |
| 111 | "/resilience/nightly/:job": "a nightly job's name (flake-hunt, outside-gate); the placeholder names none, so its not-found page is what gets the floor checked", | |
| 111 | 112 | "/settings/origins/:id/mirror/:job": ORIGIN, |
| 112 | 113 | "/settings/origins/:id/repos": ORIGIN, |
| 113 | 114 | "/settings/origins/flows/:id": ORIGIN, |
test/tui-nightly-output.test.ts added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | /** | |
| 2 | * The terminal's door to a nightly run's output (#9019): `o` in the | |
| 3 | * Resilience dialog, a forge admin's as /resilience/nightly/<job> is, shows | |
| 4 | * each job's line and the tail of its newest output. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 11 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 12 | import { startOutput } from "../src/nightly/output.ts"; | |
| 13 | import { writeLast } from "../src/nightly/stamps.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { resilienceScreen, OUTPUT_TAIL } from "../src/tui/screens/resilience.ts"; | |
| 16 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("a nightly run's output in the terminal", () => { | |
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | ||
| 27 | function boot(username: string) { | |
| 28 | const terminal = headlessTerminal(140, 48); | |
| 29 | const user = getUserByName(forge.db, username)!; | |
| 30 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 31 | const { app, session } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 32 | app.start(); | |
| 33 | const send = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 34 | const press = async (...keys: string[]) => { | |
| 35 | for (const k of keys) send(k); | |
| 36 | await settleApp(app); | |
| 37 | }; | |
| 38 | const open = async () => { | |
| 39 | send("k", true); | |
| 40 | await settleApp(app); | |
| 41 | for (const ch of "Resilience") send(ch); | |
| 42 | await settleApp(app); | |
| 43 | await press("enter"); | |
| 44 | }; | |
| 45 | return { app, session, text: () => app.screen.toText(), press, open, stop: () => app.stop() }; | |
| 46 | } | |
| 47 | ||
| 48 | beforeAll(async () => { | |
| 49 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-nightly-output-")); | |
| 50 | forge = createApp({ dataDir, port: 0 }); | |
| 51 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 52 | const made = await createUser(forge.db, { | |
| 53 | origin: "seed", | |
| 54 | trusted: true, | |
| 55 | username, | |
| 56 | email: `${username}@forge.local`, | |
| 57 | password: "a-good-passphrase", | |
| 58 | adminIfFirst, | |
| 59 | }); | |
| 60 | if (!made.ok) throw new Error(made.error); | |
| 61 | } | |
| 62 | const out = startOutput(dataDir, "outside-gate"); | |
| 63 | out.write("an early line the tail leaves out"); | |
| 64 | for (let i = 0; i < OUTPUT_TAIL - 1; i++) out.write(`green: test/f${i}.test.ts`); | |
| 65 | out.write(" filed #12 for test/x.test.ts"); | |
| 66 | await out.end(); | |
| 67 | writeLast(dataDir, "outside-gate", { | |
| 68 | slot: Date.parse("2026-11-03T03:47:00Z"), | |
| 69 | startedAt: Date.parse("2026-11-03T03:47:01Z"), | |
| 70 | finishedAt: Date.parse("2026-11-03T04:02:00Z"), | |
| 71 | says: "every step finished; filed #12.", | |
| 72 | }); | |
| 73 | }); | |
| 74 | ||
| 75 | afterAll(async () => { | |
| 76 | forge.close(); | |
| 77 | await rm(dataDir, { recursive: true, force: true }); | |
| 78 | }); | |
| 79 | ||
| 80 | test("an admin presses o and reads each job's line and the tail of its output", async () => { | |
| 81 | const h = boot("alice"); | |
| 82 | await h.open(); | |
| 83 | expect(h.text()).toContain("o nightly output"); | |
| 84 | await h.press("o"); | |
| 85 | const text = h.text(); | |
| 86 | expect(text).toContain("Nightly output"); | |
| 87 | expect(text).toContain("every step finished; filed #12."); | |
| 88 | expect(text).toContain("filed #12 for test/x.test.ts"); | |
| 89 | expect(text).toContain("green: test/f0.test.ts"); | |
| 90 | expect(text).not.toContain("an early line the tail leaves out"); | |
| 91 | expect(text).toContain("no output here yet: it has not run on this forge"); | |
| 92 | await h.press("esc"); | |
| 93 | expect(h.text()).not.toContain("Nightly output"); | |
| 94 | expect(h.text()).toContain("Snapshots, newest first:"); | |
| 95 | h.stop(); | |
| 96 | }); | |
| 97 | ||
| 98 | test("anyone but an admin is not offered o, and o opens nothing for them", async () => { | |
| 99 | const h = boot("bob"); | |
| 100 | const pushed: string[] = []; | |
| 101 | const ctx = { ...h.app.ctx, push: (s: { title: string }) => void pushed.push(s.title), invalidate: () => undefined }; | |
| 102 | const screen = resilienceScreen(h.session, ctx as never); | |
| 103 | expect(screen.keys.some((k) => k.help === "nightly output")).toBe(false); | |
| 104 | screen.key({ type: "key", key: "o", ctrl: false, alt: false, shift: false }); | |
| 105 | expect(pushed).toEqual([]); | |
| 106 | h.stop(); | |
| 107 | }); | |
| 108 | }); |
new above
offline