mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-06-26 16:13:57 +00:00
(test): customisation sync
This commit is contained in:
@@ -125,7 +125,7 @@ Initial discovery on Linux ARM64 found that:
|
|||||||
Current implementation status:
|
Current implementation status:
|
||||||
|
|
||||||
- Added `test/e2e-obsidian/runner` helpers for Obsidian discovery, CLI discovery, temporary vault creation, plug-in installation, process launch, CLI execution, and readiness polling.
|
- Added `test/e2e-obsidian/runner` helpers for Obsidian discovery, CLI discovery, temporary vault creation, plug-in installation, process launch, CLI execution, and readiness polling.
|
||||||
- Added `test:e2e:obsidian:discover`, `test:e2e:obsidian:cli-help`, `test:e2e:obsidian:smoke`, `test:e2e:obsidian:vault-reflection`, `test:e2e:obsidian:couchdb-upload`, `test:e2e:obsidian:startup-scan`, `test:e2e:obsidian:hidden-file-snippet-sync`, `test:e2e:obsidian:setting-markdown-export`, and `test:e2e:obsidian:install-appimage`.
|
- Added `test:e2e:obsidian:discover`, `test:e2e:obsidian:cli-help`, `test:e2e:obsidian:smoke`, `test:e2e:obsidian:vault-reflection`, `test:e2e:obsidian:couchdb-upload`, `test:e2e:obsidian:startup-scan`, `test:e2e:obsidian:two-vault-sync`, `test:e2e:obsidian:hidden-file-snippet-sync`, `test:e2e:obsidian:customisation-sync`, `test:e2e:obsidian:setting-markdown-export`, and `test:e2e:obsidian:install-appimage`.
|
||||||
- Added `startObsidianLiveSyncSession()` so future workflows can reuse the launch, vault open, community plug-in enablement, plug-in reload, and readiness sequence without duplicating smoke runner code.
|
- Added `startObsidianLiveSyncSession()` so future workflows can reuse the launch, vault open, community plug-in enablement, plug-in reload, and readiness sequence without duplicating smoke runner code.
|
||||||
- Added CouchDB runner utilities that reuse `.test.env`/process environment values, create unique temporary databases, query uploaded documents directly, and clean up the database unless `E2E_OBSIDIAN_KEEP_COUCHDB=true` is set.
|
- Added CouchDB runner utilities that reuse `.test.env`/process environment values, create unique temporary databases, query uploaded documents directly, and clean up the database unless `E2E_OBSIDIAN_KEEP_COUCHDB=true` is set.
|
||||||
- Added a manual AppImage installer that downloads Obsidian `1.12.7` for `arm64` or `x86_64`, stores it under `_testdata/obsidian`, and extracts it for FUSE-free execution.
|
- Added a manual AppImage installer that downloads Obsidian `1.12.7` for `arm64` or `x86_64`, stores it under `_testdata/obsidian`, and extracts it for FUSE-free execution.
|
||||||
@@ -142,7 +142,9 @@ Current verification:
|
|||||||
- `npm run test:e2e:obsidian:vault-reflection` creates a note through Obsidian's vault API, verifies the reflected file on disk, and reads it back through Obsidian.
|
- `npm run test:e2e:obsidian:vault-reflection` creates a note through Obsidian's vault API, verifies the reflected file on disk, and reads it back through Obsidian.
|
||||||
- `npm run test:e2e:obsidian:couchdb-upload` configures a unique CouchDB database, creates a note through Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that CouchDB contains the metadata document and all referenced chunk documents.
|
- `npm run test:e2e:obsidian:couchdb-upload` configures a unique CouchDB database, creates a note through Obsidian, commits it into the local database, runs one-shot synchronisation, and verifies that CouchDB contains the metadata document and all referenced chunk documents.
|
||||||
- `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: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: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: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: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.
|
- `npm run test:e2e:obsidian:install-appimage` reuses the existing AppImage and extracted binary when they are already present.
|
||||||
|
|
||||||
@@ -167,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 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 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, hidden `.obsidian/snippets` file round-tripping, hidden JSON conflict resolution, per-device hidden target-pattern 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 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.
|
||||||
|
|
||||||
### Phase 3: Two-Vault Synchronisation
|
### Phase 3: Two-Vault Synchronisation
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,9 @@
|
|||||||
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
|
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
|
||||||
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
|
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
|
||||||
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
"test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts",
|
||||||
|
"test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts",
|
||||||
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
"test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts",
|
||||||
|
"test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts",
|
||||||
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
|
"test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh",
|
"test:docker-couchdb:up": "npx dotenv-cli -e .env -e .test.env -- ./test/shell/couchdb-start.sh",
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ npm run test:e2e:obsidian:smoke
|
|||||||
npm run test:e2e:obsidian:vault-reflection
|
npm run test:e2e:obsidian:vault-reflection
|
||||||
npm run test:e2e:obsidian:couchdb-upload
|
npm run test:e2e:obsidian:couchdb-upload
|
||||||
npm run test:e2e:obsidian:startup-scan
|
npm run test:e2e:obsidian:startup-scan
|
||||||
|
npm run test:e2e:obsidian:two-vault-sync
|
||||||
npm run test:e2e:obsidian:hidden-file-snippet-sync
|
npm run test:e2e:obsidian:hidden-file-snippet-sync
|
||||||
|
npm run test:e2e:obsidian:customisation-sync
|
||||||
npm run test:e2e:obsidian:setting-markdown-export
|
npm run test:e2e:obsidian:setting-markdown-export
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,8 +58,12 @@ npm run test:e2e:obsidian:setting-markdown-export
|
|||||||
|
|
||||||
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
|
`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file.
|
||||||
|
|
||||||
|
`test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, deletion, Markdown conflict automatic merging with the merged result propagated by a second synchronisation, and per-device target filters where one vault ignores a note that the other vault synchronises.
|
||||||
|
|
||||||
`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: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: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`.
|
`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`.
|
||||||
|
|
||||||
Start the local CouchDB fixture first when one is not already running:
|
Start the local CouchDB fixture first when one is not already running:
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { evalObsidianJson } from "../runner/cli.ts";
|
||||||
|
import {
|
||||||
|
assertCouchDbReachable,
|
||||||
|
createCouchDbDatabase,
|
||||||
|
deleteCouchDbDatabase,
|
||||||
|
loadCouchDbConfig,
|
||||||
|
makeUniqueDatabaseName,
|
||||||
|
waitForCouchDbDocs,
|
||||||
|
type CouchDbConfig,
|
||||||
|
} from "../runner/couchdb.ts";
|
||||||
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||||
|
import {
|
||||||
|
assertEqual,
|
||||||
|
configureCouchDb,
|
||||||
|
prepareRemote,
|
||||||
|
pushLocalChanges,
|
||||||
|
waitForLiveSyncCoreReady,
|
||||||
|
} from "../runner/liveSyncWorkflow.ts";
|
||||||
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||||
|
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||||
|
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||||
|
|
||||||
|
const snippetPath = ".obsidian/snippets/livesync-customisation-e2e.css";
|
||||||
|
const snippetContent = [
|
||||||
|
"body {",
|
||||||
|
" --livesync-customisation-e2e-colour: #3d6f54;",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
".livesync-customisation-e2e {",
|
||||||
|
" color: var(--livesync-customisation-e2e-colour);",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
type RunnerContext = {
|
||||||
|
binary: string;
|
||||||
|
cliBinary: string;
|
||||||
|
couchDb: CouchDbConfig;
|
||||||
|
dbName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomisationEntry = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
children: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomisationScanResult = {
|
||||||
|
enabled: boolean;
|
||||||
|
useV2: boolean;
|
||||||
|
device: string;
|
||||||
|
configDir: string;
|
||||||
|
files: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||||
|
const fullPath = join(vaultPath, path);
|
||||||
|
await mkdir(dirname(fullPath), { recursive: true });
|
||||||
|
await writeFile(fullPath, content, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVaultFile(vaultPath: string, path: string): Promise<string> {
|
||||||
|
return await readFile(join(vaultPath, path), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(vaultPath: string, path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await readFile(join(vaultPath, path));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPathContent(
|
||||||
|
vaultPath: string,
|
||||||
|
path: string,
|
||||||
|
predicate: (content: string) => boolean,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
|
||||||
|
): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastContent = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await pathExists(vaultPath, path)) {
|
||||||
|
lastContent = await readVaultFile(vaultPath, path);
|
||||||
|
if (predicate(lastContent)) {
|
||||||
|
return lastContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startConfiguredSession(
|
||||||
|
context: RunnerContext,
|
||||||
|
vault: TemporaryVault,
|
||||||
|
deviceName: string
|
||||||
|
): Promise<ObsidianLiveSyncSession> {
|
||||||
|
const session = await startObsidianLiveSyncSession({
|
||||||
|
binary: context.binary,
|
||||||
|
cliBinary: context.cliBinary,
|
||||||
|
vault,
|
||||||
|
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||||
|
});
|
||||||
|
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||||
|
await configureCouchDb(
|
||||||
|
context.cliBinary,
|
||||||
|
session.cliEnv,
|
||||||
|
{
|
||||||
|
uri: context.couchDb.uri,
|
||||||
|
username: context.couchDb.username,
|
||||||
|
password: context.couchDb.password,
|
||||||
|
dbName: context.dbName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deviceAndVaultName: deviceName,
|
||||||
|
usePluginSync: true,
|
||||||
|
usePluginSyncV2: true,
|
||||||
|
autoSweepPlugins: false,
|
||||||
|
autoSweepPluginsPeriodic: false,
|
||||||
|
syncInternalFiles: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
context.cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
`core.services.setting.setDeviceAndVaultName(${JSON.stringify(deviceName)});`,
|
||||||
|
"await core.services.setting.saveSettingData();",
|
||||||
|
"return JSON.stringify({device:core.services.setting.getDeviceAndVaultName()});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
session.cliEnv
|
||||||
|
);
|
||||||
|
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||||
|
await prepareRemote(context.cliBinary, session.cliEnv);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanCustomisations(cliBinary: string, env: NodeJS.ProcessEnv): Promise<CustomisationScanResult> {
|
||||||
|
return await evalObsidianJson<CustomisationScanResult>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
"const addOn=core.getAddOn('ConfigSync');",
|
||||||
|
"const before=await addOn.scanInternalFiles();",
|
||||||
|
"await addOn.scanAllConfigFiles(false);",
|
||||||
|
"return JSON.stringify({",
|
||||||
|
"ok:true,",
|
||||||
|
"enabled:core.settings.usePluginSync,",
|
||||||
|
"useV2:core.settings.usePluginSyncV2,",
|
||||||
|
"device:core.services.setting.getDeviceAndVaultName(),",
|
||||||
|
"configDir:addOn.configDir,",
|
||||||
|
"files:before,",
|
||||||
|
"});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeCustomisationSnippet(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const path=${JSON.stringify(path)};`,
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
"const addOn=core.getAddOn('ConfigSync');",
|
||||||
|
"const term=core.services.setting.getDeviceAndVaultName();",
|
||||||
|
"const stat=await core.storageAccess.statHidden(path);",
|
||||||
|
"const category=addOn.getFileCategory(path);",
|
||||||
|
"const result=await addOn.storeCustomizationFiles(path,term);",
|
||||||
|
"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)}`);",
|
||||||
|
"}",
|
||||||
|
"return JSON.stringify({ok:true,path,term,category,entries});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForCustomisationEntry(
|
||||||
|
cliBinary: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
filename: string,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
|
||||||
|
): Promise<CustomisationEntry> {
|
||||||
|
return await evalObsidianJson<CustomisationEntry>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const filename=${JSON.stringify(filename)};`,
|
||||||
|
`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 entry=false;",
|
||||||
|
"while(Date.now()<deadline){",
|
||||||
|
" const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;",
|
||||||
|
" entry=rows.map((row)=>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;",
|
||||||
|
" 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||[]});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRemoteCustomisationSnippet(
|
||||||
|
cliBinary: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
filename: string
|
||||||
|
): Promise<void> {
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const filename=${JSON.stringify(filename)};`,
|
||||||
|
"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 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);",
|
||||||
|
"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 entry: ${entry.path}`);",
|
||||||
|
"}",
|
||||||
|
"return JSON.stringify({ok:true,path:entry.path});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const binary = requireObsidianBinary();
|
||||||
|
const cli = discoverObsidianCli();
|
||||||
|
if (!cli.binary) {
|
||||||
|
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const couchDb = await loadCouchDbConfig();
|
||||||
|
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "customisation-sync");
|
||||||
|
const vaultA = await createTemporaryVault();
|
||||||
|
const vaultB = await createTemporaryVault();
|
||||||
|
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
|
||||||
|
const snippetPathParts = snippetPath.split("/");
|
||||||
|
const snippetName = snippetPathParts[snippetPathParts.length - 1] ?? snippetPath;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertCouchDbReachable(couchDb);
|
||||||
|
await createCouchDbDatabase(couchDb, dbName);
|
||||||
|
|
||||||
|
console.log(`Using Obsidian executable: ${binary}`);
|
||||||
|
console.log(`Temporary vault A: ${vaultA.path}`);
|
||||||
|
console.log(`Temporary vault B: ${vaultB.path}`);
|
||||||
|
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||||
|
|
||||||
|
await writeVaultFile(vaultA.path, snippetPath, snippetContent);
|
||||||
|
|
||||||
|
let session = await startConfiguredSession(context, vaultA, "customisation-sync-a");
|
||||||
|
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 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));
|
||||||
|
});
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB, "customisation-sync-b");
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, snippetName);
|
||||||
|
assertEqual(
|
||||||
|
await pathExists(vaultB.path, snippetPath),
|
||||||
|
false,
|
||||||
|
"Customisation Sync snippet was reflected before explicit application."
|
||||||
|
);
|
||||||
|
await applyRemoteCustomisationSnippet(context.cliBinary, session.cliEnv, snippetName);
|
||||||
|
const applied = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
|
||||||
|
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.`);
|
||||||
|
} finally {
|
||||||
|
await vaultA.dispose();
|
||||||
|
await vaultB.dispose();
|
||||||
|
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||||
|
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||||
|
console.warn(error instanceof Error ? error.message : error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { evalObsidianJson } from "../runner/cli.ts";
|
||||||
|
import {
|
||||||
|
assertCouchDbReachable,
|
||||||
|
createCouchDbDatabase,
|
||||||
|
deleteCouchDbDatabase,
|
||||||
|
loadCouchDbConfig,
|
||||||
|
makeUniqueDatabaseName,
|
||||||
|
waitForCouchDbDocs,
|
||||||
|
type CouchDbConfig,
|
||||||
|
} from "../runner/couchdb.ts";
|
||||||
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||||
|
import {
|
||||||
|
assertEqual,
|
||||||
|
configureCouchDb,
|
||||||
|
prepareRemote,
|
||||||
|
pushLocalChanges,
|
||||||
|
waitForLiveSyncCoreReady,
|
||||||
|
waitForLocalDatabaseEntry,
|
||||||
|
type LocalDatabaseEntry,
|
||||||
|
} from "../runner/liveSyncWorkflow.ts";
|
||||||
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||||
|
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||||
|
|
||||||
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||||
|
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||||
|
|
||||||
|
const createPath = "E2E/two-vault/create.md";
|
||||||
|
const updatePath = "E2E/two-vault/update.md";
|
||||||
|
const deletePath = "E2E/two-vault/delete.md";
|
||||||
|
const conflictPath = "E2E/two-vault/conflict.md";
|
||||||
|
const targetMismatchPath = "E2E/two-vault/target-mismatch.md";
|
||||||
|
|
||||||
|
type RunnerContext = {
|
||||||
|
binary: string;
|
||||||
|
cliBinary: string;
|
||||||
|
couchDb: CouchDbConfig;
|
||||||
|
dbName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
|
||||||
|
const fullPath = join(vaultPath, path);
|
||||||
|
await mkdir(dirname(fullPath), { recursive: true });
|
||||||
|
await writeFile(fullPath, content, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
|
||||||
|
await rm(join(vaultPath, path), { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readVaultFile(vaultPath: string, path: string): Promise<string> {
|
||||||
|
return await readFile(join(vaultPath, path), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(vaultPath: string, path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await readFile(join(vaultPath, path));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPathContent(
|
||||||
|
vaultPath: string,
|
||||||
|
path: string,
|
||||||
|
predicate: (content: string) => boolean,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
|
||||||
|
): Promise<string> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastContent = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await pathExists(vaultPath, path)) {
|
||||||
|
lastContent = await readVaultFile(vaultPath, path);
|
||||||
|
if (predicate(lastContent)) {
|
||||||
|
return lastContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPathDeleted(
|
||||||
|
vaultPath: string,
|
||||||
|
path: string,
|
||||||
|
timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000)
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!(await pathExists(vaultPath, path))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string) {
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const path=${JSON.stringify(path)};`,
|
||||||
|
`const content=${JSON.stringify(content)};`,
|
||||||
|
"const folder=path.split('/').slice(0,-1).join('/');",
|
||||||
|
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
|
||||||
|
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||||
|
"if(existing) await app.vault.modify(existing,content);",
|
||||||
|
"else await app.vault.create(path,content);",
|
||||||
|
"return JSON.stringify({ok:true});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string) {
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const path=${JSON.stringify(path)};`,
|
||||||
|
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||||
|
"if(existing) await app.vault.delete(existing);",
|
||||||
|
"return JSON.stringify({ok:true});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startConfiguredSession(
|
||||||
|
context: RunnerContext,
|
||||||
|
vault: TemporaryVault,
|
||||||
|
overrides: Record<string, unknown> = {}
|
||||||
|
): Promise<ObsidianLiveSyncSession> {
|
||||||
|
const session = await startObsidianLiveSyncSession({
|
||||||
|
binary: context.binary,
|
||||||
|
cliBinary: context.cliBinary,
|
||||||
|
vault,
|
||||||
|
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||||
|
});
|
||||||
|
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||||
|
await configureCouchDb(
|
||||||
|
context.cliBinary,
|
||||||
|
session.cliEnv,
|
||||||
|
{
|
||||||
|
uri: context.couchDb.uri,
|
||||||
|
username: context.couchDb.username,
|
||||||
|
password: context.couchDb.password,
|
||||||
|
dbName: context.dbName,
|
||||||
|
},
|
||||||
|
overrides
|
||||||
|
);
|
||||||
|
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
|
||||||
|
await prepareRemote(context.cliBinary, session.cliEnv);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadNote(
|
||||||
|
context: RunnerContext,
|
||||||
|
session: ObsidianLiveSyncSession,
|
||||||
|
path: string
|
||||||
|
): Promise<LocalDatabaseEntry> {
|
||||||
|
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAndApply(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeFileRevision(
|
||||||
|
cliBinary: string,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
path: string,
|
||||||
|
content: string,
|
||||||
|
baseRev?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await evalObsidianJson<{ rev: string }>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const path=${JSON.stringify(path)};`,
|
||||||
|
`const content=${JSON.stringify(content)};`,
|
||||||
|
`const baseRev=${JSON.stringify(baseRev ?? "")};`,
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
"const blob=new Blob([content],{type:'text/plain'});",
|
||||||
|
"const id=await core.services.path.path2id(path);",
|
||||||
|
"const now=Date.now();",
|
||||||
|
"const result=await core.localDatabase.putDBEntry({",
|
||||||
|
" _id:id,",
|
||||||
|
" path,",
|
||||||
|
" data:blob,",
|
||||||
|
" ctime:now,",
|
||||||
|
" mtime:now,",
|
||||||
|
" size:(await blob.arrayBuffer()).byteLength,",
|
||||||
|
" children:[],",
|
||||||
|
" datatype:'plain',",
|
||||||
|
" type:'plain',",
|
||||||
|
" eden:{},",
|
||||||
|
"},false,baseRev||undefined);",
|
||||||
|
"if(!result?.ok) throw new Error(`Could not store file revision: ${path}`);",
|
||||||
|
"return JSON.stringify({ok:true,rev:result.rev});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
return result.rev;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMarkdownConflict(
|
||||||
|
context: RunnerContext,
|
||||||
|
session: ObsidianLiveSyncSession,
|
||||||
|
vault: TemporaryVault,
|
||||||
|
path: string,
|
||||||
|
base: string,
|
||||||
|
left: string,
|
||||||
|
right: string
|
||||||
|
): Promise<void> {
|
||||||
|
const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base);
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path);
|
||||||
|
await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev);
|
||||||
|
await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev);
|
||||||
|
await writeVaultFile(vault.path, path, right);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise<void> {
|
||||||
|
await evalObsidianJson<unknown>(
|
||||||
|
cliBinary,
|
||||||
|
[
|
||||||
|
"(async()=>{",
|
||||||
|
`const path=${JSON.stringify(path)};`,
|
||||||
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||||
|
"const result=await core.localDatabase.managers.conflictManager.tryAutoMerge(path,true);",
|
||||||
|
"if(!('result' in result)){",
|
||||||
|
" throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);",
|
||||||
|
"}",
|
||||||
|
"if(!(await core.databaseFileAccess.storeContent(path,result.result))){",
|
||||||
|
" throw new Error(`Could not store merged Markdown content: ${path}`);",
|
||||||
|
"}",
|
||||||
|
"if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){",
|
||||||
|
" throw new Error(`Could not delete conflicted revision: ${path}`);",
|
||||||
|
"}",
|
||||||
|
"if(!(await core.fileHandler.dbToStorage(path,path,true))){",
|
||||||
|
" throw new Error(`Could not reflect merged Markdown content: ${path}`);",
|
||||||
|
"}",
|
||||||
|
"return JSON.stringify({ok:true});",
|
||||||
|
"})()",
|
||||||
|
].join(""),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCreateUpdateDelete(
|
||||||
|
context: RunnerContext,
|
||||||
|
vaultA: TemporaryVault,
|
||||||
|
vaultB: TemporaryVault
|
||||||
|
): Promise<void> {
|
||||||
|
const createdContent = "# Created on A\n\nThis note should appear on B.\n";
|
||||||
|
let session = await startConfiguredSession(context, vaultA);
|
||||||
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent);
|
||||||
|
await uploadNote(context, session, createPath);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB);
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent);
|
||||||
|
await session.app.stop();
|
||||||
|
assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault.");
|
||||||
|
|
||||||
|
const initialUpdateContent = "# Update target\n\nInitial content.\n";
|
||||||
|
const updatedContent = "# Update target\n\nUpdated content from A.\n";
|
||||||
|
session = await startConfiguredSession(context, vaultA);
|
||||||
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, initialUpdateContent);
|
||||||
|
await uploadNote(context, session, updatePath);
|
||||||
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent);
|
||||||
|
await uploadNote(context, session, updatePath);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB);
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent);
|
||||||
|
await session.app.stop();
|
||||||
|
assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault.");
|
||||||
|
|
||||||
|
const deleteContent = "# Delete target\n\nThis note should be removed from B.\n";
|
||||||
|
session = await startConfiguredSession(context, vaultA);
|
||||||
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent);
|
||||||
|
await uploadNote(context, session, deletePath);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB);
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultA);
|
||||||
|
await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath);
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB);
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
await waitForPathDeleted(vaultB.path, deletePath);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
console.log("Two-vault note creation, update, and deletion round-tripped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMarkdownAutoMerge(
|
||||||
|
context: RunnerContext,
|
||||||
|
vaultA: TemporaryVault,
|
||||||
|
vaultB: TemporaryVault
|
||||||
|
): Promise<void> {
|
||||||
|
const base = "# Conflict\n\nBase line\n\nShared tail\n";
|
||||||
|
const left = "# Conflict\n\nLeft line\n\nShared tail\n";
|
||||||
|
const right = "# Conflict\n\nBase line\n\nRight tail\n";
|
||||||
|
|
||||||
|
let session = await startConfiguredSession(context, vaultB);
|
||||||
|
await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right);
|
||||||
|
await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath);
|
||||||
|
await pushLocalChanges(context.cliBinary, session.cliEnv);
|
||||||
|
const mergedOnB = await waitForPathContent(
|
||||||
|
vaultB.path,
|
||||||
|
conflictPath,
|
||||||
|
(content) => content.includes("Left line") && content.includes("Right tail")
|
||||||
|
);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultA);
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
const mergedOnA = await waitForPathContent(
|
||||||
|
vaultA.path,
|
||||||
|
conflictPath,
|
||||||
|
(content) => content.includes("Left line") && content.includes("Right tail")
|
||||||
|
);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults.");
|
||||||
|
console.log("Markdown conflict was automatically merged and propagated by the next synchronisation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTargetMismatch(
|
||||||
|
context: RunnerContext,
|
||||||
|
vaultA: TemporaryVault,
|
||||||
|
vaultB: TemporaryVault
|
||||||
|
): Promise<void> {
|
||||||
|
const ignoredContent = "# Target mismatch\n\nB should ignore this revision.\n";
|
||||||
|
const acceptedContent = "# Target mismatch\n\nB should accept this revision after its target filter changes.\n";
|
||||||
|
|
||||||
|
let session = await startConfiguredSession(context, vaultA);
|
||||||
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent);
|
||||||
|
await uploadNote(context, session, targetMismatchPath);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB, {
|
||||||
|
syncOnlyRegEx: "^E2E/two-vault/allowed/.*",
|
||||||
|
});
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
assertEqual(
|
||||||
|
await pathExists(vaultB.path, targetMismatchPath),
|
||||||
|
false,
|
||||||
|
"A note was reflected on a device where it was not a target file."
|
||||||
|
);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultA);
|
||||||
|
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent);
|
||||||
|
await uploadNote(context, session, targetMismatchPath);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
session = await startConfiguredSession(context, vaultB, {
|
||||||
|
syncOnlyRegEx: "",
|
||||||
|
});
|
||||||
|
await syncAndApply(context, session);
|
||||||
|
const received = await waitForPathContent(
|
||||||
|
vaultB.path,
|
||||||
|
targetMismatchPath,
|
||||||
|
(content) => content === acceptedContent
|
||||||
|
);
|
||||||
|
await session.app.stop();
|
||||||
|
|
||||||
|
assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path.");
|
||||||
|
console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const binary = requireObsidianBinary();
|
||||||
|
const cli = discoverObsidianCli();
|
||||||
|
if (!cli.binary) {
|
||||||
|
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const couchDb = await loadCouchDbConfig();
|
||||||
|
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "two-vault-sync");
|
||||||
|
const vaultA = await createTemporaryVault();
|
||||||
|
const vaultB = await createTemporaryVault();
|
||||||
|
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assertCouchDbReachable(couchDb);
|
||||||
|
await createCouchDbDatabase(couchDb, dbName);
|
||||||
|
|
||||||
|
console.log(`Using Obsidian executable: ${binary}`);
|
||||||
|
console.log(`Temporary vault A: ${vaultA.path}`);
|
||||||
|
console.log(`Temporary vault B: ${vaultB.path}`);
|
||||||
|
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||||
|
|
||||||
|
await runCreateUpdateDelete(context, vaultA, vaultB);
|
||||||
|
await runMarkdownAutoMerge(context, vaultA, vaultB);
|
||||||
|
await runTargetMismatch(context, vaultA, vaultB);
|
||||||
|
} finally {
|
||||||
|
await vaultA.dispose();
|
||||||
|
await vaultB.dispose();
|
||||||
|
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||||
|
await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => {
|
||||||
|
console.warn(error instanceof Error ? error.message : error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error: unknown) => {
|
||||||
|
console.error(error instanceof Error ? error.stack : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user