@openparachute/vault 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -15
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the backup module.
|
|
3
|
+
*
|
|
4
|
+
* Strategy mirrors `doctor.test.ts`: spin up an isolated `PARACHUTE_HOME`
|
|
5
|
+
* tempdir, populate it with fake vaults / DBs / config, run backup end-to-
|
|
6
|
+
* end, then unpack the resulting tarball and assert on contents. This
|
|
7
|
+
* exercises the full pipeline — SQLite VACUUM INTO, tar assembly, local
|
|
8
|
+
* destination copy, retention pruning — without requiring a live daemon.
|
|
9
|
+
*
|
|
10
|
+
* We also unit-test the pure helpers (filename round-tripping, retention
|
|
11
|
+
* ordering, tilde expansion) so regressions don't hide behind the
|
|
12
|
+
* integration harness.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import {
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
existsSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
} from "fs";
|
|
25
|
+
import { join, resolve } from "path";
|
|
26
|
+
import { tmpdir, homedir } from "os";
|
|
27
|
+
import { Database } from "bun:sqlite";
|
|
28
|
+
import { $ } from "bun";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
backupFilename,
|
|
32
|
+
parseBackupFilename,
|
|
33
|
+
expandTilde,
|
|
34
|
+
stageSnapshot,
|
|
35
|
+
assembleTarball,
|
|
36
|
+
pruneRetention,
|
|
37
|
+
computeKeepSet,
|
|
38
|
+
listSnapshots,
|
|
39
|
+
tierTally,
|
|
40
|
+
runBackup,
|
|
41
|
+
readLastBackup,
|
|
42
|
+
nextRunEstimate,
|
|
43
|
+
checkDestinationWritable,
|
|
44
|
+
} from "./backup.ts";
|
|
45
|
+
import type { BackupConfig, RetentionPolicy } from "./config.ts";
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Helpers
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Build a small SQLite DB with one row, for snapshot-contents assertions. */
|
|
52
|
+
function makeFakeDb(path: string, marker: string) {
|
|
53
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
54
|
+
const db = new Database(path);
|
|
55
|
+
db.run("CREATE TABLE marker (v TEXT)");
|
|
56
|
+
db.run("INSERT INTO marker VALUES (?)", [marker]);
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Extract a tar.gz into a fresh tempdir and return that dir. */
|
|
61
|
+
async function untar(tarball: string): Promise<string> {
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), "untar-"));
|
|
63
|
+
await $`tar -xzf ${tarball} -C ${dir}`.quiet();
|
|
64
|
+
return dir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Write a minimal vault.yaml so `listVaultsIn` picks up the vault. */
|
|
68
|
+
function makeFakeVault(vaultsDir: string, name: string, marker: string) {
|
|
69
|
+
const dir = join(vaultsDir, name);
|
|
70
|
+
mkdirSync(dir, { recursive: true });
|
|
71
|
+
writeFileSync(join(dir, "vault.yaml"), `name: ${name}\ncreated_at: "2026-01-01T00:00:00.000Z"\napi_keys:\n`);
|
|
72
|
+
makeFakeDb(join(dir, "vault.db"), marker);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Pure helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
describe("backup — pure helpers", () => {
|
|
80
|
+
test("backupFilename uses timestamp with colons replaced by hyphens", () => {
|
|
81
|
+
const ts = "2026-04-17T08:30:00.000Z";
|
|
82
|
+
expect(backupFilename(ts)).toBe("parachute-backup-2026-04-17T08-30-00.000Z.tar.gz");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("parseBackupFilename round-trips with backupFilename", () => {
|
|
86
|
+
const ts = "2026-04-17T08-30-00.000Z";
|
|
87
|
+
const name = `parachute-backup-${ts}.tar.gz`;
|
|
88
|
+
expect(parseBackupFilename(name)).toEqual({ timestamp: ts });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("parseBackupFilename returns null for unrelated files", () => {
|
|
92
|
+
expect(parseBackupFilename("something.tar.gz")).toBeNull();
|
|
93
|
+
expect(parseBackupFilename("parachute-backup.tar")).toBeNull(); // missing .gz
|
|
94
|
+
expect(parseBackupFilename("README.md")).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("expandTilde expands leading ~/ but leaves absolute paths alone", () => {
|
|
98
|
+
expect(expandTilde("~/foo")).toBe(join(homedir(), "foo"));
|
|
99
|
+
expect(expandTilde("~")).toBe(homedir());
|
|
100
|
+
expect(expandTilde("/absolute/path")).toBe("/absolute/path");
|
|
101
|
+
expect(expandTilde("relative/path")).toBe("relative/path"); // not expanded — by design
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("nextRunEstimate returns null for manual, forward-moving Date otherwise", () => {
|
|
105
|
+
const base = new Date("2026-04-17T00:00:00Z");
|
|
106
|
+
expect(nextRunEstimate("manual", base)).toBeNull();
|
|
107
|
+
const daily = nextRunEstimate("daily", base)!;
|
|
108
|
+
expect(daily.getTime()).toBeGreaterThan(base.getTime());
|
|
109
|
+
// Weekly is strictly later than daily.
|
|
110
|
+
const weekly = nextRunEstimate("weekly", base)!;
|
|
111
|
+
expect(weekly.getTime()).toBeGreaterThan(daily.getTime());
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Tiered retention pruning (grandfather / father / son)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Touch a synthetic snapshot file with the given Date as its embedded
|
|
121
|
+
* timestamp. We produce a filename in the exact format runBackup would
|
|
122
|
+
* produce so the parse/bucket pipeline sees a realistic input.
|
|
123
|
+
*/
|
|
124
|
+
function makeSnapshot(dir: string, d: Date): string {
|
|
125
|
+
const name = backupFilename(d.toISOString());
|
|
126
|
+
writeFileSync(join(dir, name), "x");
|
|
127
|
+
return name;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Build a dense sequence of daily snapshots across a UTC date range. */
|
|
131
|
+
function dailySnapshots(dir: string, startUtc: string, days: number): Date[] {
|
|
132
|
+
const start = new Date(startUtc);
|
|
133
|
+
const out: Date[] = [];
|
|
134
|
+
for (let i = 0; i < days; i++) {
|
|
135
|
+
const d = new Date(start.getTime() + i * 86400_000);
|
|
136
|
+
// Fix to 12:00 UTC so every timestamp lands in the middle of the day —
|
|
137
|
+
// avoids local-midnight edge cases in test assertions.
|
|
138
|
+
d.setUTCHours(12, 0, 0, 0);
|
|
139
|
+
makeSnapshot(dir, d);
|
|
140
|
+
out.push(d);
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Full policy: helper so tests read naturally. */
|
|
146
|
+
function policy(p: Partial<RetentionPolicy>): RetentionPolicy {
|
|
147
|
+
return { daily: 0, weekly: 0, monthly: 0, yearly: 0, ...p };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
describe("backup — tiered retention pruning", () => {
|
|
151
|
+
let dir: string;
|
|
152
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-prune-")); });
|
|
153
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
154
|
+
|
|
155
|
+
test("daily tier: keeps last N snapshots unconditionally", () => {
|
|
156
|
+
// 10 consecutive days, keep daily=3 — the 3 most recent survive.
|
|
157
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 10);
|
|
158
|
+
const pruned = pruneRetention(dir, policy({ daily: 3 }));
|
|
159
|
+
expect(pruned).toBe(7);
|
|
160
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
161
|
+
expect(left.length).toBe(3);
|
|
162
|
+
// Chronological order: last 3 days are Jan 8, 9, 10.
|
|
163
|
+
expect(left[0]).toMatch(/2026-01-08T/);
|
|
164
|
+
expect(left[2]).toMatch(/2026-01-10T/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("daily: 0 disables tier but other tiers still apply", () => {
|
|
168
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 30);
|
|
169
|
+
// Only the monthly tier: one snapshot from January kept (the last),
|
|
170
|
+
// everything else pruned.
|
|
171
|
+
pruneRetention(dir, policy({ monthly: 1 }));
|
|
172
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
173
|
+
expect(left.length).toBe(1);
|
|
174
|
+
expect(left[0]).toMatch(/2026-01-30T/);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("weekly tier: one snapshot per ISO week, N most recent weeks", () => {
|
|
178
|
+
// 4 weeks of daily snapshots — 28 entries across 4 weeks plus overflow.
|
|
179
|
+
dailySnapshots(dir, "2026-01-05T00:00:00Z", 28); // Mon Jan 5 → Sun Feb 1
|
|
180
|
+
// Weekly=2 alone: keep last-of-week for the last 2 ISO weeks only.
|
|
181
|
+
pruneRetention(dir, policy({ weekly: 2 }));
|
|
182
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
183
|
+
// 2 survivors: the Sunday of each of the two most recent weeks.
|
|
184
|
+
expect(left.length).toBe(2);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("monthly tier: last snapshot of each of N months", () => {
|
|
188
|
+
// One snapshot per day across 3 full months.
|
|
189
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 90);
|
|
190
|
+
pruneRetention(dir, policy({ monthly: 2 }));
|
|
191
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
192
|
+
expect(left.length).toBe(2);
|
|
193
|
+
// Last-of-Feb (Feb 28, 2026 — not leap year) and last-of-Mar (Mar 31).
|
|
194
|
+
expect(left[0]).toMatch(/2026-02-28T/);
|
|
195
|
+
expect(left[1]).toMatch(/2026-03-31T/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("yearly: null means unbounded — keeps last-of-year for every year in history", () => {
|
|
199
|
+
// One snapshot per year across 5 years. Every year survives because
|
|
200
|
+
// yearly is null.
|
|
201
|
+
for (const y of [2022, 2023, 2024, 2025, 2026]) {
|
|
202
|
+
makeSnapshot(dir, new Date(Date.UTC(y, 5, 15, 12, 0, 0)));
|
|
203
|
+
}
|
|
204
|
+
const pruned = pruneRetention(dir, policy({ yearly: null }));
|
|
205
|
+
expect(pruned).toBe(0);
|
|
206
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
207
|
+
expect(left.length).toBe(5);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("sparse data: alternate-day snapshots across 3 years, full policy", () => {
|
|
211
|
+
// Build roughly 550 snapshots spread every other day across three years.
|
|
212
|
+
// The union tier selection should degrade gracefully across the gaps.
|
|
213
|
+
const entries: Date[] = [];
|
|
214
|
+
for (let y = 2024; y <= 2026; y++) {
|
|
215
|
+
for (let day = 0; day < 365; day += 2) {
|
|
216
|
+
const d = new Date(Date.UTC(y, 0, 1 + day, 12, 0, 0));
|
|
217
|
+
makeSnapshot(dir, d);
|
|
218
|
+
entries.push(d);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// The full default-shaped policy with unbounded yearly.
|
|
222
|
+
pruneRetention(dir, policy({ daily: 7, weekly: 4, monthly: 12, yearly: null }));
|
|
223
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
224
|
+
|
|
225
|
+
// Assertions — exact membership:
|
|
226
|
+
// - daily: the last 7 entries in the ascending list.
|
|
227
|
+
// - weekly: at most 4 weeks; each contributes one keeper.
|
|
228
|
+
// - monthly: the 12 most recent calendar months with data.
|
|
229
|
+
// - yearly (null): every year with data — 2024, 2025, 2026 → 3 keepers
|
|
230
|
+
// (some of which overlap with the monthly/daily tiers; union dedupes).
|
|
231
|
+
// A loose but informative check: the keep count falls well under the
|
|
232
|
+
// full 550-ish and comfortably above the bare daily=7.
|
|
233
|
+
expect(left.length).toBeGreaterThanOrEqual(7);
|
|
234
|
+
// Union of tiers cannot exceed: 7 daily + 4 weekly + 12 monthly + 3 yearly
|
|
235
|
+
// = 26 at the absolute upper bound, minus overlap. We just bound it here.
|
|
236
|
+
expect(left.length).toBeLessThanOrEqual(26);
|
|
237
|
+
|
|
238
|
+
// At least one keeper per year — the yearly tier guarantees this.
|
|
239
|
+
const years = new Set(
|
|
240
|
+
left.map((n) => n.match(/parachute-backup-(\d{4})-/)?.[1]).filter(Boolean),
|
|
241
|
+
);
|
|
242
|
+
expect(years.has("2024")).toBe(true);
|
|
243
|
+
expect(years.has("2025")).toBe(true);
|
|
244
|
+
expect(years.has("2026")).toBe(true);
|
|
245
|
+
|
|
246
|
+
// The most recent snapshot is always kept (it's in the daily tier).
|
|
247
|
+
const mostRecent = backupFilename(entries[entries.length - 1].toISOString());
|
|
248
|
+
expect(left).toContain(mostRecent);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("year-boundary overlap: Dec 31 vs Jan 1 land in different yearly buckets", () => {
|
|
252
|
+
// Snapshots on two consecutive days straddling the year boundary.
|
|
253
|
+
// The yearly tier should keep BOTH — one for each year.
|
|
254
|
+
makeSnapshot(dir, new Date(Date.UTC(2025, 11, 31, 12, 0, 0))); // Dec 31 2025
|
|
255
|
+
makeSnapshot(dir, new Date(Date.UTC(2026, 0, 1, 12, 0, 0))); // Jan 1 2026
|
|
256
|
+
pruneRetention(dir, policy({ yearly: null }));
|
|
257
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
258
|
+
expect(left.length).toBe(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("end-of-week rollover: Sunday and Monday cross ISO-week boundary", () => {
|
|
262
|
+
// 2026-01-04 is Sun (ISO week 1), 2026-01-05 is Mon (ISO week 2).
|
|
263
|
+
// Weekly=2 should keep one from each week. Noon UTC → most timezones
|
|
264
|
+
// put both on the expected local calendar day.
|
|
265
|
+
const sun = new Date(Date.UTC(2026, 0, 4, 12, 0, 0));
|
|
266
|
+
const mon = new Date(Date.UTC(2026, 0, 5, 12, 0, 0));
|
|
267
|
+
makeSnapshot(dir, sun);
|
|
268
|
+
makeSnapshot(dir, mon);
|
|
269
|
+
const keep = computeKeepSet(listSnapshots(dir), policy({ weekly: 2 }));
|
|
270
|
+
expect(keep.size).toBe(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("no-op when every snapshot is kept by some tier", () => {
|
|
274
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 5);
|
|
275
|
+
const pruned = pruneRetention(dir, policy({ daily: 7 }));
|
|
276
|
+
expect(pruned).toBe(0);
|
|
277
|
+
expect(readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).length).toBe(5);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("ignores non-backup files in the destination directory", () => {
|
|
281
|
+
// iCloud sometimes drops .DS_Store / .icloud placeholder files into a
|
|
282
|
+
// sync dir. Retention must only touch parachute-backup-*.tar.gz.
|
|
283
|
+
writeFileSync(join(dir, ".DS_Store"), "x");
|
|
284
|
+
writeFileSync(join(dir, "README.md"), "x");
|
|
285
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 5);
|
|
286
|
+
pruneRetention(dir, policy({ daily: 1 }));
|
|
287
|
+
// The non-backup files are untouched.
|
|
288
|
+
expect(existsSync(join(dir, ".DS_Store"))).toBe(true);
|
|
289
|
+
expect(existsSync(join(dir, "README.md"))).toBe(true);
|
|
290
|
+
// Only 1 backup file survives.
|
|
291
|
+
expect(readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).length).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("all tiers 0 / null yearly=0: everything pruned", () => {
|
|
295
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 5);
|
|
296
|
+
const pruned = pruneRetention(dir, policy({}));
|
|
297
|
+
expect(pruned).toBe(5);
|
|
298
|
+
expect(readdirSync(dir).filter((n) => n.startsWith("parachute-backup-")).length).toBe(0);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("a snapshot satisfying multiple tiers is kept once, not duplicated", () => {
|
|
302
|
+
// A single snapshot at year-end/month-end/week-end satisfies all four
|
|
303
|
+
// tiers. Deletion count is 0, on-disk count stays at 1.
|
|
304
|
+
makeSnapshot(dir, new Date(Date.UTC(2026, 11, 31, 12, 0, 0)));
|
|
305
|
+
const pruned = pruneRetention(dir, policy({ daily: 7, weekly: 4, monthly: 12, yearly: null }));
|
|
306
|
+
expect(pruned).toBe(0);
|
|
307
|
+
const left = readdirSync(dir).filter((n) => n.startsWith("parachute-backup-"));
|
|
308
|
+
expect(left.length).toBe(1);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("backup — tierTally", () => {
|
|
313
|
+
let dir: string;
|
|
314
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-tally-")); });
|
|
315
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
316
|
+
|
|
317
|
+
test("reports per-tier contribution counts for `backup status`", () => {
|
|
318
|
+
dailySnapshots(dir, "2026-01-01T00:00:00Z", 60);
|
|
319
|
+
const t = tierTally(dir, policy({ daily: 7, weekly: 4, monthly: 2, yearly: null }));
|
|
320
|
+
expect(t.total).toBe(60);
|
|
321
|
+
expect(t.daily).toBe(7);
|
|
322
|
+
// 60 daily snapshots span ~9 ISO weeks; cap at 4.
|
|
323
|
+
expect(t.weekly).toBe(4);
|
|
324
|
+
// 60 days straddles Jan + Feb + 1 day into March → monthly cap at 2.
|
|
325
|
+
expect(t.monthly).toBe(2);
|
|
326
|
+
// All 60 days are in 2026 → yearly is 1.
|
|
327
|
+
expect(t.yearly).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// stageSnapshot — SQLite VACUUM INTO + config copy
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
describe("backup — stageSnapshot", () => {
|
|
336
|
+
let home: string;
|
|
337
|
+
beforeEach(() => { home = mkdtempSync(join(tmpdir(), "backup-stage-")); });
|
|
338
|
+
afterEach(() => { rmSync(home, { recursive: true, force: true }); });
|
|
339
|
+
|
|
340
|
+
test("snapshots top-level *.db files and mirrors vaults/", async () => {
|
|
341
|
+
// Top-level legacy-style DB.
|
|
342
|
+
makeFakeDb(join(home, "daily.db"), "top-level-marker");
|
|
343
|
+
// Per-vault DBs with yaml config.
|
|
344
|
+
const vaultsDir = join(home, "vaults");
|
|
345
|
+
makeFakeVault(vaultsDir, "default", "default-marker");
|
|
346
|
+
makeFakeVault(vaultsDir, "work", "work-marker");
|
|
347
|
+
// Global config.yaml.
|
|
348
|
+
writeFileSync(join(home, "config.yaml"), "port: 1940\n");
|
|
349
|
+
|
|
350
|
+
const stage = mkdtempSync(join(tmpdir(), "stage-"));
|
|
351
|
+
try {
|
|
352
|
+
const { stagingDir, contents } = await stageSnapshot({
|
|
353
|
+
configDir: home,
|
|
354
|
+
vaultsDir,
|
|
355
|
+
stagingDir: stage,
|
|
356
|
+
});
|
|
357
|
+
expect(stagingDir).toBe(stage);
|
|
358
|
+
|
|
359
|
+
// DB snapshots are at expected paths
|
|
360
|
+
expect(contents.dbSnapshots).toContain("config-daily.db");
|
|
361
|
+
expect(contents.dbSnapshots).toContain(join("vaults", "default", "vault.db"));
|
|
362
|
+
expect(contents.dbSnapshots).toContain(join("vaults", "work", "vault.db"));
|
|
363
|
+
|
|
364
|
+
// Config files are all there
|
|
365
|
+
expect(contents.configFiles).toContain("config.yaml");
|
|
366
|
+
expect(contents.configFiles).toContain(join("vaults", "default", "vault.yaml"));
|
|
367
|
+
expect(contents.configFiles).toContain(join("vaults", "work", "vault.yaml"));
|
|
368
|
+
|
|
369
|
+
// Snapshot preserves DB contents — open a snapshot and verify the
|
|
370
|
+
// marker row round-tripped (proves VACUUM INTO actually ran, not
|
|
371
|
+
// just a zero-byte file).
|
|
372
|
+
const defaultSnap = new Database(join(stage, "vaults", "default", "vault.db"), {
|
|
373
|
+
readonly: true,
|
|
374
|
+
});
|
|
375
|
+
const row = defaultSnap.query("SELECT v FROM marker").get() as { v: string };
|
|
376
|
+
expect(row.v).toBe("default-marker");
|
|
377
|
+
defaultSnap.close();
|
|
378
|
+
} finally {
|
|
379
|
+
rmSync(stage, { recursive: true, force: true });
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("empty parachute home: no DBs, no configs, no crash", async () => {
|
|
384
|
+
const stage = mkdtempSync(join(tmpdir(), "stage-empty-"));
|
|
385
|
+
try {
|
|
386
|
+
const { contents } = await stageSnapshot({
|
|
387
|
+
configDir: home,
|
|
388
|
+
vaultsDir: join(home, "vaults"),
|
|
389
|
+
stagingDir: stage,
|
|
390
|
+
});
|
|
391
|
+
expect(contents.dbSnapshots).toEqual([]);
|
|
392
|
+
expect(contents.configFiles).toEqual([]);
|
|
393
|
+
} finally {
|
|
394
|
+
rmSync(stage, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// assembleTarball
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
describe("backup — assembleTarball", () => {
|
|
404
|
+
let dir: string;
|
|
405
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "backup-tar-")); });
|
|
406
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
407
|
+
|
|
408
|
+
test("produces a readable .tar.gz containing the staging contents", async () => {
|
|
409
|
+
const stage = mkdtempSync(join(tmpdir(), "stage-tar-"));
|
|
410
|
+
try {
|
|
411
|
+
writeFileSync(join(stage, "hello.txt"), "hi");
|
|
412
|
+
mkdirSync(join(stage, "subdir"), { recursive: true });
|
|
413
|
+
writeFileSync(join(stage, "subdir", "nested.txt"), "nested");
|
|
414
|
+
|
|
415
|
+
const tarball = join(dir, "out.tar.gz");
|
|
416
|
+
await assembleTarball(stage, tarball);
|
|
417
|
+
|
|
418
|
+
expect(existsSync(tarball)).toBe(true);
|
|
419
|
+
const extracted = await untar(tarball);
|
|
420
|
+
try {
|
|
421
|
+
expect(readFileSync(join(extracted, "hello.txt"), "utf-8")).toBe("hi");
|
|
422
|
+
expect(readFileSync(join(extracted, "subdir", "nested.txt"), "utf-8")).toBe("nested");
|
|
423
|
+
} finally {
|
|
424
|
+
rmSync(extracted, { recursive: true, force: true });
|
|
425
|
+
}
|
|
426
|
+
} finally {
|
|
427
|
+
rmSync(stage, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("produces a valid empty tarball when staging dir is empty", async () => {
|
|
432
|
+
const stage = mkdtempSync(join(tmpdir(), "stage-empty-tar-"));
|
|
433
|
+
try {
|
|
434
|
+
const tarball = join(dir, "empty.tar.gz");
|
|
435
|
+
await assembleTarball(stage, tarball);
|
|
436
|
+
expect(existsSync(tarball)).toBe(true);
|
|
437
|
+
// tar still extracts cleanly — just produces an empty dir.
|
|
438
|
+
const extracted = await untar(tarball);
|
|
439
|
+
try {
|
|
440
|
+
expect(readdirSync(extracted).length).toBe(0);
|
|
441
|
+
} finally {
|
|
442
|
+
rmSync(extracted, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
} finally {
|
|
445
|
+
rmSync(stage, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// runBackup — end-to-end
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
describe("backup — runBackup end-to-end", () => {
|
|
455
|
+
let home: string;
|
|
456
|
+
let destDir: string;
|
|
457
|
+
|
|
458
|
+
beforeEach(() => {
|
|
459
|
+
home = mkdtempSync(join(tmpdir(), "backup-e2e-"));
|
|
460
|
+
destDir = mkdtempSync(join(tmpdir(), "backup-dest-"));
|
|
461
|
+
});
|
|
462
|
+
afterEach(() => {
|
|
463
|
+
rmSync(home, { recursive: true, force: true });
|
|
464
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("writes a tarball with DB snapshots + configs to a local destination", async () => {
|
|
468
|
+
// Populate: one top-level DB, one vault, global config.
|
|
469
|
+
makeFakeDb(join(home, "daily.db"), "daily-marker");
|
|
470
|
+
const vaultsDir = join(home, "vaults");
|
|
471
|
+
makeFakeVault(vaultsDir, "default", "default-marker");
|
|
472
|
+
writeFileSync(join(home, "config.yaml"), "port: 1940\n");
|
|
473
|
+
|
|
474
|
+
const cfg: BackupConfig = {
|
|
475
|
+
schedule: "manual",
|
|
476
|
+
retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
|
|
477
|
+
destinations: [{ kind: "local", path: destDir }],
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const now = "2026-04-17T08:30:00.000Z";
|
|
481
|
+
const result = await runBackup({
|
|
482
|
+
configDir: home,
|
|
483
|
+
vaultsDir,
|
|
484
|
+
backup: cfg,
|
|
485
|
+
now,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Tarball landed at the destination with the expected filename.
|
|
489
|
+
const expectedName = backupFilename(now);
|
|
490
|
+
expect(result.destinations.length).toBe(1);
|
|
491
|
+
expect(result.destinations[0].writtenPath).toBe(join(destDir, expectedName));
|
|
492
|
+
expect(existsSync(join(destDir, expectedName))).toBe(true);
|
|
493
|
+
|
|
494
|
+
// Tarball contents include the vault DB + yaml + top-level DB + config.
|
|
495
|
+
const extracted = await untar(join(destDir, expectedName));
|
|
496
|
+
try {
|
|
497
|
+
expect(existsSync(join(extracted, "config.yaml"))).toBe(true);
|
|
498
|
+
expect(existsSync(join(extracted, "config-daily.db"))).toBe(true);
|
|
499
|
+
expect(existsSync(join(extracted, "vaults", "default", "vault.db"))).toBe(true);
|
|
500
|
+
expect(existsSync(join(extracted, "vaults", "default", "vault.yaml"))).toBe(true);
|
|
501
|
+
|
|
502
|
+
// Verify the snapshotted DB is readable and contains the marker row.
|
|
503
|
+
const snap = new Database(join(extracted, "vaults", "default", "vault.db"), {
|
|
504
|
+
readonly: true,
|
|
505
|
+
});
|
|
506
|
+
const row = snap.query("SELECT v FROM marker").get() as { v: string };
|
|
507
|
+
expect(row.v).toBe("default-marker");
|
|
508
|
+
snap.close();
|
|
509
|
+
} finally {
|
|
510
|
+
rmSync(extracted, { recursive: true, force: true });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// runBackup wrote a last-backup metadata file for `status`.
|
|
514
|
+
const last = readLastBackup(home);
|
|
515
|
+
expect(last).not.toBeNull();
|
|
516
|
+
expect(last!.timestamp).toBe(now);
|
|
517
|
+
expect(last!.destinations[0].path).toBe(join(destDir, expectedName));
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("retention pruning runs end-to-end: daily=3 leaves 3 most recent", async () => {
|
|
521
|
+
makeFakeDb(join(home, "daily.db"), "m");
|
|
522
|
+
const cfg: BackupConfig = {
|
|
523
|
+
schedule: "manual",
|
|
524
|
+
// Pure daily tier so this test stays focused on pipeline integration
|
|
525
|
+
// rather than tier bucketing (unit-tested above).
|
|
526
|
+
retention: { daily: 3, weekly: 0, monthly: 0, yearly: 0 },
|
|
527
|
+
destinations: [{ kind: "local", path: destDir }],
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
// Simulate 5 runs with monotonically increasing timestamps.
|
|
531
|
+
for (let i = 1; i <= 5; i++) {
|
|
532
|
+
const now = `2026-04-${String(i).padStart(2, "0")}T08:30:00.000Z`;
|
|
533
|
+
await runBackup({
|
|
534
|
+
configDir: home,
|
|
535
|
+
vaultsDir: join(home, "vaults"),
|
|
536
|
+
backup: cfg,
|
|
537
|
+
now,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Only the 3 most recent remain.
|
|
542
|
+
const survivors = readdirSync(destDir).filter((n) => n.startsWith("parachute-backup-")).sort();
|
|
543
|
+
expect(survivors.length).toBe(3);
|
|
544
|
+
// The earliest (Apr 1, Apr 2) are gone; Apr 3/4/5 are kept.
|
|
545
|
+
expect(survivors[0]).toMatch(/2026-04-03T/);
|
|
546
|
+
expect(survivors[2]).toMatch(/2026-04-05T/);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("per-destination failure doesn't abort other destinations", async () => {
|
|
550
|
+
makeFakeDb(join(home, "daily.db"), "m");
|
|
551
|
+
// Use an unwritable path alongside a working one. `/` is a classic
|
|
552
|
+
// unwritable-root target that mkdirSync(recursive: true) on a normal
|
|
553
|
+
// user account will reject with EACCES.
|
|
554
|
+
const cfg: BackupConfig = {
|
|
555
|
+
schedule: "manual",
|
|
556
|
+
retention: { daily: 7, weekly: 4, monthly: 12, yearly: null },
|
|
557
|
+
destinations: [
|
|
558
|
+
{ kind: "local", path: "/this/path/should/definitely/not/exist/and/be/unwritable" },
|
|
559
|
+
{ kind: "local", path: destDir },
|
|
560
|
+
],
|
|
561
|
+
};
|
|
562
|
+
const result = await runBackup({
|
|
563
|
+
configDir: home,
|
|
564
|
+
vaultsDir: join(home, "vaults"),
|
|
565
|
+
backup: cfg,
|
|
566
|
+
now: "2026-04-17T08:30:00.000Z",
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
expect(result.destinations.length).toBe(2);
|
|
570
|
+
// Either the mkdirSync throws (expected) or it somehow succeeds. Assert
|
|
571
|
+
// only on the second destination's success — that's the contract:
|
|
572
|
+
// one bad destination must not poison the other.
|
|
573
|
+
const good = result.destinations[1];
|
|
574
|
+
expect(good.error).toBeUndefined();
|
|
575
|
+
expect(good.writtenPath).toBe(join(destDir, backupFilename("2026-04-17T08:30:00.000Z")));
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
// checkDestinationWritable
|
|
581
|
+
// ---------------------------------------------------------------------------
|
|
582
|
+
|
|
583
|
+
describe("backup — checkDestinationWritable", () => {
|
|
584
|
+
let dir: string;
|
|
585
|
+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "dest-writable-")); });
|
|
586
|
+
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
|
587
|
+
|
|
588
|
+
test("writable path: passes + creates missing directory", () => {
|
|
589
|
+
const sub = join(dir, "nested", "dest");
|
|
590
|
+
const res = checkDestinationWritable({ kind: "local", path: sub });
|
|
591
|
+
expect(res.ok).toBe(true);
|
|
592
|
+
expect(res.path).toBe(sub);
|
|
593
|
+
expect(existsSync(sub)).toBe(true);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("unwritable path: fails with error detail", () => {
|
|
597
|
+
const res = checkDestinationWritable({
|
|
598
|
+
kind: "local",
|
|
599
|
+
path: "/nonexistent/and/root-only/destination",
|
|
600
|
+
});
|
|
601
|
+
expect(res.ok).toBe(false);
|
|
602
|
+
expect(res.error).toBeTruthy();
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ---------------------------------------------------------------------------
|
|
607
|
+
// CLI integration — `parachute vault backup` + `backup status`
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
describe("CLI — vault backup", () => {
|
|
611
|
+
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
612
|
+
|
|
613
|
+
function runCli(
|
|
614
|
+
args: string[],
|
|
615
|
+
parachuteHome: string,
|
|
616
|
+
): { exitCode: number; stdout: string; stderr: string } {
|
|
617
|
+
const proc = Bun.spawnSync({
|
|
618
|
+
cmd: ["bun", CLI, ...args],
|
|
619
|
+
env: { ...process.env, PARACHUTE_HOME: parachuteHome },
|
|
620
|
+
stdout: "pipe",
|
|
621
|
+
stderr: "pipe",
|
|
622
|
+
});
|
|
623
|
+
return {
|
|
624
|
+
exitCode: proc.exitCode ?? -1,
|
|
625
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
626
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let home: string;
|
|
631
|
+
let destDir: string;
|
|
632
|
+
beforeEach(() => {
|
|
633
|
+
home = mkdtempSync(join(tmpdir(), "cli-backup-"));
|
|
634
|
+
destDir = mkdtempSync(join(tmpdir(), "cli-backup-dest-"));
|
|
635
|
+
});
|
|
636
|
+
afterEach(() => {
|
|
637
|
+
rmSync(home, { recursive: true, force: true });
|
|
638
|
+
rmSync(destDir, { recursive: true, force: true });
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("exits non-zero with a clear message when no destinations are configured", () => {
|
|
642
|
+
writeFileSync(join(home, "config.yaml"), "port: 1940\n");
|
|
643
|
+
const res = runCli(["backup"], home);
|
|
644
|
+
expect(res.exitCode).not.toBe(0);
|
|
645
|
+
expect(res.stderr).toMatch(/No backup destinations configured/);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("`vault backup` writes a tarball when a local destination is configured", async () => {
|
|
649
|
+
// Populate a minimal vault + config with a local destination.
|
|
650
|
+
writeFileSync(
|
|
651
|
+
join(home, "config.yaml"),
|
|
652
|
+
[
|
|
653
|
+
"port: 1940",
|
|
654
|
+
"default_vault: default",
|
|
655
|
+
"backup:",
|
|
656
|
+
" schedule: manual",
|
|
657
|
+
" retention:",
|
|
658
|
+
" daily: 7",
|
|
659
|
+
" weekly: 4",
|
|
660
|
+
" monthly: 12",
|
|
661
|
+
" yearly: null",
|
|
662
|
+
" destinations:",
|
|
663
|
+
" - kind: local",
|
|
664
|
+
` path: ${destDir}`,
|
|
665
|
+
].join("\n") + "\n",
|
|
666
|
+
);
|
|
667
|
+
// A minimal vault so stageSnapshot has a DB to snapshot.
|
|
668
|
+
makeFakeVault(join(home, "vaults"), "default", "cli-marker");
|
|
669
|
+
|
|
670
|
+
const res = runCli(["backup"], home);
|
|
671
|
+
expect(res.exitCode, res.stderr).toBe(0);
|
|
672
|
+
expect(res.stdout).toMatch(/Running backup/);
|
|
673
|
+
expect(res.stdout).toMatch(/local →/);
|
|
674
|
+
|
|
675
|
+
const tarballs = readdirSync(destDir).filter((n) => n.startsWith("parachute-backup-"));
|
|
676
|
+
expect(tarballs.length).toBe(1);
|
|
677
|
+
|
|
678
|
+
// Unpack and verify the vault DB + vault.yaml are in there.
|
|
679
|
+
const extracted = await untar(join(destDir, tarballs[0]));
|
|
680
|
+
try {
|
|
681
|
+
expect(existsSync(join(extracted, "vaults", "default", "vault.db"))).toBe(true);
|
|
682
|
+
expect(existsSync(join(extracted, "vaults", "default", "vault.yaml"))).toBe(true);
|
|
683
|
+
expect(existsSync(join(extracted, "config.yaml"))).toBe(true);
|
|
684
|
+
} finally {
|
|
685
|
+
rmSync(extracted, { recursive: true, force: true });
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
test("`vault backup status` prints schedule / destinations / last run", () => {
|
|
690
|
+
writeFileSync(
|
|
691
|
+
join(home, "config.yaml"),
|
|
692
|
+
[
|
|
693
|
+
"port: 1940",
|
|
694
|
+
"backup:",
|
|
695
|
+
" schedule: daily",
|
|
696
|
+
" retention:",
|
|
697
|
+
" daily: 7",
|
|
698
|
+
" weekly: 4",
|
|
699
|
+
" monthly: 12",
|
|
700
|
+
" yearly: null",
|
|
701
|
+
" destinations:",
|
|
702
|
+
" - kind: local",
|
|
703
|
+
` path: ${destDir}`,
|
|
704
|
+
].join("\n") + "\n",
|
|
705
|
+
);
|
|
706
|
+
const res = runCli(["backup", "status"], home);
|
|
707
|
+
expect(res.exitCode).toBe(0);
|
|
708
|
+
expect(res.stdout).toMatch(/Schedule:\s+daily/);
|
|
709
|
+
// Tiered retention line: "7 daily / 4 weekly / 12 monthly / ∞ yearly"
|
|
710
|
+
expect(res.stdout).toMatch(/Retention:\s+7 daily \/ 4 weekly \/ 12 monthly \/ ∞ yearly/);
|
|
711
|
+
expect(res.stdout).toMatch(new RegExp(`local:\\s+${destDir.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}`));
|
|
712
|
+
// No backup has been run yet — assert on the "never" line.
|
|
713
|
+
expect(res.stdout).toMatch(/Last run:\s+\(never\)/);
|
|
714
|
+
});
|
|
715
|
+
});
|