mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-27 11:33:21 +00:00
Refine status item placement and inactive styling
This commit is contained in:
1 parent
68dc40ff05
commit
e7f7e62cfa
9 files changed
+345
-68
No files matched your search
@@ -88,7 +88,7 @@ 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, and unchanged DOM nodes for repeated values. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
|
||||
`replication-status` uses 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, and unchanged DOM nodes for repeated values. 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.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../r
|
||||
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);
|
||||
@@ -40,10 +41,12 @@ type TestWorkspace = {
|
||||
getActiveFile?: () => { path?: string } | null;
|
||||
};
|
||||
type TestLogModule = {
|
||||
adjustStatusDivPosition?: () => void;
|
||||
statusLog?: WritableSource<string>;
|
||||
statusDiv?: HTMLElement;
|
||||
statusLine?: HTMLElement;
|
||||
statusLineRenderer?: { render?: (parts: readonly unknown[]) => void };
|
||||
statusBarRenderer?: { render?: (parts: readonly unknown[]) => void };
|
||||
statusBarLabels?: WritableSource<{ parts: readonly unknown[] }>;
|
||||
__addLog?: (message: unknown, level?: number, key?: string) => void;
|
||||
};
|
||||
@@ -171,7 +174,7 @@ async function appendLiveSyncLog(page: Page, message: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function waitForPartText(page: Page, key: string, expected: string): Promise<void> {
|
||||
const selector = `.livesync-status-statusline .livesync-stat-${key} .livesync-stat-value`;
|
||||
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 },
|
||||
@@ -442,11 +445,152 @@ async function assertNumericWidths(
|
||||
return await measureNumericWidths(page);
|
||||
}
|
||||
|
||||
async function assertEditorItemSpacing(page: Page, mobile: boolean): Promise<void> {
|
||||
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<HTMLElement>(selector);
|
||||
if (!root || root.getBoundingClientRect().width === 0) continue;
|
||||
const firstItem = root.querySelector<HTMLElement>(`.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<HTMLElement>(
|
||||
`.livesync-stat-${parts[i - 1].key} .livesync-stat-value`
|
||||
);
|
||||
const next = root.querySelector<HTMLElement>(`.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<void> {
|
||||
const collapsedItems = await page.evaluate(() => {
|
||||
const items = document.querySelectorAll<HTMLElement>(".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<void> {
|
||||
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<HTMLElement>(".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 .livesync-stat-value";
|
||||
const selector = ".syncstatusbar .livesync-stat-network:not(.livesync-stat-inactive) .livesync-stat-value";
|
||||
await page.waitForFunction(
|
||||
({ selector, expectedValue }) => {
|
||||
const value = document.querySelector<HTMLElement>(selector);
|
||||
@@ -606,6 +750,13 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
|
||||
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.");
|
||||
|
||||
@@ -617,11 +768,13 @@ async function runStatusScenario(session: ObsidianLiveSyncSession): Promise<void
|
||||
if (remainingLinger > 0) await page.waitForTimeout(remainingLinger);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.querySelector<HTMLElement>(".livesync-status-statusline .livesync-stat-ff")?.hidden ===
|
||||
true,
|
||||
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("⏩"),
|
||||
@@ -735,32 +888,69 @@ async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
const vault = await createTemporaryVault("obsidian-livesync-replication-status-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary: cli.binary,
|
||||
vault,
|
||||
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.");
|
||||
await runStatusScenario(session);
|
||||
} finally {
|
||||
if (session) await session.app.stop();
|
||||
await vault.dispose();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user