mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
test: verify P2P progress and native cleanup in Obsidian
Check diagnostic disclosure, responsive peer controls, and current-connection document counters after normal bidirectional synchronisation. Verify that the native API cleanup callback runs once through normal and failed unload. Retain the existing Fetch, recovery, data-preservation, and reload checks, and describe the native scenarios in the E2E guide.
This commit is contained in:
1 parent
b64568aff4
commit
a5d3769cc1
4 files changed
+156
-3
No files matched your search
@@ -162,7 +162,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe
|
||||
|
||||
The Harness also measures ID generation with fixed in-memory data on desktop and mobile displays, checks that live settings remain unchanged, and verifies that the copied report includes both per-1,000-ID and per-ID timings, key derivation, and JavaScript heap availability. Mobile test mode verifies the UI and execution path; measure native device performance by running the same Harness on that device.
|
||||
|
||||
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
|
||||
`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile emulation. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile-emulation screenshots. The mobile-emulation session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state, and measures the rendered Refresh touch target at 44 CSS pixels or larger with the default application font. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions.
|
||||
|
||||
`test:e2e:obsidian:configuration-encryption-sync` requires only the CouchDB fixture configured through `.test.env` or the process environment. It changes the configuration protection key through the real settings interface, restarts the source Vault, and sends a note with E2EE enabled. A fresh Vault receives the encrypted settings without a saved device key, cancels and retries recovery, and restarts with the validated key. Both Vaults then exchange exact note bytes through CouchDB. The scenario checks the restored connection credentials, E2EE passphrase, ID key, remote Metadata, and Chunks, and removes its temporary Vaults, profiles, and unique remote database.
|
||||
|
||||
@@ -238,7 +238,7 @@ Set `E2E_OBSIDIAN_CUSTOM_HTTP_HANDLER=true` when the local Object Storage fixtur
|
||||
|
||||
`test:e2e:obsidian:object-storage-compatible-setup-uri-workflow` selects **Compatible (no time limit)** in the real generation dialogue and uses Persistent mode for the bootstrap tool. All three Object Storage sharing scenarios require the generated independent ID key to survive import and natural restarts on both devices, remain encrypted in local settings, and retain document and Chunk IDs during the bidirectional transfer. Before valid setup, they submit an incorrect passphrase and a URI generated in a past window, require the visible rejection, and verify unchanged runtime and persisted settings. Only an isolated fixture worker uses the past clock; Obsidian and the runner use real time.
|
||||
|
||||
`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.
|
||||
`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, and checks the live peer pane: Stats remains visible, Diagnostic summary starts collapsed and can be expanded to show Relay, Peer, and Sync observations plus Open log, then can be collapsed again. It measures the peer-action buttons at 44 CSS pixels or larger and checks that they fit the peer item and viewport, while retaining the menu check for the three persistent per-peer actions. After reconnecting both P2P sessions in join order, it verifies the B-to-A return journey through the normal bidirectional synchronisation action and checks numeric ↑ and ↓ document counters on that current connection. Zero-document comparisons are valid; the checks do not require counters to survive a reconnect. 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.
|
||||
|
||||
@@ -274,7 +274,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two current versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. Its unload check requires the queue to report cancellation, closes active and waiting dialogues, and compares Vault contents, conflict leaves, and revision ancestry after reload.
|
||||
|
||||
`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. It verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service.
|
||||
`test:e2e:obsidian:log-lifecycle` delays the local database close and tests both successful and failed shutdown. Through the actual core API, it registers one marker cleanup callback and verifies that it runs exactly once after plug-in disable completes. It also verifies that logs remain available during shutdown, that later logs go to the console after display subscriptions are released, and that logs from the previous instance do not reach the reloaded instance. It uses an isolated Vault and profile without a remote service.
|
||||
|
||||
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two current leaf revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected current branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type FixtureLog = {
|
||||
|
||||
type ShutdownObservation = {
|
||||
closeCalls: number;
|
||||
cleanupCalls: number;
|
||||
disposeCalls: number;
|
||||
disposeCompleted: boolean;
|
||||
fixtureLogs: FixtureLog[];
|
||||
@@ -106,6 +107,7 @@ function createShutdownScript(options: {
|
||||
duringMarker: string;
|
||||
failureMarker: string;
|
||||
afterMarker: string;
|
||||
cleanupMarker: string;
|
||||
reloadMarker: string;
|
||||
oldReloadMarker: string;
|
||||
injectFailure: boolean;
|
||||
@@ -133,6 +135,7 @@ function createShutdownScript(options: {
|
||||
consoleLogs:[],
|
||||
uiAddLogCalls:0,
|
||||
events:[],
|
||||
cleanupCalls:0,
|
||||
closeStarted:false,
|
||||
closeCalls:0,
|
||||
disposeCalls:0,
|
||||
@@ -141,6 +144,10 @@ function createShutdownScript(options: {
|
||||
disableError:"",
|
||||
};
|
||||
app.__livesyncLogLifecycleState=state;
|
||||
api.registerCleanup(()=>{
|
||||
state.cleanupCalls++;
|
||||
state.events.push("cleanup:"+fixtures.cleanupMarker);
|
||||
});
|
||||
|
||||
const originalAddLog=module.__addLog;
|
||||
const originalConsoleLog=console.log;
|
||||
@@ -307,8 +314,13 @@ function createShutdownScript(options: {
|
||||
"The disposed ModuleLog UI handler received a post-disposal log");
|
||||
await state.disablePromise;
|
||||
require(!state.disableError,"Disabling the plug-in failed: "+state.disableError);
|
||||
require(state.cleanupCalls===1,
|
||||
"The registered API cleanup callback did not run exactly once after plug-in disable completed");
|
||||
require(state.events.filter(event=>event==="cleanup:"+fixtures.cleanupMarker).length===1,
|
||||
"The registered API cleanup marker was not recorded exactly once");
|
||||
return JSON.stringify({
|
||||
closeCalls:state.closeCalls,
|
||||
cleanupCalls:state.cleanupCalls,
|
||||
disposeCalls:state.disposeCalls,
|
||||
disposeCompleted:state.disposeCompleted,
|
||||
fixtureLogs:state.fixtureLogs,
|
||||
@@ -353,6 +365,7 @@ async function runCase(options: {
|
||||
const markers = {
|
||||
duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`,
|
||||
afterMarker: "log-lifecycle-" + options.caseName + "-after-disposal-" + randomUUID(),
|
||||
cleanupMarker: `log-lifecycle-${options.caseName}-api-cleanup-${randomUUID()}`,
|
||||
failureMarker: options.injectFailure
|
||||
? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}`
|
||||
: "",
|
||||
|
||||
@@ -180,6 +180,23 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise<s
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const refresh = pane.getByRole("button", { name: "Refresh", exact: true });
|
||||
await refresh.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (mobile) {
|
||||
const touchTarget = await refresh.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
rootFontSize: getComputedStyle(document.documentElement).fontSize,
|
||||
};
|
||||
});
|
||||
if (touchTarget.width < 44 || touchTarget.height < 44) {
|
||||
throw new Error(
|
||||
`The mobile P2P Refresh touch target was smaller than 44 CSS pixels at the default font: ${JSON.stringify(touchTarget)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
await pane.getByRole("button", { name: "Open connection" }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { connect } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { Locator, Page } from "playwright";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts";
|
||||
@@ -602,6 +603,14 @@ async function waitForDetectedPeer(port: number): Promise<void> {
|
||||
|
||||
async function capturePeerActionsMenu(port: number): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const detectedPeers = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await detectedPeers.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = detectedPeers.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
await assertP2PDiagnosticSummary(pane);
|
||||
await assertPeerActionButtonsFit(pane);
|
||||
|
||||
const moreActions = page.getByRole("button", { name: /^More actions for /u }).first();
|
||||
await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await moreActions.click({ timeout: uiTimeoutMs });
|
||||
@@ -641,6 +650,117 @@ async function capturePeerActionsMenu(port: number): Promise<string> {
|
||||
return screenshot;
|
||||
}
|
||||
|
||||
async function assertP2PDiagnosticSummary(pane: Locator): Promise<void> {
|
||||
const stats = pane.getByRole("heading", { name: "Stats", exact: true });
|
||||
await stats.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const diagnosticDetails = pane.locator("details.diag-details");
|
||||
await diagnosticDetails.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (await diagnosticDetails.evaluate((element) => (element as HTMLDetailsElement).open)) {
|
||||
throw new Error("The P2P diagnostic summary was open before the user expanded it.");
|
||||
}
|
||||
const diagnosticSummary = diagnosticDetails.locator("summary");
|
||||
await diagnosticSummary.click({ timeout: uiTimeoutMs });
|
||||
const diagnosticRows = diagnosticDetails.locator(".diag-summary > div");
|
||||
await diagnosticRows.first().waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const rowText = await diagnosticRows.allInnerTexts();
|
||||
for (const label of ["Relay:", "Peer:", "Sync:"]) {
|
||||
if (!rowText.some((text) => text.trim().startsWith(label))) {
|
||||
throw new Error(`The expanded P2P diagnostics did not show the ${label} observation.`);
|
||||
}
|
||||
}
|
||||
const openLog = diagnosticDetails.getByRole("button", { name: "Open log", exact: true });
|
||||
await openLog.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
if (!(await stats.isVisible())) {
|
||||
throw new Error("The P2P Stats section was hidden while diagnostic observations were expanded.");
|
||||
}
|
||||
await diagnosticSummary.click({ timeout: uiTimeoutMs });
|
||||
await diagnosticRows.first().waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
if (await diagnosticDetails.evaluate((element) => (element as HTMLDetailsElement).open)) {
|
||||
throw new Error("The P2P diagnostic summary did not collapse after it was toggled again.");
|
||||
}
|
||||
if (!(await stats.isVisible())) {
|
||||
throw new Error("The P2P Stats section was hidden after collapsing diagnostic observations.");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPeerActionButtonsFit(pane: Locator): Promise<void> {
|
||||
const peerItem = pane.locator(".peers-section .peer-item").first();
|
||||
await peerItem.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await peerItem.scrollIntoViewIfNeeded();
|
||||
const actionButtons = await peerItem.evaluate((element) => {
|
||||
const item = element.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: document.documentElement.clientWidth,
|
||||
height: document.documentElement.clientHeight,
|
||||
};
|
||||
return {
|
||||
hasHorizontalOverflow: element.scrollWidth > element.clientWidth,
|
||||
buttons: Array.from(element.querySelectorAll("button"))
|
||||
.filter((button) => button.getClientRects().length > 0)
|
||||
.map((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return {
|
||||
label: button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "",
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
insidePeer:
|
||||
rect.left >= item.left &&
|
||||
rect.top >= item.top &&
|
||||
rect.right <= item.right &&
|
||||
rect.bottom <= item.bottom,
|
||||
insideViewport:
|
||||
rect.left >= 0 &&
|
||||
rect.top >= 0 &&
|
||||
rect.right <= viewport.width &&
|
||||
rect.bottom <= viewport.height,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
if (
|
||||
actionButtons.buttons.length === 0 ||
|
||||
actionButtons.hasHorizontalOverflow ||
|
||||
actionButtons.buttons.some(
|
||||
(button) =>
|
||||
button.width < 44 ||
|
||||
button.height < 44 ||
|
||||
!button.insidePeer ||
|
||||
!button.insideViewport
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`P2P peer action buttons were too small or did not fit their peer item and viewport: ${JSON.stringify(actionButtons)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function assertPeerDocumentProgress(page: Page, directions: readonly ("↑" | "↓")[]): Promise<void> {
|
||||
const detectedPeers = page.getByRole("heading", { name: "Detected Peers" }).last();
|
||||
await detectedPeers.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const pane = detectedPeers.locator(
|
||||
"xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]"
|
||||
);
|
||||
const progress = pane.locator(".peers-section .peer-item .peer-progress").first();
|
||||
await progress.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const deadline = Date.now() + uiTimeoutMs;
|
||||
let message = "";
|
||||
while (Date.now() < deadline) {
|
||||
message = (await progress.innerText()).replace(/\s+/gu, " ").trim();
|
||||
const documentProgress = message.replace(/⏩[↑↓]\s+\d+/gu, "");
|
||||
if (
|
||||
directions.every((direction) =>
|
||||
new RegExp(`${direction}\\s+\\d+(?:\\s|$)`, "u").test(documentProgress)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
throw new Error(
|
||||
`Completed P2P transfer progress did not show document counters for ${directions.join(" and ")}: ${message}`
|
||||
);
|
||||
}
|
||||
|
||||
async function captureNote(port: number, path: string, text: string, filename: string): Promise<string> {
|
||||
await withObsidianPage(port, async (page) => {
|
||||
await page.evaluate((notePath) => {
|
||||
@@ -740,6 +860,9 @@ async function main(): Promise<void> {
|
||||
console.log("P2P workflow: return replication requested; waiting for the second device's note.");
|
||||
await waitForPathContent(vaultA, noteFromSecond, secondContent);
|
||||
console.log("P2P workflow: return note reached the first device.");
|
||||
await withObsidianPage(portA, async (page) => {
|
||||
await assertPeerDocumentProgress(page, ["↑", "↓"]);
|
||||
});
|
||||
} finally {
|
||||
returnJourneyFinished = true;
|
||||
await returnJourneyAcceptor;
|
||||
|
||||
Reference in new issue
Block a user