From e916683b8df3d5ccd6e9f30a2b571748e1e1c52d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 26 Jun 2026 12:16:39 +0000 Subject: [PATCH] (test): customisation sync 2 --- docs/adr/2026_06_real_obsidian_e2e.md | 6 +- test/e2e-obsidian/README.md | 2 +- .../scripts/customisation-sync.ts | 329 ++++++++++++++++-- 3 files changed, 306 insertions(+), 31 deletions(-) diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index eefe4e3..0e83884 100644 --- a/docs/adr/2026_06_real_obsidian_e2e.md +++ b/docs/adr/2026_06_real_obsidian_e2e.md @@ -144,13 +144,13 @@ Current verification: - `npm run test:e2e:obsidian:startup-scan` verifies that a file written while Obsidian is stopped is picked up during the next real Obsidian boot and uploaded to CouchDB after one-shot synchronisation. - `npm run test:e2e:obsidian:two-vault-sync` verifies two-vault note synchronisation: creation, update, deletion, Markdown conflict automatic merging with the merged result propagated by a second synchronisation, and per-device target-filter differences. - `npm run test:e2e:obsidian:hidden-file-snippet-sync` verifies hidden file synchronisation as a two-vault round-trip: creation, deletion, automatic JSON conflict merging with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target-pattern differences. -- `npm run test:e2e:obsidian:customisation-sync` verifies a two-vault Customisation Sync snippet workflow: scan a real snippet CSS file into per-file Customisation Sync data, synchronise it through CouchDB, apply it on the second vault, and assert the resulting `.obsidian/snippets/*.css` file. +- `npm run test:e2e:obsidian:customisation-sync` verifies a two-vault Customisation Sync workflow: scan a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronise them through CouchDB, apply them on the second vault, assert the resulting `.obsidian` files, propagate a snippet update, and verify deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. - `npm run test:e2e:obsidian:setting-markdown-export` verifies that setting Markdown export creates a vault file and omits credentials when credential export is disabled. - `npm run test:e2e:obsidian:install-appimage` reuses the existing AppImage and extracted binary when they are already present. Known limits: -- The smoke runner currently proves only one-vault launch and plug-in load readiness. It does not yet exercise synchronisation, settings persistence, restart behaviour, or database writes. +- The smoke runner currently proves only one-vault launch and plug-in load readiness. Broader workflows are covered by separate real Obsidian scripts, including CouchDB upload, startup scan, two-vault note synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export. - Cross-platform support is still discovery-level. The working path has been validated on Linux ARM64. - CI wiring is not yet implemented. CI should use `OBSIDIAN_BINARY` or a cached `_testdata/obsidian/squashfs-root` rather than downloading the AppImage on every run. @@ -169,7 +169,7 @@ Current implementation status: - Added a pre-CouchDB workflow that creates a note through Obsidian's vault API, confirms the note is reflected as a real vault file, and reads the same note back through Obsidian. This covers the vault reflection part of the Phase 2 path before remote database setup is introduced. - Added a first CouchDB-backed upload workflow, modelled after the CLI Deno tests: reuse the standard CouchDB environment variables, create a unique remote database, apply CouchDB settings through the plug-in's setting service, commit the note through the real Obsidian vault path, run one-shot synchronisation, and assert that remote metadata and chunks exist. -- Added Obsidian-specific workflows for boot-time vault scanning, two-vault note synchronisation, hidden `.obsidian/snippets` file round-tripping, hidden JSON conflict resolution, Customisation Sync snippet application, per-device target-filter differences, and setting Markdown export. These scenarios assert against CouchDB documents, vault files, or real Obsidian UI outcomes instead of internal service state. +- Added Obsidian-specific workflows for boot-time vault scanning, two-vault note synchronisation, hidden `.obsidian/snippets` file round-tripping, hidden JSON conflict resolution, Customisation Sync application for snippets, config JSON files, and plug-in fixtures, per-device target-filter differences, and setting Markdown export. These scenarios assert against CouchDB documents, vault files, or real Obsidian UI outcomes instead of internal service state. ### Phase 3: Two-Vault Synchronisation diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 6bf895d..c95e52b 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -62,7 +62,7 @@ npm run test:e2e:obsidian:setting-markdown-export `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. -`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file into per-file Customisation Sync data, synchronises the entry through CouchDB, applies it on the second vault, and verifies the resulting `.obsidian/snippets/*.css` file. +`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:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`. diff --git a/test/e2e-obsidian/scripts/customisation-sync.ts b/test/e2e-obsidian/scripts/customisation-sync.ts index 03c086c..52bcd5e 100644 --- a/test/e2e-obsidian/scripts/customisation-sync.ts +++ b/test/e2e-obsidian/scripts/customisation-sync.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { evalObsidianJson } from "../runner/cli.ts"; import { @@ -35,6 +35,47 @@ const snippetContent = [ "}", "", ].join("\n"); +const snippetUpdatedContent = [ + "body {", + " --livesync-customisation-e2e-colour: #73548f;", + "}", + "", + ".livesync-customisation-e2e {", + " background-color: var(--livesync-customisation-e2e-colour);", + "}", + "", +].join("\n"); +const configPath = ".obsidian/livesync-customisation-e2e.json"; +const configContent = JSON.stringify({ source: "customisation-sync", enabled: true }, null, 4) + "\n"; +const pluginDir = ".obsidian/plugins/livesync-e2e-sample"; +const pluginManifestPath = `${pluginDir}/manifest.json`; +const pluginMainPath = `${pluginDir}/main.js`; +const pluginStylesPath = `${pluginDir}/styles.css`; +const pluginManifestContent = + JSON.stringify( + { + id: "livesync-e2e-sample", + name: "LiveSync E2E Sample", + version: "0.0.1", + minAppVersion: "1.0.0", + description: "A sample plug-in fixture for real Obsidian E2E.", + author: "Self-hosted LiveSync", + isDesktopOnly: false, + }, + null, + 4 + ) + "\n"; +const pluginMainContent = [ + "module.exports = class LiveSyncE2ESamplePlugin extends Plugin {", + " async onload() {", + " this.register(() => undefined);", + " }", + "};", + "", +].join("\n"); +const pluginStylesContent = ".livesync-e2e-sample { color: #73548f; }\n"; +const sourceDeviceName = "customisation-sync-a"; +const targetDeviceName = "customisation-sync-b"; type RunnerContext = { binary: string; @@ -63,6 +104,10 @@ async function writeVaultFile(vaultPath: string, path: string, content: string): await writeFile(fullPath, content, "utf-8"); } +async function removeVaultFile(vaultPath: string, path: string): Promise { + await rm(join(vaultPath, path), { force: true }); +} + async function readVaultFile(vaultPath: string, path: string): Promise { return await readFile(join(vaultPath, path), "utf-8"); } @@ -169,7 +214,7 @@ async function scanCustomisations(cliBinary: string, env: NodeJS.ProcessEnv): Pr ); } -async function storeCustomisationSnippet(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { +async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { await evalObsidianJson( cliBinary, [ @@ -184,7 +229,7 @@ async function storeCustomisationSnippet(cliBinary: string, env: NodeJS.ProcessE "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", "const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>doc.path);", "if(!result){", - " throw new Error(`Could not store Customisation Sync snippet: path=${path}; term=${term}; category=${category}; stat=${JSON.stringify(stat)}; result=${JSON.stringify(result)}; entries=${JSON.stringify(entries)}`);", + " throw new Error(`Could not store Customisation Sync file: path=${path}; term=${term}; category=${category}; stat=${JSON.stringify(stat)}; result=${JSON.stringify(result)}; entries=${JSON.stringify(entries)}`);", "}", "return JSON.stringify({ok:true,path,term,category,entries});", "})()", @@ -193,17 +238,102 @@ async function storeCustomisationSnippet(cliBinary: string, env: NodeJS.ProcessE ); } +async function deleteCustomisationSyncEntry( + cliBinary: string, + env: NodeJS.ProcessEnv, + category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN", + name: string, + term?: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const category=${JSON.stringify(category)};`, + `const name=${JSON.stringify(name)};`, + `const term=${JSON.stringify(term ?? "")};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('ConfigSync');", + "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", + "const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&!doc.deleted&&!doc._deleted)||false;", + "if(!entry) throw new Error(`Could not find customisation sync entry to delete: ${category}/${name}`);", + "if(!(await addOn.deleteConfigOnDatabase(entry.path))){", + " throw new Error(`Could not delete Customisation Sync entry: ${entry.path}`);", + "}", + "return JSON.stringify({ok:true,path:entry.path});", + "})()", + ].join(""), + env + ); +} + async function waitForCustomisationEntry( cliBinary: string, env: NodeJS.ProcessEnv, - filename: string, + category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN", + name: string, + term?: string, timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000) ): Promise { - return await evalObsidianJson( + const entries = await waitForCustomisationEntries(cliBinary, env, category, name, 1, term, timeoutMs); + return entries[0]; +} + +async function waitForCustomisationEntries( + cliBinary: string, + env: NodeJS.ProcessEnv, + category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN", + name: string, + count: number, + term?: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000) +): Promise { + return await evalObsidianJson( cliBinary, [ "(async()=>{", - `const filename=${JSON.stringify(filename)};`, + `const category=${JSON.stringify(category)};`, + `const name=${JSON.stringify(name)};`, + `const count=${JSON.stringify(count)};`, + `const term=${JSON.stringify(term ?? "")};`, + `const timeoutMs=${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const deadline=Date.now()+timeoutMs;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let entries=[];", + "while(Date.now()row.doc).filter((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&Array.isArray(doc.children)&&doc.children.length>0);", + " if(entries.length>=count) break;", + " await sleep(250);", + "}", + "if(entries.lengthrow.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>({id:doc._id,path:doc.path,children:doc.children?.length??0}));", + " throw new Error(`Timed out waiting for customisation sync entries: ${category}/${name}; expected=${count}; entries=${JSON.stringify(found)}`);", + "}", + "return JSON.stringify(entries.map((entry)=>({id:entry._id,path:entry.path,children:entry.children||[]})));", + "})()", + ].join(""), + env + ); +} + +async function waitForCustomisationEntryAbsent( + cliBinary: string, + env: NodeJS.ProcessEnv, + category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN", + name: string, + term?: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000) +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const category=${JSON.stringify(category)};`, + `const name=${JSON.stringify(name)};`, + `const term=${JSON.stringify(term ?? "")};`, `const timeoutMs=${JSON.stringify(timeoutMs)};`, "const core=app.plugins.plugins['obsidian-livesync'].core;", "const deadline=Date.now()+timeoutMs;", @@ -211,37 +341,36 @@ async function waitForCustomisationEntry( "let entry=false;", "while(Date.now()row.doc).find((doc)=>doc?.path?.includes('/SNIPPET/')&&doc.path?.endsWith(`%${filename}`))||false;", - " if(entry&&entry._id&&Array.isArray(entry.children)&&entry.children.length>0) break;", + " entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`))&&!doc.deleted&&!doc._deleted)||false;", + " if(!entry) return JSON.stringify({ok:true});", " await sleep(250);", "}", - "if(!entry||!entry._id){", - " const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", - " const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.startsWith('ix:')).map((doc)=>({id:doc._id,path:doc.path,children:doc.children?.length??0}));", - " throw new Error(`Timed out waiting for customisation sync entry: ${filename}; entries=${JSON.stringify(entries)}`);", - "}", - "return JSON.stringify({id:entry._id,path:entry.path,children:entry.children||[]});", + "throw new Error(`Timed out waiting for customisation sync entry deletion: ${category}/${name}; entry=${JSON.stringify(entry)}`);", "})()", ].join(""), env ); } -async function applyRemoteCustomisationSnippet( +async function applyRemoteCustomisationEntry( cliBinary: string, env: NodeJS.ProcessEnv, - filename: string + category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN", + name: string, + term?: string ): Promise { await evalObsidianJson( cliBinary, [ "(async()=>{", - `const filename=${JSON.stringify(filename)};`, + `const category=${JSON.stringify(category)};`, + `const name=${JSON.stringify(name)};`, + `const term=${JSON.stringify(term ?? "")};`, "const core=app.plugins.plugins['obsidian-livesync'].core;", "const addOn=core.getAddOn('ConfigSync');", "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", - "const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes('/SNIPPET/')&&doc.path?.endsWith(`%${filename}`))||false;", - "if(!entry) throw new Error(`Could not find remote customisation entry: ${filename}`);", + "const entry=rows.map((row)=>row.doc).find((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`)))||false;", + "if(!entry) throw new Error(`Could not find remote customisation entry: ${category}/${name}`);", "const display=addOn.createPluginDataFromV2(entry.path);", "if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entry.path}`);", "const file=await addOn.createPluginDataExFileV2(entry.path);", @@ -257,6 +386,42 @@ async function applyRemoteCustomisationSnippet( ); } +async function applyRemoteCustomisationGroup( + cliBinary: string, + env: NodeJS.ProcessEnv, + category: "PLUGIN_MAIN", + name: string, + term?: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const category=${JSON.stringify(category)};`, + `const name=${JSON.stringify(name)};`, + `const term=${JSON.stringify(term ?? "")};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('ConfigSync');", + "const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;", + "const entries=rows.map((row)=>row.doc).filter((doc)=>doc?.path?.includes(`/${category}/`)&&doc.path?.includes(`/${name}%`)&&(!term||doc.path?.startsWith(`ix:${term}/`)));", + "if(entries.length===0) throw new Error(`Could not find remote customisation entries: ${category}/${name}`);", + "const display=addOn.createPluginDataFromV2(entries[0].path);", + "if(!display) throw new Error(`Could not create Customisation Sync display entry: ${entries[0].path}`);", + "for(const entry of entries){", + " const file=await addOn.createPluginDataExFileV2(entry.path);", + " if(!file) throw new Error(`Could not load Customisation Sync file entry: ${entry.path}`);", + " await display.setFile(file);", + "}", + "if(!(await addOn.applyDataV2(display))){", + " throw new Error(`Could not apply Customisation Sync group: ${category}/${name}`);", + "}", + "return JSON.stringify({ok:true,count:entries.length});", + "})()", + ].join(""), + env + ); +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -271,6 +436,8 @@ async function main(): Promise { const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName }; const snippetPathParts = snippetPath.split("/"); const snippetName = snippetPathParts[snippetPathParts.length - 1] ?? snippetPath; + const configName = configPath.split("/").pop() ?? configPath; + const pluginName = pluginDir.split("/").pop() ?? pluginDir; try { await assertCouchDbReachable(couchDb); @@ -282,33 +449,141 @@ async function main(): Promise { console.log(`Temporary CouchDB database: ${dbName}`); await writeVaultFile(vaultA.path, snippetPath, snippetContent); + await writeVaultFile(vaultA.path, configPath, configContent); + await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent); + await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent); + await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent); - let session = await startConfiguredSession(context, vaultA, "customisation-sync-a"); + 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 storeCustomisationSnippet(context.cliBinary, session.cliEnv, snippetPath); - const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, snippetName); + 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); + const entry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); + const configEntry = await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName); + const pluginEntries = await waitForCustomisationEntries( + context.cliBinary, + session.cliEnv, + "PLUGIN_MAIN", + pluginName, + 3 + ); await pushLocalChanges(context.cliBinary, session.cliEnv); await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { const ids = new Set(docs.map((doc) => doc._id)); - return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + const entries = [entry, configEntry, ...pluginEntries]; + return entries.every( + (target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId)) + ); }); await session.app.stop(); - session = await startConfiguredSession(context, vaultB, "customisation-sync-b"); + session = await startConfiguredSession(context, vaultB, targetDeviceName); await pushLocalChanges(context.cliBinary, session.cliEnv); - await waitForCustomisationEntry(context.cliBinary, session.cliEnv, snippetName); + await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName); assertEqual( await pathExists(vaultB.path, snippetPath), false, "Customisation Sync snippet was reflected before explicit application." ); - await applyRemoteCustomisationSnippet(context.cliBinary, session.cliEnv, snippetName); + await applyRemoteCustomisationEntry( + context.cliBinary, + session.cliEnv, + "SNIPPET", + snippetName, + sourceDeviceName + ); const applied = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent); + await applyRemoteCustomisationEntry(context.cliBinary, session.cliEnv, "CONFIG", configName, sourceDeviceName); + const appliedConfig = await waitForPathContent(vaultB.path, configPath, (content) => content === configContent); + await applyRemoteCustomisationGroup( + context.cliBinary, + session.cliEnv, + "PLUGIN_MAIN", + pluginName, + sourceDeviceName + ); + const appliedPluginManifest = await waitForPathContent( + vaultB.path, + pluginManifestPath, + (content) => content === pluginManifestContent + ); + const appliedPluginMain = await waitForPathContent( + vaultB.path, + pluginMainPath, + (content) => content === pluginMainContent + ); + const appliedPluginStyles = await waitForPathContent( + vaultB.path, + pluginStylesPath, + (content) => content === pluginStylesContent + ); await session.app.stop(); assertEqual(applied, snippetContent, "Customisation Sync snippet content did not match after application."); - console.log(`Customisation Sync applied snippet ${snippetName} from the remote database.`); + assertEqual(appliedConfig, configContent, "Customisation Sync config content did not match after application."); + assertEqual( + appliedPluginManifest, + pluginManifestContent, + "Customisation Sync plug-in manifest did not match after application." + ); + assertEqual(appliedPluginMain, pluginMainContent, "Customisation Sync plug-in main file did not match."); + assertEqual(appliedPluginStyles, pluginStylesContent, "Customisation Sync plug-in stylesheet did not match."); + + await writeVaultFile(vaultA.path, snippetPath, snippetUpdatedContent); + session = await startConfiguredSession(context, vaultA, sourceDeviceName); + await storeCustomisationFile(context.cliBinary, session.cliEnv, snippetPath); + await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, targetDeviceName); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await applyRemoteCustomisationEntry( + context.cliBinary, + session.cliEnv, + "SNIPPET", + snippetName, + sourceDeviceName + ); + const updated = await waitForPathContent( + vaultB.path, + snippetPath, + (content) => content === snippetUpdatedContent + ); + await session.app.stop(); + assertEqual(updated, snippetUpdatedContent, "Updated Customisation Sync snippet did not apply."); + + await removeVaultFile(vaultA.path, snippetPath); + session = await startConfiguredSession(context, vaultA, sourceDeviceName); + await deleteCustomisationSyncEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName); + await waitForCustomisationEntryAbsent( + context.cliBinary, + session.cliEnv, + "SNIPPET", + snippetName, + sourceDeviceName + ); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await session.app.stop(); + + session = await startConfiguredSession(context, vaultB, targetDeviceName); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForCustomisationEntryAbsent( + context.cliBinary, + session.cliEnv, + "SNIPPET", + snippetName, + sourceDeviceName + ); + await session.app.stop(); + + console.log( + `Customisation Sync applied snippet, config, and plug-in fixtures, then propagated snippet update and sync-data deletion.` + ); } finally { await vaultA.dispose(); await vaultB.dispose();