Merge main and verify comparison status with published Commonlib

This commit is contained in:
vorotamoroz committed 2026-10-26 04:09:02 +00:00
commit c9381d6a59
38 files changed
+4615 -119

No files matched your search

+12 -2
View File
@@ -88,7 +88,7 @@ npm run test:e2e:obsidian:focused -- replication-status
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
`replication-status` uses an isolated Vault without a remote service. It injects synthetic replication statistics into the real plug-in to check the ⏩ icon, the comparison count and its three-second expiry, independently styled numeric items, stable rendered digit widths, and unchanged DOM nodes for repeated values. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. A separate session enters mobile emulation before loading the plug-in and measures the editor item gaps at a 390px viewport width. The desktop checks also ensure that the status bar keeps its compact spacing. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
`replication-status` uses an isolated Vault without a remote service. It injects synthetic replication statistics into the real plug-in to check the ⏩ icon, the comparison count and its three-second expiry, independently styled numeric items, stable rendered digit widths, computed right alignment, and unchanged DOM nodes for repeated values. It also feeds the existing queue sources and checks their values in both displays, the separate P2P line, file warnings and their compact icon, and the corresponding plain-text status. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. A separate session enters mobile emulation before loading the plug-in and measures the editor item gaps at a 390px viewport width. The desktop checks also ensure that the status bar keeps its compact spacing. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including visible radio choices for original names and pseudonyms, their default and keyboard navigation, the default-on integrity-only remote option, retained local findings when the remote is unavailable, cancellation during inspection with earlier rows retained, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice and result dialogues in mobile emulation, checking layout at 800×600 and 375×667, radio choices for available formats, their touch targets, and export controls. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged.
@@ -130,7 +130,7 @@ The underlying `test:e2e:obsidian:<scenario>` scripts remain available for an im
`test:e2e:obsidian:joined-devices` uses a temporary Vault and isolated CouchDB database to open Joined devices from Remote Configuration, a version Notice, and its registered command. `test:e2e:obsidian:joined-devices-object-storage` runs the same assertions against a unique Object Storage prefix, including the conditional removal supported by the test S3 service. It checks that newer and older Self-hosted LiveSync versions share one Notice with one recommendation and review action, that the current device appears first with current metadata, and that locked-out and other-device records are shown. It confirms that a manual refresh reads an updated remote record and that cancelling the inline confirmation leaves the remote unchanged before forgetting one device. It verifies the raw milestone retains the other device, remote state, and preferred-ID tweaks, then checks mobile scrolling, safe-area bounds, and touch targets. It captures the version Notice, desktop list, forget confirmation, completed removal, and mobile dialogue; unrelated Notices are dismissed before the list captures.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
`test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then enables Advanced mode and persists one numeric Advanced setting. It also checks that batching starts disabled, that each Sync Mode shows its own batching choice and the appropriate shared delay controls, and that switching modes and reopening settings preserves both choices. It reads the saved plug-in data to confirm that the choices are persisted independently. On Obsidian before 1.13, this covers the imperative settings fallback used by the `SettingSpec` proof. On Obsidian 1.13 or later, it verifies the task-oriented landing order, the separate Synchronisation group containing Remote Configuration and Sync Settings, and the General Settings group containing Appearance, Logging, and Extra menus. It reopens and cancels onboarding from Quick Setup, opens all 14 nested settings pages, searches globally for the Advanced control, captures the landing page, search result, Advanced page, and mobile landing page, and verifies that the value remains after the settings dialogue is closed and reopened. The mobile landing check confirms that Remote Configuration is reachable without initial scrolling. Finally, it selects the Synchronisation Settings page and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour.
The default runner uses the maintained pre-1.13 Obsidian fixture and therefore exercises the complete compatibility-review, mobile-layout, and imperative-settings path. To exercise only the native settings contract against an additional Obsidian 1.13-or-later installation, supply its executable and companion CLI explicitly:
@@ -255,10 +255,20 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
`test:e2e:obsidian:hidden-file-selectors` stores and restores selected JS, CSS, and JSON files with a normal-file allow-list set, and compares both directions with an empty allow-list. It uses one temporary Vault and a local database, and also runs in the complete local suite. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_HIDDEN_FILE_SELECTORS_OUTPUT` to choose the JSON report path.
Before enablement, the scenario directly requests initialisation while Hidden File Sync is disabled. It requires a refused result, unchanged local files, Metadata revisions, and processed markers, and no initialisation progress Notice.
`test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy.
`test:e2e:obsidian:customisation-catalogue-reload` opens the Customisation Sync dialogue, enables Maintenance mode, and clicks Reload. It verifies that this first Reload lists all three files of a sample plug-in, then selects the source copy and clicks Apply to restore their contents. It also checks a second enumeration and Apply through the existing methods in one temporary Vault with a local database. Run it through the focused wrapper to build the current bundle first; no remote service is required. Set `E2E_CUSTOMISATION_CATALOGUE_OUTPUT` to choose the JSON report path.
The workflow also opens the Customisation Sync dialogue. `--case=visibility` checks that **Hide not applicable items** hides an identical snippet, preserves a different snippet and its source selector, and restores the identical snippet when cleared. `--case=mtime` gives the multi-file plug-in fixture modern millisecond timestamps and requires an older remote copy to remain labelled **Older** and unselected by **Select All Shiny**. With no case argument, both checks run before the existing apply, update, and deletion workflow.
`--case=automatic` separately checks that choosing **Automatic** while Hidden File Sync is disabled displays a Notice without changing modes, files, Metadata revisions, or processed markers. The fixture starts with a saved Automatic selection and requires it to survive start-up and both rejected actions. It also checks disabling the feature after opening **Initial Action**. After enablement, selecting a whole plug-in must initialise both **MAIN** and **DATA**, and transfer their files to the other Vault. Separate temporary Vaults exercise **Overwrite Remote**, **Overwrite Local**, and **Use newer**. The latter two start with different local and database contents; deterministic per-file modification times require **Use newer** to keep the newer database MAIN content and the newer local DATA content.
`test:e2e:obsidian:livesync-batch` uses two real Obsidian Vaults and a disposable local CouchDB to compare batching disabled, the older `batchSave` choice alone, and the explicit LiveSync batching choice. It covers repeated edits in both directions, template-like writes, deletion and recreation, incoming changes, maximum delay, concurrent paths, manual flush, and restart recovery before and after a database write. Restarts use the saved profile with natural plug-in start-up. `ISSUE1207_CASE_FILTER` selects case names; `ISSUE1207_RESTART_BUCKET=same|exact|different` exercises interrupted processing at the selected timestamp condition. `ISSUE1207_RESTART_SAVE_AFTER_EDIT=true` separately checks a settings save after the pending edit. Use `npm run test:e2e:obsidian:focused -- livesync-batch` to build and run the default matrix. This scenario requires a localhost CouchDB and is outside the complete local suite.
`test:e2e:obsidian:optional-file-save-completion` exercises Hidden File Sync and both Customisation Sync formats in one isolated real Obsidian Vault. It holds a local database write to verify that its event stays in the snapshot and that flush waits for the write, then checks unchanged notifications, subsequent edits, and repeated deletions. Injected save failures remain pending and must replay successfully before the next start reaches readiness. This local scenario disables replication and requires no remote service. It is outside `test:e2e:obsidian:local-suite`.
`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`.
`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.
@@ -0,0 +1,246 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { isDeepStrictEqual } from "node:util";
import { fileURLToPath } from "node:url";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const pluginName = "livesync-catalogue-example";
const sourceTerm = "catalogue-source";
const pluginRoot = `.obsidian/plugins/${pluginName}`;
const pluginFiles = {
[`${pluginRoot}/main.js`]: "module.exports = class ExamplePlugin {};\n",
[`${pluginRoot}/manifest.json`]: JSON.stringify({
id: pluginName,
name: "Catalogue Example",
version: "1.0.0",
minAppVersion: "1.0.0",
description: "An isolated test fixture",
author: "Self-hosted LiveSync",
}),
[`${pluginRoot}/styles.css`]: ".livesync-catalogue-example { color: red; }\n",
};
const allowList = "\\.(?:md|png|pdf)$";
const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;";
const configCode = `${coreCode}const addOn=core.getAddOn('ConfigSync');`;
const settleCode = `
const settle=async()=>{
await new Promise(resolve=>setTimeout(resolve,100));
for(let attempt=0;attempt<200;attempt++){
if(addOn.updatingV2Count===0)return;
await new Promise(resolve=>setTimeout(resolve,25));
}
throw new Error('Customisation Sync did not settle');
};
`;
const sourceRowCode = `
const row=addOn.pluginList.find(entry=>entry.category==='PLUGIN_MAIN'&&entry.name===${JSON.stringify(pluginName)}&&entry.term===${JSON.stringify(sourceTerm)});
if(!row)throw new Error('The source catalogue row is missing');
`;
type Observation = { name: string; expected: unknown; actual: unknown; passed: boolean };
async function writeFixtures(vaultPath: string, files: Record<string, string>) {
for (const [path, content] of Object.entries(files)) {
await mkdir(dirname(join(vaultPath, path)), { recursive: true });
await writeFile(join(vaultPath, path), content);
}
}
async function removeFixtures(vaultPath: string, files: Record<string, string>) {
for (const path of Object.keys(files)) await rm(join(vaultPath, path), { force: true });
}
async function matchingFiles(vaultPath: string, files: Record<string, string>) {
const matches: string[] = [];
for (const [path, content] of Object.entries(files)) {
try {
if ((await readFile(join(vaultPath, path), "utf8")) === content) matches.push(path);
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error;
}
}
return matches.sort();
}
async function waitForMatchingFiles(vaultPath: string, files: Record<string, string>) {
const deadline = Date.now() + 10_000;
let matches = await matchingFiles(vaultPath, files);
while (matches.length !== Object.keys(files).length && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
matches = await matchingFiles(vaultPath, files);
}
return matches;
}
async function main() {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error("The Obsidian CLI is unavailable");
const cliBinary = cli.binary;
const output =
process.env.E2E_CUSTOMISATION_CATALOGUE_OUTPUT ?? join(tmpdir(), "livesync-customisation-catalogue.json");
const observations: Observation[] = [];
const report: Record<string, unknown> = {
revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
sourceDiffSha256: createHash("sha256")
.update(new Uint8Array(execFileSync("git", ["diff", "--binary", "HEAD"])))
.digest("hex"),
scenarioSha256: createHash("sha256")
.update(new Uint8Array(await readFile(fileURLToPath(import.meta.url))))
.digest("hex"),
commonlibEntrySha256: createHash("sha256")
.update(
new Uint8Array(
await readFile(
"node_modules/@vrtmrz/livesync-commonlib/dist/managers/EntryManager/EntryManagerImpls.js"
)
)
)
.digest("hex"),
bundleSha256: createHash("sha256")
.update(new Uint8Array(await readFile("main.js")))
.digest("hex"),
commonlibVersion: JSON.parse(await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8"))
.version,
observations,
};
const vault = await createTemporaryVault("livesync-customisation-catalogue-");
let session: ObsidianLiveSyncSession | undefined;
const record = (name: string, actual: unknown, expected: unknown) => {
const observation = { name, expected, actual, passed: isDeepStrictEqual(actual, expected) };
observations.push(observation);
console.log(JSON.stringify(observation));
};
const evaluate = async <T>(code: string): Promise<T> => {
if (!session) throw new Error("The isolated Obsidian session has not started");
return await evalObsidianJson<T>(cliBinary, `(async()=>{${code}})()`, session.cliEnv, 60_000);
};
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary,
vault,
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
pluginData: createE2eCouchDbPluginData(
{ uri: "http://127.0.0.1:9", username: "", password: "", dbName: "catalogue-local-only" },
{ periodicReplication: false, syncOnFileOpen: false, useOnlyLocalChunk: true }
),
});
report.pluginVersion = session.readiness.pluginVersion;
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
report.runtime = await evaluate(`${coreCode}
core.services.setting.setDeviceAndVaultName('catalogue-local');
Object.assign(core.settings,{
isConfigured:true,usePluginSync:true,usePluginSyncV2:true,syncInternalFiles:false,
autoSweepPlugins:false,autoSweepPluginsPeriodic:false,watchInternalFileChanges:false,
syncOnlyRegEx:${JSON.stringify(allowList)},syncIgnoreRegEx:''
});
return JSON.stringify({userAgent:navigator.userAgent,databaseReady:core.localDatabase.isReady});
`);
await writeFixtures(vault.path, pluginFiles);
await evaluate(`${configCode}${settleCode}
for(const path of ${JSON.stringify(Object.keys(pluginFiles))}){
const stored=await addOn.storeCustomisationFileV2(path,${JSON.stringify(sourceTerm)});
if(stored===false)throw new Error('Could not seed a Customisation Sync document');
}
await settle();
return JSON.stringify({seeded:true});
`);
const opened = await evaluate<{ opened: boolean }>(
`return JSON.stringify({opened:app.commands.executeCommandById('obsidian-livesync:livesync-plugin-dialog-ex')});`
);
if (!opened.opened) throw new Error("Could not open the Customisation Sync dialogue command");
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
await dialogue
.locator("label")
.filter({ hasText: "Maintenance mode" })
.locator('input[type="checkbox"]')
.check();
await dialogue.getByRole("button", { name: "Reload", exact: true }).click();
});
const catalogue = await evaluate<{ files: string[]; documents: number }>(`${configCode}${settleCode}
await settle();
${sourceRowCode}
let documents=0;
for await(const doc of core.localDatabase.findEntries('ix:','ix;')){
if(doc.path.startsWith(${JSON.stringify(`ix:${sourceTerm}/PLUGIN_MAIN/${pluginName}%`)})&&!doc.deleted&&!doc._deleted)documents++;
}
return JSON.stringify({files:row.files.map(file=>file.filename).sort(),documents});
`);
record("#1271: all source documents remain in the database", catalogue.documents, 3);
record(
"#1271: one reload lists all files",
catalogue.files,
Object.keys(pluginFiles)
.map((path) => path.substring(".obsidian/".length))
.sort()
);
await removeFixtures(vault.path, pluginFiles);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Customization Sync (Beta3)");
const row = dialogue.locator(`.filerow:has(select option[value="${sourceTerm}"])`);
await row.locator("select").selectOption(sourceTerm);
await row.locator(".body").getByRole("button", { name: "✓", exact: true }).click();
});
report.firstApply = { action: "UI Apply", sourceTerm };
record(
"#1271: first Apply restores all file contents",
await waitForMatchingFiles(vault.path, pluginFiles),
Object.keys(pluginFiles).sort()
);
const secondPass = await evaluate<string[]>(`${configCode}${settleCode}
await addOn.updatePluginList(false);await settle();${sourceRowCode}
return JSON.stringify(row.files.map(file=>file.filename).sort());
`);
record(
"#1271 control: a second pass lists all files",
secondPass,
Object.keys(pluginFiles)
.map((path) => path.substring(".obsidian/".length))
.sort()
);
await removeFixtures(vault.path, pluginFiles);
await evaluate(`${configCode}${sourceRowCode}return JSON.stringify({applied:await addOn.applyDataV2(row)});`);
record(
"#1271 control: Apply after a second pass restores all contents",
await matchingFiles(vault.path, pluginFiles),
Object.keys(pluginFiles).sort()
);
} catch (error) {
report.runtimeError = error instanceof Error ? error.stack : String(error);
if (session) report.processOutput = JSON.stringify(session.app.output()).slice(-4_000);
throw error;
} finally {
try {
await writeFile(output, JSON.stringify(report, null, 2) + "\n");
console.log(`Verification report: ${output}`);
} finally {
try {
if (session) await session.app.stop();
} finally {
await vault.dispose();
}
}
}
if (observations.some((observation) => !observation.passed)) process.exitCode = 1;
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
+433 -14
View File
@@ -1,6 +1,12 @@
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 {
MODE_AUTOMATIC,
VERSIONING_DOCID,
type ObsidianLiveSyncSettings,
type LoadedEntry,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
import { evalObsidianJson } from "../runner/cli.ts";
import {
@@ -23,11 +29,12 @@ import {
prepareRemote,
pushLocalChanges,
waitForLiveSyncCoreReady,
waitForLocalDatabaseEntry,
} 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";
import type { Locator } from "playwright";
import type { Locator, Page } from "playwright";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
@@ -59,6 +66,18 @@ const pluginDir = ".obsidian/plugins/livesync-e2e-sample";
const pluginManifestPath = `${pluginDir}/manifest.json`;
const pluginMainPath = `${pluginDir}/main.js`;
const pluginStylesPath = `${pluginDir}/styles.css`;
const pluginDisplayName = "LiveSync E2E Sample";
const pluginDataPath = `${pluginDir}/data.json`;
const pluginDataContent = JSON.stringify({ source: "customisation-sync-a", enabled: true }, null, 4) + "\n";
const targetPluginDataContent = JSON.stringify({ source: "customisation-sync-b", enabled: true }, null, 4) + "\n";
const existingAutomaticKey = "SNIPPET/livesync-e2e-existing-automatic.css";
const existingAutomaticSelections: ObsidianLiveSyncSettings["pluginSyncExtendedSetting"] = {
[existingAutomaticKey]: {
key: existingAutomaticKey,
mode: MODE_AUTOMATIC,
files: ["snippets/livesync-e2e-existing-automatic.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";
@@ -104,7 +123,8 @@ const pluginFixtures = [
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
] as const;
type CustomisationSyncCase = "all" | "visibility" | "mtime";
type CustomisationSyncCase = "all" | "visibility" | "mtime" | "automatic";
type AutomaticInitialAction = "Overwrite Remote" | "Overwrite Local" | "Use newer";
type CustomisationSyncTestGlobal = typeof globalThis & {
app?: { commands?: { executeCommandById(commandId: string): boolean } };
@@ -131,6 +151,14 @@ type CustomisationScanResult = {
files: string[];
};
type AutomaticSyncSnapshot = {
syncInternalFiles: boolean;
pluginSyncExtendedSetting: Record<string, { key: string; mode: number; files: string[] }>;
hiddenFiles: { path: string; rev: string; deleted: boolean }[];
processedFiles: [string, string | undefined][];
processedDatabaseFiles: [string, string | undefined][];
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
const fullPath = join(vaultPath, path);
await mkdir(dirname(fullPath), { recursive: true });
@@ -147,10 +175,11 @@ 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|visibility|mtime]");
throw new Error("Usage: test:e2e:obsidian:customisation-sync [--case=all|visibility|mtime|automatic]");
}
const selected = arg.slice("--case=".length);
if (selected === "all" || selected === "visibility" || selected === "mtime") return selected;
if (selected === "all" || selected === "visibility" || selected === "mtime" || selected === "automatic")
return selected;
throw new Error(`Unknown Customisation Sync E2E case: ${selected}`);
}
@@ -244,6 +273,282 @@ async function inspectPluginFreshnessAndNewestSelection(session: ObsidianLiveSyn
});
}
async function openAutomaticModeForPlugin(page: Page, pluginName: string): Promise<Locator> {
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 pluginRow = dialogue.locator(".labelrow").filter({ hasText: pluginName }).first();
await pluginRow.waitFor({ state: "visible", timeout: 10000 });
await pluginRow.locator("button.status").click();
const automaticOption = page.locator(".menu-item").filter({ hasText: "Automatic" }).last();
await automaticOption.waitFor({ state: "visible", timeout: 10000 });
await automaticOption.click();
return dialogue;
}
async function selectAutomaticInitialAction(page: Page, label: AutomaticInitialAction): Promise<void> {
const action = page.locator(".menu-item").filter({ hasText: label }).last();
await action.waitFor({ state: "visible", timeout: 10000 });
await action.click();
}
async function readAutomaticSyncSnapshot(cliBinary: string, env: NodeJS.ProcessEnv): Promise<AutomaticSyncSnapshot> {
return await evalObsidianJson<AutomaticSyncSnapshot>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const hidden=core.getAddOn('HiddenFileSync');",
"const files=await hidden.getAllDatabaseFiles();",
"const processed=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
"return JSON.stringify({",
"syncInternalFiles:core.settings.syncInternalFiles,",
"pluginSyncExtendedSetting:core.settings.pluginSyncExtendedSetting,",
"hiddenFiles:files.map((entry)=>{const path=hidden.getPath(entry);return {path:path.startsWith('i:')?path.slice(2):path,rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)};}).sort((a,b)=>a.path.localeCompare(b.path)),",
"processedFiles:processed(hidden._fileInfoLastProcessed),",
"processedDatabaseFiles:processed(hidden._databaseInfoLastProcessed),",
"});",
"})()",
].join(""),
env
);
}
async function readAutomaticFileContents(context: RunnerContext, session: ObsidianLiveSyncSession) {
const files = await evalObsidianJson<
{ path: string; entry: LoadedEntry | false; local: string; localMtime: number }[]
>(
context.cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const paths=${JSON.stringify([pluginStylesPath, pluginDataPath])};`,
"const files=await Promise.all(paths.map(async(path)=>{",
"const entry=await core.localDatabase.getDBEntry('i:'+path,undefined,false,true);",
"const stat=await app.vault.adapter.stat(path);",
"if(!stat) throw new Error('Missing Automatic fixture file: '+path);",
"return {path,entry,local:await app.vault.adapter.read(path),localMtime:stat.mtime};",
"}));",
"return JSON.stringify(files);",
"})()",
].join(""),
session.cliEnv
);
return await Promise.all(
files.map(async (file) => ({
path: file.path,
stored: file.entry ? await readAsBlob(file.entry).text() : null,
storedMtime: file.entry ? file.entry.mtime : 0,
local: file.local,
localMtime: file.localMtime,
}))
);
}
async function setHiddenSyncEnabledForFixture(
cliBinary: string,
env: NodeJS.ProcessEnv,
enabled: boolean
): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`core.settings.syncInternalFiles=${JSON.stringify(enabled)};`,
"return JSON.stringify({syncInternalFiles:core.settings.syncInternalFiles});",
"})()",
].join(""),
env
);
}
async function inspectAutomaticSyncSelection(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
pluginName: string,
initialAction: AutomaticInitialAction
): Promise<void> {
const noticeText =
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.";
const trackedFiles = [pluginStylesPath, pluginDataPath];
const contentsBefore = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
const disabledBefore = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
assertEqual(
JSON.stringify(disabledBefore.pluginSyncExtendedSetting[existingAutomaticKey]),
JSON.stringify(existingAutomaticSelections[existingAutomaticKey]),
"The fixture did not retain the saved Automatic selection while Hidden File Sync was disabled."
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
await notice.waitFor({ state: "visible", timeout: 10000 });
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
if ((await initialActionMenu.count()) !== 0) {
throw new Error("Automatic displayed an Initial Action menu while Hidden File Sync was disabled.");
}
await page.keyboard.press("Escape");
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
await notice.click({ force: true });
await notice.waitFor({ state: "hidden", timeout: 10000 });
});
const disabledAfter = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
const contentsAfter = await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)));
if (JSON.stringify(disabledAfter) !== JSON.stringify(disabledBefore)) {
throw new Error(
`Automatic changed Hidden File Sync state while it was disabled: ${JSON.stringify({
before: disabledBefore,
after: disabledAfter,
})}`
);
}
assertEqual(
JSON.stringify(contentsAfter),
JSON.stringify(contentsBefore),
"Automatic changed plug-in files while Hidden File Sync was disabled."
);
// Exercise the second guard: the user may open Initial Action while enabled,
// then disable Hidden File Sync before choosing a direction.
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await openAutomaticModeForPlugin(page, pluginDisplayName);
const initialActionMenu = page.locator(".menu:visible").filter({ hasText: "Initial Action" });
await initialActionMenu.waitFor({ state: "visible", timeout: 10000 });
});
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
const beforeDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await selectAutomaticInitialAction(page, "Overwrite Remote");
const notice = page.locator(".notice:visible").filter({ hasText: noticeText }).last();
await notice.waitFor({ state: "visible", timeout: 10000 });
await notice.click({ force: true });
await notice.waitFor({ state: "hidden", timeout: 10000 });
});
const afterDisabledAction = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
if (JSON.stringify(afterDisabledAction) !== JSON.stringify(beforeDisabledAction)) {
throw new Error(
`Automatic changed Hidden File Sync state after it was disabled from Initial Action: ${JSON.stringify({
before: beforeDisabledAction,
after: afterDisabledAction,
})}`
);
}
assertEqual(
JSON.stringify(await Promise.all(trackedFiles.map((path) => readVaultFile(vault.path, path)))),
JSON.stringify(contentsBefore),
"Automatic changed plug-in files after Hidden File Sync was disabled from Initial Action."
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
});
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
if (initialAction !== "Overwrite Remote") {
const fixtures = [
{
path: pluginStylesPath,
stored: pluginStylesContent,
local: targetPluginStylesContent,
storedIsNewer: true,
},
{
path: pluginDataPath,
stored: pluginDataContent,
local: targetPluginDataContent,
storedIsNewer: false,
},
];
const actualFiles = await readAutomaticFileContents(context, session);
for (const fixture of fixtures) {
const actual = actualFiles.find((file) => file.path === fixture.path);
if (
!actual ||
actual.stored !== fixture.stored ||
actual.local !== fixture.local ||
actual.storedMtime > actual.localMtime !== fixture.storedIsNewer ||
actual.storedMtime === actual.localMtime
) {
throw new Error(
`Automatic direction fixture did not retain distinct contents and modification times: ${JSON.stringify(actual)}`
);
}
}
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
await selectAutomaticInitialAction(page, initialAction);
for (const label of ["MAIN", "DATA"]) {
const row = dialogue.locator(".filerow").filter({ hasText: label }).first();
await row.locator(".statusnote").filter({ hasText: "Automatic" }).waitFor({ state: "visible" });
}
await page.keyboard.press("Escape");
await dialogue.waitFor({ state: "hidden", timeout: 10000 });
});
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path, { hidden: true });
}
const enabled = await readAutomaticSyncSnapshot(context.cliBinary, session.cliEnv);
const mainMode = enabled.pluginSyncExtendedSetting[`PLUGIN_MAIN/${pluginName}`];
const dataMode = enabled.pluginSyncExtendedSetting[`PLUGIN_DATA/${pluginName}`];
if (mainMode?.mode !== MODE_AUTOMATIC || dataMode?.mode !== MODE_AUTOMATIC) {
throw new Error(
`Automatic did not save both plug-in group child settings: ${JSON.stringify({ mainMode, dataMode })}`
);
}
assertEqual(
JSON.stringify([...mainMode.files].sort()),
JSON.stringify([
`plugins/${pluginName}/main.js`,
`plugins/${pluginName}/manifest.json`,
`plugins/${pluginName}/styles.css`,
]),
"Automatic did not select the PLUGIN_MAIN files."
);
assertEqual(
JSON.stringify([...dataMode.files].sort()),
JSON.stringify([`plugins/${pluginName}/data.json`]),
"Automatic did not select the PLUGIN_DATA file."
);
assertEqual(
enabled.hiddenFiles.filter((entry) => entry.path.startsWith(pluginDir + "/")).length,
4,
"Automatic did not initialise all files in the PLUGIN_ALL group."
);
const expectedFiles = [
{
path: pluginStylesPath,
content: initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent,
},
{
path: pluginDataPath,
content: initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent,
},
];
const deadline = Date.now() + 15000;
let actualFiles = await readAutomaticFileContents(context, session);
const contentsMatch = () =>
actualFiles.every(
(file, index) => file.stored === expectedFiles[index].content && file.local === expectedFiles[index].content
);
while (!contentsMatch() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 250));
actualFiles = await readAutomaticFileContents(context, session);
}
if (!contentsMatch()) {
throw new Error(`${initialAction} did not apply both MAIN and DATA: ${JSON.stringify(actualFiles)}`);
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
}
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
await rm(join(vaultPath, path), { force: true });
}
@@ -287,7 +592,8 @@ async function waitForPathContent(
async function startConfiguredSession(
context: RunnerContext,
vault: TemporaryVault,
deviceName: string
deviceName: string,
settings: Partial<Pick<ObsidianLiveSyncSettings, "syncInternalFiles" | "pluginSyncExtendedSetting">> = {}
): Promise<ObsidianLiveSyncSession> {
const couchDbSettings = {
uri: context.couchDb.uri,
@@ -306,6 +612,7 @@ async function startConfiguredSession(
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
syncInternalFiles: false,
...settings,
};
const session = await startObsidianLiveSyncSession({
binary: context.binary,
@@ -319,6 +626,18 @@ async function startConfiguredSession(
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
if (Object.keys(settings.pluginSyncExtendedSetting ?? {}).length > 0) {
const restoredSelections = await evalObsidianJson<ObsidianLiveSyncSettings["pluginSyncExtendedSetting"]>(
context.cliBinary,
"JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.pluginSyncExtendedSetting)",
session.cliEnv
);
assertEqual(
JSON.stringify(restoredSelections),
JSON.stringify(settings.pluginSyncExtendedSetting),
"The saved Automatic selection changed during start-up."
);
}
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, customisationSettings);
await evalObsidianJson<unknown>(
context.cliBinary,
@@ -389,7 +708,7 @@ async function storeCustomisationFile(cliBinary: string, env: NodeJS.ProcessEnv,
async function deleteCustomisationSyncEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
name: string,
term?: string
): Promise<void> {
@@ -418,7 +737,7 @@ async function deleteCustomisationSyncEntry(
async function waitForCustomisationEntry(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
name: string,
term?: string,
timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000)
@@ -430,7 +749,7 @@ async function waitForCustomisationEntry(
async function waitForCustomisationEntries(
cliBinary: string,
env: NodeJS.ProcessEnv,
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN",
category: "CONFIG" | "SNIPPET" | "PLUGIN_MAIN" | "PLUGIN_DATA",
name: string,
count: number,
term?: string,
@@ -570,10 +889,13 @@ async function applyRemoteCustomisationGroup(
);
}
async function main(): Promise<void> {
const testCase = selectedCase();
async function runWorkflow(
testCase: CustomisationSyncCase,
initialAction: AutomaticInitialAction = "Overwrite Remote"
): Promise<void> {
const testVisibility = testCase === "all" || testCase === "visibility";
const testMtime = testCase === "all" || testCase === "mtime";
const testAutomatic = testCase === "automatic";
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
@@ -604,6 +926,11 @@ async function main(): Promise<void> {
await writeVaultFile(vaultA.path, pluginManifestPath, pluginManifestContent);
await writeVaultFile(vaultA.path, pluginMainPath, pluginMainContent);
await writeVaultFile(vaultA.path, pluginStylesPath, pluginStylesContent);
if (testAutomatic) {
await writeVaultFile(vaultA.path, pluginDataPath, pluginDataContent);
await setVaultFileMtime(vaultA.path, pluginStylesPath, targetPluginMtime);
await setVaultFileMtime(vaultA.path, pluginDataPath, sourcePluginMtime);
}
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
@@ -627,6 +954,14 @@ async function main(): Promise<void> {
await setVaultFileMtime(vaultB.path, fixture.path, targetPluginMtime);
}
}
if (testAutomatic) {
for (const fixture of pluginFixtures) {
await writeVaultFile(vaultB.path, fixture.path, fixture.target);
}
await writeVaultFile(vaultB.path, pluginDataPath, targetPluginDataContent);
await setVaultFileMtime(vaultB.path, pluginStylesPath, sourcePluginMtime);
await setVaultFileMtime(vaultB.path, pluginDataPath, targetPluginMtime);
}
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
@@ -636,6 +971,9 @@ async function main(): Promise<void> {
for (const fixture of pluginFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
}
if (testAutomatic) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, pluginDataPath);
}
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
@@ -650,15 +988,35 @@ async function main(): Promise<void> {
pluginName,
3
);
const pluginDataEntry = testAutomatic
? await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName)
: undefined;
if (testAutomatic && initialAction !== "Overwrite Remote") {
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, true);
await evalObsidianJson<unknown>(
context.cliBinary,
[
"(async()=>{",
"const hidden=app.plugins.plugins['obsidian-livesync'].core.getAddOn('HiddenFileSync');",
`const paths=${JSON.stringify([pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath])};`,
"const result=await hidden.initialiseInternalFileSync('pushForce',false,paths);",
"if(result!==true) throw new Error('Could not prepare source hidden-file documents.');",
"return JSON.stringify({initialised:result});",
"})()",
].join(""),
session.cliEnv
);
await setHiddenSyncEnabledForFixture(context.cliBinary, session.cliEnv, false);
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
const entries = [entry, configEntry, ...pluginEntries];
const entries = [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])];
return entries.every(
(target) => ids.has(target.id) && target.children.every((childId) => ids.has(childId))
);
});
for (const target of [entry, configEntry, ...pluginEntries]) {
for (const target of [entry, configEntry, ...pluginEntries, ...(pluginDataEntry ? [pluginDataEntry] : [])]) {
const remoteEntry = await fetchCouchDbDocument(context.couchDb, context.dbName, target.id);
if (
!remoteEntry.path?.startsWith("/\\:") ||
@@ -678,8 +1036,15 @@ async function main(): Promise<void> {
throw new Error("The remote feature list does not declare encrypted internal Metadata.");
}
await session.app.stop();
if (testAutomatic && initialAction === "Overwrite Remote") {
for (const path of [pluginManifestPath, pluginMainPath, pluginStylesPath, pluginDataPath]) {
await removeVaultFile(vaultA.path, path);
}
}
session = await startConfiguredSession(context, vaultB, targetDeviceName);
session = await startConfiguredSession(context, vaultB, targetDeviceName, {
pluginSyncExtendedSetting: testAutomatic ? existingAutomaticSelections : {},
});
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await storeCustomisationFile(context.cliBinary, session.cliEnv, fixture.path);
@@ -692,6 +1057,15 @@ async function main(): Promise<void> {
}
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "SNIPPET", snippetName, sourceDeviceName);
if (testAutomatic) {
await waitForCustomisationEntry(
context.cliBinary,
session.cliEnv,
"PLUGIN_DATA",
pluginName,
sourceDeviceName
);
}
if (testVisibility) {
for (const fixture of visibilityFixtures) {
const name = fixture.path.split("/").pop() ?? fixture.path;
@@ -721,10 +1095,43 @@ async function main(): Promise<void> {
try {
if (testVisibility) await inspectCustomisationVisibility(session);
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
if (testAutomatic) {
await inspectAutomaticSyncSelection(context, session, vaultB, pluginName, initialAction);
}
} catch (error) {
await session.app.stop().catch(() => undefined);
throw error;
}
if (testAutomatic) {
await session.app.stop();
const receivingVault = initialAction === "Overwrite Remote" ? vaultA : await createTemporaryVault();
session = await startConfiguredSession(context, receivingVault, sourceDeviceName, {
syncInternalFiles: true,
});
try {
await pushLocalChanges(context.cliBinary, session.cliEnv);
const expectedStyles =
initialAction === "Overwrite Remote" ? targetPluginStylesContent : pluginStylesContent;
const expectedData = initialAction === "Overwrite Local" ? pluginDataContent : targetPluginDataContent;
const receivedStyles = await waitForPathContent(
receivingVault.path,
pluginStylesPath,
(content) => content === expectedStyles
);
const receivedData = await waitForPathContent(
receivingVault.path,
pluginDataPath,
(content) => content === expectedData
);
assertEqual(receivedStyles, expectedStyles, "Automatic plug-in group lost PLUGIN_MAIN files.");
assertEqual(receivedData, expectedData, "Automatic plug-in group lost the PLUGIN_DATA file.");
} finally {
await session.app.stop();
if (receivingVault !== vaultA) await receivingVault.dispose();
}
console.log(`Automatic ${initialAction} initialised and transferred both plug-in group children.`);
return;
}
if (testCase !== "all") {
await session.app.stop();
console.log(`Customisation Sync ${testCase} regression case passed.`);
@@ -841,6 +1248,18 @@ async function main(): Promise<void> {
}
}
async function main(): Promise<void> {
const testCase = selectedCase();
if (testCase === "automatic") {
for (const action of ["Overwrite Remote", "Overwrite Local", "Use newer"] as const) {
console.log(`Checking Automatic initial action: ${action}`);
await runWorkflow(testCase, action);
}
} else {
await runWorkflow(testCase);
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
@@ -63,6 +63,21 @@ const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json";
const targetPath = ".obsidian/livesync-targeted/only-a.json";
const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000);
const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation";
const disabledInitialisationProbeKey = "__livesyncE2EDisabledHiddenInitialisation";
type DisabledInitialisationSnapshot = {
syncInternalFiles: boolean;
hiddenFiles: { id: string; path: string; rev: string; deleted: boolean }[];
processedFiles: [string, string | undefined][];
processedDatabaseFiles: [string, string | undefined][];
};
type DisabledInitialisationObservation = {
result: boolean | null;
before: DisabledInitialisationSnapshot;
after: DisabledInitialisationSnapshot;
progressNotices: string[];
};
type RunnerContext = {
binary: string;
@@ -133,6 +148,114 @@ async function waitForPathDeleted(
throw new Error(`Timed out waiting for deleted file: ${join(vaultPath, path)}`);
}
async function assertDisabledInitialisationIsNoOp(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault
): Promise<void> {
const snippetExisted = await pathExists(vault.path, snippetPath);
const originalSnippet = snippetExisted ? await readVaultFile(vault.path, snippetPath) : undefined;
const originalJson = await readVaultFile(vault.path, mergeJsonPath);
const preservedSnippet = "body { --disabled-hidden-initialisation: preserved; }\n";
const preservedJson = JSON.stringify({ disabledInitialisation: "preserved" }, null, 4) + "\n";
await writeVaultFile(vault.path, snippetPath, preservedSnippet);
await writeVaultFile(vault.path, mergeJsonPath, preservedJson);
try {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.evaluate((stateKey) => {
const state = { progressNotices: [] as string[] };
const observer = new MutationObserver(() => {
for (const notice of document.querySelectorAll<HTMLElement>(".notice")) {
const text = notice.textContent?.trim() ?? "";
if (text.includes("[⚙") && !state.progressNotices.includes(text)) {
state.progressNotices.push(text);
}
}
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
(globalThis as unknown as Record<string, { progressNotices: string[]; observer: MutationObserver }>)[
stateKey
] = { ...state, observer };
}, disabledInitialisationProbeKey);
});
const observation = await evalObsidianJson<DisabledInitialisationObservation>(
context.cliBinary,
[
"(async()=>{",
`const targetFiles=${JSON.stringify([snippetPath, mergeJsonPath])};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const addOn=core.getAddOn('HiddenFileSync');",
"const snapshot=async()=>{",
"const files=await addOn.getAllDatabaseFiles();",
"const markers=(cache)=>Array.from(cache.keys()).sort().map((path)=>[path,cache.get(path)]);",
"return {",
"syncInternalFiles:core.settings.syncInternalFiles,",
"hiddenFiles:files.map((entry)=>({id:entry._id,path:addOn.getPath(entry),rev:entry._rev??'',deleted:!!(entry.deleted||entry._deleted)})).sort((a,b)=>a.path.localeCompare(b.path)),",
"processedFiles:markers(addOn._fileInfoLastProcessed),",
"processedDatabaseFiles:markers(addOn._databaseInfoLastProcessed),",
"};};",
"const before=await snapshot();",
"const result=await addOn.initialiseInternalFileSync('pullForce',true,targetFiles);",
"const after=await snapshot();",
"return JSON.stringify({result:result===undefined?null:result,before,after});",
"})()",
].join(""),
session.cliEnv,
hiddenFileCliTimeoutMs
);
const progressNotices = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
return await page.evaluate((stateKey) => {
const state = (
globalThis as unknown as Record<
string,
{ progressNotices: string[]; observer: MutationObserver } | undefined
>
)[stateKey];
state?.observer.disconnect();
return state?.progressNotices ?? [];
}, disabledInitialisationProbeKey);
});
const failures: string[] = [];
if (observation.result !== false)
failures.push(`expected false result, received ${String(observation.result)}`);
if (observation.before.syncInternalFiles !== false || observation.after.syncInternalFiles !== false) {
failures.push("Hidden File Sync setting changed while initialisation was disabled");
}
if (JSON.stringify(observation.before) !== JSON.stringify(observation.after)) {
failures.push("local hidden database state or processed markers changed");
}
if ((await readVaultFile(vault.path, snippetPath)) !== preservedSnippet) {
failures.push("the existing local snippet was deleted or replaced");
}
if ((await readVaultFile(vault.path, mergeJsonPath)) !== preservedJson) {
failures.push("the existing local JSON file was replaced");
}
if (progressNotices.length > 0)
failures.push(`initialisation created progress Notices: ${JSON.stringify(progressNotices)}`);
if (failures.length > 0) {
throw new Error(
`Disabled Hidden File Sync initialisation was not a no-op: ${failures.join("; ")}\n${JSON.stringify({
observation,
progressNotices,
})}`
);
}
console.log(
"Disabled Hidden File Sync initialisation returned false without changing hidden files or cached state."
);
} finally {
await writeVaultFile(vault.path, mergeJsonPath, originalJson);
if (snippetExisted && originalSnippet !== undefined) {
await writeVaultFile(vault.path, snippetPath, originalSnippet);
} else {
await removeVaultFile(vault.path, snippetPath);
}
}
}
function hasJsonValues(content: string, values: Record<string, unknown>): boolean {
try {
const parsed = JSON.parse(content) as Record<string, unknown>;
@@ -649,6 +772,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
"Transient start-up Notices remained before the Hidden File Sync initialisation check."
);
});
await assertDisabledInitialisationIsNoOp(context, session, vault);
await withObsidianPage(port, async (page) => {
await page.evaluate((stateKey) => {
const obsidianApp = (globalThis as typeof globalThis & { app: any }).app;
@@ -0,0 +1,962 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import type { Page } from "playwright";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
loadCouchDbConfig,
makeUniqueDatabaseName,
} from "../runner/couchdb.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertE2eCompatibilityUnpaused,
configureCouchDb,
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
prepareRemote,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
type Mode = "normal" | "ignored-batch-setting" | "enabled-batch";
type Slot = { name: string; vault: TemporaryVault; session?: ObsidianLiveSyncSession };
type Leaf = { rev: string; ancestry: string[]; logicalDeleted: boolean; pouchDeleted: boolean; body: string | null };
type Snapshot = {
path: string;
rev: string | null;
content: string | null;
fileMtime: number | null;
databaseMtime: number | null;
exists: boolean;
leaves: Leaf[];
waiting: Array<{ path: string; type: string; since: number }>;
queued: Array<{ path: string | null; type: string }>;
shouldBatchSave: boolean;
};
type Pair = { a: Page; b: Page };
const diagnostics = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/issue1207-livesync-batch";
const artifactRoot = process.env.ISSUE1207_ARTIFACT_ROOT ?? process.cwd();
const evidence: Record<string, unknown> = { cases: [], observations: [] };
const cases = evidence.cases as Array<Record<string, unknown>>;
const observations = evidence.observations as unknown[];
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const prefix = "issue1207-batch-";
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function hashes() {
return Object.fromEntries(
["main.js", "manifest.json", "styles.css"].map((name) => [
name,
createHash("sha256")
.update(new Uint8Array(readFileSync(join(artifactRoot, name))))
.digest("hex"),
])
);
}
async function availablePort(): Promise<number> {
const server = createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
check(address && typeof address !== "string", "Missing debug port");
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
return address.port;
}
async function evaluate<T>(page: Page, code: string): Promise<T> {
return await page.evaluate(`(async()=>{
const core=app.plugins.plugins['obsidian-livesync'].core;
const manager=core.serviceModules.storageAccess.vaultManager;
${code}
})()`);
}
async function installProbe(page: Page): Promise<void> {
await evaluate(
page,
`
if(globalThis.__issue1207Batch) return;
const probe={events:[],changes:[]};
globalThis.__issue1207Batch=probe;
for(const type of ['create','modify','rename','delete']) {
app.vault.on(type,(file,oldPath)=>{
const path=file.path;
if(!path.startsWith(${JSON.stringify(prefix)})) return;
const item={type,path,oldPath:oldPath??null,at:Date.now(),content:null};
probe.events.push(item);
if(type!=='delete' && file.stat) {
void app.vault.read(file).then(body=>item.content=body).catch(error=>item.error=String(error));
}
});
}
probe.feed=core.localDatabase.localDatabase.changes({live:true,since:'now',include_docs:true})
.on('change',change=>{
if(typeof change.doc?.path==='string' && change.doc.path.startsWith(${JSON.stringify(prefix)})) {
probe.changes.push({path:change.doc.path,rev:change.doc._rev,
logicalDeleted:!!change.doc.deleted,pouchDeleted:!!change.doc._deleted,at:Date.now()});
}
}).on('error',error=>probe.changes.push({error:String(error),at:Date.now()}));
`
);
}
async function options(page: Page, mode: Mode, minimum = 2, maximum = 6): Promise<void> {
await evaluate(
page,
`
const settings=core.services.setting.currentSettings();
Object.assign(settings,{liveSync:true,periodicReplication:false,
batchSave:${mode === "ignored-batch-setting"},batchSaveInLiveSync:${mode === "enabled-batch"},
batchSaveMinimumDelay:${minimum},batchSaveMaximumDelay:${maximum},deleteMetadataOfDeletedFiles:false});
return {liveSync:settings.liveSync,batchSave:settings.batchSave,
batchSaveInLiveSync:settings.batchSaveInLiveSync,shouldBatchSave:manager.shouldBatchSave};
`
);
}
async function snapshot(page: Page, path: string): Promise<Snapshot> {
return await evaluate<Snapshot>(
page,
`
const path=${JSON.stringify(path)};
const id=await core.services.path.path2id(path);
const row=(await core.localDatabase.allDocsRaw({keys:[id],include_docs:true})).rows[0];
let leaves=[];
if(row?.value?.rev) {
const rawLeaves=await core.localDatabase.getRaw(id,{open_revs:'all',revs:true});
for(const item of rawLeaves) {
if(!item.ok) continue;
const raw=item.ok;
let body=null;
if(!raw._deleted) {
const entry=await core.localDatabase.getDBEntryFromMeta(raw,false,false,true);
if(entry!==false) body=Array.isArray(entry.data)?entry.data.join(''):entry.data;
}
leaves.push({rev:raw._rev,logicalDeleted:!!raw.deleted,pouchDeleted:!!raw._deleted,body,
ancestry:raw._revisions?.ids.map((hash,i)=>(raw._revisions.start-i)+'-'+hash)??[]});
}
}
const file=app.vault.getAbstractFileByPath(path);
return {path,rev:row?.value?.rev??null,exists:!!file?.stat,
fileMtime:file?.stat?.mtime??null,databaseMtime:row?.doc?.mtime??null,
content:file?.stat?await app.vault.read(file):null,leaves,
waiting:[...manager._waitingMap].map(([key,value])=>({path:key,type:value.type,since:value.since})),
queued:manager.bufferedQueuedItems.map(item=>({path:item.args?.file?.path??null,type:item.type})),
shouldBatchSave:!!manager.shouldBatchSave};
`
);
}
function liveLeaves(state: Snapshot) {
return state.leaves.filter((leaf) => !leaf.logicalDeleted && !leaf.pouchDeleted);
}
async function waitFor(
page: Page,
path: string,
predicate: (state: Snapshot) => boolean,
timeout = 20000
): Promise<Snapshot> {
const deadline = Date.now() + timeout;
let state: Snapshot;
do {
state = await snapshot(page, path);
if (predicate(state)) return state;
await pause(100);
} while (Date.now() < deadline);
throw new Error(`State did not settle: ${JSON.stringify(state)}`);
}
async function settled(pair: Pair, path: string, body: string): Promise<[Snapshot, Snapshot]> {
const ready = (state: Snapshot) =>
state.content === body &&
liveLeaves(state).length === 1 &&
liveLeaves(state)[0].body === body &&
state.leaves.length === 1 &&
!state.waiting.some((item) => item.path === path);
const deadline = Date.now() + 20000;
let states: [Snapshot, Snapshot];
do {
states = await Promise.all([snapshot(pair.a, path), snapshot(pair.b, path)]);
// The row, leaves, and storage are read asynchronously. Require each
// sample to agree internally, then compare both current devices.
if (
states.every((state) => ready(state) && state.rev === liveLeaves(state)[0].rev) &&
states[0].rev === states[1].rev
)
return states;
await pause(100);
} while (Date.now() < deadline);
throw new Error(`Devices did not converge: ${JSON.stringify(states)}`);
}
async function create(page: Page, path: string, body: string): Promise<void> {
await evaluate(page, `await app.vault.create(${JSON.stringify(path)},${JSON.stringify(body)});`);
}
async function modify(page: Page, path: string, body: string): Promise<void> {
await evaluate(
page,
`await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),${JSON.stringify(body)});`
);
}
async function trace(page: Page, path: string) {
return await evaluate(
page,
`return {
events:globalThis.__issue1207Batch.events.filter(item=>item.path===${JSON.stringify(path)}||item.oldPath===${JSON.stringify(path)}),
changes:globalThis.__issue1207Batch.changes.filter(item=>item.path===${JSON.stringify(path)}||item.error)};`
);
}
async function main(): Promise<void> {
await mkdir(diagnostics, { recursive: true });
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
check(cli.binary, "Missing Obsidian CLI");
const cliBinary = cli.binary;
const config = await loadCouchDbConfig(process.env.ISSUE1207_COUCHDB_ENV);
check(["127.0.0.1", "localhost"].includes(new URL(config.uri).hostname), "Use a disposable local CouchDB");
await assertCouchDbReachable(config);
const dbName = makeUniqueDatabaseName(config.dbPrefix, "live-batch");
const couch = { ...config, dbName };
const overrides = {
doctorProcessedVersion: "1.0.0",
notifyThresholdOfRemoteStorageSize: -1,
liveSync: false,
periodicReplication: false,
batchSave: false,
batchSaveInLiveSync: false,
syncOnStart: false,
syncOnSave: false,
syncOnFileOpen: false,
syncOnEditorSave: false,
syncAfterMerge: false,
checkConflictOnlyOnOpen: true,
showMergeDialogOnlyOnActive: true,
disableMarkdownAutoMerge: true,
resolveConflictsByNewerFile: false,
writeDocumentsIfConflicted: false,
writeLogToTheFile: false,
};
const before = hashes();
evidence.artefactsBefore = before;
evidence.manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf8"));
evidence.commonlibVersion = JSON.parse(
await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8")
).version;
evidence.sourceBaseRevision = "73a14d4e4b0b414a1acfe170d01500abb3dca2e9";
evidence.unreleasedImplementation = true;
if (process.env.ISSUE1207_COMMONLIB_ARTEFACT) {
evidence.commonlibArtefactSha256 = createHash("sha256")
.update(new Uint8Array(readFileSync(process.env.ISSUE1207_COMMONLIB_ARTEFACT)))
.digest("hex");
}
evidence.timing = { minimumSeconds: 2, maximumSeconds: 6, incomingAndManualSeconds: { minimum: 5, maximum: 60 } };
const slots: Slot[] = [];
let ownedDatabase = false;
function redact(value: unknown): string {
return JSON.stringify(value, null, 2)
.split(config.password)
.join("<redacted>")
.split(Buffer.from(`${config.username}:${config.password}`).toString("base64"))
.join("<redacted>");
}
async function start(slot: Slot, restart = false) {
console.log(JSON.stringify({ phase: "starting", device: slot.name, restart }));
slot.session = await startObsidianLiveSyncSession({
binary,
cliBinary,
artifactRoot,
vault: slot.vault,
startupGraceMs: 1000,
pluginStartup: restart ? "natural" : undefined,
pluginData: restart ? undefined : createE2eCouchDbPluginData(couch, overrides),
localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(slot.vault.name),
env: { E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(await availablePort()) },
lifecycle: {
beforeLaunch: async () => {
for (const registry of [
join(slot.vault.homePath, ".config", "obsidian", "obsidian.json"),
join(slot.vault.homePath, "Library", "Application Support", "obsidian", "obsidian.json"),
join(slot.vault.xdgConfigPath, "obsidian", "obsidian.json"),
join(slot.vault.userDataPath, "obsidian.json"),
]) {
const state = JSON.parse(await readFile(registry, "utf8"));
state.updateDisabled = true;
await writeFile(registry, JSON.stringify(state));
}
},
},
});
console.log(
JSON.stringify({
phase: "device-started",
device: slot.name,
port: slot.session.remoteDebuggingPort,
vault: slot.vault.path,
})
);
await waitForLiveSyncCoreReady(cliBinary, slot.session.cliEnv);
await assertE2eCompatibilityUnpaused(cliBinary, slot.session.cliEnv, slot.session.remoteDebuggingPort);
if (!restart) {
await configureCouchDb(cliBinary, slot.session.cliEnv, couch, overrides);
await waitForLiveSyncCoreReady(cliBinary, slot.session.cliEnv);
await prepareRemote(cliBinary, slot.session.cliEnv);
}
}
async function stop(slot: Slot) {
if (!slot.session) return;
await slot.session.app.stop();
await writeFile(
join(diagnostics, `process-${slot.name}-${slot.session.remoteDebuggingPort}.json`),
redact(slot.session.app.output())
);
slot.session = undefined;
}
async function withPair<T>(callback: (pair: Pair) => Promise<T>): Promise<T> {
check(slots[0].session && slots[1].session, "Both devices must be running");
return await withObsidianPage(
slots[0].session.remoteDebuggingPort,
async (a) =>
await withObsidianPage(slots[1].session!.remoteDebuggingPort, async (b) => {
await installProbe(a);
await installProbe(b);
return await callback({ a, b });
})
);
}
async function run(name: string, body: () => Promise<unknown>) {
if (process.env.ISSUE1207_CASE_FILTER && !new RegExp(process.env.ISSUE1207_CASE_FILTER).test(name)) return;
console.log(JSON.stringify({ phase: "case-start", name }));
try {
const result = await body();
cases.push({ name, passed: true, result });
console.log(JSON.stringify({ phase: "case-passed", name }));
} catch (error) {
const failure = error instanceof Error ? error.stack : String(error);
cases.push({ name, passed: false, error: failure });
console.log(JSON.stringify({ phase: "case-failed", name, error: failure }));
await withPair(async (pair) => {
let flushFailure: unknown;
for (const [device, page] of Object.entries(pair)) {
observations.push({
name,
device,
probe: await evaluate(
page,
`return {
events:globalThis.__issue1207Batch.events,changes:globalThis.__issue1207Batch.changes,
pending:(await manager.adapter.persistence.loadSnapshot()).map(item=>({
type:item.type,path:item.args?.file?.path,oldPath:item.args?.oldPath}))};`
),
});
await page.screenshot({ path: join(diagnostics, `${name}-${device}-failure.png`) });
try {
await evaluate(page, "await manager.waitForIdle();");
} catch (error) {
observations.push({ name, device, flushFailure: String(error) });
flushFailure ??= error;
}
}
if (flushFailure) throw flushFailure;
});
await pause(500);
}
await writeFile(join(diagnostics, "evidence.json"), redact(evidence));
}
try {
await createCouchDbDatabase(config, dbName);
ownedDatabase = true;
for (const name of ["a", "b"]) {
const slot = { name, vault: await createTemporaryVault(`issue1207-live-batch-${name}-`) };
slots.push(slot);
await start(slot);
}
evidence.runtime = await withPair(async (pair) => {
const versions = [];
for (const page of [pair.a, pair.b]) {
versions.push(
await evaluate(
page,
`let appVersion=null;
try {appVersion=JSON.parse(require('fs').readFileSync(require('path').join(process.resourcesPath,'app.asar','package.json'),'utf8')).version;} catch {}
return {appVersion,electronVersion:process.versions.electron,
pluginVersion:app.plugins.plugins['obsidian-livesync'].manifest.version};`
)
);
await options(page, "normal");
const outcome = await evaluate(
page,
`return await core.services.replication.startContinuous({
trigger:'daemon',interaction:{kind:'forbidden'}});`
);
check(
(outcome as { status?: string })?.status === "completed",
`Continuous start failed: ${JSON.stringify(outcome)}`
);
}
return versions;
});
evidence.connectivity = await withPair(async (pair) => {
const path = `${prefix}connectivity.md`;
await create(pair.a, path, "Connectivity probe\n");
const first = await settled(pair, path, "Connectivity probe\n");
await modify(pair.b, path, "Bidirectional connectivity probe\n");
const second = await settled(pair, path, "Bidirectional connectivity probe\n");
return { first, second };
});
for (const mode of ["normal", "ignored-batch-setting", "enabled-batch"] as const) {
for (let repeat = 0; repeat < 2; repeat++) {
await run(`${mode}-coalesce-${repeat}`, () =>
withPair(async (pair) => {
for (const page of [pair.a, pair.b]) await options(page, mode);
const source = repeat === 0 ? pair.a : pair.b;
const receiver = repeat === 0 ? pair.b : pair.a;
const path = `${prefix}${mode}-edits-${repeat}.md`;
await create(source, path, "Initial body\n");
const initial = (await settled(pair, path, "Initial body\n"))[0];
for (let edit = 0; edit < 6; edit++) {
await modify(source, path, `Edit ${edit}\n`);
await pause(150);
}
const during = await snapshot(source, path);
const final = await settled(pair, path, "Edit 5\n");
const finalSource = repeat === 0 ? final[0] : final[1];
const delta = Number(finalSource.rev?.split("-")[0]) - Number(initial.rev?.split("-")[0]);
if (mode === "enabled-batch") {
check(
during.waiting.some((item) => item.path === path),
"Batch did not retain the pending edit"
);
check(delta === 1, `Repeated edits created ${delta} revisions`);
} else {
check(!during.shouldBatchSave, "LiveSync batching was enabled without an explicit choice");
check(delta >= 3, `The control did not expose enough edits: ${delta}`);
}
return {
path,
initial,
during,
final,
revisionDelta: delta,
source: await trace(source, path),
receiver: await trace(receiver, path),
};
})
);
}
await run(`${mode}-template-lifecycle`, () =>
withPair(async (pair) => {
for (const page of [pair.a, pair.b]) await options(page, mode);
const oldPath = `${prefix}${mode}-untitled.md`;
const path = `${prefix}${mode}-completed.md`;
await create(pair.a, oldPath, "");
await pause(300);
await modify(pair.a, oldPath, "Template in progress\n");
await pause(300);
await evaluate(
pair.a,
`await app.fileManager.renameFile(app.vault.getFileByPath(${JSON.stringify(oldPath)}),${JSON.stringify(path)});`
);
await pause(300);
await modify(pair.a, path, "Completed template\n");
const during = await snapshot(pair.a, path);
const final = await settled(pair, path, "Completed template\n");
await waitFor(pair.b, oldPath, (state) => !state.exists && liveLeaves(state).length === 0);
return {
oldPath,
path,
during,
final,
oldSource: await snapshot(pair.a, oldPath),
oldReceiver: await snapshot(pair.b, oldPath),
source: await trace(pair.a, path),
receiver: await trace(pair.b, path),
oldSourceTrace: await trace(pair.a, oldPath),
oldReceiverTrace: await trace(pair.b, oldPath),
};
})
);
for (const physical of [false, true]) {
await run(`${mode}-recreate-${physical}`, () =>
withPair(async (pair) => {
for (const page of [pair.a, pair.b]) {
await options(page, mode);
await evaluate(
page,
`core.services.setting.currentSettings().deleteMetadataOfDeletedFiles=${physical};`
);
}
const path = `${prefix}${mode}-recreate-${physical}.md`;
await create(pair.a, path, "Transient body\n");
await pause(400);
await evaluate(
pair.a,
`await app.vault.delete(app.vault.getFileByPath(${JSON.stringify(path)}),true);`
);
await pause(400);
await create(pair.a, path, "Recreated body\n");
const final = await settled(pair, path, "Recreated body\n");
return {
physical,
final,
source: await trace(pair.a, path),
receiver: await trace(pair.b, path),
};
})
);
}
}
for (const incoming of ["identical", "different", "delete"] as const) {
await run(`enabled-pending-incoming-${incoming}`, () =>
withPair(async (pair) => {
await options(pair.a, "enabled-batch", 5, 60);
await options(pair.b, "normal", 5, 60);
const path = `${prefix}pending-${incoming}.md`;
await create(pair.b, path, "Shared base\n");
const initial = await settled(pair, path, "Shared base\n");
const local = "Local edit\n";
await modify(pair.a, path, local);
const pending = await waitFor(pair.a, path, (state) =>
state.waiting.some((item) => item.path === path)
);
check(pending.rev === initial[0].rev, "Local edit reached the DB before incoming delivery");
const sentAt = Date.now();
if (incoming === "delete") {
await evaluate(
pair.b,
`await app.vault.delete(app.vault.getFileByPath(${JSON.stringify(path)}),true);`
);
} else await modify(pair.b, path, incoming === "identical" ? local : "Remote edit\n");
const arrived = await waitFor(
pair.a,
path,
(state) =>
incoming === "identical"
? state.rev !== initial[0].rev && liveLeaves(state).some((leaf) => leaf.body === local)
: state.leaves.some((leaf) => leaf.body === local) &&
state.leaves.some((leaf) =>
incoming === "delete" ? leaf.logicalDeleted : leaf.body === "Remote edit\n"
),
4000
);
check(
arrived.waiting.some((item) => item.path === path),
"The incoming assertion did not occur during Batch waiting"
);
const leavesBefore = arrived.leaves.map((leaf) => leaf.rev).sort();
if (incoming !== "identical") {
check(arrived.content === local, "Incoming reflection overwrote the pending local edit");
check(arrived.leaves.length === 2, "Expected exactly two genuine concurrent branches");
check(
arrived.leaves.every((leaf) => leaf.ancestry[1] === initial[0].rev),
"Concurrent branches lost their shared base"
);
}
await pause(5600);
const after = await snapshot(pair.a, path);
const peer = await snapshot(pair.b, path);
check(!after.waiting.some((item) => item.path === path), "Batch timer did not complete");
check(
JSON.stringify(after.leaves.map((leaf) => leaf.rev).sort()) === JSON.stringify(leavesBefore),
"The delayed event created another revision"
);
check(
JSON.stringify(peer.leaves.map((leaf) => leaf.rev).sort()) === JSON.stringify(leavesBefore),
"Both branches did not converge to the peer"
);
if (incoming === "identical")
check(after.leaves.length === 1, "Identical content created a branch");
return { initial, pending, arrived, after, peer, incomingLatencyMs: Date.now() - sentAt - 5600 };
})
);
}
await run("enabled-maximum-delay", () =>
withPair(async (pair) => {
await options(pair.a, "enabled-batch", 2, 6);
await options(pair.b, "normal");
const path = `${prefix}maximum.md`;
await create(pair.b, path, "Initial maximum body\n");
const initial = await settled(pair, path, "Initial maximum body\n");
const started = Date.now();
let finalBody = "";
for (let edit = 0; edit < 26; edit++) {
finalBody = `Continuous edit ${edit}\n`;
await modify(pair.a, path, finalBody);
await pause(350);
}
const stopped = Date.now();
const final = await settled(pair, path, finalBody);
const changes = await evaluate<Array<{ at: number; rev: string }>>(
pair.a,
`return globalThis.__issue1207Batch.changes.filter(item=>item.path===${JSON.stringify(path)}&&item.rev!==${JSON.stringify(initial[0].rev)});`
);
check(changes.length >= 2, "Continuous edits did not produce bounded intermediate and final saves");
const first = changes[0].at - started;
check(first >= 5000 && first <= 8000, `Unexpected first maximum-delay flush: ${first}ms`);
check(changes[0].at < stopped - 1000, "The maximum flush only occurred after typing stopped");
return { started, stopped, firstSaveMs: first, changes, final };
})
);
await run("enabled-many-paths", () =>
withPair(async (pair) => {
await options(pair.a, "enabled-batch", 2, 6);
await options(pair.b, "normal");
const started = Date.now();
for (let index = 0; index < 12; index++)
await create(pair.a, `${prefix}many-${index}.md`, `File ${index}\n`);
const final = [];
for (let index = 0; index < 12; index++)
final.push(await settled(pair, `${prefix}many-${index}.md`, `File ${index}\n`));
return { elapsedMs: Date.now() - started, final };
})
);
await run("enabled-manual-sync", () =>
withPair(async (pair) => {
await options(pair.a, "enabled-batch", 5, 60);
await options(pair.b, "normal");
const path = `${prefix}manual.md`;
await create(pair.b, path, "Before manual sync\n");
await settled(pair, path, "Before manual sync\n");
await modify(pair.a, path, "After manual sync\n");
const pending = await waitFor(pair.a, path, (state) =>
state.waiting.some((item) => item.path === path)
);
const started = Date.now();
const outcome = await evaluate(pair.a, "return await core.services.replication.replicate(true);");
const atReturn = await snapshot(pair.a, path);
const final = await settled(pair, path, "After manual sync\n");
const elapsedMs = Date.now() - started;
check(elapsedMs < 4000, `Manual sync waited for the Batch timer: ${elapsedMs}ms`);
return { pending, outcome, atReturn, final, elapsedMs };
})
);
const saveAfterEdit = process.env.ISSUE1207_RESTART_SAVE_AFTER_EDIT === "true";
const restartBucket = process.env.ISSUE1207_RESTART_BUCKET;
check(
!restartBucket || ["same", "exact", "different"].includes(restartBucket),
"Unknown restart timestamp condition"
);
const restartName = restartBucket
? `enabled-inflight-restart-${restartBucket}`
: saveAfterEdit
? "enabled-settings-save-restart"
: "enabled-restart";
await run(restartName, async () => {
const path = `${prefix}restart.md`;
const beforeRestart = await withPair(async (pair) => {
await options(pair.a, "enabled-batch", 5, 60);
await options(pair.b, "normal");
if (!saveAfterEdit)
await evaluate(
pair.a,
"await core.services.setting.saveSettingData(); await core.services.control.applySettings();"
);
await create(pair.b, path, "Before restart\n");
const initial = await settled(pair, path, "Before restart\n");
if (restartBucket) {
check(initial[0].databaseMtime !== null, "Missing initial database mtime");
const mtime =
restartBucket === "exact"
? initial[0].databaseMtime
: Math.floor(initial[0].databaseMtime / 2000) * 2000 +
(restartBucket === "same" ? 1000 : 3000);
await evaluate(
pair.a,
`const original=manager.requestProcessQueue.bind(manager);
manager.requestProcessQueue=async item=>{
if(item.args?.file?.path===${JSON.stringify(path)}) {
globalThis.__issue1207Batch.inflightPaused=true;
await new Promise(resolve=>setTimeout(resolve,30000));
}
return await original(item);
};
await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),"Retained after restart\\n",{mtime:${mtime}});`
);
} else await modify(pair.a, path, "Retained after restart\n");
const pending = await waitFor(pair.a, path, (state) =>
state.waiting.some((item) => item.path === path)
);
if (saveAfterEdit) await evaluate(pair.a, "await core.services.setting.saveSettingData();");
if (restartBucket)
await evaluate(
pair.a,
`const deadline=Date.now()+core.services.setting.currentSettings().batchSaveMinimumDelay*1000+3000;
while(!globalThis.__issue1207Batch.inflightPaused&&Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,10));
if(!globalThis.__issue1207Batch.inflightPaused) throw new Error('The in-flight test gate did not start');`
);
await evaluate(pair.a, "await manager._takeSnapshot();");
check(pending.rev === initial[0].rev, "Restart test did not retain a pending edit");
if (restartBucket) {
const inFlight = await snapshot(pair.a, path);
const saved = await evaluate<Array<{ path: string }>>(
pair.a,
`return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));`
);
check(
inFlight.rev === initial[0].rev && !inFlight.waiting.some((item) => item.path === path),
"The in-flight fixture did not stop between gate release and database storage"
);
check(
saved.some((item) => item.path === path),
"The in-flight event was lost from the recoverable snapshot"
);
check(inFlight.fileMtime !== null && inFlight.databaseMtime !== null, "Missing in-flight mtimes");
const sameBucket =
Math.floor(inFlight.fileMtime / 2000) === Math.floor(inFlight.databaseMtime / 2000);
check(sameBucket === (restartBucket !== "different"), "The timestamp condition was not applied");
if (restartBucket === "exact")
check(inFlight.fileMtime === inFlight.databaseMtime, "Raw mtimes differ");
observations.push({
phase: "inflight-before-stop",
restartBucket,
pauseBeforeDatabaseMs: 30000,
inFlight,
saved,
sameBucket,
});
}
if (!saveAfterEdit && !restartBucket) {
const strictState = await snapshot(pair.a, path);
const saved = await evaluate<Array<{ path: string }>>(
pair.a,
`return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));`
);
check(
strictState.rev === initial[0].rev && strictState.waiting.some((item) => item.path === path),
"Restart fixture flushed before shutdown"
);
check(
saved.some((item) => item.path === path),
"Restart fixture did not persist the pending event"
);
observations.push({
phase: "restart-verified-pending-snapshot",
strictState,
saved,
initialized: await evaluate(pair.a, "return await core.kvDB.get('initialized');"),
});
}
if (!saveAfterEdit)
observations.push({
phase: "restart-before-stop",
initial,
pending,
persisted: await evaluate(
pair.a,
`const events=await manager.adapter.persistence.loadSnapshot();
const settings=core.services.setting.currentSettings();
return {initialized:await core.kvDB.get('initialized'),restorationRequested:manager.snapShotRestored!==null,
settings:{isConfigured:settings.isConfigured,liveSync:settings.liveSync,batchSave:settings.batchSave,batchSaveInLiveSync:settings.batchSaveInLiveSync,
suspendFileWatching:settings.suspendFileWatching},
events:events?.map(item=>({type:item.type,path:item.args?.file?.path,
stat:item.args?.file?.stat,skipBatchWait:item.skipBatchWait}))};`
),
});
return { initial, pending };
});
await stop(slots[0]);
const persistedSettings = JSON.parse(
await readFile(join(slots[0].vault.path, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8")
);
observations.push({
phase: "restart-on-disk-settings",
settings: Object.fromEntries(
[
"isConfigured",
"liveSync",
"batchSave",
"batchSaveInLiveSync",
"suspendFileWatching",
"settingVersion",
].map((key) => [key, persistedSettings[key]])
),
});
await start(slots[0], true);
const afterRestart = await withPair(async (pair) => {
observations.push({
phase: "restart-after-boot",
state: await snapshot(pair.a, path),
persisted: await evaluate(
pair.a,
`const events=await manager.adapter.persistence.loadSnapshot();
const settings=core.services.setting.currentSettings();
return {initialized:await core.kvDB.get('initialized'),restorationRequested:manager.snapShotRestored!==null,
settings:{isConfigured:settings.isConfigured,liveSync:settings.liveSync,batchSave:settings.batchSave,batchSaveInLiveSync:settings.batchSaveInLiveSync,
suspendFileWatching:settings.suspendFileWatching},
events:events?.map(item=>({type:item.type,path:item.args?.file?.path,stat:item.args?.file?.stat}))};`
),
});
const result = await settled(pair, path, "Retained after restart\n");
check(result[0].shouldBatchSave, "The saved LiveSync batching choice was not restored");
return { result, restoredGuard: result[0].shouldBatchSave };
});
return { beforeRestart, afterRestart };
});
await run("enabled-after-write-restart", async () => {
const path = `${prefix}after-write.md`;
const saved = await withPair(async (pair) => {
await options(pair.a, "enabled-batch", 1, 3);
await options(pair.b, "normal");
await evaluate(
pair.a,
"await core.services.setting.saveSettingData(); await core.services.control.applySettings();"
);
await create(pair.b, path, "Before the durable write\n");
const initial = await settled(pair, path, "Before the durable write\n");
await evaluate(
pair.a,
`
const original=manager.requestProcessQueue.bind(manager);
manager.requestProcessQueue=async item=>{
await original(item);
if(item.args?.file?.path===${JSON.stringify(path)}) {
globalThis.__issue1207Batch.afterWritePaused=true;
await new Promise(resolve=>setTimeout(resolve,30000));
}
};
await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),"Durable before restart\\n");
const deadline=Date.now()+6000;
while(!globalThis.__issue1207Batch.afterWritePaused&&Date.now()<deadline) await new Promise(resolve=>setTimeout(resolve,20));
if(!globalThis.__issue1207Batch.afterWritePaused) throw new Error('The after-write gate did not start');
await manager._takeSnapshot();
`
);
const durable = await snapshot(pair.a, path);
const pending = await evaluate<Array<{ path: string }>>(
pair.a,
"return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));"
);
check(durable.rev !== initial[0].rev, "The database write did not complete before interruption");
check(
pending.some((item) => item.path === path),
"The after-write operation was not recoverable"
);
return { initial, durable, pending };
});
const restarts = [];
for (let attempt = 0; attempt < 2; attempt++) {
await stop(slots[0]);
await start(slots[0], true);
const final = await withPair((pair) => settled(pair, path, "Durable before restart\n"));
check(final[0].rev === saved.durable.rev, "Replay created a duplicate database revision");
restarts.push(final);
}
const later = await withPair(async (pair) => {
await modify(pair.a, path, "A later edit survives\n");
return await settled(pair, path, "A later edit survives\n");
});
return { saved, restarts, later };
});
await run("enabled-before-snapshot-restart-exact", async () => {
const path = `${prefix}before-snapshot.md`;
const beforeStop = await withPair(async (pair) => {
await options(pair.a, "enabled-batch", 5, 60);
await options(pair.b, "normal");
await evaluate(
pair.a,
"await core.services.setting.saveSettingData(); await core.services.control.applySettings();"
);
await create(pair.b, path, "Before the first snapshot\n");
const initial = await settled(pair, path, "Before the first snapshot\n");
check(initial[0].databaseMtime !== null, "Missing initial database mtime");
await evaluate(
pair.a,
`
await manager.waitForIdle();
const persistence=manager.adapter.persistence;
const original=persistence.saveSnapshot.bind(persistence);
persistence.saveSnapshot=async events=>{
if(events.some(item=>item.type==='CHANGED'&&item.args?.file?.path===${JSON.stringify(path)})) {
globalThis.__issue1207Batch.beforeSnapshotPaused=true;
await new Promise(resolve=>setTimeout(resolve,30000));
}
return await original(events);
};
await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),
"Recovered without an event snapshot\\n",{mtime:${initial[0].databaseMtime}});
const deadline=Date.now()+3000;
while(!globalThis.__issue1207Batch.beforeSnapshotPaused&&Date.now()<deadline)
await new Promise(resolve=>setTimeout(resolve,10));
if(!globalThis.__issue1207Batch.beforeSnapshotPaused)
throw new Error('The snapshot persistence gate did not start');
`
);
const state = await snapshot(pair.a, path);
const persisted = await evaluate<Array<{ path: string }>>(
pair.a,
"return (await manager.adapter.persistence.loadSnapshot()??[]).map(item=>({path:item.args?.file?.path}));"
);
check(state.rev === initial[0].rev, "The unsaved edit reached the database before interruption");
check(state.fileMtime === state.databaseMtime, "Raw mtimes differ before interruption");
check(!persisted.some((item) => item.path === path), "The interrupted edit already has a saved event");
return { initial, state, persisted };
});
await stop(slots[0]);
await start(slots[0], true);
const recovered = await withPair((pair) => settled(pair, path, "Recovered without an event snapshot\n"));
check(recovered[0].shouldBatchSave, "The saved LiveSync batching choice was not restored");
return { beforeStop, recovered };
});
evidence.artefactsAfter = hashes();
check(
JSON.stringify(before) === JSON.stringify(evidence.artefactsAfter),
"Plug-in artefacts changed during testing"
);
evidence.passed = cases.every((item) => item.passed);
await writeFile(join(diagnostics, "evidence.json"), redact(evidence));
console.log(
JSON.stringify({
phase: "completed",
passed: evidence.passed,
cases: cases.length,
failed: cases.filter((item) => !item.passed).map((item) => item.name),
})
);
if (!evidence.passed) process.exitCode = 1;
} finally {
for (const slot of slots) await stop(slot);
for (const slot of slots) await slot.vault.dispose();
if (ownedDatabase) await deleteCouchDbDatabase(config, dbName);
evidence.cleanup = { devicesStopped: slots.every((slot) => !slot.session), databaseRemoved: ownedDatabase };
await writeFile(join(diagnostics, "evidence.json"), redact(evidence));
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exitCode = 1;
});
@@ -0,0 +1,230 @@
/** Exercises optional file admission, actual save completion, and replay in a real Obsidian Vault. */
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import { requireObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
createE2eCouchDbPluginData,
createE2eObsidianDeviceLocalState,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cliBinary = requireObsidianCli();
const vault = await createTemporaryVault("obsidian-optional-save-");
let session: ObsidianLiveSyncSession | undefined;
const observations: unknown[] = [];
const bundleHash = createHash("sha256")
.update(new Uint8Array(await readFile("main.js")))
.digest("hex");
const settings = createE2eCouchDbPluginData(
// These scenarios use only the local database; every replication trigger is disabled.
{ uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName: "optional-save-local" },
{
syncInternalFiles: true,
usePluginSync: true,
usePluginSyncV2: true,
deviceAndVaultName: "optional-save-device",
watchInternalFileChanges: true,
syncInternalFilesTargetPatterns: "^\\.obsidian(?:$|/(?:optional-save|snippets)(?:/|$))",
syncInternalFilesIgnorePatterns: "",
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
syncInternalFilesInterval: 0,
liveSync: false,
periodicReplication: false,
syncOnStart: false,
syncOnSave: false,
syncOnFileOpen: false,
syncOnEditorSave: false,
syncAfterMerge: false,
showVerboseLog: true,
}
);
async function start(restart = false): Promise<void> {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cliBinary,
vault,
pluginStartup: restart ? "natural" : undefined,
pluginData: restart ? undefined : settings,
localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(vault.name),
lifecycle: {
beforeLaunch: async () => {
const registryPath = join(vault.userDataPath, "obsidian.json");
const registry = JSON.parse(await readFile(registryPath, "utf8"));
registry.updateDisabled = true;
await writeFile(registryPath, JSON.stringify(registry));
},
},
});
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
}
async function evaluate<T>(body: string): Promise<T> {
if (!session) throw new Error("The Obsidian session is not running");
return await evalObsidianJson<T>(
cliBinary,
`(async()=>{
const core=app.plugins.plugins['obsidian-livesync'].core;
const manager=core.serviceModules.storageAccess.vaultManager;
const config=core.getAddOn('ConfigSync');
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const require=(condition,message)=>{if(!condition)throw new Error(message);};
const waitFor=async (predicate,message)=>{
const until=Date.now()+10000;
while(!(await predicate())) {
if(Date.now()>until)throw new Error(message);
await sleep(20);
}
};
const pending=async path=>(await manager.adapter.persistence.loadSnapshot()??[])
.some(event=>event.args?.file?.path===path);
const entry=async path=>await core.localDatabase.getDBEntry(path,undefined,false,true);
const contents=doc=>Array.isArray(doc.data)?doc.data.join(''):doc.data;
const represents=(doc,content)=>doc&& !doc.deleted &&
(contents(doc).includes(content)||contents(doc).includes(btoa(content)));
const notify=async path=>{
await manager._watchVaultRawEvents(path);
await sleep(30);
};
${body}
})()`,
session.cliEnv
);
}
try {
await start();
await evaluate(`
require(core.services.fileProcessing.processOptionalFileEventWithResult?.hasHandlers,
'Typed optional providers were not registered');
core.services.setting.setDeviceAndVaultName('optional-save-device');
await core.services.setting.saveSettingData();
for(const path of ['.obsidian/optional-save','.obsidian/snippets']) {
if(!(await app.vault.adapter.exists(path)))await app.vault.adapter.mkdir(path);
}
return JSON.stringify({ready:true});
`);
for (const mode of ["hidden", "config-v1", "config-v2"]) {
observations.push(
await evaluate(`
const mode=${JSON.stringify(mode)};
core.settings.usePluginSyncV2=mode!=='config-v1';
await core.services.setting.saveSettingData();
require(await core.services.fileProcessing.commitPendingFileEvents(),'Initial flush failed');
const path=mode==='hidden'?'.obsidian/optional-save/content.json':
'.obsidian/snippets/optional-save-'+mode+'.css';
const databasePath=mode==='hidden'?'i:'+path:mode==='config-v1'?
config.filenameToUnifiedKey(path):config.filenameWithUnifiedKey(path);
const content='optional save '+mode+' initial';
const original=core.localDatabase.putDBEntry.bind(core.localDatabase);
let release;
const gate=new Promise(resolve=>{release=resolve;});
let started=false;
core.localDatabase.putDBEntry=async data=>{
if(data.path===databasePath){started=true;await gate;}
return await original(data);
};
try {
await app.vault.adapter.write(path,content);
await notify(path);
await waitFor(()=>started,'The optional database save did not start: '+mode);
require(await pending(path),'The in-flight optional save was missing from the snapshot: '+mode);
let flushed=false;
const flush=core.services.fileProcessing.commitPendingFileEvents().then(result=>{flushed=true;return result;});
await sleep(30);
require(!flushed,'Flush returned before the actual database save: '+mode);
release();
require(await flush,'Flush failed after the write was released: '+mode);
} finally {
release();
core.localDatabase.putDBEntry=original;
}
const first=await entry(databasePath);
require(represents(first,content),'The completed save did not contain the expected content: '+mode);
await notify(path);
require(await core.services.fileProcessing.commitPendingFileEvents(),'Unchanged notification failed: '+mode);
require((await entry(databasePath))._rev===first._rev,'Unchanged notification added a revision: '+mode);
const updated=content+' with a later edit';
await app.vault.adapter.write(path,updated);
await notify(path);
require(await core.services.fileProcessing.commitPendingFileEvents(),'Later edit was blocked: '+mode);
require(represents(await entry(databasePath),updated),'Later edit was not saved: '+mode);
await app.vault.adapter.remove(path);
await notify(path);
require(await core.services.fileProcessing.commitPendingFileEvents(),'Deletion failed: '+mode);
const deleted=await core.localDatabase.getDBEntryMeta(databasePath,undefined,true);
require(deleted&&deleted.deleted,'Deletion was not represented: '+mode);
await notify(path);
require(await core.services.fileProcessing.commitPendingFileEvents(),'Repeated deletion failed: '+mode);
require((await core.localDatabase.getDBEntryMeta(databasePath,undefined,true))._rev===deleted._rev,
'Repeated deletion added a revision: '+mode);
return JSON.stringify({mode,saveWaited:true,unchangedPreservedRevision:true,laterEditSaved:true,deletionReplayed:true});
`)
);
}
const replay = [
{ path: ".obsidian/optional-save/replay.json", content: "hidden save retained across restart" },
{
path: ".obsidian/snippets/optional-save-replay.css",
content: "customisation save retained across restart",
},
];
observations.push(
await evaluate(`
const fixtures=${JSON.stringify(replay)};
core.settings.usePluginSyncV2=true;
await core.services.setting.saveSettingData();
const failures=new Set(fixtures.map(({path})=>path.endsWith('.json')?'i:'+path:config.filenameWithUnifiedKey(path)));
const original=core.localDatabase.putDBEntry.bind(core.localDatabase);
let failed=0;
core.localDatabase.putDBEntry=async data=>{
if(failures.delete(data.path)){failed++;return false;}
return await original(data);
};
try {
for(const {path,content} of fixtures) {
await app.vault.adapter.write(path,content);
await notify(path);
}
await waitFor(()=>failed===fixtures.length,'Injected optional failures were not reached');
require(!(await core.services.fileProcessing.commitPendingFileEvents()),'Failed saves were acknowledged');
for(const {path} of fixtures)require(await pending(path),'Failed save was lost: '+path);
} finally {
core.localDatabase.putDBEntry=original;
}
return JSON.stringify({failedSavesRetained:failed});
`)
);
await session!.app.stop();
session = undefined;
await start(true);
observations.push(
await evaluate(`
const fixtures=${JSON.stringify(replay)};
for(const {path,content} of fixtures) {
const databasePath=path.endsWith('.json')?'i:'+path:config.filenameWithUnifiedKey(path);
require(represents(await entry(databasePath),content),'Start-up did not recover '+path);
require(!(await pending(path)),'Recovered save is still pending: '+path);
}
return JSON.stringify({replayedBeforeReadiness:fixtures.length,ready:core.services.appLifecycle.isReady()});
`)
);
console.log(JSON.stringify({ ok: true, bundleHash, observations }, null, 2));
} finally {
await session?.app.stop();
await vault.dispose();
}
}
await main();
+136 -2
View File
@@ -43,11 +43,13 @@ type TestWorkspace = {
type TestLogModule = {
adjustStatusDivPosition?: () => void;
statusLog?: WritableSource<string>;
activeFileStatus?: WritableSource<string>;
p2pLogCollector?: { p2pReplicationLine: WritableSource<string> };
statusDiv?: HTMLElement;
statusLine?: HTMLElement;
statusLineRenderer?: { render?: (parts: readonly unknown[]) => void };
statusBarRenderer?: { render?: (parts: readonly unknown[]) => void };
statusBarLabels?: WritableSource<{ parts: readonly unknown[] }>;
statusBarLabels?: WritableSource<{ parts: readonly unknown[]; message: string }>;
__addLog?: (message: unknown, level?: number, key?: string) => void;
};
type TestCore = {
@@ -57,6 +59,17 @@ type TestCore = {
replicator?: {
replicationStatics?: WritableSource<TestReplicationStatics>;
};
replication?: {
replicationResultCount: WritableSource<number>;
databaseQueueCount: WritableSource<number>;
storageApplyingCount: WritableSource<number>;
};
fileProcessing?: {
processing: WritableSource<number>;
totalQueued: WritableSource<number>;
batched: WritableSource<number>;
};
conflict?: { conflictProcessQueueCount: WritableSource<number> };
API?: {
requestCount?: WritableSource<number>;
responseCount?: WritableSource<number>;
@@ -120,7 +133,8 @@ async function installFixtureFile(page: Page): Promise<void> {
}
let file = vault.getAbstractFileByPath(path);
if (!file) file = await vault.create(path, "Replication status renderer fixture.\n");
const leaf = workspace.getLeaf(false);
// Opening a new tab emits the active-leaf transition used by the overlay placement hook.
const leaf = workspace.getLeaf("tab");
if (!leaf.openFile) return { ready: false, reason: "The active workspace leaf cannot open a file." };
await leaf.openFile(file);
workspace.setActiveLeaf(leaf, { focus: true });
@@ -412,6 +426,9 @@ async function measureNumericWidths(page: Page): Promise<Record<(typeof numericK
if (getComputedStyle(value).fontVariantNumeric !== "tabular-nums") {
throw new Error(`The numeric status item ${key} does not use tabular figures.`);
}
if (getComputedStyle(value).textAlign !== "right") {
throw new Error(`The numeric status item ${key} is not right-aligned.`);
}
result[key] = value.getBoundingClientRect().width;
}
return result;
@@ -707,6 +724,122 @@ async function hideMainWindow(page: Page): Promise<MainWindowControl> {
};
}
async function assertLegacyStatusPaths(page: Page): Promise<void> {
await page.evaluate(async (timeout) => {
const core = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core;
const moduleLog = core?.modules?.find((entry): entry is TestLogModule => {
if (typeof entry !== "object" || entry === null) return false;
return (entry as TestLogModule).statusBarLabels !== undefined;
});
const services = core?.services;
const settings = services?.setting?.currentSettings?.();
if (
!settings ||
!services?.replication ||
!services.fileProcessing ||
!services.conflict ||
!moduleLog?.activeFileStatus ||
!moduleLog.p2pLogCollector ||
!moduleLog.statusBarLabels
)
throw new Error("The legacy status sources are unavailable.");
const counters = [
{ key: "file-processing", mark: "⏳", source: services.fileProcessing.processing, value: 2 },
{ key: "file-pending", mark: "🛫", source: services.fileProcessing.totalQueued, value: 3 },
{ key: "file-batched", mark: "📬", source: services.fileProcessing.batched, value: 4 },
{ key: "queue-replication", mark: "📥", source: services.replication.replicationResultCount, value: 5 },
{ key: "queue-database", mark: "📄", source: services.replication.databaseQueueCount, value: 6 },
{ key: "queue-storage", mark: "💾", source: services.replication.storageApplyingCount, value: 7 },
{ key: "queue-conflicts", mark: "🔩", source: services.conflict.conflictProcessQueueCount, value: 8 },
];
const warning = moduleLog.activeFileStatus;
const p2p = moduleLog.p2pLogCollector.p2pReplicationLine;
const labels = moduleLog.statusBarLabels;
const previous = {
counters: counters.map(({ source }) => source.value),
warning: warning.value,
p2p: p2p.value,
hideFileWarningNotice: settings.hideFileWarningNotice,
};
const peerLine = "⚡ E2E peer ↑ 11/12 ↓ 13/14";
const wait = {
async until(condition: () => boolean, message: string) {
const deadline = Date.now() + timeout;
while (!condition()) {
if (Date.now() >= deadline) throw new Error(message);
await new Promise((resolve) => setTimeout(resolve, 20));
}
},
};
try {
for (const { source, value } of counters) source.value = value;
p2p.value = peerLine;
settings.hideFileWarningNotice = false;
warning.value = "E2E file warning";
await wait.until(
() =>
[".livesync-status-statusline", ".syncstatusbar"].every((root) =>
counters.every(({ key, value }) => {
const item = document.querySelector<HTMLElement>(`${root} .livesync-stat-${key}`);
return (
item !== null &&
getComputedStyle(item).display !== "none" &&
item.querySelector(".livesync-stat-value")?.textContent?.trim() === String(value)
);
})
),
"Queue values did not reach both status displays."
);
await wait.until(
() =>
document.querySelector(".livesync-status-statusline .livesync-stat-p2p")?.textContent?.trim() ===
peerLine &&
document
.querySelector(".livesync-status-messagearea")
?.textContent?.includes("⚠️ E2E file warning") === true,
"The P2P line or file warning was lost from the editor display."
);
const plain = labels.value.message.replace(/\u2007/gu, "");
if (
!counters.every(({ mark, value }) => plain.includes(` ${mark}${value}`)) ||
!plain.endsWith("\n" + peerLine)
) {
throw new Error("The plain-text status lost a queue count or its separate P2P line.");
}
if (document.querySelector(".syncstatusbar .livesync-stat-p2p:not(.livesync-stat-inactive)")) {
throw new Error("The status bar unexpectedly included the second-line P2P status.");
}
settings.hideFileWarningNotice = true;
warning.value = "E2E file warning as an icon";
await wait.until(
() =>
document
.querySelector(".livesync-status-statusline .livesync-stat-file-skip")
?.textContent?.trim() === "⛔ SKIP" &&
document
.querySelector(".livesync-status-messagearea")
?.textContent?.includes("E2E file warning") === false,
"The file warning did not switch to its existing compact icon."
);
p2p.value = "";
await wait.until(
() =>
document.querySelector(".syncstatusbar .livesync-stat-file-skip")?.textContent?.trim() ===
"⛔ SKIP" && labels.value.message.endsWith(" ⛔ SKIP"),
"The single-line status lost the compact file warning."
);
} finally {
counters.forEach(({ source }, index) => {
source.value = previous.counters[index];
});
settings.hideFileWarningNotice = previous.hideFileWarningNotice;
p2p.value = previous.p2p;
warning.value = previous.warning;
}
}, uiTimeoutMs);
console.log("Queue counts, P2P text, file warnings, and plain-text status remain available.");
}
async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
let popoutMarker: string | undefined;
@@ -865,6 +998,7 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
const returnedLog = `E2E main-window log ${Date.now()}`;
await appendLiveSyncLog(page, returnedLog);
await waitForLogMessage(page, returnedLog);
await assertLegacyStatusPaths(page);
console.log("Replication status, split-pane ownership, and popout status/log checks passed.");
} finally {
if (mainWindow) await mainWindow.restore();
+3
View File
@@ -44,6 +44,9 @@ const focusedScenarios = new Set([
"hidden-file-snippet-sync",
"hidden-file-selectors",
"customisation-sync",
"customisation-catalogue-reload",
"optional-file-save-completion",
"livesync-batch",
"received-change-readiness",
"internal-metadata-doctor",
"setting-markdown-export",
+106 -1
View File
@@ -1,4 +1,5 @@
import { mkdir } from "node:fs/promises";
import { mkdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session";
import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
@@ -59,6 +60,10 @@ type LiveSyncTestPlugin = {
useEdgeCaseMode: boolean;
hashCacheMaxCount: number;
customChunkSize: number;
liveSync: boolean;
periodicReplication: boolean;
batchSave: boolean;
batchSaveInLiveSync: boolean;
};
config: { getValue(key: string, defaultValue: string): Promise<string> };
};
@@ -643,6 +648,105 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
return screenshot;
}
async function verifyBatchSettings(vaultPath: string): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
let navigator = await openLiveSyncSettings(page, uiTimeoutMs);
let settingsPage = await navigator.openPage("Sync Settings");
const row = (name: string) =>
settingsPage.locator(".setting-item").filter({ has: page.getByText(name, { exact: true }) });
const legacyName = "Batch database update";
const liveName = "Batch database updates in LiveSync mode";
const delays = ["Minimum delay for batch database updating", "Maximum delay for batch database updating"];
async function assertState(liveSync: boolean, batchSave: boolean, batchSaveInLiveSync: boolean) {
await page.waitForFunction(
(expected) => {
const settings = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins[
"obsidian-livesync"
]?.core.services.setting.currentSettings();
return (
settings?.liveSync === expected.liveSync &&
settings?.batchSave === expected.batchSave &&
settings?.batchSaveInLiveSync === expected.batchSaveInLiveSync
);
},
{ liveSync, batchSave, batchSaveInLiveSync },
{ timeout: uiTimeoutMs }
);
const visibleName = liveSync ? liveName : legacyName;
await row(visibleName).waitFor({ state: "visible", timeout: uiTimeoutMs });
await row(liveSync ? legacyName : liveName).waitFor({ state: "hidden", timeout: uiTimeoutMs });
const enabled = liveSync ? batchSaveInLiveSync : batchSave;
if ((await row(visibleName).locator(".checkbox-container.is-enabled").count()) !== Number(enabled)) {
throw new Error(`The displayed batching choice did not match ${visibleName}: ${enabled}`);
}
for (const name of delays) {
await row(name).waitFor({ state: enabled ? "visible" : "hidden", timeout: uiTimeoutMs });
}
}
async function mode(value: "ONEVENTS" | "PERIODIC" | "LIVESYNC") {
await row("Sync Mode").locator("select").selectOption(value);
await page.waitForFunction(
(value) => {
const settings = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins[
"obsidian-livesync"
]?.core.services.setting.currentSettings();
return (
settings?.liveSync === (value === "LIVESYNC") &&
settings?.periodicReplication === (value === "PERIODIC")
);
},
value,
{ timeout: uiTimeoutMs }
);
}
await assertState(false, false, false);
await row(legacyName).locator(".checkbox-container").click();
await assertState(false, true, false);
await mode("LIVESYNC");
await assertState(true, true, false);
await row(liveName).locator(".checkbox-container").click();
await assertState(true, true, true);
await mode("PERIODIC");
await assertState(false, true, true);
await row(legacyName).locator(".checkbox-container").click();
await assertState(false, false, true);
await mode("LIVESYNC");
await assertState(true, false, true);
await row(liveName).locator(".checkbox-container").click();
await assertState(true, false, false);
await mode("ONEVENTS");
await assertState(false, false, false);
await row(legacyName).locator(".checkbox-container").click();
await assertState(false, true, false);
await navigator.close();
navigator = await openLiveSyncSettings(page, uiTimeoutMs);
settingsPage = await navigator.openPage("Sync Settings");
await assertState(false, true, false);
await mode("LIVESYNC");
await assertState(true, true, false);
await navigator.close();
const saved = JSON.parse(
await readFile(join(vaultPath, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8")
);
if (saved.batchSave !== true || saved.batchSaveInLiveSync !== false || saved.liveSync !== true) {
throw new Error("The independent batching choices were not saved to plug-in data.");
}
navigator = await openLiveSyncSettings(page, uiTimeoutMs);
settingsPage = await navigator.openPage("Sync Settings");
await assertState(true, true, false);
await mode("ONEVENTS");
await row(legacyName).locator(".checkbox-container").click();
await assertState(false, false, false);
await navigator.close();
console.log(
"Batching controls start disabled, show the current mode's delays, and retain independent saved choices."
);
});
}
async function verifyEffectiveSettings(): Promise<"declarative" | "imperative"> {
return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const sleepPreferences = await page.evaluate(() => {
@@ -1080,6 +1184,7 @@ async function main(): Promise<void> {
console.log(`Config Doctor screenshot: ${configDoctorScreenshot}`);
}
settingsRenderer = await verifyEffectiveSettings();
await verifyBatchSettings(vault.path);
const initialisation = await verifyPendingSettingsInitialisationFlow();
const p2pInitialisation = await captureP2PSettingsInitialisationDialogue();
console.log(