From b7f38abae92ba5bc0315398d72da28c93bce097a Mon Sep 17 00:00:00 2001 From: Immick <12298761+Immick@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:03:51 +0300 Subject: [PATCH 1/6] fix(customisation-sync): stop truncating entry mtimes to 32 bits PluginDataExDisplayV2.mtime applied `~~` to the sum of millisecond timestamps before dividing, which keeps only the low 32 bits. The freshness chip and "Select All Shiny" then compared wrapped values and could offer an older copy as newer. Fixes #1194 --- .../CmdConfigSync.command.unit.spec.ts | 24 ++++++++++++++++++- src/features/ConfigSync/CmdConfigSync.ts | 3 ++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts index 0237360c..5f524f8a 100644 --- a/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts +++ b/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts @@ -52,7 +52,7 @@ vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ })); import { cancelTask } from "@/common/utils.ts"; -import { ConfigSync } from "./CmdConfigSync"; +import { ConfigSync, PluginDataExDisplayV2, type IPluginDataExDisplay } from "./CmdConfigSync"; describe("ConfigSync commands", () => { it("shows the Customisation Sync command only whilst the feature is enabled", () => { @@ -111,3 +111,25 @@ describe("ConfigSync commands", () => { expect(periodicPluginSweepProcessor.disable).toHaveBeenCalledOnce(); }); }); + +describe("PluginDataExDisplayV2", () => { + const entryWithModifiedTimes = (mtimes: number[]) => + new PluginDataExDisplayV2({ + documentPath: "ix:device/plugin_main/example", + category: "PLUGIN_MAIN", + name: "example", + term: "device", + files: mtimes.map((mtime, index) => ({ filename: `file-${index}`, mtime, data: [] })), + } as unknown as IPluginDataExDisplay); + + it("averages millisecond modification times without truncating them to 32 bits", () => { + const mtime = Date.UTC(2026, 8, 15); + expect(entryWithModifiedTimes([mtime, mtime, mtime]).mtime).toBe(mtime); + }); + + it("keeps a newer copy newer than an older one", () => { + const newer = entryWithModifiedTimes([Date.UTC(2026, 8, 15)]); + const older = entryWithModifiedTimes([Date.UTC(2026, 8, 2)]); + expect(newer.mtime).toBeGreaterThan(older.mtime); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.ts b/src/features/ConfigSync/CmdConfigSync.ts index 4201385f..b560dba6 100644 --- a/src/features/ConfigSync/CmdConfigSync.ts +++ b/src/features/ConfigSync/CmdConfigSync.ts @@ -368,7 +368,8 @@ export class PluginDataExDisplayV2 { return this._version; } get mtime(): number { - return ~~this.files.reduce((a, b) => a + b.mtime, 0) / this.files.length; + // Millisecond timestamps exceed 32 bits, so `~~` would wrap them. + return Math.floor(this.files.reduce((a, b) => a + b.mtime, 0) / this.files.length); } } export type PluginDataEx = { From f1746fa4b864197d297467e70511b6ae50940b78 Mon Sep 17 00:00:00 2001 From: Immick <12298761+Immick@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:03:51 +0300 Subject: [PATCH 2/6] fix(customisation-sync): hide identical items with "Hide not applicable items" The checkbox only toggled a CSS class for rows labelled "All the same or non-existent", while `hideNotApplicable`, which filters the offered devices by whether their copy can be applied, was hard-coded to false. Items present on another device therefore stayed visible even when every copy was the same. Drive `hideNotApplicable` from the checkbox, so the comparison only runs while it is on, and pass it to the reactive update so toggling it re-evaluates the rows. The term selection moves into PluginTerms.ts so it can be unit-tested. Fixes #1193 --- src/features/ConfigSync/PluginCombo.svelte | 31 +++++------ src/features/ConfigSync/PluginPane.svelte | 3 +- src/features/ConfigSync/PluginTerms.ts | 32 +++++++++++ .../ConfigSync/PluginTerms.unit.spec.ts | 54 +++++++++++++++++++ 4 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 src/features/ConfigSync/PluginTerms.ts create mode 100644 src/features/ConfigSync/PluginTerms.unit.spec.ts diff --git a/src/features/ConfigSync/PluginCombo.svelte b/src/features/ConfigSync/PluginCombo.svelte index 7daba34a..91798795 100644 --- a/src/features/ConfigSync/PluginCombo.svelte +++ b/src/features/ConfigSync/PluginCombo.svelte @@ -12,6 +12,7 @@ // import { askString } from "../../common/utils"; import { Menu } from "@/deps.ts"; import { $msg as translateMessage } from "@/common/translation"; + import { selectSourceTerms } from "./PluginTerms.ts"; export let list: IPluginDataExDisplay[] = []; export let thisTerm = ""; @@ -182,24 +183,20 @@ } } - async function updateTerms(list: IPluginDataExDisplay[], selectNewest: boolean, isMaintenanceMode: boolean) { + async function updateTerms( + list: IPluginDataExDisplay[], + selectNewest: boolean, + isMaintenanceMode: boolean, + hideNotApplicable: boolean + ) { const local = list.find((e) => e.term == thisTerm); // selected = ""; - if (isMaintenanceMode) { - terms = [...new Set(list.map((e) => e.term))]; - } else if (hideNotApplicable) { - const termsTmp = []; - const wk = [...new Set(list.map((e) => e.term))]; - for (const termName of wk) { - const remote = list.find((e) => e.term == termName); - if ((await comparePlugin(local, remote)).canApply) { - termsTmp.push(termName); - } - } - terms = [...termsTmp]; - } else { - terms = [...new Set(list.map((e) => e.term))].filter((e) => e != thisTerm); - } + terms = await selectSourceTerms( + list, + thisTerm, + { isMaintenanceMode, hideNotApplicable }, + async (local, remote) => (await comparePlugin(local, remote)).canApply + ); let newest: IPluginDataExDisplay | undefined = local; if (selectNewest) { for (const term of terms) { @@ -230,7 +227,7 @@ } // currentSelectNewest = selectNewest; } - updateTerms(list, doSelectNewest, isMaintenanceMode); + updateTerms(list, doSelectNewest, isMaintenanceMode, hideNotApplicable); currentSelectNewest = selectNewest; } $: { diff --git a/src/features/ConfigSync/PluginPane.svelte b/src/features/ConfigSync/PluginPane.svelte index a6be2a6e..18a65109 100644 --- a/src/features/ConfigSync/PluginPane.svelte +++ b/src/features/ConfigSync/PluginPane.svelte @@ -32,7 +32,8 @@ export let core :LiveSyncBaseCore; // $: core = plugin.core; - $: hideNotApplicable = false; + // Comparing every copy is only worth it while the user asks to hide the unchanged ones. + $: hideNotApplicable = hideEven; $: thisTerm = core.services.setting.getDeviceAndVaultName(); const addOn = core.getAddOn(ConfigSync.name)!; diff --git a/src/features/ConfigSync/PluginTerms.ts b/src/features/ConfigSync/PluginTerms.ts new file mode 100644 index 00000000..71c1d503 --- /dev/null +++ b/src/features/ConfigSync/PluginTerms.ts @@ -0,0 +1,32 @@ +import type { IPluginDataExDisplay } from "./CmdConfigSync.ts"; + +export type CanApplyFrom = ( + local: IPluginDataExDisplay | undefined, + remote: IPluginDataExDisplay | undefined +) => Promise; + +/** + * Devices offered as sources for one item in the Customisation Sync dialogue. + * + * Maintenance mode lists every device, including this one. Otherwise every other + * device that has the item is listed; with `hideNotApplicable`, only devices whose + * copy can actually be applied remain, so an item that is the same everywhere ends + * up with no source and is shown as "All the same or non-existent". + */ +export async function selectSourceTerms( + list: IPluginDataExDisplay[], + thisTerm: string, + options: { isMaintenanceMode: boolean; hideNotApplicable: boolean }, + canApplyFrom: CanApplyFrom +): Promise { + const terms = [...new Set(list.map((e) => e.term))]; + if (options.isMaintenanceMode) return terms; + if (!options.hideNotApplicable) return terms.filter((term) => term != thisTerm); + const local = list.find((e) => e.term == thisTerm); + const applicable: string[] = []; + for (const term of terms) { + const remote = list.find((e) => e.term == term); + if (await canApplyFrom(local, remote)) applicable.push(term); + } + return applicable; +} diff --git a/src/features/ConfigSync/PluginTerms.unit.spec.ts b/src/features/ConfigSync/PluginTerms.unit.spec.ts new file mode 100644 index 00000000..f6fd714a --- /dev/null +++ b/src/features/ConfigSync/PluginTerms.unit.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import type { IPluginDataExDisplay } from "./CmdConfigSync.ts"; +import { selectSourceTerms } from "./PluginTerms.ts"; + +const copyOn = (term: string) => ({ term, files: [] }) as unknown as IPluginDataExDisplay; +const list = [copyOn("desktop"), copyOn("phone"), copyOn("tablet")]; +const differsOn = + (...terms: string[]) => + (_local: IPluginDataExDisplay | undefined, remote: IPluginDataExDisplay | undefined) => + Promise.resolve(terms.includes(remote?.term ?? "")); + +describe("selectSourceTerms", () => { + it("offers every other device without comparing copies by default", async () => { + const canApplyFrom = vi.fn(differsOn()); + const terms = await selectSourceTerms( + list, + "desktop", + { isMaintenanceMode: false, hideNotApplicable: false }, + canApplyFrom + ); + expect(terms).toEqual(["phone", "tablet"]); + expect(canApplyFrom).not.toHaveBeenCalled(); + }); + + it("offers every device, including this one, in maintenance mode", async () => { + const terms = await selectSourceTerms( + list, + "desktop", + { isMaintenanceMode: true, hideNotApplicable: true }, + differsOn() + ); + expect(terms).toEqual(["desktop", "phone", "tablet"]); + }); + + it("leaves out devices whose copy is the same when hiding items that are not applicable", async () => { + const terms = await selectSourceTerms( + list, + "desktop", + { isMaintenanceMode: false, hideNotApplicable: true }, + differsOn("tablet") + ); + expect(terms).toEqual(["tablet"]); + }); + + it("offers no source for an item that is the same on every device", async () => { + const terms = await selectSourceTerms( + list, + "desktop", + { isMaintenanceMode: false, hideNotApplicable: true }, + differsOn() + ); + expect(terms).toEqual([]); + }); +}); From 368274421a23f2e2d99e7114db0f61aab3b49b80 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 30 Sep 2026 16:26:46 +0000 Subject: [PATCH 3/6] Cover multi-file Customisation Sync freshness in real Obsidian --- test/e2e-obsidian/README.md | 2 + .../scripts/customisation-sync.ts | 147 +++++++++++++++++- 2 files changed, 141 insertions(+), 8 deletions(-) diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 5c0a8589..37e83f17 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -219,6 +219,8 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `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. +The workflow also opens the Customisation Sync dialogue. `--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, this check runs before the existing apply, update, and deletion workflow. + `test:e2e:obsidian:remote-feature-change` starts real Obsidian with continuous CouchDB replication, then changes the remote version document from generation 12 to generation 13 with an unknown feature. It waits for the control document to reach the local database and the active Replicator to retire, checks that another replication is refused, and verifies that an already accepted Vault note remains intact. After restarting the same Vault, the current remote declaration still blocks finite replication and the actual continuous connection attempt. No KV feature history is involved. It is a focused test outside `test:e2e:obsidian:local-suite`; recovery with a future compatible client remains a separate validation boundary. `test:e2e:obsidian:internal-metadata-migration` enables internal Metadata encryption through the settings UI without Rebuild. It checks unchanged plaintext and rewritten encrypted Hidden File Sync and Customisation Sync Metadata in CouchDB, stable document IDs, mismatch rejection on a second device, and file restoration after aligning settings. It then turns the preference OFF, runs Fast Fetch, and compares content loaded from both Metadata representations and their Chunks while retaining the remote feature declaration. These focused tests use the local CouchDB fixture and are outside `test:e2e:obsidian:local-suite`. diff --git a/test/e2e-obsidian/scripts/customisation-sync.ts b/test/e2e-obsidian/scripts/customisation-sync.ts index 5fa2fdcb..0bcfc6a3 100644 --- a/test/e2e-obsidian/scripts/customisation-sync.ts +++ b/test/e2e-obsidian/scripts/customisation-sync.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication"; @@ -25,6 +25,7 @@ import { waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; @@ -80,8 +81,23 @@ const pluginMainContent = [ "", ].join("\n"); const pluginStylesContent = ".livesync-e2e-sample { color: #73548f; }\n"; +const targetPluginStylesContent = ".livesync-e2e-sample { color: #3d6f54; }\n"; +// These dates straddle a signed 32-bit wrap boundary for millisecond timestamps. +const sourcePluginMtime = new Date("2026-09-01T12:00:00.000Z"); +const targetPluginMtime = new Date("2026-09-15T12:00:00.000Z"); const sourceDeviceName = "customisation-sync-a"; const targetDeviceName = "customisation-sync-b"; +const pluginFixtures = [ + { path: pluginManifestPath, source: pluginManifestContent, target: pluginManifestContent }, + { path: pluginMainPath, source: pluginMainContent, target: pluginMainContent }, + { path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent }, +] as const; + +type CustomisationSyncCase = "all" | "mtime"; + +type CustomisationSyncTestGlobal = typeof globalThis & { + app?: { commands?: { executeCommandById(commandId: string): boolean } }; +}; type RunnerContext = { binary: string; @@ -110,6 +126,67 @@ async function writeVaultFile(vaultPath: string, path: string, content: string): await writeFile(fullPath, content, "utf-8"); } +async function setVaultFileMtime(vaultPath: string, path: string, mtime: Date): Promise { + const fullPath = join(vaultPath, path); + await utimes(fullPath, mtime, mtime); +} + +function selectedCase(): CustomisationSyncCase { + const args = process.argv.slice(2); + if (args.length === 0) return "all"; + const arg = args[0]; + if (args.length !== 1 || !arg?.startsWith("--case=")) { + throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|mtime]"); + } + const selected = arg.slice("--case=".length); + if (selected === "all" || selected === "mtime") return selected; + throw new Error(`Unknown Customisation Sync E2E case: ${selected}`); +} + +async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyncSession): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const opened = await page.evaluate(() => + (globalThis as CustomisationSyncTestGlobal).app?.commands?.executeCommandById( + "obsidian-livesync:livesync-plugin-dialog-ex" + ) + ); + if (opened !== true) throw new Error("Could not open the Customisation Sync dialogue command."); + const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)"); + const pluginMainRow = dialogue.locator(`.filerow:has(select option[value="${sourceDeviceName}"])`).first(); + const sourceOption = pluginMainRow.locator(`select option[value="${sourceDeviceName}"]`); + const sourceSelect = pluginMainRow.locator("select"); + const failures: string[] = []; + + await sourceOption.waitFor({ state: "attached", timeout: 10000 }); + await sourceSelect.selectOption(sourceDeviceName); + try { + await pluginMainRow.locator(".chip.modified").filter({ hasText: "Older" }).waitFor({ + state: "visible", + timeout: 10000, + }); + } catch { + const freshness = (await pluginMainRow.locator(".chip.modified").textContent())?.trim() || "(empty)"; + failures.push(`Expected the remote multi-file plug-in copy to be Older; found '${freshness}'.`); + } + + await dialogue.getByRole("button", { name: "Deselect all", exact: true }).click(); + await sourceSelect.waitFor({ state: "visible", timeout: 10000 }); + await page.waitForTimeout(50); + if ((await sourceSelect.inputValue()) !== "") { + throw new Error("Deselect all did not clear the selected Customisation Sync source."); + } + + await dialogue.getByRole("button", { name: "Select All Shiny", exact: true }).click(); + await page.waitForTimeout(100); + if ((await sourceSelect.inputValue()) !== "") { + failures.push("Select All Shiny chose the older multi-file plug-in copy."); + } + if (failures.length > 0) throw new Error(failures.join("\n")); + await page.keyboard.press("Escape"); + await dialogue.waitFor({ state: "hidden", timeout: 10000 }); + }); +} + async function removeVaultFile(vaultPath: string, path: string): Promise { await rm(join(vaultPath, path), { force: true }); } @@ -437,6 +514,8 @@ async function applyRemoteCustomisationGroup( } async function main(): Promise { + const testCase = selectedCase(); + const testMtime = testCase === "all" || testCase === "mtime"; const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); if (!cli.binary) { @@ -467,15 +546,26 @@ async function main(): Promise { await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent); await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent); await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent); + if (testMtime) { + for (const fixture of pluginFixtures) { + await setVaultFileMtime(vaultA.path, fixture.path, sourcePluginMtime); + } + } + if (testMtime) { + for (const fixture of pluginFixtures) { + await writeVaultFile(vaultB.path, fixture.path, fixture.target); + await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime); + } + } let session = await startConfiguredSession(context, vaultA, sourceDeviceName); const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv); console.log(`Customisation scan files: ${scanResult.files.join(", ") || "(none)"}`); await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath); await storeCustomisationFile(context.cliBinary, session.cliEnv, configPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginManifestPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginMainPath); - await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginStylesPath); + for (const fixture of pluginFixtures) { + await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); + } const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName); const pluginEntries = await waitForCustomisationEntries( @@ -495,21 +585,62 @@ async function main(): Promise { }); for (const target of [entry, configEntry, ...pluginEntries]) { const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id); - if (!remoteEntry.path?.startsWith("/\\:") || remoteEntry.children?.length !== 0 || - remoteEntry.ctime !== 0 || remoteEntry.mtime !== 0 || remoteEntry.size !== 0) { + if ( + !remoteEntry.path?.startsWith("/\\:") || + remoteEntry.children?.length !== 0 || + remoteEntry.ctime !== 0 || + remoteEntry.mtime !== 0 || + remoteEntry.size !== 0 + ) { throw new Error(`Customisation Sync Metadata was not encrypted for ${target.id}.`); } } const versionInfo = await fetchCouchDbDocument(context.couchDb, context.dbName, VERSIONING_DOCID); - if (versionInfo.version !== REMOTE_FEATURE_GENERATION || - !(versionInfo.used_features as unknown[] | undefined)?.includes(ENCRYPTED_INTERNAL_METADATA_FEATURE)) { + if ( + versionInfo.version !== REMOTE_FEATURE_GENERATION || + !(versionInfo.used_features as unknown[] | undefined)?.includes(ENCRYPTED_INTERNAL_METADATA_FEATURE) + ) { throw new Error("The remote feature list does not declare encrypted internal Metadata."); } await session.app.stop(); session = await startConfiguredSession(context, vaultB, targetDeviceName); + if (testMtime) { + for (const fixture of pluginFixtures) { + await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); + } + } await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName); + if (testMtime) { + await waitForCustomisationEntries( + context.cliBinary, + session.cliEnv, + "PLUGIN_MAIN", + pluginName, + 3, + sourceDeviceName + ); + await waitForCustomisationEntries( + context.cliBinary, + session.cliEnv, + "PLUGIN_MAIN", + pluginName, + 3, + targetDeviceName + ); + } + try { + if (testMtime) await inspectPluginFreshnessAndNewestSelection(session); + } catch (error) { + await session.app.stop().catch(() => undefined); + throw error; + } + if (testCase !== "all") { + await session.app.stop(); + console.log(`Customisation Sync ${testCase} regression case passed.`); + return; + } assertEqual( await pathExists(vaultB.path, snippetPath), false, From 009bbcb37c0480e059b90f816130de7836d05c32 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 30 Sep 2026 16:42:43 +0000 Subject: [PATCH 4/6] Cover Customisation Sync visibility in real Obsidian --- test/e2e-obsidian/README.md | 2 +- .../scripts/customisation-sync.ts | 95 ++++++++++++++++++- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 62608bbe..f2f4167e 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -219,7 +219,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `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. -The workflow also opens the Customisation Sync dialogue. `--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, this check runs 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. `test:e2e:obsidian:received-change-readiness` uses two sequential real Obsidian sessions and isolated CouchDB databases. The source creates ordinary notes and their Chunks; the target starts continuous replication, resets readiness through the public lifecycle service, and receives those documents while its Vault files remain absent. Marking the target ready twice must emit one readiness event and reflect the queued content. A second note remains absent after readiness while database reflecting is explicitly suspended, then appears when that setting is resumed. This focused scenario is outside `test:e2e:obsidian:local-suite`. diff --git a/test/e2e-obsidian/scripts/customisation-sync.ts b/test/e2e-obsidian/scripts/customisation-sync.ts index 0bcfc6a3..78fd7a96 100644 --- a/test/e2e-obsidian/scripts/customisation-sync.ts +++ b/test/e2e-obsidian/scripts/customisation-sync.ts @@ -27,6 +27,7 @@ import { import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; +import type { Locator } from "playwright"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; @@ -58,6 +59,11 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample"; const pluginManifestPath = `${pluginDir}/manifest.json`; const pluginMainPath = `${pluginDir}/main.js`; const pluginStylesPath = `${pluginDir}/styles.css`; +const identicalSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-identical.css"; +const identicalSnippetContent = ".livesync-customisation-identical { color: #3d6f54; }\n"; +const differentSnippetPath = ".obsidian/snippets/livesync-customisation-e2e-different.css"; +const sourceDifferentSnippetContent = ".livesync-customisation-different { color: #73548f; }\n"; +const targetDifferentSnippetContent = ".livesync-customisation-different { color: #3d6f54; }\n"; const pluginManifestContent = JSON.stringify( { @@ -85,15 +91,20 @@ const targetPluginStylesContent = ".livesync-e2e-sample { color: #3d6f54; }\n"; // These dates straddle a signed 32-bit wrap boundary for millisecond timestamps. const sourcePluginMtime = new Date("2026-09-01T12:00:00.000Z"); const targetPluginMtime = new Date("2026-09-15T12:00:00.000Z"); +const matchingMtime = new Date("2026-09-15T12:00:00.000Z"); const sourceDeviceName = "customisation-sync-a"; const targetDeviceName = "customisation-sync-b"; +const visibilityFixtures = [ + { path: identicalSnippetPath, source: identicalSnippetContent, target: identicalSnippetContent }, + { path: differentSnippetPath, source: sourceDifferentSnippetContent, target: targetDifferentSnippetContent }, +] as const; const pluginFixtures = [ { path: pluginManifestPath, source: pluginManifestContent, target: pluginManifestContent }, { path: pluginMainPath, source: pluginMainContent, target: pluginMainContent }, { path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent }, ] as const; -type CustomisationSyncCase = "all" | "mtime"; +type CustomisationSyncCase = "all" | "visibility" | "mtime"; type CustomisationSyncTestGlobal = typeof globalThis & { app?: { commands?: { executeCommandById(commandId: string): boolean } }; @@ -136,13 +147,59 @@ function selectedCase(): CustomisationSyncCase { if (args.length === 0) return "all"; const arg = args[0]; if (args.length !== 1 || !arg?.startsWith("--case=")) { - throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|mtime]"); + throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime]"); } const selected = arg.slice("--case=".length); - if (selected === "all" || selected === "mtime") return selected; + if (selected === "all" || selected === "visibility" || selected === "mtime") return selected; throw new Error(`Unknown Customisation Sync E2E case: ${selected}`); } +async function assertSourceCandidate(row: Locator, term: string, name: string): Promise { + const candidate = row.locator(`select option[value="${term}"]`); + try { + await candidate.waitFor({ state: "attached", timeout: 10000 }); + } catch { + throw new Error(`Customisation Sync ${name} source candidate was not available: ${term}`); + } +} + +async function inspectCustomisationVisibility(session: ObsidianLiveSyncSession): Promise { + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const opened = await page.evaluate(() => + (globalThis as CustomisationSyncTestGlobal).app?.commands?.executeCommandById( + "obsidian-livesync:livesync-plugin-dialog-ex" + ) + ); + if (opened !== true) throw new Error("Could not open the Customisation Sync dialogue command."); + const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)"); + const identicalName = identicalSnippetPath.split("/").pop() ?? identicalSnippetPath; + const differentName = differentSnippetPath.split("/").pop() ?? differentSnippetPath; + const identicalRow = dialogue.locator(".labelrow").filter({ hasText: identicalName }).first(); + const differentRow = dialogue.locator(".labelrow").filter({ hasText: differentName }).first(); + const hideCheckbox = dialogue + .locator("label") + .filter({ hasText: "Hide not applicable items" }) + .locator('input[type="checkbox"]'); + + await identicalRow.waitFor({ state: "visible", timeout: 10000 }); + await differentRow.waitFor({ state: "visible", timeout: 10000 }); + await assertSourceCandidate(identicalRow, sourceDeviceName, "identical item"); + await assertSourceCandidate(differentRow, sourceDeviceName, "different item"); + if (await hideCheckbox.isChecked()) throw new Error("Hide not applicable items started checked."); + + await hideCheckbox.check(); + await identicalRow.waitFor({ state: "hidden", timeout: 10000 }); + await differentRow.waitFor({ state: "visible", timeout: 10000 }); + await assertSourceCandidate(differentRow, sourceDeviceName, "different item while hiding identical items"); + + await hideCheckbox.uncheck(); + await identicalRow.waitFor({ state: "visible", timeout: 10000 }); + await assertSourceCandidate(identicalRow, sourceDeviceName, "identical item after unhiding"); + await page.keyboard.press("Escape"); + await dialogue.waitFor({ state: "hidden", timeout: 10000 }); + }); +} + async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyncSession): Promise { await withObsidianPage(session.remoteDebuggingPort, async (page) => { const opened = await page.evaluate(() => @@ -515,6 +572,7 @@ async function applyRemoteCustomisationGroup( async function main(): Promise { const testCase = selectedCase(); + const testVisibility = testCase === "all" || testCase === "visibility"; const testMtime = testCase === "all" || testCase === "mtime"; const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -546,11 +604,23 @@ async function main(): Promise { await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent); await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent); await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent); + if (testVisibility) { + for (const fixture of visibilityFixtures) { + await writeVaultFile(vaultA.path, fixture.path, fixture.source); + await setVaultFileMtime(vaultA.path, fixture.path, matchingMtime); + } + } if (testMtime) { for (const fixture of pluginFixtures) { await setVaultFileMtime(vaultA.path, fixture.path, sourcePluginMtime); } } + if (testVisibility) { + for (const fixture of visibilityFixtures) { + await writeVaultFile(vaultB.path, fixture.path, fixture.target); + await setVaultFileMtime(vaultB.path, fixture.path, matchingMtime); + } + } if (testMtime) { for (const fixture of pluginFixtures) { await writeVaultFile(vaultB.path, fixture.path, fixture.target); @@ -566,6 +636,11 @@ async function main(): Promise { for (const fixture of pluginFixtures) { await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); } + if (testVisibility) { + for (const fixture of visibilityFixtures) { + await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); + } + } const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName); const pluginEntries = await waitForCustomisationEntries( @@ -605,6 +680,11 @@ async function main(): Promise { await session.app.stop(); session = await startConfiguredSession(context, vaultB, targetDeviceName); + if (testVisibility) { + for (const fixture of visibilityFixtures) { + await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); + } + } if (testMtime) { for (const fixture of pluginFixtures) { await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path); @@ -612,6 +692,14 @@ async function main(): Promise { } await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName); + if (testVisibility) { + for (const fixture of visibilityFixtures) { + const name = fixture.path.split("/").pop() ?? fixture.path; + for (const term of [sourceDeviceName, targetDeviceName]) { + await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", name, term); + } + } + } if (testMtime) { await waitForCustomisationEntries( context.cliBinary, @@ -631,6 +719,7 @@ async function main(): Promise { ); } try { + if (testVisibility) await inspectCustomisationVisibility(session); if (testMtime) await inspectPluginFreshnessAndNewestSelection(session); } catch (error) { await session.app.stop().catch(() => undefined); From ec4a8f919872de2faea05b656e8ff024340b28b2 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 30 Sep 2026 17:06:07 +0000 Subject: [PATCH 5/6] Document the Customisation Sync timestamp correction --- updates.md | 1 + 1 file changed, 1 insertion(+) diff --git a/updates.md b/updates.md index e036efa9..d1b2096d 100644 --- a/updates.md +++ b/updates.md @@ -42,6 +42,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### Fixed - Received changes held during start-up or a fetch are applied when LiveSync becomes ready, without waiting for another change or a settings save. **Suspend database reflecting** continues to hold changes (#1200). +- Customisation Sync now compares full millisecond timestamps, so the freshness labels and **Select All Shiny** no longer mistake an older copy for a newer one because of timestamp truncation. (#1194) ## 1.0.32 From db4887bcef082e55e36bfc2f652a356f3381a05e Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 30 Sep 2026 17:07:53 +0000 Subject: [PATCH 6/6] Document the Customisation Sync visibility correction --- updates.md | 1 + 1 file changed, 1 insertion(+) diff --git a/updates.md b/updates.md index d1b2096d..b1212644 100644 --- a/updates.md +++ b/updates.md @@ -43,6 +43,7 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - Received changes held during start-up or a fetch are applied when LiveSync becomes ready, without waiting for another change or a settings save. **Suspend database reflecting** continues to hold changes (#1200). - Customisation Sync now compares full millisecond timestamps, so the freshness labels and **Select All Shiny** no longer mistake an older copy for a newer one because of timestamp truncation. (#1194) +- **Hide not applicable items** now hides identical Customisation Sync items and refreshes the list when toggled. Items with applicable differences stay visible. (#1193) ## 1.0.32