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
@@ -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