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