mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Merge pull request #1277 from vrtmrz/fix/customisation-catalogue-race
Fix concurrent Customisation Sync catalogue updates
This commit is contained in:
7 files changed
+590
-23
No files matched your search
@@ -96,6 +96,7 @@
|
|||||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||||
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
|
"test:e2e:obsidian:hidden-file-selectors": "tsx test/e2e-obsidian/scripts/hidden-file-selectors.ts",
|
||||||
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||||
|
"test:e2e:obsidian:customisation-catalogue-reload": "tsx test/e2e-obsidian/scripts/customisation-catalogue-reload.ts",
|
||||||
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
"test:e2e:obsidian:received-change-readiness": "tsx test/e2e-obsidian/scripts/received-change-readiness.ts",
|
||||||
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
"test:e2e:obsidian:remote-feature-change": "tsx test/e2e-obsidian/scripts/remote-feature-change.ts",
|
||||||
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
|
"test:e2e:obsidian:internal-metadata-migration": "tsx test/e2e-obsidian/scripts/internal-metadata-migration.ts",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -412,6 +412,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
|
periodicPluginSweepProcessor = new PeriodicProcessor(this.core, async () => await this.scanAllConfigFiles(false));
|
||||||
|
|
||||||
pluginList: IPluginDataExDisplay[] = [];
|
pluginList: IPluginDataExDisplay[] = [];
|
||||||
|
private pluginListGeneration = 0;
|
||||||
showPluginSyncModal() {
|
showPluginSyncModal() {
|
||||||
if (!this.isThisModuleEnabled()) {
|
if (!this.isThisModuleEnabled()) {
|
||||||
return;
|
return;
|
||||||
@@ -532,6 +533,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
async reloadPluginList(showMessage: boolean) {
|
async reloadPluginList(showMessage: boolean) {
|
||||||
|
this.pluginListGeneration++;
|
||||||
this.pluginList = [];
|
this.pluginList = [];
|
||||||
this.loadedManifest_mTime.clear();
|
this.loadedManifest_mTime.clear();
|
||||||
pluginList.set(this.pluginList);
|
pluginList.set(this.pluginList);
|
||||||
@@ -761,39 +763,46 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
updatingV2Count = 0;
|
updatingV2Count = 0;
|
||||||
|
|
||||||
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise<void> {
|
||||||
|
const generation = this.pluginListGeneration;
|
||||||
|
const isCurrent = () => generation === this.pluginListGeneration && this.isThisModuleEnabled();
|
||||||
try {
|
try {
|
||||||
this.updatingV2Count++;
|
this.updatingV2Count++;
|
||||||
pluginV2Progress.set(this.updatingV2Count);
|
pluginV2Progress.set(this.updatingV2Count);
|
||||||
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
// const unifiedFilenameWithKey = this.filenameWithUnifiedKey(updatedDocumentPath);
|
||||||
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
|
const { pathV1 } = this.parseUnifiedPath(unifiedFilenameWithKey);
|
||||||
|
|
||||||
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
await serialized(`config:catalogue:${pathV1}`, async () => {
|
||||||
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
if (!isCurrent()) return;
|
||||||
|
const oldEntry = this.pluginList.find((e) => e.documentPath == pathV1);
|
||||||
|
let entry: PluginDataExDisplayV2 | undefined = undefined;
|
||||||
|
|
||||||
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
if (!oldEntry || !(oldEntry instanceof PluginDataExDisplayV2)) {
|
||||||
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
const newEntry = this.createPluginDataFromV2(unifiedFilenameWithKey);
|
||||||
if (newEntry) {
|
if (newEntry) {
|
||||||
entry = newEntry;
|
entry = newEntry;
|
||||||
|
}
|
||||||
|
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
||||||
|
entry = oldEntry;
|
||||||
}
|
}
|
||||||
} else if (oldEntry instanceof PluginDataExDisplayV2) {
|
if (!entry) return;
|
||||||
entry = oldEntry;
|
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
||||||
}
|
if (!isCurrent()) return;
|
||||||
if (!entry) return;
|
if (file) {
|
||||||
const file = await this.createPluginDataExFileV2(unifiedFilenameWithKey);
|
await entry.setFile(file);
|
||||||
if (file) {
|
} else {
|
||||||
await entry.setFile(file);
|
entry.deleteFile(unifiedFilenameWithKey);
|
||||||
} else {
|
if (entry.files.length == 0) {
|
||||||
entry.deleteFile(unifiedFilenameWithKey);
|
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
||||||
if (entry.files.length == 0) {
|
}
|
||||||
this.pluginList = this.pluginList.filter((e) => e.documentPath != pathV1);
|
|
||||||
}
|
}
|
||||||
}
|
if (!isCurrent()) return;
|
||||||
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
const newList = this.pluginList.filter((e) => e.documentPath != entry.documentPath);
|
||||||
newList.push(entry);
|
newList.push(entry);
|
||||||
this.pluginList = newList;
|
this.pluginList = newList;
|
||||||
|
|
||||||
scheduleTask("updatePluginListV2", 100, () => {
|
scheduleTask("updatePluginListV2", 100, () => {
|
||||||
pluginList.set(this.pluginList);
|
pluginList.set(this.pluginList);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.updatingV2Count--;
|
this.updatingV2Count--;
|
||||||
@@ -871,11 +880,13 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
|
|
||||||
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise<void> {
|
||||||
if (!this.isThisModuleEnabled()) {
|
if (!this.isThisModuleEnabled()) {
|
||||||
|
this.pluginListGeneration++;
|
||||||
this.pluginScanProcessor.clearQueue();
|
this.pluginScanProcessor.clearQueue();
|
||||||
this.pluginList = [];
|
this.pluginList = [];
|
||||||
pluginList.set(this.pluginList);
|
pluginList.set(this.pluginList);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const generation = this.pluginListGeneration;
|
||||||
try {
|
try {
|
||||||
this.updatingV2Count++;
|
this.updatingV2Count++;
|
||||||
pluginV2Progress.set(this.updatingV2Count);
|
pluginV2Progress.set(this.updatingV2Count);
|
||||||
@@ -888,6 +899,7 @@ export class ConfigSync extends LiveSyncCommands {
|
|||||||
})
|
})
|
||||||
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
: this.localDatabase.findEntries(ICXHeader + "", `${ICXHeader}\u{10ffff}`, { include_docs: true });
|
||||||
for await (const v of plugins) {
|
for await (const v of plugins) {
|
||||||
|
if (generation !== this.pluginListGeneration || !this.isThisModuleEnabled()) return;
|
||||||
if (v.deleted || v._deleted) continue;
|
if (v.deleted || v._deleted) continue;
|
||||||
if (v.path.indexOf("%") !== -1) {
|
if (v.path.indexOf("%") !== -1) {
|
||||||
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
fireAndForget(() => this.updatePluginListV2(showMessage, v.path));
|
||||||
|
|||||||
@@ -256,6 +256,8 @@ Before enablement, the scenario directly requests initialisation while Hidden Fi
|
|||||||
|
|
||||||
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
|
||||||
|
|
||||||
|
`test:e2e:obsidian:customisation-catalogue-reload` opens the Customisation Sync dialogue, enables Maintenance mode, and clicks Reload. It verifies that this first Reload lists all three files of a sample plug-in, then selects the source copy and clicks Apply to restore their contents. It also checks a second enumeration and Apply through the existing methods in one temporary Vault with a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_CUSTOMISATION_CATALOGUE_OUTPUT` to choose the JSON report path.
|
||||||
|
|
||||||
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
|
||||||
|
|
||||||
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { evalObsidianJson } from "../runner/cli.ts";
|
||||||
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||||
|
import {
|
||||||
|
createE2eCouchDbPluginData,
|
||||||
|
createE2eObsidianDeviceLocalState,
|
||||||
|
waitForLiveSyncCoreReady,
|
||||||
|
} from "../runner/liveSyncWorkflow.ts";
|
||||||
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||||
|
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||||
|
import { createTemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
|
const pluginName = "livesync-catalogue-example";
|
||||||
|
const sourceTerm = "catalogue-source";
|
||||||
|
const pluginRoot = `.obsidian/plugins/${pluginName}`;
|
||||||
|
const pluginFiles = {
|
||||||
|
[`${pluginRoot}/main.js`]: "module.exports = class ExamplePlugin {};\n",
|
||||||
|
[`${pluginRoot}/manifest.json`]: JSON.stringify({
|
||||||
|
id: pluginName,
|
||||||
|
name: "Catalogue Example",
|
||||||
|
version: "1.0.0",
|
||||||
|
minAppVersion: "1.0.0",
|
||||||
|
description: "An isolated test fixture",
|
||||||
|
author: "Self-hosted LiveSync",
|
||||||
|
}),
|
||||||
|
[`${pluginRoot}/styles.css`]: ".livesync-catalogue-example { color: red; }\n",
|
||||||
|
};
|
||||||
|
const allowList = "\\.(?:md|png|pdf)$";
|
||||||
|
const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;";
|
||||||
|
const configCode = `${coreCode}const addOn=core.getAddOn('ConfigSync');`;
|
||||||
|
const settleCode = `
|
||||||
|
const settle=async()=>{
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,100));
|
||||||
|
for(let attempt=0;attempt<200;attempt++){
|
||||||
|
if(addOn.updatingV2Count===0)return;
|
||||||
|
await new Promise(resolve=>setTimeout(resolve,25));
|
||||||
|
}
|
||||||
|
throw new Error('Customisation Sync did not settle');
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
const sourceRowCode = `
|
||||||
|
const row=addOn.pluginList.find(entry=>entry.category==='PLUGIN_MAIN'&&entry.name===${JSON.stringify(pluginName)}&&entry.term===${JSON.stringify(sourceTerm)});
|
||||||
|
if(!row)throw new Error('The source catalogue row is missing');
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Observation = { name: string; expected: unknown; actual: unknown; passed: boolean };
|
||||||
|
|
||||||
|
async function writeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||||
|
for (const [path, content] of Object.entries(files)) {
|
||||||
|
await mkdir(dirname(join(vaultPath, path)), { recursive: true });
|
||||||
|
await writeFile(join(vaultPath, path), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFixtures(vaultPath: string, files: Record<string, string>) {
|
||||||
|
for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||||
|
const matches: string[] = [];
|
||||||
|
for (const [path, content] of Object.entries(files)) {
|
||||||
|
try {
|
||||||
|
if ((await readFile(join(vaultPath, path), "utf8")) === content) matches.push(path);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMatchingFiles(vaultPath: string, files: Record<string, string>) {
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
let matches = await matchingFiles(vaultPath, files);
|
||||||
|
while (matches.length !== Object.keys(files).length && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
matches = await matchingFiles(vaultPath, files);
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const binary = requireObsidianBinary();
|
||||||
|
const cli = discoverObsidianCli();
|
||||||
|
if (!cli.binary) throw new Error("The Obsidian CLI is unavailable");
|
||||||
|
const cliBinary = cli.binary;
|
||||||
|
const output =
|
||||||
|
process.env.E2E_CUSTOMISATION_CATALOGUE_OUTPUT ?? join(tmpdir(), "livesync-customisation-catalogue.json");
|
||||||
|
const observations: Observation[] = [];
|
||||||
|
const report: Record<string, unknown> = {
|
||||||
|
revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
|
||||||
|
sourceDiffSha256: createHash("sha256")
|
||||||
|
.update(new Uint8Array(execFileSync("git", ["diff", "--binary", "HEAD"])))
|
||||||
|
.digest("hex"),
|
||||||
|
scenarioSha256: createHash("sha256")
|
||||||
|
.update(new Uint8Array(await readFile(fileURLToPath(import.meta.url))))
|
||||||
|
.digest("hex"),
|
||||||
|
commonlibEntrySha256: createHash("sha256")
|
||||||
|
.update(
|
||||||
|
new Uint8Array(
|
||||||
|
await readFile(
|
||||||
|
"node_modules/@vrtmrz/livesync-commonlib/dist/managers/EntryManager/EntryManagerImpls.js"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.digest("hex"),
|
||||||
|
bundleSha256: createHash("sha256")
|
||||||
|
.update(new Uint8Array(await readFile("main.js")))
|
||||||
|
.digest("hex"),
|
||||||
|
commonlibVersion: JSON.parse(await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8"))
|
||||||
|
.version,
|
||||||
|
observations,
|
||||||
|
};
|
||||||
|
const vault = await createTemporaryVault("livesync-customisation-catalogue-");
|
||||||
|
let session: ObsidianLiveSyncSession | undefined;
|
||||||
|
const record = (name: string, actual: unknown, expected: unknown) => {
|
||||||
|
const observation = { name, expected, actual, passed: isDeepStrictEqual(actual, expected) };
|
||||||
|
observations.push(observation);
|
||||||
|
console.log(JSON.stringify(observation));
|
||||||
|
};
|
||||||
|
const evaluate = async <T>(code: string): Promise<T> => {
|
||||||
|
if (!session) throw new Error("The isolated Obsidian session has not started");
|
||||||
|
return await evalObsidianJson<T>(cliBinary, `(async()=>{${code}})()`, session.cliEnv, 60_000);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
session = await startObsidianLiveSyncSession({
|
||||||
|
binary,
|
||||||
|
cliBinary,
|
||||||
|
vault,
|
||||||
|
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||||
|
pluginData: createE2eCouchDbPluginData(
|
||||||
|
{ uri: "http://127.0.0.1:9", username: "", password: "", dbName: "catalogue-local-only" },
|
||||||
|
{ periodicReplication: false, syncOnFileOpen: false, useOnlyLocalChunk: true }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
report.pluginVersion = session.readiness.pluginVersion;
|
||||||
|
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||||
|
report.runtime = await evaluate(`${coreCode}
|
||||||
|
core.services.setting.setDeviceAndVaultName('catalogue-local');
|
||||||
|
Object.assign(core.settings,{
|
||||||
|
isConfigured:true,usePluginSync:true,usePluginSyncV2:true,syncInternalFiles:false,
|
||||||
|
autoSweepPlugins:false,autoSweepPluginsPeriodic:false,watchInternalFileChanges:false,
|
||||||
|
syncOnlyRegEx:${JSON.stringify(allowList)},syncIgnoreRegEx:''
|
||||||
|
});
|
||||||
|
return JSON.stringify({userAgent:navigator.userAgent,databaseReady:core.localDatabase.isReady});
|
||||||
|
`);
|
||||||
|
|
||||||
|
await writeFixtures(vault.path, pluginFiles);
|
||||||
|
await evaluate(`${configCode}${settleCode}
|
||||||
|
for(const path of ${JSON.stringify(Object.keys(pluginFiles))}){
|
||||||
|
const stored=await addOn.storeCustomisationFileV2(path,${JSON.stringify(sourceTerm)});
|
||||||
|
if(stored===false)throw new Error('Could not seed a Customisation Sync document');
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
return JSON.stringify({seeded:true});
|
||||||
|
`);
|
||||||
|
const opened = await evaluate<{ opened: boolean }>(
|
||||||
|
`return JSON.stringify({opened:app.commands.executeCommandById('obsidian-livesync:livesync-plugin-dialog-ex')});`
|
||||||
|
);
|
||||||
|
if (!opened.opened) throw new Error("Could not open the Customisation Sync dialogue command");
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||||
|
await dialogue
|
||||||
|
.locator("label")
|
||||||
|
.filter({ hasText: "Maintenance mode" })
|
||||||
|
.locator('input[type="checkbox"]')
|
||||||
|
.check();
|
||||||
|
await dialogue.getByRole("button", { name: "Reload", exact: true }).click();
|
||||||
|
});
|
||||||
|
const catalogue = await evaluate<{ files: string[]; documents: number }>(`${configCode}${settleCode}
|
||||||
|
await settle();
|
||||||
|
${sourceRowCode}
|
||||||
|
let documents=0;
|
||||||
|
for await(const doc of core.localDatabase.findEntries('ix:','ix;')){
|
||||||
|
if(doc.path.startsWith(${JSON.stringify(`ix:${sourceTerm}/PLUGIN_MAIN/${pluginName}%`)})&&!doc.deleted&&!doc._deleted)documents++;
|
||||||
|
}
|
||||||
|
return JSON.stringify({files:row.files.map(file=>file.filename).sort(),documents});
|
||||||
|
`);
|
||||||
|
record("#1271: all source documents remain in the database", catalogue.documents, 3);
|
||||||
|
record(
|
||||||
|
"#1271: one reload lists all files",
|
||||||
|
catalogue.files,
|
||||||
|
Object.keys(pluginFiles)
|
||||||
|
.map((path) => path.substring(".obsidian/".length))
|
||||||
|
.sort()
|
||||||
|
);
|
||||||
|
await removeFixtures(vault.path, pluginFiles);
|
||||||
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||||
|
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
|
||||||
|
const row = dialogue.locator(`.filerow:has(select option[value="${sourceTerm}"])`);
|
||||||
|
await row.locator("select").selectOption(sourceTerm);
|
||||||
|
await row.locator(".body").getByRole("button", { name: "✓", exact: true }).click();
|
||||||
|
});
|
||||||
|
report.firstApply = { action: "UI Apply", sourceTerm };
|
||||||
|
record(
|
||||||
|
"#1271: first Apply restores all file contents",
|
||||||
|
await waitForMatchingFiles(vault.path, pluginFiles),
|
||||||
|
Object.keys(pluginFiles).sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondPass = await evaluate<string[]>(`${configCode}${settleCode}
|
||||||
|
await addOn.updatePluginList(false);await settle();${sourceRowCode}
|
||||||
|
return JSON.stringify(row.files.map(file=>file.filename).sort());
|
||||||
|
`);
|
||||||
|
record(
|
||||||
|
"#1271 control: a second pass lists all files",
|
||||||
|
secondPass,
|
||||||
|
Object.keys(pluginFiles)
|
||||||
|
.map((path) => path.substring(".obsidian/".length))
|
||||||
|
.sort()
|
||||||
|
);
|
||||||
|
await removeFixtures(vault.path, pluginFiles);
|
||||||
|
await evaluate(`${configCode}${sourceRowCode}return JSON.stringify({applied:await addOn.applyDataV2(row)});`);
|
||||||
|
record(
|
||||||
|
"#1271 control: Apply after a second pass restores all contents",
|
||||||
|
await matchingFiles(vault.path, pluginFiles),
|
||||||
|
Object.keys(pluginFiles).sort()
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
report.runtimeError = error instanceof Error ? error.stack : String(error);
|
||||||
|
if (session) report.processOutput = JSON.stringify(session.app.output()).slice(-4_000);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await writeFile(output, JSON.stringify(report, null, 2) + "\n");
|
||||||
|
console.log(`Verification report: ${output}`);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (session) await session.app.stop();
|
||||||
|
} finally {
|
||||||
|
await vault.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (observations.some((observation) => !observation.passed)) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -43,6 +43,7 @@ const focusedScenarios = new Set([
|
|||||||
"hidden-file-snippet-sync",
|
"hidden-file-snippet-sync",
|
||||||
"hidden-file-selectors",
|
"hidden-file-selectors",
|
||||||
"customisation-sync",
|
"customisation-sync",
|
||||||
|
"customisation-catalogue-reload",
|
||||||
"received-change-readiness",
|
"received-change-readiness",
|
||||||
"internal-metadata-doctor",
|
"internal-metadata-doctor",
|
||||||
"setting-markdown-export",
|
"setting-markdown-export",
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Customisation Sync
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
|
||||||
|
- We can reload the customisation list and apply every file in a multi-file item on the first attempt. (#1271)
|
||||||
|
- Reloading or clearing the list also prevents earlier pending updates from restoring removed files and items.
|
||||||
|
|
||||||
### Setup tools
|
### Setup tools
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|||||||
Reference in new issue
Block a user