Add browser P2P connection check

This commit is contained in:
vorotamoroz
2026-07-31 14:13:34 +00:00
parent e32e3f545e
commit 48c398948a
20 changed files with 2710 additions and 8 deletions
@@ -0,0 +1,131 @@
import { decodeSettingsFromSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting";
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
import { describe, expect, it } from "vitest";
import {
P2P_CHECK_APP_ID,
generateP2PCheckSetup,
resolveLocalP2PCheckRelayOverride,
} from "@/apps/webpeer/src/P2PCheckSetup";
describe("P2P connection-check setup", () => {
it.each(["desktop", "mobile"] as const)(
"creates an isolated, disposable %s Setup URI and diagnostic browser peer",
async (target) => {
const generated = await generateP2PCheckSetup(target);
const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase);
expect(decoded).not.toBe(false);
if (decoded === false) {
throw new Error("The generated Setup URI could not be decoded");
}
const effective = { ...DEFAULT_SETTINGS, ...decoded };
expect(generated.target).toBe(target);
expect(generated.setupPassphrase).toMatch(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/);
expect(generated.setupURI).toMatch(/^obsidian:\/\/setuplivesync\?settings=/);
expect(effective).toEqual(
expect.objectContaining({
remoteType: REMOTE_P2P,
isConfigured: true,
encrypt: true,
usePathObfuscation: true,
P2P_Enabled: true,
P2P_AppID: P2P_CHECK_APP_ID,
P2P_roomID: generated.groupId,
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
})
);
expect(decoded.P2P_DevicePeerName).toBeUndefined();
expect(decoded.P2P_useDiagRTC).toBeUndefined();
expect(effective.passphrase).toHaveLength(32);
expect(effective.P2P_passphrase).toHaveLength(32);
expect(effective.passphrase).not.toBe(effective.P2P_passphrase);
expect(effective.P2P_AutoAccepting).toBe(DEFAULT_SETTINGS.P2P_AutoAccepting);
expect(effective.P2P_AutoSyncPeers).toBe("");
expect(effective.P2P_AutoWatchPeers).toBe("");
expect(effective.P2P_SyncOnReplication).toBe("");
const remoteConfigurations = Object.values(decoded.remoteConfigurations ?? {});
expect(remoteConfigurations).toHaveLength(1);
expect(decoded.activeConfigurationId).toBe(remoteConfigurations[0].id);
expect(decoded.P2P_ActiveRemoteConfigurationId).toBe(remoteConfigurations[0].id);
const deviceRemote = ConnectionStringParser.parse(remoteConfigurations[0].uri);
expect(deviceRemote).toEqual(
expect.objectContaining({
type: "p2p",
settings: expect.objectContaining({
P2P_AutoStart: true,
P2P_AutoBroadcast: false,
}),
})
);
expect("P2P_useDiagRTC" in deviceRemote.settings).toBe(false);
expect(generated.browserSettings).toEqual(
expect.objectContaining({
remoteType: REMOTE_P2P,
P2P_Enabled: true,
P2P_AppID: P2P_CHECK_APP_ID,
P2P_roomID: effective.P2P_roomID,
P2P_passphrase: effective.P2P_passphrase,
passphrase: effective.passphrase,
P2P_AutoStart: false,
P2P_AutoBroadcast: false,
P2P_useDiagRTC: true,
})
);
expect(generated.browserDeviceName).toMatch(/^p2p-check-browser-(?:desktop|mobile)-/);
const browserConfigurations = Object.values(generated.browserSettings.remoteConfigurations ?? {});
expect(browserConfigurations).toHaveLength(1);
const browserRemote = ConnectionStringParser.parse(browserConfigurations[0].uri);
expect(browserRemote).toEqual(
expect.objectContaining({
type: "p2p",
settings: expect.objectContaining({
P2P_AutoStart: false,
P2P_AutoBroadcast: false,
}),
})
);
expect("P2P_useDiagRTC" in browserRemote.settings).toBe(false);
}
);
it("creates independent rooms and secrets for separate checks", async () => {
const first = await generateP2PCheckSetup("desktop");
const second = await generateP2PCheckSetup("desktop");
expect(second.groupId).not.toBe(first.groupId);
expect(second.setupPassphrase).not.toBe(first.setupPassphrase);
expect(second.browserSettings.P2P_passphrase).not.toBe(first.browserSettings.P2P_passphrase);
expect(second.browserSettings.passphrase).not.toBe(first.browserSettings.passphrase);
});
it("uses the same explicitly selected relay for the Setup URI and browser peer", async () => {
const relay = "ws://127.0.0.1:4010/";
const generated = await generateP2PCheckSetup("desktop", { relay });
const decoded = await decodeSettingsFromSetupURI(generated.setupURI, generated.setupPassphrase);
expect(decoded).not.toBe(false);
if (decoded === false) {
throw new Error("The generated Setup URI could not be decoded");
}
expect(generated.relay).toBe(relay);
expect(decoded.P2P_relays).toBe(relay);
expect(generated.browserSettings.P2P_relays).toBe(relay);
});
it("accepts a relay query override only from a loopback-served check page", () => {
const search = "?relay=ws%3A%2F%2F127.0.0.1%3A4010%2F";
expect(resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search })).toBe("ws://127.0.0.1:4010/");
expect(resolveLocalP2PCheckRelayOverride({ hostname: "localhost", search })).toBe("ws://127.0.0.1:4010/");
expect(resolveLocalP2PCheckRelayOverride({ hostname: "example.com", search })).toBeUndefined();
expect(
resolveLocalP2PCheckRelayOverride({ hostname: "127.0.0.1", search: "?relay=https%3A%2F%2Fexample.com" })
).toBeUndefined();
});
});
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_P2P_CHECK_DIAGNOSTICS,
P2P_CHECK_OBSERVATION_MILLISECONDS,
captureP2PAdditionalCheckBaseline,
deriveP2PAdditionalCheckProgress,
deriveP2PCheckOutcome,
} from "@/apps/webpeer/src/P2PCheckState";
describe("P2P connection-check outcome", () => {
it("waits for a target after the browser monitor joins the room", () => {
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, true, 5_000)).toBe("waiting");
});
it("reports an active negotiation before any connection succeeds", () => {
expect(
deriveP2PCheckOutcome(
{
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 1,
details: {
"rtc-1": {
connectionState: "connecting",
iceConnectionState: "checking",
},
},
},
true,
10_000
)
).toBe("connecting");
});
it("keeps a failed attempt retryable until the observation period expires", () => {
const diagnostics = {
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 1,
totalFailedConnections: 1,
};
expect(deriveP2PCheckOutcome(diagnostics, true, 15_000)).toBe("retrying");
expect(deriveP2PCheckOutcome(diagnostics, true, P2P_CHECK_OBSERVATION_MILLISECONDS)).toBe("inconclusive");
});
it("lets a later success take precedence over failures and closure", () => {
expect(
deriveP2PCheckOutcome(
{
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 2,
totalFailedConnections: 1,
totalSuccessfulConnections: 1,
totalClosedConnections: 1,
},
true,
P2P_CHECK_OBSERVATION_MILLISECONDS * 2
)
).toBe("connected");
});
it("distinguishes an idle page and a monitor-start error", () => {
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0)).toBe("idle");
expect(deriveP2PCheckOutcome(EMPTY_P2P_CHECK_DIAGNOSTICS, false, 0, true)).toBe("error");
});
});
describe("same-room additional-device progress", () => {
const firstDeviceDiagnostics = {
...EMPTY_P2P_CHECK_DIAGNOSTICS,
totalNewConnections: 1,
totalSuccessfulConnections: 1,
details: {
"rtc-a": {
connectionState: "connected" as const,
iceConnectionState: "connected" as const,
},
},
};
it("captures the counter and currently active connection baseline", () => {
expect(captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics)).toEqual({
activeConnectionIds: ["rtc-a"],
totalClosedConnections: 0,
totalFailedConnections: 0,
totalNewConnections: 1,
totalSuccessfulConnections: 1,
});
});
it("does not mistake a reconnect from the first peer for an additional device", () => {
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
const progress = deriveP2PAdditionalCheckProgress(
{
...firstDeviceDiagnostics,
totalNewConnections: 2,
totalSuccessfulConnections: 2,
details: {
"rtc-a": {
connectionState: "closed",
iceConnectionState: "closed",
},
"rtc-b": {
connectionState: "connected",
iceConnectionState: "connected",
},
},
},
baseline,
10_000
);
expect(progress).toEqual({
activeConnections: 0,
closedConnections: 0,
failedConnections: 0,
newConnections: 1,
newActiveConnectionIds: ["rtc-b"],
outcome: "negotiating",
successfulConnections: 1,
});
});
it("requires both a new successful state and another simultaneous active connection", () => {
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
expect(
deriveP2PAdditionalCheckProgress(
{
...firstDeviceDiagnostics,
details: {
...firstDeviceDiagnostics.details,
"rtc-b": {
connectionState: "connected",
iceConnectionState: "connected",
},
},
},
baseline,
10_000
).outcome
).toBe("negotiating");
expect(
deriveP2PAdditionalCheckProgress(
{
...firstDeviceDiagnostics,
totalNewConnections: 2,
totalSuccessfulConnections: 2,
details: {
...firstDeviceDiagnostics.details,
"rtc-b": {
connectionState: "connected",
iceConnectionState: "connected",
},
},
},
baseline,
10_000
)
).toEqual({
activeConnections: 1,
closedConnections: 0,
failedConnections: 0,
newConnections: 1,
newActiveConnectionIds: ["rtc-b"],
outcome: "connected",
successfulConnections: 1,
});
});
it("marks an additional-device attempt inconclusive after its own observation period", () => {
const baseline = captureP2PAdditionalCheckBaseline(firstDeviceDiagnostics);
expect(
deriveP2PAdditionalCheckProgress(firstDeviceDiagnostics, baseline, P2P_CHECK_OBSERVATION_MILLISECONDS)
.outcome
).toBe("inconclusive");
});
});
+25 -1
View File
@@ -1,5 +1,5 @@
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SETTINGS, SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { describe, expect, it, vi } from "vitest";
@@ -73,4 +73,28 @@ describe("WebPeer runtime composition", () => {
await expect(runtime.start()).rejects.toThrow("WebPeer local database could not be opened");
});
it("isolates a specialised runtime and applies its device name before opening the database", async () => {
const runtime = new WebPeerRuntime({
store: createMemoryStore(),
deviceName: " p2p-check-browser-desktop-abc ",
systemVaultName: "p2p-check-vault",
});
vi.spyOn(runtime.services.setting, "loadSettings").mockResolvedValue(undefined);
vi.spyOn(runtime.services.setting, "currentSettings").mockReturnValue({
...DEFAULT_SETTINGS,
P2P_AutoStart: false,
P2P_Enabled: false,
});
const setSmallConfig = vi.spyOn(runtime.services.config, "setSmallConfig").mockImplementation(() => undefined);
const openDatabase = vi.spyOn(runtime.services.database, "openDatabase").mockImplementation(async () => {
expect(setSmallConfig).toHaveBeenCalledWith(SETTING_KEY_P2P_DEVICE_NAME, "p2p-check-browser-desktop-abc");
return true;
});
await runtime.start();
expect(runtime.services.API.getSystemVaultName()).toBe("p2p-check-vault");
expect(openDatabase).toHaveBeenCalledOnce();
});
});
@@ -21,6 +21,12 @@ Deno.test({
timeout: 30_000,
});
await page.getByText("No Connection", { exact: true }).waitFor();
assertEquals(
await page
.getByRole("link", { name: "Try the P2P connection check", exact: true })
.getAttribute("href"),
"./check.html"
);
await page.getByPlaceholder("anything-you-like").fill("browser-e2e-room");
await page.getByPlaceholder("iphone-16").fill("browser-e2e-peer");
@@ -44,10 +50,7 @@ Deno.test({
await page.getByText("Optional TURN server settings", { exact: true }).click();
assertEquals(await page.getByPlaceholder("turn:turn.example.com:3478").inputValue(), "turn:127.0.0.1:3478");
assertEquals(await page.getByPlaceholder("Enter TURN username").inputValue(), "browser-turn-user");
assertEquals(
await page.getByPlaceholder("Enter TURN credential").inputValue(),
"browser-turn-credential"
);
assertEquals(await page.getByPlaceholder("Enter TURN credential").inputValue(), "browser-turn-credential");
assertEquals(await page.getByRole("button", { name: "Connect", exact: true }).isVisible(), true);
assertNoPageFailures();
} finally {
@@ -56,3 +59,82 @@ Deno.test({
}
},
});
Deno.test({
name: "WebPeer: P2P connection check prepares a local Setup URI and zeroed diagnostics",
sanitizeOps: false,
sanitizeResources: false,
async fn() {
const server = await startStaticServer(webPeerDist);
const browser = await chromium.launch({ headless: true });
try {
for (const target of ["desktop", "mobile"] as const) {
const page = await browser.newPage();
const assertNoPageFailures = observePageFailures(page);
try {
await page.goto(`${server.baseUrl}check.html`);
await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({
timeout: 30_000,
});
if (target === "mobile") {
await page.locator('input[type="radio"][value="mobile"]').check();
}
await page.getByRole("button", { name: `Prepare ${target} check`, exact: true }).click();
await page.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true }).waitFor({
timeout: 30_000,
});
const setupURI = await page.getByLabel("Setup URI", { exact: true }).inputValue();
const passphrase = await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue();
const qrSource = await page
.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true })
.getAttribute("src");
assertEquals(setupURI.startsWith("obsidian://setuplivesync?settings="), true);
assertEquals(/^[a-z2-9]{4}(?:-[a-z2-9]{4}){3}$/.test(passphrase), true);
assertEquals(qrSource?.startsWith("data:image/"), true);
for (const label of ["Setup URI", "Setup URI passphrase"] as const) {
const credentialField = page.getByLabel(label, { exact: true });
assertEquals(await credentialField.getAttribute("autocomplete"), "off");
assertEquals(await credentialField.getAttribute("spellcheck"), "false");
}
assertEquals(await page.getByTestId("diag-new").textContent(), "0");
assertEquals(await page.getByTestId("diag-successful").textContent(), "0");
assertEquals(await page.getByTestId("diag-failed").textContent(), "0");
assertEquals(await page.getByTestId("diag-closed").textContent(), "0");
assertEquals(
await page.getByRole("button", { name: "Start connection monitor", exact: true }).isVisible(),
true
);
assertEquals(
await page
.getByRole("button", { name: "Try another device without resetting", exact: true })
.count(),
0
);
await page.locator(".results-card").scrollIntoViewIfNeeded();
await page.getByRole("button", { name: "Show the Setup QR again", exact: true }).click();
await waitFor(
async () =>
await page
.getByAltText(`Setup URI QR code for the ${target} check`, { exact: true })
.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= document.documentElement.clientHeight;
}),
`The ${target} Setup QR did not return to the viewport`
);
assertEquals(await page.getByLabel("Setup URI", { exact: true }).inputValue(), setupURI);
assertNoPageFailures();
} finally {
await page.close();
}
}
} finally {
await browser.close();
await server.close();
}
},
});
+4
View File
@@ -151,6 +151,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
`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-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.
`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets.
@@ -223,6 +225,8 @@ Useful environment variables:
- `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds.
- `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds.
- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds.
- `E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS`: timeout for the browser-to-Obsidian successful WebRTC diagnostic; default is 60 seconds.
- `E2E_P2P_CHECK_SCREENSHOT`: explicit path for the successful browser result screenshot; default is `p2p-connection-check-browser-success.png` under `E2E_OBSIDIAN_DIAGNOSTICS_DIR`.
- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`.
- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`.
- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port.
@@ -0,0 +1,406 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, stat } from "node:fs/promises";
import { createServer, type Server } from "node:http";
import { connect } from "node:net";
import { dirname, extname, relative, resolve } from "node:path";
import { chromium, type Browser, type ConsoleMessage, type Page } from "playwright";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import {
acknowledgeDisabledOptionalFeatures,
captureAndStartInitialisation,
confirmRebuild,
enterSetupURI,
finishInitialisation,
resumeCompatibilityReviewIfShown,
type SetupArtifact,
type SetupCaptureNames,
} from "../runner/setupUri.ts";
import { obsidianRemoteDebuggingPort } from "../runner/ui.ts";
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000";
const captures: SetupCaptureNames = { scenario: "p2p-connection-check", guide: "p2p-setup" };
const connectionTimeoutMs = Number(process.env.E2E_P2P_CHECK_CONNECTION_TIMEOUT_MS ?? 60000);
const webPeerDist = resolve(process.cwd(), "src/apps/webpeer/dist");
type StaticServer = {
readonly baseUrl: string;
close(): Promise<void>;
};
function contentType(path: string): string {
switch (extname(path)) {
case ".css":
return "text/css; charset=utf-8";
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "text/javascript; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
function closeServer(server: Server): Promise<void> {
return new Promise((resolveClose, reject) => {
server.close((error) => {
if (error) reject(error);
else resolveClose();
});
});
}
async function startStaticServer(): Promise<StaticServer> {
const distribution = await stat(webPeerDist).catch(() => undefined);
if (!distribution?.isDirectory()) {
throw new Error(
`WebPeer production bundle was not found at ${webPeerDist}. Build the webpeer workspace first.`
);
}
const server = createServer((request, response) => {
void (async () => {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
const requestedPath = decodeURIComponent(url.pathname).replace(/^\/+/, "") || "index.html";
const candidate = resolve(webPeerDist, requestedPath);
const relativePath = relative(webPeerDist, candidate);
if (relativePath.startsWith("..") || relativePath.includes("\0")) {
response.writeHead(404).end("Not found");
return;
}
try {
const candidateStat = await stat(candidate);
const filePath = candidateStat.isDirectory() ? resolve(candidate, "index.html") : candidate;
const body = await readFile(filePath);
response.writeHead(200, {
"cache-control": "no-store",
"content-type": contentType(filePath),
});
response.end(body);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
response.writeHead(404).end("Not found");
return;
}
throw error;
}
})().catch((error: unknown) => {
response.writeHead(500).end("Internal server error");
console.error(error instanceof Error ? error.stack : error);
});
});
await new Promise<void>((resolveListen, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolveListen();
});
});
const address = server.address();
if (!address || typeof address === "string") {
await closeServer(server);
throw new Error("The WebPeer static server did not expose a TCP port.");
}
return {
baseUrl: `http://127.0.0.1:${address.port}/`,
close: async () => await closeServer(server),
};
}
async function waitForRelay(relay: string): Promise<void> {
const endpoint = new URL(relay);
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
throw new Error(`P2P relay must use ws: or wss:, received ${endpoint.protocol}`);
}
const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80));
const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname;
const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000);
let lastError: unknown;
let consecutiveConnections = 0;
while (Date.now() < deadline) {
try {
await new Promise<void>((resolveConnection, reject) => {
const socket = connect({ host, port });
socket.setTimeout(1000);
socket.once("connect", () => {
socket.destroy();
resolveConnection();
});
socket.once("timeout", () => {
socket.destroy();
reject(new Error("connection timed out"));
});
socket.once("error", reject);
});
consecutiveConnections += 1;
if (consecutiveConnections >= 3) return;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
} catch (error) {
lastError = error;
consecutiveConnections = 0;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
}
}
throw new Error(
`P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : String(lastError)}`
);
}
async function readDiagnostics(page: Page): Promise<Record<string, string>> {
return {
new: (await page.getByTestId("diag-new").textContent())?.trim() ?? "",
successful: (await page.getByTestId("diag-successful").textContent())?.trim() ?? "",
failed: (await page.getByTestId("diag-failed").textContent())?.trim() ?? "",
closed: (await page.getByTestId("diag-closed").textContent())?.trim() ?? "",
};
}
async function waitForSuccessfulConnection(page: Page, consoleErrors: string[]): Promise<Record<string, string>> {
try {
await page.waitForFunction(
() => Number(document.querySelector('[data-testid="diag-successful"]')?.textContent ?? "0") > 0,
undefined,
{ timeout: connectionTimeoutMs }
);
} catch (error) {
throw new Error(
`${error instanceof Error ? error.message : String(error)}\n` +
`Browser diagnostics: ${JSON.stringify(await readDiagnostics(page))}\n` +
`Browser console errors: ${JSON.stringify(consoleErrors)}`
);
}
await page.getByRole("heading", { name: "P2P connection observed", exact: true }).waitFor({
timeout: 5000,
});
return await readDiagnostics(page);
}
function sessionPorts(): readonly [number, number] {
const first = obsidianRemoteDebuggingPort();
const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1);
if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) {
throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`);
}
return [first, second];
}
function sessionEnvironment(port: number): NodeJS.ProcessEnv {
return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) };
}
function npmBinary(): string {
return process.platform === "win32" ? "npm.cmd" : "npm";
}
function runNpmScript(script: string, optional = false): Promise<void> {
return new Promise((resolveRun, reject) => {
const child = spawn(npmBinary(), ["run", script], {
cwd: process.cwd(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (code === 0 || optional) {
resolveRun();
return;
}
reject(new Error(`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
});
});
}
async function runScenario(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) {
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
}
const relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`;
await waitForRelay(relay);
const server = await startStaticServer();
let browser: Browser | undefined;
const vaults: TemporaryVault[] = [];
const sessions: ObsidianLiveSyncSession[] = [];
const pageErrors: string[] = [];
const consoleErrors: string[] = [];
try {
browser = await chromium.launch({ headless: true });
const firstVault = await createTemporaryVault("obsidian-livesync-p2p-check-first-e2e-");
vaults.push(firstVault);
const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
page.on("pageerror", (error) => pageErrors.push(error.stack ?? error.message));
page.on("console", (message: ConsoleMessage) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
const checkUrl = new URL("check.html", server.baseUrl);
checkUrl.searchParams.set("relay", relay);
await page.goto(checkUrl.href);
await page.getByRole("heading", { name: "P2P connection check", exact: true }).waitFor({ timeout: 30000 });
await page.getByRole("button", { name: "Prepare desktop check", exact: true }).click();
await page.getByAltText("Setup URI QR code for the desktop check", { exact: true }).waitFor({
timeout: 30000,
});
const artifact: SetupArtifact = {
setupURI: await page.getByLabel("Setup URI", { exact: true }).inputValue(),
setupPassphrase: await page.getByLabel("Setup URI passphrase", { exact: true }).inputValue(),
};
if (!artifact.setupURI.startsWith("obsidian://setuplivesync?settings=")) {
throw new Error("The browser did not generate a Setup URI.");
}
await page.getByText(relay, { exact: true }).waitFor({ timeout: 5000 });
await page.getByRole("button", { name: "Start connection monitor", exact: true }).click();
await page.getByRole("button", { name: "Monitoring is active", exact: true }).waitFor({ timeout: 30000 });
const [firstPort, secondPort] = sessionPorts();
const firstSession = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault: firstVault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(firstPort),
});
sessions.push(firstSession);
await enterSetupURI(firstPort, "new", artifact, captures);
await captureAndStartInitialisation(firstPort, "new", captures);
await confirmRebuild(firstPort, captures);
await acknowledgeDisabledOptionalFeatures(firstPort, captures);
const firstSetupState = await finishInitialisation(firstPort, cli.binary, firstSession.cliEnv);
await resumeCompatibilityReviewIfShown(firstPort);
if (!firstSetupState.p2pEnabled || firstSetupState.p2pRelays !== relay) {
throw new Error(
`The first Obsidian device did not apply the browser P2P setup: ${JSON.stringify(firstSetupState)}`
);
}
const firstDiagnostics = await waitForSuccessfulConnection(page, consoleErrors);
const tryAnotherDevice = page.getByRole("button", {
name: "Try another device without resetting",
exact: true,
});
await tryAnotherDevice.click({ timeout: connectionTimeoutMs });
await page
.getByRole("heading", { name: "Use this same one-off configuration on another device", exact: true })
.waitFor({ timeout: 5000 });
await page.getByAltText("Setup URI QR code for another device", { exact: true }).waitFor({ timeout: 5000 });
if ((await page.getByLabel("Setup URI", { exact: true }).inputValue()) !== artifact.setupURI) {
throw new Error("The additional-device action regenerated or replaced the Setup URI.");
}
const secondVault = await createTemporaryVault("obsidian-livesync-p2p-check-second-e2e-");
vaults.push(secondVault);
const secondSession = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault: secondVault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
env: sessionEnvironment(secondPort),
});
sessions.push(secondSession);
await enterSetupURI(secondPort, "new", artifact, captures);
await captureAndStartInitialisation(secondPort, "new", captures);
await confirmRebuild(secondPort, captures);
await acknowledgeDisabledOptionalFeatures(secondPort, captures);
const secondSetupState = await finishInitialisation(secondPort, cli.binary, secondSession.cliEnv);
await resumeCompatibilityReviewIfShown(secondPort);
if (
!secondSetupState.p2pEnabled ||
secondSetupState.p2pRelays !== relay ||
secondSetupState.p2pRoomId !== firstSetupState.p2pRoomId
) {
throw new Error(
`The second Obsidian device did not reuse the browser P2P setup: ${JSON.stringify(secondSetupState)}`
);
}
await page.getByRole("heading", { name: "An additional connection was observed", exact: true }).waitFor({
timeout: connectionTimeoutMs,
});
const diagnostics = await readDiagnostics(page);
if (pageErrors.length > 0) {
throw new Error(`The browser page reported runtime errors: ${JSON.stringify(pageErrors)}`);
}
const screenshotPath = resolve(
process.env.E2E_P2P_CHECK_SCREENSHOT ??
resolve(
process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e",
"p2p-connection-check-browser-success.png"
)
);
await mkdir(dirname(screenshotPath), { recursive: true });
await page.locator(".results-card").screenshot({
animations: "disabled",
caret: "hide",
path: screenshotPath,
});
console.log(
`Browser-to-two-Obsidian P2P connection check succeeded through ${relay}. ` +
`First diagnostics: ${JSON.stringify(firstDiagnostics)}; final diagnostics: ${JSON.stringify(diagnostics)}`
);
console.log(`Browser result screenshot: ${screenshotPath}`);
if (consoleErrors.length > 0) {
console.warn(`Browser console errors after successful connection: ${JSON.stringify(consoleErrors)}`);
}
} finally {
for (const session of sessions.reverse()) {
await session.app.stop().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
for (const vault of vaults.reverse()) {
await vault.dispose().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
await browser?.close().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
await server.close().catch((error: unknown) => {
console.warn(error instanceof Error ? error.message : error);
});
}
}
async function main(): Promise<void> {
const manageP2P = process.argv.includes("--manage-p2p");
let shouldStopP2P = false;
try {
if (manageP2P) {
await runNpmScript("test:docker-p2p:stop", true);
await runNpmScript("test:docker-p2p:start");
shouldStopP2P = true;
}
await runScenario();
} finally {
if (shouldStopP2P) {
await runNpmScript("test:docker-p2p:stop", true);
}
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});