mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
feat: show P2P diagnostics in the status pane and log
Show expandable relay, peer, and sync observations below Stats and route Diag events to the existing Log Pane without notification or editor-status churn. Retain departed-peer evidence and replay logs recorded before the pane opens. Keep narrow panes usable with wrapping peer controls, readable device names, and larger buttons. Add browser regressions for disclosure, control bounds, button targets, and existing actions.
This commit is contained in:
1 parent
fc5f02ca3b
commit
cf527fbf86
22 files changed
+978
-88
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# P2P diagnostic UI checks
|
||||
|
||||
These checks use Node.js 24 or newer, the existing Playwright dependency, and a
|
||||
Chromium installation.
|
||||
The Log Pane fixture starts with a buffered diagnostic message and suppresses
|
||||
ordinary logger output, modelling a report opened while ordinary INFO messages
|
||||
are hidden. It checks initial display, later events, and unmounting. The status-card fixture
|
||||
also checks three summary rows at a narrow viewport and the existing Log Pane
|
||||
link. The full status-pane fixture exercises accepted, session-accepted, new,
|
||||
and denied peers at 320, 390, and 768 px, including long names. It checks bounds,
|
||||
complete action labels, and existing sync/admission callbacks. The card check
|
||||
also verifies initially collapsed diagnostic details, keyboard toggling, and
|
||||
updates received while collapsed. Host view, Menu, and SetupManager capabilities
|
||||
are stubbed; the Svelte components and shared log store are real, while P2P and
|
||||
Obsidian capabilities are fixtures. Add `?preview` to the status-pane URL for a
|
||||
shorter illustrative peer list.
|
||||
|
||||
From the repository root, start the fixture server:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node node_modules/vite/bin/vite.js --config test/p2p-diagnostics/vite.config.mjs
|
||||
```
|
||||
|
||||
In another terminal, run the focused browser check:
|
||||
|
||||
```sh
|
||||
NODE_OPTIONS=--max-old-space-size=3072 node --test --test-concurrency=1 test/p2p-diagnostics/log-pane.test.ts test/p2p-diagnostics/status-pane.test.ts
|
||||
```
|
||||
|
||||
The default endpoint is `http://127.0.0.1:5157`. Set `DIAG_UI_BASE_URL` when using
|
||||
another endpoint. Stop the fixture server after the check. This browser fixture
|
||||
does not replace verification in Obsidian.
|
||||
@@ -0,0 +1,3 @@
|
||||
// Host-only controls are not exercised by the browser layout fixture.
|
||||
export class Menu {}
|
||||
export class SetupManager {}
|
||||
@@ -0,0 +1 @@
|
||||
export const VIEW_TYPE_LOG = "log-log";
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
test("Log Pane shows diagnostics recorded before it opens and follows subsequent events", async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157";
|
||||
await page.goto(`${base}/test/p2p-diagnostics/log-pane/index.html`);
|
||||
await page.waitForSelector(".logpane");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".log")?.textContent?.includes("Earlier connection failure"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
await page.click("#append");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".log")?.textContent?.includes("Later connection recovery"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
assert.match((await page.locator(".log").textContent()) ?? "", /Earlier connection failure/);
|
||||
await page.click("#unmount");
|
||||
await page.waitForSelector(".logpane", { state: "detached" });
|
||||
await page.click("#append");
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("P2P Stats has three diagnostic rows and opens the existing Log Pane", async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157";
|
||||
await page.goto(`${base}/test/p2p-diagnostics/status-card/index.html`);
|
||||
await page.waitForSelector(".diag-details", { state: "attached", timeout: 3_000 });
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true, "Stats remains visible");
|
||||
assert.equal(await page.getByRole("button", { name: "Open log", exact: true }).isVisible(), false);
|
||||
await page.click("#complete");
|
||||
assert.equal(await page.locator(".diag-summary").isVisible(), false, "New events do not expand the log");
|
||||
await page.locator(".diag-details > summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".diag-summary");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
const rows = page.locator(".diag-summary > div");
|
||||
assert.equal(await rows.count(), 3);
|
||||
assert.match((await rows.nth(0).textContent()) ?? "", /Room opened; relay replies not observed/);
|
||||
assert.match((await rows.nth(1).textContent()) ?? "", /Peer left/);
|
||||
assert.match((await rows.nth(2).textContent()) ?? "", /Pull completed/);
|
||||
assert.equal(
|
||||
await page.locator(".diag-summary").evaluate((element) => getComputedStyle(element).fontSize),
|
||||
"12px"
|
||||
);
|
||||
if (process.env.DIAG_UI_SCREENSHOT) {
|
||||
await page.screenshot({ path: process.env.DIAG_UI_SCREENSHOT, fullPage: true });
|
||||
}
|
||||
await page.click("#complete");
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector(".diag-summary")?.textContent?.includes("Pull completed"),
|
||||
undefined,
|
||||
{ timeout: 3_000 }
|
||||
);
|
||||
await page.locator(".diag-details > summary").click();
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true);
|
||||
assert.equal(await page.locator(".diag-summary").isVisible(), false);
|
||||
await page.locator(".diag-details > summary").focus();
|
||||
await page.keyboard.press("Space");
|
||||
await page.getByRole("button", { name: "Open log", exact: true }).click();
|
||||
await page.waitForSelector(".logpane");
|
||||
assert.equal(await page.locator("#opened").textContent(), "log-log");
|
||||
assert.match((await page.locator(".log").textContent()) ?? "", /Earlier connection failure/);
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Log Pane diagnostic fixture</title></head>
|
||||
<body><button id="append">Append event</button><button id="unmount">Unmount</button><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { mount, unmount } from "svelte";
|
||||
import LogPane from "../../../src/modules/features/Log/LogPane.svelte";
|
||||
import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
|
||||
// Simulate ordinary INFO messages being hidden while a prior diagnostic is visible.
|
||||
setGlobalLogFunction(() => undefined);
|
||||
logMessages.value = ["[P2P Diag] Earlier connection failure"];
|
||||
const component = mount(LogPane, { target: document.querySelector("#app")!, props: { close: () => undefined } });
|
||||
document.querySelector<HTMLButtonElement>("#append")!.onclick = () => {
|
||||
logMessages.value = [...logMessages.value, "[P2P Diag] Later connection recovery"];
|
||||
};
|
||||
document.querySelector<HTMLButtonElement>("#unmount")!.onclick = () => {
|
||||
void unmount(component);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>P2P diagnostic summary fixture</title>
|
||||
<style>:root { --font-ui-small: 12px; --text-muted: #666; --size-4-1: 4px; --size-4-2: 8px; } body { margin: 8px; font: 14px sans-serif; }</style></head>
|
||||
<body><button id="complete">Complete pull</button><div id="opened"></div><div id="app"></div><div id="logapp"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mount } from "svelte";
|
||||
import Card from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusCard.svelte";
|
||||
import LogPane from "../../../src/modules/features/Log/LogPane.svelte";
|
||||
import { eventHub } from "../../../src/common/events";
|
||||
import { EVENT_SERVER_STATUS } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { logMessages } from "@vrtmrz/livesync-commonlib/compat/mock_and_interop/stores";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import type { P2PDiagnosticEvent } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
|
||||
setGlobalLogFunction(() => undefined);
|
||||
const timestamp = 1_700_000_000_000;
|
||||
const listeners = new Set<(event: P2PDiagnosticEvent) => void>();
|
||||
const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room" };
|
||||
const p2p = {
|
||||
transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined },
|
||||
changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} },
|
||||
diagnostics: {
|
||||
getDiagnosticSnapshot: () => ({
|
||||
relay: { category: "relay", code: "room-opened", reason: "relay-details-unobserved", timestamp },
|
||||
peer: { category: "peer", code: "peer-left", peerId: "departed-peer", timestamp },
|
||||
}),
|
||||
onDiagnosticEvent: (listener: (event: P2PDiagnosticEvent) => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
requestStatus: () =>
|
||||
eventHub.emitEvent(EVENT_SERVER_STATUS, {
|
||||
isConnected: true,
|
||||
knownAdvertisements: [],
|
||||
serverPeerId: "fixture-self",
|
||||
roomId: "fixture-room",
|
||||
diag: {
|
||||
totalNewConnections: 0,
|
||||
totalSuccessfulConnections: 0,
|
||||
totalFailedConnections: 0,
|
||||
totalClosedConnections: 0,
|
||||
details: {},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const core = {
|
||||
services: {
|
||||
setting: { currentSettings: () => settings },
|
||||
API: {
|
||||
showWindow: async (view: string) => {
|
||||
document.querySelector("#opened")!.textContent = view;
|
||||
logMessages.value = ["[P2P Diag] Earlier connection failure"];
|
||||
mount(LogPane, { target: document.querySelector("#logapp")!, props: { close() {} } });
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mount(Card, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } });
|
||||
document.querySelector<HTMLButtonElement>("#complete")!.onclick = () => {
|
||||
for (const listener of listeners)
|
||||
listener({ category: "sync", code: "pull-completed", timestamp: timestamp + 1_000 });
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
for (const width of [320, 390, 768]) {
|
||||
test(`P2P pane controls fit a ${width}px viewport`, async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width, height: 844 },
|
||||
colorScheme: width === 320 ? "dark" : "light",
|
||||
});
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const base = process.env.DIAG_UI_BASE_URL ?? "http://127.0.0.1:5157";
|
||||
await page.goto(`${base}/test/p2p-diagnostics/status-pane/index.html`);
|
||||
await page.waitForSelector(".peer-item");
|
||||
assert.deepEqual(errors, [], "Fixture must load without browser errors");
|
||||
const undersized = await page.locator(".peers-section button").evaluateAll((buttons) =>
|
||||
buttons
|
||||
.filter((button) => {
|
||||
const rect = button.getBoundingClientRect();
|
||||
return rect.width < 43.5 || rect.height < 43.5;
|
||||
})
|
||||
.map((button) => ({
|
||||
label: button.textContent?.trim(),
|
||||
width: button.getBoundingClientRect().width,
|
||||
height: button.getBoundingClientRect().height,
|
||||
}))
|
||||
);
|
||||
assert.deepEqual(undersized, [], "Peer buttons must have at least a 44px target at the default font size");
|
||||
const overflow = await page.locator(".peer-item").evaluateAll((items) =>
|
||||
items.flatMap((item, index) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const style = getComputedStyle(item);
|
||||
const left = rect.left + parseFloat(style.paddingLeft) + parseFloat(style.borderLeftWidth);
|
||||
const right = rect.right - parseFloat(style.paddingRight) - parseFloat(style.borderRightWidth);
|
||||
return Array.from(item.querySelectorAll("button")).flatMap((button) => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return bounds.left < left - 1 || bounds.right > right + 1
|
||||
? [
|
||||
{
|
||||
peer: index,
|
||||
button: button.textContent?.trim(),
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
container: [left, right],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
})
|
||||
);
|
||||
assert.deepEqual(overflow, [], "Every peer action must stay inside its card content");
|
||||
const clipped = await page
|
||||
.locator(".peer-actions .action-button")
|
||||
.evaluateAll((buttons) =>
|
||||
buttons
|
||||
.filter((button) => button.scrollWidth > button.clientWidth + 1)
|
||||
.map((button) => button.textContent?.trim())
|
||||
);
|
||||
assert.deepEqual(clipped, [], "Peer action labels must remain readable in full");
|
||||
assert.equal(
|
||||
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
|
||||
true,
|
||||
"Long names must not cause horizontal page overflow"
|
||||
);
|
||||
assert.equal(await page.locator(".diag-grid").isVisible(), true);
|
||||
assert.deepEqual(await page.locator(".diag-item > span:last-child").allTextContents(), [
|
||||
"4",
|
||||
"2",
|
||||
"1",
|
||||
"1",
|
||||
]);
|
||||
if (process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX) {
|
||||
await page.screenshot({
|
||||
path: `${process.env.DIAG_LAYOUT_SCREENSHOT_PREFIX}-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
await page.locator(".peer-item").nth(0).getByRole("button", { name: "Replicate now", exact: true }).click();
|
||||
assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), {
|
||||
sync: "permanent-peer",
|
||||
});
|
||||
await page
|
||||
.locator(".peer-item")
|
||||
.nth(2)
|
||||
.getByRole("button", { name: "Allow in session", exact: true })
|
||||
.click();
|
||||
assert.deepEqual(JSON.parse((await page.locator("#action").textContent()) ?? "null"), {
|
||||
peerId: "new-peer",
|
||||
name: "New device",
|
||||
decision: true,
|
||||
isTemporary: true,
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>P2P status pane layout fixture</title>
|
||||
<style>
|
||||
:root { --font-ui-small: 12px; --text-muted: #666; --text-normal: #222; --divider-color: #bbb; --size-4-1: 4px; --size-4-2: 8px; --background-secondary: #f4f4f4; --background-tertiary: #ddd; --interactive-normal: #eee; --interactive-hover: #ddd; --background-modifier-success: #bdb; --background-modifier-border: #ddd; --background-modifier-error: #ecc; }
|
||||
@media (prefers-color-scheme: dark) { :root { color-scheme: dark; --text-muted: #aaa; --text-normal: #ddd; --divider-color: #555; --background-secondary: #262626; --background-tertiary: #444; --interactive-normal: #333; --interactive-hover: #444; --background-modifier-success: #253f25; --background-modifier-border: #444; --background-modifier-error: #522; } }
|
||||
body { margin: 8px; font: 16px sans-serif; color: var(--text-normal); }
|
||||
#app { margin: 16px; }
|
||||
button, select { font: inherit; color: var(--text-normal); background: var(--interactive-normal); }
|
||||
button { padding: 4px 12px; white-space: nowrap; }
|
||||
</style></head>
|
||||
<body><div id="app"></div><output id="action"></output><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mount } from "svelte";
|
||||
import Pane from "../../../src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte";
|
||||
import { eventHub } from "../../../src/common/events";
|
||||
import {
|
||||
EVENT_SERVER_STATUS,
|
||||
EVENT_P2P_REPLICATOR_STATUS,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/TrysteroReplicatorP2PServer";
|
||||
import { setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
|
||||
setGlobalLogFunction(() => undefined);
|
||||
const settings = { P2P_useDiagRTC: true, P2P_roomID: "fixture-room", remoteConfigurations: {} };
|
||||
const knownAdvertisements = [
|
||||
{ peerId: "permanent-peer", name: "Phone", platform: "mobile", isAccepted: true },
|
||||
{
|
||||
peerId: "session-peer",
|
||||
name: "LongDeviceNameWithoutWordBreaks".repeat(4),
|
||||
platform: "desktop",
|
||||
isTemporaryAccepted: true,
|
||||
},
|
||||
{ peerId: "new-peer", name: "New device", platform: "mobile" },
|
||||
{ peerId: "denied-peer", name: "Denied device", platform: "mobile", isTemporaryAccepted: false },
|
||||
];
|
||||
if (new URLSearchParams(location.search).has("preview")) {
|
||||
knownAdvertisements.splice(1, 1);
|
||||
knownAdvertisements.splice(2, 1);
|
||||
knownAdvertisements[0].name = "Android phone";
|
||||
knownAdvertisements[1].name = "MacBook Pro (Living room)";
|
||||
}
|
||||
const record = (action: unknown) => {
|
||||
document.querySelector("#action")!.textContent = JSON.stringify(action);
|
||||
};
|
||||
const p2p = {
|
||||
transportLifecycle: { connect: async () => undefined, disconnect: async () => undefined },
|
||||
changeRelay: { enableBroadcastChanges() {}, disableBroadcastChanges() {} },
|
||||
peerAdmission: {
|
||||
makeDecision: async (decision: unknown) => record(decision),
|
||||
revokeDecision: async (decision: unknown) => record(decision),
|
||||
},
|
||||
targetedTransfer: { synchroniseWithPeer: async (peerId: string) => record({ sync: peerId }) },
|
||||
diagnostics: {
|
||||
getDiagnosticSnapshot: () => ({ peer: { category: "peer", code: "peer-left", timestamp: 1_700_000_000_000 } }),
|
||||
onDiagnosticEvent: () => () => undefined,
|
||||
requestStatus: () => {
|
||||
eventHub.emitEvent(EVENT_SERVER_STATUS, {
|
||||
isConnected: true,
|
||||
knownAdvertisements,
|
||||
serverPeerId: "fixture-self",
|
||||
roomId: "fixture-room",
|
||||
diag: {
|
||||
totalNewConnections: 4,
|
||||
totalSuccessfulConnections: 2,
|
||||
totalFailedConnections: 1,
|
||||
totalClosedConnections: 1,
|
||||
details: {},
|
||||
},
|
||||
} as never);
|
||||
eventHub.emitEvent(EVENT_P2P_REPLICATOR_STATUS, {
|
||||
replicatingFrom: [],
|
||||
replicatingTo: [],
|
||||
watchingPeers: [],
|
||||
isBroadcasting: false,
|
||||
} as never);
|
||||
},
|
||||
},
|
||||
};
|
||||
const core = { services: { setting: { currentSettings: () => settings }, API: { showWindow: async () => undefined } } };
|
||||
mount(Pane, { target: document.querySelector("#app")!, props: { p2p: p2p as never, core: core as never } });
|
||||
@@ -0,0 +1,21 @@
|
||||
import { mergeConfig } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import common from "../../vitest.config.common.ts";
|
||||
|
||||
const config = mergeConfig(common, {
|
||||
optimizeDeps: {
|
||||
entries: [
|
||||
"test/p2p-diagnostics/log-pane/index.html",
|
||||
"test/p2p-diagnostics/status-card/index.html",
|
||||
"test/p2p-diagnostics/status-pane/index.html",
|
||||
],
|
||||
},
|
||||
server: { host: "127.0.0.1", port: 5157, strictPort: true },
|
||||
});
|
||||
config.resolve.alias = {
|
||||
"@/modules/features/Log/LogPaneView": fileURLToPath(new URL("./log-pane-view.ts", import.meta.url)),
|
||||
"@/deps": fileURLToPath(new URL("./host-ui.ts", import.meta.url)),
|
||||
"@/modules/features/SetupManager": fileURLToPath(new URL("./host-ui.ts", import.meta.url)),
|
||||
...common.resolve.alias,
|
||||
};
|
||||
export default config;
|
||||
Reference in new issue
Block a user