Merge main and verify catalogue Reload and Apply in Obsidian

This commit is contained in:
vorotamoroz committed 2026-10-26 02:36:32 +00:00
commit b2fc839584
117 files changed
+7215 -744

No files matched your search

@@ -47,6 +47,16 @@ const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 3000
const e2eePassphrase = `%${randomBytes(24).toString("base64url")}`;
const notePath = "E2E/manual-couchdb/from-first-device.md";
const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n";
const binaryPath = "E2E/manual-couchdb/content-boundaries.png";
const binaryBlockSize = 32 * 1024;
function binaryFixture(): Uint8Array {
const bytes = new Uint8Array(binaryBlockSize * 8);
for (let index = 0; index < 8; index++) {
bytes[index * binaryBlockSize] = index + 2;
bytes[(index + 1) * binaryBlockSize - 1] = 1;
}
return bytes;
}
const returnNotePath = "E2E/manual-couchdb/from-second-device.md";
const returnNoteContent =
"# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n";
@@ -395,6 +405,32 @@ async function waitForVaultFile(
throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`);
}
async function waitForBinaryFile(vault: TemporaryVault): Promise<void> {
const expected = Buffer.from(binaryFixture());
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000);
while (Date.now() < deadline) {
try {
if ((await readFile(join(vault.path, binaryPath))).equals(expected)) return;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error("The fetched binary did not match its exact original bytes.");
}
async function readChunkInventory(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<string[]> {
return await evalObsidianJson<string[]>(
context.cliBinary,
`(async()=>{
const core=app.plugins.plugins['obsidian-livesync'].core;
const rows=(await core.localDatabase.allDocsRaw({include_docs:true})).rows;
return JSON.stringify(rows.filter(row=>row.doc?.type==='leaf').map(row=>row.id).sort());
})()`,
session.cliEnv
);
}
async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise<void> {
await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => {
const ids = new Set(docs.map((doc) => doc._id));
@@ -681,6 +717,8 @@ async function main(): Promise<void> {
process.env.E2E_OBSIDIAN_INDEPENDENT_IDS === "true" ? randomBytes(32).toString("base64url") : undefined;
let firstEntryId: string | undefined;
let returnEntryId: string | undefined;
let binaryRevision: string | undefined;
let chunkInventory: string[] = [];
try {
await assertCouchDbReachable(couchDb);
@@ -712,10 +750,34 @@ async function main(): Promise<void> {
}
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent);
const size = await evalObsidianJson<number>(
context.cliBinary,
`JSON.stringify(app.plugins.plugins['obsidian-livesync'].core.settings.customChunkSize)`,
session.cliEnv
);
assertEqual(size, 0, "New manual setup did not retain the common Chunk-size recommendation.");
await evalObsidianJson(
context.cliBinary,
`(async()=>{
const bytes=new Uint8Array(${binaryBlockSize}*8);
for(let index=0;index<8;index++){
bytes[index*${binaryBlockSize}]=index+2;
bytes[(index+1)*${binaryBlockSize}-1]=1;
}
await app.vault.createBinary(${JSON.stringify(binaryPath)},bytes.buffer);
return JSON.stringify(true);
})()`,
session.cliEnv
);
const binaryEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, binaryPath);
assertEqual(binaryEntry.children.length, 8, "V3 size 0 did not use the expected content boundaries.");
binaryRevision = binaryEntry.rev;
const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath);
firstEntryId = entry.id;
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForRemoteEntry(context, entry);
await waitForRemoteEntry(context, binaryEntry);
chunkInventory = await readChunkInventory(context, session);
} catch (error) {
await captureFailure(session, "first-device");
throw error;
@@ -729,6 +791,13 @@ async function main(): Promise<void> {
session = await startUnconfiguredSession(context, vaultA);
try {
await assertRestoredE2EEPassphrase(session, context.cliBinary);
const unchanged = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, binaryPath);
assertEqual(unchanged.rev, binaryRevision, "Restart rewrote unchanged binary Metadata.");
assertEqual(
JSON.stringify(await readChunkInventory(context, session)),
JSON.stringify(chunkInventory),
"Restart created Chunks for unchanged Vault files."
);
await scheduleRemoteOverwrite(session.remoteDebuggingPort);
screenshots.push(await confirmRebuild(session.remoteDebuggingPort, e2eeRebuildCaptures));
screenshots.push(
@@ -775,6 +844,7 @@ async function main(): Promise<void> {
await assertPersistedE2EE(vaultB, independentIdSource);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await waitForVaultFile(vaultB, notePath, noteContent);
await waitForBinaryFile(vaultB);
if (independentIdSource) {
assertEqual(
await readDocumentId(context.cliBinary, session.cliEnv, notePath),
@@ -815,7 +885,7 @@ async function main(): Promise<void> {
}
console.log(
`Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
`Manual CouchDB onboarding retained size 0, used binary content boundaries, preserved unchanged revisions and Chunks across restart, fetched exact binary bytes, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}`
);
} finally {
await stopTrackedSessions(context).catch((error: unknown) => {
@@ -13,6 +13,7 @@ import {
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";
@@ -73,6 +74,16 @@ async function matchingFiles(vaultPath: string, files: Record<string, string>) {
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();
@@ -148,8 +159,20 @@ async function main() {
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 addOn.reloadPluginList(false);
await settle();
${sourceRowCode}
let documents=0;
@@ -167,13 +190,16 @@ async function main() {
.sort()
);
await removeFixtures(vault.path, pluginFiles);
const firstApply = await evaluate(
`${configCode}${sourceRowCode}return JSON.stringify({applied:await addOn.applyDataV2(row)});`
);
report.firstApply = firstApply;
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 matchingFiles(vault.path, pluginFiles),
await waitForMatchingFiles(vault.path, pluginFiles),
Object.keys(pluginFiles).sort()
);
+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);
@@ -31,6 +31,18 @@ type DiagnosticPageGlobal = typeof globalThis & {
diagnosticDownload?: { name: string; content: string };
};
async function findVisibleElement(page: Page, selector: string): Promise<Locator> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const candidate of page.context().pages()) {
const element = candidate.locator(selector);
if (await element.isVisible()) return element;
}
await page.waitForTimeout(50);
}
throw new Error(`No Obsidian renderer contains the visible element: ${selector}`);
}
async function openCommand(page: Page, kind: "usage" | "integrity") {
const id = kind === "usage" ? "analyse-database" : "inspect-database-integrity";
const opened = await page.evaluate(
@@ -243,11 +255,14 @@ async function assertDiagnosticFileLookup(
session: ObsidianLiveSyncSession,
mobile = false
): Promise<void> {
const dialoguePage = hatch.page();
const viewport = page.viewportSize();
if (viewport) await dialoguePage.setViewportSize(viewport);
const setting = hatch.locator(".setting-item").filter({
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
has: hatch.page().getByText("Find a file from a report pseudonym", { exact: true }),
});
await setting.getByRole("button", { name: "Find file", exact: true }).click();
const dialogue = page.locator(".diagnostic-file-lookup");
const dialogue = dialoguePage.locator(".diagnostic-file-lookup");
await dialogue.waitFor({ state: "visible", timeout });
const input = dialogue.getByLabel("Report path pseudonym", { exact: true });
await input.fill("file-001");
@@ -259,8 +274,8 @@ async function assertDiagnosticFileLookup(
await candidates.first().waitFor({ timeout });
assert.equal(await candidates.count(), 2, "Distinct Metadata IDs claiming the same path must remain selectable");
if (mobile) {
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file lookup matches" });
await assertNoHorizontalOverflow(page, dialogue.locator(".lookup-candidates"), {
await assertNoHorizontalOverflow(dialoguePage, dialogue, { label: "Pseudonym file lookup matches" });
await assertNoHorizontalOverflow(dialoguePage, dialogue.locator(".lookup-candidates"), {
label: "Pseudonym file lookup candidate list",
});
assert.ok(
@@ -270,7 +285,7 @@ async function assertDiagnosticFileLookup(
await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-file-matches-mobile.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
(page) => findVisibleElement(page, ".modal-container:has(.diagnostic-file-lookup)")
);
}
const shadow = candidates.filter({ hasText: shadowId });
@@ -294,28 +309,30 @@ async function assertDiagnosticFileLookup(
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()]) {
assert.ok(!json.includes(privateValue), "A follow-up report must preserve the original report's pseudonyms");
}
await installDiagnosticExportInterception(page);
await installDiagnosticExportInterception(dialoguePage);
await dialogue.getByRole("button", { name: "Copy report", exact: true }).click();
assert.equal(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
assert.equal(await dialoguePage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard), json);
await dialogue.getByRole("button", { name: "Save report", exact: true }).click();
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, { timeout });
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
await dialoguePage.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
timeout,
});
assert.deepEqual(await dialoguePage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
name: "diagnostic-file-report.json",
content: json,
});
await assertDesktopDiagnosticResultLayout(page, dialogue, "saving a follow-up report");
await assertDesktopDiagnosticResultLayout(dialoguePage, dialogue, "saving a follow-up report");
if (mobile) {
const modal = page.locator(".modal-container").filter({ has: dialogue });
await assertMobileDialogueLayout(page, modal, "Pseudonym file follow-up report");
await assertNoHorizontalOverflow(page, dialogue, { label: "Pseudonym file follow-up content" });
await assertNoHorizontalOverflow(page, dialogue.locator(".report-token"), {
const modal = dialoguePage.locator(".modal-container").filter({ has: dialogue });
await assertMobileDialogueLayout(dialoguePage, modal, "Pseudonym file follow-up report");
await assertNoHorizontalOverflow(dialoguePage, dialogue, { label: "Pseudonym file follow-up content" });
await assertNoHorizontalOverflow(dialoguePage, dialogue.locator(".report-token"), {
label: "Pseudonym file follow-up token",
});
}
await captureObsidianElement(
session.remoteDebuggingPort,
mobile ? "database-diagnostics-file-follow-up-mobile.png" : "database-diagnostics-file-follow-up.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".diagnostic-file-lookup") })
(page) => findVisibleElement(page, ".modal-container:has(.diagnostic-file-lookup)")
);
await dialogue.getByRole("radio", { name: "MARKDOWN", exact: true }).check();
const markdown = await dialogue.locator("textarea").inputValue();
@@ -379,10 +396,10 @@ async function assertFollowUpStopsAfterClose(page: Page, hatch: Locator, token:
})()`);
try {
const setting = hatch.locator(".setting-item").filter({
has: page.getByText("Find a file from a report pseudonym", { exact: true }),
has: hatch.page().getByText("Find a file from a report pseudonym", { exact: true }),
});
await setting.getByRole("button", { name: "Find file", exact: true }).click();
const dialogue = page.locator(".diagnostic-file-lookup");
const dialogue = hatch.page().locator(".diagnostic-file-lookup");
await dialogue.getByLabel("Report path pseudonym", { exact: true }).fill(token);
await dialogue.getByRole("button", { name: "Find file", exact: true }).click();
const candidate = dialogue.locator("[data-diagnostic-candidate]").filter({ hasText: shadowId });
@@ -466,7 +483,7 @@ async function main(): Promise<void> {
const activeSession = session;
let usageJSON = "";
let usageFixtureRow: { path: string; id: string; revision: string } | undefined;
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const desktopScreenshot = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 800, height: 600 });
const choice = await openCommand(page, "usage");
await choice.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).focus();
@@ -560,13 +577,15 @@ async function main(): Promise<void> {
hatch = await (await openLiveSyncSettings(page, timeout)).openPage("Hatch");
await assertFollowUpStopsAfterClose(page, hatch, usageFixtureRow.path);
await captureObsidianElement(activeSession.remoteDebuggingPort, "database-diagnostics-hatch.png", (page) =>
page.locator(".modal.mod-settings")
findVisibleElement(page, ".modal.mod-settings")
);
const setting = hatch
.locator(".setting-item")
.filter({ has: page.getByText("Inspect database integrity", { exact: true }) });
.filter({ has: hatch.page().getByText("Inspect database integrity", { exact: true }) });
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
const progressDialogue = page.locator(".database-diagnostic");
const progressDialogue = await findVisibleElement(page, ".database-diagnostic");
const progressPage = progressDialogue.page();
await progressPage.setViewportSize({ width: 800, height: 600 });
assert.equal(
await progressDialogue
.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true })
@@ -579,7 +598,7 @@ async function main(): Promise<void> {
.isChecked(),
true
);
await assertDesktopDiagnosticChoiceLayout(page, progressDialogue);
await assertDesktopDiagnosticChoiceLayout(progressPage, progressDialogue);
await installDiagnosticReadGate(cli, activeSession, shadowId, "metadata");
try {
await progressDialogue.getByRole("button", { name: "Begin inspection", exact: true }).click();
@@ -593,7 +612,7 @@ async function main(): Promise<void> {
await captureObsidianElement(
activeSession.remoteDebuggingPort,
"database-diagnostics-progress.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
);
await progressDialogue.getByRole("button", { name: "Cancel inspection", exact: true }).click();
await evalObsidianJson(
@@ -601,7 +620,7 @@ async function main(): Promise<void> {
`(async()=>{globalThis.databaseDiagnosticReadGate?.release();return JSON.stringify(true);})()`,
activeSession.cliEnv
);
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await progressPage.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await assertFormatChoices(progressDialogue, ["MARKDOWN", "JSON"]);
await chooseFormat(progressDialogue, "json");
assert.equal(JSON.parse(await progressDialogue.locator("textarea").inputValue()).status, "cancelled");
@@ -612,33 +631,41 @@ async function main(): Promise<void> {
}
await setting.getByRole("button", { name: "Inspect", exact: true }).click();
const integrity = page.locator(".database-diagnostic");
const integrity = await findVisibleElement(page, ".database-diagnostic");
const integrityPage = integrity.page();
assert.equal(
await integrity.getByRole("radio", { name: "Pseudonyms (recommended)", exact: true }).isChecked(),
true
);
await integrity.getByRole("button", { name: "Begin inspection", exact: true }).click();
await page.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await integrityPage.locator('[data-diagnostic-phase="result"]').waitFor({ timeout });
await assertFormatChoices(integrity, ["MARKDOWN", "JSON"]);
assert.equal(await integrity.getByRole("radio", { name: "MARKDOWN", exact: true }).isChecked(), true);
const markdownReport = await integrity.locator("textarea").inputValue();
const markdownDetailsWithCodeBlock =
/<details>\s*<summary>Database integrity report \(partial\)<\/summary>\s*(`{3,})text\r?\n[\s\S]*?\1\s*<\/details>/;
assert.match(markdownReport, markdownDetailsWithCodeBlock);
await installDiagnosticExportInterception(page);
await installDiagnosticExportInterception(integrityPage);
await integrity.getByRole("button", { name: "Copy report", exact: true }).click();
assert.equal(
await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
await integrityPage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticClipboard),
markdownReport
);
await integrity.getByRole("button", { name: "Save report", exact: true }).click();
await page.waitForFunction(() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload, undefined, {
timeout,
});
assert.deepEqual(await page.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload), {
name: "database-integrity-partial.md",
content: markdownReport,
});
await integrityPage.waitForFunction(
() => !!(globalThis as DiagnosticPageGlobal).diagnosticDownload,
undefined,
{
timeout,
}
);
assert.deepEqual(
await integrityPage.evaluate(() => (globalThis as DiagnosticPageGlobal).diagnosticDownload),
{
name: "database-integrity-partial.md",
content: markdownReport,
}
);
await chooseFormat(integrity, "json");
const reportText = await integrity.locator("textarea").inputValue();
const integrityReport = JSON.parse(reportText);
@@ -670,15 +697,17 @@ async function main(): Promise<void> {
);
for (const privateValue of [fixturePath, shadowId, fixtureContent.trim()])
assert.ok(!reportText.includes(privateValue));
const screenshot = await captureObsidianElement(
activeSession.remoteDebuggingPort,
"database-diagnostics-desktop.png",
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
);
await integrity.getByRole("button", { name: "Close", exact: true }).click();
await integrity.waitFor({ state: "hidden", timeout });
await settingsNavigator.close();
return screenshot;
});
const desktopScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-desktop.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.locator(".database-diagnostic").getByRole("button", { name: "Close", exact: true }).click();
await page.keyboard.press("Escape");
const localOnly = await openCommand(page, "integrity");
await localOnly.getByRole("checkbox", { name: "Include remote database", exact: true }).uncheck();
await localOnly.getByRole("button", { name: "Begin inspection", exact: true }).click();
@@ -780,7 +809,7 @@ async function main(): Promise<void> {
const mobileScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-mobile.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
@@ -828,7 +857,7 @@ async function main(): Promise<void> {
const mobileResultScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"database-diagnostics-mobile-result.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.setViewportSize({ width: 375, height: 667 });
@@ -857,7 +886,7 @@ async function main(): Promise<void> {
await captureObsidianElement(
activeSession.remoteDebuggingPort,
"database-diagnostics-mobile-saved.png",
(page) => page.locator(".modal-container").filter({ has: page.locator(".database-diagnostic") })
(page) => findVisibleElement(page, ".modal-container:has(.database-diagnostic)")
);
await dialogue.getByRole("button", { name: "Close", exact: true }).click();
await dialogue.waitFor({ state: "hidden", timeout });
+181 -74
View File
@@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Page } from "playwright";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "../../../src/common/translation.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
@@ -13,18 +15,37 @@ import {
captureObsidianPage,
obsidianRemoteDebuggingPort,
openLiveSyncSettings,
withObsidianPage,
waitForVisibleObsidianDialogue,
withObsidianPage as withObsidianSessionPage,
} from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
const dialogRunStateKey = "__livesyncE2EDialogMount";
const savedP2PDeviceName = "e2e-p2p-saved-device";
const repairRunStateKey = "__livesyncE2ETroubleshootingRepair";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000);
async function withObsidianPage<T>(port: number, operation: (page: Page) => Promise<T>): Promise<T> {
return await withObsidianSessionPage(port, async (page) => {
for (const candidate of page.context().pages()) {
const hasPlugin = await candidate
.evaluate(
() => (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"] !== undefined
)
.catch(() => false);
if (hasPlugin) return await operation(candidate);
}
throw new Error("The LiveSync Vault renderer is unavailable.");
});
}
type DialogueMode = "desktop" | "mobile";
type DialogueRunState = {
done: boolean;
deviceNameRead?: boolean;
deviceNameReadPending?: boolean;
releaseDeviceNameRead?: () => void;
error?: string;
expected?: unknown;
kind: string;
@@ -46,6 +67,14 @@ type LiveSyncTestPlugin = {
createAllChunks(force: boolean): Promise<unknown>;
};
modules: SetupManagerHandle[];
services: {
setting: {
config: {
getValue(key: string, defaultValue?: string): Promise<string>;
setValue(key: string, value: string): Promise<void>;
};
};
};
settings: Record<string, unknown>;
};
};
@@ -91,38 +120,81 @@ async function openRemoteSelectionDialogue(): Promise<void> {
async function openP2PRemoteSelectionDialogueForInspection(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
if (
typeof manager?.onSelectServer !== "function" ||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
) {
throw new Error("Could not find the P2P setup workflow");
}
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
const state: DialogueRunState = { kind: "p2p-compatibility-settings", done: false };
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
state.expected = settings;
return true;
};
void manager
.onSelectServer(plugin.core.settings, "unknown")
.then(
(result) => {
state.result = result;
state.done = true;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
state.done = true;
}
)
.finally(() => {
manager.onConfirmApplySettingsFromWizard = originalConfirm;
await page.evaluate(
async ({ stateKey, deviceNameKey, deviceName }) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded");
const initialPeerName = plugin.core.settings.P2P_DevicePeerName;
if (typeof initialPeerName === "string" && initialPeerName.trim() !== "") {
throw new Error("The P2P setup fixture unexpectedly has an initial device name.");
}
const manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager");
if (
typeof manager?.onSelectServer !== "function" ||
typeof manager.onConfirmApplySettingsFromWizard !== "function"
) {
throw new Error("Could not find the P2P setup workflow");
}
const config = plugin.core.services.setting.config;
await config.setValue(deviceNameKey, deviceName);
const originalGetValue = config.getValue;
const originalConfirm = manager.onConfirmApplySettingsFromWizard;
const state: DialogueRunState = {
kind: "p2p-compatibility-settings",
done: false,
deviceNameRead: false,
deviceNameReadPending: false,
};
(globalThis as unknown as Record<string, DialogueRunState>)[stateKey] = state;
const deviceNameReadGate = new Promise<void>((resolve) => {
state.releaseDeviceNameRead = resolve;
});
config.getValue = async (key, defaultValue) => {
const value = await originalGetValue.call(config, key, defaultValue);
if (key === deviceNameKey) {
state.deviceNameReadPending = true;
await deviceNameReadGate;
state.deviceNameReadPending = false;
if (value === deviceName) state.deviceNameRead = true;
}
return value;
};
manager.onConfirmApplySettingsFromWizard = async (settings: unknown) => {
state.expected = settings;
return true;
};
void manager
.onSelectServer(plugin.core.settings, "unknown")
.then(
(result) => {
state.result = result;
},
(error: unknown) => {
state.error = error instanceof Error ? error.message : String(error);
}
)
.finally(() => {
state.releaseDeviceNameRead?.();
state.releaseDeviceNameRead = undefined;
manager.onConfirmApplySettingsFromWizard = originalConfirm;
config.getValue = originalGetValue;
state.done = true;
});
},
{
stateKey: dialogRunStateKey,
deviceNameKey: SETTING_KEY_P2P_DEVICE_NAME,
deviceName: savedP2PDeviceName,
}
);
});
}
async function releasePendingP2PDeviceNameRead(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate((stateKey) => {
const state = (globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey];
state?.releaseDeviceNameRead?.();
}, dialogRunStateKey);
});
}
@@ -388,7 +460,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
if (previousDialogue === null) {
throw new Error("The remote selection dialogue did not expose a close control.");
}
await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs });
await modal.locator(".modal-close-button, .modal-header-button").last().click({ timeout: uiTimeoutMs });
await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs });
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
}
@@ -473,9 +545,11 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
await openP2PRemoteSelectionDialogueForInspection();
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const remoteSelection = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }),
});
const remoteSelection = await waitForVisibleObsidianDialogue(
page,
"Choose a synchronisation remote",
uiTimeoutMs
);
await remoteSelection
.locator("label")
.filter({ hasText: "Peer-to-Peer (P2P)" })
@@ -491,16 +565,51 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
obsidianRemoteDebuggingPort(),
"setup-p2p-compatibility-dialogue.png",
async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
});
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
const modalPage = modal.page();
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const peerName = modal.getByLabel("Device Peer ID", { exact: true });
await peerName.waitFor({ state: "visible", timeout: uiTimeoutMs });
await withObsidianPage(obsidianRemoteDebuggingPort(), async (statePage) => {
await statePage.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]
?.deviceNameReadPending === true,
dialogRunStateKey,
{ timeout: uiTimeoutMs }
);
});
const testAndContinue = modal.getByRole("button", { name: "Test Settings and Continue", exact: true });
const continueAnyway = modal.getByRole("button", { name: "Continue anyway", exact: true });
await testAndContinue.waitFor({ state: "visible", timeout: uiTimeoutMs });
await continueAnyway.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (
!(await peerName.isDisabled()) ||
!(await testAndContinue.isDisabled()) ||
!(await continueAnyway.isDisabled())
) {
throw new Error(
"The P2P device name and setup actions must remain disabled while the saved device name is loading."
);
}
await releasePendingP2PDeviceNameRead();
await modalPage.waitForFunction(
(expectedName) => {
const input = document.querySelector('input[name="p2p-device-peer-id"]');
return input instanceof HTMLInputElement && !input.disabled && input.value === expectedName;
},
savedP2PDeviceName,
{ timeout: uiTimeoutMs }
);
if ((await peerName.inputValue()) !== savedP2PDeviceName) {
throw new Error("The saved P2P device name was not loaded before submitting the setup dialogue.");
}
await modal.locator('input[name="p2p-room-id"]').fill("e2e-p2p-compatibility");
await modal.locator('input[name="p2p-password"]').fill("e2e-passphrase");
await modal.locator('input[name="p2p-device-peer-id"]').fill("e2e-device");
const compatibility = modal.locator("details").filter({
has: page.locator("summary").filter({ hasText: "Connection compatibility" }),
has: modalPage.locator("summary").filter({ hasText: "Connection compatibility" }),
});
await compatibility.waitFor({ state: "visible", timeout: uiTimeoutMs });
if (!(await compatibility.evaluate((element) => (element as HTMLDetailsElement).open))) {
@@ -541,7 +650,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
}
const advanced = modal.locator("details").filter({
has: page.locator("summary").filter({ hasText: "Advanced Settings" }),
has: modalPage.locator("summary").filter({ hasText: "Advanced Settings" }),
});
if (!(await advanced.evaluate((element) => (element as HTMLDetailsElement).open))) {
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
@@ -553,20 +662,19 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
if (relayOnlyElement === null || connectionPathElement === null) {
throw new Error("P2P connection-path controls were removed whilst validating TURN settings.");
}
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
timeout: uiTimeoutMs,
});
await connectionPath.selectOption("relay");
await turnServers.fill("");
await page.waitForFunction(
await modalPage.waitForFunction(
(element) => (element as HTMLSelectElement).value === "automatic",
connectionPathElement,
{ timeout: uiTimeoutMs }
);
const resetNotice = compatibility.locator(".sls-info-note-notice").filter({
hasText:
"TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
hasText: "TURN relay only requires TURN configuration. Connection path has been restored to Automatic.",
});
await resetNotice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await resetNotice
@@ -575,7 +683,7 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await turnServers.fill("turn:turn.example.com:3478");
await page.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
await modalPage.waitForFunction((element) => !(element as HTMLOptionElement).disabled, relayOnlyElement, {
timeout: uiTimeoutMs,
});
await connectionPath.selectOption("relay");
@@ -583,21 +691,25 @@ async function verifyP2PCompatibilitySettingsDialogue(): Promise<string> {
await advanced.locator("summary").click({ timeout: uiTimeoutMs });
await compatibility.scrollIntoViewIfNeeded();
}
);
).finally(releasePendingP2PDeviceNameRead);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "P2P Configuration" }),
});
const modal = await waitForVisibleObsidianDialogue(page, "P2P Configuration", uiTimeoutMs);
await modal.getByRole("button", { name: "Continue anyway", exact: true }).click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const state = await assertDialogueRunCompleted();
if (state.deviceNameRead !== true) {
throw new Error("The saved P2P device name was not read through setting.config.");
}
if (state.result !== true || typeof state.expected !== "object" || state.expected === null) {
throw new Error("The P2P compatibility settings were not passed to the setup workflow.");
}
const settings = state.expected as Record<string, unknown>;
if (settings.P2P_DevicePeerName !== savedP2PDeviceName) {
throw new Error("The saved P2P device name was not passed through the setup workflow.");
}
if (settings.P2P_maxWirePayloadBytes !== 800 || settings.P2P_connectionPath !== "relay") {
throw new Error(
`The P2P compatibility choices did not round-trip through the setup dialogue: ${JSON.stringify({
@@ -672,15 +784,14 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
const app = (globalThis as ObsidianTestGlobal).app;
return Boolean(
app?.plugins?.plugins["obsidian-livesync"]?.core?.settings.isConfigured &&
app.commands?.commands?.[commandId]
app.commands?.commands?.[commandId]
);
},
"obsidian-livesync:livesync-copysetupuri",
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
(commandId) =>
(globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
(commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true,
"obsidian-livesync:livesync-copysetupuri"
);
});
@@ -714,7 +825,8 @@ async function verifyGenerateSetupUriDialogue(mode: DialogueMode): Promise<strin
timeout: uiTimeoutMs,
});
await choice.getByRole("button", { name: "Time-bound", exact: true }).waitFor({
state: "visible", timeout: uiTimeoutMs,
state: "visible",
timeout: uiTimeoutMs,
});
if (mode === "mobile") await assertMobileDialogueLayout(page, choice, "Setup URI availability dialogue");
await choice.getByRole("button", { name: selected, exact: true }).click({ timeout: uiTimeoutMs });
@@ -866,6 +978,7 @@ async function verifyCompatibleAlignmentSettingDefault(): Promise<void> {
if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) {
throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined.");
}
await settingsNavigator.close();
});
}
@@ -1094,9 +1207,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
);
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const liveSyncSettings = page.locator(".sls-setting");
const settingsNavigator = await openLiveSyncSettings(page, uiTimeoutMs);
const liveSyncSettings = await settingsNavigator.openPage("Hatch");
const logSetting = liveSyncSettings.locator(".setting-item").filter({
has: page.getByText("Write logs into the file", { exact: true }),
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
});
await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs });
await page.waitForFunction(
@@ -1130,10 +1244,10 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
// Saving a toggle refreshes the settings pane. Resolve the visible control again so the
// second action does not target the detached pre-save element.
const refreshedLogToggle = page
.locator(".sls-setting:visible .setting-item:visible")
const refreshedLogToggle = liveSyncSettings
.locator(".setting-item:visible")
.filter({
has: page.getByText("Write logs into the file", { exact: true }),
has: settingsNavigator.page.getByText("Write logs into the file", { exact: true }),
})
.locator(".checkbox-container:visible")
.last();
@@ -1179,13 +1293,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
}
};
}, repairRunStateKey);
await page
.locator(".sls-setting:visible")
.last()
.getByRole("button", { name: "Recreate current chunks", exact: true })
.click({
timeout: uiTimeoutMs,
});
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).click({
timeout: uiTimeoutMs,
});
await page.waitForFunction(
(stateKey) =>
(globalThis as unknown as Record<string, DialogueRunState | undefined>)[stateKey]?.done === true,
@@ -1200,13 +1310,9 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
throw new Error(`Recreate missing chunks failed: ${repairState.error}`);
}
await page
.locator(".sls-setting:visible")
.last()
.getByRole("button", { name: "Begin inspection", exact: true })
.click({
timeout: uiTimeoutMs,
});
await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).click({
timeout: uiTimeoutMs,
});
await page
.locator(".notice")
.filter({ hasText: /^done$/u })
@@ -1214,6 +1320,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
state: "visible",
timeout: uiTimeoutMs,
});
await settingsNavigator.close();
});
return screenshotPath;
@@ -0,0 +1,202 @@
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 { createTemporaryVault } from "../runner/vault.ts";
const hiddenRoot = ".obsidian/livesync-selector-example";
const hiddenFiles = {
[`${hiddenRoot}/main.js`]: "console.log('hidden fixture');\n",
[`${hiddenRoot}/styles.css`]: ".hidden-fixture { color: blue; }\n",
[`${hiddenRoot}/settings.json`]: '{"fixture":true}\n',
};
const controlRoot = ".obsidian/livesync-selector-control";
const controlFiles = {
[`${controlRoot}/main.js`]: "console.log('control fixture');\n",
[`${controlRoot}/styles.css`]: ".control-fixture { color: red; }\n",
[`${controlRoot}/settings.json`]: '{"control":true}\n',
};
const allowList = "\\.(?:md|png|pdf)$";
const coreCode = "const core=app.plugins.plugins['obsidian-livesync'].core;";
const hiddenCode = `${coreCode}const addOn=core.getAddOn('HiddenFileSync');`;
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 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_HIDDEN_FILE_SELECTORS_OUTPUT ?? join(tmpdir(), "livesync-hidden-file-selectors.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-hidden-file-selectors-");
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: "selector-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('selector-local');
Object.assign(core.settings,{
isConfigured:true,usePluginSync:false,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, hiddenFiles);
const uploadCode = (normalAllowList: string, files: Record<string, string>) => `${hiddenCode}
Object.assign(core.settings,{
syncInternalFiles:true,syncOnlyRegEx:${JSON.stringify(normalAllowList)},
syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/livesync-selector-(?:example|control)(?:/|$))',syncInternalFilesIgnorePatterns:''
});
const candidates=await addOn.scanInternalFileNames();
const selected=candidates.filter(path=>${JSON.stringify(Object.keys(files))}.includes(path));
if(selected.length!==3)throw new Error('Hidden-file fixture selection failed: '+JSON.stringify({selected,target:core.settings.syncInternalFilesTargetPatterns}));
for(const path of selected)await addOn.updateLastProcessedAsActualFile(path);
await addOn.rebuildFromStorage(false,${JSON.stringify(Object.keys(files))},false);
const paths=[];
for await(const doc of core.localDatabase.findEntries('i:','i;')){
if(${JSON.stringify(Object.keys(files).map((path) => `i:${path}`))}.includes(doc.path)&&!doc.deleted&&!doc._deleted)paths.push(doc.path.substring(2));
}
return JSON.stringify(paths.sort());
`;
const hiddenUpload = await evaluate<string[]>(uploadCode(allowList, hiddenFiles));
record(
"#1272: a normal allow-list permits selected hidden uploads",
hiddenUpload,
Object.keys(hiddenFiles).sort()
);
await writeFixtures(vault.path, controlFiles);
const uploadControl = await evaluate<string[]>(uploadCode("", controlFiles));
record(
"#1272 control: clearing the normal allow-list permits hidden uploads",
uploadControl,
Object.keys(controlFiles).sort()
);
const pullCode = (normalAllowList: string, files: Record<string, string>) => `${hiddenCode}
core.settings.syncOnlyRegEx=${JSON.stringify(normalAllowList)};
const results=[];
for await(const doc of core.localDatabase.findEntries('i:','i;')){
if(${JSON.stringify(Object.keys(files).map((path) => `i:${path}`))}.includes(doc.path)&&!doc.deleted&&!doc._deleted){
results.push(await addOn.extractInternalFileFromDatabase(doc.path.substring(2),true,doc,false));
}
}
return JSON.stringify(results);
`;
await removeFixtures(vault.path, hiddenFiles);
report.hiddenPullWithAllowList = await evaluate(pullCode(allowList, hiddenFiles));
record(
"#1272: a normal allow-list permits selected hidden downloads",
await matchingFiles(vault.path, hiddenFiles),
Object.keys(hiddenFiles).sort()
);
await removeFixtures(vault.path, controlFiles);
report.hiddenPullWithoutAllowList = await evaluate(pullCode("", controlFiles));
record(
"#1272 control: clearing the normal allow-list permits hidden downloads",
await matchingFiles(vault.path, controlFiles),
Object.keys(controlFiles).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;
});
@@ -63,12 +63,28 @@ 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;
cliBinary: string;
couchDb: CouchDbConfig;
dbName: string;
activeSessions: Set<ObsidianLiveSyncSession>;
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
@@ -132,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>;
@@ -348,6 +472,7 @@ async function startConfiguredSession(
pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings),
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
context.activeSessions.add(session);
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv);
await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings);
@@ -355,6 +480,11 @@ async function startConfiguredSession(
return session;
}
async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
await session.app.stop();
context.activeSessions.delete(session);
}
async function uploadHiddenFile(
context: RunnerContext,
session: ObsidianLiveSyncSession,
@@ -430,12 +560,12 @@ async function runCreateRoundTrip(
throw new Error("Remote Rebuild did not declare and encrypt internal Metadata for its accepted writer.");
}
console.log("Remote Rebuild declared the feature and preserved encrypted Hidden File Sync Metadata.");
await session.app.stop();
await stopSession(context, session);
session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session);
const received = await waitForPathContent(vaultB.path, snippetPath, (content) => content === snippetContent);
await session.app.stop();
await stopSession(context, session);
assertEqual(received, snippetContent, "Hidden snippet content did not round-trip to the second vault.");
console.log(`Hidden create round-trip copied ${entry.id} to the second vault.`);
@@ -450,12 +580,12 @@ async function runDeleteRoundTrip(
let session = await startConfiguredSession(context, vaultA);
await scanHiddenStorage(context.cliBinary, session.cliEnv);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await session.app.stop();
await stopSession(context, session);
session = await startConfiguredSession(context, vaultB);
await pullAndApplyHiddenFiles(context, session);
await waitForPathDeleted(vaultB.path, snippetPath);
await session.app.stop();
await stopSession(context, session);
console.log("Hidden delete round-trip removed the snippet from the second vault.");
}
@@ -476,14 +606,14 @@ async function runJsonConflictRoundTrip(
const mergedOnB = await waitForPathContent(vaultB.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true })
);
await session.app.stop();
await stopSession(context, session);
session = await startConfiguredSession(context, vaultA);
await pullAndApplyHiddenFiles(context, session);
const mergedOnA = await waitForPathContent(vaultA.path, mergeJsonPath, (content) =>
hasJsonValues(content, { fromA: true, fromB: true })
);
await session.app.stop();
await stopSession(context, session);
assertEqual(mergedOnA, mergedOnB, "Merged hidden JSON content was not consistent across both vaults.");
console.log("Hidden JSON conflict was automatically merged and round-tripped.");
@@ -503,7 +633,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te
const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) =>
hasJsonValues(content, { shared: "right", fromA: true, fromB: true })
);
await session.app.stop();
await stopSession(context, session);
const parsed = JSON.parse(merged);
assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result.");
@@ -517,17 +647,19 @@ async function runTargetMismatch(
vaultA: TemporaryVault,
vaultB: TemporaryVault
): Promise<void> {
const selectors = { syncOnlyRegEx: String.raw`\.(md|pdf)$` };
const targetContent = JSON.stringify({ onlyA: true, targetMismatch: true }, null, 4) + "\n";
await writeVaultFile(vaultA.path, targetPath, targetContent);
let session = await startConfiguredSession(context, vaultA);
let session = await startConfiguredSession(context, vaultA, selectors);
try {
await uploadHiddenFile(context, session, targetPath);
} finally {
await session.app.stop();
await stopSession(context, session);
}
session = await startConfiguredSession(context, vaultB, {
...selectors,
syncInternalFilesTargetPatterns: "snippets",
});
try {
@@ -538,10 +670,11 @@ async function runTargetMismatch(
"Hidden file was applied on a device where it was not a target file."
);
} finally {
await session.app.stop();
await stopSession(context, session);
}
session = await startConfiguredSession(context, vaultB, {
...selectors,
syncInternalFilesTargetPatterns: "",
});
let received = "";
@@ -549,11 +682,17 @@ async function runTargetMismatch(
await pullAndApplyHiddenFiles(context, session, { resolveConflicts: false });
received = await waitForPathContent(vaultB.path, targetPath, (content) => content === targetContent);
} finally {
await session.app.stop();
await stopSession(context, session);
}
assertEqual(received, targetContent, "Hidden file was not applied after it became a target file.");
console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target.");
assertEqual(
received,
targetContent,
"The normal file allow-list blocked a selected hidden file from the other device."
);
console.log(
"A normal Markdown/PDF allow-list preserved hidden target exclusion and exact remote restoration after enabling the target."
);
}
async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise<void> {
@@ -633,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;
@@ -828,7 +968,7 @@ async function runInitialisationNoticeGrouping(context: RunnerContext, vault: Te
`Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}`
);
} finally {
await session.app.stop();
await stopSession(context, session);
}
}
@@ -889,7 +1029,7 @@ async function runConfigurationNoticeGrouping(context: RunnerContext, vault: Tem
} finally {
await clearHiddenFileNoticeFixtures(port).catch(() => undefined);
await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined);
await session.app.stop();
await stopSession(context, session);
}
}
@@ -904,7 +1044,7 @@ async function main(): Promise<void> {
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "hidden-roundtrip");
const vaultA = await createTemporaryVault();
const vaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName };
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, activeSessions: new Set() };
try {
await assertCouchDbReachable(couchDb);
@@ -923,6 +1063,7 @@ async function main(): Promise<void> {
await runInitialisationNoticeGrouping(context, vaultB);
await runConfigurationNoticeGrouping(context, vaultB);
} finally {
for (const session of context.activeSessions) await stopSession(context, session);
await vaultA.dispose();
await vaultB.dispose();
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
+588
View File
@@ -0,0 +1,588 @@
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { mkdir } from "node:fs/promises";
import { join } from "node:path";
import { DEVICE_ID_PREFERRED, MILESTONE_DOCID, NODEINFO_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { evalObsidianJson } from "../runner/cli.ts";
import {
assertCouchDbReachable,
createCouchDbDatabase,
deleteCouchDbDatabase,
fetchCouchDbDocument,
loadCouchDbConfig,
makeUniqueDatabaseName,
putCouchDbDocument,
type CouchDbDocument,
} from "../runner/couchdb.ts";
import {
createObjectStorageClient,
deleteObjectStoragePrefix,
ensureObjectStorageBucket,
loadObjectStorageConfig,
makeUniqueBucketPrefix,
readObjectStorageJson,
} from "../runner/objectStorage.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import {
assertEqual,
assertE2eCompatibilityUnpaused,
configureCouchDb,
configureObjectStorage,
createE2eCouchDbPluginData,
createE2eObjectStoragePluginData,
prepareRemote,
waitForLiveSyncCoreReady,
} from "../runner/liveSyncWorkflow.ts";
import {
assertLocatorHasMinimumTouchTarget,
assertLocatorWithinSafeArea,
assertLocatorWithinViewport,
assertNoHorizontalOverflow,
} from "@vrtmrz/obsidian-test-session";
import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { openLiveSyncSettings, waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator, Page } from "playwright";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_JOINED_DEVICES_TIMEOUT_MS ?? 10000);
const commandId = "obsidian-livesync:joined-devices";
const targetId = "joined-devices-target";
const retainedId = "joined-devices-retained";
const initialPlatform = "E2E Android tablet";
const refreshedPlatform = "E2E refreshed platform";
const connectedAt = Date.UTC(2026, 0, 12, 9, 30, 0);
type SelfDeviceInfo = {
id: string;
platform: string;
pluginVersion: string;
appVersion: string;
};
type DeviceInfo = {
device_name: string;
vault_name: string;
app_version: string;
plugin_version: string;
platform: string;
progress: string;
last_connected: number;
};
type JoinedDevicesMilestone = CouchDbDocument & {
accepted_nodes: string[];
node_info: Record<string, DeviceInfo>;
locked: boolean;
cleaned: boolean;
node_chunk_info: Record<string, { min: number; max: number; current: number }>;
tweak_values: Record<string, Record<string, unknown>>;
};
type DeviceFixture = {
milestone: JoinedDevicesMilestone;
preferredTweaks: Record<string, unknown>;
};
type JoinedDevicesRemote = {
label: string;
pluginData: Record<string, unknown>;
prepare(): Promise<void>;
configure(cliBinary: string, env: NodeJS.ProcessEnv): Promise<unknown>;
read(): Promise<JoinedDevicesMilestone>;
write(milestone: JoinedDevicesMilestone): Promise<void>;
cleanup(): Promise<void>;
};
async function createRemoteFixture(): Promise<JoinedDevicesRemote> {
const args = process.argv.slice(2);
if (args.some((argument) => argument !== "--object-storage")) {
throw new Error("Usage: joined-devices.ts [--object-storage]");
}
if (args.includes("--object-storage")) {
const config = await loadObjectStorageConfig();
const bucketPrefix = makeUniqueBucketPrefix("joined-devices");
const settings = { ...config, bucketPrefix };
const key = `${bucketPrefix}_00000000-milestone.json`;
return {
label: "Object Storage",
pluginData: createE2eObjectStoragePluginData(settings),
prepare: () => ensureObjectStorageBucket(config),
configure: (cliBinary, env) => configureObjectStorage(cliBinary, env, settings),
read: () => readObjectStorageJson<JoinedDevicesMilestone>(config, key),
write: async (milestone) => {
const client = createObjectStorageClient(config);
try {
await client.send(
new PutObjectCommand({
Bucket: config.bucket,
Key: key,
Body: JSON.stringify(milestone),
ContentType: "application/json",
})
);
} finally {
client.destroy();
}
},
cleanup: () => deleteObjectStoragePrefix(config, bucketPrefix),
};
}
const config = await loadCouchDbConfig();
const dbName = makeUniqueDatabaseName(config.dbPrefix, "joined-devices");
const settings = { uri: config.uri, username: config.username, password: config.password, dbName };
return {
label: "CouchDB",
pluginData: createE2eCouchDbPluginData(settings),
prepare: async () => {
await assertCouchDbReachable(config);
await createCouchDbDatabase(config, dbName);
},
configure: (cliBinary, env) => configureCouchDb(cliBinary, env, settings),
read: async () => {
const milestone = await fetchCouchDbDocument(config, dbName, MILESTONE_DOCID);
assert(Boolean(milestone._rev), "The remote milestone did not have a revision.");
return milestone as JoinedDevicesMilestone;
},
write: async (milestone) => {
await putCouchDbDocument(config, dbName, milestone);
},
cleanup: () => deleteCouchDbDatabase(config, dbName),
};
}
function assert(condition: boolean, message: string): void {
if (!condition) throw new Error(message);
}
function deviceCard(dialogue: Locator, id: string): Locator {
return dialogue.locator(".device-card").filter({ hasText: id });
}
async function assertDetail(card: Locator, label: string, expected: string): Promise<void> {
const rows = card.locator(".device-details > div");
for (let index = 0; index < (await rows.count()); index++) {
const row = rows.nth(index);
if ((await row.locator("dt").innerText()).trim() === label) {
assertEqual((await row.locator("dd").innerText()).trim(), expected, `${label} did not match.`);
return;
}
}
throw new Error(`The device details did not contain '${label}'.`);
}
async function readSelfDeviceInfo(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SelfDeviceInfo> {
return await evalObsidianJson<SelfDeviceInfo>(
cliBinary,
[
"(async()=>{",
"const core=app.plugins.plugins['obsidian-livesync'].core;",
`const nodeInfo=await core.services.database.localDatabase.localDatabase.get(${JSON.stringify(NODEINFO_DOCID)});`,
"return JSON.stringify({",
"id:nodeInfo.nodeid,",
"platform:core.services.API.getPlatform(),",
"pluginVersion:core.services.API.getPluginVersion(),",
"appVersion:core.services.API.getAppVersion(),",
"});",
"})()",
].join(""),
env
);
}
function createFixture(self: SelfDeviceInfo): DeviceFixture {
const preferredTweaks = { customChunkSize: 60, chunkSplitterVersion: "v3-rabin-karp" };
const milestone: JoinedDevicesMilestone = {
_id: MILESTONE_DOCID,
type: "milestoneinfo",
created: Date.now(),
// The current device is deliberately absent: it must still be listed first and identified as locked out.
accepted_nodes: [targetId, retainedId],
node_info: {
[targetId]: {
device_name: "E2E target device",
vault_name: "Joined devices target fixture",
app_version: "1.99.0-test",
plugin_version: "999.0.0",
platform: initialPlatform,
progress: "ready",
last_connected: connectedAt,
},
[retainedId]: {
device_name: "E2E retained device",
vault_name: "Joined devices retained fixture",
app_version: "1.88.0-test",
plugin_version: "0.1.0",
platform: "E2E Linux desktop",
progress: "ready",
last_connected: connectedAt - 86400000,
},
},
locked: true,
cleaned: true,
node_chunk_info: {
[targetId]: { min: 1, max: 3, current: 3 },
[retainedId]: { min: 1, max: 2, current: 2 },
},
tweak_values: {
[targetId]: { customChunkSize: 48 },
[retainedId]: { customChunkSize: 60 },
[DEVICE_ID_PREFERRED]: preferredTweaks,
},
};
assert(self.id !== targetId && self.id !== retainedId, "The local device identity collided with a fixture ID.");
return { milestone, preferredTweaks };
}
async function openFromRemoteSettings(page: Page): Promise<Locator> {
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
const remoteConfiguration = await settings.openPage("Remote Configuration");
await remoteConfiguration
.getByRole("button", { name: "Review devices", exact: true })
.click({ timeout: uiTimeoutMs });
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await dialogue.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
return dialogue;
}
async function assertInitialDialogue(dialogue: Locator, self: SelfDeviceInfo, page: Page): Promise<void> {
const cards = dialogue.locator(".device-card");
assertEqual(
await cards.count(),
3,
"The joined devices dialogue did not show the self, target, and retained records."
);
const selfCard = cards.nth(0);
assert(Boolean(self.id), "The current device identity was not read from the local NODEINFO_DOCID record.");
assertEqual(
(await selfCard.locator("code.device-id").innerText()).trim(),
self.id,
"The current device was not listed first."
);
await selfCard.getByText("This device", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await selfCard.getByText("Locked out", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await selfCard.getByText("Not accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await selfCard
.getByText("This device has been locked out.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertDetail(selfCard, "Platform", self.platform);
await assertDetail(selfCard, "Plug-in version", self.pluginVersion);
await assertDetail(selfCard, "App version", self.appVersion);
assertEqual(
await selfCard.getByRole("button", { name: "Forget device", exact: true }).count(),
0,
"The current device exposed a forget control."
);
const targetCard = deviceCard(dialogue, targetId);
await assertDetail(targetCard, "Platform", initialPlatform);
await assertDetail(targetCard, "Plug-in version", "999.0.0");
await assertDetail(targetCard, "App version", "1.99.0-test");
const targetDate = await page.evaluate((timestamp) => new Date(timestamp).toLocaleString(), connectedAt);
await assertDetail(targetCard, "Last recorded", targetDate);
await targetCard.getByText("Accepted", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
const retainedCard = deviceCard(dialogue, retainedId);
await assertDetail(retainedCard, "Platform", "E2E Linux desktop");
await assertDetail(retainedCard, "Plug-in version", "0.1.0");
await assertDetail(retainedCard, "App version", "1.88.0-test");
await dialogue
.getByText(
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
{ exact: true }
)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await dialogue
.locator(".sls-info-note")
.getByText(
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
{ exact: true }
)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await dialogue
.getByText("This remote is locked.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await dialogue
.getByText("This remote has been cleaned.", { exact: false })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
}
async function refreshRemotePlatform(page: Page, remote: JoinedDevicesRemote): Promise<void> {
const milestone = await remote.read();
await remote.write({
...milestone,
node_info: {
...milestone.node_info,
[targetId]: { ...milestone.node_info[targetId], platform: refreshedPlatform },
},
});
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await dialogue.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs });
await deviceCard(dialogue, targetId).getByText(refreshedPlatform, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
async function showForgetConfirmation(dialogue: Locator): Promise<void> {
const targetCard = deviceCard(dialogue, targetId);
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
await targetCard.getByText("Forget this device record?", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
async function assertCancelledForget(dialogue: Locator, remote: JoinedDevicesRemote): Promise<void> {
const before = await remote.read();
await showForgetConfirmation(dialogue);
const confirmation = deviceCard(dialogue, targetId).locator(".forget-confirmation");
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs });
await confirmation.waitFor({ state: "detached", timeout: uiTimeoutMs });
assertEqual(JSON.stringify(await remote.read()), JSON.stringify(before), "Cancelling Forget changed the remote.");
}
async function confirmForgetTarget(dialogue: Locator): Promise<void> {
const targetCard = deviceCard(dialogue, targetId);
await targetCard.getByRole("button", { name: "Forget device", exact: true }).click({ timeout: uiTimeoutMs });
await dialogue.getByText("The device record was forgotten.", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await targetCard.waitFor({ state: "detached", timeout: uiTimeoutMs });
}
async function assertTargetForgotten(remote: JoinedDevicesRemote, fixture: DeviceFixture): Promise<void> {
const milestone = await remote.read();
assertEqual(
JSON.stringify(milestone.accepted_nodes),
JSON.stringify([retainedId]),
"Forgetting changed the wrong accepted device records."
);
assertEqual(
JSON.stringify(Object.keys(milestone.node_info)),
JSON.stringify([retainedId]),
"Forgetting changed the wrong device metadata records."
);
assertEqual(
JSON.stringify(Object.keys(milestone.node_chunk_info)),
JSON.stringify([retainedId]),
"Forgetting changed the wrong chunk metadata records."
);
assertEqual(
JSON.stringify(Object.keys(milestone.tweak_values).sort()),
JSON.stringify([DEVICE_ID_PREFERRED, retainedId].sort()),
"Forgetting changed the wrong tweak metadata records."
);
assertEqual(milestone.locked, true, "Forgetting changed the remote lock state.");
assertEqual(milestone.cleaned, true, "Forgetting changed the remote cleaned state.");
assertEqual(
JSON.stringify(milestone.tweak_values[DEVICE_ID_PREFERRED]),
JSON.stringify(fixture.preferredTweaks),
"Forgetting changed the preferred-ID tweak record."
);
assertEqual(
JSON.stringify(milestone.node_info[retainedId]),
JSON.stringify(fixture.milestone.node_info[retainedId]),
"Forgetting changed the retained device metadata."
);
}
async function assertScrollableMobileDialogue(page: Page, container: Locator): Promise<void> {
const dialogue = container.locator(".modal").last();
const closeButton = container.locator(".modal-close-button, .modal-header-button").last();
await assertLocatorWithinViewport(page, dialogue, { label: "Joined devices dialogue" });
await assertNoHorizontalOverflow(page, dialogue, { label: "Joined devices dialogue" });
await assertLocatorWithinSafeArea(page, dialogue, {
label: "Joined devices dialogue",
safeAreaInsets: iPhoneSafeArea,
});
const buttons = [closeButton, ...(await dialogue.locator("button:visible").all())];
for (const button of buttons) {
// Device records form a scrollable list; every control must be reachable after scrolling.
await button.scrollIntoViewIfNeeded();
const label = (await button.innerText()).trim() || "Close dialogue";
await assertLocatorWithinViewport(page, button, { label });
await assertLocatorWithinSafeArea(page, button, { label, safeAreaInsets: iPhoneSafeArea });
await assertNoHorizontalOverflow(page, button, { label });
await assertLocatorHasMinimumTouchTarget(page, button, { label });
}
await container.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
}
async function dismissNotices(page: Page): Promise<void> {
const notices = page.locator(".notice:visible");
for (let remaining = 20; remaining > 0 && (await notices.count()) > 0; remaining--) {
const notice = await notices.first().elementHandle();
if (!notice) continue;
try {
await notice.click({ position: { x: 20, y: 20 }, timeout: uiTimeoutMs });
await notice.waitForElementState("hidden", { timeout: uiTimeoutMs });
} finally {
await notice.dispose();
}
}
assertEqual(await notices.count(), 0, "Notices still obscured the dialogue screenshot.");
}
async function captureJoinedDevices(
port: number,
filename: string,
assertReady: (page: Page) => Promise<Locator>
): Promise<void> {
const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
await mkdir(outputDirectory, { recursive: true });
await withObsidianPage(port, async (page) => {
try {
const dialogue = await assertReady(page);
await dialogue.page().screenshot({ path: join(outputDirectory, filename), fullPage: true });
} catch (error) {
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", 500).catch(() => undefined);
const failurePath = join(outputDirectory, filename.replace(/\.png$/u, ".failure.png"));
await (dialogue?.page() ?? page).screenshot({ path: failurePath, fullPage: true }).catch(() => undefined);
console.error(`Joined devices UI failure screenshot: ${failurePath}`);
throw error;
}
});
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const remote = await createRemoteFixture();
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
try {
await remote.prepare();
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: remote.pluginData,
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await assertE2eCompatibilityUnpaused(cli.binary, session.cliEnv, session.remoteDebuggingPort);
await remote.configure(cli.binary, session.cliEnv);
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await prepareRemote(cli.binary, session.cliEnv);
const self = await readSelfDeviceInfo(cli.binary, session.cliEnv);
const fixture = createFixture(self);
const preparedMilestone = await remote.read();
await remote.write({
...fixture.milestone,
...(preparedMilestone._rev ? { _rev: preparedMilestone._rev } : {}),
});
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-notice.png", async (page) => {
const dialogue = await openFromRemoteSettings(page);
const dialoguePage = dialogue.page();
await dialoguePage.setViewportSize({ width: 1280, height: 1100 });
await assertInitialDialogue(dialogue, self, dialoguePage);
const notice = dialoguePage.locator(".notice").filter({
hasText: "Newer Self-hosted LiveSync versions detected: 999.0.0.",
});
await notice.waitFor({ state: "visible", timeout: uiTimeoutMs });
await notice
.getByText("Older Self-hosted LiveSync versions detected: 0.1.0.", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
assertEqual(
await notice
.getByText("Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.", {
exact: true,
})
.count(),
1,
"The version Notice did not contain exactly one recommendation."
);
assertEqual(
await notice.getByRole("button", { name: "Review devices", exact: true }).count(),
1,
"The version Notice did not contain exactly one shared review action."
);
return dialogue;
});
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-desktop.png", async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
await dialogue
.page()
.locator(".notice")
.getByRole("button", { name: "Review devices", exact: true })
.click();
const reopened = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await reopened.getByText(targetId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await dismissNotices(reopened.page());
return reopened;
});
await captureJoinedDevices(
session.remoteDebuggingPort,
"joined-devices-forget-confirmation.png",
async (page) => {
await refreshRemotePlatform(page, remote);
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await assertCancelledForget(dialogue, remote);
await showForgetConfirmation(dialogue);
await deviceCard(dialogue, targetId).locator(".forget-confirmation").scrollIntoViewIfNeeded();
return dialogue;
}
);
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-after-forget.png", async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await confirmForgetTarget(dialogue);
await dialogue.locator(".modal-content").evaluate((element) => element.scrollTo(0, 0));
return dialogue;
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await dialogue.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs });
const settings = await openLiveSyncSettings(page, uiTimeoutMs);
await settings.close();
});
await assertTargetForgotten(remote, fixture);
await setObsidianMobileTestMode(session.remoteDebuggingPort, true, uiTimeoutMs);
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await captureJoinedDevices(session.remoteDebuggingPort, "joined-devices-mobile.png", async (page) => {
const opened = await page.evaluate((id) => {
const app = (
window as typeof window & {
app?: { commands?: { executeCommandById?(commandId: string): boolean } };
}
).app;
return app?.commands?.executeCommandById?.(id) === true;
}, commandId);
assert(opened, "The Joined devices command did not open the dialogue.");
const dialogue = await waitForVisibleObsidianDialogue(page, "Joined devices", uiTimeoutMs);
await dialogue.getByText(retainedId, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertScrollableMobileDialogue(dialogue.page(), dialogue);
assertEqual(
await dialogue.locator(".device-card").count(),
2,
"The refreshed mobile list did not retain both remaining devices."
);
await dismissNotices(dialogue.page());
return dialogue;
});
console.log(`Joined devices scenario passed against ${remote.label}.`);
} finally {
try {
if (session) await session.app.stop();
} finally {
try {
await vault.dispose();
} finally {
await remote.cleanup();
}
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+3
View File
@@ -26,6 +26,7 @@ const testSteps: Step[] = [
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
{ name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] },
{ name: "joined devices with CouchDB", args: ["run", "test:e2e:obsidian:joined-devices"] },
{ name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] },
{
name: "manual CouchDB setup workflow",
@@ -35,6 +36,7 @@ const testSteps: Step[] = [
name: "CLI to real Obsidian synchronisation",
args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"],
},
{ name: "joined devices with Object Storage", args: ["run", "test:e2e:obsidian:joined-devices-object-storage"] },
{ name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] },
{
name: "Object Storage Setup URI workflow",
@@ -56,6 +58,7 @@ const testSteps: Step[] = [
{ name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] },
{ name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] },
{ name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] },
{ name: "hidden file selectors", args: ["run", "test:e2e:obsidian:hidden-file-selectors"] },
{ name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] },
{ name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] },
{ name: "internal Metadata Doctor", args: ["run", "test:e2e:obsidian:internal-metadata-doctor"] },
+11 -3
View File
@@ -372,13 +372,21 @@ async function restartAndResumeHarness(): Promise<string> {
await harness
.locator('[data-testid="review-harness-resumed"]')
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const continuationRemoved = await page.evaluate((stateKey) => {
const continuationRemoved = await page.evaluate(async (stateKey) => {
const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) {
throw new Error("Self-hosted LiveSync is unavailable after restart.");
}
const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }).core;
return core.services.setting.getSmallConfig(stateKey) === "";
const core = (
plugin as {
core: {
services: {
setting: { config: { getValue(key: string, defaultValue: string): Promise<string> } };
};
};
}
).core;
return (await core.services.setting.config.getValue(stateKey, "")) === "";
}, REVIEW_HARNESS_STATE_KEY);
if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use.");
await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" });
+3
View File
@@ -8,6 +8,8 @@ const focusedScenarios = new Set([
"smoke",
"onboarding-invitation",
"dialog-mounts",
"joined-devices",
"joined-devices-object-storage",
"database-diagnostics",
"database-diagnostics-couchdb",
"conflict-dialog-policy",
@@ -39,6 +41,7 @@ const focusedScenarios = new Set([
"two-vault-sync",
"security-seed-reconnect",
"hidden-file-snippet-sync",
"hidden-file-selectors",
"customisation-sync",
"customisation-catalogue-reload",
"received-change-readiness",
+17 -9
View File
@@ -58,8 +58,9 @@ type LiveSyncTestPlugin = {
usePowerUserMode: boolean;
useEdgeCaseMode: boolean;
hashCacheMaxCount: number;
customChunkSize: number;
};
getSmallConfig(key: string): string | null;
config: { getValue(key: string, defaultValue: string): Promise<string> };
};
};
};
@@ -484,12 +485,12 @@ async function verifyCompatibilityReview(): Promise<void> {
});
await withObsidianPage(port, async (page) => {
const markerBeforeAcknowledgement = await page.evaluate(() => {
const markerBeforeAcknowledgement = await page.evaluate(async () => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable");
return plugin.core.services.setting.getSmallConfig("database-compatibility-version");
return await plugin.core.services.setting.config.getValue("database-compatibility-version", "");
});
if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") {
if (markerBeforeAcknowledgement !== "") {
throw new Error(
`The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}`
);
@@ -579,12 +580,12 @@ async function verifyCompatibilityReview(): Promise<void> {
.click();
await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await page.waitForFunction(
(expectedVersion) => {
async (expectedVersion) => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
if (plugin === undefined) return false;
const setting = plugin.core.services.setting;
return (
setting.getSmallConfig("database-compatibility-version") === expectedVersion &&
(await setting.config.getValue("database-compatibility-version", "")) === expectedVersion &&
setting.currentSettings().versionUpFlash === ""
);
},
@@ -611,10 +612,16 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<string> {
state: "visible",
timeout: uiTimeoutMs,
});
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
const chunkSize = await page.evaluate(() => {
const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"];
return plugin?.core.services.setting.currentSettings().customChunkSize;
});
if (chunkSize !== 0) {
throw new Error("Compatibility review must retain the fixture's zero chunk-size value.");
}
if ((await doctor.getByText("Enhance chunk size", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor must accept the recommended zero chunk-size value.");
}
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
}
@@ -978,6 +985,7 @@ function createSettingsPluginData(settingsOnlyRun: boolean): Record<string, unkn
liveSync: false,
versionUpFlash: settingsOnlyRun ? "" : compatibilityReviewMessage,
notifyThresholdOfRemoteStorageSize: 0,
customChunkSize: 0,
syncOnStart: false,
syncOnSave: false,
syncOnEditorSave: false,