mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-28 20:13:21 +00:00
Merge main and verify comparison status with published Commonlib
This commit is contained in:
commit
c9381d6a59
38 files changed
+4615
-119
No files matched your search
@@ -7,6 +7,9 @@
|
||||
* remove it from this map in the same change.
|
||||
*/
|
||||
export const liveSyncProvisionalEnglishMessages = {
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
|
||||
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
|
||||
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
|
||||
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: (path: string) => path,
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({ PluginDialogModal: class PluginDialogModal {} }));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({ PeriodicProcessor: class PeriodicProcessor {} }));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: { onEvent: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({ $msg: (message: string) => message }));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ getObsidianCommunityPluginManager: vi.fn() }));
|
||||
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
import { ConfigSync, PluginDataExDisplayV2, pluginManifests } from "./CmdConfigSync";
|
||||
|
||||
const filenames = ["main.js", "manifest.json", "styles.css"];
|
||||
const content: Record<string, string> = {
|
||||
"main.js": "module.exports = class ExamplePlugin {};",
|
||||
"manifest.json": JSON.stringify({ id: "example", name: "Example", version: "1.0.0" }),
|
||||
"styles.css": ".example { color: red; }",
|
||||
};
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function catalogue() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.syncOnlyRegEx = "\\.(?:md|png|pdf)$" as typeof fixture.settings.syncOnlyRegEx;
|
||||
const paths = filenames.map((name) => `ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix);
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const wireContent = `fixture-header\u200b\n\u200c${btoa(content[filenames[index]])}`;
|
||||
await expect(fixture.localDatabase.putDBEntry(optionalFileEntry(path, wireContent))).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
const gates = new Map(paths.map((path) => [path, deferred()]));
|
||||
const readEntry = fixture.localDatabase.getDBEntry.bind(fixture.localDatabase);
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockImplementation(async (path, ...args) => {
|
||||
await gates.get(path)?.promise;
|
||||
return readEntry(path, ...args);
|
||||
});
|
||||
const written = new Map<string, string>();
|
||||
const configSync = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(configSync, {
|
||||
core: {
|
||||
localDatabase: fixture.localDatabase,
|
||||
settings: fixture.settings,
|
||||
services: fixture.services,
|
||||
storageAccess: {
|
||||
ensureDir: vi.fn(async () => true),
|
||||
readHiddenFileText: vi.fn(async (path: string) => written.get(path) ?? ""),
|
||||
writeHiddenFileAuto: vi.fn(async (path: string, data: string) => {
|
||||
written.set(path, data);
|
||||
}),
|
||||
},
|
||||
},
|
||||
pluginList: [],
|
||||
pluginListGeneration: 0,
|
||||
loadedManifest_mTime: new Map(),
|
||||
updatingV2Count: 0,
|
||||
pluginScanProcessor: { clearQueue: vi.fn(), enqueue: vi.fn() },
|
||||
_log: vi.fn(),
|
||||
storeCustomisationFileV2: vi.fn(async () => true),
|
||||
});
|
||||
const updates: Promise<void>[] = [];
|
||||
const update = configSync.updatePluginListV2.bind(configSync);
|
||||
vi.spyOn(configSync, "updatePluginListV2").mockImplementation((...args) => {
|
||||
const promise = update(...args);
|
||||
updates.push(promise);
|
||||
return promise;
|
||||
});
|
||||
const release = (order = filenames) => {
|
||||
for (const name of order) gates.get(`ix:device/PLUGIN_MAIN/example%${name}` as FilePathWithPrefix)!.resolve();
|
||||
};
|
||||
return { ...fixture, configSync, updates, paths, release, written };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
pluginManifests.clear();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("issue #1271: rebuilding a multi-file Customisation Sync item", () => {
|
||||
it.each([
|
||||
{ order: filenames },
|
||||
{ order: [...filenames].reverse() },
|
||||
{ order: ["manifest.json", "main.js", "styles.css"] },
|
||||
])("retains all per-file documents when reads are released as $order", async ({ order }) => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release(order);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
const documents = await fixture.database.allDocs({ keys: fixture.paths });
|
||||
expect(documents.rows).toHaveLength(3);
|
||||
expect(fixture.configSync.pluginList).toHaveLength(1);
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("applies all three files after the first reload", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
await expect(
|
||||
fixture.configSync.applyDataV2(fixture.configSync.pluginList[0] as PluginDataExDisplayV2)
|
||||
).resolves.toBe(true);
|
||||
expect([...fixture.written.keys()].sort()).toEqual(
|
||||
filenames.map((name) => `.obsidian/plugins/example/${name}`).sort()
|
||||
);
|
||||
for (const name of filenames)
|
||||
expect(fixture.written.get(`.obsidian/plugins/example/${name}`)).toBe(content[name]);
|
||||
});
|
||||
|
||||
it("fills an existing row during a second enumeration", async () => {
|
||||
const fixture = await catalogue();
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(fixture.updates);
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
await Promise.all(fixture.updates);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("retains all files when item updates run sequentially", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename).sort()).toEqual(
|
||||
filenames.map((name) => `plugins/example/${name}`).sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("updates a different item while another item's reads are pending", async () => {
|
||||
const fixture = await catalogue();
|
||||
const otherPath = "ix:device/PLUGIN_MAIN/other%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(otherPath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
try {
|
||||
await fixture.configSync.updatePluginListV2(false, otherPath);
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["other"]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(3);
|
||||
} finally {
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
}
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("continues updating an item after an earlier read fails", async () => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockRejectedValueOnce(new Error("Read failed"));
|
||||
const failed = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
const next = fixture.configSync.updatePluginListV2(false, fixture.paths[1]);
|
||||
|
||||
await expect(failed).rejects.toThrow("Read failed");
|
||||
await next;
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/manifest.json",
|
||||
]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["read", "file update"])("reload discards cached files while an earlier %s is pending", async (phase) => {
|
||||
const fixture = await catalogue();
|
||||
fixture.release();
|
||||
for (const path of fixture.paths) await fixture.configSync.updatePluginListV2(false, path);
|
||||
const earlierRead = deferred();
|
||||
const freshRead = deferred();
|
||||
const entered = deferred();
|
||||
const createFile = fixture.configSync.createPluginDataExFileV2.bind(fixture.configSync);
|
||||
let reads = 0;
|
||||
vi.spyOn(fixture.configSync, "createPluginDataExFileV2").mockImplementation(async (path) => {
|
||||
if (reads++ === 0) {
|
||||
if (phase === "read") {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
}
|
||||
} else {
|
||||
await freshRead.promise;
|
||||
}
|
||||
return createFile(path);
|
||||
});
|
||||
if (phase === "file update") {
|
||||
const entry = fixture.configSync.pluginList[0] as PluginDataExDisplayV2;
|
||||
const setFile = entry.setFile.bind(entry);
|
||||
vi.spyOn(entry, "setFile").mockImplementationOnce(async (file) => {
|
||||
entered.resolve();
|
||||
await earlierRead.promise;
|
||||
await setFile(file);
|
||||
});
|
||||
}
|
||||
const pending = fixture.configSync.updatePluginListV2(false, fixture.paths[0]);
|
||||
try {
|
||||
await entered.promise;
|
||||
for (const path of fixture.paths.slice(1)) await fixture.database.remove(await fixture.database.get(path));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
earlierRead.resolve();
|
||||
await pending;
|
||||
} finally {
|
||||
earlierRead.resolve();
|
||||
freshRead.resolve();
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
expect(fixture.configSync.pluginList[0].files.map((file) => file.filename)).toEqual([
|
||||
"plugins/example/main.js",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearing the disabled feature prevents pending updates from repopulating the list", async () => {
|
||||
const fixture = await catalogue();
|
||||
const pending = fixture.paths.map((path) => fixture.configSync.updatePluginListV2(false, path));
|
||||
fixture.settings.usePluginSync = false;
|
||||
await fixture.configSync.updatePluginList(false);
|
||||
fixture.release();
|
||||
await Promise.all(pending);
|
||||
|
||||
expect(fixture.configSync.pluginList).toEqual([]);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
|
||||
it("a superseded enumeration cannot reintroduce a deleted item", async () => {
|
||||
const fixture = await catalogue();
|
||||
const obsoletePath = "ix:device/PLUGIN_MAIN/obsolete%main.js" as FilePathWithPrefix;
|
||||
await fixture.localDatabase.putDBEntry(
|
||||
optionalFileEntry(obsoletePath, `fixture-header\u200b\n\u200c${btoa(content["main.js"])}`)
|
||||
);
|
||||
const enumerate = fixture.localDatabase.findEntries.bind(fixture.localDatabase);
|
||||
type EnumeratedEntry = ReturnType<typeof enumerate> extends AsyncGenerator<infer Entry> ? Entry : never;
|
||||
const obsoleteDocs: EnumeratedEntry[] = [];
|
||||
for await (const doc of enumerate(obsoletePath, `${obsoletePath}\u{10ffff}`, { include_docs: true })) {
|
||||
obsoleteDocs.push(doc);
|
||||
}
|
||||
const entered = deferred();
|
||||
const release = deferred();
|
||||
let scans = 0;
|
||||
vi.spyOn(fixture.localDatabase, "findEntries").mockImplementation((...args) => {
|
||||
if (scans++ !== 0) return enumerate(...args);
|
||||
return (async function* () {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
yield* obsoleteDocs;
|
||||
})();
|
||||
});
|
||||
const pending = fixture.configSync.updatePluginList(false);
|
||||
try {
|
||||
await entered.promise;
|
||||
await fixture.database.remove(await fixture.database.get(obsoletePath));
|
||||
await fixture.configSync.reloadPluginList(false);
|
||||
} finally {
|
||||
release.resolve();
|
||||
fixture.release();
|
||||
await pending;
|
||||
await Promise.all(fixture.updates);
|
||||
}
|
||||
|
||||
expect(fixture.configSync.pluginList.map((entry) => entry.name)).toEqual(["example"]);
|
||||
expect(fixture.configSync.pluginList[0].files).toHaveLength(3);
|
||||
expect(fixture.configSync.updatingV2Count).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
addIcon: vi.fn(),
|
||||
diff_match_patch: class DiffMatchPatch {},
|
||||
normalizePath: vi.fn((path: string) => path),
|
||||
parseYaml: vi.fn(),
|
||||
Platform: {},
|
||||
}));
|
||||
vi.mock("./PluginDialogModal.ts", () => ({
|
||||
PluginDialogModal: class PluginDialogModal {},
|
||||
}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
JsonResolveModal: class JsonResolveModal {},
|
||||
}));
|
||||
vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({
|
||||
ConflictResolveModal: class ConflictResolveModal {},
|
||||
}));
|
||||
// The readiness helpers come from the real LiveSyncCommands, so only its log view dependency is replaced.
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({
|
||||
MARK_DONE: "",
|
||||
}));
|
||||
vi.mock("@/common/types.ts", () => ({
|
||||
ICXHeader: "ix:",
|
||||
PERIODIC_PLUGIN_SWEEP: 60,
|
||||
}));
|
||||
vi.mock("@/common/utils.ts", () => ({
|
||||
cancelTask: vi.fn(),
|
||||
EVEN: Symbol("even"),
|
||||
isCustomisationSyncMetadata: vi.fn(),
|
||||
isPluginMetadata: vi.fn(),
|
||||
scheduleTask: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/common/PeriodicProcessor.ts", () => ({
|
||||
PeriodicProcessor: class PeriodicProcessor {},
|
||||
}));
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync",
|
||||
eventHub: {
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: vi.fn((message: string) => message),
|
||||
}));
|
||||
vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({
|
||||
getObsidianCommunityPluginManager: vi.fn(),
|
||||
}));
|
||||
|
||||
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ConfigSync } from "./CmdConfigSync";
|
||||
import { createOptionalFileSyncDatabase } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/snippets/test.css" as FilePath;
|
||||
const fixtures: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function createConfigSync(useV2 = false) {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtures.push(fixture);
|
||||
fixture.settings.usePluginSyncV2 = useV2;
|
||||
const files = new Map<string, { content: string; mtime: number }>([
|
||||
[path, { content: "body { color: red; }", mtime: 2000 }],
|
||||
]);
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
return entry
|
||||
? { type: "file" as const, ctime: 1000, mtime: entry.mtime, size: entry.content.length }
|
||||
: null;
|
||||
}),
|
||||
readHiddenFileBinary: vi.fn(async (file: string) => {
|
||||
const entry = files.get(file);
|
||||
if (!entry) throw new Error(`Missing fixture: ${file}`);
|
||||
return new TextEncoder().encode(entry.content).buffer;
|
||||
}),
|
||||
};
|
||||
const services = {
|
||||
...fixture.services,
|
||||
appLifecycle: { isReady: () => false, isSuspended: () => true },
|
||||
database: { ...fixture.services.database, isDatabaseReady: vi.fn(() => true) },
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
isMarkedAsSameChanges: vi.fn(),
|
||||
markChangesAreSame: vi.fn(),
|
||||
},
|
||||
};
|
||||
const config = Object.create(ConfigSync.prototype) as ConfigSync;
|
||||
Object.assign(config, {
|
||||
core: { settings: fixture.settings, localDatabase: fixture.localDatabase, storageAccess, services },
|
||||
_log: vi.fn(),
|
||||
updatePluginList: vi.fn(async () => undefined),
|
||||
updatePluginListV2: vi.fn(async () => undefined),
|
||||
recentProcessedInternalFiles: [],
|
||||
});
|
||||
const databasePath = useV2 ? config.filenameWithUnifiedKey(path) : config.filenameToUnifiedKey(path);
|
||||
return { fixture, config, files, services, storageAccess, databasePath };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtures.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Customisation Sync optional save completion", () => {
|
||||
const flush = { whenFlushRequested: Promise.resolve() };
|
||||
|
||||
it("waits for the database write while the application is suspended or still starting", async () => {
|
||||
const { fixture, config } = await createConfigSync();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const put = fixture.localDatabase.putDBEntry.bind(fixture.localDatabase);
|
||||
const spy = vi.spyOn(fixture.localDatabase, "putDBEntry").mockImplementation(async (...args) => {
|
||||
await gate;
|
||||
return await put(...args);
|
||||
});
|
||||
let completed = false;
|
||||
const result = config._processOptionalFileEventWithResult(path, flush).then((outcome) => {
|
||||
completed = true;
|
||||
return outcome;
|
||||
});
|
||||
await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce());
|
||||
expect(completed).toBe(false);
|
||||
release();
|
||||
await expect(result).resolves.toEqual({ kind: "completed" });
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms identical contents and stores a same-time edit (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const first = await fixture.database.get(databasePath);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(first._rev);
|
||||
files.get(path)!.content = "body { color: tan; }";
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
expect((await fixture.database.get(databasePath))._rev).not.toBe(first._rev);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ useV2: false, throws: false },
|
||||
{ useV2: true, throws: false },
|
||||
{ useV2: false, throws: true },
|
||||
{ useV2: true, throws: true },
|
||||
])("rejects a failed write and permits the unchanged notification to retry: %j", async ({ useV2, throws }) => {
|
||||
const { fixture, config, databasePath } = await createConfigSync(useV2);
|
||||
const failure = new Error("temporary write failure");
|
||||
const put = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
if (throws) put.mockRejectedValueOnce(failure);
|
||||
else put.mockResolvedValueOnce(false);
|
||||
const failed = config._processOptionalFileEventWithResult(path, flush);
|
||||
if (throws) await expect(failed).rejects.toBe(failure);
|
||||
else await expect(failed).rejects.toThrow("Could not save customisation");
|
||||
await expect(fixture.localDatabase.getDBEntry(databasePath)).resolves.toBe(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect(put).toHaveBeenCalledTimes(2);
|
||||
expect(await fixture.localDatabase.getDBEntry(databasePath)).not.toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])("confirms deletion and treats a repeated deletion as complete (V2: %s)", async (useV2) => {
|
||||
const { fixture, config, databasePath, files } = await createConfigSync(useV2);
|
||||
await config._processOptionalFileEventWithResult(path, flush);
|
||||
files.delete(path);
|
||||
const put = vi.spyOn(fixture.localDatabase, "putRaw");
|
||||
put.mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).rejects.toThrow(
|
||||
"Could not delete customisation"
|
||||
);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
const deleted = await fixture.database.get(databasePath);
|
||||
expect("deleted" in deleted && deleted.deleted).toBe(true);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({ kind: "completed" });
|
||||
expect((await fixture.database.get(databasePath))._rev).toBe(deleted._rev);
|
||||
});
|
||||
|
||||
it("distinguishes a different owner and unmet prerequisites", async () => {
|
||||
const { config, services, storageAccess } = await createConfigSync();
|
||||
await expect(config._processOptionalFileEventWithResult("notes.md" as FilePath, flush)).resolves.toEqual({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "database-not-ready",
|
||||
});
|
||||
services.database.isDatabaseReady.mockReturnValue(true);
|
||||
vi.spyOn(services.setting, "getDeviceAndVaultName").mockReturnValue("");
|
||||
await expect(config._processOptionalFileEventWithResult(path, flush)).resolves.toEqual({
|
||||
kind: "veto",
|
||||
value: "device-name-not-configured",
|
||||
});
|
||||
expect(storageAccess.readHiddenFileBinary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves every V2 file when neighbouring plug-in files arrive together", async () => {
|
||||
const { fixture, config, files } = await createConfigSync(true);
|
||||
const main = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const style = ".obsidian/plugins/example/styles.css" as FilePath;
|
||||
files.set(main, { content: "module.exports = {};", mtime: 3000 });
|
||||
files.set(style, { content: "body {}", mtime: 3000 });
|
||||
await Promise.all([
|
||||
config._processOptionalFileEventWithResult(main, flush),
|
||||
config._processOptionalFileEventWithResult(style, flush),
|
||||
]);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(main))).not.toBe(false);
|
||||
expect(await fixture.localDatabase.getDBEntry(config.filenameWithUnifiedKey(style))).not.toBe(false);
|
||||
});
|
||||
|
||||
it("does not acknowledge a database deletion which was not accepted", async () => {
|
||||
const { fixture, config, databasePath } = await createConfigSync();
|
||||
await config.storeCustomizationFiles(path);
|
||||
const original = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(original).not.toBe(false);
|
||||
vi.spyOn(fixture.localDatabase, "putRaw").mockResolvedValueOnce({ ok: false, id: "failed", rev: "" });
|
||||
await expect(config.deleteConfigOnDatabase(databasePath)).resolves.toBe(false);
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(databasePath);
|
||||
expect(unchanged && getDocData(unchanged.data)).toBe(original && getDocData(original.data));
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,10 @@ import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
const d = "\u200b";
|
||||
const d2 = "\n";
|
||||
const UPDATED_CONFIGURATION_NOTICE_KEY = "config-sync:updated-configuration";
|
||||
@@ -412,6 +416,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
|
||||
|
||||
pluginList: IPluginDataExDisplay[] = [];
|
||||
private pluginListGeneration = 0;
|
||||
showPluginSyncModal() {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
return;
|
||||
@@ -532,6 +537,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async reloadPluginList(showMessage: boolean) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginList = [];
|
||||
this.loadedManifest_mTime.clear();
|
||||
pluginList.set(this.pluginList);
|
||||
@@ -761,39 +767,46 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
updatingV2Count = 0;
|
||||
|
||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||
const generation = this.pluginListGeneration;
|
||||
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
||||
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
|
||||
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
await serialized(`config:catalogue:${pathV1}`, async () => {
|
||||
if (!isCurrent()) return;
|
||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||
|
||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||
if (newEntry) {
|
||||
entry = newEntry;
|
||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||
if (newEntry) {
|
||||
entry = newEntry;
|
||||
}
|
||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||
entry = oldEntry;
|
||||
}
|
||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||
entry = oldEntry;
|
||||
}
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
entry.deleteFile(unifiedFilenameWithKey);
|
||||
if (entry.files.length == 0) {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
if (!entry) return;
|
||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||
if (!isCurrent()) return;
|
||||
if (file) {
|
||||
await entry.setFile(file);
|
||||
} else {
|
||||
entry.deleteFile(unifiedFilenameWithKey);
|
||||
if (entry.files.length == 0) {
|
||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||
}
|
||||
}
|
||||
}
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
if (!isCurrent()) return;
|
||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||
newList.push(entry);
|
||||
this.pluginList = newList;
|
||||
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
scheduleTask("updatePluginListV2", 100, () => {
|
||||
pluginList.set(this.pluginList);
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
this.updatingV2Count--;
|
||||
@@ -871,11 +884,13 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
|
||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this.pluginListGeneration++;
|
||||
this.pluginScanProcessor.clearQueue();
|
||||
this.pluginList = [];
|
||||
pluginList.set(this.pluginList);
|
||||
return;
|
||||
}
|
||||
const generation = this.pluginListGeneration;
|
||||
try {
|
||||
this.updatingV2Count++;
|
||||
pluginV2Progress.set(this.updatingV2Count);
|
||||
@@ -888,6 +903,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
})
|
||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||
for await (const v of plugins) {
|
||||
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
||||
if (v.deleted || v._deleted) continue;
|
||||
if (v.path.indexOf("%") !== -1) {
|
||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||
@@ -1206,7 +1222,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
recentProcessedInternalFiles = [] as string[];
|
||||
async makeEntryFromFile(path: FilePath): Promise<false | PluginDataExFile> {
|
||||
async makeEntryFromFile(path: FilePath, requireCompletion = false): Promise<false | PluginDataExFile> {
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
let version: string | undefined;
|
||||
let displayName: string | undefined;
|
||||
@@ -1240,6 +1256,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
} catch (ex) {
|
||||
this._log(`The file ${path} could not be encoded`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
@@ -1254,6 +1271,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async storeCustomisationFileV2(path: FilePath, term: string, force = false) {
|
||||
return await this.storeCustomisationFileV2Core(path, term, force, false);
|
||||
}
|
||||
|
||||
private async storeCustomisationFileV2Core(
|
||||
path: FilePath,
|
||||
term: string,
|
||||
force: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const vf = this.filenameWithUnifiedKey(path, term);
|
||||
return await serialized(`plugin-${vf}`, async () => {
|
||||
const prefixedFileName = vf;
|
||||
@@ -1261,7 +1287,8 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const id = await this.path2id(prefixedFileName);
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (!stat) {
|
||||
return false;
|
||||
if (!requireCompletion) return false;
|
||||
return await this.deleteConfigOnDatabaseCore(vf, force, true);
|
||||
}
|
||||
const mtime = stat.mtime;
|
||||
const content = await this.core.storageAccess.readHiddenFileBinary(path);
|
||||
@@ -1285,7 +1312,10 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN) {
|
||||
if (
|
||||
!requireCompletion &&
|
||||
this.services.path.isMarkedAsSameChanges(prefixedFileName, [old.mtime, mtime + 1]) == EVEN
|
||||
) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Already checked the same)`,
|
||||
LOG_LEVEL_DEBUG
|
||||
@@ -1300,7 +1330,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
const dataStart = dataSrc.indexOf(DUMMY_END);
|
||||
const oldContent = dataSrc.substring(dataStart + DUMMY_END.length);
|
||||
const oldContentArray = base64ToArrayBuffer(oldContent);
|
||||
if (await isDocContentSame(oldContentArray, content)) {
|
||||
if (!old.deleted && (await isDocContentSame(oldContentArray, content))) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (the same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1320,24 +1350,36 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
fireAndForget(() => this.updatePluginListV2(false, this.filenameWithUnifiedKey(path)));
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
async storeCustomizationFiles(path: FilePath, termOverRide?: string) {
|
||||
return await this.storeCustomizationFilesCore(path, termOverRide, false);
|
||||
}
|
||||
|
||||
private async storeCustomizationFilesCore(
|
||||
path: FilePath,
|
||||
termOverRide: string | undefined,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
const term = termOverRide || this.services.setting.getDeviceAndVaultName();
|
||||
if (term == "") {
|
||||
this._log($msg("We have to configure the device name"), LOG_LEVEL_NOTICE);
|
||||
if (requireCompletion) throw new LiveSyncError("The device name is not configured");
|
||||
return;
|
||||
}
|
||||
if (this.useV2) {
|
||||
return await this.storeCustomisationFileV2(path, term);
|
||||
return await this.storeCustomisationFileV2Core(path, term, false, requireCompletion);
|
||||
}
|
||||
const vf = this.filenameToUnifiedKey(path, term);
|
||||
// console.warn(`Storing ${path} to ${bareVF} :--> ${keyedVF}`);
|
||||
@@ -1378,7 +1420,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
fileTargets = ["manifest.json", "theme.css"].map((e) => `${parentPath}/${e}` as FilePath);
|
||||
}
|
||||
for (const target of fileTargets) {
|
||||
const data = await this.makeEntryFromFile(target);
|
||||
const data = await this.makeEntryFromFile(target, requireCompletion);
|
||||
if (data == false) {
|
||||
this._log(`Config: skipped (Possibly is not exist): ${target} `, LOG_LEVEL_VERBOSE);
|
||||
continue;
|
||||
@@ -1398,9 +1440,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
// this._log(`Configuration saving: ${prefixedFileName}`);
|
||||
if (dt.files.length == 0) {
|
||||
this._log(`Nothing left: deleting.. ${path}`);
|
||||
await this.deleteConfigOnDatabase(prefixedFileName);
|
||||
const deleted = await this.deleteConfigOnDatabaseCore(prefixedFileName, false, requireCompletion);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
return;
|
||||
return requireCompletion ? deleted : undefined;
|
||||
}
|
||||
|
||||
const content = createTextBlob(serialize(dt));
|
||||
@@ -1422,7 +1464,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
eden: {},
|
||||
};
|
||||
} else {
|
||||
if (old.mtime == mtime) {
|
||||
if (!requireCompletion && old.mtime == mtime) {
|
||||
// this._log(`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same time)`, LOG_LEVEL_VERBOSE);
|
||||
return true;
|
||||
}
|
||||
@@ -1443,7 +1485,7 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
});
|
||||
const isSame = (await Promise.all(diffs)).every((e) => e == true);
|
||||
if (isSame) {
|
||||
if (isSame && !old.deleted) {
|
||||
this._log(
|
||||
`STORAGE --> DB:${prefixedFileName}: (config) Skipped (Same content)`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
@@ -1464,16 +1506,51 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
};
|
||||
}
|
||||
const ret = await this.localDatabase.putDBEntry(saveData);
|
||||
if (requireCompletion && (!ret || !ret.ok))
|
||||
throw new LiveSyncError(`Could not save customisation: ${path}`);
|
||||
await this.updatePluginList(false, saveData.path);
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Done`);
|
||||
return ret;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
private pendingOptionalSaves?: PendingConfigSaves;
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled() || !this.isTargetPath(path)) return { kind: "not-applicable" };
|
||||
const configDir = normalizePath(this.configDir);
|
||||
const handledElsewhere = Object.values(this.settings.pluginSyncExtendedSetting)
|
||||
.filter((entry) => entry.mode !== MODE_SELECTIVE && entry.mode !== MODE_SHINY)
|
||||
.flatMap((entry) => entry.files)
|
||||
.some((file) => `${configDir}/${file}`.toLowerCase().startsWith(path.toLowerCase()));
|
||||
if (handledElsewhere) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) return { kind: "veto", value: "database-not-ready" };
|
||||
const term = this.services.setting.getDeviceAndVaultName();
|
||||
if (!term) return { kind: "veto", value: "device-name-not-configured" };
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat && stat.type !== "file") return { kind: "not-applicable" };
|
||||
|
||||
// V1 saves a complete file group; V2 persists each file separately.
|
||||
const key = this.useV2 ? this.filenameWithUnifiedKey(path, term) : this.filenameToUnifiedKey(path, term);
|
||||
this.pendingOptionalSaves ??= new PendingConfigSaves();
|
||||
await this.pendingOptionalSaves.enqueue(
|
||||
key,
|
||||
async () => {
|
||||
await this.storeCustomizationFilesCore(path, term, true);
|
||||
},
|
||||
options?.whenFlushRequested
|
||||
);
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
async _anyProcessOptionalFileEvent(path: FilePath): Promise<boolean> {
|
||||
return await this.watchVaultRawEventsAsync(path);
|
||||
}
|
||||
@@ -1615,6 +1692,14 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
|
||||
async deleteConfigOnDatabase(prefixedFileName: FilePathWithPrefix, forceWrite = false) {
|
||||
return await this.deleteConfigOnDatabaseCore(prefixedFileName, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteConfigOnDatabaseCore(
|
||||
prefixedFileName: FilePathWithPrefix,
|
||||
forceWrite: boolean,
|
||||
requireCompletion: boolean
|
||||
) {
|
||||
// const id = await this.path2id(prefixedFileName);
|
||||
const mtime = new Date().getTime();
|
||||
return await serialized("file-x-" + prefixedFileName, async () => {
|
||||
@@ -1640,13 +1725,15 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
type: "newnote",
|
||||
};
|
||||
}
|
||||
await this.localDatabase.putRaw(saveData);
|
||||
const result = await this.localDatabase.putRaw(saveData);
|
||||
if (!result.ok) throw new LiveSyncError(`Could not delete customisation: ${prefixedFileName}`);
|
||||
await this.updatePluginList(false, prefixedFileName);
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Done`);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB:${prefixedFileName}: (config) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (requireCompletion) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1756,6 +1843,9 @@ export class ConfigSync extends LiveSyncCommands {
|
||||
}
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processVirtualDocument.addHandler(this._anyModuleParsedReplicationResultItem.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { compatGlobal, type CompatTimeoutHandle } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
|
||||
type PendingSave = {
|
||||
save: () => Promise<void>;
|
||||
completion: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: unknown) => void;
|
||||
timer?: CompatTimeoutHandle;
|
||||
started: boolean;
|
||||
};
|
||||
|
||||
/** Owns the quiet-period delay and completion of coalesced customisation saves. */
|
||||
export class PendingConfigSaves {
|
||||
private readonly pending = new Map<string, PendingSave>();
|
||||
|
||||
enqueue(key: string, save: () => Promise<void>, whenFlushRequested?: Promise<void>): Promise<void> {
|
||||
let group = this.pending.get(key);
|
||||
if (!group) {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const completion = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
group = { save, completion, resolve, reject, started: false };
|
||||
this.pending.set(key, group);
|
||||
} else {
|
||||
group.save = save;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
}
|
||||
const captured = group;
|
||||
group.timer = compatGlobal.setTimeout(() => this.run(key, captured), 100);
|
||||
// Capture this group: a late flush of an earlier event must not release
|
||||
// a newer group which happens to use the same customisation key.
|
||||
void whenFlushRequested?.then(
|
||||
() => this.run(key, captured),
|
||||
() => {}
|
||||
);
|
||||
return group.completion;
|
||||
}
|
||||
|
||||
private run(key: string, group: PendingSave): void {
|
||||
if (group.started) return;
|
||||
group.started = true;
|
||||
if (group.timer !== undefined) compatGlobal.clearTimeout(group.timer);
|
||||
this.pending.delete(key);
|
||||
void Promise.resolve().then(group.save).then(group.resolve, group.reject);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingConfigSaves } from "./PendingConfigSaves";
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<void>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("pending customisation saves", () => {
|
||||
it("retains the quiet period and completes every coalesced notification after the actual save", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const save = deferred();
|
||||
const firstSave = vi.fn(async () => {});
|
||||
const lastSave = vi.fn(() => save.promise);
|
||||
const first = pending.enqueue("plugin", firstSave);
|
||||
await vi.advanceTimersByTimeAsync(80);
|
||||
const second = pending.enqueue("plugin", lastSave);
|
||||
let completed = false;
|
||||
const all = Promise.all([first, second]).then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(99);
|
||||
expect(lastSave).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(firstSave).not.toHaveBeenCalled();
|
||||
expect(lastSave).toHaveBeenCalledOnce();
|
||||
expect(completed).toBe(false);
|
||||
save.resolve();
|
||||
await all;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
|
||||
it("flushes the captured group without waiting for or releasing later notifications", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const flush = deferred();
|
||||
const lateFlush = deferred();
|
||||
const firstSave = deferred();
|
||||
const work = vi.fn(() => firstSave.promise);
|
||||
const first = pending.enqueue("plugin", work, flush.promise);
|
||||
const coalesced = pending.enqueue("plugin", work, lateFlush.promise);
|
||||
flush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(work).toHaveBeenCalledOnce();
|
||||
const nextWork = vi.fn(async () => {});
|
||||
const later = pending.enqueue("plugin", nextWork);
|
||||
lateFlush.resolve();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
firstSave.resolve();
|
||||
await Promise.all([first, coalesced]);
|
||||
expect(nextWork).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await later;
|
||||
expect(nextWork).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects every coalesced notification with the original failure and permits a later retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
const pending = new PendingConfigSaves();
|
||||
const failure = new Error("database write failed");
|
||||
const save = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const first = pending.enqueue("plugin", save);
|
||||
const second = pending.enqueue("plugin", save);
|
||||
const outcomes = Promise.allSettled([first, second]);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(await outcomes).toEqual([
|
||||
{ status: "rejected", reason: failure },
|
||||
{ status: "rejected", reason: failure },
|
||||
]);
|
||||
const retry = pending.enqueue("plugin", async () => {}, Promise.resolve());
|
||||
await retry;
|
||||
});
|
||||
});
|
||||
@@ -20,10 +20,14 @@
|
||||
MODE_SHINY,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { normalizePath } from "@/deps";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import {
|
||||
HiddenFileSync,
|
||||
HIDDEN_FILE_SYNC_DISABLED_NOTICE,
|
||||
} from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
import { $msg as translateMessage } from "@/common/translation";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
import {
|
||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
@@ -139,6 +143,11 @@
|
||||
async function deleteData(data: IPluginDataExDisplay): Promise<boolean> {
|
||||
return await addOn.deleteData(data);
|
||||
}
|
||||
function checkAutomaticSyncAvailable() {
|
||||
if (addOnHiddenFileSync.isThisModuleEnabled()) return true;
|
||||
Logger(translateMessage(HIDDEN_FILE_SYNC_DISABLED_NOTICE), LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
function askMode(evt: MouseEvent, title: string, key: string) {
|
||||
const menu = new Menu();
|
||||
menu.addItem((item) => item.setTitle(title).setIsLabel(true));
|
||||
@@ -149,6 +158,7 @@
|
||||
item.setTitle(`${getIcon(mode as SYNC_MODE)}:${TITLES[mode]}`)
|
||||
.onClick((e) => {
|
||||
if (mode === MODE_AUTOMATIC) {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
askOverwriteModeForAutomatic(evt, key);
|
||||
} else {
|
||||
setMode(key, mode as SYNC_MODE);
|
||||
@@ -161,10 +171,11 @@
|
||||
menu.showAtMouseEvent(evt);
|
||||
}
|
||||
function applyAutomaticSync(key: string, direction: "pushForce" | "pullForce" | "safe") {
|
||||
if (!checkAutomaticSyncAvailable()) return;
|
||||
setMode(key, MODE_AUTOMATIC);
|
||||
const configDir = normalizePath(plugin.core.services.API.getSystemConfigDir());
|
||||
const files = (plugin.core.settings.pluginSyncExtendedSetting[key]?.files ?? []).map((e) => `${configDir}/${e}`);
|
||||
addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
const files = getAutomaticSyncTargetFiles(key, configDir, plugin.core.settings.pluginSyncExtendedSetting);
|
||||
void addOnHiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
}
|
||||
function askOverwriteModeForAutomatic(evt: MouseEvent, key: string) {
|
||||
const menu = new Menu();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
export function getAutomaticSyncTargetFiles(
|
||||
key: string,
|
||||
configDir: string,
|
||||
entries: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]
|
||||
): string[] {
|
||||
const keys = key.startsWith("PLUGIN_ALL/")
|
||||
? [`PLUGIN_DATA/${key.substring("PLUGIN_ALL/".length)}`, `PLUGIN_MAIN/${key.substring("PLUGIN_ALL/".length)}`]
|
||||
: [key];
|
||||
const files = new Set(keys.flatMap((entryKey) => entries[entryKey]?.files ?? []));
|
||||
return [...files].map((file) => `${configDir}/${file}`);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_AUTOMATIC, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { getAutomaticSyncTargetFiles } from "./getAutomaticSyncTargetFiles.ts";
|
||||
|
||||
function createEntries(): ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] {
|
||||
return {
|
||||
"CONFIG/app.json": { key: "CONFIG/app.json", mode: MODE_AUTOMATIC, files: ["app.json"] },
|
||||
"PLUGIN_MAIN/example": {
|
||||
key: "PLUGIN_MAIN/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/main.js", "plugins/example/manifest.json"],
|
||||
},
|
||||
"PLUGIN_DATA/example": {
|
||||
key: "PLUGIN_DATA/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/data.json"],
|
||||
},
|
||||
"PLUGIN_ETC/example": {
|
||||
key: "PLUGIN_ETC/example",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/example/extra.json"],
|
||||
},
|
||||
"PLUGIN_MAIN/other": {
|
||||
key: "PLUGIN_MAIN/other",
|
||||
mode: MODE_AUTOMATIC,
|
||||
files: ["plugins/other/main.js"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("getAutomaticSyncTargetFiles", () => {
|
||||
it("initialises a single selected item in the current configuration directory", () => {
|
||||
expect(getAutomaticSyncTargetFiles("CONFIG/app.json", ".custom-config", createEntries())).toEqual([
|
||||
".custom-config/app.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("initialises both saved child selections when the whole plug-in is selected", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", createEntries())).toEqual([
|
||||
".obsidian/plugins/example/data.json",
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains the available child files when the other child has no saved entry", () => {
|
||||
const entries = createEntries();
|
||||
delete entries["PLUGIN_DATA/example"];
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/example", ".obsidian", entries)).toEqual([
|
||||
".obsidian/plugins/example/main.js",
|
||||
".obsidian/plugins/example/manifest.json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an empty selection empty rather than including unrelated plug-ins", () => {
|
||||
expect(getAutomaticSyncTargetFiles("PLUGIN_ALL/absent", ".obsidian", createEntries())).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ JsonResolveModal: class JsonResolveModal {} }));
|
||||
vi.mock("@/modules/features/ModuleLog.ts", () => ({ MARK_DONE: "" }));
|
||||
vi.mock("./configureHiddenFileSyncMode.ts", () => ({ configureHiddenFileSyncMode: vi.fn() }));
|
||||
|
||||
import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync";
|
||||
import { createOptionalFileSyncDatabase, optionalFileEntry } from "../../../test/fixtures/optionalFileSyncDatabase";
|
||||
|
||||
const path = ".obsidian/plugins/example/main.js" as FilePath;
|
||||
const content = "module.exports = class ExamplePlugin {};";
|
||||
const fixtureOwners: Awaited<ReturnType<typeof createOptionalFileSyncDatabase>>[] = [];
|
||||
|
||||
async function getText(entry: Parameters<typeof readContent>[0]) {
|
||||
const value = await readContent(entry);
|
||||
return typeof value === "string" ? value : new TextDecoder().decode(value);
|
||||
}
|
||||
|
||||
async function createHiddenFileSync() {
|
||||
const fixture = await createOptionalFileSyncDatabase();
|
||||
fixtureOwners.push(fixture);
|
||||
fixture.settings.usePluginSync = false;
|
||||
fixture.settings.syncInternalFilesTargetPatterns = "" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
fixture.settings.syncInternalFilesIgnorePatterns = "" as typeof fixture.settings.syncInternalFilesIgnorePatterns;
|
||||
let fileContent = content;
|
||||
let stat: UXStat | null = {
|
||||
ctime: 1_000,
|
||||
mtime: 2_000,
|
||||
size: fileContent.length,
|
||||
type: "file",
|
||||
};
|
||||
const values = new Map<string, unknown>();
|
||||
const kvDB = {
|
||||
get: vi.fn(async <T>(key: string) => values.get(key) as T | undefined),
|
||||
set: vi.fn(async <T>(key: string, value: T) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
};
|
||||
const storageAccess = {
|
||||
statHidden: vi.fn(async () => stat),
|
||||
readHiddenFileAuto: vi.fn(async () => fileContent),
|
||||
writeHiddenFileAuto: vi.fn(async () => undefined),
|
||||
removeHidden: vi.fn(async () => true),
|
||||
isExistsIncludeHidden: vi.fn(async () => true),
|
||||
ensureDir: vi.fn(async () => undefined),
|
||||
triggerHiddenFile: vi.fn(async () => undefined),
|
||||
setFile(nextContent: string | null, mtime = 2_000) {
|
||||
fileContent = nextContent ?? "";
|
||||
stat = nextContent === null ? null : { ctime: 1_000, mtime, size: fileContent.length, type: "file" };
|
||||
},
|
||||
};
|
||||
const databaseService = { isDatabaseReady: vi.fn(() => true) };
|
||||
const appLifecycle = { isReady: vi.fn(() => false), isSuspended: vi.fn(() => true) };
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: fixture.settings,
|
||||
localDatabase: fixture.localDatabase,
|
||||
kvDB,
|
||||
storageAccess,
|
||||
services: {
|
||||
...fixture.services,
|
||||
database: databaseService,
|
||||
appLifecycle,
|
||||
path: {
|
||||
...fixture.services.path,
|
||||
markChangesAreSame: vi.fn(),
|
||||
unmarkChanges: vi.fn(),
|
||||
},
|
||||
vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) },
|
||||
},
|
||||
},
|
||||
cacheFileRegExps: new Map(),
|
||||
cacheCustomisationSyncIgnoredFiles: new Map(),
|
||||
_fileInfoLastProcessed: new Map(),
|
||||
_databaseInfoLastProcessed: new Map(),
|
||||
_fileInfoLastKnown: new Map(),
|
||||
semaphore: Semaphore(10),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return { fixture, hiddenFileSync, storageAccess, kvDB, databaseService, appLifecycle };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
for (const fixture of fixtureOwners.splice(0)) await fixture.dispose();
|
||||
});
|
||||
|
||||
describe("Hidden File Sync optional save outcomes", () => {
|
||||
it("retries a failed database write when the storage fingerprint is unchanged", async () => {
|
||||
const { fixture, hiddenFileSync } = await createHiddenFileSync();
|
||||
await expect(hiddenFileSync.isTargetFile(path)).resolves.toBe(true);
|
||||
vi.spyOn(fixture.localDatabase, "putDBEntry").mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(false);
|
||||
await expect(hiddenFileSync.trackStorageFileModification(path)).resolves.toBe(true);
|
||||
|
||||
expect(fixture.localDatabase.putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a failed strict write and retries the same fingerprint without delaying readiness", async () => {
|
||||
const { fixture, hiddenFileSync, appLifecycle } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockRejectedValueOnce(new Error("temporary database failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary database failure"
|
||||
);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(2);
|
||||
const saved = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(saved).not.toBe(false);
|
||||
if (saved) expect(await getText(saved)).toBe(content);
|
||||
expect(appLifecycle.isReady()).toBe(false);
|
||||
expect(appLifecycle.isSuspended()).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a false database-write result and can store a later edit after a same-content event", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
const putDBEntry = vi
|
||||
.spyOn(fixture.localDatabase, "putDBEntry")
|
||||
.mockImplementationOnce(async () => false as never);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
`Failed to store hidden file in the database: ${path}`
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const first = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(first).not.toBe(false);
|
||||
if (first) {
|
||||
const firstRevision = first._rev;
|
||||
hiddenFileSync._fileInfoLastProcessed.delete(path);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const unchanged = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(unchanged).not.toBe(false);
|
||||
if (unchanged) expect(unchanged._rev).toBe(firstRevision);
|
||||
}
|
||||
|
||||
storageAccess.setFile("module.exports = class UpdatedExamplePlugin {};", 3_000);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
const updated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(updated).not.toBe(false);
|
||||
if (updated) expect(await getText(updated)).toBe("module.exports = class UpdatedExamplePlugin {};");
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("vetoes when the physical database is unavailable and ignores disabled or filtered events", async () => {
|
||||
const { fixture, hiddenFileSync, databaseService } = await createHiddenFileSync();
|
||||
|
||||
databaseService.isDatabaseReady.mockReturnValue(false);
|
||||
await expect(
|
||||
hiddenFileSync._processOptionalFileEventWithResult(path, {
|
||||
whenFlushRequested: new Promise<void>(() => undefined),
|
||||
})
|
||||
).resolves.toMatchObject({ kind: "veto", value: expect.any(String) });
|
||||
databaseService.isDatabaseReady.mockReturnValue(true);
|
||||
|
||||
fixture.settings.syncInternalFiles = false;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
fixture.settings.syncInternalFiles = true;
|
||||
fixture.settings.syncInternalFilesTargetPatterns =
|
||||
"^\\.obsidian/snippets/" as typeof fixture.settings.syncInternalFilesTargetPatterns;
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "not-applicable",
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an absent replayed deletion as complete and leaves cache initialisation intact", async () => {
|
||||
const { hiddenFileSync, storageAccess, kvDB } = await createHiddenFileSync();
|
||||
storageAccess.setFile(null);
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
const lifecycle = hiddenFileSync as unknown as {
|
||||
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean>;
|
||||
};
|
||||
hiddenFileSync.core.settings.syncInternalFiles = false;
|
||||
await lifecycle._everyOnDatabaseInitialized(false);
|
||||
expect(kvDB.get).toHaveBeenCalledTimes(3);
|
||||
expect(hiddenFileSync.getLastProcessedFileKey(path)).toBe("0-0");
|
||||
});
|
||||
|
||||
it("rejects actual deletion failures and completes duplicate deletions without another write", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, content));
|
||||
storageAccess.setFile(null);
|
||||
const putRaw = vi
|
||||
.spyOn(fixture.localDatabase, "putRaw")
|
||||
.mockRejectedValueOnce(new Error("temporary deletion failure"));
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).rejects.toThrow(
|
||||
"temporary deletion failure"
|
||||
);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
hiddenFileSync.resetLastProcessedFile([path]);
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
expect(putRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recreates an empty hidden file when its database entry is logically deleted", async () => {
|
||||
const { fixture, hiddenFileSync, storageAccess } = await createHiddenFileSync();
|
||||
await fixture.localDatabase.putDBEntry(optionalFileEntry(`i:${path}`, ""));
|
||||
await expect(hiddenFileSync.deleteInternalFileOnDatabase(path)).resolves.toBe(true);
|
||||
const deletedBase = await fixture.localDatabase.getDBEntry(
|
||||
`i:${path}` as FilePath,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
);
|
||||
expect(deletedBase).not.toBe(false);
|
||||
if (deletedBase) expect(deletedBase.deleted).toBe(true);
|
||||
if (!deletedBase) throw new Error("The logically deleted test entry could not be loaded");
|
||||
vi.spyOn(fixture.localDatabase, "getDBEntry").mockResolvedValueOnce(deletedBase);
|
||||
|
||||
storageAccess.setFile("", 4_000);
|
||||
const putDBEntry = vi.spyOn(fixture.localDatabase, "putDBEntry");
|
||||
|
||||
await expect(hiddenFileSync._processOptionalFileEventWithResult(path)).resolves.toMatchObject({
|
||||
kind: "completed",
|
||||
});
|
||||
|
||||
expect(putDBEntry).toHaveBeenCalledTimes(1);
|
||||
const recreated = await fixture.localDatabase.getDBEntry(`i:${path}` as FilePath);
|
||||
expect(recreated).not.toBe(false);
|
||||
if (recreated) {
|
||||
expect(recreated.deleted).not.toBe(true);
|
||||
expect(await getText(recreated)).toBe("");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||
import type { HandlerOutcome } from "@vrtmrz/livesync-commonlib/compat/services/lib/HandlerUtils";
|
||||
import type { OptionalFileEventOptions } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts";
|
||||
import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts";
|
||||
import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts";
|
||||
@@ -69,6 +71,9 @@ type HiddenFileInitialisationProgress = {
|
||||
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
|
||||
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
|
||||
|
||||
export const HIDDEN_FILE_SYNC_DISABLED_NOTICE =
|
||||
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
|
||||
|
||||
function getComparingMTime(
|
||||
doc: (MetaEntry | LoadedEntry | false) | UXFileInfo | UXStat | null | undefined,
|
||||
includeDeleted = false
|
||||
@@ -161,9 +166,7 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
// return true;
|
||||
// }
|
||||
private async _everyOnDatabaseInitialized(showNotice: boolean) {
|
||||
this._fileInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed");
|
||||
this._databaseInfoLastProcessed = await autosaveCache(this.kvDB, "hidden-file-lastProcessed-database");
|
||||
this._fileInfoLastKnown = await autosaveCache(this.kvDB, "hidden-file-lastKnown");
|
||||
await this.ensureProcessedCaches();
|
||||
if (this.isThisModuleEnabled()) {
|
||||
if (this._fileInfoLastProcessed.size == 0 && this._fileInfoLastProcessed.size == 0) {
|
||||
this._log(`No cache found. Performing startup scan.`, LOG_LEVEL_VERBOSE);
|
||||
@@ -249,6 +252,26 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
return false;
|
||||
}
|
||||
|
||||
async _processOptionalFileEventWithResult(
|
||||
path: FilePath,
|
||||
_options?: OptionalFileEventOptions
|
||||
): Promise<HandlerOutcome<void, string>> {
|
||||
if (!this.isThisModuleEnabled()) return { kind: "not-applicable" };
|
||||
if (!(await this.isTargetFile(path))) return { kind: "not-applicable" };
|
||||
if (!this._isDatabaseReady()) {
|
||||
return { kind: "veto", value: "The local database is not ready" };
|
||||
}
|
||||
|
||||
await this.ensureProcessedCaches();
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
if (!(await this.trackStorageFileModificationStrict(path))) {
|
||||
return { kind: "not-applicable" };
|
||||
}
|
||||
return { kind: "completed" };
|
||||
}
|
||||
|
||||
_anyGetOptionalConflictCheckMethod(path: FilePathWithPrefix): Promise<boolean | "newer"> {
|
||||
if (isInternalMetadata(path)) {
|
||||
this.queueConflictCheck(path);
|
||||
@@ -317,6 +340,51 @@ export class HiddenFileSync extends LiveSyncCommands {
|
||||
_fileInfoLastKnown!: MapLike<string, number>;
|
||||
_databaseInfoLastProcessed!: MapLike<string, string>;
|
||||
|
||||
private processedCacheStore: unknown;
|
||||
private processedCacheStoreReady = false;
|
||||
private processedCacheLoadStore: unknown;
|
||||
private processedCacheLoad?: Promise<void>;
|
||||
|
||||
private async ensureProcessedCaches() {
|
||||
const store = this.kvDB;
|
||||
if (!store) throw new Error("The Hidden File Sync cache store is unavailable");
|
||||
if (this.processedCacheStoreReady && this.processedCacheStore === store) return;
|
||||
if (this.processedCacheLoad && this.processedCacheLoadStore === store) {
|
||||
await this.processedCacheLoad;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let load!: Promise<void>;
|
||||
load = Promise.all([
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed"),
|
||||
autosaveCache<string, string>(store, "hidden-file-lastProcessed-database"),
|
||||
autosaveCache<string, number>(store, "hidden-file-lastKnown"),
|
||||
])
|
||||
.then(([fileInfoLastProcessed, databaseInfoLastProcessed, fileInfoLastKnown]) => {
|
||||
if (this.processedCacheLoad !== load || this.kvDB !== store) return;
|
||||
this._fileInfoLastProcessed = fileInfoLastProcessed;
|
||||
this._databaseInfoLastProcessed = databaseInfoLastProcessed;
|
||||
this._fileInfoLastKnown = fileInfoLastKnown;
|
||||
this.processedCacheStore = store;
|
||||
this.processedCacheStoreReady = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.processedCacheLoad === load) {
|
||||
this.processedCacheLoad = undefined;
|
||||
this.processedCacheLoadStore = undefined;
|
||||
}
|
||||
});
|
||||
this.processedCacheLoadStore = store;
|
||||
this.processedCacheLoad = load;
|
||||
await load;
|
||||
if (!this.processedCacheStoreReady || this.processedCacheStore !== this.kvDB) {
|
||||
throw new Error("The Hidden File Sync cache store changed while loading its processing state");
|
||||
}
|
||||
}
|
||||
|
||||
statToKey(stat: UXStat | null) {
|
||||
return `${stat?.mtime ?? 0}-${stat?.size ?? 0}`;
|
||||
}
|
||||
@@ -600,7 +668,6 @@ Offline Changed files: ${processFiles.length}`;
|
||||
this._log(`Hidden file:${path} is changed.`, LOG_LEVEL_VERBOSE);
|
||||
stat = cache.stat;
|
||||
}
|
||||
this.updateLastProcessedFile(path, stat!);
|
||||
const lastIsNotFound = !lastKey || lastKey.endsWith("-0-0");
|
||||
const nowIsNotFound = cache.deleted;
|
||||
const type = lastIsNotFound && nowIsNotFound ? "invalid" : nowIsNotFound ? "delete" : "modified";
|
||||
@@ -651,6 +718,27 @@ Offline Changed files: ${processFiles.length}`;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async trackStorageFileModificationStrict(path: FilePath): Promise<boolean> {
|
||||
return await this.serializedForEvent(path, async () => {
|
||||
if (!this.isThisModuleEnabled() || !(await this.isTargetFile(path))) return false;
|
||||
const stat = await this.core.storageAccess.statHidden(path);
|
||||
if (stat != null && stat.type != "file") return true;
|
||||
const key = await this.fileToStatKey(path, stat);
|
||||
if (this.getLastProcessedFileKey(path) === key) {
|
||||
this._log(`${path} Already processed.`, LOG_LEVEL_DEBUG);
|
||||
return true;
|
||||
}
|
||||
|
||||
const fileInfo = await this.loadFileWithInfo(path);
|
||||
if (fileInfo.deleted) {
|
||||
await this.deleteInternalFileOnDatabaseStrict(path);
|
||||
return true;
|
||||
}
|
||||
await this.storeInternalFileToDatabaseStrict(fileInfo);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// --> Conflict processing
|
||||
|
||||
// Keep one in-flight conflict check per path so repeated sync events do not close the active merge dialogue.
|
||||
@@ -1444,6 +1532,11 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?: HiddenFileInitialisationProgress
|
||||
) {
|
||||
const logLevel = showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||
if (!this.isThisModuleEnabled()) {
|
||||
this._log($msg(HIDDEN_FILE_SYNC_DISABLED_NOTICE), logLevel);
|
||||
initialisationProgress?.done($msg("Hidden File Sync initialisation was not started."));
|
||||
return false;
|
||||
}
|
||||
const p = initialisationProgress ?? this._progress("[⚙ Initialise]\n", logLevel);
|
||||
// p.log("Resolving conflicts before starting...");
|
||||
// await this.resolveConflictOnInternalFiles();
|
||||
@@ -1494,12 +1587,25 @@ Offline Changed files: ${files.length}`;
|
||||
await this.scanAllDatabaseChanges(showChildNotices, true, false);
|
||||
}
|
||||
p.done();
|
||||
return true;
|
||||
}
|
||||
// <-- Initialization functions
|
||||
|
||||
// --> Storage To Database Functions
|
||||
|
||||
async __loadBaseSaveData(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, false);
|
||||
}
|
||||
|
||||
private async loadBaseSaveDataStrict(file: FilePath, includeContent = true): Promise<LoadedEntry | false> {
|
||||
return await this.loadBaseSaveData(file, includeContent, true);
|
||||
}
|
||||
|
||||
private async loadBaseSaveData(
|
||||
file: FilePath,
|
||||
includeContent: boolean,
|
||||
strict: boolean
|
||||
): Promise<LoadedEntry | false> {
|
||||
const prefixedFileName = addPrefix(file, ICHeader);
|
||||
const id = await this.path2id(prefixedFileName, ICHeader);
|
||||
try {
|
||||
@@ -1525,6 +1631,7 @@ Offline Changed files: ${files.length}`;
|
||||
return old;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (strict) throw ex;
|
||||
this._log(`Getting base save data failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
@@ -1552,6 +1659,18 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, false);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseStrict(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
return await this.storeInternalFileToDatabaseCore(file, forceWrite, true);
|
||||
}
|
||||
|
||||
private async storeInternalFileToDatabaseCore(
|
||||
file: InternalFileInfo | UXFileInfo,
|
||||
forceWrite: boolean,
|
||||
strict: boolean
|
||||
) {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
@@ -1565,9 +1684,11 @@ Offline Changed files: ${files.length}`;
|
||||
if (fileInfo.deleted) {
|
||||
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
|
||||
}
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, true);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, true)
|
||||
: await this.__loadBaseSaveData(storeFilePath, true);
|
||||
if (baseData === false) throw new Error("Failed to load base data");
|
||||
if (baseData._rev && !forceWrite) {
|
||||
if (baseData._rev && !baseData.deleted && !baseData._deleted && !forceWrite) {
|
||||
// Not newly created, we should check the content is actually modified.
|
||||
const isSame = await isDocContentSame(readAsBlob(baseData), fileInfo.body);
|
||||
if (isSame) {
|
||||
@@ -1592,11 +1713,15 @@ Offline Changed files: ${files.length}`;
|
||||
this.updateLastProcessed(storeFilePath, saveData, fileInfo.stat);
|
||||
}
|
||||
const success = ret && ret.ok;
|
||||
if (!success && strict) {
|
||||
throw new Error(`Failed to store hidden file in the database: ${storeFilePath}`);
|
||||
}
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) ${success ? "Done" : "Failed"}`);
|
||||
return success;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1676,6 +1801,14 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
|
||||
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, false);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseStrict(filenameSrc: FilePath, forceWrite = false) {
|
||||
return await this.deleteInternalFileOnDatabaseCore(filenameSrc, forceWrite, true);
|
||||
}
|
||||
|
||||
private async deleteInternalFileOnDatabaseCore(filenameSrc: FilePath, _forceWrite: boolean, strict: boolean) {
|
||||
const storeFilePath = filenameSrc;
|
||||
const storageFilePath = filenameSrc;
|
||||
const displayFileName = filenameSrc;
|
||||
@@ -1686,18 +1819,26 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const baseData = await this.__loadBaseSaveData(storeFilePath, false);
|
||||
const baseData = strict
|
||||
? await this.loadBaseSaveDataStrict(storeFilePath, false)
|
||||
: await this.__loadBaseSaveData(storeFilePath, false);
|
||||
if (baseData === false) throw new Error("Failed to load base data during deleting");
|
||||
if (baseData._conflicts !== undefined) {
|
||||
for (const conflictRev of baseData._conflicts) {
|
||||
await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
const removed = await this.localDatabase.removeRevision(baseData._id, conflictRev);
|
||||
if (strict && removed === false) {
|
||||
throw new Error(`Failed to remove conflicted hidden-file revision ${conflictRev}`);
|
||||
}
|
||||
this._log(
|
||||
`STORAGE -x> DB: ${displayFileName}: (hidden) conflict removed ${baseData._rev} => ${conflictRev}`,
|
||||
LOG_LEVEL_VERBOSE
|
||||
);
|
||||
}
|
||||
}
|
||||
if (baseData.deleted) {
|
||||
const alreadyDeleted = strict
|
||||
? !baseData._rev || baseData.deleted || baseData._deleted
|
||||
: baseData.deleted;
|
||||
if (alreadyDeleted) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) already deleted`, LOG_LEVEL_VERBOSE);
|
||||
this.updateLastProcessedDeletion(storeFilePath, baseData);
|
||||
return true;
|
||||
@@ -1718,11 +1859,13 @@ Offline Changed files: ${files.length}`;
|
||||
return true;
|
||||
} else {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
if (strict) throw new Error(`Failed to delete hidden file from the database: ${storeFilePath}`);
|
||||
return false;
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE -x> DB: ${displayFileName}: (hidden) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
if (strict) throw ex;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -1925,6 +2068,7 @@ Offline Changed files: ${files.length}`;
|
||||
|
||||
async configureHiddenFileSync(mode: OptionalSyncFeatureMode) {
|
||||
let initialisationProgress: HiddenFileInitialisationProgress | undefined;
|
||||
let initialised = false;
|
||||
let result: ConfigureHiddenFileSyncResult;
|
||||
try {
|
||||
result = await configureHiddenFileSyncMode(mode, {
|
||||
@@ -1954,7 +2098,7 @@ Offline Changed files: ${files.length}`;
|
||||
);
|
||||
},
|
||||
initialise: async (direction) => {
|
||||
await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialised = await this.initialiseInternalFileSync(direction, true, false, initialisationProgress);
|
||||
initialisationProgress = undefined;
|
||||
},
|
||||
});
|
||||
@@ -1962,7 +2106,7 @@ Offline Changed files: ${files.length}`;
|
||||
initialisationProgress?.done("Failed");
|
||||
throw error;
|
||||
}
|
||||
if (result == "ignored" || result == "disabled") {
|
||||
if (result == "ignored" || result == "disabled" || !initialised) {
|
||||
return;
|
||||
}
|
||||
// this.plugin.settings.useAdvancedMode = true;
|
||||
@@ -2084,6 +2228,9 @@ Offline Changed files: ${files.length}`;
|
||||
// services.databaseEvents.handleOnDatabaseInitialisation(this._everyOnInitializeDatabase.bind(this));
|
||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
||||
services.fileProcessing.processOptionalFileEvent.addHandler(this._anyProcessOptionalFileEvent.bind(this));
|
||||
services.fileProcessing.processOptionalFileEventWithResult?.addHandler(
|
||||
this._processOptionalFileEventWithResult.bind(this)
|
||||
);
|
||||
services.conflict.getOptionalConflictCheckMethod.addHandler(this._anyGetOptionalConflictCheckMethod.bind(this));
|
||||
services.replication.processOptionalSynchroniseResult.addHandler(this._anyProcessOptionalSyncFiles.bind(this));
|
||||
services.setting.onRealiseSetting.addHandler(this._everyRealizeSettingSyncMode.bind(this));
|
||||
|
||||
@@ -99,6 +99,115 @@ function createHiddenRevisionOperation() {
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"refuses %s initialisation before processing files while Hidden File Sync is disabled",
|
||||
async (direction) => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const operations = {
|
||||
rebuildFromStorage: vi.fn(async () => [path]),
|
||||
rebuildFromDatabase: vi.fn(async () => []),
|
||||
rebuildMerging: vi.fn(async () => [path]),
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(async () => undefined),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(async () => undefined),
|
||||
scanAllStorageChanges: vi.fn(async () => undefined),
|
||||
scanAllDatabaseChanges: vi.fn(async () => undefined),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
const settings = { syncInternalFiles: false };
|
||||
const createProgress = vi.fn(() => progress);
|
||||
const log = vi.fn();
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings },
|
||||
_progress: createProgress,
|
||||
_log: log,
|
||||
...operations,
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, [path]);
|
||||
|
||||
for (const operation of Object.values(operations)) {
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(result).toBe(false);
|
||||
expect(settings.syncInternalFiles).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Hidden File Sync is disabled"), LOG_LEVEL_NOTICE);
|
||||
expect(progress.done).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("finishes an existing preparation Notice as not started when initialisation is disabled", async () => {
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const createProgress = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: false } },
|
||||
_progress: createProgress,
|
||||
_log: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true, false, progress);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(createProgress).not.toHaveBeenCalled();
|
||||
expect(progress.done).toHaveBeenCalledExactlyOnceWith("Hidden File Sync initialisation was not started.");
|
||||
});
|
||||
|
||||
it("does not apply a stored deletion when initialisation is requested while disabled", async () => {
|
||||
const path = ".obsidian/example.json" as FilePath;
|
||||
const deleted: MetaEntry = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-deleted",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
type: "plain",
|
||||
ctime: 1,
|
||||
mtime: 10,
|
||||
size: 0,
|
||||
deleted: true,
|
||||
children: [],
|
||||
eden: {},
|
||||
};
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const deleteFile = vi.fn(async () => "OK" as const);
|
||||
const updateDeletion = vi.fn();
|
||||
const getFiles = vi.fn(async () => [deleted]);
|
||||
const processedStorage = new Map([[path, "storage-processed"]]);
|
||||
const processedDatabase = new Map([[path, "database-processed"]]);
|
||||
const resetProcessed = vi.fn(() => processedDatabase.clear());
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
settings: { syncInternalFiles: false },
|
||||
services: { vault: { isIgnoredByIgnoreFile: vi.fn(async () => false) } },
|
||||
},
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: vi.fn(),
|
||||
_verbose: vi.fn(),
|
||||
_fileInfoLastProcessed: processedStorage,
|
||||
_databaseInfoLastProcessed: processedDatabase,
|
||||
getAllDatabaseFiles: getFiles,
|
||||
getPath: (doc: MetaEntry) => doc.path,
|
||||
resetLastProcessedDatabase: resetProcessed,
|
||||
serializedForEvent: async (_path: unknown, operation: () => Promise<unknown>) => await operation(),
|
||||
__deleteFile: deleteFile,
|
||||
updateLastProcessedDeletion: updateDeletion,
|
||||
adoptCurrentStorageFilesAsProcessed: vi.fn(),
|
||||
adoptCurrentDatabaseFilesAsProcessed: vi.fn(),
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("pullForce", true);
|
||||
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(updateDeletion).not.toHaveBeenCalled();
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(resetProcessed).not.toHaveBeenCalled();
|
||||
expect(processedStorage).toEqual(new Map([[path, "storage-processed"]]));
|
||||
expect(processedDatabase).toEqual(new Map([[path, "database-processed"]]));
|
||||
});
|
||||
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
id: string;
|
||||
@@ -136,6 +245,13 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
}
|
||||
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
const command = commands.find(({ id }) => id === commandId);
|
||||
expect(command?.checkCallback?.(true)).toBe(false);
|
||||
expect(command?.checkCallback?.(false)).toBe(false);
|
||||
}
|
||||
|
||||
settings.syncInternalFiles = true;
|
||||
settings.useAdvancedMode = true;
|
||||
for (const commandId of commandIds) {
|
||||
@@ -252,6 +368,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const scanAllDatabaseChanges = vi.fn(async () => undefined);
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed,
|
||||
@@ -260,14 +377,83 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
scanAllDatabaseChanges,
|
||||
});
|
||||
|
||||
await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync("safe", true);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(rebuildMerging).toHaveBeenCalledWith(false, false);
|
||||
expect(scanAllStorageChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(scanAllDatabaseChanges).toHaveBeenCalledWith(false, true, false);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["pushForce", "pullForce", "safe"] as const)(
|
||||
"retains the selected files and completes %s initialisation when enabled",
|
||||
async (direction) => {
|
||||
const files = [".obsidian/plugins/example/main.js", ".obsidian/plugins/example/data.json"] as FilePath[];
|
||||
const entries = files.map((path) => ({ path: `i:${path}` as FilePathWithPrefix }));
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const rebuildFromStorage = vi.fn(async () => files);
|
||||
const rebuildFromDatabase = vi.fn(async () => entries);
|
||||
const rebuildMerging = vi.fn(async () => files);
|
||||
const adoptStorage = vi.fn();
|
||||
const adoptDatabase = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { settings: { syncInternalFiles: true } },
|
||||
_progress: vi.fn(() => progress),
|
||||
getPath: (entry: { path: FilePathWithPrefix }) => entry.path,
|
||||
rebuildFromStorage,
|
||||
rebuildFromDatabase,
|
||||
rebuildMerging,
|
||||
adoptCurrentStorageFilesAsProcessed: adoptStorage,
|
||||
adoptCurrentDatabaseFilesAsProcessed: adoptDatabase,
|
||||
scanAllStorageChanges: vi.fn(),
|
||||
scanAllDatabaseChanges: vi.fn(),
|
||||
});
|
||||
|
||||
const result = await hiddenFileSync.initialiseInternalFileSync(direction, true, files);
|
||||
|
||||
expect(result).toBe(true);
|
||||
if (direction === "pushForce") {
|
||||
expect(rebuildFromStorage).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else if (direction === "pullForce") {
|
||||
expect(rebuildFromDatabase).toHaveBeenCalledExactlyOnceWith(false, files, false);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildMerging).not.toHaveBeenCalled();
|
||||
} else {
|
||||
expect(rebuildMerging).toHaveBeenCalledExactlyOnceWith(false, files);
|
||||
expect(rebuildFromStorage).not.toHaveBeenCalled();
|
||||
expect(rebuildFromDatabase).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(adoptStorage).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(adoptDatabase).toHaveBeenCalledExactlyOnceWith(files);
|
||||
expect(progress.done).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
|
||||
it("does not report completion when the initialisation entry point refuses to start", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
await handlers.initialise("safe");
|
||||
return "enabled";
|
||||
});
|
||||
const progress = { log: vi.fn(), once: vi.fn(), done: vi.fn() };
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: { services: { setting: { applyPartial: vi.fn() } } },
|
||||
_progress: vi.fn(() => progress),
|
||||
_log: log,
|
||||
initialiseInternalFileSync: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
await hiddenFileSync.configureHiddenFileSync("MERGE");
|
||||
|
||||
expect(log).not.toHaveBeenCalledWith("Hidden File Sync initialisation completed.", expect.any(Number));
|
||||
});
|
||||
|
||||
it("retirement guard: does not restore separate gathering and restart Notices", async () => {
|
||||
vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => {
|
||||
await handlers.enable();
|
||||
@@ -286,7 +472,7 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
const applyPartial = vi.fn(async () => {
|
||||
events.push("apply-settings");
|
||||
});
|
||||
const initialiseInternalFileSync = vi.fn(async () => undefined);
|
||||
const initialiseInternalFileSync = vi.fn(async () => true);
|
||||
const log = vi.fn();
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
|
||||
+8
-1
@@ -1,4 +1,5 @@
|
||||
import { useP2PSettingsPreparation } from "@/serviceFeatures/useP2PSettingsPreparation";
|
||||
import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger";
|
||||
import { getLanguage, Notice, Plugin, type App, type PluginManifest } from "./deps";
|
||||
import { setGetLanguage } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
setGetLanguage(getLanguage);
|
||||
@@ -233,6 +234,12 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
void this._startUp();
|
||||
}
|
||||
override onunload(): void {
|
||||
return void this.core.services.control.onUnload();
|
||||
void this.core.services.control.onUnload().catch((error) => {
|
||||
this.core.services.API.addLog(
|
||||
"Failed to unload Self-hosted LiveSync: " + String(error),
|
||||
LOG_LEVEL_URGENT,
|
||||
"shutdown"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -230,7 +230,13 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
}
|
||||
this.isLastHidden = isHidden;
|
||||
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
|
||||
if (!pendingFileEventsCommitted) {
|
||||
this._log(
|
||||
"Pending file events remain because their commit did not complete before the window visibility changed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the
|
||||
// window is hidden, instead of suspending on hide. On hide we skip the suspend for both
|
||||
@@ -287,7 +293,10 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
|
||||
if (this.settings.suspendFileWatching) return;
|
||||
if (!this.settings.isConfigured) return;
|
||||
if (!this.services.appLifecycle.isReady()) return;
|
||||
await this.services.fileProcessing.commitPendingFileEvents();
|
||||
const pendingFileEventsCommitted = await this.services.fileProcessing.commitPendingFileEvents();
|
||||
if (!pendingFileEventsCommitted) {
|
||||
return;
|
||||
}
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { ModuleObsidianEvents } from "./ModuleObsidianEvents";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
|
||||
type SetupOptions = {
|
||||
settings?: Partial<typeof DEFAULT_SETTINGS>;
|
||||
@@ -23,6 +24,8 @@ function setup(opts: SetupOptions) {
|
||||
onResumed: vi.fn(async () => true),
|
||||
};
|
||||
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
|
||||
const replication = { replicateUnattendedByEvent: vi.fn(async () => undefined) };
|
||||
const conflict = { queueCheckForIfOpen: vi.fn(async () => undefined) };
|
||||
const boundedRemoteActivityCount = reactiveSource(0);
|
||||
const boundedLocalApplicationActivityCount = reactiveSource(0);
|
||||
|
||||
@@ -39,6 +42,8 @@ function setup(opts: SetupOptions) {
|
||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replication,
|
||||
conflict,
|
||||
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
|
||||
},
|
||||
settings: {
|
||||
@@ -61,6 +66,9 @@ function setup(opts: SetupOptions) {
|
||||
module,
|
||||
appLifecycle,
|
||||
fileProcessing,
|
||||
replication,
|
||||
conflict,
|
||||
api: core._services.API,
|
||||
boundedRemoteActivityCount,
|
||||
boundedLocalApplicationActivityCount,
|
||||
};
|
||||
@@ -303,6 +311,23 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("logs a failed pending-file commit and still suspends on hide", async () => {
|
||||
const { module, appLifecycle, fileProcessing, api } = setup({
|
||||
settings: { keepReplicationActiveInBackground: false, liveSync: true },
|
||||
hidden: true,
|
||||
});
|
||||
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
|
||||
|
||||
await module.watchWindowVisibilityAsync();
|
||||
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
expect(api.addLog).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Pending file events remain"),
|
||||
LOG_LEVEL_NOTICE,
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply on mobile even if the flag is set", async () => {
|
||||
const { module, appLifecycle } = setup({
|
||||
settings: { keepReplicationActiveInBackground: true, liveSync: true },
|
||||
@@ -313,3 +338,20 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
|
||||
expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchWorkspaceOpenAsync — pending file event commit", () => {
|
||||
it("skips replication and conflict checks when pending file events fail to commit", async () => {
|
||||
const { module, fileProcessing, replication, conflict } = setup({
|
||||
settings: { syncOnFileOpen: true },
|
||||
hidden: false,
|
||||
});
|
||||
fileProcessing.commitPendingFileEvents.mockResolvedValue(false);
|
||||
|
||||
await module.watchWorkspaceOpenAsync({ path: "note.md" } as Parameters<
|
||||
typeof module.watchWorkspaceOpenAsync
|
||||
>[0]);
|
||||
|
||||
expect(replication.replicateUnattendedByEvent).not.toHaveBeenCalled();
|
||||
expect(conflict.queueCheckForIfOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -171,20 +171,18 @@ export function paneSyncSettings(
|
||||
}
|
||||
});
|
||||
|
||||
void addPanel(
|
||||
paneEl,
|
||||
$msg("obsidianLiveSyncSettingTab.titleUpdateThinning"),
|
||||
undefined,
|
||||
visibleOnly(() => !this.isConfiguredAs("syncMode", "LIVESYNC"))
|
||||
).then((paneEl) => {
|
||||
new Setting(paneEl).autoWireToggle("batchSave");
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleUpdateThinning")).then((paneEl) => {
|
||||
const inLiveSyncMode = () => this.isConfiguredAs("syncMode", "LIVESYNC");
|
||||
const batchEnabled = () => this.isConfiguredAs(inLiveSyncMode() ? "batchSaveInLiveSync" : "batchSave", true);
|
||||
new Setting(paneEl).autoWireToggle("batchSave", { onUpdate: visibleOnly(() => !inLiveSyncMode()) });
|
||||
new Setting(paneEl).autoWireToggle("batchSaveInLiveSync", { onUpdate: visibleOnly(inLiveSyncMode) });
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMinimumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
new Setting(paneEl).autoWireNumeric("batchSaveMaximumDelay", {
|
||||
acceptZero: true,
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("batchSave", true)),
|
||||
onUpdate: visibleOnly(batchEnabled),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab";
|
||||
import type { OnUpdateFunc, PageFunctions } from "./SettingPane";
|
||||
|
||||
const controls = vi.hoisted(() => new Map<string, { onUpdate?: () => { visibility?: boolean } }>());
|
||||
vi.mock("@/common/translation", () => ({ $msg: (key: string) => key }));
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({ Logger: vi.fn() }));
|
||||
vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
LiveSyncSetting: class {
|
||||
autoWireDropDown(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireToggle(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
autoWireNumeric(key: string, options = {}) {
|
||||
controls.set(key, options);
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { paneSyncSettings } from "./PaneSyncSettings";
|
||||
|
||||
function setup(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
|
||||
const callbacks = new Map<string, (value: string) => Promise<void>>();
|
||||
const panels = new Map<string, OnUpdateFunc | undefined>();
|
||||
const tab = {
|
||||
editingSettings: { ...DEFAULT_SETTINGS, liveSync, batchSave, batchSaveInLiveSync },
|
||||
services: { API: { isMobile: () => false }, control: { applySettings: vi.fn(async () => {}) } },
|
||||
addOnSaved: (key: string, callback: (value: string) => Promise<void>) => callbacks.set(key, callback),
|
||||
isConfiguredAs(key: string, value: unknown) {
|
||||
if (key === "syncMode") {
|
||||
return (
|
||||
(this.editingSettings.liveSync
|
||||
? "LIVESYNC"
|
||||
: this.editingSettings.periodicReplication
|
||||
? "PERIODIC"
|
||||
: "ONEVENTS") === value
|
||||
);
|
||||
}
|
||||
return this.editingSettings[key as keyof typeof this.editingSettings] === value;
|
||||
},
|
||||
saveSettings: vi.fn(async () => {}),
|
||||
requestCatalogueRefresh: vi.fn(),
|
||||
};
|
||||
const functions = {
|
||||
addPanel: (_element: HTMLElement, title: string, _callback: unknown, onUpdate?: OnUpdateFunc) => ({
|
||||
then(callback: (element: HTMLElement) => void) {
|
||||
if (!title.endsWith("titleSynchronizationMethod") && !title.endsWith("titleUpdateThinning")) return;
|
||||
panels.set(title, onUpdate);
|
||||
callback({} as HTMLElement);
|
||||
},
|
||||
}),
|
||||
} as unknown as PageFunctions;
|
||||
paneSyncSettings.call(tab as unknown as ObsidianLiveSyncSettingTab, {} as HTMLElement, functions);
|
||||
return { tab, callbacks, panels };
|
||||
}
|
||||
|
||||
function visible(key: string): boolean {
|
||||
const control = controls.get(key);
|
||||
if (!control) throw new Error(`Missing setting control: ${key}`);
|
||||
return control.onUpdate?.().visibility !== false;
|
||||
}
|
||||
|
||||
beforeEach(() => controls.clear());
|
||||
|
||||
describe("mode-specific database batching settings", () => {
|
||||
it.each([
|
||||
{ liveSync: true, batchSave: true, batchSaveInLiveSync: false, delays: false },
|
||||
{ liveSync: true, batchSave: false, batchSaveInLiveSync: true, delays: true },
|
||||
{ liveSync: false, batchSave: true, batchSaveInLiveSync: false, delays: true },
|
||||
{ liveSync: false, batchSave: false, batchSaveInLiveSync: true, delays: false },
|
||||
])("shows the current mode choice and its shared delays: %j", ({ delays, ...settings }) => {
|
||||
const { panels } = setup(settings.liveSync, settings.batchSave, settings.batchSaveInLiveSync);
|
||||
expect(panels.get("obsidianLiveSyncSettingTab.titleUpdateThinning")?.().visibility).not.toBe(false);
|
||||
expect(visible("batchSave")).toBe(!settings.liveSync);
|
||||
expect(visible("batchSaveInLiveSync")).toBe(settings.liveSync);
|
||||
expect(visible("batchSaveMinimumDelay")).toBe(delays);
|
||||
expect(visible("batchSaveMaximumDelay")).toBe(delays);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, false],
|
||||
[false, true],
|
||||
])("retains both batching choices across mode changes: %j", async (batchSave, batchSaveInLiveSync) => {
|
||||
const { tab, callbacks } = setup(false, batchSave, batchSaveInLiveSync);
|
||||
const switchMode = callbacks.get("syncMode")!;
|
||||
for (const mode of ["LIVESYNC", "PERIODIC", "ONEVENTS", "LIVESYNC"]) {
|
||||
await switchMode(mode);
|
||||
expect(tab.editingSettings).toMatchObject({ batchSave, batchSaveInLiveSync });
|
||||
}
|
||||
expect(tab.services.control.applySettings).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user