mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
161 lines
7.5 KiB
TypeScript
161 lines
7.5 KiB
TypeScript
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
ReconstructionState,
|
|
inspectLegacyState,
|
|
readOptionalFile,
|
|
runtimeDatabaseDirectory,
|
|
runtimeScopeDirectory,
|
|
} from "./runtimeState";
|
|
|
|
describe("CLI runtime scope compatibility", () => {
|
|
it("retains SHA-256 directory names for database and Unicode Vault paths", () => {
|
|
expect(runtimeScopeDirectory("state", "headless-app", "/vault/日本語😀")).toBe(
|
|
path.join(
|
|
"state",
|
|
".livesync",
|
|
"runtime",
|
|
"scopes",
|
|
"511afda5613706862fa4add801b3f9b19e0db2acacc07b9218f2d01ff41ca61d",
|
|
"542d31d6918ad8bf915e204e41e479fdbd7251690f182fd802378323b4c6de23"
|
|
)
|
|
);
|
|
expect(path.basename(runtimeDatabaseDirectory("state", ""))).toBe(
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("CLI reconstruction state", () => {
|
|
let directory: string;
|
|
let settingsPath: string;
|
|
let state: ReconstructionState;
|
|
|
|
beforeEach(() => {
|
|
directory = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-reconstruction-"));
|
|
settingsPath = path.join(directory, "settings.json");
|
|
fs.writeFileSync(settingsPath, JSON.stringify({ additionalSuffixOfDatabaseName: "original" }));
|
|
state = new ReconstructionState(directory, path.join(directory, "vault"), settingsPath);
|
|
});
|
|
|
|
afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
|
|
it("retains the persisted settings fingerprint across hash implementations", () => {
|
|
state.prepare("fetch", true);
|
|
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({
|
|
settingsBeforeHash: "29e04e3dd1d10b352678a922ff5a03fbe529d3505e9cf46c2ecc860f44478ae8",
|
|
});
|
|
});
|
|
|
|
it("persists progress before returning and permits only the matching forced retry", () => {
|
|
state.prepare("fetch", true);
|
|
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({
|
|
phase: "running",
|
|
command: "fetch",
|
|
});
|
|
if (process.platform !== "win32") expect(fs.statSync(state.markerPath).mode & 0o777).toBe(0o600);
|
|
expect(() => state.prepare("sync", false)).toThrow("interrupted fetch");
|
|
expect(() => state.prepare("ls", false, true)).toThrow("interrupted fetch");
|
|
expect(() => state.prepare("fetch", false)).toThrow("interrupted fetch");
|
|
expect(() => state.prepare("rebuild", true)).toThrow("interrupted fetch");
|
|
expect(() =>
|
|
new ReconstructionState(directory, path.join(directory, "other"), settingsPath).prepare("fetch", true)
|
|
).toThrow("interrupted fetch");
|
|
expect(() =>
|
|
new ReconstructionState(
|
|
directory,
|
|
path.join(directory, "vault"),
|
|
path.join(directory, "other.json")
|
|
).prepare("fetch", true)
|
|
).toThrow("interrupted fetch");
|
|
expect(() => state.prepare("ls", false)).not.toThrow();
|
|
expect(() => state.prepare("fetch", true)).not.toThrow();
|
|
});
|
|
|
|
it("recovers completed settings before permitting another command", () => {
|
|
state.prepare("fetch", true);
|
|
const completed = JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app", retained: true });
|
|
state.complete(completed);
|
|
|
|
new ReconstructionState(directory, path.join(directory, "vault"), settingsPath).prepare("sync", false);
|
|
|
|
expect(fs.readFileSync(settingsPath, "utf-8")).toBe(completed);
|
|
expect(readOptionalFile(state.markerPath)).toBeUndefined();
|
|
});
|
|
|
|
it.each(["normal completion", "restart"] as const)(
|
|
"preserves user settings changed during reconstruction at %s",
|
|
(phase) => {
|
|
state.prepare("rebuild", true);
|
|
state.complete(JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app" }));
|
|
const edited = JSON.stringify({ additionalSuffixOfDatabaseName: "user-selection", unique: "edit" });
|
|
fs.writeFileSync(settingsPath, edited);
|
|
|
|
const commit =
|
|
phase === "restart"
|
|
? () =>
|
|
new ReconstructionState(directory, path.join(directory, "vault"), settingsPath).prepare(
|
|
"sync",
|
|
false
|
|
)
|
|
: () => state.commit();
|
|
|
|
expect(commit).toThrow("settings file changed");
|
|
expect(fs.readFileSync(settingsPath, "utf-8")).toBe(edited);
|
|
expect(JSON.parse(fs.readFileSync(state.markerPath, "utf-8"))).toMatchObject({ phase: "completed" });
|
|
}
|
|
);
|
|
|
|
it("clears a completed marker after an interruption following the settings rename", () => {
|
|
state.prepare("fetch", true);
|
|
const completed = JSON.stringify({ additionalSuffixOfDatabaseName: "headless-app" });
|
|
state.complete(completed);
|
|
fs.writeFileSync(settingsPath, completed);
|
|
state.prepare("ls", false);
|
|
expect(readOptionalFile(state.markerPath)).toBeUndefined();
|
|
});
|
|
|
|
it.each(["null", "[]", "{}", '{"version":2}', '{"version":1,"phase":"completed"}'])(
|
|
"refuses malformed state %s without clearing it",
|
|
(contents) => {
|
|
fs.mkdirSync(path.dirname(state.markerPath), { recursive: true });
|
|
fs.writeFileSync(state.markerPath, contents);
|
|
expect(() => state.prepare("sync", false)).toThrow("Invalid reconstruction state");
|
|
expect(fs.readFileSync(state.markerPath, "utf-8")).toBe(contents);
|
|
}
|
|
);
|
|
|
|
it.each(["null", "[]", '"text"', "{", ""])("reports invalid legacy KV %s without changing it", (contents) => {
|
|
const legacy = path.join(directory, ".livesync/runtime/keyvalue-db.json");
|
|
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
|
fs.writeFileSync(legacy, contents);
|
|
expect(inspectLegacyState(directory, directory)).toEqual([{ path: legacy, reason: "invalid" }]);
|
|
expect(fs.readFileSync(legacy, "utf-8")).toBe(contents);
|
|
});
|
|
|
|
it("reports both unreadable KV and invalid snapshot instead of hiding the second problem", () => {
|
|
const legacy = path.join(directory, ".livesync/runtime/keyvalue-db.json");
|
|
const snapshot = path.join(directory, ".livesync-snapshot.json");
|
|
fs.mkdirSync(legacy, { recursive: true });
|
|
fs.writeFileSync(snapshot, "{}");
|
|
expect(inspectLegacyState(directory, directory)).toEqual([
|
|
{ path: legacy, reason: "unreadable" },
|
|
{ path: snapshot, reason: "invalid" },
|
|
]);
|
|
expect(fs.statSync(legacy).isDirectory()).toBe(true);
|
|
expect(fs.readFileSync(snapshot, "utf-8")).toBe("{}");
|
|
});
|
|
|
|
it("preserves non-empty unowned legacy state and ignores empty legacy containers", () => {
|
|
const legacy = path.join(directory, ".livesync", "runtime", "keyvalue-db.json");
|
|
fs.mkdirSync(path.dirname(legacy), { recursive: true });
|
|
fs.writeFileSync(legacy, "{}");
|
|
fs.writeFileSync(path.join(directory, ".livesync-snapshot.json"), "[]");
|
|
expect(inspectLegacyState(directory, directory)).toEqual([]);
|
|
const queued = JSON.stringify({ "queued-files": ["unique-edit.md"] });
|
|
fs.writeFileSync(legacy, queued);
|
|
expect(inspectLegacyState(directory, directory)).toEqual([{ path: legacy, reason: "unowned" }]);
|
|
expect(fs.readFileSync(legacy, "utf-8")).toBe(queued);
|
|
});
|
|
});
|