mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-29 14:57:05 +00:00
Add browser P2P connection check
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user