Public: anyone can read this; the crew can change it.
An admin runs a restore drill from the terminal's Resilience dialog with `r`, after a y/n, and reads the verdict there in the Backups page's words #8018
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8007-an-admin-runs-a-restore-drill-from-the-t → main 2 commits fork 1 thread · 1 unresolved
5 files
changed +256
−39
docs/TOUR.md +1 −1
| @@ -213,7 +213,7 @@ A writer of the app's repository redeploys production from the terminal's **Depl | ||
| 213 | 213 | |
| 214 | 214 | The app's admin also sees its own domains on that screen, one line with each domain's state (`live` or `waiting for DNS`), and presses `n` for the domains screen: `+` adds one, `c` checks the selected one's DNS proof now and says the record still needed, `-` removes it after `remove <host>? y/n`. They are the settings page's and MCP `app_domains`' actions (`src/actions/domains.ts`), answered in the page's words (`src/actions/domain_words.ts`); anyone `listAppDomains` refuses sees neither the domains nor `n`. |
| 215 | 215 | |
| 216 | A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n` and brings the off-box mirror level with `l`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`), so all three doors say the same words; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. While one runs, neither key is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`. | |
| 216 | A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n`, brings the off-box mirror level with `l` and runs a restore drill with `r`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`, `drillNow` for the drill), so every door says the same words, and the drill's verdict is the Backups page's; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. The three keys are shown only to a forge admin, and while one action runs none of them is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`. | |
| 217 | 217 | |
| 218 | 218 | The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker. |
| 219 | 219 |
src/actions/backups_now.ts +37 −4
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | /** |
| 2 | * The Backups page's two buttons as actions: take a snapshot now, and bring | |
| 3 | * the off-box mirror level now. A forge admin's only; anyone else is told | |
| 4 | * `not_found`. Each answer carries `says`, worded as the page's flash, so | |
| 5 | * MCP `snapshot_now` / `mirror_now` and the terminal say the same thing. | |
| 2 | * The Backups page's three buttons as actions: take a snapshot now, bring | |
| 3 | * the off-box mirror level now, and run a restore drill now. A forge | |
| 4 | * admin's only; anyone else is told `not_found`. Each answer carries | |
| 5 | * `says`, worded as the page's flash, so MCP `snapshot_now` / `mirror_now` | |
| 6 | * and the terminal say the same thing. | |
| 6 | 7 | * Nothing here ever names a key or a secret: only the store's name. |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | import type { App } from "../app.ts"; |
| 10 | 11 | import type { User } from "../core/users.ts"; |
| 11 | 12 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 13 | import type { DrillStep } from "../snapshot/drill.ts"; | |
| 12 | 14 | import { snapshotsOf } from "../snapshot/service.ts"; |
| 13 | 15 | import { fileSize } from "../web/ui.ts"; |
| 14 | 16 | |
| @@ -85,6 +87,37 @@ export async function mirrorNow( | ||
| 85 | 87 | }); |
| 86 | 88 | } |
| 87 | 89 | |
| 90 | export interface DrillRun { | |
| 91 | readonly verdict: "green" | "red"; | |
| 92 | readonly failedStep: DrillStep | null; | |
| 93 | readonly says: string; | |
| 94 | } | |
| 95 | ||
| 96 | export type DrillRefusal = | |
| 97 | | { readonly code: "not_found"; readonly says: string } | |
| 98 | | { readonly code: "no_store"; readonly says: string } | |
| 99 | | { readonly code: "failed"; readonly says: string }; | |
| 100 | ||
| 101 | /** Run a restore drill now; waits for any in flight. A red drill is an answer, not a refusal. */ | |
| 102 | export async function drillNow( | |
| 103 | app: App, | |
| 104 | actor: User | undefined, | |
| 105 | ): Promise<Result<DrillRun, DrillRefusal>> { | |
| 106 | if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin runs a restore drill." }); | |
| 107 | const svc = snapshotsOf(app); | |
| 108 | if (svc?.store === undefined) return err({ code: "no_store", says: "This forge has no snapshots to drill." }); | |
| 109 | try { | |
| 110 | const d = await svc.drill(); | |
| 111 | return ok({ | |
| 112 | verdict: d.verdict, | |
| 113 | failedStep: d.failedStep, | |
| 114 | says: d.verdict === "green" ? "The drill restored green." : `The drill failed at ${d.failedStep}.`, | |
| 115 | }); | |
| 116 | } catch (error) { | |
| 117 | return err({ code: "failed", says: `The drill could not run: ${String(error)}` }); | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 88 | 121 | /** |
| 89 | 122 | * The newest snapshots first, one line each: `name · size`; empty when there |
| 90 | 123 | * is no store. A store that cannot be listed is `unreadable`, never an empty |
src/tui/screens/resilience.ts +37 −12
| @@ -4,11 +4,11 @@ | ||
| 4 | 4 | * THREATS.md protection with its standing in the last gate, as /resilience |
| 5 | 5 | * says it. Enter, Esc or q closes it. |
| 6 | 6 | * |
| 7 | * A forge admin takes a snapshot now with `n` and brings the mirror level with | |
| 8 | * `l`, each after a `y/n` asked on the dialog's first line, through | |
| 9 | * `actions/backups_now.ts` — MCP `snapshot_now`'s and `mirror_now`'s actions — | |
| 10 | * and the answer is said there in the page's words. While one runs, neither | |
| 11 | * key is offered. | |
| 7 | * A forge admin takes a snapshot now with `n`, brings the mirror level with | |
| 8 | * `l` and runs a restore drill with `r`, each after a `y/n` asked on the | |
| 9 | * dialog's first line, through `actions/backups_now.ts` — MCP `snapshot_now`'s | |
| 10 | * and `mirror_now`'s actions, and the Backups page's drill — and the answer is | |
| 11 | * said there in the page's words. While one runs, none of the keys is offered. | |
| 12 | 12 | * |
| 13 | 13 | * For an admin who is a person, `d` sets the destination and `x` forgets it |
| 14 | 14 | * after a confirm, through `actions/destination.ts`; `m` seals the mirror and |
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | * A key is never drawn, kept past the save, or put in a note. |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | import { mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts"; | |
| 22 | import { drillNow, mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts"; | |
| 23 | 23 | import { readResilience } from "../../checks/resilience.ts"; |
| 24 | 24 | import { |
| 25 | 25 | forgetDestination, |
| @@ -87,21 +87,36 @@ type Target = { | ||
| 87 | 87 | readonly save: (input: DestinationInput) => string; |
| 88 | 88 | }; |
| 89 | 89 | |
| 90 | type Asking = "snapshot" | "mirror"; | |
| 90 | type Asking = "snapshot" | "mirror" | "drill"; | |
| 91 | 91 | |
| 92 | 92 | const QUESTION: Record<Asking, string> = { |
| 93 | 93 | snapshot: "take a snapshot now? y/n", |
| 94 | 94 | mirror: "bring the mirror level now? y/n", |
| 95 | drill: "run a restore drill now? y/n", | |
| 95 | 96 | }; |
| 96 | 97 | |
| 98 | const RUNNING: Record<Asking, string> = { | |
| 99 | snapshot: "Taking a snapshot…", | |
| 100 | mirror: "Bringing the mirror level…", | |
| 101 | drill: "Running a restore drill…", | |
| 102 | }; | |
| 103 | ||
| 104 | const KEY: ReadonlyMap<string, Asking> = new Map([ | |
| 105 | ["n", "snapshot"], | |
| 106 | ["l", "mirror"], | |
| 107 | ["r", "drill"], | |
| 108 | ]); | |
| 109 | ||
| 97 | 110 | export function resilienceScreen(f: Session, ctx: Ctx): Screen { |
| 98 | 111 | // One rule for the destination and the mirror: a site admin who is a person. |
| 99 | 112 | const mayChange = (): boolean => mayChangeDestination(f.user); |
| 100 | 113 | const refusedAll = "Where snapshots go is a forge admin's."; |
| 114 | // The actions refuse anyone else too; the keys are not offered to them. | |
| 115 | const mayAct = (): boolean => f.user?.isAdmin === true; | |
| 101 | 116 | /** The newest snapshots, `unreadable` when the store could not be listed; undefined until read. */ |
| 102 | 117 | let snapshots: string[] | "unreadable" | undefined; |
| 103 | 118 | let asking: Asking | undefined; |
| 104 | // While an action runs, `n` and `l` are ignored: a second would queue behind it. | |
| 119 | // While an action runs, `n`, `l` and `r` are ignored: a second would queue behind it. | |
| 105 | 120 | let busy = false; |
| 106 | 121 | let said = ""; |
| 107 | 122 | let note = ""; |
| @@ -119,13 +134,16 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 119 | 134 | |
| 120 | 135 | const act = (what: Asking): void => { |
| 121 | 136 | busy = true; |
| 122 | said = what === "snapshot" ? "Taking a snapshot…" : "Bringing the mirror level…"; | |
| 137 | said = RUNNING[what]; | |
| 123 | 138 | ctx.invalidate(); |
| 124 | 139 | runAsync(ctx, async () => { |
| 125 | 140 | try { |
| 126 | 141 | if (what === "snapshot") { |
| 127 | 142 | const taken = await snapshotNow(f.app, f.user); |
| 128 | 143 | said = taken.ok ? taken.value.says : taken.error.says; |
| 144 | } else if (what === "drill") { | |
| 145 | const ran = await drillNow(f.app, f.user); | |
| 146 | said = ran.ok ? ran.value.says : ran.error.says; | |
| 129 | 147 | } else { |
| 130 | 148 | const level = await mirrorNow(f.app, f.user); |
| 131 | 149 | said = level.ok ? level.value.says : level.error.says; |
| @@ -237,7 +255,13 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 237 | 255 | get keys() { |
| 238 | 256 | if (asking !== undefined) return [binding(["y"], "yes"), binding(["n", "esc"], "no")]; |
| 239 | 257 | return [ |
| 240 | ...(busy ? [] : [binding(["n"], "take a snapshot now"), binding(["l"], "bring the mirror level now")]), | |
| 258 | ...(busy || !mayAct() | |
| 259 | ? [] | |
| 260 | : [ | |
| 261 | binding(["n"], "take a snapshot now"), | |
| 262 | binding(["l"], "bring the mirror level now"), | |
| 263 | binding(["r"], "run a restore drill"), | |
| 264 | ]), | |
| 241 | 265 | ...(mayChange() |
| 242 | 266 | ? [ |
| 243 | 267 | binding(["d"], "set destination"), |
| @@ -269,8 +293,9 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 269 | 293 | if (e.key === "y") act(was); |
| 270 | 294 | return true; |
| 271 | 295 | } |
| 272 | if ((e.key === "n" || e.key === "l") && !busy) { | |
| 273 | asking = e.key === "n" ? "snapshot" : "mirror"; | |
| 296 | const ask = KEY.get(e.key); | |
| 297 | if (ask !== undefined && !busy && mayAct()) { | |
| 298 | asking = ask; | |
| 274 | 299 | said = ""; |
| 275 | 300 | } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); |
| 276 | 301 | else if (!mayChange()) return true; |
src/web/pages/backups.ts +9 −22
| @@ -28,7 +28,7 @@ import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../s | ||
| 28 | 28 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 29 | 29 | import type { Store } from "../../snapshot/store.ts"; |
| 30 | 30 | import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts"; |
| 31 | import { mirrorNow, snapshotNow } from "../../actions/backups_now.ts"; | |
| 31 | import { drillNow, mirrorNow, snapshotNow } from "../../actions/backups_now.ts"; | |
| 32 | 32 | import { lastSnapshotOutcome } from "../../snapshot/outcome.ts"; |
| 33 | 33 | import type { Ctx } from "../../http/context.ts"; |
| 34 | 34 | import { |
| @@ -645,27 +645,14 @@ export async function backupsDrill(ctx: Ctx): Promise<Response> { | ||
| 645 | 645 | }); |
| 646 | 646 | const form = await readForm(ctx, "admin"); |
| 647 | 647 | if (form instanceof Response) return form; |
| 648 | const svc = snapshotsOf(ctx.app); | |
| 649 | if (svc === undefined || svc.store === undefined) | |
| 650 | return flashTo(ctx, "/settings/backups", { | |
| 651 | tone: "danger", | |
| 652 | message: "This forge has no snapshots to drill.", | |
| 653 | }); | |
| 654 | try { | |
| 655 | const d = await svc.drill(); | |
| 656 | return flashTo( | |
| 657 | ctx, | |
| 658 | "/settings/backups", | |
| 659 | d.verdict === "green" | |
| 660 | ? { message: "The drill restored green." } | |
| 661 | : { tone: "danger", message: `The drill failed at ${d.failedStep}.` }, | |
| 662 | ); | |
| 663 | } catch (error) { | |
| 664 | return flashTo(ctx, "/settings/backups", { | |
| 665 | tone: "danger", | |
| 666 | message: `The drill could not run: ${String(error)}`, | |
| 667 | }); | |
| 668 | } | |
| 648 | const ran = await drillNow(ctx.app, ctx.auth.user); | |
| 649 | return flashTo( | |
| 650 | ctx, | |
| 651 | "/settings/backups", | |
| 652 | ran.ok && ran.value.verdict === "green" | |
| 653 | ? { message: ran.value.says } | |
| 654 | : { tone: "danger", message: ran.ok ? ran.value.says : ran.error.says }, | |
| 655 | ); | |
| 669 | 656 | } |
| 670 | 657 | |
| 671 | 658 | /** POST /settings/backups/mirror — seal the mirror. */ |
test/tui-resilience-drill.test.ts added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | /** | |
| 2 | * The terminal's Resilience dialog runs a restore drill with `r` after a y/n | |
| 3 | * (issue #8007, pull #8018), through `drillNow` — the Backups page's action — and says the | |
| 4 | * verdict in the page's words. The drill itself is stubbed: a real one | |
| 5 | * restores a snapshot and can take minutes. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { drillNow } from "../src/actions/backups_now.ts"; | |
| 14 | import type { DrillResult } from "../src/snapshot/drill.ts"; | |
| 15 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 16 | import { dirStore } from "../src/snapshot/store.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | ||
| 23 | quietLogs(); | |
| 24 | ||
| 25 | const GREEN: DrillResult = { | |
| 26 | at: new Date(0).toISOString(), | |
| 27 | snapshotId: "s1", | |
| 28 | ms: 1, | |
| 29 | counts: { repositories: 0, issues: 0, users: 2, pulls: 0, apps: 0 }, | |
| 30 | health: "ok", | |
| 31 | verdict: "green", | |
| 32 | failedStep: null, | |
| 33 | phases: [], | |
| 34 | budgetMs: 1000, | |
| 35 | reason: null, | |
| 36 | }; | |
| 37 | ||
| 38 | describe("a restore drill from the terminal's Resilience dialog", () => { | |
| 39 | let dataDir: string; | |
| 40 | let forge: Forge; | |
| 41 | let svc: SnapshotsHandle; | |
| 42 | let drills = 0; | |
| 43 | let verdict: DrillResult = GREEN; | |
| 44 | ||
| 45 | function boot(username: string) { | |
| 46 | const terminal = headlessTerminal(140, 40); | |
| 47 | const user = getUserByName(forge.db, username)!; | |
| 48 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 49 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 50 | app.start(); | |
| 51 | const send = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false }); | |
| 52 | const press = async (...keys: string[]) => { | |
| 53 | for (const k of keys) send(k); | |
| 54 | await settleApp(app); | |
| 55 | }; | |
| 56 | const open = async () => { | |
| 57 | send("k", true); | |
| 58 | await settleApp(app); | |
| 59 | for (const ch of "Resilience") send(ch); | |
| 60 | await settleApp(app); | |
| 61 | const listed = app.screen.toText(); | |
| 62 | await press("enter"); | |
| 63 | return listed; | |
| 64 | }; | |
| 65 | const waitFor = async (text: string) => { | |
| 66 | for (let i = 0; i < 100 && !app.screen.toText().includes(text); i++) { | |
| 67 | await Bun.sleep(20); | |
| 68 | await press(); | |
| 69 | } | |
| 70 | }; | |
| 71 | return { text: () => app.screen.toText(), press, open, waitFor, stop: () => app.stop() }; | |
| 72 | } | |
| 73 | ||
| 74 | const start = (store: boolean) => | |
| 75 | startSnapshots(forge, store ? dirStore(join(dataDir, "store")) : () => undefined, { | |
| 76 | version: "t", | |
| 77 | nightly: false, | |
| 78 | drill: async () => { | |
| 79 | drills++; | |
| 80 | return verdict; | |
| 81 | }, | |
| 82 | }); | |
| 83 | ||
| 84 | beforeAll(async () => { | |
| 85 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-resilience-drill-")); | |
| 86 | forge = createApp({ dataDir, port: 0 }); | |
| 87 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 88 | const made = await createUser(forge.db, { | |
| 89 | origin: "seed", | |
| 90 | trusted: true, | |
| 91 | username, | |
| 92 | email: `${username}@forge.local`, | |
| 93 | password: "a-good-passphrase", | |
| 94 | adminIfFirst, | |
| 95 | }); | |
| 96 | if (!made.ok) throw new Error(made.error); | |
| 97 | } | |
| 98 | svc = start(true); | |
| 99 | }, 30_000); | |
| 100 | ||
| 101 | afterAll(async () => { | |
| 102 | await svc.stop(); | |
| 103 | forge.close(); | |
| 104 | await rm(dataDir, { recursive: true, force: true }); | |
| 105 | }); | |
| 106 | ||
| 107 | test("an admin is offered r, and r then y says the drill restored green", async () => { | |
| 108 | const h = boot("alice"); | |
| 109 | await h.open(); | |
| 110 | expect(h.text()).toContain("r run a restore drill"); | |
| 111 | await h.press("r"); | |
| 112 | expect(h.text()).toContain("run a restore drill now? y/n"); | |
| 113 | expect(h.text()).not.toContain("r run a restore drill"); | |
| 114 | const before = drills; | |
| 115 | await h.press("y"); | |
| 116 | await h.waitFor("The drill restored green."); | |
| 117 | expect(h.text()).toContain("The drill restored green."); | |
| 118 | expect(drills).toBe(before + 1); | |
| 119 | h.stop(); | |
| 120 | }); | |
| 121 | ||
| 122 | test("a red drill names the step it failed at", async () => { | |
| 123 | verdict = { ...GREEN, verdict: "red", failedStep: "restore", health: "unreached" }; | |
| 124 | const h = boot("alice"); | |
| 125 | await h.open(); | |
| 126 | await h.press("r", "y"); | |
| 127 | await h.waitFor("The drill failed at restore."); | |
| 128 | expect(h.text()).toContain("The drill failed at restore."); | |
| 129 | verdict = GREEN; | |
| 130 | h.stop(); | |
| 131 | }); | |
| 132 | ||
| 133 | test("r then n, or esc, runs nothing", async () => { | |
| 134 | const h = boot("alice"); | |
| 135 | await h.open(); | |
| 136 | const before = drills; | |
| 137 | await h.press("r", "n"); | |
| 138 | expect(h.text()).not.toContain("run a restore drill now? y/n"); | |
| 139 | await h.press("r", "esc"); | |
| 140 | expect(h.text()).not.toContain("run a restore drill now? y/n"); | |
| 141 | expect(h.text()).toContain("Snapshots, newest first:"); | |
| 142 | await Bun.sleep(20); | |
| 143 | expect(drills).toBe(before); | |
| 144 | h.stop(); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a non-admin is not offered the dialog, and drillNow refuses them", async () => { | |
| 148 | const h = boot("bob"); | |
| 149 | expect(await h.open()).not.toContain("each protection green, red or untested"); | |
| 150 | expect(h.text()).not.toContain("r run a restore drill"); | |
| 151 | h.stop(); | |
| 152 | const before = drills; | |
| 153 | const ran = await drillNow(forge, getUserByName(forge.db, "bob")!); | |
| 154 | expect(ran.ok).toBe(false); | |
| 155 | if (!ran.ok) expect(ran.error.code).toBe("not_found"); | |
| 156 | expect(drills).toBe(before); | |
| 157 | }); | |
| 158 | ||
| 159 | test("a forge with no store says it has no snapshots to drill", async () => { | |
| 160 | await svc.stop(); | |
| 161 | svc = start(false); | |
| 162 | const h = boot("alice"); | |
| 163 | await h.open(); | |
| 164 | await h.press("r", "y"); | |
| 165 | await h.waitFor("no snapshots to drill"); | |
| 166 | expect(h.text()).toContain("This forge has no snapshots to drill."); | |
| 167 | const ran = await drillNow(forge, getUserByName(forge.db, "alice")!); | |
| 168 | expect(ran.ok).toBe(false); | |
| 169 | if (!ran.ok) expect(ran.error.code).toBe("no_store"); | |
| 170 | h.stop(); | |
| 171 | }); | |
| 172 | }); |
new above
offline
Rewrap this comment line; it is now far longer than its neighbours.