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:
vorotamoroz committed 2026-11-07 01:54:49 +00:00
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()}`
: "",
+17
View File
@@ -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;