fix: complete directional CouchDB status integration

Keep editor comparison counters compact with their directional CSS classes,
and document selector migration plus an optional CSS animation snippet.

Extend real Obsidian coverage for push comparison with zero transfers,
independent expiry, idle and error states, and the documented snippet.
Prepare the fixture editor before loading the plug-in so the UI scenario
owns its setup order.
This commit is contained in:
vorotamoroz committed 2026-10-29 12:22:44 +00:00
1 parent b3d74aa06d
commit 2fb1c0352d
5 files changed
+309 -28

No files matched your search

+5 -1
View File
@@ -88,7 +88,11 @@ npm run test:e2e:obsidian:focused -- replication-status
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
`replication-status` uses an isolated Vault without a remote service. It injects synthetic replication statistics into the real plug-in to check the ⏩ icon, the comparison count and its three-second expiry, independently styled numeric items, stable rendered digit widths, computed right alignment, and unchanged DOM nodes for repeated values. It also feeds the existing queue sources and checks their values in both displays, the separate P2P line, file warnings and their compact icon, and the corresponding plain-text status. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. A separate session enters mobile emulation before loading the plug-in and measures the editor item gaps at a 390px viewport width. The desktop checks also ensure that the status bar keeps its compact spacing. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
`replication-status` uses isolated Vaults without a remote service. It opens the fixture editor before loading the plug-in, then checks the resulting overlay and subsequent leaf transitions. It injects synthetic replication statistics into real Obsidian to check the ⏩ icon and the push and pull comparison counts. The comparison cases cover a push-only count while the `sent` total is zero, independent three-second expiry when both directions are active, a zeroed state before a fresh 0-to-1 count, restoration of the ordinary Sync icon when CouchDB is idle, and error-icon priority. The plain-text status is checked for separate comparison and transfer counts, including its `(LIVE)` labels.
The scenario also checks independently styled numeric items, stable rendered digit widths, computed right alignment, and unchanged DOM nodes for repeated values. It feeds the existing queue sources and checks their values in both displays, the separate P2P line, file warnings and their compact icon, and the corresponding plain-text status. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. The desktop checks ensure that the status bar keeps its compact spacing.
The scenario reads the animation CSS fence from [the status display guide](../../docs/tips/status-display.md) and checks it in desktop and mobile emulation: an inactive item collapses through an interpolated width, the same DOM node expands when reactivated, and reduced motion disables the transition. The fallback check substitutes a false `@supports` condition and confirms default hiding; this simulates feature unavailability and does not verify a browser which lacks `calc-size()` or Safari. A separate session enters mobile emulation before loading the plug-in and measures editor item gaps at a 390px viewport width. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. The scenario switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
`database-diagnostics` needs no remote service. It checks the Usage command and Hatch integrity action with an isolated Vault, including visible radio choices for original names and pseudonyms, their default and keyboard navigation, the default-on integrity-only remote option, retained local findings when the remote is unavailable, cancellation during inspection with earlier rows retained, and unchanged settings, local revisions, and Vault files. It captures desktop results and the choice and result dialogues in mobile emulation, checking layout at 800×600 and 375×667, radio choices for available formats, their touch targets, and export controls. Copy and download payloads are captured inside the isolated renderer, so the scenario leaves the host clipboard and download directory unchanged.
+249 -22
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
@@ -8,7 +9,7 @@ import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.t
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;
const numericKeys = ["network", "sent", "push-remaining", "arrived", "pull-remaining", "ff-push", "ff-pull"] as const;
type TestReplicationStatics = {
sent: number;
@@ -17,9 +18,9 @@ type TestReplicationStatics = {
maxPushSeq: number;
lastSyncPullSeq: number;
lastSyncPushSeq: number;
syncStatus: "CONNECTED";
syncStatus: "CONNECTED" | "PAUSED" | "ERRORED";
couchDB: {
phase: "continuous";
phase: "continuous" | "idle";
pull: { checkedChanges: number; completedBatches: number; checking: boolean };
push: { checkedChanges: number; completedBatches: number; checking: boolean };
};
@@ -33,6 +34,8 @@ type TestLeaf = {
detach?: () => void;
};
type TestWorkspace = {
layoutReady?: boolean;
getMostRecentLeaf?: () => TestLeaf | null;
activeLeaf?: TestLeaf | null;
getLeaf?: (newLeaf?: boolean | "split" | "tab" | "window", direction?: "horizontal" | "vertical") => TestLeaf;
openPopoutLeaf?: () => TestLeaf;
@@ -106,7 +109,12 @@ function failUnless(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function replicationStats(checkedChanges: number, sent: number, arrived: number): TestReplicationStatics {
function replicationStats(
checkedChanges: number,
sent: number,
arrived: number,
pushCheckedChanges = 0
): TestReplicationStatics {
return {
sent,
arrived,
@@ -118,7 +126,7 @@ function replicationStats(checkedChanges: number, sent: number, arrived: number)
couchDB: {
phase: "continuous",
pull: { checkedChanges, completedBatches: 0, checking: true },
push: { checkedChanges: 0, completedBatches: 0, checking: false },
push: { checkedChanges: pushCheckedChanges, completedBatches: 0, checking: pushCheckedChanges > 0 },
},
};
}
@@ -252,7 +260,7 @@ async function getStatusDiagnostics(page: Page): Promise<Record<string, unknown>
const status = document.querySelector<HTMLElement>(".livesync-status");
const statusParent = status?.parentElement;
const activeLeaf = app?.workspace?.activeLeaf;
const recentLeaf = app?.workspace?.getLeaf?.(false);
const recentLeaf = app?.workspace?.getMostRecentLeaf?.();
const statusStyle = status ? getComputedStyle(status) : undefined;
const statusParentStyle = statusParent ? getComputedStyle(statusParent) : undefined;
const activeLeafContainer = activeLeaf?.view?.containerEl;
@@ -284,7 +292,11 @@ async function getStatusDiagnostics(page: Page): Promise<Record<string, unknown>
visibility: statusParentStyle?.visibility,
}
: undefined,
workspaceLayoutReady: app?.workspace?.layoutReady,
moduleHasStatusDiv: moduleLog?.statusDiv !== undefined,
moduleStatusDivConnected: moduleLog?.statusDiv?.isConnected,
moduleStatusDivParent: moduleLog?.statusDiv?.parentElement?.className,
recentLeafIsActive: recentLeaf === activeLeaf,
moduleStatusLine: moduleLog?.statusLine
? {
tag: moduleLog.statusLine.tagName,
@@ -557,7 +569,7 @@ async function assertStatusBarItemSpacing(page: Page): Promise<void> {
async function assertInactiveItemStyling(page: Page): Promise<void> {
const roots = [".livesync-status-statusline", ".syncstatusbar"];
for (const root of roots) {
const state = await page.locator(`${root} .livesync-stat-ff`).evaluate((element) => {
const state = await page.locator(`${root} .livesync-stat-ff-pull`).evaluate((element) => {
const item = element as HTMLElement;
return {
inactive: item.classList.contains("livesync-stat-inactive"),
@@ -588,7 +600,7 @@ async function assertInactiveItemStyling(page: Page): Promise<void> {
try {
for (const root of roots) {
failUnless(
await page.locator(`${root} .livesync-stat-ff`).isVisible(),
await page.locator(`${root} .livesync-stat-ff-pull`).isVisible(),
`A CSS override could not reveal the inactive item in ${root}.`
);
}
@@ -597,12 +609,221 @@ async function assertInactiveItemStyling(page: Page): Promise<void> {
}
for (const root of roots) {
failUnless(
await page.locator(`${root} .livesync-stat-ff`).isHidden(),
await page.locator(`${root} .livesync-stat-ff-pull`).isHidden(),
`The default inactive style was not restored in ${root}.`
);
}
}
async function waitForComparisonCounts(page: Page, push?: number, pull?: number): Promise<void> {
await page.waitForFunction(
({ push, pull }) =>
[".livesync-status-statusline", ".syncstatusbar"].every((root) =>
(["push", "pull"] as const).every((direction) => {
const value = document.querySelector(
`${root} .livesync-stat-ff-${direction}:not(.livesync-stat-inactive) .livesync-stat-value`
);
const expected = direction === "push" ? push : pull;
return expected === undefined ? value === null : value?.textContent?.trim() === String(expected);
})
),
{ push, pull },
{ timeout: uiTimeoutMs }
);
}
async function waitForSyncIcon(page: Page, icon: string): Promise<void> {
await page.waitForFunction(
(expected) =>
[".livesync-status-statusline", ".syncstatusbar"].every(
(root) => document.querySelector(`${root} .livesync-stat-sync`)?.textContent === `Sync: ${expected}`
),
icon,
{ timeout: uiTimeoutMs }
);
}
async function assertDirectionalComparison(page: Page): Promise<void> {
const pushOnly = replicationStats(0, 0, 0, 71975);
pushOnly.couchDB.pull.checking = false;
await injectReplicationStats(page, pushOnly, 0, 0);
await waitForComparisonCounts(page, 71975);
await waitForSyncIcon(page, "⏩");
for (const root of [".livesync-status-statusline", ".syncstatusbar"]) {
failUnless(
(await page.locator(`${root} .livesync-stat-sent .livesync-stat-value`).textContent())?.trim() === "0",
`Push comparison changed the transfer count in ${root}.`
);
}
await injectReplicationStats(page, replicationStats(4, 0, 0, 40), 0, 0);
await waitForComparisonCounts(page, 40, 4);
for (const direction of ["push", "pull"]) {
const margin = await page
.locator(`.livesync-status-statusline .livesync-stat-ff-${direction}`)
.evaluate((element) => Number.parseFloat(getComputedStyle(element).marginInlineStart));
failUnless(margin === 0, `The ${direction} comparison item acquired an extra ${margin}px editor gap.`);
}
for (let update = 1; update <= 3; update++) {
await page.waitForTimeout(700);
await injectReplicationStats(page, replicationStats(4, 0, 0, 40 + update), 0, 0);
}
await waitForComparisonCounts(page, 43);
await waitForComparisonCounts(page);
await injectReplicationStats(page, replicationStats(4, 7, 11, 43), 0, 0);
await waitForPartText(page, "arrived", "11");
await waitForComparisonCounts(page);
const idle = replicationStats(4, 7, 11, 43);
idle.syncStatus = "PAUSED";
idle.couchDB.phase = "idle";
idle.couchDB.push.checking = false;
idle.couchDB.pull.checking = false;
await injectReplicationStats(page, idle, 0, 0);
await waitForSyncIcon(page, "💤");
await waitForComparisonCounts(page);
const failed = replicationStats(5, 7, 11, 44);
failed.syncStatus = "ERRORED";
await injectReplicationStats(page, failed, 0, 0);
await waitForComparisonCounts(page, 44, 5);
await waitForSyncIcon(page, "⚠");
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await waitForComparisonCounts(page);
await injectReplicationStats(page, replicationStats(1, 0, 0, 1), 0, 0);
await waitForComparisonCounts(page, 1, 1);
await waitForSyncIcon(page, "⏩");
const labels = await page.evaluate(() => {
const modules = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core?.modules ?? [];
const moduleLog = modules.find(
(entry): entry is TestLogModule =>
typeof entry === "object" && entry !== null && (entry as TestLogModule).statusBarLabels !== undefined
);
return moduleLog?.statusBarLabels?.value.message;
});
failUnless(
labels?.includes("(⏩↑ 1) (⏩↓ 1) ↑ 0 (LIVE) ↓ 0 (LIVE)"),
`The plain-text status did not distinguish comparison directions from transfers: ${labels}`
);
console.log(
"Directional comparison passed: push-only, independent expiry, idle/error icons, and fresh-run counts."
);
}
function documentedAnimationSnippet(): string {
const guide = readFileSync(new URL("../../../docs/tips/status-display.md", import.meta.url), "utf8");
const section = guide.split("### Animate editor status items")[1];
const snippet = section?.match(/```css\r?\n([\s\S]*?)\r?\n```/u)?.[1];
failUnless(snippet, "The documented editor animation snippet could not be found.");
return snippet;
}
async function assertDocumentedAnimation(page: Page): Promise<void> {
const css = documentedAnimationSnippet();
const fallbackCss = css.replace("@supports (width: calc-size(auto, size))", "@supports (width: e2e-unsupported)");
failUnless(fallbackCss !== css, "The animation example is missing its calc-size feature guard.");
const selector = ".livesync-status-statusline .livesync-stat-ff-push";
await injectReplicationStats(page, replicationStats(0, 0, 0, 200), 0, 0);
await waitForComparisonCounts(page, 200);
const fallback = await page.addStyleTag({ content: fallbackCss });
try {
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await waitForComparisonCounts(page);
failUnless(
(await page.locator(selector).evaluate((element) => getComputedStyle(element).display)) === "none",
"An unsupported animation guard overrode the default inactive-item hiding."
);
} finally {
await fallback.evaluate((element) => element.parentNode?.removeChild(element));
}
const supported = await page.evaluate(() => CSS.supports("width", "calc-size(auto, size)"));
if (!supported) {
console.log("Documented snippet: default hiding passed; this runtime does not support calc-size animations.");
return;
}
await page.emulateMedia({ reducedMotion: "no-preference" });
const style = await page.addStyleTag({ content: css });
try {
const item = page.locator(selector);
await injectReplicationStats(page, replicationStats(0, 0, 0, 201), 0, 0);
await waitForComparisonCounts(page, 201);
await page.waitForFunction(
(selector) => {
const item = document.querySelector<HTMLElement>(selector);
return item !== null && item.getBoundingClientRect().width > 1 && item.getAnimations().length === 0;
},
selector,
{ timeout: uiTimeoutMs }
);
const openWidth = await item.evaluate((element) => element.getBoundingClientRect().width);
const original = await item.elementHandle();
failUnless(original, "The animated comparison item is missing.");
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await page.waitForFunction(
({ selector, openWidth }) => {
const item = document.querySelector<HTMLElement>(selector);
const width = item?.getBoundingClientRect().width ?? 0;
return item?.classList.contains("livesync-stat-inactive") && width > 0.5 && width < openWidth - 0.5;
},
{ selector, openWidth },
{ timeout: uiTimeoutMs }
);
await page.waitForFunction(
(selector) => {
const item = document.querySelector<HTMLElement>(selector);
return item !== null && item.getBoundingClientRect().width < 0.5 && item.getAnimations().length === 0;
},
selector,
{ timeout: uiTimeoutMs }
);
const collapsed = await item.evaluate((element) => ({
display: getComputedStyle(element).display,
margin: getComputedStyle(element).marginInlineStart,
ariaHidden: element.getAttribute("aria-hidden"),
}));
failUnless(
collapsed.display === "inline-block" && collapsed.margin === "0px" && collapsed.ariaHidden === "true",
"The animated inactive item lost its collapsed layout or accessibility state."
);
await injectReplicationStats(page, replicationStats(0, 0, 0, 202), 0, 0);
await waitForComparisonCounts(page, 202);
await page.waitForFunction(
({ selector, openWidth }) => {
const item = document.querySelector<HTMLElement>(selector);
return item !== null && Math.abs(item.getBoundingClientRect().width - openWidth) < 0.5;
},
{ selector, openWidth },
{ timeout: uiTimeoutMs }
);
failUnless(
await original.evaluate((element, selector) => element === document.querySelector(selector), selector),
"Reopening an animated item replaced its retained DOM node."
);
await original.dispose();
await page.emulateMedia({ reducedMotion: "reduce" });
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await waitForComparisonCounts(page);
const reduced = await item.evaluate((element) => ({
width: element.getBoundingClientRect().width,
transition: getComputedStyle(element).transitionProperty,
animations: element.getAnimations().length,
}));
failUnless(
reduced.width < 0.5 && reduced.transition === "none" && reduced.animations === 0,
"The documented snippet did not honour reduced motion."
);
console.log(
"Documented snippet passed: interpolated collapse, retained-node reopening, reduced motion, and disabled-guard fallback."
);
} finally {
await style.evaluate((element) => element.parentNode?.removeChild(element));
await page.emulateMedia({ reducedMotion: null });
}
}
async function measureStatusBarNetworkWidth(
page: Page,
expectedValue: string
@@ -846,7 +1067,7 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
let popoutPage: Page | undefined;
let mainWindow: MainWindowControl | undefined;
try {
await installFixtureFile(page);
console.log(`Replication status runtime: ${await page.evaluate(() => navigator.userAgent)}`);
try {
await page.locator(".livesync-status").waitFor({ state: "attached", timeout: uiTimeoutMs });
} catch (error) {
@@ -882,11 +1103,11 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
const startedAt = Date.now();
await injectReplicationStats(page, initialStats, 0, 0);
await waitForPartText(page, "arrived", "0");
await waitForPartText(page, "ff", "1000");
await waitForPartText(page, "ff-pull", "1000");
await assertStatusBarItemSpacing(page);
for (const root of [".livesync-status-statusline", ".syncstatusbar"]) {
failUnless(
(await page.locator(`${root} .livesync-stat-ff + .livesync-stat-sent`).count()) === 1,
(await page.locator(`${root} .livesync-stat-ff-pull + .livesync-stat-sent`).count()) === 1,
`The comparison count did not appear immediately before uploads in ${root}.`
);
}
@@ -902,7 +1123,7 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
await page.waitForFunction(
() =>
document
.querySelector(".livesync-status-statusline .livesync-stat-ff")
.querySelector(".livesync-status-statusline .livesync-stat-ff-pull")
?.classList.contains("livesync-stat-inactive") === true,
undefined,
{ timeout: uiTimeoutMs }
@@ -914,11 +1135,11 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
"The fast-forward checking icon disappeared when its count expired."
);
await injectReplicationStats(page, replicationStats(1001, 1001, 1001), 1001, 0);
await injectReplicationStats(page, replicationStats(1001, 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);
await injectReplicationStats(page, replicationStats(7, 7, 7, 7), 7, 0);
const narrowWidths = await assertNumericWidths(page, "7");
const narrowStatusBarWidth = await measureStatusBarNetworkWidth(page, "7");
for (const key of numericKeys) {
@@ -942,6 +1163,8 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
`Identical status render: 1000 iterations, 0 DOM mutations, ${repeatedRender.elapsedMs.toFixed(2)} ms.`
);
await assertDirectionalComparison(page);
await createSplitLeaf(page);
await assertSingleOverlay(page.context().pages());
await waitForOverlayOnActiveLeaf(page);
@@ -1030,13 +1253,15 @@ async function main(): Promise<void> {
binary,
cliBinary: cli.binary,
vault,
lifecycle: mobile
? {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
},
}
: undefined,
lifecycle: {
beforePluginStart: async ({ remoteDebuggingPort }) => {
if (mobile) {
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
}
// Prepare the editor before asynchronous plug-in loading can place the overlay in it.
await withObsidianPage(remoteDebuggingPort, installFixtureFile);
},
},
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
liveSync: false,
@@ -1080,6 +1305,8 @@ async function main(): Promise<void> {
);
}
await assertEditorItemSpacing(page, mobile);
if (mobile) await assertDirectionalComparison(page);
await assertDocumentedAnimation(page);
});
} finally {
if (session) await session.app.stop();