diff --git a/package.json b/package.json index 3d04a41d..72b2884b 100644 --- a/package.json +++ b/package.json @@ -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-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-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: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", diff --git a/src/features/ConfigSync/CmdConfigSync.catalogue.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.catalogue.unit.spec.ts new file mode 100644 index 00000000..06b04919 --- /dev/null +++ b/src/features/ConfigSync/CmdConfigSync.catalogue.unit.spec.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 = { + "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>[] = []; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((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(); + 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[] = []; + 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 extends AsyncGenerator ? 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); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.ts b/src/features/ConfigSync/CmdConfigSync.ts index 0e1412ee..4b07c99f 100644 --- a/src/features/ConfigSync/CmdConfigSync.ts +++ b/src/features/ConfigSync/CmdConfigSync.ts @@ -412,6 +412,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 +533,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 +763,46 @@ export class ConfigSync extends LiveSyncCommands { updatingV2Count = 0; async updatePluginListV2(showMessage: boolean, unifiedFilenameWithKey: FilePathWithPrefix): Promise { + 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 +880,13 @@ export class ConfigSync extends LiveSyncCommands { async updatePluginList(showMessage: boolean, updatedDocumentPath?: FilePathWithPrefix): Promise { 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 +899,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)); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 53eb4a79..03ff4798 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -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-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. `--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. diff --git a/test/e2e-obsidian/scripts/customisation-catalogue-reload.ts b/test/e2e-obsidian/scripts/customisation-catalogue-reload.ts new file mode 100644 index 00000000..ea797d91 --- /dev/null +++ b/test/e2e-obsidian/scripts/customisation-catalogue-reload.ts @@ -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) { + 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) { + for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true }); +} + +async function matchingFiles(vaultPath: string, files: Record) { + 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) { + 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 = { + 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 (code: string): Promise => { + if (!session) throw new Error("The isolated Obsidian session has not started"); + return await evalObsidianJson(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(`${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; +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 31a8f339..6fb2adba 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -43,6 +43,7 @@ const focusedScenarios = new Set([ "hidden-file-snippet-sync", "hidden-file-selectors", "customisation-sync", + "customisation-catalogue-reload", "received-change-readiness", "internal-metadata-doctor", "setting-markdown-export", diff --git a/updates.md b/updates.md index 064e6a44..f9d295e3 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi ## 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 #### Fixed