Show CouchDB comparison progress in customisable status items

This commit is contained in:
vorotamoroz committed 2026-10-25 05:40:10 +00:00
1 parent db15bcd5f2
commit 68dc40ff05
14 files changed
+1837 -72

No files matched your search

@@ -0,0 +1,770 @@
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Page } from "playwright";
const fixturePath = "replication-status-e2e.md";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
const fastForwardLingerMs = 3000;
const numericKeys = ["network", "sent", "push-remaining", "arrived", "pull-remaining", "ff"] as const;
type TestReplicationStatics = {
sent: number;
arrived: number;
maxPullSeq: number;
maxPushSeq: number;
lastSyncPullSeq: number;
lastSyncPushSeq: number;
syncStatus: "CONNECTED";
couchDB: {
phase: "continuous";
pull: { checkedChanges: number; completedBatches: number; checking: boolean };
push: { checkedChanges: number; completedBatches: number; checking: boolean };
};
};
type WritableSource<T> = { value: T };
type TestLeaf = {
containerEl?: HTMLElement;
view?: { containerEl?: HTMLElement };
openFile?: (file: unknown) => Promise<void>;
detach?: () => void;
};
type TestWorkspace = {
activeLeaf?: TestLeaf | null;
getLeaf?: (newLeaf?: boolean | "split" | "tab" | "window", direction?: "horizontal" | "vertical") => TestLeaf;
openPopoutLeaf?: () => TestLeaf;
setActiveLeaf?: (leaf: TestLeaf, params?: { focus?: boolean }) => void;
iterateAllLeaves?: (callback: (leaf: TestLeaf) => void) => void;
getActiveFile?: () => { path?: string } | null;
};
type TestLogModule = {
statusLog?: WritableSource<string>;
statusDiv?: HTMLElement;
statusLine?: HTMLElement;
statusLineRenderer?: { render?: (parts: readonly unknown[]) => void };
statusBarLabels?: WritableSource<{ parts: readonly unknown[] }>;
__addLog?: (message: unknown, level?: number, key?: string) => void;
};
type TestCore = {
modules?: unknown[];
services?: {
setting?: { currentSettings?: () => Record<string, unknown> };
replicator?: {
replicationStatics?: WritableSource<TestReplicationStatics>;
};
API?: {
requestCount?: WritableSource<number>;
responseCount?: WritableSource<number>;
};
};
};
type TestApp = {
plugins?: {
plugins?: { "obsidian-livesync"?: { core?: TestCore } };
};
vault?: {
getAbstractFileByPath?: (path: string) => unknown | null;
create?: (path: string, content: string) => Promise<unknown>;
};
workspace?: TestWorkspace;
};
type TestWindow = typeof globalThis & {
app?: TestApp;
require?: (moduleName: string) => unknown;
__livesyncStatusFixtureFile?: unknown;
__livesyncStatusFirstLeaf?: TestLeaf;
__livesyncStatusSplitLeaf?: TestLeaf;
__livesyncStatusPopoutLeaf?: TestLeaf;
__livesyncStatusNode?: HTMLElement;
};
type MainWindowControl = {
hidden: boolean;
method?: string;
reason?: string;
restore(): Promise<void>;
};
function failUnless(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function replicationStats(checkedChanges: number, sent: number, arrived: number): TestReplicationStatics {
return {
sent,
arrived,
maxPullSeq: sent + 1,
maxPushSeq: sent + 1,
lastSyncPullSeq: 1,
lastSyncPushSeq: 1,
syncStatus: "CONNECTED",
couchDB: {
phase: "continuous",
pull: { checkedChanges, completedBatches: 0, checking: true },
push: { checkedChanges: 0, completedBatches: 0, checking: false },
},
};
}
async function installFixtureFile(page: Page): Promise<void> {
const result = await page.evaluate(async (path) => {
const app = (globalThis as TestWindow).app;
const workspace = app?.workspace;
const vault = app?.vault;
if (!workspace?.getLeaf || !workspace.setActiveLeaf || !vault?.create || !vault.getAbstractFileByPath) {
return { ready: false, reason: "Obsidian workspace APIs are unavailable." };
}
let file = vault.getAbstractFileByPath(path);
if (!file) file = await vault.create(path, "Replication status renderer fixture.\n");
const leaf = workspace.getLeaf(false);
if (!leaf.openFile) return { ready: false, reason: "The active workspace leaf cannot open a file." };
await leaf.openFile(file);
workspace.setActiveLeaf(leaf, { focus: true });
(globalThis as TestWindow).__livesyncStatusFixtureFile = file;
(globalThis as TestWindow).__livesyncStatusFirstLeaf = leaf;
return { ready: true };
}, fixturePath);
if (!result.ready)
throw new Error("reason" in result ? result.reason : "Could not initialise the fixture editor leaf.");
}
async function injectReplicationStats(
page: Page,
stats: TestReplicationStatics,
requestCount: number,
responseCount: number
): Promise<void> {
const result = await page.evaluate(
({ stats, requestCount, responseCount }) => {
const core = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core;
const replicator = core?.services?.replicator;
const api = core?.services?.API;
if (!replicator?.replicationStatics || !api?.requestCount || !api.responseCount) {
return { ready: false, reason: "LiveSync replication status sources are unavailable." };
}
replicator.replicationStatics.value = stats;
api.requestCount.value = requestCount;
api.responseCount.value = responseCount;
return { ready: true };
},
{ stats, requestCount, responseCount }
);
if (!result.ready)
throw new Error("reason" in result ? result.reason : "Could not inject replication status fixtures.");
}
async function appendLiveSyncLog(page: Page, message: string): Promise<void> {
const result = await page.evaluate((message) => {
const core = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core;
const modules = core?.modules ?? [];
const moduleLog = modules.find((entry): entry is TestLogModule => {
if (typeof entry !== "object" || entry === null) return false;
const candidate = entry as TestLogModule;
return typeof candidate.__addLog === "function" && candidate.statusLog !== undefined;
});
if (!moduleLog?.__addLog) return { ready: false, reason: "LiveSync logging is unavailable." };
moduleLog.__addLog(message);
return { ready: true };
}, message);
if (!result.ready) throw new Error("reason" in result ? result.reason : "Could not append a LiveSync log entry.");
}
async function waitForPartText(page: Page, key: string, expected: string): Promise<void> {
const selector = `.livesync-status-statusline .livesync-stat-${key} .livesync-stat-value`;
await page.waitForFunction(
({ selector, expected }) => document.querySelector(selector)?.textContent?.trim() === expected,
{ selector, expected },
{ timeout: uiTimeoutMs }
);
}
async function waitForLogMessage(page: Page, expected: string, timeout = uiTimeoutMs): Promise<void> {
await page.waitForFunction(
(message) => document.querySelector(".livesync-status-logmessage")?.textContent?.includes(message) === true,
expected,
{ timeout }
);
}
async function assertSingleOverlay(contextPages: Page[], expectedCount = 1): Promise<void> {
const counts = await Promise.all(
contextPages.map(
async (page) =>
await page
.locator(".livesync-status")
.count()
.catch(() => 0)
)
);
const actualCount = counts.reduce((total, count) => total + count, 0);
failUnless(
actualCount === expectedCount,
`Expected ${expectedCount} LiveSync status overlay across Obsidian windows, found ${actualCount} (${counts.join(", ")}).`
);
}
async function waitForOverlayOnActiveLeaf(page: Page): Promise<void> {
await page.waitForFunction(
() => {
const host = globalThis as TestWindow;
const status = host.__livesyncStatusNode;
const activeLeaf = host.app?.workspace?.activeLeaf;
return (
status !== undefined &&
document.querySelectorAll(".livesync-status").length === 1 &&
document.querySelector(".livesync-status") === status &&
activeLeaf?.view?.containerEl?.contains(status) === true
);
},
undefined,
{ timeout: uiTimeoutMs }
);
}
async function getStatusDiagnostics(page: Page): Promise<Record<string, unknown>> {
return await page.evaluate(() => {
const app = (globalThis as TestWindow).app;
const core = app?.plugins?.plugins?.["obsidian-livesync"]?.core;
const moduleLog = core?.modules?.find((entry): entry is TestLogModule => {
if (typeof entry !== "object" || entry === null) return false;
const candidate = entry as TestLogModule;
return candidate.statusDiv !== undefined || candidate.statusLineRenderer !== undefined;
});
const currentSettings = core?.services?.setting?.currentSettings?.();
const status = document.querySelector<HTMLElement>(".livesync-status");
const statusParent = status?.parentElement;
const activeLeaf = app?.workspace?.activeLeaf;
const recentLeaf = app?.workspace?.getLeaf?.(false);
const statusStyle = status ? getComputedStyle(status) : undefined;
const statusParentStyle = statusParent ? getComputedStyle(statusParent) : undefined;
const activeLeafContainer = activeLeaf?.view?.containerEl;
const recentLeafContainer = recentLeaf?.view?.containerEl;
const activeLeafStyle = activeLeafContainer ? getComputedStyle(activeLeafContainer) : undefined;
const recentLeafStyle = recentLeafContainer ? getComputedStyle(recentLeafContainer) : undefined;
return {
settings: currentSettings
? {
isConfigured: currentSettings.isConfigured,
showStatusOnEditor: currentSettings.showStatusOnEditor,
showStatusOnStatusbar: currentSettings.showStatusOnStatusbar,
}
: undefined,
statusCount: document.querySelectorAll(".livesync-status").length,
status: status
? {
tag: status.tagName,
className: status.className,
display: statusStyle?.display,
visibility: statusStyle?.visibility,
}
: undefined,
statusParent: statusParent
? {
tag: statusParent.tagName,
className: statusParent.className,
display: statusParentStyle?.display,
visibility: statusParentStyle?.visibility,
}
: undefined,
moduleHasStatusDiv: moduleLog?.statusDiv !== undefined,
moduleStatusLine: moduleLog?.statusLine
? {
tag: moduleLog.statusLine.tagName,
className: moduleLog.statusLine.className,
}
: undefined,
activeFile: app?.workspace?.getActiveFile?.()?.path,
activeLeafContainer: activeLeafContainer
? {
tag: activeLeafContainer.tagName,
className: activeLeafContainer.className,
display: activeLeafStyle?.display,
visibility: activeLeafStyle?.visibility,
}
: undefined,
recentLeafContainer: recentLeafContainer
? {
tag: recentLeafContainer.tagName,
className: recentLeafContainer.className,
display: recentLeafStyle?.display,
visibility: recentLeafStyle?.visibility,
}
: undefined,
statusInActiveLeaf: activeLeafContainer?.contains(status) ?? false,
statusInRecentLeaf: recentLeafContainer?.contains(status) ?? false,
};
});
}
async function waitForPopoutPage(page: Page, marker: string): Promise<Page> {
const deadline = Date.now() + uiTimeoutMs;
const selector = `[data-e2e-status-popout="${marker}"]`;
while (Date.now() < deadline) {
for (const candidate of page.context().pages()) {
if (
(await candidate
.locator(selector)
.count()
.catch(() => 0)) > 0
)
return candidate;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Obsidian did not expose the popout renderer containing ${selector}.`);
}
async function createPopoutLeaf(page: Page, marker: string): Promise<void> {
const result = await page.evaluate(async (marker) => {
const host = globalThis as TestWindow;
const app = host.app;
const workspace = app?.workspace;
const file = host.__livesyncStatusFixtureFile;
if (!workspace?.openPopoutLeaf || !workspace.setActiveLeaf || file === undefined) {
return { ready: false, reason: "Obsidian popout workspace APIs are unavailable." };
}
const leaf = workspace.openPopoutLeaf();
if (!leaf.openFile || !leaf.containerEl) return { ready: false, reason: "The popout leaf is incomplete." };
leaf.containerEl.dataset.e2eStatusPopout = marker;
await leaf.openFile(file);
workspace.setActiveLeaf(leaf, { focus: true });
host.__livesyncStatusPopoutLeaf = leaf;
return { ready: true };
}, marker);
if (!result.ready) throw new Error("reason" in result ? result.reason : "Could not open an Obsidian popout leaf.");
}
async function switchToLeaf(page: Page, leafName: "first" | "split"): Promise<void> {
const result = await page.evaluate((leafName) => {
const host = globalThis as TestWindow;
const workspace = host.app?.workspace;
const leaf = leafName === "first" ? host.__livesyncStatusFirstLeaf : host.__livesyncStatusSplitLeaf;
if (!workspace?.setActiveLeaf || !leaf) return { ready: false };
workspace.setActiveLeaf(leaf, { focus: true });
return { ready: true };
}, leafName);
failUnless(result.ready, `Could not activate the ${leafName} editor leaf.`);
await waitForOverlayOnActiveLeaf(page);
}
async function createSplitLeaf(page: Page): Promise<void> {
const result = await page.evaluate(async () => {
const host = globalThis as TestWindow;
const workspace = host.app?.workspace;
const file = host.__livesyncStatusFixtureFile;
if (!workspace?.getLeaf || !workspace.setActiveLeaf || file === undefined) {
return { ready: false, reason: "Obsidian split workspace APIs are unavailable." };
}
const leaf = workspace.getLeaf("split", "vertical");
if (!leaf.openFile) return { ready: false, reason: "The split leaf cannot open a file." };
await leaf.openFile(file);
workspace.setActiveLeaf(leaf, { focus: true });
host.__livesyncStatusSplitLeaf = leaf;
return { ready: true };
});
if (!result.ready) throw new Error("reason" in result ? result.reason : "Could not create a split editor leaf.");
}
async function observeRepeatedRendering(page: Page): Promise<{ mutationCount: number; elapsedMs: number }> {
return await page.evaluate(async () => {
const core = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core;
const modules = core?.modules ?? [];
const moduleLog = modules.find((entry): entry is TestLogModule => {
if (typeof entry !== "object" || entry === null) return false;
const candidate = entry as TestLogModule;
return (
typeof candidate.statusLineRenderer?.render === "function" && candidate.statusBarLabels !== undefined
);
});
const statusLine = document.querySelector(".livesync-status-statusline");
if (!moduleLog?.statusLineRenderer?.render || !moduleLog.statusBarLabels || !statusLine) {
throw new Error("The LiveSync status renderer is unavailable for the idempotence observation.");
}
let mutationCount = 0;
const observer = new MutationObserver((records) => {
mutationCount += records.length;
});
observer.observe(statusLine, { attributes: true, characterData: true, childList: true, subtree: true });
const parts = moduleLog.statusBarLabels.value.parts;
const startedAt = performance.now();
for (let iteration = 0; iteration < 1000; iteration++) {
moduleLog.statusLineRenderer.render(parts);
}
const elapsedMs = performance.now() - startedAt;
await new Promise((resolve) => setTimeout(resolve, 0));
observer.disconnect();
return { mutationCount, elapsedMs };
});
}
async function measureNumericWidths(page: Page): Promise<Record<(typeof numericKeys)[number], number>> {
const measurements = await page.evaluate((keys) => {
const result: Record<string, number> = {};
const statusLine = document.querySelector(".livesync-status-statusline");
if (!statusLine) throw new Error("The LiveSync editor status line is unavailable.");
for (const key of keys) {
const value = statusLine.querySelector<HTMLElement>(`.livesync-stat-${key} .livesync-stat-value`);
if (!value || value.hidden) throw new Error(`The numeric status item ${key} is not visible.`);
if (getComputedStyle(value).fontVariantNumeric !== "tabular-nums") {
throw new Error(`The numeric status item ${key} does not use tabular figures.`);
}
result[key] = value.getBoundingClientRect().width;
}
return result;
}, numericKeys);
return measurements as Record<(typeof numericKeys)[number], number>;
}
async function assertNumericWidths(
page: Page,
expectedValue: string
): Promise<Record<(typeof numericKeys)[number], number>> {
for (const key of numericKeys) await waitForPartText(page, key, expectedValue);
const widths = await page.evaluate((keys) => {
const result: Record<string, { width: string; value: string }> = {};
const statusLine = document.querySelector(".livesync-status-statusline");
if (!statusLine) throw new Error("The LiveSync editor status line is unavailable.");
for (const key of keys) {
const value = statusLine.querySelector<HTMLElement>(`.livesync-stat-${key} .livesync-stat-value`);
if (!value || value.hidden) throw new Error(`The numeric status item ${key} is not visible.`);
result[key] = {
width: value.style.getPropertyValue("--livesync-stat-width"),
value: value.textContent?.trim() ?? "",
};
}
return result;
}, numericKeys);
for (const key of numericKeys) {
failUnless(widths[key]?.value === expectedValue, `Status item ${key} did not render ${expectedValue}.`);
failUnless(widths[key]?.width === "4ch", `Status item ${key} did not retain a 4ch width.`);
}
return await measureNumericWidths(page);
}
async function measureStatusBarNetworkWidth(
page: Page,
expectedValue: string
): Promise<{ cssWidth: string; renderedWidth: number }> {
const selector = ".syncstatusbar .livesync-stat-network .livesync-stat-value";
await page.waitForFunction(
({ selector, expectedValue }) => {
const value = document.querySelector<HTMLElement>(selector);
return value !== null && !value.hidden && value.textContent?.trim() === expectedValue;
},
{ selector, expectedValue },
{ timeout: uiTimeoutMs }
);
return await page.locator(selector).evaluate((element) => {
const value = element as HTMLElement;
return {
cssWidth: value.style.getPropertyValue("--livesync-stat-width"),
renderedWidth: value.getBoundingClientRect().width,
};
});
}
async function hideMainWindow(page: Page): Promise<MainWindowControl> {
const nativeWindow = await page.evaluate((action) => {
type ElectronWindow = {
isVisible?: () => boolean;
hide?: () => void;
show?: () => void;
focus?: () => void;
};
type RemoteModule = { getCurrentWindow?: () => ElectronWindow };
const host = globalThis as TestWindow;
let electronModule: unknown;
try {
electronModule = host.require?.("electron");
} catch {
electronModule = undefined;
}
const electron = electronModule as { remote?: RemoteModule } | undefined;
let remoteModule: unknown = electron?.remote;
if (!remoteModule) {
try {
remoteModule = host.require?.("@electron/remote");
} catch {
remoteModule = undefined;
}
}
const remote = remoteModule as RemoteModule | undefined;
const currentWindow = remote?.getCurrentWindow?.();
if (!currentWindow?.isVisible || !currentWindow.hide || !currentWindow.show) {
return {
available: false,
visible: false,
changed: false,
reason: "Electron remote window controls are unavailable.",
};
}
if (action === "hide") {
if (!currentWindow.isVisible()) {
return {
available: true,
visible: false,
changed: false,
reason: "The main Electron window was already hidden.",
};
}
currentWindow.hide();
const visible = currentWindow.isVisible();
return { available: true, visible, changed: !visible };
}
currentWindow.show();
currentWindow.focus?.();
return { available: true, visible: currentWindow.isVisible(), changed: false };
}, "hide");
if (nativeWindow.available && nativeWindow.changed && !nativeWindow.visible) {
return {
hidden: true,
method: "Electron remote",
async restore() {
const restored = await page.evaluate(() => {
type ElectronWindow = {
isVisible?: () => boolean;
hide?: () => void;
show?: () => void;
focus?: () => void;
};
type RemoteModule = { getCurrentWindow?: () => ElectronWindow };
const host = globalThis as TestWindow;
let electronModule: unknown;
try {
electronModule = host.require?.("electron");
} catch {
electronModule = undefined;
}
const electron = electronModule as { remote?: RemoteModule } | undefined;
let remoteModule: unknown = electron?.remote;
if (!remoteModule) {
try {
remoteModule = host.require?.("@electron/remote");
} catch {
remoteModule = undefined;
}
}
const remote = remoteModule as RemoteModule | undefined;
const currentWindow = remote?.getCurrentWindow?.();
if (!currentWindow?.show) return false;
currentWindow.show();
currentWindow.focus?.();
return currentWindow.isVisible?.() === true;
});
if (!restored) throw new Error("The hidden main Electron window could not be restored.");
},
};
}
return {
hidden: false,
reason: nativeWindow.reason ?? "Electron remote did not hide the main window.",
restore: async () => {},
};
}
async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void> {
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
let popoutMarker: string | undefined;
let popoutPage: Page | undefined;
let mainWindow: MainWindowControl | undefined;
try {
await installFixtureFile(page);
try {
await page.locator(".livesync-status").waitFor({ state: "attached", timeout: uiTimeoutMs });
} catch (error) {
const diagnostics = await getStatusDiagnostics(page);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nLiveSync status setup: ${JSON.stringify(diagnostics)}`
);
}
const setup = await getStatusDiagnostics(page);
const settings = setup.settings as { showStatusOnEditor?: unknown } | undefined;
failUnless(
setup.moduleHasStatusDiv === true && settings?.showStatusOnEditor === true,
`LiveSync editor status is not enabled in the isolated vault: ${JSON.stringify(setup)}`
);
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
try {
await page.locator(".livesync-status").waitFor({ state: "visible", timeout: uiTimeoutMs });
} catch (error) {
const diagnostics = await getStatusDiagnostics(page);
throw new Error(
`${error instanceof Error ? error.message : String(error)}\nLiveSync status setup: ${JSON.stringify(diagnostics)}`
);
}
await page.evaluate(() => {
const host = globalThis as TestWindow;
const status = document.querySelector<HTMLElement>(".livesync-status");
if (!status) throw new Error("LiveSync editor status overlay was not created.");
host.__livesyncStatusNode = status;
});
await waitForOverlayOnActiveLeaf(page);
const initialStats = replicationStats(1000, 1000, 0);
const startedAt = Date.now();
await injectReplicationStats(page, initialStats, 0, 0);
await waitForPartText(page, "arrived", "0");
await waitForPartText(page, "ff", "1000");
const syncIcon = await page.locator(".livesync-status-statusline .livesync-stat-sync").textContent();
failUnless(syncIcon?.includes("⏩"), "The fast-forward checking icon was not independent of arrivals.");
for (let update = 1; update <= 3; update++) {
await page.waitForTimeout(700);
await injectReplicationStats(page, replicationStats(1000, 1000 + update, 0), 0, 0);
}
const remainingLinger = Math.max(0, fastForwardLingerMs + 350 - (Date.now() - startedAt));
if (remainingLinger > 0) await page.waitForTimeout(remainingLinger);
await page.waitForFunction(
() =>
document.querySelector<HTMLElement>(".livesync-status-statusline .livesync-stat-ff")?.hidden ===
true,
undefined,
{ timeout: uiTimeoutMs }
);
const iconAfterExpiry = await page.locator(".livesync-status-statusline .livesync-stat-sync").textContent();
failUnless(
iconAfterExpiry?.includes("⏩"),
"The fast-forward checking icon disappeared when its count expired."
);
await injectReplicationStats(page, replicationStats(1001, 1001, 1001), 1001, 0);
const wideWidths = await assertNumericWidths(page, "1001");
const wideStatusBarWidth = await measureStatusBarNetworkWidth(page, "1001");
failUnless(wideStatusBarWidth.cssWidth === "4ch", "The status-bar network count did not reserve 4ch.");
await injectReplicationStats(page, replicationStats(7, 7, 7), 7, 0);
const narrowWidths = await assertNumericWidths(page, "7");
const narrowStatusBarWidth = await measureStatusBarNetworkWidth(page, "7");
for (const key of numericKeys) {
failUnless(
Math.abs(wideWidths[key] - narrowWidths[key]) < 0.01,
`Status item ${key} changed rendered width from ${wideWidths[key]}px to ${narrowWidths[key]}px.`
);
}
failUnless(
narrowStatusBarWidth.cssWidth === "4ch" &&
Math.abs(wideStatusBarWidth.renderedWidth - narrowStatusBarWidth.renderedWidth) < 0.01,
`The status-bar network count changed width from ${wideStatusBarWidth.renderedWidth}px to ${narrowStatusBarWidth.renderedWidth}px.`
);
const repeatedRender = await observeRepeatedRendering(page);
failUnless(
repeatedRender.mutationCount === 0,
`Repeated identical status rendering caused ${repeatedRender.mutationCount} DOM mutations.`
);
console.log(
`Identical status render: 1000 iterations, 0 DOM mutations, ${repeatedRender.elapsedMs.toFixed(2)} ms.`
);
await createSplitLeaf(page);
await assertSingleOverlay(page.context().pages());
await waitForOverlayOnActiveLeaf(page);
await switchToLeaf(page, "first");
await assertSingleOverlay(page.context().pages());
await switchToLeaf(page, "split");
await assertSingleOverlay(page.context().pages());
await switchToLeaf(page, "first");
await assertSingleOverlay(page.context().pages());
popoutMarker = `livesync-status-${process.pid}-${Date.now()}`;
await createPopoutLeaf(page, popoutMarker);
popoutPage = await waitForPopoutPage(page, popoutMarker);
await popoutPage.locator(".livesync-status").waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertSingleOverlay(page.context().pages());
await popoutPage.waitForFunction(
(marker) => {
const leaf = document.querySelector<HTMLElement>(`[data-e2e-status-popout="${marker}"]`);
const status = document.querySelector<HTMLElement>(".livesync-status");
return leaf !== null && status !== null && leaf.contains(status);
},
popoutMarker,
{ timeout: uiTimeoutMs }
);
const popoutLog = `E2E popout log ${Date.now()}`;
await appendLiveSyncLog(page, popoutLog);
await waitForLogMessage(popoutPage, popoutLog);
mainWindow = await hideMainWindow(page);
if (!mainWindow.hidden) {
console.warn(
`Skipped hidden-main-window assertion: ${mainWindow.reason ?? "minimisation unavailable"}`
);
} else {
await popoutPage.bringToFront();
const hiddenWindowLog = `E2E hidden-main log ${Date.now()}`;
await appendLiveSyncLog(page, hiddenWindowLog);
await waitForLogMessage(popoutPage, hiddenWindowLog, Math.min(uiTimeoutMs, 2500));
console.log(`Popout log updated while the main window was hidden through ${mainWindow.method}.`);
}
await mainWindow.restore();
mainWindow = undefined;
await page.bringToFront();
await switchToLeaf(page, "first");
await assertSingleOverlay(page.context().pages());
await waitForOverlayOnActiveLeaf(page);
failUnless(
await page.evaluate(
() => (globalThis as TestWindow).__livesyncStatusNode === document.querySelector(".livesync-status")
),
"The LiveSync status overlay node was replaced while moving between editor windows."
);
const returnedLog = `E2E main-window log ${Date.now()}`;
await appendLiveSyncLog(page, returnedLog);
await waitForLogMessage(page, returnedLog);
console.log("Replication status, split-pane ownership, and popout status/log checks passed.");
} finally {
if (mainWindow) await mainWindow.restore();
await page.bringToFront().catch(() => {});
if (popoutMarker !== undefined) {
await page
.evaluate((marker) => {
const workspace = (globalThis as TestWindow).app?.workspace;
workspace?.iterateAllLeaves?.((leaf) => {
if (leaf.containerEl?.dataset.e2eStatusPopout === marker) leaf.detach?.();
});
}, popoutMarker)
.catch(() => {});
}
if (popoutPage) await popoutPage.close().catch(() => {});
}
});
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const vault = await createTemporaryVault("obsidian-livesync-replication-status-");
let session: ObsidianLiveSyncSession | undefined;
try {
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
liveSync: false,
syncOnStart: false,
syncOnSave: false,
isConfigured: false,
remoteType: "",
showStatusOnEditor: true,
showOnlyIconsOnEditor: false,
showStatusOnStatusbar: true,
lessInformationInLog: false,
writeLogToTheFile: false,
},
});
failUnless(session.readiness.pluginId === "obsidian-livesync", "The LiveSync plug-in did not become ready.");
await runStatusScenario(session);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});