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

A green restore drill keeps how long each phase took, and /settings/backups shows it against the budget #2630

Merged opened this

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

Merged

scout/forge:scout/2622-a-green-restore-drill-keeps-how-long-eac → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/2622-a-green-restore-drill-keeps-how-long-eac was merged into main as 45ebeb5.

Review

Sign in to review this change.
12 files changed +348 −27
README.md +3 −1
@@ -978,7 +978,9 @@ The terminal has no backups view, so it has no readout.
978978
979979 **Run a drill** on the same page (admins only) restores the newest snapshot
980980 into scratch, opens it, counts what is there and unseals a value; the page
981 waits for it, up to 10 minutes, and the last drill shows there as one line.
981 waits for it, up to 10 minutes, and the last drill shows there as one line,
982 ending with the drill's time against its budget and each phase's time ("6 min
983 12 s of its 14 min budget: files 58 s, …"); drills from before this show none.
982984 Only one runs at a time. See `docs/SNAPSHOT.md`.
983985
984986 The forge also runs one itself every Sunday 09:00 UTC (a forge that was down
src/core/migrations/20261019120000-drill-phases.ts added +5
@@ -0,0 +1,5 @@
1 // A restore drill keeps how long each restore phase took and the budget it had
2 // (#2622): JSON {phases, budgetMs}. An older drill has NULL and reads as none.
3 export default `
4 ALTER TABLE drill ADD COLUMN phases TEXT;
5 `;
src/snapshot/drill.ts +32
@@ -65,6 +65,22 @@ export function formatBytes(bytes: number): string {
6565 return `${Math.round(v * 10) / 10} ${units[i]}`;
6666 }
6767
68 /** A span for people: "6 min 12 s", "58 s", "340 ms". */
69 export function formatSpan(ms: number): string {
70 if (!Number.isFinite(ms) || ms < 0) return "0 ms";
71 if (ms < 1_000) return `${Math.round(ms)} ms`;
72 const s = Math.round(ms / 1_000);
73 if (s < 60) return `${s} s`;
74 const m = Math.floor(s / 60);
75 return s % 60 === 0 ? `${m} min` : `${m} min ${s % 60} s`;
76 }
77
78 /** One restore phase and the milliseconds it took. */
79 export interface PhaseTime {
80 readonly phase: Progress["phase"];
81 readonly ms: number;
82 }
83
6884 export type DrillStep = "fetch" | "restore" | "open" | "count" | "unseal" | "health";
6985
7086 export interface DrillDeps {
@@ -91,6 +107,10 @@ export interface DrillResult {
91107 readonly health: "ok" | "failed" | "unreached";
92108 readonly verdict: "green" | "red";
93109 readonly failedStep: DrillStep | null;
110 /** How long each restore phase took, in the order they began; empty when none was reported. */
111 readonly phases: readonly PhaseTime[];
112 /** The time the drill was given, in ms. */
113 readonly budgetMs: number;
94114 /** Why it is red, for the person reading; never a value. */
95115 readonly reason: string | null;
96116 }
@@ -128,6 +148,9 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
128148 let scratch: string | undefined;
129149 let reason: string | null = null;
130150 let progress: Progress | undefined;
151 // When each restore phase was first reported, and when the restore ended.
152 const began: { phase: Progress["phase"]; at: number }[] = [];
153 let restoreEnd: number | undefined;
131154 let health: DrillResult["health"] = "unreached";
132155 try {
133156 // One clock for the whole drill, so a hang in any step ends it red.
@@ -148,11 +171,13 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
148171 passphrase: deps.passphrase,
149172 onProgress: (p) => {
150173 progress = p;
174 if (began[began.length - 1]?.phase !== p.phase) began.push({ phase: p.phase, at: performance.now() });
151175 },
152176 }),
153177 left(),
154178 budget,
155179 );
180 restoreEnd = performance.now();
156181
157182 step = "open";
158183 const { Database } = await import("bun:sqlite");
@@ -229,6 +254,11 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
229254 return finish(null);
230255
231256 function finish(failedStep: DrillStep | null): DrillResult {
257 const end = restoreEnd ?? performance.now();
258 const phases = began.map((b, i) => ({
259 phase: b.phase,
260 ms: Math.max(0, Math.round((began[i + 1]?.at ?? end) - b.at)),
261 }));
232262 return {
233263 at,
234264 snapshotId,
@@ -237,6 +267,8 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
237267 health,
238268 verdict: failedStep === null ? "green" : "red",
239269 failedStep,
270 phases,
271 budgetMs,
240272 reason,
241273 };
242274 }
src/snapshot/drills.ts +50 −23
@@ -5,7 +5,7 @@
55 */
66
77 import type { Database } from "bun:sqlite";
8 import type { DrillResult, DrillStep } from "./drill.ts";
8 import type { DrillResult, DrillStep, PhaseTime } from "./drill.ts";
99
1010 interface Row {
1111 at: string;
@@ -19,12 +19,36 @@ interface Row {
1919 verdict: "green" | "red";
2020 failed_step: DrillStep | null;
2121 reason: string | null;
22 phases: string | null;
23 }
24
25 const PHASES = new Set(["manifest", "files", "unpack", "bundles", "key"]);
26
27 /** The stored JSON as phases; anything that is not a list of known phases with whole ms reads as none. */
28 export function parsePhases(raw: string | null): { phases: PhaseTime[]; budgetMs: number } {
29 const none = { phases: [], budgetMs: 0 };
30 if (raw === null) return none;
31 try {
32 const v: unknown = JSON.parse(raw);
33 if (typeof v !== "object" || v === null) return none;
34 const { phases, budgetMs } = v as { phases?: unknown; budgetMs?: unknown };
35 if (!Array.isArray(phases) || typeof budgetMs !== "number" || !Number.isFinite(budgetMs) || budgetMs < 0) return none;
36 const out: PhaseTime[] = [];
37 for (const p of phases) {
38 const { phase, ms } = (p ?? {}) as { phase?: unknown; ms?: unknown };
39 if (typeof phase !== "string" || !PHASES.has(phase) || typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return none;
40 out.push({ phase: phase as PhaseTime["phase"], ms });
41 }
42 return { phases: out, budgetMs };
43 } catch {
44 return none;
45 }
2246 }
2347
2448 export function recordDrill(db: Database, result: DrillResult): void {
2549 db.query(
26 `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, users, pulls, apps, verdict, failed_step, reason)
27 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
50 `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, users, pulls, apps, verdict, failed_step, reason, phases)
51 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2852 ).run(
2953 result.at,
3054 result.snapshotId,
@@ -37,6 +61,7 @@ export function recordDrill(db: Database, result: DrillResult): void {
3761 result.verdict,
3862 result.failedStep,
3963 result.reason,
64 JSON.stringify({ phases: result.phases, budgetMs: result.budgetMs }),
4065 );
4166 }
4267
@@ -45,24 +70,26 @@ export function lastDrill(db: Database): DrillResult | undefined {
4570 const row = db
4671 .query<Row, []>("SELECT * FROM drill ORDER BY id DESC LIMIT 1")
4772 .get();
48 return row === null
49 ? undefined
50 : {
51 at: row.at,
52 snapshotId: row.snapshot_id,
53 ms: row.ms,
54 counts: {
55 repositories: row.repositories,
56 issues: row.issues,
57 users: row.users,
58 pulls: row.pulls,
59 apps: row.apps,
60 },
61 // Not stored: green means health read ok; a red one failed at it or never got there.
62 health:
63 row.verdict === "green" ? "ok" : row.failed_step === "health" ? "failed" : "unreached",
64 verdict: row.verdict,
65 failedStep: row.failed_step,
66 reason: row.reason,
67 };
73 if (row === null) return undefined;
74 const kept = parsePhases(row.phases);
75 return {
76 at: row.at,
77 snapshotId: row.snapshot_id,
78 ms: row.ms,
79 counts: {
80 repositories: row.repositories,
81 issues: row.issues,
82 users: row.users,
83 pulls: row.pulls,
84 apps: row.apps,
85 },
86 // Not stored: green means health read ok; a red one failed at it or never got there.
87 health:
88 row.verdict === "green" ? "ok" : row.failed_step === "health" ? "failed" : "unreached",
89 verdict: row.verdict,
90 failedStep: row.failed_step,
91 reason: row.reason,
92 phases: kept.phases,
93 budgetMs: kept.budgetMs,
94 };
6895 }
src/web/pages/backups.ts +14 −2
@@ -18,6 +18,7 @@ import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
1818 import { mirrorSealedSet, mirrorNeed, MIRROR_SEALED } from "../../snapshot/mirror.ts";
1919 import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
2020 import { keyOf } from "../../snapshot/snapshot.ts";
21 import { formatBudget, formatSpan, type DrillResult } from "../../snapshot/drill.ts";
2122 import { lastDrill } from "../../snapshot/drills.ts";
2223 import { snapshotsOf } from "../../snapshot/service.ts";
2324 import { lastSnapshotOutcome } from "../../snapshot/outcome.ts";
@@ -89,9 +90,18 @@ function keyNote(
8990 </p>`;
9091 }
9192
93 /** "6 min 12 s of its 14 min budget: files 58 s, ..."; empty for a drill that kept no phases. */
94 function drillTiming(d: DrillResult): string {
95 if (d.phases.length === 0 || d.budgetMs <= 0) return "";
96 return `${formatSpan(d.ms)} of its ${formatBudget(d.budgetMs)} budget: ${d.phases
97 .map((p) => `${p.phase} ${formatSpan(p.ms)}`)
98 .join(", ")}`;
99 }
100
92101 /** The last drill in one line, and the button to run another. */
93102 function drillLine(ctx: Ctx): Html {
94103 const d = lastDrill(ctx.app.db);
104 const timing = d === undefined ? "" : drillTiming(d);
95105 const line =
96106 d === undefined
97107 ? html`<p class="backups-drill">No restore drill has run yet.</p>`
@@ -99,14 +109,16 @@ function drillLine(ctx: Ctx): Html {
99109 ? html`<p class="backups-drill">
100110 ${icon("check")} Last drill: ${time(d.at)}, ${d.ms} ms, all counts matched —
101111 snapshot <code>${d.snapshotId ?? ""}</code>, key opened —
102 ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps.
112 ${d.counts.users} users, ${d.counts.repositories} repositories, ${d.counts.issues} issues, ${d.counts.pulls} pulls, ${d.counts.apps} apps.${timing
113 ? html` <span class="backups-timing">${timing}.</span>`
114 : ""}
103115 </p>`
104116 : html`<p class="backups-error backups-drill">
105117 ${icon("alert")} Last drill ${time(d.at)}: snapshot
106118 <code>${d.snapshotId ?? "none"}</code> failed at
107119 ${d.failedStep ?? "an unknown step"} after ${d.ms} ms${d.reason
108120 ? html`: ${d.reason}`
109 : ""}.
121 : ""}.${timing ? html` <span class="backups-timing">${timing}.</span>` : ""}

The red branch renders the timing span, but no test covers it. Add one with a red drill that has phases.

110122 </p>`;
111123 return html`${line}
112124 <form method="post" action="/settings/backups/drill" class="backups-now">
src/web/style/content.ts +1
@@ -381,6 +381,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
381381 .flag-form .input { min-width: 0; flex: 1 1 8rem; }
382382 }
383383 .backups-now { margin-top: 12px; }
384 .backups-drill { overflow-wrap: anywhere; }
384385 /* The fleet graph: a selection's spend and runs, hour by hour and day by day.
385386 Series colours are Okabe–Ito (told apart with common colour-vision
386387 deficiencies); the eighth, the folded "others", is grey. */
test/domains.test.ts +4 −1
@@ -66,7 +66,10 @@ test("a nearly full disk says the state's words", () => {
6666 test("a red restore drill says the snapshot store's words", () => {
6767 recordVerdict(dataDir, {
6868 at: new Date().toISOString(), snapshotId: null, ms: 1, counts: { repositories: 0, issues: 0, users: 0, pulls: 0, apps: 0 },
69 health: "unreached", verdict: "red", failedStep: "fetch", reason: "no bucket",
69 health: "unreached", verdict: "red", failedStep: "fetch",
70 phases: [],
71 budgetMs: 0,
72 reason: "no bucket",
7073 });
7174 for (const s of said("snapshots")) expect(read()).toContain(s);
7275 });
test/drill-board-line.test.ts +2
@@ -27,6 +27,8 @@ const drill = (at: number, red: boolean): DrillResult => ({
2727 health: red ? "failed" : "ok",
2828 verdict: red ? "red" : "green",
2929 failedStep: red ? "unseal" : null,
30 phases: [],
outdated

Put phases and budgetMs on their own lines, matching the other fields.

31 budgetMs: 0,
3032 reason: red ? "a sealed value does not open with the restored key" : null,
3133 });
3234
test/drill-verdict.test.ts +4
@@ -139,6 +139,8 @@ describe("the drill's verdict", () => {
139139 health: "unreached",
140140 verdict: "red",
141141 failedStep: "fetch",
142 phases: [],
143 budgetMs: 0,
142144 reason: "the store holds no snapshot",
143145 });
144146 expect(text).toContain("FAILED at fetch");
@@ -226,6 +228,8 @@ describe("telling the recovery issue", () => {
226228 health: "ok",
227229 verdict: "green",
228230 failedStep: null,
231 phases: [],
232 budgetMs: 0,
229233 reason: null,
230234 },
231235 async (b) => (posts.push(b), "posted: 201"),
test/drill-weekly.test.ts +6
@@ -77,6 +77,8 @@ describe("the service runs a due drill once", () => {
7777 health: "ok",
7878 verdict: "green",
7979 failedStep: null,
80 phases: [],
81 budgetMs: 0,
8082 reason: null,
8183 };
8284 },
@@ -115,6 +117,8 @@ describe("the service runs a due drill once", () => {
115117 health: verdict === "red" ? "unreached" : "ok",
116118 verdict,
117119 failedStep: verdict === "red" ? "restore" : null,
120 phases: [],
121 budgetMs: 0,
118122 reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null,
119123 };
120124 },
@@ -145,6 +149,8 @@ describe("the service's own clock drills a red one again", () => {
145149 health: verdict === "red" ? "unreached" : "ok",
146150 verdict,
147151 failedStep: verdict === "red" ? "restore" : null,
152 phases: [],
153 budgetMs: 0,
148154 reason: verdict === "red" ? "restore timed out: no answer in 600000 ms" : null,
149155 });
150156
test/snapshot-drill-page.test.ts +2
@@ -68,6 +68,8 @@ describe("the drill on the Backups page", () => {
6868 health: verdict === "green" ? "ok" : "unreached",
6969 verdict,
7070 failedStep: verdict === "green" ? null : "unseal",
71 phases: [],
72 budgetMs: 0,
7173 reason: verdict === "green" ? null : redReason,
7274 };
7375 },
test/snapshot-drill-phases.test.ts added +225
@@ -0,0 +1,225 @@
1 /**
2 * A drill keeps how long each restore phase took and its budget (#2622):
3 * green and red alike, round-tripped through the drill row, an older row
4 * reading as no phases, and the Backups page showing the line escaped.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { formatSpan, runDrill, type DrillResult } from "../src/snapshot/drill.ts";
14 import { lastDrill, parsePhases, recordDrill } from "../src/snapshot/drills.ts";
15 import { restoreSnapshot } from "../src/snapshot/restore.ts";
16 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
17 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
18 import { dirStore } from "../src/snapshot/store.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
24
25 describe("drill phases", () => {
26 let root = "";
27 let app: App;
28
29 beforeAll(async () => {
30 root = mkdtempSync(join(tmpdir(), "forge-drillphases-"));
31 app = createApp({ dataDir: root, port: 0 });
32 const u = await createUser(app.db, {
33 origin: "seed",
34 trusted: true,
35 username: "alice",
36 email: "[email protected]",
37 password: "a-good-passphrase",
38 adminIfFirst: true,
39 });
40 if (!u.ok) throw new Error(u.error);
41 await takeSnapshot(
42 { db: app.db, dataDir: root, reposRoot: app.reposRoot, version: "t" },
43 dirStore(join(root, "store")),
44 new Date("2026-10-18T08:00:00Z"),
45 );
46 });
47 afterAll(() => {
48 app.close();
49 rmSync(root, { recursive: true, force: true });
50 });
51
52 const drill = (restore: typeof restoreSnapshot, timeoutMs?: number) =>
53 runDrill({
54 store: dirStore(join(root, "store")),
55 dataDir: join(root, "drill-data"),
56 boot: async () => ({ ok: true, reason: "" }),
57 restore,
58 timeoutMs,
59 });
60
61 test("a green drill keeps the phases a stubbed restore reported, and the budget", async () => {
62 const r = await drill((async (s: never, n: never, t: never, o: { onProgress?: (p: unknown) => void }) => {
63 o.onProgress?.({ phase: "files", done: 0, total: 2, bytes: 0 });
64 await sleep(30);
65 o.onProgress?.({ phase: "files", done: 2, total: 2, bytes: 9 });
66 o.onProgress?.({ phase: "unpack", done: 0, total: 1, bytes: 0 });
67 await sleep(30);
68 o.onProgress?.({ phase: "key", done: 0, total: 1, bytes: 0 });
69 return restoreSnapshot(s, n, t, {});
70 }) as never);
71 expect(r.verdict).toBe("green");
72 expect(r.budgetMs).toBeGreaterThanOrEqual(600_000);
73 expect(r.budgetMs).toBeLessThan(601_000);
74 expect(r.phases.map((p) => p.phase)).toEqual(["files", "unpack", "key"]);
75 expect(r.phases[0]!.ms).toBeGreaterThanOrEqual(25);
76 expect(r.phases[1]!.ms).toBeGreaterThanOrEqual(25);
77 });
78
79 test("a red drill keeps them too", async () => {
80 const r = await drill(
81 (async (_s: never, _n: never, _t: never, o: { onProgress?: (p: unknown) => void }) => {
82 o.onProgress?.({ phase: "bundles", done: 1, total: 9, bytes: 1 });
83 await new Promise(() => {});
84 }) as never,
85 300,
86 );
87 expect(r.verdict).toBe("red");
88 expect(r.budgetMs).toBe(300);
89 expect(r.phases.map((p) => p.phase)).toEqual(["bundles"]);
90 });
91
92 test("a stored row round-trips, and an older row reads as no phases", () => {
93 const base: DrillResult = {
94 at: "2026-10-18T09:00:00.000Z",
95 snapshotId: "s",
96 ms: 5,
97 counts: { repositories: 1, issues: 2, users: 3, pulls: 4, apps: 5 },
98 health: "ok",
99 verdict: "green",
100 failedStep: null,
101 phases: [
102 { phase: "files", ms: 58_000 },
103 { phase: "bundles", ms: 290_000 },
104 ],
105 budgetMs: 840_000,
106 reason: null,
107 };
108 recordDrill(app.db, base);
109 expect(lastDrill(app.db)).toEqual(base);
110 app.db.query("INSERT INTO drill (at, ms, repositories, issues, verdict) VALUES ('2026-10-19T00:00:00Z', 1, 0, 0, 'green')").run();
111 const old = lastDrill(app.db)!;
112 expect(old.phases).toEqual([]);
113 expect(old.budgetMs).toBe(0);
114 });
115
116 test("stored JSON that is not phases reads as none", () => {
117 for (const bad of ["x", "[]", '{"phases":[{"phase":"<b>","ms":1}],"budgetMs":5}', '{"phases":[{"phase":"files","ms":-1}],"budgetMs":5}', '{"phases":"a","budgetMs":5}'])
118 expect(parsePhases(bad)).toEqual({ phases: [], budgetMs: 0 });
119 });
120
121 test("spans read as people say them", () => {
122 expect(formatSpan(372_000)).toBe("6 min 12 s");
123 expect(formatSpan(58_000)).toBe("58 s");
124 expect(formatSpan(240_000)).toBe("4 min");
125 expect(formatSpan(340)).toBe("340 ms");
126 });
127 });
128
129 describe("the Backups page line", () => {
130 let root = "";
131 let app: App;
132 let svc: SnapshotsHandle;
133 let servers: Servers;
134
135 beforeAll(async () => {
136 root = mkdtempSync(join(tmpdir(), "forge-drillline-"));
137 app = createApp({ dataDir: root, port: 0 });
138 const u = await createUser(app.db, {
139 origin: "seed",
140 trusted: true,
141 username: "alice",
142 email: "[email protected]",
143 password: "a-good-passphrase",
144 adminIfFirst: true,
145 });
146 if (!u.ok) throw new Error(u.error);
147 svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false });
148 servers = await serve(app);
149 });
150 afterAll(async () => {
151 servers.stop();
152 await svc.stop();
153 app.close();
154 rmSync(root, { recursive: true, force: true });
155 });
156
157 const kept = (over: Partial<DrillResult>): DrillResult => ({
158 at: new Date().toISOString(),
159 snapshotId: "snap-1",
160 ms: 372_000,
161 counts: { repositories: 1, issues: 1, users: 1, pulls: 0, apps: 0 },
162 health: "ok",
163 verdict: "green",
164 failedStep: null,
165 phases: [
166 { phase: "files", ms: 58_000 },
167 { phase: "unpack", ms: 20_000 },
168 { phase: "bundles", ms: 290_000 },
169 { phase: "key", ms: 1_000 },
170 ],
171 budgetMs: 840_000,
172 reason: null,
173 ...over,
174 });
175 const LINE = "6 min 12 s of its 14 min budget: files 58 s, unpack 20 s, bundles 4 min 50 s, key 1 s";
176 const backups = async (): Promise<string> => {
177 const r = await fetch(`${servers.url}/signin`, {
178 method: "POST",
179 headers: { origin: servers.url },
180 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
181 redirect: "manual",
182 });
183 const cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!;
184 return (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text();
185 };
186
187 test("a green drill shows the time against the budget with each phase", async () => {
188 recordDrill(app.db, kept({}));
189 expect(await backups()).toContain(LINE);
190 });
191
192 test("a red drill shows it too, beside its reason", async () => {
193 recordDrill(
194 app.db,
195 kept({
196 verdict: "red",
197 health: "unreached",
198 failedStep: "restore",
199 reason: "restore timed out in bundles <b>x</b>",
200 }),
201 );
202 const html = await backups();
203 expect(html).toContain("failed at");
204 expect(html).toContain("restore timed out in bundles &lt;b&gt;x&lt;/b&gt;");
205 expect(html).toContain(LINE);
206 });
207
208 test("a drill with no phases shows no timing", async () => {
209 for (const verdict of ["green", "red"] as const) {
210 recordDrill(
211 app.db,
212 kept({
213 verdict,
214 failedStep: verdict === "red" ? "restore" : null,
215 phases: [],
216 budgetMs: 0,
217 }),
218 );
219 const html = await backups();
220 expect(html).toContain("snap-1");
221 expect(html).not.toContain("budget:");
222 expect(html).not.toContain("backups-timing");
223 }
224 });
225 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs