Folio/tests/run-snapshots-test.mjs
avi b80aa798b2 fix(backup): exclude snapshots from recovery copy, harden change walk
Migration recovery backups no longer re-copy Folio-Backups (up to 21x bloat). bookChangedSince tracks visited dev:ino so a symlink loop in the book cannot wedge the main-process interval tick. Snapshot toggle check aligned to === false; drop dead home-shortening branch in Settings.
2026-09-19 17:20:31 -05:00

128 lines
4.7 KiB
JavaScript

// Standalone test for continuous snapshot logic (src/main/backup.ts).
// Run with: node tests/run-snapshots-test.mjs
import fs from "fs";
import os from "os";
import path from "path";
import { bundleForTest } from "./helpers/bundle.mjs";
const { mod: project, cleanupBundle: cleanupProj } = await bundleForTest({
name: "folio-snap-project",
entryPoints: ["src/main/project.ts"],
});
const { mod: chapters, cleanupBundle: cleanupChap } = await bundleForTest({
name: "folio-snap-chapters",
entryPoints: ["src/main/chapters.ts"],
});
const { mod: backup, cleanupBundle: cleanupBackup } = await bundleForTest({
name: "folio-snap-backup",
entryPoints: ["src/main/backup.ts"],
});
const { createBook } = project;
const { createChapter, setChapterContent } = chapters;
const {
bindBackupSettings,
getSnapshotRoot,
listSnapshots,
createSnapshot,
pruneSnapshots,
bookChangedSince,
ensureRecoveryBackup,
getRecoveryRoot,
_resetSessionBackupGuard,
SNAPSHOT_DIR_NAME,
} = backup;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else { failures++; console.error("FAIL -", msg); }
}
// In-memory settings store (backup.ts stays Electron-free).
const settings = new Map();
bindBackupSettings({
get: (k, fb) => (settings.has(k) ? settings.get(k) : fb),
set: (k, v) => { settings.set(k, v); },
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-snap-"));
createBook(dir, "Snap Book");
const c = createChapter(dir, "One");
setChapterContent(dir, c.id, "hello");
// default root = <book>/Folio-Backups
ok(getSnapshotRoot(dir) === path.join(dir, SNAPSHOT_DIR_NAME), "default snapshot root is per-book folder");
// snapshot creates a timestamped copy with content
const s1 = createSnapshot(dir, 20);
ok(typeof s1 === "string" && fs.existsSync(s1), "createSnapshot returns existing dir: " + s1);
ok(fs.existsSync(path.join(s1, "folio.json")), "snapshot contains folio.json");
ok(listSnapshots(dir).length === 1, "one snapshot listed");
// snapshots never nest: second snapshot must not contain the backup dir
await new Promise((r) => setTimeout(r, 5));
const s2 = createSnapshot(dir, 20);
ok(listSnapshots(dir).length === 2, "two snapshots listed");
ok(!fs.existsSync(path.join(s2, SNAPSHOT_DIR_NAME)), "snapshot does not contain nested backup dir");
// bookChangedSince: quiet -> false, touched -> true
ok(bookChangedSince(dir, Date.now()) === false, "no change since now");
setChapterContent(dir, c.id, "hello edited");
ok(bookChangedSince(dir, Date.now() - 1000) === true, "detects content change");
// pruning keeps newest N
await new Promise((r) => setTimeout(r, 5));
createSnapshot(dir, 20);
ok(listSnapshots(dir).length === 3, "three snapshots before prune");
pruneSnapshots(dir, 2);
const kept = listSnapshots(dir);
ok(kept.length === 2, "prune keeps newest 2");
ok(!fs.existsSync(s1), "oldest snapshot pruned");
// custom root honored
const custom = fs.mkdtempSync(path.join(os.tmpdir(), "folio-snaproot-"));
settings.set("snapshotLocation", custom);
ok(getSnapshotRoot(dir) === custom, "custom snapshot root honored");
const s3 = createSnapshot(dir, 20);
ok(s3.startsWith(custom), "snapshot lands in custom root");
settings.delete("snapshotLocation");
// missing dir -> null, no throw
ok(createSnapshot(path.join(dir, "nope"), 5) === null, "missing book returns null");
// symlink loop inside the book must not hang the change check
try {
fs.symlinkSync(dir, path.join(dir, "chapters", "loop"), "dir");
const t0 = Date.now();
const changed = bookChangedSince(dir, Date.now() + 60_000);
ok(changed === false && Date.now() - t0 < 5000, "symlink loop terminates without false positive");
} catch {
ok(true, "symlink loop test skipped (no symlink permission)");
}
// migration recovery backup excludes the snapshot dir (no 21x bloat)
const home = fs.mkdtempSync(path.join(os.tmpdir(), "folio-fakehome-"));
const origHome = process.env.HOME;
process.env.HOME = home;
try {
_resetSessionBackupGuard();
const dest = ensureRecoveryBackup(dir);
ok(typeof dest === "string" && fs.existsSync(dest), "recovery backup created");
ok(fs.existsSync(path.join(dest, "folio.json")), "recovery backup contains folio.json");
ok(!fs.existsSync(path.join(dest, SNAPSHOT_DIR_NAME)), "recovery backup excludes snapshots");
ok(getRecoveryRoot().startsWith(home), "recovery root honors HOME");
} finally {
process.env.HOME = origHome;
}
// cleanup
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(custom, { recursive: true, force: true });
fs.rmSync(home, { recursive: true, force: true });
cleanupProj();
cleanupChap();
cleanupBackup();
if (failures) { console.error(`${failures} snapshot check(s) FAILED`); process.exit(1); }
console.log("All snapshot checks passed.");