Merge main and validate local saves with Commonlib 0.2.1

This commit is contained in:
vorotamoroz committed 2026-10-26 03:29:16 +00:00
commit 6d289a8824
26 files changed
+1858 -83

No files matched your search

@@ -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;
@@ -286,8 +286,9 @@ async function main(): Promise<void> {
artifactRoot,
vault: slot.vault,
startupGraceMs: 1000,
pluginStartup: restart ? "natural" : undefined,
pluginData: restart ? undefined : createE2eCouchDbPluginData(couch, overrides),
localStorageEntries: createE2eObsidianDeviceLocalState(slot.vault.name),
localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(slot.vault.name),
env: { E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(await availablePort()) },
lifecycle: {
beforeLaunch: async () => {
@@ -710,7 +711,7 @@ async function main(): Promise<void> {
if (restartBucket)
await evaluate(
pair.a,
`const deadline=Date.now()+3000;
`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');`
);
+3
View File
@@ -43,6 +43,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(