mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-25 12:57:07 +00:00
407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
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);
|
|
});
|