@openparachute/vault 0.4.6 → 0.4.7-rc.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/package.json +1 -1
- package/src/config.ts +24 -0
- package/src/mirror-config.test.ts +328 -0
- package/src/mirror-config.ts +470 -0
- package/src/mirror-deps.ts +88 -0
- package/src/mirror-manager.test.ts +550 -0
- package/src/mirror-manager.ts +521 -0
- package/src/mirror-registry.ts +26 -0
- package/src/mirror-routes.test.ts +380 -0
- package/src/mirror-routes.ts +152 -0
- package/src/routing.test.ts +76 -0
- package/src/routing.ts +46 -0
- package/src/server.ts +52 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the MirrorManager lifecycle — bootstrap, start/stop/reload,
|
|
3
|
+
* watch loop arming, status tracking (vault-sync Phase A1).
|
|
4
|
+
*
|
|
5
|
+
* The manager is dependency-injected so tests pass fake `runExport` +
|
|
6
|
+
* `firstChangedNoteTitle` + config read/write closures. No real vault
|
|
7
|
+
* store needed.
|
|
8
|
+
*
|
|
9
|
+
* Filesystem assertions hit real tempdirs + spawn real `git` so we
|
|
10
|
+
* exercise the actual bootstrap logic (mkdir + git init + initial commit).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
bootstrapInternalMirror,
|
|
20
|
+
MirrorManager,
|
|
21
|
+
type MirrorDeps,
|
|
22
|
+
} from "./mirror-manager.ts";
|
|
23
|
+
import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
|
|
24
|
+
|
|
25
|
+
// Snapshot HOME + PARACHUTE_HOME at module load; restore after every test
|
|
26
|
+
// so the `process.env.HOME = ...` rewrite in `makeFakeDeps` doesn't leak
|
|
27
|
+
// into sibling test files (e.g. mcp-install.test.ts reads `os.homedir()`
|
|
28
|
+
// and would otherwise see our tempdir).
|
|
29
|
+
const ORIG_HOME = process.env.HOME;
|
|
30
|
+
const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
33
|
+
else process.env.HOME = ORIG_HOME;
|
|
34
|
+
if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
|
|
35
|
+
else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
|
|
36
|
+
});
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
39
|
+
else process.env.HOME = ORIG_HOME;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function tmp(prefix: string): string {
|
|
43
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function initRepo(dir: string): void {
|
|
47
|
+
Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
|
|
48
|
+
Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
|
|
49
|
+
Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
|
|
50
|
+
Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: dir });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function seedCommit(dir: string): void {
|
|
54
|
+
fs.writeFileSync(path.join(dir, ".gitkeep"), "");
|
|
55
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: dir });
|
|
56
|
+
Bun.spawnSync(["git", "commit", "-q", "-m", "initial"], { cwd: dir });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isGitRepoSync(dir: string): boolean {
|
|
60
|
+
const proc = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], {
|
|
61
|
+
cwd: dir,
|
|
62
|
+
});
|
|
63
|
+
return (proc.exitCode ?? 1) === 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commitCount(dir: string): number {
|
|
67
|
+
const proc = Bun.spawnSync(["git", "log", "--oneline"], { cwd: dir, stdout: "pipe" });
|
|
68
|
+
return new TextDecoder()
|
|
69
|
+
.decode(proc.stdout)
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((l) => l.trim().length > 0).length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a fake `MirrorDeps` with controllable export + config behavior.
|
|
76
|
+
* `parachuteHome` controls `vaultDir()` lookups so internal-location
|
|
77
|
+
* resolution lands inside a tempdir.
|
|
78
|
+
*/
|
|
79
|
+
function makeFakeDeps(opts: {
|
|
80
|
+
vaultName?: string;
|
|
81
|
+
parachuteHome: string;
|
|
82
|
+
initialConfig?: MirrorConfig | undefined;
|
|
83
|
+
/** Optional override for runExport — return note count per call. */
|
|
84
|
+
runExport?: (call: { outDir: string; sinceCursor?: string }) => Promise<{ notes: number }>;
|
|
85
|
+
}): MirrorDeps & {
|
|
86
|
+
exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
|
|
87
|
+
storedConfig: MirrorConfig | undefined;
|
|
88
|
+
} {
|
|
89
|
+
process.env.PARACHUTE_HOME = opts.parachuteHome;
|
|
90
|
+
process.env.HOME = opts.parachuteHome;
|
|
91
|
+
|
|
92
|
+
const state: {
|
|
93
|
+
config: MirrorConfig | undefined;
|
|
94
|
+
exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
|
|
95
|
+
} = {
|
|
96
|
+
config: opts.initialConfig,
|
|
97
|
+
exportCalls: [],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const base: MirrorDeps = {
|
|
101
|
+
vaultName: opts.vaultName ?? "default",
|
|
102
|
+
runExport: async (call: { outDir: string; sinceCursor?: string }) => {
|
|
103
|
+
state.exportCalls.push({
|
|
104
|
+
outDir: call.outDir,
|
|
105
|
+
sinceCursor: call.sinceCursor,
|
|
106
|
+
});
|
|
107
|
+
if (opts.runExport) return opts.runExport(call);
|
|
108
|
+
return { notes: 1 };
|
|
109
|
+
},
|
|
110
|
+
firstChangedNoteTitle: async () => "Inbox/fake",
|
|
111
|
+
readMirrorConfig: () => state.config,
|
|
112
|
+
writeMirrorConfig: (c: MirrorConfig) => {
|
|
113
|
+
state.config = c;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
// Defining the test-visible getters via defineProperty so the getter
|
|
117
|
+
// bodies run on every access — otherwise an Object.assign snapshots
|
|
118
|
+
// the field once at construction time and stale values leak.
|
|
119
|
+
Object.defineProperty(base, "exportCalls", {
|
|
120
|
+
get: () => state.exportCalls,
|
|
121
|
+
enumerable: true,
|
|
122
|
+
});
|
|
123
|
+
Object.defineProperty(base, "storedConfig", {
|
|
124
|
+
get: () => state.config,
|
|
125
|
+
enumerable: true,
|
|
126
|
+
});
|
|
127
|
+
return base as MirrorDeps & {
|
|
128
|
+
exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
|
|
129
|
+
storedConfig: MirrorConfig | undefined;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// bootstrapInternalMirror
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
describe("bootstrapInternalMirror", () => {
|
|
138
|
+
let dir: string;
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("creates the dir + git-inits + seed commit when path doesn't exist", async () => {
|
|
144
|
+
dir = path.join(tmp("mirror-boot-"), "mirror");
|
|
145
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
146
|
+
const r = await bootstrapInternalMirror(dir);
|
|
147
|
+
expect(r.ok).toBe(true);
|
|
148
|
+
if (r.ok) {
|
|
149
|
+
expect(r.initialized).toBe(true);
|
|
150
|
+
expect(fs.existsSync(dir)).toBe(true);
|
|
151
|
+
expect(isGitRepoSync(dir)).toBe(true);
|
|
152
|
+
expect(commitCount(dir)).toBe(1);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("idempotent on already-bootstrapped repo (no re-init)", async () => {
|
|
157
|
+
const parent = tmp("mirror-boot-idem-");
|
|
158
|
+
dir = path.join(parent, "mirror");
|
|
159
|
+
fs.mkdirSync(dir);
|
|
160
|
+
initRepo(dir);
|
|
161
|
+
seedCommit(dir);
|
|
162
|
+
const before = commitCount(dir);
|
|
163
|
+
const r = await bootstrapInternalMirror(dir);
|
|
164
|
+
expect(r.ok).toBe(true);
|
|
165
|
+
if (r.ok) {
|
|
166
|
+
expect(r.initialized).toBe(false);
|
|
167
|
+
expect(commitCount(dir)).toBe(before); // didn't add a new seed commit
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("refuses to clobber a non-empty, non-git directory", async () => {
|
|
172
|
+
dir = tmp("mirror-boot-clobber-");
|
|
173
|
+
fs.writeFileSync(path.join(dir, "important.txt"), "do not nuke");
|
|
174
|
+
const r = await bootstrapInternalMirror(dir);
|
|
175
|
+
expect(r.ok).toBe(false);
|
|
176
|
+
if (!r.ok) {
|
|
177
|
+
expect(r.error).toContain("isn't a git repository");
|
|
178
|
+
expect(r.error).toContain("Remove it");
|
|
179
|
+
}
|
|
180
|
+
// The operator's file is untouched.
|
|
181
|
+
expect(fs.readFileSync(path.join(dir, "important.txt"), "utf-8")).toBe("do not nuke");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("initializes an empty, non-git directory", async () => {
|
|
185
|
+
dir = tmp("mirror-boot-empty-");
|
|
186
|
+
expect(fs.readdirSync(dir)).toEqual([]);
|
|
187
|
+
const r = await bootstrapInternalMirror(dir);
|
|
188
|
+
expect(r.ok).toBe(true);
|
|
189
|
+
if (r.ok) {
|
|
190
|
+
expect(r.initialized).toBe(true);
|
|
191
|
+
expect(isGitRepoSync(dir)).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// MirrorManager.start — boot-time lifecycle matrix
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
describe("MirrorManager.start — lifecycle matrix", () => {
|
|
201
|
+
let home: string;
|
|
202
|
+
afterEach(async () => {
|
|
203
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("enabled: false → no mirror behavior (regression for upgrading vaults)", async () => {
|
|
207
|
+
home = tmp("mgr-disabled-");
|
|
208
|
+
// Seed the vault dir so vaultDir() resolves cleanly.
|
|
209
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
210
|
+
const deps = makeFakeDeps({
|
|
211
|
+
parachuteHome: home,
|
|
212
|
+
initialConfig: { ...defaultMirrorConfig(), enabled: false },
|
|
213
|
+
});
|
|
214
|
+
const mgr = new MirrorManager(deps);
|
|
215
|
+
const status = await mgr.start();
|
|
216
|
+
expect(status.enabled).toBe(false);
|
|
217
|
+
expect(status.watch_running).toBe(false);
|
|
218
|
+
expect(status.mirror_path).toBeNull();
|
|
219
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
220
|
+
await mgr.stop();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("undefined config → behaves like disabled", async () => {
|
|
224
|
+
home = tmp("mgr-undef-");
|
|
225
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
226
|
+
const deps = makeFakeDeps({ parachuteHome: home, initialConfig: undefined });
|
|
227
|
+
const mgr = new MirrorManager(deps);
|
|
228
|
+
const status = await mgr.start();
|
|
229
|
+
expect(status.enabled).toBe(false);
|
|
230
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
231
|
+
await mgr.stop();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("internal + watch:false → bootstraps + runs initial export, no watch", async () => {
|
|
235
|
+
home = tmp("mgr-int-nowatch-");
|
|
236
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
237
|
+
const deps = makeFakeDeps({
|
|
238
|
+
parachuteHome: home,
|
|
239
|
+
initialConfig: {
|
|
240
|
+
...defaultMirrorConfig(),
|
|
241
|
+
enabled: true,
|
|
242
|
+
location: "internal",
|
|
243
|
+
watch: false,
|
|
244
|
+
auto_commit: false, // skip commit cycle for this unit
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const mgr = new MirrorManager(deps);
|
|
248
|
+
const status = await mgr.start();
|
|
249
|
+
expect(status.enabled).toBe(true);
|
|
250
|
+
expect(status.watch_running).toBe(false);
|
|
251
|
+
expect(status.mirror_path).toBe(
|
|
252
|
+
path.join(home, "vault", "data", "default", "mirror"),
|
|
253
|
+
);
|
|
254
|
+
expect(deps.exportCalls).toHaveLength(1);
|
|
255
|
+
expect(deps.exportCalls[0]!.sinceCursor).toBeUndefined(); // initial = full
|
|
256
|
+
// Bootstrapped on disk.
|
|
257
|
+
expect(fs.existsSync(status.mirror_path!)).toBe(true);
|
|
258
|
+
expect(isGitRepoSync(status.mirror_path!)).toBe(true);
|
|
259
|
+
await mgr.stop();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("internal + watch:true → bootstraps + runs initial export + arms watch", async () => {
|
|
263
|
+
home = tmp("mgr-int-watch-");
|
|
264
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
265
|
+
const deps = makeFakeDeps({
|
|
266
|
+
parachuteHome: home,
|
|
267
|
+
initialConfig: {
|
|
268
|
+
...defaultMirrorConfig(),
|
|
269
|
+
enabled: true,
|
|
270
|
+
location: "internal",
|
|
271
|
+
watch: true,
|
|
272
|
+
auto_commit: false,
|
|
273
|
+
interval_seconds: 1,
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const mgr = new MirrorManager(deps);
|
|
277
|
+
const status = await mgr.start();
|
|
278
|
+
expect(status.enabled).toBe(true);
|
|
279
|
+
expect(status.watch_running).toBe(true);
|
|
280
|
+
// Initial export ran.
|
|
281
|
+
expect(deps.exportCalls).toHaveLength(1);
|
|
282
|
+
await mgr.stop();
|
|
283
|
+
expect(mgr.getStatus().watch_running).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("external + watch:true with valid git repo → uses external path", async () => {
|
|
287
|
+
home = tmp("mgr-ext-watch-");
|
|
288
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
289
|
+
const external = tmp("mgr-ext-target-");
|
|
290
|
+
initRepo(external);
|
|
291
|
+
seedCommit(external);
|
|
292
|
+
const deps = makeFakeDeps({
|
|
293
|
+
parachuteHome: home,
|
|
294
|
+
initialConfig: {
|
|
295
|
+
...defaultMirrorConfig(),
|
|
296
|
+
enabled: true,
|
|
297
|
+
location: "external",
|
|
298
|
+
external_path: external,
|
|
299
|
+
watch: true,
|
|
300
|
+
auto_commit: false,
|
|
301
|
+
interval_seconds: 1,
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
const mgr = new MirrorManager(deps);
|
|
305
|
+
const status = await mgr.start();
|
|
306
|
+
expect(status.enabled).toBe(true);
|
|
307
|
+
expect(status.watch_running).toBe(true);
|
|
308
|
+
expect(status.mirror_path).toBe(external);
|
|
309
|
+
// Initial export pointed at the external path.
|
|
310
|
+
expect(deps.exportCalls[0]!.outDir).toBe(external);
|
|
311
|
+
await mgr.stop();
|
|
312
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("external + missing path → enabled:false with clear error", async () => {
|
|
316
|
+
home = tmp("mgr-ext-missing-");
|
|
317
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
318
|
+
const deps = makeFakeDeps({
|
|
319
|
+
parachuteHome: home,
|
|
320
|
+
initialConfig: {
|
|
321
|
+
...defaultMirrorConfig(),
|
|
322
|
+
enabled: true,
|
|
323
|
+
location: "external",
|
|
324
|
+
external_path: "/definitely/not/a/path/here",
|
|
325
|
+
watch: true,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
const mgr = new MirrorManager(deps);
|
|
329
|
+
const status = await mgr.start();
|
|
330
|
+
expect(status.enabled).toBe(false);
|
|
331
|
+
expect(status.last_error).toContain("doesn't exist");
|
|
332
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
333
|
+
await mgr.stop();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("external + path exists but not a git repo → enabled:false", async () => {
|
|
337
|
+
home = tmp("mgr-ext-nogit-");
|
|
338
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
339
|
+
const external = tmp("mgr-ext-plain-");
|
|
340
|
+
const deps = makeFakeDeps({
|
|
341
|
+
parachuteHome: home,
|
|
342
|
+
initialConfig: {
|
|
343
|
+
...defaultMirrorConfig(),
|
|
344
|
+
enabled: true,
|
|
345
|
+
location: "external",
|
|
346
|
+
external_path: external,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
const mgr = new MirrorManager(deps);
|
|
350
|
+
const status = await mgr.start();
|
|
351
|
+
expect(status.enabled).toBe(false);
|
|
352
|
+
expect(status.last_error).toContain("isn't a git repository");
|
|
353
|
+
await mgr.stop();
|
|
354
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("internal bootstrap refuses to clobber pre-existing non-git data", async () => {
|
|
358
|
+
home = tmp("mgr-int-clobber-");
|
|
359
|
+
const mirrorPath = path.join(home, "vault", "data", "default", "mirror");
|
|
360
|
+
fs.mkdirSync(mirrorPath, { recursive: true });
|
|
361
|
+
fs.writeFileSync(path.join(mirrorPath, "manual-note.md"), "user content");
|
|
362
|
+
const deps = makeFakeDeps({
|
|
363
|
+
parachuteHome: home,
|
|
364
|
+
initialConfig: {
|
|
365
|
+
...defaultMirrorConfig(),
|
|
366
|
+
enabled: true,
|
|
367
|
+
location: "internal",
|
|
368
|
+
watch: false,
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
const mgr = new MirrorManager(deps);
|
|
372
|
+
const status = await mgr.start();
|
|
373
|
+
expect(status.enabled).toBe(false);
|
|
374
|
+
expect(status.last_error).toContain("isn't a git repository");
|
|
375
|
+
// The operator's file survives.
|
|
376
|
+
expect(fs.existsSync(path.join(mirrorPath, "manual-note.md"))).toBe(true);
|
|
377
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
378
|
+
await mgr.stop();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("internal bootstrap reuses an existing git repo without re-init", async () => {
|
|
382
|
+
home = tmp("mgr-int-reuse-");
|
|
383
|
+
const mirrorPath = path.join(home, "vault", "data", "default", "mirror");
|
|
384
|
+
fs.mkdirSync(mirrorPath, { recursive: true });
|
|
385
|
+
initRepo(mirrorPath);
|
|
386
|
+
seedCommit(mirrorPath);
|
|
387
|
+
const before = commitCount(mirrorPath);
|
|
388
|
+
const deps = makeFakeDeps({
|
|
389
|
+
parachuteHome: home,
|
|
390
|
+
initialConfig: {
|
|
391
|
+
...defaultMirrorConfig(),
|
|
392
|
+
enabled: true,
|
|
393
|
+
location: "internal",
|
|
394
|
+
watch: false,
|
|
395
|
+
auto_commit: false,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
const mgr = new MirrorManager(deps);
|
|
399
|
+
const status = await mgr.start();
|
|
400
|
+
expect(status.enabled).toBe(true);
|
|
401
|
+
// Did not re-bootstrap; the existing seed commit is still the only commit.
|
|
402
|
+
expect(commitCount(mirrorPath)).toBe(before);
|
|
403
|
+
await mgr.stop();
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// MirrorManager.stop + reload
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
|
|
411
|
+
describe("MirrorManager.stop / reload", () => {
|
|
412
|
+
let home: string;
|
|
413
|
+
afterEach(() => {
|
|
414
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("stop() halts the watch timer", async () => {
|
|
418
|
+
home = tmp("mgr-stop-");
|
|
419
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
420
|
+
const deps = makeFakeDeps({
|
|
421
|
+
parachuteHome: home,
|
|
422
|
+
initialConfig: {
|
|
423
|
+
...defaultMirrorConfig(),
|
|
424
|
+
enabled: true,
|
|
425
|
+
location: "internal",
|
|
426
|
+
watch: true,
|
|
427
|
+
auto_commit: false,
|
|
428
|
+
interval_seconds: 1,
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
const mgr = new MirrorManager(deps);
|
|
432
|
+
await mgr.start();
|
|
433
|
+
expect(mgr.getStatus().watch_running).toBe(true);
|
|
434
|
+
await mgr.stop();
|
|
435
|
+
expect(mgr.getStatus().watch_running).toBe(false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("reload() persists + restarts with new config", async () => {
|
|
439
|
+
home = tmp("mgr-reload-");
|
|
440
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
441
|
+
const deps = makeFakeDeps({
|
|
442
|
+
parachuteHome: home,
|
|
443
|
+
initialConfig: undefined,
|
|
444
|
+
});
|
|
445
|
+
const mgr = new MirrorManager(deps);
|
|
446
|
+
await mgr.start();
|
|
447
|
+
expect(mgr.getStatus().enabled).toBe(false);
|
|
448
|
+
|
|
449
|
+
// Enable + reload.
|
|
450
|
+
const newConfig: MirrorConfig = {
|
|
451
|
+
...defaultMirrorConfig(),
|
|
452
|
+
enabled: true,
|
|
453
|
+
location: "internal",
|
|
454
|
+
watch: false,
|
|
455
|
+
auto_commit: false,
|
|
456
|
+
};
|
|
457
|
+
const status = await mgr.reload(newConfig);
|
|
458
|
+
expect(status.enabled).toBe(true);
|
|
459
|
+
expect(deps.storedConfig).toEqual(newConfig);
|
|
460
|
+
|
|
461
|
+
// Disable.
|
|
462
|
+
const disabled = await mgr.reload({ ...newConfig, enabled: false });
|
|
463
|
+
expect(disabled.enabled).toBe(false);
|
|
464
|
+
expect(mgr.getStatus().watch_running).toBe(false);
|
|
465
|
+
await mgr.stop();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("reload from internal → external swaps the mirror path", async () => {
|
|
469
|
+
home = tmp("mgr-swap-");
|
|
470
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
471
|
+
const external = tmp("mgr-swap-ext-");
|
|
472
|
+
initRepo(external);
|
|
473
|
+
seedCommit(external);
|
|
474
|
+
const deps = makeFakeDeps({
|
|
475
|
+
parachuteHome: home,
|
|
476
|
+
initialConfig: {
|
|
477
|
+
...defaultMirrorConfig(),
|
|
478
|
+
enabled: true,
|
|
479
|
+
location: "internal",
|
|
480
|
+
watch: false,
|
|
481
|
+
auto_commit: false,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
const mgr = new MirrorManager(deps);
|
|
485
|
+
await mgr.start();
|
|
486
|
+
const internalPath = mgr.getStatus().mirror_path;
|
|
487
|
+
expect(internalPath).toContain(path.join("vault", "data", "default", "mirror"));
|
|
488
|
+
|
|
489
|
+
const swapped = await mgr.reload({
|
|
490
|
+
...defaultMirrorConfig(),
|
|
491
|
+
enabled: true,
|
|
492
|
+
location: "external",
|
|
493
|
+
external_path: external,
|
|
494
|
+
watch: false,
|
|
495
|
+
auto_commit: false,
|
|
496
|
+
});
|
|
497
|
+
expect(swapped.mirror_path).toBe(external);
|
|
498
|
+
expect(deps.exportCalls.length).toBeGreaterThanOrEqual(2);
|
|
499
|
+
await mgr.stop();
|
|
500
|
+
fs.rmSync(external, { recursive: true, force: true });
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// runNow
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
describe("MirrorManager.runNow", () => {
|
|
509
|
+
let home: string;
|
|
510
|
+
afterEach(() => {
|
|
511
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("runs a single cycle on demand when enabled", async () => {
|
|
515
|
+
home = tmp("mgr-runnow-");
|
|
516
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
517
|
+
const deps = makeFakeDeps({
|
|
518
|
+
parachuteHome: home,
|
|
519
|
+
initialConfig: {
|
|
520
|
+
...defaultMirrorConfig(),
|
|
521
|
+
enabled: true,
|
|
522
|
+
location: "internal",
|
|
523
|
+
watch: false,
|
|
524
|
+
auto_commit: false,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
const mgr = new MirrorManager(deps);
|
|
528
|
+
await mgr.start();
|
|
529
|
+
expect(deps.exportCalls).toHaveLength(1); // initial
|
|
530
|
+
await mgr.runNow();
|
|
531
|
+
expect(deps.exportCalls).toHaveLength(2);
|
|
532
|
+
// The non-initial run carries a cursor.
|
|
533
|
+
expect(deps.exportCalls[1]!.sinceCursor).toBeDefined();
|
|
534
|
+
await mgr.stop();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("noop when disabled", async () => {
|
|
538
|
+
home = tmp("mgr-runnow-disabled-");
|
|
539
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
540
|
+
const deps = makeFakeDeps({
|
|
541
|
+
parachuteHome: home,
|
|
542
|
+
initialConfig: { ...defaultMirrorConfig(), enabled: false },
|
|
543
|
+
});
|
|
544
|
+
const mgr = new MirrorManager(deps);
|
|
545
|
+
await mgr.start();
|
|
546
|
+
await mgr.runNow();
|
|
547
|
+
expect(deps.exportCalls).toHaveLength(0);
|
|
548
|
+
await mgr.stop();
|
|
549
|
+
});
|
|
550
|
+
});
|