mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Merge main into shared runtime foundations
Keep SyncGuard startup dependencies isolated in unload logging tests.
This commit is contained in:
commit
bd05dde39c
83 files changed
+6313
-1507
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user