Merge main into shared runtime foundations

Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
vorotamoroz committed 2026-10-30 15:36:57 +00:00
commit bd05dde39c
83 files changed
+6313 -1507

No files matched your search

+191 -2
View File
@@ -1,13 +1,19 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import type { Page } from "playwright";
import type { Locator, Page } from "playwright";
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/setup-uri";
import { decryptString } from "@vrtmrz/livesync-commonlib/compat/encryption/stringEncryption";
import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "../../../src/common/translation.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts";
import {
assertMobileDialogueLayout,
assertMobileNoticeLayout,
iPhoneSafeArea,
mobileViewport,
setObsidianMobileTestMode,
} from "../runner/mobileUi.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
captureObsidianDialogue,
@@ -400,6 +406,185 @@ async function verifyRemoteSizeNoticeAndDialogue(): Promise<{
};
}
async function assertDialogueActionAlignment(modal: Locator, mode: DialogueMode, name: string): Promise<void> {
const groups = await modal.locator(".dialog-host .button-group").evaluateAll((elements) => {
return elements
.filter((element) => element.getClientRects().length > 0)
.map((element) => {
const group = element.getBoundingClientRect();
const buttons = Array.from(element.children)
.filter((child) => child instanceof HTMLButtonElement && child.getClientRects().length > 0)
.map((child) => {
const rect = child.getBoundingClientRect();
return { left: rect.left, right: rect.right, width: rect.width };
});
return { group: { left: group.left, right: group.right, width: group.width }, buttons };
})
.filter((group) => group.buttons.length > 0);
});
if (groups.length === 0) {
throw new Error(`Expected visible action buttons in ${name} (${mode}).`);
}
console.log(JSON.stringify({ event: "dialogue-action-alignment", name, mode, groups }));
for (const { group, buttons } of groups) {
for (const button of buttons) {
if (Math.abs(button.left - group.left) > 1 || Math.abs(button.right - group.right) > 1) {
throw new Error(
`Expected ${name} (${mode}) action buttons to align with both edges of their content column: ${JSON.stringify({ group, button })}`
);
}
}
}
}
async function assertNoExtraKeyboardSpace(page: Page, modal: Locator): Promise<void> {
const original = await page.evaluate(() => ({
value: document.body.style.getPropertyValue("--keyboard-height"),
priority: document.body.style.getPropertyPriority("--keyboard-height"),
}));
const originalScroll = await modal.locator(".modal-content").evaluate((element) => element.scrollTop);
const keyboardHeight = await page.evaluate(() => Math.round(innerHeight * 0.4));
let baseline: number | undefined;
try {
for (const height of [0, keyboardHeight, 0]) {
await page.evaluate((value) => document.body.style.setProperty("--keyboard-height", `${value}px`), height);
await modal.locator(".modal-content").evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
const gap = await modal.locator(".dialog-host").evaluate((host) => {
const buttons = Array.from(host.querySelectorAll("button")).filter(
(button) => button.getClientRects().length > 0
);
const last = buttons[buttons.length - 1];
if (last === undefined) throw new Error("The dialogue has no action button.");
return host.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom;
});
console.log(JSON.stringify({ event: "dialogue-keyboard-spacing", height, gap, baseline }));
baseline ??= gap;
if (Math.abs(gap - baseline) > 1) {
throw new Error(
`Expected the keyboard not to add blank content after the final action: ${JSON.stringify({ height, gap, baseline })}`
);
}
}
} finally {
await page.evaluate(({ value, priority }) => {
if (value === "") document.body.style.removeProperty("--keyboard-height");
else document.body.style.setProperty("--keyboard-height", value, priority);
}, original);
await modal.locator(".modal-content").evaluate((element, top) => {
element.scrollTop = top;
}, originalScroll);
}
}
async function assertCouchDBKeyboardFormSpace(page: Page, modal: Locator): Promise<void> {
const originalViewport = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
const properties = ["--keyboard-height", "--safe-area-inset-top", "--safe-area-inset-bottom"];
const original = await page.evaluate(
(names) =>
names.map((name) => ({
name,
value: document.body.style.getPropertyValue(name),
priority: document.body.style.getPropertyPriority(name),
})),
properties
);
const content = modal.locator(".modal-content");
const originalScroll = await content.evaluate((element) => element.scrollTop);
const keyboardHeight = Math.round(mobileViewport.height * 0.42);
try {
await page.setViewportSize(mobileViewport);
await page.evaluate(
({ keyboardHeight, top }) => {
document.body.style.setProperty("--keyboard-height", `${keyboardHeight}px`);
document.body.style.setProperty("--safe-area-inset-bottom", `${keyboardHeight}px`);
document.body.style.setProperty("--safe-area-inset-top", `${top}px`);
},
{ keyboardHeight, top: iPhoneSafeArea.top }
);
await modal.locator('input[name="couchdb-username"]').focus();
await page.waitForTimeout(350);
await page.screenshot({
path: join(
process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e",
"setup-couchdb-keyboard-form.png"
),
animations: "disabled",
style: ".notice-container { visibility: hidden !important; }",
});
const warning = modal.locator(".sls-info-note").filter({
hasText:
"Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected.",
});
if ((await warning.count()) !== 1) throw new Error("Expected the untested-save warning to remain available.");
const pinned = await warning.evaluate((element) => {
for (let parent = element.parentElement; parent !== null; parent = parent.parentElement) {
if (parent.classList.contains("modal-content")) break;
const position = getComputedStyle(parent).position;
if (position === "sticky" || position === "fixed") return true;
}
return false;
});
if (pinned) throw new Error("The untested-save warning still occupies the fixed action area.");
const bounds = await modal.evaluate((element) => {
const host = element.querySelector(".dialog-host");
const content = element.querySelector(".modal-content");
const actionGroups = host?.querySelectorAll(":scope > .button-group");
const footerElement = actionGroups?.item(actionGroups.length - 1);
const rowElement = host?.querySelector(".row");
if (!host || !content || !footerElement || !rowElement)
throw new Error("CouchDB form layout is unavailable.");
const footer = footerElement.getBoundingClientRect();
const row = rowElement.getBoundingClientRect();
const actions = Array.from(footerElement.querySelectorAll("button"), (button) =>
button.getBoundingClientRect()
);
const footerTop = Math.min(footer.top, ...actions.map((action) => action.top));
const footerBottom = Math.max(footer.bottom, ...actions.map((action) => action.bottom));
const modalElement = element.querySelector(".modal");
if (!modalElement) throw new Error("The CouchDB modal is unavailable.");
const modalRect = modalElement.getBoundingClientRect();
const contentRect = content.getBoundingClientRect();
const headerBottom =
element.querySelector(".modal-header")?.getBoundingClientRect().bottom ?? modalRect.top;
const formTop = Math.max(contentRect.top, modalRect.top, headerBottom);
return {
width: innerWidth,
height: innerHeight,
formTop,
footerTop,
formHeight: Math.min(footerTop, contentRect.bottom, modalRect.bottom) - formTop,
rowHeight: row.height + (parseFloat(getComputedStyle(host).rowGap) || 0),
footerBottom,
actionBounds: actions.map((action) => ({ top: action.top, bottom: action.bottom })),
};
});
console.log(JSON.stringify({ event: "couchdb-keyboard-form-space", keyboardHeight, bounds }));
if (
bounds.width !== mobileViewport.width ||
bounds.height !== mobileViewport.height ||
bounds.formHeight < bounds.rowHeight * 2 ||
bounds.footerBottom > bounds.height - keyboardHeight + 1
) {
throw new Error(
`Expected room for at least two input rows above keyboard actions: ${JSON.stringify(bounds)}`
);
}
} finally {
await page.evaluate((entries) => {
for (const { name, value, priority } of entries) {
if (value === "") document.body.style.removeProperty(name);
else document.body.style.setProperty(name, value, priority);
}
}, original);
await page.setViewportSize(originalViewport);
await content.evaluate((element, top) => {
element.scrollTop = top;
}, originalScroll);
}
}
async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string> {
await openRemoteSelectionDialogue();
const screenshotPath = await captureObsidianDialogue(
@@ -446,6 +631,7 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise<string
await modal
.getByRole("button", { name: "No, please take me back" })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertDialogueActionAlignment(modal, mode, "remote selection dialogue");
if (mode === "mobile") {
await assertMobileDialogueLayout(page, modal, "remote selection dialogue");
}
@@ -526,8 +712,11 @@ async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise<string
throw new Error(`Expected vertical CouchDB actions, received ${flexDirection}.`);
}
}
await assertDialogueActionAlignment(modal, mode, "CouchDB settings dialogue");
if (mode === "mobile") {
await assertMobileDialogueLayout(page, modal, "CouchDB settings dialogue");
await assertNoExtraKeyboardSpace(page, modal);
await assertCouchDBKeyboardFormSpace(page, modal);
}
}
);
@@ -330,7 +330,8 @@ async function fetchFromFirstPeer(
sessionA: ObsidianLiveSyncSession,
portA: number,
portB: number,
screenshots: string[]
screenshots: string[],
selectionScreenshot = "guide-p2p-setup-select-first-device.png"
): Promise<void> {
try {
await withObsidianPage(portB, async (page) => {
@@ -363,7 +364,7 @@ async function fetchFromFirstPeer(
.join("\n")
);
}
screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild"));
screenshots.push(await captureGuideDialogue(portB, selectionScreenshot, "P2P Rebuild"));
let finished = false;
const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots);
@@ -390,6 +391,186 @@ async function fetchFromFirstPeer(
});
}
async function verifyInterruptedFetchRestart(
context: RunnerContext,
sessionA: ObsidianLiveSyncSession,
sessionB: ObsidianLiveSyncSession,
vaultA: TemporaryVault,
vaultB: TemporaryVault,
screenshots: string[]
): Promise<void> {
const portA = sessionA.remoteDebuggingPort;
const portB = sessionB.remoteDebuggingPort;
const remotePath = "E2E/p2p/fetched-after-restart.md";
const remoteContent = "# P2P recovery source\n\nFetch these exact bytes after confirming the saved choices.\n";
const localPath = "E2E/p2p/local-only-during-recovery.md";
const localContent = "# Local recovery note\n\nKeep these exact bytes while restarting the P2P Fetch.\n";
await writeNote(context.cliBinary, sessionA.cliEnv, remotePath, remoteContent);
await withObsidianPage(portB, async (page) => {
const before = await page.evaluate(async (path) => {
const app = (globalThis as any).app;
const core = app.plugins.plugins["obsidian-livesync"].core;
return {
inVault: await app.vault.adapter.exists(path),
inDatabase: !!(await core.localDatabase.getDBEntry(path, undefined, false, true)),
};
}, remotePath);
assertEqual(before.inVault, false, "The recovery source note already existed in the receiving Vault.");
assertEqual(before.inDatabase, false, "The recovery source note arrived before the scheduled P2P Fetch.");
await page.evaluate(async () => {
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
if (!(await core.rebuilder.scheduleFetch())) throw new Error("Could not schedule the P2P Fetch.");
});
});
await withObsidianPage(portB, async (page) => {
await modalByTitle(page, "Data retrieval scheduled").waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.evaluate(() => {
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
const fetch = core.rebuilder.$fetchLocalDBFast.bind(core.rebuilder);
// Keep the actual P2P transport and interrupt before the subsequent Vault scan.
core.rebuilder.$fetchLocalDBFast = async (...args: unknown[]) => {
await fetch(...args);
(globalThis as any).__p2pFetchInterrupted = true;
throw new Error("E2E: interrupted after P2P receive and before Vault reflection");
};
});
});
screenshots.push(...(await confirmFastFetch(portB, { scenario: "p2p-fetch-restart", guide: "p2p-fetch-restart" })));
await fetchFromFirstPeer(sessionA, portA, portB, screenshots, "p2p-fetch-interrupted-select-source.png");
const pending = await withObsidianPage(portB, async (page) => {
await page.waitForFunction(
() => {
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
const settings = core.services.setting.currentSettings();
return (
(globalThis as any).__p2pFetchInterrupted === true &&
settings.suspendFileWatching &&
settings.suspendParseReplicationResult
);
},
undefined,
{ timeout: uiTimeoutMs }
);
return await page.evaluate(
async ({ remotePath, localPath, localContent }) => {
const app = (globalThis as any).app;
const core = app.plugins.plugins["obsidian-livesync"].core;
const remoteEntry = await core.localDatabase.getDBEntry(remotePath, undefined, false, true);
const remoteInVault = await app.vault.adapter.exists(remotePath);
await app.vault.create(localPath, localContent);
return {
received: !!remoteEntry,
remoteInVault,
markdown: await app.vault.adapter.read("flag_fetch.md"),
room: core.services.setting.currentSettings().P2P_roomID,
};
},
{ remotePath, localPath, localContent }
);
});
assertEqual(pending.received, true, "The interruption occurred before the real P2P receive completed.");
assertEqual(pending.remoteInVault, false, "The interrupted P2P Fetch reflected the source note too early.");
const block = /```json\s*\n([\s\S]*?)\n```/.exec(pending.markdown);
if (!block) throw new Error("The interrupted P2P Fetch did not retain its choices in the flag.");
const selection = JSON.parse(block[1]);
assertEqual(selection.stage1, "Overwrite all with remote files", "The P2P overwrite choice was not retained.");
assertEqual(
selection.stage2,
"Keep local files even if not on remote",
"The P2P preservation choice was not retained."
);
console.log("P2P workflow: received the recovery note, interrupted before reflection, and retained the choices.");
await withObsidianPage(portB, async (page) => {
await page.evaluate(() =>
(globalThis as any).app.plugins.plugins["obsidian-livesync"].core.services.appLifecycle.performRestart()
);
});
await withObsidianPage(portB, async (page) => {
const modal = modalByTitle(page, "Resume scheduled fetch?");
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const message = await modal.innerText();
for (const expected of ["Current remote:", "P2P", pending.room, selection.stage1, selection.stage2]) {
if (!message.includes(expected))
throw new Error("The P2P resume dialogue omitted the remote or saved choices.");
}
const beforeConfirmation = await page.evaluate(async (remotePath) => {
const app = (globalThis as any).app;
const core = app.plugins.plugins["obsidian-livesync"].core;
const settings = core.services.setting.currentSettings();
return {
reloaded: (globalThis as any).__p2pFetchInterrupted === undefined,
remoteInVault: await app.vault.adapter.exists(remotePath),
markdown: await app.vault.adapter.read("flag_fetch.md"),
suspendFileWatching: settings.suspendFileWatching,
suspendParseReplicationResult: settings.suspendParseReplicationResult,
};
}, remotePath);
assertEqual(beforeConfirmation.reloaded, true, "Obsidian did not reload before the recovery confirmation.");
assertEqual(beforeConfirmation.remoteInVault, false, "The source note was reflected before confirmation.");
assertEqual(beforeConfirmation.markdown, pending.markdown, "Restart changed the retained P2P choices.");
assertEqual(beforeConfirmation.suspendFileWatching, true, "File watching resumed before confirmation.");
assertEqual(
beforeConfirmation.suspendParseReplicationResult,
true,
"Database reflection resumed before confirmation."
);
});
screenshots.push(await captureGuideDialogue(portB, "p2p-fetch-resume-confirmation.png", "Resume scheduled fetch?"));
await withObsidianPage(portB, async (page) => {
await modalByTitle(page, "Resume scheduled fetch?")
.getByRole("button", { name: "Continue with these choices", exact: true })
.click({ timeout: uiTimeoutMs });
});
await fetchFromFirstPeer(sessionA, portA, portB, screenshots, "p2p-fetch-resumed-select-source.png");
await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv);
await waitForPathContent(vaultB, remotePath, remoteContent);
await waitForPathContent(vaultB, localPath, localContent);
await waitForPathContent(vaultA, remotePath, remoteContent);
const completed = await withObsidianPage(
portB,
async (page) =>
await page.evaluate(async () => {
const app = (globalThis as any).app;
const settings = app.plugins.plugins["obsidian-livesync"].core.services.setting.currentSettings();
return {
flags: await Promise.all(
["flag_fetch.md", "redflag3.md"].map((path) => app.vault.adapter.exists(path))
),
suspendFileWatching: settings.suspendFileWatching,
suspendParseReplicationResult: settings.suspendParseReplicationResult,
};
})
);
assertEqual(completed.flags.some(Boolean), false, "The resumed P2P Fetch retained a fetch flag.");
assertEqual(completed.suspendFileWatching, false, "File watching remained suspended after P2P recovery.");
assertEqual(
completed.suspendParseReplicationResult,
false,
"Database reflection remained suspended after P2P recovery."
);
const sourceHasLocalNote = await withObsidianPage(
portA,
async (page) =>
await page.evaluate(async (path) => (globalThis as any).app.vault.adapter.exists(path), localPath)
);
assertEqual(sourceHasLocalNote, false, "The recovery note was not local-only during the Fetch.");
console.log(
"P2P_FETCH_RESTART " +
JSON.stringify({
realReceiveBeforeInterruption: true,
promptedAfterReload: true,
remoteContentPreserved: true,
localOnlyContentPreserved: true,
flagsRemoved: true,
reflectionResumed: true,
})
);
}
async function replicateFromStatusPane(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const heading = page.getByRole("heading", { name: "Detected Peers" }).last();
@@ -539,6 +720,8 @@ async function main(): Promise<void> {
);
console.log("P2P workflow: initial Fetch from the first device completed.");
await verifyInterruptedFetchRestart(context, sessionA, sessionB, vaultA, vaultB, screenshots);
await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent);
await reconnectP2PStatus(portA);
await reconnectP2PStatus(portB);
+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();
+231 -4
View File
@@ -192,6 +192,7 @@ async function startUnconfiguredSession(
binary: context.binary,
cliBinary: context.cliBinary,
vault,
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
});
context.activeSessions.add(session);
@@ -210,11 +211,29 @@ async function stopTrackedSessions(context: RunnerContext): Promise<void> {
}
}
async function enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise<void> {
async function enterSetupURI(
port: number,
mode: "new" | "existing",
artifact: SetupArtifact,
repeat = false
): Promise<void> {
await withObsidianPage(port, async (page) => {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
if (repeat) {
const navigator = await openLiveSyncSettings(page, uiTimeoutMs);
const panel =
navigator.renderer === "declarative"
? (await navigator.returnToCatalogue(), navigator.dialogue)
: await navigator.openPage("Quick Setup");
const rerun = panel.locator(".setting-item").filter({
has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }),
});
if (navigator.renderer === "declarative") await rerun.click({ timeout: uiTimeoutMs });
else await rerun.getByRole("button", { name: "Rerun Wizard", exact: true }).click({ timeout: uiTimeoutMs });
} else {
const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" });
await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs });
}
const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync");
await intro.waitFor({ state: "visible", timeout: uiTimeoutMs });
@@ -669,6 +688,154 @@ async function captureFailure(session: ObsidianLiveSyncSession): Promise<void> {
).catch(() => undefined);
}
async function assertFetchFlagsAbsent(port: number): Promise<void> {
const flags = await withObsidianPage(
port,
async (page) =>
await page.evaluate(async () => {
const app = (globalThis as any).app;
return await Promise.all(
["flag_fetch.md", "redflag3.md"].map((path) => app.vault.adapter.exists(path))
);
})
);
assertEqual(flags.some(Boolean), false, "A completed Fetch retained a fetch flag.");
}
async function verifyInterruptedFetchRestart(
context: RunnerContext,
session: ObsidianLiveSyncSession,
vault: TemporaryVault,
artifact: SetupArtifact,
unreadable = false
): Promise<void> {
const port = session.remoteDebuggingPort;
await enterSetupURI(port, "existing", artifact, true);
await captureAndStartInitialisation(port, "existing");
await withObsidianPage(port, async (page) => {
await modalByTitle(page, "Data retrieval scheduled").waitFor({ state: "visible", timeout: uiTimeoutMs });
await page.evaluate(() => {
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
core.rebuilder.$fetchLocalDBFast = async () => {
await core.rebuilder.resetLocalDatabase();
(globalThis as any).__fetchChoiceInterrupted = true;
throw new Error("E2E: interrupted after resetting the local database");
};
});
});
await confirmFastFetch(port);
const pending = await withObsidianPage(port, async (page) => {
await page.waitForFunction(
() => {
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
const settings = core.services.setting.currentSettings();
return (
(globalThis as any).__fetchChoiceInterrupted === true &&
settings.suspendFileWatching &&
settings.suspendParseReplicationResult
);
},
undefined,
{ timeout: uiTimeoutMs }
);
return await page.evaluate(async () => {
const app = (globalThis as any).app;
const core = app.plugins.plugins["obsidian-livesync"].core;
return {
markdown: await app.vault.adapter.read("flag_fetch.md"),
suffix: core.services.setting.currentSettings().additionalSuffixOfDatabaseName,
appId: core.services.API.getAppID(),
};
});
});
assertEqual(pending.suffix, pending.appId, "The interrupted Fetch did not change the database suffix.");
const block = /```json\s*\n([\s\S]*?)\n```/.exec(pending.markdown);
if (!block) throw new Error("The pending Fetch has no editable JSON code block.");
const selection = JSON.parse(block[1]);
assertEqual(selection.type, "livesync-fast-setup", "Unexpected saved Fetch state.");
assertEqual(selection.stage1, "Overwrite all with remote files", "The first Fetch choice was not retained.");
assertEqual(
selection.stage2,
"Keep local files even if not on remote",
"The second Fetch choice was not retained."
);
const localPath = `E2E/setup-uri/retained-during-recovery${unreadable ? "-unreadable" : ""}.md`;
const localContent = "# Local recovery note\n\nRetain this local-only file after restart.\n";
await withObsidianPage(port, async (page) => {
await page.evaluate(
async ({ localPath, localContent, unreadable }) => {
const app = (globalThis as any).app;
const flag = app.vault.getAbstractFileByPath("flag_fetch.md");
const markdown = await app.vault.read(flag);
await app.vault.modify(
flag,
"# Recovery notes\n\nEdited in Obsidian before restarting.\n\n" +
(unreadable ? "<!-- unfinished comment\n" : "") +
markdown
);
await app.vault.create(localPath, localContent);
await app.workspace.openLinkText("flag_fetch.md", "", false);
},
{ localPath, localContent, unreadable }
);
});
await captureObsidianPage(port, "issue-1244-editable-fetch-flag.png", async () => undefined);
await withObsidianPage(port, async (page) => {
await page.evaluate(() =>
(globalThis as any).app.plugins.plugins["obsidian-livesync"].core.services.appLifecycle.performRestart()
);
});
const title = unreadable ? "Saved fetch choices could not be read" : "Resume scheduled fetch?";
await withObsidianPage(port, async (page) => {
const modal = modalByTitle(page, title);
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
const message = await modal.textContent();
if (!message?.includes("Current remote:") || !message.includes(context.dbName)) {
throw new Error("The resume dialogue did not identify the current remote.");
}
if (!unreadable && (!message.includes(selection.stage1) || !message.includes(selection.stage2))) {
throw new Error("The resume dialogue did not display both saved choices.");
}
if (unreadable) {
await page.evaluate(async () => {
const app = (globalThis as any).app;
const core = app.plugins.plugins["obsidian-livesync"].core;
const originalMarkdown = await app.vault.adapter.read("flag_fetch.md");
const fetch = core.rebuilder.$fetchLocalDBFast.bind(core.rebuilder);
core.rebuilder.$fetchLocalDBFast = async (...args: unknown[]) => {
if ((await app.vault.adapter.read("flag_fetch.md")) !== originalMarkdown) {
throw new Error("Choosing new options rewrote the unreadable fetch flag.");
}
return await fetch(...args);
};
});
}
});
await captureObsidianPage(
port,
unreadable ? "issue-1244-unreadable-confirmation.png" : "issue-1244-resume-confirmation.png",
async () => undefined
);
await withObsidianPage(port, async (page) => {
await modalByTitle(page, title)
.getByRole("button", {
name: unreadable ? "Choose new options and continue" : "Continue with these choices",
exact: true,
})
.click({ timeout: uiTimeoutMs });
});
if (unreadable) await confirmFastFetch(port);
await finishInitialisation(port, "issue-1244-resumed.png", context.cliBinary, session.cliEnv);
await assertFetchFlagsAbsent(port);
await waitForPathContent(vault.path, notePath, noteContent);
await waitForPathContent(vault.path, localPath, localContent);
console.log(
"ISSUE_1244_RESTART " +
JSON.stringify({ prompted: true, unreadable, localOnlyFilePreserved: true, flagRemoved: true })
);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
@@ -768,6 +935,66 @@ async function main(): Promise<void> {
const secondState = secondCompletion.state;
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile.");
if (process.env.E2E_OBSIDIAN_ISSUE_1244 === "true") {
const observations: Array<{ attempt: number; prompted: boolean; state: unknown }> = [];
const record = async (attempt: number, prompted: boolean) => {
const state = await withObsidianPage(
session.remoteDebuggingPort,
async (page) =>
await page.evaluate(() => {
const core = (globalThis as any).app.plugins.plugins["obsidian-livesync"].core;
return {
suffix: core.services.setting.currentSettings().additionalSuffixOfDatabaseName,
appId: core.services.API.getAppID(),
savedChoices: Object.keys(localStorage)
.filter((key) => key.endsWith("-simple-fetch-mode"))
.map((key) => ({ key, value: localStorage.getItem(key) })),
};
})
);
await assertFetchFlagsAbsent(session.remoteDebuggingPort);
observations.push({ attempt, prompted, state });
console.log(`ISSUE_1244 ${JSON.stringify(observations[observations.length - 1])}`);
};
await waitForPathContent(vaultB.path, notePath, noteContent);
await record(1, true);
for (const attempt of [2, 3]) {
await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, true);
await captureAndStartInitialisation(session.remoteDebuggingPort, "existing");
const prompted = await withObsidianPage(
session.remoteDebuggingPort,
async (page) =>
await modalByTitle(page, "Data retrieval scheduled")
.waitFor({ state: "visible", timeout: 15000 })
.then(
() => true,
() => false
)
);
if (prompted) await confirmFastFetch(session.remoteDebuggingPort);
await finishInitialisation(
session.remoteDebuggingPort,
`issue-1244-join-${attempt}.png`,
context.cliBinary,
session.cliEnv
);
await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort);
await waitForPathContent(vaultB.path, notePath, noteContent);
await captureObsidianPage(
session.remoteDebuggingPort,
`issue-1244-after-join-${attempt}.png`,
async () => undefined
);
await record(attempt, prompted);
}
assertEqual(
observations.every((observation) => observation.prompted),
true,
`Every completed Join must offer fetch choices on the next Join: ${JSON.stringify(observations)}`
);
await verifyInterruptedFetchRestart(context, session, vaultB, secondDeviceArtifact);
await verifyInterruptedFetchRestart(context, session, vaultB, secondDeviceArtifact, true);
}
await enableHiddenFileSync(context.cliBinary, session.cliEnv);
await pushLocalChanges(context.cliBinary, session.cliEnv);
await scanHiddenDatabase(context.cliBinary, session.cliEnv);