Fix Automatic mode prerequisites and plug-in initialisation targets

This commit is contained in:
vorotamoroz committed 2026-10-23 01:00:22 +00:00
1 parent ce0524d49f
commit e83833d4e8
12 files changed
+735 -19

No files matched your search

+304 -12
View File
@@ -1,6 +1,10 @@
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,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
import { evalObsidianJson } from "../runner/cli.ts";
import {
@@ -23,11 +27,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 +64,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 +121,7 @@ const pluginFixtures = [
{ path: pluginStylesPath, source: pluginStylesContent, target: targetPluginStylesContent },
] as const;
type CustomisationSyncCase = "all" | "visibility" | "mtime";
type CustomisationSyncCase = "all" | "visibility" | "mtime" | "automatic";
type CustomisationSyncTestGlobal = typeof globalThis & {
app?: { commands?: { executeCommandById(commandId: string): boolean } };
@@ -131,6 +148,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 +172,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 +270,196 @@ 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: "Overwrite Remote" | "Overwrite Local"): 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 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
): 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);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const dialogue = await openAutomaticModeForPlugin(page, pluginDisplayName);
await selectAutomaticInitialAction(page, "Overwrite Remote");
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."
);
await pushLocalChanges(context.cliBinary, session.cliEnv);
}
async function removeVaultFile(vaultPath: string, path: string): Promise<void> {
await rm(join(vaultPath, path), { force: true });
}
@@ -287,7 +503,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 +523,7 @@ async function startConfiguredSession(
autoSweepPlugins: false,
autoSweepPluginsPeriodic: false,
syncInternalFiles: false,
...settings,
};
const session = await startObsidianLiveSyncSession({
binary: context.binary,
@@ -319,6 +537,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 +619,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 +648,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 +660,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,
@@ -574,6 +804,7 @@ async function main(): Promise<void> {
const testCase = selectedCase();
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 +835,7 @@ 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);
if (testVisibility) {
for (const fixture of visibilityFixtures) {
await writeVaultFile(vaultA.path, fixture.path, fixture.source);
@@ -627,6 +859,12 @@ 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);
}
let session = await startConfiguredSession(context, vaultA, sourceDeviceName);
const scanResult = await scanCustomisations(context.cliBinary, session.cliEnv);
@@ -636,6 +874,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 +891,18 @@ async function main(): Promise<void> {
pluginName,
3
);
const pluginDataEntry = testAutomatic
? await waitForCustomisationEntry(context.cliBinary, session.cliEnv, "PLUGIN_DATA", pluginName)
: undefined;
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 +922,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) {
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 +943,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 +981,42 @@ async function main(): Promise<void> {
try {
if (testVisibility) await inspectCustomisationVisibility(session);
if (testMtime) await inspectPluginFreshnessAndNewestSelection(session);
if (testAutomatic) await inspectAutomaticSyncSelection(context, session, vaultB, pluginName);
} catch (error) {
await session.app.stop().catch(() => undefined);
throw error;
}
if (testAutomatic) {
await session.app.stop();
session = await startConfiguredSession(context, vaultA, sourceDeviceName, { syncInternalFiles: true });
try {
await pushLocalChanges(context.cliBinary, session.cliEnv);
const receivedStyles = await waitForPathContent(
vaultA.path,
pluginStylesPath,
(content) => content === targetPluginStylesContent
);
const receivedData = await waitForPathContent(
vaultA.path,
pluginDataPath,
(content) => content === targetPluginDataContent
);
assertEqual(
receivedStyles,
targetPluginStylesContent,
"Automatic plug-in group lost PLUGIN_MAIN files."
);
assertEqual(
receivedData,
targetPluginDataContent,
"Automatic plug-in group lost the PLUGIN_DATA file."
);
} finally {
await session.app.stop();
}
console.log("Automatic Customisation Sync initialised and transferred both plug-in group children.");
return;
}
if (testCase !== "all") {
await session.app.stop();
console.log(`Customisation Sync ${testCase} regression case passed.`);