Merge main and retain Fetch recovery notes

This commit is contained in:
vorotamoroz committed 2026-10-29 14:46:06 +00:00
commit 8422c19881
47 files changed
+2695 -1303

No files matched your search

+249 -22
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
@@ -8,7 +9,7 @@ import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.t
const fixturePath = "replication-status-e2e.md";
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000);
const fastForwardLingerMs = 3000;
const numericKeys = ["network", "sent", "push-remaining", "arrived", "pull-remaining", "ff"] as const;
const numericKeys = ["network", "sent", "push-remaining", "arrived", "pull-remaining", "ff-push", "ff-pull"] as const;
type TestReplicationStatics = {
sent: number;
@@ -17,9 +18,9 @@ type TestReplicationStatics = {
maxPushSeq: number;
lastSyncPullSeq: number;
lastSyncPushSeq: number;
syncStatus: "CONNECTED";
syncStatus: "CONNECTED" | "PAUSED" | "ERRORED";
couchDB: {
phase: "continuous";
phase: "continuous" | "idle";
pull: { checkedChanges: number; completedBatches: number; checking: boolean };
push: { checkedChanges: number; completedBatches: number; checking: boolean };
};
@@ -33,6 +34,8 @@ type TestLeaf = {
detach?: () => void;
};
type TestWorkspace = {
layoutReady?: boolean;
getMostRecentLeaf?: () => TestLeaf | null;
activeLeaf?: TestLeaf | null;
getLeaf?: (newLeaf?: boolean | "split" | "tab" | "window", direction?: "horizontal" | "vertical") => TestLeaf;
openPopoutLeaf?: () => TestLeaf;
@@ -106,7 +109,12 @@ function failUnless(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
function replicationStats(checkedChanges: number, sent: number, arrived: number): TestReplicationStatics {
function replicationStats(
checkedChanges: number,
sent: number,
arrived: number,
pushCheckedChanges = 0
): TestReplicationStatics {
return {
sent,
arrived,
@@ -118,7 +126,7 @@ function replicationStats(checkedChanges: number, sent: number, arrived: number)
couchDB: {
phase: "continuous",
pull: { checkedChanges, completedBatches: 0, checking: true },
push: { checkedChanges: 0, completedBatches: 0, checking: false },
push: { checkedChanges: pushCheckedChanges, completedBatches: 0, checking: pushCheckedChanges > 0 },
},
};
}
@@ -252,7 +260,7 @@ async function getStatusDiagnostics(page: Page): Promise<Record<string, unknown>
const status = document.querySelector<HTMLElement>(".livesync-status");
const statusParent = status?.parentElement;
const activeLeaf = app?.workspace?.activeLeaf;
const recentLeaf = app?.workspace?.getLeaf?.(false);
const recentLeaf = app?.workspace?.getMostRecentLeaf?.();
const statusStyle = status ? getComputedStyle(status) : undefined;
const statusParentStyle = statusParent ? getComputedStyle(statusParent) : undefined;
const activeLeafContainer = activeLeaf?.view?.containerEl;
@@ -284,7 +292,11 @@ async function getStatusDiagnostics(page: Page): Promise<Record<string, unknown>
visibility: statusParentStyle?.visibility,
}
: undefined,
workspaceLayoutReady: app?.workspace?.layoutReady,
moduleHasStatusDiv: moduleLog?.statusDiv !== undefined,
moduleStatusDivConnected: moduleLog?.statusDiv?.isConnected,
moduleStatusDivParent: moduleLog?.statusDiv?.parentElement?.className,
recentLeafIsActive: recentLeaf === activeLeaf,
moduleStatusLine: moduleLog?.statusLine
? {
tag: moduleLog.statusLine.tagName,
@@ -557,7 +569,7 @@ async function assertStatusBarItemSpacing(page: Page): Promise<void> {
async function assertInactiveItemStyling(page: Page): Promise<void> {
const roots = [".livesync-status-statusline", ".syncstatusbar"];
for (const root of roots) {
const state = await page.locator(`${root} .livesync-stat-ff`).evaluate((element) => {
const state = await page.locator(`${root} .livesync-stat-ff-pull`).evaluate((element) => {
const item = element as HTMLElement;
return {
inactive: item.classList.contains("livesync-stat-inactive"),
@@ -588,7 +600,7 @@ async function assertInactiveItemStyling(page: Page): Promise<void> {
try {
for (const root of roots) {
failUnless(
await page.locator(`${root} .livesync-stat-ff`).isVisible(),
await page.locator(`${root} .livesync-stat-ff-pull`).isVisible(),
`A CSS override could not reveal the inactive item in ${root}.`
);
}
@@ -597,12 +609,221 @@ async function assertInactiveItemStyling(page: Page): Promise<void> {
}
for (const root of roots) {
failUnless(
await page.locator(`${root} .livesync-stat-ff`).isHidden(),
await page.locator(`${root} .livesync-stat-ff-pull`).isHidden(),
`The default inactive style was not restored in ${root}.`
);
}
}
async function waitForComparisonCounts(page: Page, push?: number, pull?: number): Promise<void> {
await page.waitForFunction(
({ push, pull }) =>
[".livesync-status-statusline", ".syncstatusbar"].every((root) =>
(["push", "pull"] as const).every((direction) => {
const value = document.querySelector(
`${root} .livesync-stat-ff-${direction}:not(.livesync-stat-inactive) .livesync-stat-value`
);
const expected = direction === "push" ? push : pull;
return expected === undefined ? value === null : value?.textContent?.trim() === String(expected);
})
),
{ push, pull },
{ timeout: uiTimeoutMs }
);
}
async function waitForSyncIcon(page: Page, icon: string): Promise<void> {
await page.waitForFunction(
(expected) =>
[".livesync-status-statusline", ".syncstatusbar"].every(
(root) => document.querySelector(`${root} .livesync-stat-sync`)?.textContent === `Sync: ${expected}`
),
icon,
{ timeout: uiTimeoutMs }
);
}
async function assertDirectionalComparison(page: Page): Promise<void> {
const pushOnly = replicationStats(0, 0, 0, 71975);
pushOnly.couchDB.pull.checking = false;
await injectReplicationStats(page, pushOnly, 0, 0);
await waitForComparisonCounts(page, 71975);
await waitForSyncIcon(page, "⏩");
for (const root of [".livesync-status-statusline", ".syncstatusbar"]) {
failUnless(
(await page.locator(`${root} .livesync-stat-sent .livesync-stat-value`).textContent())?.trim() === "0",
`Push comparison changed the transfer count in ${root}.`
);
}
await injectReplicationStats(page, replicationStats(4, 0, 0, 40), 0, 0);
await waitForComparisonCounts(page, 40, 4);
for (const direction of ["push", "pull"]) {
const margin = await page
.locator(`.livesync-status-statusline .livesync-stat-ff-${direction}`)
.evaluate((element) => Number.parseFloat(getComputedStyle(element).marginInlineStart));
failUnless(margin === 0, `The ${direction} comparison item acquired an extra ${margin}px editor gap.`);
}
for (let update = 1; update <= 3; update++) {
await page.waitForTimeout(700);
await injectReplicationStats(page, replicationStats(4, 0, 0, 40 + update), 0, 0);
}
await waitForComparisonCounts(page, 43);
await waitForComparisonCounts(page);
await injectReplicationStats(page, replicationStats(4, 7, 11, 43), 0, 0);
await waitForPartText(page, "arrived", "11");
await waitForComparisonCounts(page);
const idle = replicationStats(4, 7, 11, 43);
idle.syncStatus = "PAUSED";
idle.couchDB.phase = "idle";
idle.couchDB.push.checking = false;
idle.couchDB.pull.checking = false;
await injectReplicationStats(page, idle, 0, 0);
await waitForSyncIcon(page, "💤");
await waitForComparisonCounts(page);
const failed = replicationStats(5, 7, 11, 44);
failed.syncStatus = "ERRORED";
await injectReplicationStats(page, failed, 0, 0);
await waitForComparisonCounts(page, 44, 5);
await waitForSyncIcon(page, "⚠");
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await waitForComparisonCounts(page);
await injectReplicationStats(page, replicationStats(1, 0, 0, 1), 0, 0);
await waitForComparisonCounts(page, 1, 1);
await waitForSyncIcon(page, "⏩");
const labels = await page.evaluate(() => {
const modules = (globalThis as TestWindow).app?.plugins?.plugins?.["obsidian-livesync"]?.core?.modules ?? [];
const moduleLog = modules.find(
(entry): entry is TestLogModule =>
typeof entry === "object" && entry !== null && (entry as TestLogModule).statusBarLabels !== undefined
);
return moduleLog?.statusBarLabels?.value.message;
});
failUnless(
labels?.includes("(⏩↑ 1) (⏩↓ 1) ↑ 0 (LIVE) ↓ 0 (LIVE)"),
`The plain-text status did not distinguish comparison directions from transfers: ${labels}`
);
console.log(
"Directional comparison passed: push-only, independent expiry, idle/error icons, and fresh-run counts."
);
}
function documentedAnimationSnippet(): string {
const guide = readFileSync(new URL("../../../docs/tips/status-display.md", import.meta.url), "utf8");
const section = guide.split("### Animate editor status items")[1];
const snippet = section?.match(/```css\r?\n([\s\S]*?)\r?\n```/u)?.[1];
failUnless(snippet, "The documented editor animation snippet could not be found.");
return snippet;
}
async function assertDocumentedAnimation(page: Page): Promise<void> {
const css = documentedAnimationSnippet();
const fallbackCss = css.replace("@supports (width: calc-size(auto, size))", "@supports (width: e2e-unsupported)");
failUnless(fallbackCss !== css, "The animation example is missing its calc-size feature guard.");
const selector = ".livesync-status-statusline .livesync-stat-ff-push";
await injectReplicationStats(page, replicationStats(0, 0, 0, 200), 0, 0);
await waitForComparisonCounts(page, 200);
const fallback = await page.addStyleTag({ content: fallbackCss });
try {
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await waitForComparisonCounts(page);
failUnless(
(await page.locator(selector).evaluate((element) => getComputedStyle(element).display)) === "none",
"An unsupported animation guard overrode the default inactive-item hiding."
);
} finally {
await fallback.evaluate((element) => element.parentNode?.removeChild(element));
}
const supported = await page.evaluate(() => CSS.supports("width", "calc-size(auto, size)"));
if (!supported) {
console.log("Documented snippet: default hiding passed; this runtime does not support calc-size animations.");
return;
}
await page.emulateMedia({ reducedMotion: "no-preference" });
const style = await page.addStyleTag({ content: css });
try {
const item = page.locator(selector);
await injectReplicationStats(page, replicationStats(0, 0, 0, 201), 0, 0);
await waitForComparisonCounts(page, 201);
await page.waitForFunction(
(selector) => {
const item = document.querySelector<HTMLElement>(selector);
return item !== null && item.getBoundingClientRect().width > 1 && item.getAnimations().length === 0;
},
selector,
{ timeout: uiTimeoutMs }
);
const openWidth = await item.evaluate((element) => element.getBoundingClientRect().width);
const original = await item.elementHandle();
failUnless(original, "The animated comparison item is missing.");
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await page.waitForFunction(
({ selector, openWidth }) => {
const item = document.querySelector<HTMLElement>(selector);
const width = item?.getBoundingClientRect().width ?? 0;
return item?.classList.contains("livesync-stat-inactive") && width > 0.5 && width < openWidth - 0.5;
},
{ selector, openWidth },
{ timeout: uiTimeoutMs }
);
await page.waitForFunction(
(selector) => {
const item = document.querySelector<HTMLElement>(selector);
return item !== null && item.getBoundingClientRect().width < 0.5 && item.getAnimations().length === 0;
},
selector,
{ timeout: uiTimeoutMs }
);
const collapsed = await item.evaluate((element) => ({
display: getComputedStyle(element).display,
margin: getComputedStyle(element).marginInlineStart,
ariaHidden: element.getAttribute("aria-hidden"),
}));
failUnless(
collapsed.display === "inline-block" && collapsed.margin === "0px" && collapsed.ariaHidden === "true",
"The animated inactive item lost its collapsed layout or accessibility state."
);
await injectReplicationStats(page, replicationStats(0, 0, 0, 202), 0, 0);
await waitForComparisonCounts(page, 202);
await page.waitForFunction(
({ selector, openWidth }) => {
const item = document.querySelector<HTMLElement>(selector);
return item !== null && Math.abs(item.getBoundingClientRect().width - openWidth) < 0.5;
},
{ selector, openWidth },
{ timeout: uiTimeoutMs }
);
failUnless(
await original.evaluate((element, selector) => element === document.querySelector(selector), selector),
"Reopening an animated item replaced its retained DOM node."
);
await original.dispose();
await page.emulateMedia({ reducedMotion: "reduce" });
await injectReplicationStats(page, replicationStats(0, 0, 0), 0, 0);
await waitForComparisonCounts(page);
const reduced = await item.evaluate((element) => ({
width: element.getBoundingClientRect().width,
transition: getComputedStyle(element).transitionProperty,
animations: element.getAnimations().length,
}));
failUnless(
reduced.width < 0.5 && reduced.transition === "none" && reduced.animations === 0,
"The documented snippet did not honour reduced motion."
);
console.log(
"Documented snippet passed: interpolated collapse, retained-node reopening, reduced motion, and disabled-guard fallback."
);
} finally {
await style.evaluate((element) => element.parentNode?.removeChild(element));
await page.emulateMedia({ reducedMotion: null });
}
}
async function measureStatusBarNetworkWidth(
page: Page,
expectedValue: string
@@ -846,7 +1067,7 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
let popoutPage: Page | undefined;
let mainWindow: MainWindowControl | undefined;
try {
await installFixtureFile(page);
console.log(`Replication status runtime: ${await page.evaluate(() => navigator.userAgent)}`);
try {
await page.locator(".livesync-status").waitFor({ state: "attached", timeout: uiTimeoutMs });
} catch (error) {
@@ -882,11 +1103,11 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
const startedAt = Date.now();
await injectReplicationStats(page, initialStats, 0, 0);
await waitForPartText(page, "arrived", "0");
await waitForPartText(page, "ff", "1000");
await waitForPartText(page, "ff-pull", "1000");
await assertStatusBarItemSpacing(page);
for (const root of [".livesync-status-statusline", ".syncstatusbar"]) {
failUnless(
(await page.locator(`${root} .livesync-stat-ff + .livesync-stat-sent`).count()) === 1,
(await page.locator(`${root} .livesync-stat-ff-pull + .livesync-stat-sent`).count()) === 1,
`The comparison count did not appear immediately before uploads in ${root}.`
);
}
@@ -902,7 +1123,7 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
await page.waitForFunction(
() =>
document
.querySelector(".livesync-status-statusline .livesync-stat-ff")
.querySelector(".livesync-status-statusline .livesync-stat-ff-pull")
?.classList.contains("livesync-stat-inactive") === true,
undefined,
{ timeout: uiTimeoutMs }
@@ -914,11 +1135,11 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
"The fast-forward checking icon disappeared when its count expired."
);
await injectReplicationStats(page, replicationStats(1001, 1001, 1001), 1001, 0);
await injectReplicationStats(page, replicationStats(1001, 1001, 1001, 1001), 1001, 0);
const wideWidths = await assertNumericWidths(page, "1001");
const wideStatusBarWidth = await measureStatusBarNetworkWidth(page, "1001");
failUnless(wideStatusBarWidth.cssWidth === "4ch", "The status-bar network count did not reserve 4ch.");
await injectReplicationStats(page, replicationStats(7, 7, 7), 7, 0);
await injectReplicationStats(page, replicationStats(7, 7, 7, 7), 7, 0);
const narrowWidths = await assertNumericWidths(page, "7");
const narrowStatusBarWidth = await measureStatusBarNetworkWidth(page, "7");
for (const key of numericKeys) {
@@ -942,6 +1163,8 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
`Identical status render: 1000 iterations, 0 DOM mutations, ${repeatedRender.elapsedMs.toFixed(2)} ms.`
);
await assertDirectionalComparison(page);
await createSplitLeaf(page);
await assertSingleOverlay(page.context().pages());
await waitForOverlayOnActiveLeaf(page);
@@ -1030,13 +1253,15 @@ async function main(): Promise<void> {
binary,
cliBinary: cli.binary,
vault,
lifecycle: mobile
? {
beforePluginStart: async ({ remoteDebuggingPort }) => {
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
},
}
: undefined,
lifecycle: {
beforePluginStart: async ({ remoteDebuggingPort }) => {
if (mobile) {
await setObsidianMobileTestModeBeforePluginStart(remoteDebuggingPort, true, uiTimeoutMs);
}
// Prepare the editor before asynchronous plug-in loading can place the overlay in it.
await withObsidianPage(remoteDebuggingPort, installFixtureFile);
},
},
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
liveSync: false,
@@ -1080,6 +1305,8 @@ async function main(): Promise<void> {
);
}
await assertEditorItemSpacing(page, mobile);
if (mobile) await assertDirectionalComparison(page);
await assertDocumentedAnimation(page);
});
} finally {
if (session) await session.app.stop();