diff --git a/README.md b/README.md index f84b0267..8bdbacef 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Synchronisation status is shown in the status bar with the following icons. - 🌐N Approximate remote requests currently in progress - Status - âšī¸ Stopped + - ⏊ Comparing CouchDB change history, including entries already held locally - 💤 LiveSync enabled. Waiting for changes - âšĄī¸ Synchronisation in progress - ⚠ An error occurred @@ -104,6 +105,25 @@ Synchronisation status is shown in the status bar with the following icons. To prevent file and database corruption, please avoid closing Obsidian until all progress indicators have disappeared as much as possible (although the plug-in will attempt to resume if interrupted). This is especially important if you have deleted or renamed files. +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. + +### 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 the items we need. For example, this hides the comparison count and P2P summary: + +```css +.livesync-stat-ff, +.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`. + +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. + ## Tips and Troubleshooting - If you want a faster and simpler initial replication when setting up subsequent devices, see the [Fast Setup Guide](docs/tips/fast-setup.md). - Configure [Hidden File Sync](docs/tips/hidden-file-sync.md) only after ordinary note synchronisation works. diff --git a/package.json b/package.json index f74c889a..1c1bea1a 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "test:e2e:obsidian:configuration-encryption-sync": "tsx test/e2e-obsidian/scripts/configuration-encryption-sync.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", + "test:e2e:obsidian:replication-status": "tsx test/e2e-obsidian/scripts/replication-status.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", "test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts", "test:e2e:obsidian:couchdb-http-cache": "tsx test/e2e-obsidian/scripts/couchdb-http-cache.ts", diff --git a/src/modules/features/ModuleLog.ts b/src/modules/features/ModuleLog.ts index 99170ec1..0755506b 100644 --- a/src/modules/features/ModuleLog.ts +++ b/src/modules/features/ModuleLog.ts @@ -4,7 +4,6 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE, PREFIXMD_LOGFILE, - type DatabaseConnectingStatus, type LOG_LEVEL, } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { scheduleTask } from "octagonal-wheels/concurrency/task"; @@ -31,16 +30,14 @@ import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts"; import { serialized } from "octagonal-wheels/concurrency/lock"; import { $msg } from "@/common/translation"; import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; -import { - REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, - formatRemoteActivityStatusLabel, - getTrackedRequestCount, -} from "./RemoteActivityStatus.ts"; +import { REMOTE_REQUEST_ACTIVITY_MINIMUM_VISIBLE_MS, getTrackedRequestCount } from "./RemoteActivityStatus.ts"; import { createChunkFetchCounterLabel, createMinimumVisibleActivityCount, createPaddedCounterLabel, } from "./StatusBarDisplay.ts"; +import { createFastForwardCheckedChangesDisplay, projectReplicationStatusLabel } from "./ReplicationStatusLabel.ts"; +import { createStatusBarRenderer, type StatusBarPart, type StatusBarRenderer } from "./StatusBarRenderer.ts"; import type { LiveSyncCore } from "@/main.ts"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; import { isValidPath } from "@/common/utils.ts"; @@ -124,7 +121,14 @@ export class ModuleLog extends AbstractObsidianModule { logHistory?: HTMLDivElement; messageArea?: HTMLDivElement; - statusBarLabels!: ReactiveValue<{ message: string; status: string }>; + statusBarRenderer?: StatusBarRenderer; + statusLineRenderer?: StatusBarRenderer; + statusBarLabels!: ReactiveValue<{ + message: string; + status: string; + parts: readonly StatusBarPart[]; + statusBarParts: readonly StatusBarPart[]; + }>; statusLog = reactiveSource(""); activeFileStatus = reactiveSource(""); notifies: { [key: string]: { count: number } } = {}; @@ -164,79 +168,54 @@ export class ModuleLog extends AbstractObsidianModule { ); const requestingStatLabel = computed(() => { - return formatRemoteActivityStatusLabel({ + return { remoteOperationCount: Math.max(0, this.services.replicator.boundedRemoteActivityCount.value), trackedRequestCount: displayedTrackedRequestCount.value, - }); + }; }); - const replicationStatLabel = computed(() => { - const e = this.services.replicator.replicationStatics.value; - const sent = e.sent; - const arrived = e.arrived; - const maxPullSeq = e.maxPullSeq; - const maxPushSeq = e.maxPushSeq; - const lastSyncPullSeq = e.lastSyncPullSeq; - const lastSyncPushSeq = e.lastSyncPushSeq; - let pushLast = ""; - let pullLast = ""; - let w = ""; - const labels: Partial> = { - CONNECTED: "⚡", - JOURNAL_SEND: "đŸ“Ļ↑", - JOURNAL_RECEIVE: "đŸ“Ļ↓", - }; - switch (e.syncStatus) { - case "CLOSED": - case "COMPLETED": - case "NOT_CONNECTED": - w = "⏚"; - break; - case "STARTED": - w = "🌀"; - break; - case "PAUSED": - w = "💤"; - break; - case "CONNECTED": - case "JOURNAL_SEND": - case "JOURNAL_RECEIVE": - w = labels[e.syncStatus] || "⚡"; - pushLast = - lastSyncPushSeq == 0 - ? "" - : lastSyncPushSeq >= maxPushSeq - ? " (LIVE)" - : ` (${maxPushSeq - lastSyncPushSeq})`; - pullLast = - lastSyncPullSeq == 0 - ? "" - : lastSyncPullSeq >= maxPullSeq - ? " (LIVE)" - : ` (${maxPullSeq - lastSyncPullSeq})`; - break; - case "ERRORED": - w = "⚠"; - break; - default: - w = "?"; - } - return { w, sent, pushLast, arrived, pullLast }; - }); + const fastForwardCount = registerDisplay( + createFastForwardCheckedChangesDisplay(this.services.replicator.replicationStatics) + ); + const replicationStatLabel = computed(() => + projectReplicationStatusLabel( + this.services.replicator.replicationStatics.value, + requestingStatLabel(), + fastForwardCount.value + ) + ); const labelProc = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.processing, `âŗ`)); const labelPend = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.totalQueued, `đŸ›Ģ`)); const labelInBatchDelay = registerDisplay(createPaddedCounterLabel(this.services.fileProcessing.batched, `đŸ“Ŧ`)); const waitingLabel = computed(() => { return `${labelProc.value}${labelPend.value}${labelInBatchDelay.value}`; }); + const counterPart = (key: string, mark: string, value: number | undefined): StatusBarPart[] => + value === undefined ? [] : [{ key, text: " " + mark, value }]; const statusLineLabel = computed(() => { - const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel(); + const replication = replicationStatLabel(); const queued = queueCountLabel(); const waiting = waitingLabel(); - const networkActivity = requestingStatLabel(); const p2p = this.p2pLogCollector.p2pReplicationLine.value; + const firstLineParts = [ + ...replication.parts, + ...counterPart("file-processing", "âŗ", labelProc.count.value), + ...counterPart("file-pending", "đŸ›Ģ", labelPend.count.value), + ...counterPart("file-batched", "đŸ“Ŧ", labelInBatchDelay.count.value), + ...counterPart("queue-replication", "đŸ“Ĩ", labelReplication.count.value), + ...counterPart("queue-database", "📄", labelDBCount.count.value), + ...counterPart("queue-storage", "💾", labelStorageCount.count.value), + ...counterPart("queue-chunk-initial", "🛄", labelChunkCount.initialCount.value), + ...counterPart("queue-chunk-retrying", "🔁", labelChunkCount.retryingCount.value), + ...counterPart("queue-plugin-scan", "🔌", labelPluginScanCount.count.value), + ...counterPart("queue-hidden-files", "âš™ī¸", labelHiddenFilesCount.count.value), + ...counterPart("queue-conflicts", "🔩", labelConflictProcessCount.count.value), + ]; return { - message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`, + message: replication.message + waiting + queued + (p2p === "" ? "" : "\n" + p2p), + parts: p2p === "" ? firstLineParts : [...firstLineParts, { key: "p2p", text: "\n" + p2p }], + statusBarParts: firstLineParts, + hasP2P: p2p !== "", }; }); @@ -244,13 +223,16 @@ export class ModuleLog extends AbstractObsidianModule { const scheduleMessage = this.services.appLifecycle.isReloadingScheduled() ? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n` : ""; - const { message } = statusLineLabel(); + const { message, parts, statusBarParts, hasP2P } = statusLineLabel(); const fileStatus = this.activeFileStatus.value; const status = scheduleMessage + this.statusLog.value; const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`; + const skipPart = fileStatusIcon === "" ? [] : [{ key: "file-skip", text: fileStatusIcon }]; return { message: `${message}${fileStatusIcon}`, status, + parts: [...parts, ...skipPart], + statusBarParts: hasP2P ? statusBarParts : [...statusBarParts, ...skipPart], }; }); this.statusBarLabels = statusBarLabels; @@ -391,7 +373,8 @@ export class ModuleLog extends AbstractObsidianModule { newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1); } - this.statusBar?.setText(newMsg.split("\n")[0]); + const { parts, statusBarParts } = this.statusBarLabels.value; + this.statusBarRenderer?.render(statusBarParts); if (this.statusDiv) { this.statusDiv.setCssStyles({ display: this.settings?.showStatusOnEditor ? "" : "none" }); } @@ -409,7 +392,7 @@ export class ModuleLog extends AbstractObsidianModule { const recentLogs = recent.reverse().join("\n"); if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs; } - if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg; + if (isDirty("newMsg", newMsg)) this.statusLineRenderer?.render(parts); if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog; } else { // const root = activeDocument.documentElement; @@ -505,6 +488,16 @@ ${stringifyYaml(info)} this.statusBar = this.services.API.addStatusBarItem(); this.statusBar?.addClass("syncstatusbar"); } + if (this.statusLine) { + const renderer = createStatusBarRenderer(this.statusLine); + this.statusLineRenderer = renderer; + this.plugin.register(() => renderer.dispose()); + } + if (this.statusBar) { + const renderer = createStatusBarRenderer(this.statusBar); + this.statusBarRenderer = renderer; + this.plugin.register(() => renderer.dispose()); + } this.adjustStatusDivPosition(); this._log("Log module loaded", LOG_LEVEL_INFO); this._log("Verbose log", LOG_LEVEL_VERBOSE); diff --git a/src/modules/features/ReplicationStatusLabel.ts b/src/modules/features/ReplicationStatusLabel.ts new file mode 100644 index 00000000..8358127c --- /dev/null +++ b/src/modules/features/ReplicationStatusLabel.ts @@ -0,0 +1,209 @@ +import { reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive"; +import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; + +import type { DisposableReactiveValue } from "./StatusBarDisplay.ts"; +import type { StatusBarPart } from "./StatusBarRenderer.ts"; +import { + formatRemoteActivityStatusLabel, + REMOTE_OPERATION_ACTIVITY_ICON, + REMOTE_REQUEST_ACTIVITY_ICON, + type RemoteActivityStatus, +} from "./RemoteActivityStatus.ts"; + +export const FAST_FORWARD_COUNT_LINGER_MS = 3_000; + +function readPositiveCheckedChanges(source: ReactiveValue): number | undefined { + const count = source.value.couchDB?.pull?.checkedChanges; + return typeof count === "number" && Number.isFinite(count) && count > 0 ? count : undefined; +} + +/** + * Briefly displays changing CouchDB pull-feed comparison counts. + * + * Unrelated replication-stat changes do not extend the linger or redisplay an + * expired count. A positive initial snapshot is displayed to late subscribers. + */ +export function createFastForwardCheckedChangesDisplay( + source: ReactiveValue, + lingerMs = FAST_FORWARD_COUNT_LINGER_MS +): DisposableReactiveValue { + const displayed = reactiveSource(undefined); + const linger = Math.max(0, lingerMs); + let lastObserved = readPositiveCheckedChanges(source); + let clearTimer: number | undefined; + let disposed = false; + + const cancelClear = () => { + if (clearTimer === undefined) return; + compatGlobal.clearTimeout(clearTimer); + clearTimer = undefined; + }; + const scheduleClear = () => { + clearTimer = compatGlobal.setTimeout(() => { + clearTimer = undefined; + if (disposed) return; + if (readPositiveCheckedChanges(source) !== lastObserved) { + update(); + return; + } + displayed.value = undefined; + }, linger); + }; + const update = () => { + if (disposed) return; + const nextCount = readPositiveCheckedChanges(source); + if (nextCount === lastObserved) return; + lastObserved = nextCount; + cancelClear(); + if (nextCount === undefined) { + displayed.value = undefined; + return; + } + displayed.value = nextCount; + scheduleClear(); + }; + + if (lastObserved !== undefined) { + displayed.value = lastObserved; + scheduleClear(); + } + source.onChanged(update); + + return { + get value() { + return displayed.value; + }, + onChanged(handler) { + displayed.onChanged(handler); + }, + offChanged(handler) { + displayed.offChanged(handler); + }, + dispose() { + if (disposed) return; + disposed = true; + cancelClear(); + source.offChanged(update); + }, + }; +} + +export type ReplicationStatusProjection = { + icon: string; + message: string; + parts: StatusBarPart[]; +}; + +function remainingSequenceLabel( + key: string, + lastSyncSeq: number, + maxSeq: number +): { text: string; part?: StatusBarPart } { + if (lastSyncSeq === 0) return { text: "" }; + if (lastSyncSeq >= maxSeq) return { text: " (LIVE)", part: { key, text: " (LIVE)" } }; + const remaining = maxSeq - lastSyncSeq; + return { + text: " (" + remaining + ")", + part: { key, text: " (", value: remaining, suffix: ")" }, + }; +} + +/** Projects the existing replication labels into reusable numeric status items. */ +export function projectReplicationStatusLabel( + stats: ReplicationStatics, + networkStatus: RemoteActivityStatus, + fastForwardCount?: number +): ReplicationStatusProjection { + const { sent, arrived, maxPullSeq, maxPushSeq, lastSyncPullSeq, lastSyncPushSeq } = stats; + const networkActivity = formatRemoteActivityStatusLabel(networkStatus); + let pushRemaining: { text: string; part?: StatusBarPart } = { text: "" }; + let pullRemaining: { text: string; part?: StatusBarPart } = { text: "" }; + let icon: string; + switch (stats.syncStatus) { + case "CLOSED": + case "COMPLETED": + case "NOT_CONNECTED": + icon = "⏚"; + break; + case "STARTED": + icon = "🌀"; + break; + case "PAUSED": + icon = "💤"; + break; + case "CONNECTED": + case "JOURNAL_SEND": + case "JOURNAL_RECEIVE": + icon = stats.syncStatus === "JOURNAL_SEND" ? "đŸ“Ļ↑" : stats.syncStatus === "JOURNAL_RECEIVE" ? "đŸ“Ļ↓" : "⚡"; + pushRemaining = remainingSequenceLabel("push-remaining", lastSyncPushSeq, maxPushSeq); + pullRemaining = remainingSequenceLabel("pull-remaining", lastSyncPullSeq, maxPullSeq); + break; + case "ERRORED": + icon = "⚠"; + break; + default: + icon = "?"; + } + + const couchDB = stats.couchDB; + const isFastForwardChecking = + stats.syncStatus !== "ERRORED" && + couchDB !== undefined && + couchDB.phase !== "idle" && + (couchDB.pull?.checking === true || couchDB.push?.checking === true); + if (isFastForwardChecking) icon = "⏊"; + + const visibleFastForwardCount = + couchDB !== undefined && + typeof fastForwardCount === "number" && + Number.isFinite(fastForwardCount) && + fastForwardCount > 0 + ? fastForwardCount + : undefined; + const fastForwardLabel = visibleFastForwardCount === undefined ? "" : " (⏊ " + visibleFastForwardCount + ")"; + + const parts: StatusBarPart[] = []; + if (networkStatus.trackedRequestCount > 0) { + parts.push({ + key: "network", + text: + (networkStatus.remoteOperationCount > 0 ? `${REMOTE_OPERATION_ACTIVITY_ICON} ` : "") + + REMOTE_REQUEST_ACTIVITY_ICON, + value: networkStatus.trackedRequestCount, + suffix: " ", + }); + } else if (networkActivity !== "") { + parts.push({ key: "network", text: networkActivity }); + } + parts.push({ key: "sync", text: "Sync: " + icon }); + if (visibleFastForwardCount !== undefined) { + parts.push({ + key: "ff", + text: " (⏊ ", + value: visibleFastForwardCount, + suffix: ")", + title: "Compared CouchDB feed entries", + }); + } + parts.push({ key: "sent", text: " ↑ ", value: sent }); + if (pushRemaining.part !== undefined) parts.push(pushRemaining.part); + parts.push({ key: "arrived", text: " ↓ ", value: arrived }); + if (pullRemaining.part !== undefined) parts.push(pullRemaining.part); + + return { + icon, + message: + networkActivity + + "Sync: " + + icon + + fastForwardLabel + + " ↑ " + + sent + + pushRemaining.text + + " ↓ " + + arrived + + pullRemaining.text, + parts, + }; +} diff --git a/src/modules/features/ReplicationStatusLabel.unit.spec.ts b/src/modules/features/ReplicationStatusLabel.unit.spec.ts new file mode 100644 index 00000000..054d597e --- /dev/null +++ b/src/modules/features/ReplicationStatusLabel.unit.spec.ts @@ -0,0 +1,242 @@ +import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReplicationStatics } from "@vrtmrz/livesync-commonlib/replication"; + +import { + FAST_FORWARD_COUNT_LINGER_MS, + createFastForwardCheckedChangesDisplay, + projectReplicationStatusLabel, +} from "./ReplicationStatusLabel.ts"; + +function couchDBProgress( + phase: "idle" | "one-shot" | "catch-up" | "continuous", + checkedChanges = 0, + checking = false +): NonNullable { + return { + phase, + pull: { checkedChanges, completedBatches: 0, checking }, + push: { checkedChanges: 0, completedBatches: 0, checking: false }, + }; +} + +const noNetworkActivity = { remoteOperationCount: 0, trackedRequestCount: 0 }; + +function stats(overrides: Partial = {}): ReplicationStatics { + return { + sent: 0, + arrived: 0, + maxPullSeq: 0, + maxPushSeq: 0, + lastSyncPullSeq: 0, + lastSyncPushSeq: 0, + syncStatus: "CONNECTED", + ...overrides, + }; +} + +describe("projectReplicationStatusLabel", () => { + it("exposes the remote-request count as a numeric status item", () => { + const projection = projectReplicationStatusLabel(stats(), { remoteOperationCount: 1, trackedRequestCount: 12 }); + expect(projection.parts.find((part) => part.key === "network")).toMatchObject({ + text: "📲 🌐", + value: 12, + suffix: " ", + }); + }); + + it("preserves the connected label, directions, and remaining-sequence text", () => { + const projection = projectReplicationStatusLabel( + stats({ + sent: 4, + arrived: 2, + maxPullSeq: 20, + maxPushSeq: 30, + lastSyncPullSeq: 17, + lastSyncPushSeq: 22, + }), + { remoteOperationCount: 0, trackedRequestCount: 1 } + ); + + expect(projection.message).toBe("🌐1 Sync: ⚡ ↑ 4 (8) ↓ 2 (3)"); + expect(projection.parts.map((part) => part.key)).toEqual([ + "network", + "sync", + "sent", + "push-remaining", + "arrived", + "pull-remaining", + ]); + expect(projection.parts[2]).toMatchObject({ key: "sent", text: " ↑ ", value: 4 }); + expect(projection.parts[3]).toMatchObject({ key: "push-remaining", text: " (", value: 8, suffix: ")" }); + expect(projection.parts[4]).toMatchObject({ key: "arrived", text: " ↓ ", value: 2 }); + expect(projection.parts[5]).toMatchObject({ key: "pull-remaining", text: " (", value: 3, suffix: ")" }); + }); + + it("keeps the existing status icons when CouchDB is idle or not present", () => { + const statuses = [ + ["CLOSED", "⏚"], + ["COMPLETED", "⏚"], + ["NOT_CONNECTED", "⏚"], + ["STARTED", "🌀"], + ["PAUSED", "💤"], + ["CONNECTED", "⚡"], + ["JOURNAL_SEND", "đŸ“Ļ↑"], + ["JOURNAL_RECEIVE", "đŸ“Ļ↓"], + ["ERRORED", "⚠"], + ] as const; + + for (const [syncStatus, icon] of statuses) { + const idleWithCount = projectReplicationStatusLabel( + stats({ syncStatus, couchDB: couchDBProgress("idle", 19, true) }), + noNetworkActivity, + 19 + ); + expect(idleWithCount.icon).toBe(icon); + expect(idleWithCount.parts.some((part) => part.key === "ff")).toBe(true); + + const withoutCouchDB = projectReplicationStatusLabel(stats({ syncStatus }), noNetworkActivity, 19); + expect(withoutCouchDB.icon).toBe(icon); + expect(withoutCouchDB.parts.some((part) => part.key === "ff")).toBe(false); + } + + const noFastForward = projectReplicationStatusLabel(stats(), noNetworkActivity); + expect(noFastForward.parts.some((part) => part.key === "ff")).toBe(false); + expect(noFastForward.message).toBe("Sync: ⚡ ↑ 0 ↓ 0"); + }); + + it("shows fast-forward checking with zero arrivals, transferred data, and the final idle count", () => { + const checking = projectReplicationStatusLabel( + stats({ + syncStatus: "PAUSED", + arrived: 0, + couchDB: couchDBProgress("catch-up", 0, true), + }), + noNetworkActivity + ); + expect(checking.icon).toBe("⏊"); + expect(checking.parts[2]).toMatchObject({ key: "arrived", value: 0 }); + + const transferred = projectReplicationStatusLabel( + stats({ + arrived: 7, + couchDB: couchDBProgress("continuous", 1200, false), + }), + noNetworkActivity, + 1200 + ); + expect(transferred.icon).toBe("⚡"); + expect(transferred.message).toBe("Sync: ⚡ (⏊ 1200) ↑ 0 ↓ 7"); + expect(transferred.parts.map((part) => part.key)).toEqual(["sync", "ff", "sent", "arrived"]); + expect(transferred.parts[1]).toMatchObject({ + key: "ff", + text: " (⏊ ", + value: 1200, + suffix: ")", + }); + + const idle = projectReplicationStatusLabel( + stats({ + syncStatus: "CONNECTED", + arrived: 7, + couchDB: couchDBProgress("idle", 1200, false), + }), + noNetworkActivity, + 1200 + ); + expect(idle.icon).toBe("⚡"); + expect(idle.message).toBe("Sync: ⚡ (⏊ 1200) ↑ 0 ↓ 7"); + }); + + it("keeps the error icon ahead of an active CouchDB checking indicator", () => { + const projection = projectReplicationStatusLabel( + stats({ + syncStatus: "ERRORED", + couchDB: { + ...couchDBProgress("one-shot", 2, true), + push: { checkedChanges: 0, completedBatches: 0, checking: true }, + }, + }), + noNetworkActivity + ); + + expect(projection.icon).toBe("⚠"); + }); +}); + +describe("createFastForwardCheckedChangesDisplay", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("uses the initial snapshot, expires unchanged counts, and only reappears for new progress", () => { + const source = reactiveSource( + stats({ + couchDB: couchDBProgress("catch-up", 1200, true), + }) + ); + const display = createFastForwardCheckedChangesDisplay(source); + + expect(display.value).toBe(1200); + vi.advanceTimersByTime(1000); + source.value = stats({ + sent: 3, + couchDB: couchDBProgress("idle", 1200, false), + }); + expect(display.value).toBe(1200); + vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS - 1001); + expect(display.value).toBe(1200); + + source.value = stats({ + sent: 4, + couchDB: couchDBProgress("idle", 1200, false), + }); + vi.advanceTimersByTime(1); + expect(display.value).toBeUndefined(); + + source.value = stats({ + syncStatus: "PAUSED", + couchDB: couchDBProgress("continuous", 1200, true), + }); + expect(display.value).toBeUndefined(); + + source.value = stats({ + couchDB: couchDBProgress("continuous", 1201, true), + }); + expect(display.value).toBe(1201); + + source.value = stats({ + couchDB: couchDBProgress("one-shot", 0, true), + }); + expect(display.value).toBeUndefined(); + + source.value = stats(); + expect(display.value).toBeUndefined(); + display.dispose(); + }); + + it("clears its timer and subscription when disposed", () => { + const source = reactiveSource(stats({ couchDB: couchDBProgress("catch-up", 5, true) })); + const display = createFastForwardCheckedChangesDisplay(source); + + display.dispose(); + vi.advanceTimersByTime(FAST_FORWARD_COUNT_LINGER_MS); + source.value = stats({ couchDB: couchDBProgress("continuous", 6, true) }); + + expect(display.value).toBe(5); + }); + + it("does not display progress for providers without CouchDB replication state", () => { + const source = reactiveSource(stats()); + const display = createFastForwardCheckedChangesDisplay(source); + + expect(display.value).toBeUndefined(); + source.value = stats({ couchDB: { phase: "continuous" } }); + expect(display.value).toBeUndefined(); + display.dispose(); + }); +}); diff --git a/src/modules/features/StatusBarDisplay.ts b/src/modules/features/StatusBarDisplay.ts index 66fd10ed..9c24a972 100644 --- a/src/modules/features/StatusBarDisplay.ts +++ b/src/modules/features/StatusBarDisplay.ts @@ -10,6 +10,15 @@ export type DisposableReactiveValue = ReactiveValue & { dispose(): void; }; +export type PaddedCounterLabel = DisposableReactiveValue & { + readonly count: ReactiveValue; +}; + +export type ChunkFetchCounterLabel = DisposableReactiveValue & { + readonly initialCount: ReactiveValue; + readonly retryingCount: ReactiveValue; +}; + function asDisposableReactiveValue(value: ReactiveSource, dispose: () => void): DisposableReactiveValue { return { get value() { @@ -92,9 +101,10 @@ export function createPaddedCounterLabel( source: ReactiveValue, mark: string, inactiveLingerMs = STATUS_COUNTER_INACTIVE_LINGER_MS -): DisposableReactiveValue { +): PaddedCounterLabel { const linger = Math.max(0, inactiveLingerMs); const formatted = reactiveSource(""); + const displayedCount = reactiveSource(undefined); let maximumLength = 1; let clearTimer: number | undefined; let disposed = false; @@ -114,22 +124,39 @@ export function createPaddedCounterLabel( cancelClear(); const count = source.value; formatted.value = format(count); + displayedCount.value = count; if (count !== 0) return; clearTimer = compatGlobal.setTimeout(() => { clearTimer = undefined; if (disposed) return; formatted.value = ""; + displayedCount.value = undefined; maximumLength = 1; }, linger); }; source.onChanged(update); - return asDisposableReactiveValue(formatted, () => { + const display = asDisposableReactiveValue(formatted, () => { if (disposed) return; disposed = true; cancelClear(); source.offChanged(update); }); + return { + get value() { + return display.value; + }, + onChanged(handler) { + display.onChanged(handler); + }, + offChanged(handler) { + display.offChanged(handler); + }, + count: displayedCount, + dispose() { + display.dispose(); + }, + }; } /** @@ -138,7 +165,7 @@ export function createPaddedCounterLabel( */ export function createChunkFetchCounterLabel( source: ReactiveValue<{ initial: number; retrying: number }> -): DisposableReactiveValue { +): ChunkFetchCounterLabel { const initialCount = reactiveSource(0); const retryingCount = reactiveSource(0); const initialLabel = createPaddedCounterLabel(initialCount, "🛄"); @@ -168,7 +195,7 @@ export function createChunkFetchCounterLabel( source.onChanged(updateCounts); updateCounts(); - return asDisposableReactiveValue(formatted, () => { + const display = asDisposableReactiveValue(formatted, () => { if (disposed) return; disposed = true; source.offChanged(updateCounts); @@ -177,4 +204,20 @@ export function createChunkFetchCounterLabel( initialLabel.dispose(); retryingLabel.dispose(); }); + return { + get value() { + return display.value; + }, + onChanged(handler) { + display.onChanged(handler); + }, + offChanged(handler) { + display.offChanged(handler); + }, + initialCount: initialLabel.count, + retryingCount: retryingLabel.count, + dispose() { + display.dispose(); + }, + }; } diff --git a/src/modules/features/StatusBarDisplay.unit.spec.ts b/src/modules/features/StatusBarDisplay.unit.spec.ts index 777bf9cd..5ce8f69e 100644 --- a/src/modules/features/StatusBarDisplay.unit.spec.ts +++ b/src/modules/features/StatusBarDisplay.unit.spec.ts @@ -111,6 +111,24 @@ describe("createPaddedCounterLabel", () => { display.dispose(); }); + it("exposes the numeric value while retaining zero through the existing linger", () => { + const source = reactiveSource(0); + const display = createPaddedCounterLabel(source, "đŸ“Ĩ"); + + expect(display.count.value).toBeUndefined(); + source.value = 9; + expect(display.count.value).toBe(9); + source.value = 0; + expect(display.count.value).toBe(0); + + vi.advanceTimersByTime(STATUS_COUNTER_INACTIVE_LINGER_MS - 1); + expect(display.count.value).toBe(0); + vi.advanceTimersByTime(1); + expect(display.count.value).toBeUndefined(); + + display.dispose(); + }); + it("cancels the pending clear when counter activity resumes", () => { const source = reactiveSource(0); const display = createPaddedCounterLabel(source, "📄"); diff --git a/src/modules/features/StatusBarRenderer.ts b/src/modules/features/StatusBarRenderer.ts new file mode 100644 index 00000000..ceeceafd --- /dev/null +++ b/src/modules/features/StatusBarRenderer.ts @@ -0,0 +1,152 @@ +const INACTIVE_CLASS = "livesync-stat-inactive"; + +export type StatusBarPart = { + key: string; + text: string; + value?: number; + suffix?: string; + title?: string; +}; + +export type StatusBarRenderer = { + render(parts: readonly StatusBarPart[]): void; + dispose(): void; +}; + +type RenderedItem = { + element: HTMLSpanElement; + prefix: Text; + value: HTMLSpanElement; + valueText: Text; + suffix: Text; + widestValueLength: number; +}; + +export function createStatusBarRenderer(parent: HTMLElement): StatusBarRenderer { + const items = new Map(); + let disposed = false; + + const setText = (node: Text, text: string) => { + if (node.data !== text) node.data = text; + }; + + const setHidden = (element: HTMLElement, hidden: boolean) => { + if (element.hidden !== hidden) element.hidden = hidden; + }; + + const setInactive = (element: HTMLElement, inactive: boolean) => { + if (element.classList.contains(INACTIVE_CLASS) === inactive) return; + element.classList.toggle(INACTIVE_CLASS, inactive); + if (inactive) element.setAttribute("aria-hidden", "true"); + else element.removeAttribute("aria-hidden"); + }; + + const setWidth = (element: HTMLElement, width: string) => { + if (element.style.getPropertyValue("--livesync-stat-width") !== width) { + element.setCssProps({ "--livesync-stat-width": width }); + } + }; + + const createItem = (key: string): RenderedItem => { + const document = parent.ownerDocument; + const element = parent.createSpan(); + element.className = "livesync-stat-item livesync-stat-" + key; + + const prefix = document.createTextNode(""); + const value = element.createSpan(); + value.className = "livesync-stat-value"; + const valueText = document.createTextNode(""); + value.appendChild(valueText); + value.hidden = true; + const suffix = document.createTextNode(""); + + element.appendChild(prefix); + element.appendChild(value); + element.appendChild(suffix); + + const item = { element, prefix, value, valueText, suffix, widestValueLength: 0 }; + items.set(key, item); + return item; + }; + + const updateItem = (item: RenderedItem, part: StatusBarPart) => { + setText(item.prefix, part.text); + const title = part.title ?? ""; + if (item.element.title !== title) item.element.title = title; + + if (part.value === undefined) { + setText(item.valueText, ""); + setText(item.suffix, ""); + setHidden(item.value, true); + setWidth(item.value, ""); + item.widestValueLength = 0; + return; + } + + const formattedValue = String(part.value); + setText(item.valueText, formattedValue); + setText(item.suffix, part.suffix ?? ""); + setHidden(item.value, false); + + if (formattedValue.length > item.widestValueLength) { + item.widestValueLength = formattedValue.length; + const width = item.widestValueLength + "ch"; + setWidth(item.value, width); + } + }; + + const reorderIfNeeded = (visibleItems: readonly RenderedItem[]) => { + const desiredOrder = visibleItems.map((item) => item.element); + const visibleElements = new Set(desiredOrder); + const currentOrder = Array.from(parent.childNodes).filter((node): node is HTMLSpanElement => + visibleElements.has(node as HTMLSpanElement) + ); + if ( + currentOrder.length === desiredOrder.length && + currentOrder.every((node, index) => node === desiredOrder[index]) + ) + return; + + let reference: ChildNode | null = currentOrder[0] ?? null; + for (const element of desiredOrder) { + if (element !== reference) parent.insertBefore(element, reference); + const childNodes = Array.from(parent.childNodes); + const elementIndex = childNodes.indexOf(element); + reference = + childNodes.slice(elementIndex + 1).find((node) => visibleElements.has(node as HTMLSpanElement)) ?? null; + } + }; + + return { + render(parts) { + if (disposed) return; + + const visibleItems: RenderedItem[] = []; + const visibleKeys = new Set(); + for (const part of parts) { + let item = items.get(part.key); + if (!item) item = createItem(part.key); + if (item.element.classList.contains(INACTIVE_CLASS)) item.widestValueLength = 0; + updateItem(item, part); + setInactive(item.element, false); + visibleItems.push(item); + visibleKeys.add(part.key); + } + + for (const [key, item] of items) { + if (visibleKeys.has(key)) continue; + setInactive(item.element, true); + } + + reorderIfNeeded(visibleItems); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const item of items.values()) { + if (item.element.parentNode === parent) parent.removeChild(item.element); + } + items.clear(); + }, + }; +} diff --git a/src/modules/features/StatusBarRenderer.unit.spec.ts b/src/modules/features/StatusBarRenderer.unit.spec.ts new file mode 100644 index 00000000..c3232f99 --- /dev/null +++ b/src/modules/features/StatusBarRenderer.unit.spec.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; + +import { createStatusBarRenderer, type StatusBarPart } from "./StatusBarRenderer.ts"; + +type TestNode = TestElement | TestTextNode; + +class TestDocument { + mutations = 0; + + createElement(tagName: string): TestElement { + return new TestElement(this, tagName); + } + + createTextNode(data: string): TestTextNode { + return new TestTextNode(this, data); + } +} + +class TestTextNode { + readonly nodeType = 3; + parentNode: TestElement | undefined; + private currentData: string; + + constructor( + private readonly document: TestDocument, + data: string + ) { + this.currentData = data; + } + + get data(): string { + return this.currentData; + } + + set data(value: string) { + this.document.mutations++; + this.currentData = value; + } + + get textContent(): string { + return this.currentData; + } +} + +class TestStyle { + private properties = new Map(); + + constructor(private readonly document: TestDocument) {} + + getPropertyValue(name: string): string { + return this.properties.get(name) ?? ""; + } + + setProperty(name: string, value: string) { + this.document.mutations++; + this.properties.set(name, value); + } +} + +class TestElement { + readonly nodeType = 1; + readonly ownerDocument: Document; + readonly childNodes: TestNode[] = []; + parentNode: TestElement | undefined; + readonly style: TestStyle; + private currentClassName = ""; + private currentHidden = false; + private currentTitle = ""; + private attributes = new Map(); + readonly classList = { + contains: (token: string): boolean => this.currentClassName.split(" ").includes(token), + toggle: (token: string, force: boolean): boolean => { + const tokens = this.currentClassName.split(" ").filter(Boolean); + const present = tokens.includes(token); + if (present !== force) { + this.className = force + ? [...tokens, token].join(" ") + : tokens.filter((item) => item !== token).join(" "); + } + return force; + }, + }; + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + setAttribute(name: string, value: string) { + this.document.mutations++; + this.attributes.set(name, value); + } + + removeAttribute(name: string) { + if (this.attributes.delete(name)) this.document.mutations++; + } + + constructor( + private readonly document: TestDocument, + readonly tagName: string + ) { + this.ownerDocument = document as unknown as Document; + this.style = new TestStyle(document); + } + + get className(): string { + return this.currentClassName; + } + + set className(value: string) { + this.document.mutations++; + this.currentClassName = value; + } + + get hidden(): boolean { + return this.currentHidden; + } + + set hidden(value: boolean) { + this.document.mutations++; + this.currentHidden = value; + } + + get title(): string { + return this.currentTitle; + } + + set title(value: string) { + this.document.mutations++; + this.currentTitle = value; + } + + get children(): TestElement[] { + return this.childNodes.filter((node): node is TestElement => node instanceof TestElement); + } + + get textContent(): string { + return this.childNodes.map((node) => node.textContent ?? "").join(""); + } + + createSpan(): TestElement { + return this.appendChild(this.document.createElement("span")); + } + + setCssProps(properties: Record) { + for (const [name, value] of Object.entries(properties)) this.style.setProperty(name, value); + } + + appendChild(node: T): T { + if (node.parentNode) { + node.parentNode.removeChild(node); + } + this.childNodes.push(node); + node.parentNode = this; + this.document.mutations++; + return node; + } + + insertBefore(node: T, reference: TestNode | null): T { + if (node === reference) return node; + if (node.parentNode) { + node.parentNode.removeChild(node); + } + const index = reference ? this.childNodes.indexOf(reference) : -1; + if (index < 0) { + this.childNodes.push(node); + } else { + this.childNodes.splice(index, 0, node); + } + node.parentNode = this; + this.document.mutations++; + return node; + } + + removeChild(node: T): T { + const index = this.childNodes.indexOf(node); + if (index < 0) throw new Error("Node is not a child"); + this.childNodes.splice(index, 1); + node.parentNode = undefined; + this.document.mutations++; + return node; + } +} + +function createFixture() { + const document = new TestDocument(); + const root = new TestElement(document, "div"); + return { document, root, parent: root as unknown as HTMLElement }; +} + +function findItem(root: TestElement, key: string): TestElement { + const item = root.children.find((child) => child.className.split(" ").includes("livesync-stat-" + key)); + if (!item) throw new Error("Missing item: " + key); + return item; +} + +function findValue(item: TestElement): TestElement { + const value = item.children.find((child) => child.className.split(" ").includes("livesync-stat-value")); + if (!value) throw new Error("Missing numeric value"); + return value; +} + +function visibleItems(root: TestElement): TestElement[] { + return root.children.filter( + (child) => !child.classList.contains("livesync-stat-inactive") && child.classList.contains("livesync-stat-item") + ); +} + +describe("createStatusBarRenderer", () => { + it("renders complete text safely and gives each item an independent class hook", () => { + const { root, parent } = createFixture(); + const renderer = createStatusBarRenderer(parent); + + renderer.render([ + { key: "sync", text: "" }, + { key: "queue", text: " (queued: ", value: 2, suffix: ")" }, + ]); + + const sync = findItem(root, "sync"); + const queue = findItem(root, "queue"); + expect(sync.className.split(" ")).toEqual(["livesync-stat-item", "livesync-stat-sync"]); + expect(sync.textContent).toBe(""); + expect(queue.className.split(" ")).toContain("livesync-stat-queue"); + expect(queue.textContent).toBe(" (queued: 2)"); + expect(findValue(queue).className).toBe("livesync-stat-value"); + + renderer.render([{ key: "queue", text: " (queued: ", value: 3, suffix: ")" }]); + expect(sync.classList.contains("livesync-stat-inactive")).toBe(true); + expect(sync.hidden).toBe(false); + expect(sync.getAttribute("aria-hidden")).toBe("true"); + expect(queue.classList.contains("livesync-stat-inactive")).toBe(false); + expect(queue.textContent).toBe(" (queued: 3)"); + renderer.render([{ key: "queue", text: "Queue idle" }]); + expect(queue.textContent).toBe("Queue idle"); + expect(findValue(queue).hidden).toBe(true); + renderer.dispose(); + }); + + it("does not mutate the DOM when the same parts are rendered again", () => { + const { document, parent } = createFixture(); + const renderer = createStatusBarRenderer(parent); + const parts: StatusBarPart[] = [ + { key: "pending", text: " pending ", value: 7, suffix: " files", title: "Pending" }, + ]; + + renderer.render(parts); + const mutations = document.mutations; + renderer.render(parts); + + expect(document.mutations).toBe(mutations); + renderer.dispose(); + }); + + it("updates values while retaining the widest visible numeric width", () => { + const { root, parent } = createFixture(); + const renderer = createStatusBarRenderer(parent); + + renderer.render([{ key: "count", text: "(", value: 9, suffix: ")" }]); + const item = findItem(root, "count"); + const value = findValue(item); + expect(item.textContent).toBe("(9)"); + expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch"); + + renderer.render([{ key: "count", text: "(", value: 123, suffix: ")" }]); + expect(value.textContent).toBe("123"); + expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch"); + + renderer.render([{ key: "count", text: "(", value: 4, suffix: ")" }]); + expect(value.textContent).toBe("4"); + expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("3ch"); + renderer.dispose(); + }); + + it("retains omitted content and width, then resets width when the same item returns", () => { + const { root, parent } = createFixture(); + const renderer = createStatusBarRenderer(parent); + + renderer.render([{ key: "count", text: " ", value: 1234 }]); + const item = findItem(root, "count"); + const value = findValue(item); + expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch"); + + renderer.render([]); + expect(item.classList.contains("livesync-stat-inactive")).toBe(true); + expect(item.hidden).toBe(false); + expect(item.getAttribute("aria-hidden")).toBe("true"); + expect(item.textContent).toBe(" 1234"); + expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("4ch"); + + renderer.render([{ key: "count", text: " ", value: 5 }]); + expect(findItem(root, "count")).toBe(item); + expect(item.classList.contains("livesync-stat-inactive")).toBe(false); + expect(item.getAttribute("aria-hidden")).toBeNull(); + expect(value.style.getPropertyValue("--livesync-stat-width")).toBe("1ch"); + renderer.dispose(); + }); + + it("keeps inactive items in place when other items appear and does not repeat inactive mutations", () => { + const { document, root, parent } = createFixture(); + const renderer = createStatusBarRenderer(parent); + const first = { key: "first", text: "first " }; + const count = { key: "count", text: "count ", value: 1234 }; + const last = { key: "last", text: "last" }; + renderer.render([first, count, last]); + const initialOrder = [...root.children]; + const omitted = findItem(root, "count"); + + renderer.render([first, last]); + expect(root.children.map((item) => initialOrder.indexOf(item))).toEqual([0, 1, 2]); + expect(visibleItems(root)).toEqual([initialOrder[0], initialOrder[2]]); + const mutations = document.mutations; + renderer.render([first, last]); + expect(document.mutations).toBe(mutations); + + const added = { key: "added", text: "added " }; + renderer.render([first, added, last]); + expect(root.children.map((item) => item.textContent)).toEqual(["first ", "count 1234", "added ", "last"]); + expect(omitted.textContent).toBe("count 1234"); + expect(findValue(omitted).style.getPropertyValue("--livesync-stat-width")).toBe("4ch"); + + renderer.render([first, count, added, last]); + expect(findItem(root, "count")).toBe(omitted); + expect(visibleItems(root)).toEqual(root.children); + renderer.dispose(); + }); + + it("reorders existing nodes without replacing them", () => { + const { root, parent } = createFixture(); + const renderer = createStatusBarRenderer(parent); + renderer.render([ + { key: "first", text: "first " }, + { key: "second", text: "second " }, + ]); + const first = findItem(root, "first"); + const second = findItem(root, "second"); + + renderer.render([ + { key: "second", text: "second " }, + { key: "first", text: "first " }, + ]); + + expect(visibleItems(root)).toEqual([second, first]); + expect(findItem(root, "first")).toBe(first); + expect(findItem(root, "second")).toBe(second); + renderer.dispose(); + }); + + it("removes its nodes on dispose and ignores later renders", () => { + const { root, parent } = createFixture(); + const externalNode = new TestElement(new TestDocument(), "strong"); + root.appendChild(externalNode); + const renderer = createStatusBarRenderer(parent); + renderer.render([{ key: "sync", text: "sync" }]); + const item = findItem(root, "sync"); + + renderer.dispose(); + const childCountAfterDispose = root.childNodes.length; + renderer.dispose(); + renderer.render([{ key: "later", text: "later" }]); + + expect(item.parentNode).toBeUndefined(); + expect(root.childNodes).toEqual([externalNode]); + expect(root.childNodes).toHaveLength(childCountAfterDispose); + }); +}); diff --git a/styles.css b/styles.css index 462eab07..e957358b 100644 --- a/styles.css +++ b/styles.css @@ -839,3 +839,37 @@ body.is-mobile .livesync-compatibility-review-notice { outline-offset: 1px; border-radius: 2px; } + +.livesync-stat-item { + font-variant-numeric: tabular-nums; +} + +.livesync-stat-item.livesync-stat-inactive { + display: none; +} + +.syncstatusbar .livesync-stat-item { + white-space: pre; +} + +.livesync-status-statusline + > .livesync-stat-item:not(.livesync-stat-inactive) + ~ .livesync-stat-item:not( + .livesync-stat-push-remaining, + .livesync-stat-pull-remaining, + .livesync-stat-ff, + .livesync-stat-p2p + ) { + margin-inline-start: 1em; +} + +.livesync-stat-value { + display: inline-block; + width: var(--livesync-stat-width, auto); + text-align: right; + font-variant-numeric: tabular-nums; +} + +.livesync-stat-value[hidden] { + display: none; +} diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 78495c47..fd543794 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -83,10 +83,13 @@ npm run test:e2e:obsidian:focused -- stale-file-restart npm run test:e2e:obsidian:focused -- folder-batch npm run test:e2e:obsidian:focused -- security-seed-reconnect npm run test:e2e:obsidian:focused -- couchdb-http-cache +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. + `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. The same scenario checks the five actions in Reporting Tools, the separate Checks and Recovery panel, Metadata progress with its document total and gauge, and collapsible Markdown. It also observes Usage revision progress with a count and discovery note during a gated read. Local lookup checks cover multiple document IDs for one path, selection of an exact ID, and a follow-up report using the original salt. Closing the follow-up dialogue during a pending Metadata read must abort the report after that read settles, without querying Chunk availability. The compact Markdown must retain every JSON observation reference and pseudonym, and its copy and save payloads must match the full output. Results and export controls are checked at 800×600 on desktop and 375×667 in mobile emulation, including after copy and save feedback appears. diff --git a/test/e2e-obsidian/scripts/replication-status.ts b/test/e2e-obsidian/scripts/replication-status.ts new file mode 100644 index 00000000..155bbf61 --- /dev/null +++ b/test/e2e-obsidian/scripts/replication-status.ts @@ -0,0 +1,1094 @@ +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"; +import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts"; + +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 = { value: T }; +type TestLeaf = { + containerEl?: HTMLElement; + view?: { containerEl?: HTMLElement }; + openFile?: (file: unknown) => Promise; + 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 = { + adjustStatusDivPosition?: () => void; + statusLog?: WritableSource; + activeFileStatus?: WritableSource; + p2pLogCollector?: { p2pReplicationLine: WritableSource }; + statusDiv?: HTMLElement; + statusLine?: HTMLElement; + statusLineRenderer?: { render?: (parts: readonly unknown[]) => void }; + statusBarRenderer?: { render?: (parts: readonly unknown[]) => void }; + statusBarLabels?: WritableSource<{ parts: readonly unknown[]; message: string }>; + __addLog?: (message: unknown, level?: number, key?: string) => void; +}; +type TestCore = { + modules?: unknown[]; + services?: { + setting?: { currentSettings?: () => Record }; + replicator?: { + replicationStatics?: WritableSource; + }; + replication?: { + replicationResultCount: WritableSource; + databaseQueueCount: WritableSource; + storageApplyingCount: WritableSource; + }; + fileProcessing?: { + processing: WritableSource; + totalQueued: WritableSource; + batched: WritableSource; + }; + conflict?: { conflictProcessQueueCount: WritableSource }; + API?: { + requestCount?: WritableSource; + responseCount?: WritableSource; + }; + }; +}; +type TestApp = { + plugins?: { + plugins?: { "obsidian-livesync"?: { core?: TestCore } }; + }; + vault?: { + getAbstractFileByPath?: (path: string) => unknown | null; + create?: (path: string, content: string) => Promise; + }; + 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; +}; + +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 { + 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"); + // 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 }); + (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 { + 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 { + 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 { + const selector = `.livesync-status-statusline .livesync-stat-${key}:not(.livesync-stat-inactive) .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 { + await page.waitForFunction( + (message) => document.querySelector(".livesync-status-logmessage")?.textContent?.includes(message) === true, + expected, + { timeout } + ); +} + +async function assertSingleOverlay(contextPages: Page[], expectedCount = 1): Promise { + 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 { + 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> { + 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(".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 { + 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 { + 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 { + 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 { + 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> { + const measurements = await page.evaluate((keys) => { + const result: Record = {}; + 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(`.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.`); + } + if (getComputedStyle(value).textAlign !== "right") { + throw new Error(`The numeric status item ${key} is not right-aligned.`); + } + result[key] = value.getBoundingClientRect().width; + } + return result; + }, numericKeys); + return measurements as Record<(typeof numericKeys)[number], number>; +} + +async function assertNumericWidths( + page: Page, + expectedValue: string +): Promise> { + for (const key of numericKeys) await waitForPartText(page, key, expectedValue); + const widths = await page.evaluate((keys) => { + const result: Record = {}; + 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(`.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 assertEditorItemSpacing(page: Page, mobile: boolean): Promise { + const spacing = await page.evaluate(() => { + const modules = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core?.modules ?? []; + const moduleLog = modules.find((entry): entry is TestLogModule => { + if (typeof entry !== "object" || entry === null) return false; + return typeof (entry as TestLogModule).statusLineRenderer?.render === "function"; + }); + if (!moduleLog?.statusLineRenderer?.render || !moduleLog.statusBarLabels) { + throw new Error("The LiveSync editor status renderer is unavailable."); + } + const original = moduleLog.statusBarLabels.value.parts; + const parts = [ + { key: "queue-replication", text: " đŸ“Ĩ", value: 1 }, + { key: "queue-database", text: " 📄", value: 12 }, + { key: "queue-storage", text: " 💾", value: 3 }, + ]; + const results: { selector: string; gapsEm: number[] }[] = []; + try { + moduleLog.statusLineRenderer.render(parts); + moduleLog.statusBarRenderer?.render?.(parts); + for (const selector of [".livesync-status-statusline", ".syncstatusbar"]) { + const root = document.querySelector(selector); + if (!root || root.getBoundingClientRect().width === 0) continue; + const firstItem = root.querySelector(`.livesync-stat-${parts[0].key}`); + if (!firstItem || Number.parseFloat(getComputedStyle(firstItem).marginInlineStart) !== 0) { + throw new Error("Inactive predecessors left a gap before the first active status item."); + } + const fontSize = Number.parseFloat(getComputedStyle(root).fontSize); + const gapsEm: number[] = []; + for (let i = 1; i < parts.length; i++) { + const previous = root.querySelector( + `.livesync-stat-${parts[i - 1].key} .livesync-stat-value` + ); + const next = root.querySelector(`.livesync-stat-${parts[i].key}`)?.firstChild; + if (!previous || next?.nodeType !== Node.TEXT_NODE) + throw new Error("A spacing fixture item is missing."); + const range = document.createRange(); + range.setStart(next, next.textContent?.search(/\S/u) ?? 0); + range.setEnd(next, next.textContent?.length ?? 0); + const nextBox = range.getBoundingClientRect(); + const previousBox = previous.getBoundingClientRect(); + if (Math.abs(nextBox.top - previousBox.top) > 2) + throw new Error("The spacing fixture unexpectedly wrapped."); + gapsEm.push((nextBox.left - previousBox.right) / fontSize); + } + results.push({ selector, gapsEm }); + } + } finally { + moduleLog.statusLineRenderer.render(original); + moduleLog.statusBarRenderer?.render?.(original); + } + return results; + }); + const editor = spacing.find((entry) => entry.selector === ".livesync-status-statusline"); + failUnless(editor?.gapsEm.length === 2, "The editor status gaps could not be measured."); + for (const gap of editor.gapsEm) { + failUnless(gap >= 1 && gap <= 1.5, `Editor status items need a 1–1.5em gap; found ${gap.toFixed(3)}em.`); + } + if (!mobile) { + const statusBar = spacing.find((entry) => entry.selector === ".syncstatusbar"); + failUnless(statusBar?.gapsEm.length === 2, "The status-bar gaps could not be measured."); + failUnless( + statusBar.gapsEm.every((gap) => gap < 1), + "The status-bar items unexpectedly acquired the wider editor gaps." + ); + } + console.log( + `${mobile ? "Mobile" : "Desktop"} editor item gaps: ${editor.gapsEm.map((gap) => gap.toFixed(3)).join(", ")}em.` + ); +} + +async function assertStatusBarItemSpacing(page: Page): Promise { + const collapsedItems = await page.evaluate(() => { + const items = document.querySelectorAll(".syncstatusbar > .livesync-stat-item"); + const collapsed: string[] = []; + for (const item of items) { + if (item.classList.contains("livesync-stat-inactive")) continue; + const prefix = item.firstChild; + if (prefix?.nodeType !== Node.TEXT_NODE || !prefix.textContent?.startsWith(" ")) continue; + const range = document.createRange(); + range.setStart(prefix, 0); + range.setEnd(prefix, 1); + if (range.getBoundingClientRect().width <= 0) collapsed.push(item.className); + } + return collapsed; + }); + failUnless( + collapsedItems.length === 0, + `Status-bar item separators lost their visible spaces: ${collapsedItems.join(", ")}.` + ); +} + +async function assertInactiveItemStyling(page: Page): Promise { + const roots = [".livesync-status-statusline", ".syncstatusbar"]; + for (const root of roots) { + const state = await page.locator(`${root} .livesync-stat-ff`).evaluate((element) => { + const item = element as HTMLElement; + return { + inactive: item.classList.contains("livesync-stat-inactive"), + hidden: item.hidden, + ariaHidden: item.getAttribute("aria-hidden"), + display: getComputedStyle(item).display, + text: item.textContent, + width: item + .querySelector(".livesync-stat-value") + ?.style.getPropertyValue("--livesync-stat-width"), + precedesUploads: item.nextElementSibling?.classList.contains("livesync-stat-sent"), + }; + }); + failUnless( + state.inactive && !state.hidden && state.ariaHidden === "true", + `Inactive state was not exposed through the class in ${root}.` + ); + failUnless(state.display === "none", `Inactive items were not hidden by the default CSS in ${root}.`); + failUnless( + state.text?.includes("1000") && state.width === "4ch" && state.precedesUploads, + `An inactive item lost its content, width, or position in ${root}.` + ); + } + + const snippet = await page.addStyleTag({ + content: ".livesync-stat-item.livesync-stat-inactive { display: inline-block; }", + }); + try { + for (const root of roots) { + failUnless( + await page.locator(`${root} .livesync-stat-ff`).isVisible(), + `A CSS override could not reveal the inactive item in ${root}.` + ); + } + } finally { + await snippet.evaluate((element) => element.parentNode?.removeChild(element)); + } + for (const root of roots) { + failUnless( + await page.locator(`${root} .livesync-stat-ff`).isHidden(), + `The default inactive style was not restored in ${root}.` + ); + } +} + +async function measureStatusBarNetworkWidth( + page: Page, + expectedValue: string +): Promise<{ cssWidth: string; renderedWidth: number }> { + const selector = ".syncstatusbar .livesync-stat-network:not(.livesync-stat-inactive) .livesync-stat-value"; + await page.waitForFunction( + ({ selector, expectedValue }) => { + const value = document.querySelector(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 { + 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 assertLegacyStatusPaths(page: Page): Promise { + 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(`${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 { + 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(".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"); + await assertStatusBarItemSpacing(page); + for (const root of [".livesync-status-statusline", ".syncstatusbar"]) { + failUnless( + (await page.locator(`${root} .livesync-stat-ff + .livesync-stat-sent`).count()) === 1, + `The comparison count did not appear immediately before uploads in ${root}.` + ); + } + 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(".livesync-status-statusline .livesync-stat-ff") + ?.classList.contains("livesync-stat-inactive") === true, + undefined, + { timeout: uiTimeoutMs } + ); + await assertInactiveItemStyling(page); + 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(`[data-e2e-status-popout="${marker}"]`); + const status = document.querySelector(".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); + await assertLegacyStatusPaths(page); + 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 { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + for (const mobile of [false, true]) { + const vault = await createTemporaryVault("obsidian-livesync-replication-status-"); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + lifecycle: mobile + ? { + beforePluginStart: async ({ remoteDebuggingPort }) => { + await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs); + }, + } + : undefined, + 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." + ); + if (!mobile) await runStatusScenario(session); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await installFixtureFile(page); + if (mobile) { + await page.evaluate(() => { + const modules = + (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core?.modules ?? + []; + const moduleLog = modules.find((entry): entry is TestLogModule => { + if (typeof entry !== "object" || entry === null) return false; + return typeof (entry as TestLogModule).adjustStatusDivPosition === "function"; + }); + moduleLog?.adjustStatusDivPosition?.(); + }); + } + try { + await page.locator(".livesync-status").waitFor({ state: "attached", timeout: uiTimeoutMs }); + await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0); + await page.locator(".livesync-status").waitFor({ state: "visible", timeout: uiTimeoutMs }); + } catch (error) { + throw new Error( + `${String(error)}; spacing setup: ${JSON.stringify(await getStatusDiagnostics(page))}` + ); + } + await assertEditorItemSpacing(page, mobile); + }); + } 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); +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 5a3f4c6e..0a08e03d 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -21,6 +21,7 @@ const focusedScenarios = new Set([ "configuration-encryption-sync", "review-harness", "p2p-pane", + "replication-status", "vault-reflection", "couchdb-upload", "couchdb-http-cache", diff --git a/updates.md b/updates.md index eb26f2d0..63cc06b6 100644 --- a/updates.md +++ b/updates.md @@ -30,6 +30,8 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi #### New Feature +- We can see CouchDB change-history comparison progressing before new documents arrive, with a ⏊ status icon and a comparison count before the upload count. An unchanged count disappears after three seconds. +- We can hide or style individual status items with CSS snippets using the `livesync-stat-` classes. Tabular, right-aligned numbers retain their width while visible, reducing movement as counts change. Wider gaps between editor status items make the counts easier to read. The `livesync-stat-inactive` class lets CSS snippets control how inactive items disappear. - We can enable **Batch database updates in LiveSync mode** to combine repeated local edits before sending them to other devices. - The choice starts disabled, including when older settings contain the previously ignored batch option. Each mode retains its own choice and uses the same minimum and maximum delays. - Batching delays remote visibility and reduces intermediate updates. It does not establish when a template has finished or prevent genuine concurrent edits.