mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-31 13:28:18 +00:00
Merge main into the SyncGuard feature branch
This commit is contained in:
commit
58ddefa031
25 files changed
+3613
-1031
No files matched your search
@@ -88,7 +88,11 @@ npm run test:e2e:obsidian:focused -- replication-status
|
||||
|
||||
The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite.
|
||||
|
||||
`replication-status` uses an isolated Vault without a remote service. It injects synthetic replication statistics into the real plug-in to check the ⏩ icon, the comparison count and its three-second expiry, independently styled numeric items, stable rendered digit widths, computed right alignment, and unchanged DOM nodes for repeated values. It also feeds the existing queue sources and checks their values in both displays, the separate P2P line, file warnings and their compact icon, and the corresponding plain-text status. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. A separate session enters mobile emulation before loading the plug-in and measures the editor item gaps at a 390px viewport width. The desktop checks also ensure that the status bar keeps its compact spacing. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. It switches between split panes, opens a popout editor, checks log updates there, and returns to the main window. Where Electron exposes native window control, it also hides the main window and verifies that popout logs still update before restoring it. This checks display behaviour; Commonlib owns the tests which establish that PouchDB produces the underlying statistics.
|
||||
`replication-status` uses isolated Vaults without a remote service. It opens the fixture editor before loading the plug-in, then checks the resulting overlay and subsequent leaf transitions. It injects synthetic replication statistics into real Obsidian to check the ⏩ icon and the push and pull comparison counts. The comparison cases cover a push-only count while the `sent` total is zero, independent three-second expiry when both directions are active, a zeroed state before a fresh 0-to-1 count, restoration of the ordinary Sync icon when CouchDB is idle, and error-icon priority. The plain-text status is checked for separate comparison and transfer counts, including its `(LIVE)` labels.
|
||||
|
||||
The scenario also checks independently styled numeric items, stable rendered digit widths, computed right alignment, and unchanged DOM nodes for repeated values. It feeds the existing queue sources and checks their values in both displays, the separate P2P line, file warnings and their compact icon, and the corresponding plain-text status. It verifies the inactive class, retained inactive content and width, and the ability to override the default hiding behaviour through CSS. The desktop checks ensure that the status bar keeps its compact spacing.
|
||||
|
||||
The scenario reads the animation CSS fence from [the status display guide](../../docs/tips/status-display.md) and checks it in desktop and mobile emulation: an inactive item collapses through an interpolated width, the same DOM node expands when reactivated, and reduced motion disables the transition. The fallback check substitutes a false `@supports` condition and confirms default hiding; this simulates feature unavailability and does not verify a browser which lacks `calc-size()` or Safari. A separate session enters mobile emulation before loading the plug-in and measures editor item gaps at a 390px viewport width. The mobile spacing fixture uses the existing overlay placement method before measurement; it does not verify Android window events. The scenario 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.
|
||||
|
||||
@@ -229,6 +233,10 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur
|
||||
|
||||
`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes.
|
||||
|
||||
On headless Linux, run this two-device scenario without an inherited display, for example `env -u DISPLAY -u WAYLAND_DISPLAY npm run test:e2e:obsidian:p2p-setup-uri-workflow`. The shared launcher then gives each Obsidian session its own Xvfb display. Wrapping both sessions in one outer `xvfb-run` can hide the source window, trigger its normal suspension lifecycle, and close the P2P connection before the other device fetches.
|
||||
|
||||
The P2P Setup URI workflow also schedules another Fetch and injects an exception after the real P2P receive finishes, before the Vault scan. It verifies that the received note remains unreflected and both reflection directions stay suspended, then reloads Obsidian and confirms the current P2P remote and saved choices. The resumed Fetch must retrieve the exact source content, preserve a local-only note created during the interruption, remove both fetch flags, and resume both reflection directions. This checks application reload after an injected failure; it does not simulate a network outage, operating-system termination, or power loss.
|
||||
|
||||
`test:e2e:obsidian:p2p-connection-check` owns the browser-to-Obsidian preflight path. It serves the WebPeer production build from loopback, asks the page to generate a disposable Setup URI using the local relay, starts its browser reference peer, and applies that exact URI through visible onboarding in an isolated empty real Obsidian Vault. After the first successful WebRTC diagnostic appears, it selects the action for another device in the same room, proves that the Setup URI was not regenerated, applies it to a second isolated empty real Obsidian Vault, and requires both the successful total and the baseline number of simultaneous active connections to advance. It captures the result card without Setup URI credentials and does not claim to verify note synchronisation. Run `test:e2e:obsidian:p2p-connection-check:services` to build both production artefacts and let the scenario start and stop the Compose relay.
|
||||
|
||||
`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan.
|
||||
|
||||
@@ -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