mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-30 13:03:20 +00:00
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:
1 parent
b3d74aa06d
commit
2fb1c0352d
5 files changed
+309
-28
No files matched your search
@@ -33,23 +33,66 @@ To prevent file and database corruption, please avoid closing Obsidian until all
|
||||
|
||||
## CouchDB comparison count
|
||||
|
||||
When CouchDB change history is being compared, `(⏩ 1200) ↑ 0 ↓ 0` can appear before any new documents arrive. The count is the number of candidate changes-feed entries compared during the current replication attempt, including repeated entries on retries. It is separate from downloaded Chunks and Metadata, and is neither a Vault file count nor a count of entries already present locally. It appears before the upload count, disappears after three seconds without a change, and reappears when the count changes. The ⏩ status icon follows comparison activity independently of that timer.
|
||||
When CouchDB change history is being compared, directional counts can appear before any new documents arrive, for example `Sync: ⏩ (⏩↑ 1200) (⏩↓ 84) ↑ 0 ↓ 0`. The `⏩↑` count is for the push direction, and `⏩↓` is for the pull direction. Each counts candidate changes-feed entries compared during the current replication attempt, including entries which do not need to be transferred and repeated entries on retries. They are feed-entry counts, not counts of Vault files which need to be transferred. The final `↑` and `↓` counts continue to show uploaded and downloaded Chunks and Metadata.
|
||||
|
||||
Each directional count has its own display timer. A positive count remains visible for three seconds after its last change, then clears; a count which reaches zero clears immediately. A change in one direction does not extend the other direction's timer. The ⏩ status icon follows comparison activity independently of these timers.
|
||||
|
||||
## Customising status items with CSS snippets
|
||||
|
||||
The status bar and editor overlay use individual spans with the common `livesync-stat-` class prefix. CSS snippets can hide or style individual items. For example, this hides the comparison count and P2P summary:
|
||||
The status bar and editor overlay use individual spans with the common `livesync-stat-` class prefix. CSS snippets can hide or style individual items. For example, this hides both directional comparison counts and the P2P summary:
|
||||
|
||||
```css
|
||||
.livesync-stat-ff,
|
||||
.livesync-stat-ff-push,
|
||||
.livesync-stat-ff-pull,
|
||||
.livesync-stat-p2p {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
The item classes include `livesync-stat-network`, `livesync-stat-sync`, `livesync-stat-sent`, `livesync-stat-push-remaining`, `livesync-stat-arrived`, `livesync-stat-pull-remaining`, `livesync-stat-ff`, and `livesync-stat-p2p`. File-processing items use `livesync-stat-file-processing`, `livesync-stat-file-pending`, and `livesync-stat-file-batched`; queue items use `livesync-stat-queue-` followed by `replication`, `database`, `storage`, `chunk-initial`, `chunk-retrying`, `plugin-scan`, `hidden-files`, or `conflicts`. The skipped-file indicator uses `livesync-stat-file-skip`.
|
||||
The item classes include `livesync-stat-network`, `livesync-stat-sync`, `livesync-stat-sent`, `livesync-stat-push-remaining`, `livesync-stat-arrived`, `livesync-stat-pull-remaining`, `livesync-stat-ff-push`, `livesync-stat-ff-pull`, and `livesync-stat-p2p`. File-processing items use `livesync-stat-file-processing`, `livesync-stat-file-pending`, and `livesync-stat-file-batched`; queue items use `livesync-stat-queue-` followed by `replication`, `database`, `storage`, `chunk-initial`, `chunk-retrying`, `plugin-scan`, `hidden-files`, or `conflicts`. The skipped-file indicator uses `livesync-stat-file-skip`.
|
||||
|
||||
The former `livesync-stat-ff` class has been replaced by `livesync-stat-ff-push` and `livesync-stat-ff-pull`. Update existing CSS snippets to target one or both of the directional classes.
|
||||
|
||||
All items also have `livesync-stat-item`. Numeric children use `livesync-stat-value`, tabular digits, and right alignment. Each numeric item retains its widest displayed digit count during an active period, reducing movement as counts decrease. The editor overlay uses wider gaps between items, while remaining counts stay beside their transfer totals.
|
||||
|
||||
Inactive items receive `livesync-stat-inactive` instead of the HTML `hidden` attribute. The default CSS hides them immediately. They retain their last text, numeric width, and position amongst the other items so that CSS snippets can customise how they disappear. The numeric width starts afresh when an item becomes active again.
|
||||
|
||||
### Animate editor status items
|
||||
|
||||
This optional CSS snippet shrinks inactive items in the editor status line and expands them when they reappear. It applies to all status items in that line. Because inactive items remain in the DOM, their width can transition as their state changes; the initial creation of an item is not guaranteed to animate. Text changes, including on first appearance, do not necessarily animate an item's width. The `@supports` guard leaves the default, immediate hiding behaviour in place in CSS engines which do not support `calc-size()`.
|
||||
|
||||
```css
|
||||
@supports (width: calc-size(auto, size)) {
|
||||
.livesync-status-statusline > .livesync-stat-item {
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: width 0.125s;
|
||||
display: inline-block;
|
||||
width: calc-size(auto, size);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.livesync-status-statusline > .livesync-stat-item.livesync-stat-inactive {
|
||||
transition: width 0.46s;
|
||||
display: inline-block;
|
||||
width: calc-size(auto, size * 0);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.livesync-status-statusline > .livesync-stat-item {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.livesync-status-statusline > .livesync-stat-item.livesync-stat-inactive {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The snippet affects the editor overlay only; the status bar keeps its default appearance. See the [`calc-size()` reference](https://developer.chrome.com/docs/css-ui/animate-to-height-auto/) for the CSS feature used to calculate an item's intrinsic width.
|
||||
|
||||
See the [documentation index](../README.md) for setup and troubleshooting guides.
|
||||
+2
-1
@@ -857,7 +857,8 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
~ .livesync-stat-item:not(
|
||||
.livesync-stat-push-remaining,
|
||||
.livesync-stat-pull-remaining,
|
||||
.livesync-stat-ff,
|
||||
.livesync-stat-ff-push,
|
||||
.livesync-stat-ff-pull,
|
||||
.livesync-stat-p2p
|
||||
) {
|
||||
margin-inline-start: 1em;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -12,6 +12,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation
|
||||
|
||||
- We can see CouchDB change-feed comparison progress separately for push and pull.
|
||||
- The `⏩↑` and `⏩↓` counts show entries compared in each direction; the upload and download totals remain separate.
|
||||
- Existing CSS snippets which target `livesync-stat-ff` to hide comparison progress need to target both `livesync-stat-ff-push` and `livesync-stat-ff-pull`. See the [status display guide](docs/tips/status-display.md).
|
||||
|
||||
## 1.0.42
|
||||
|
||||
27th October, 2026
|
||||
|
||||
Reference in new issue
Block a user