Compare commits

..

6 Commits

11 changed files with 629 additions and 10 deletions

4
.gitignore vendored
View File

@@ -29,3 +29,7 @@ cov_profile/**
coverage coverage
src/apps/cli/dist/* src/apps/cli/dist/*
# Obsidian E2E test artefacts
test_e2e/playwright-report/
test_e2e/test-results/

View File

@@ -1,10 +1,10 @@
{ {
"id": "obsidian-livesync", "id": "obsidian-livesync",
"name": "Self-hosted LiveSync", "name": "Self-hosted LiveSync",
"version": "0.25.62", "version": "0.25.62",
"minAppVersion": "1.7.2", "minAppVersion": "1.7.2",
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.", "description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
"author": "vorotamoroz", "author": "vorotamoroz",
"authorUrl": "https://github.com/vrtmrz", "authorUrl": "https://github.com/vrtmrz",
"isDesktopOnly": false "isDesktopOnly": false
} }

View File

@@ -55,6 +55,9 @@
"test:docker-all:stop": "npm run test:docker-all:down", "test:docker-all:stop": "npm run test:docker-all:down",
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop", "test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh", "test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
"test:obsidian:e2e": "npx playwright test --config test_e2e/playwright.config.ts",
"test:obsidian:e2e:headed": "npx playwright test --config test_e2e/playwright.config.ts --headed",
"test:obsidian:build-and-e2e": "npm run buildDev && npm run test:obsidian:e2e",
"version": "node version-bump.mjs && git add manifest.json versions.json" "version": "node version-bump.mjs && git add manifest.json versions.json"
}, },
"keywords": [], "keywords": [],

View File

@@ -0,0 +1,35 @@
import type { Locator, Page } from "playwright";
import { type ObsidianHandle, launchObsidian } from "./obsidian";
import { type VaultSettingsOptions, type VaultSetupResult, setupTestVaultWithSettings } from "./vault";
// ---------------------------------------------------------------------------
// Helpers (vault setup, test scaffolding, etc.)
// ---------------------------------------------------------------------------
export async function withSeededVault(
options: VaultSettingsOptions,
run: (context: { app: ObsidianHandle; vault: VaultSetupResult }) => Promise<void>
): Promise<void> {
const vault = setupTestVaultWithSettings(options);
const app = await launchObsidian(vault.fakeAppData, vault.vaultDir);
try {
await run({ app, vault });
} finally {
await app.close().catch(() => {});
vault.cleanup();
}
}
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
/** CSS selector for the settings-tab content area. */
export const SELECTOR_SETTINGS_CONTENT = ".vertical-tab-content-container";
/** CSS selector for Obsidian notice toasts. */
export const SELECTOR_NOTICE = ".notice-container .notice";
export function locateModalByTitle(page: Page, title: string): Locator {
return page.locator(".modal-container .modal-title").filter({ hasText: title });
}

View File

@@ -0,0 +1,294 @@
/* eslint-disable obsidianmd/prefer-window-timers */
/* eslint-disable import/no-nodejs-modules */
/* eslint-disable import/no-extraneous-dependencies */
/**
* helpers/obsidian.ts
*
* Launch / teardown helpers for the Obsidian Electron application and
* common UI interactions needed across test files.
*
* Launch strategy
* ---------------
* Playwright's `_electron.launch()` cannot reliably connect to Obsidian.exe
* via CDP because Obsidian's startup sequence does not expose the DevTools
* URL on stdout/stderr in a way Playwright can detect. Instead, we:
* 1. Spawn Obsidian with a fixed `--remote-debugging-port`.
* 2. Poll `http://127.0.0.1:<port>/json/version` until the port is ready.
* 3. Connect with `chromium.connectOverCDP()`.
*/
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import http from "node:http";
import path from "node:path";
import os from "node:os";
import type { Browser, Page } from "playwright";
import type { ChildProcess } from "node:child_process";
import process from "node:process";
import { enablePlugin, isPluginEnabled } from "./obsidianFunctions";
// ---------------------------------------------------------------------------
// Executable path resolution
// ---------------------------------------------------------------------------
function defaultObsidianPath(): string {
switch (os.platform()) {
case "win32":
return path.join(os.homedir(), "AppData", "Local", "Obsidian", "Obsidian.exe");
case "darwin":
return "/Applications/Obsidian.app/Contents/MacOS/Obsidian";
default:
return process.env["OBSIDIAN_PATH"] ?? "/usr/bin/obsidian";
}
}
/**
* Path to the Obsidian executable.
* Override with the `OBSIDIAN_PATH` environment variable if needed.
*/
export const OBSIDIAN_EXECUTABLE: string = process.env["OBSIDIAN_PATH"] ?? defaultObsidianPath();
/** Fixed CDP port used for all test runs (workers: 1, so no collisions). */
const CDP_PORT = 19222;
// ---------------------------------------------------------------------------
// Launch
// ---------------------------------------------------------------------------
/**
* Handle returned by `launchObsidian`. Provides just enough surface to drive
* the Obsidian window and shut it down cleanly.
*/
export interface ObsidianHandle {
/** Returns the main Obsidian renderer page. */
firstWindow(): Promise<Page>;
/** Closes the CDP connection and kills the Obsidian process. */
close(): Promise<void>;
}
/** Poll `http://127.0.0.1:<port>/json/version` until Obsidian is ready. */
async function waitForCDP(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ready = await new Promise<boolean>((resolve) => {
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res: http.IncomingMessage) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on("error", () => resolve(false));
req.setTimeout(1_000, () => {
req.destroy();
resolve(false);
});
});
if (ready) return;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Obsidian CDP port ${port} was not ready within ${timeoutMs}ms`);
}
/**
* Launches Obsidian with an isolated user-data directory and opens the
* given vault via the `obsidian://open` URI scheme.
*
* Uses a fixed `--remote-debugging-port` so we can poll and connect via
* `chromium.connectOverCDP()` without relying on Playwright's electron
* startup detection, which does not work with Obsidian.exe.
*/
export async function launchObsidian(fakeAppData: string, vaultDir: string): Promise<ObsidianHandle> {
const proc: ChildProcess = spawn(
OBSIDIAN_EXECUTABLE,
[
`--remote-debugging-port=${CDP_PORT}`,
`--user-data-dir=${fakeAppData}`,
"--no-sandbox",
"--lang=en",
`obsidian://open?path=${encodeURIComponent(vaultDir)}`,
],
{ env: { ...process.env, LIBGL_ALWAYS_SOFTWARE: "1" } }
);
proc.on("error", (err: Error) => {
console.error("[launchObsidian] spawn error:", err.message);
});
await waitForCDP(CDP_PORT, 60_000);
const browser: Browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`);
const waitForProcessExit = async (): Promise<void> => {
if (proc.exitCode !== null || proc.killed) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.removeListener("exit", onExit);
proc.removeListener("close", onExit);
resolve();
}, 5_000);
const onExit = () => {
clearTimeout(timer);
proc.removeListener("exit", onExit);
proc.removeListener("close", onExit);
resolve();
};
proc.once("exit", onExit);
proc.once("close", onExit);
});
};
return {
close: async () => {
try {
await browser.close();
} catch {
/* ignore */
}
try {
proc.kill();
} catch {
/* ignore */
}
await waitForProcessExit();
},
firstWindow: async (): Promise<Page> => {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
for (const ctx of browser.contexts()) {
const pages = ctx.pages().filter((p: Page) => !p.isClosed());
if (pages.length > 0) return pages[0];
}
await new Promise((r) => setTimeout(r, 300));
}
throw new Error("No Obsidian window found after 30s");
},
};
}
// ---------------------------------------------------------------------------
// Window helpers
// ---------------------------------------------------------------------------
/**
* Returns the main Obsidian window and waits for its DOM to be ready.
*/
export async function getMainWindow(app: ObsidianHandle): Promise<Page> {
const page = await app.firstWindow();
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 });
return page;
}
/**
* Waits until the Obsidian vault workspace has finished loading.
*
* Handles the 'Trust author and enable plugins' prompt and the
* community-plugins information modal that appear on a first-time vault open.
*/
export async function waitForVaultReady(page: Page): Promise<void> {
// Trust prompt — must be dismissed before the workspace renders.
const trustButton = page.getByRole("button", { name: /trust author and enable plugins/i });
try {
await trustButton.waitFor({ state: "visible", timeout: 15_000 });
await trustButton.click();
await page.waitForTimeout(1_500);
} catch {
// Not shown — vault already trusted or safe mode off.
}
// Once the trust prompt is handled, then the plugin dialogues may appear. Wait a bit for them to show up and log them if they do, to help diagnose blocked flows.
// await page.waitForTimeout(100);
// Community-plugins modal — dismiss with Escape.
try {
const modal = page.locator(".modal-container").filter({ hasText: /community plugins/i });
await modal.waitFor({ state: "visible", timeout: 5_000 });
await page.keyboard.press("Escape");
await page.waitForTimeout(10);
} catch {
// Modal not shown.
}
await page.waitForSelector(".workspace-ribbon", { timeout: 60_000 });
}
export async function enablePluginInObsidian(page: Page, pluginName: string) {
const handled = await page.evaluateHandle(enablePlugin, pluginName);
return handled;
}
export function isPluginEnabledInObsidian(page: Page, pluginName: string): Promise<boolean> {
const handled = page.evaluate(isPluginEnabled, pluginName);
return handled;
}
// ---------------------------------------------------------------------------
// Settings modal helpers
// ---------------------------------------------------------------------------
/**
* Opens the Obsidian Settings modal via the standard keyboard shortcut and
* waits for the navigation panel to become visible.
*/
export async function openSettings(page: Page): Promise<void> {
await page.keyboard.press("Control+,");
await page.waitForSelector(".modal-container .vertical-tab-nav-item", { timeout: 15_000 });
}
/**
* Clicks a settings navigation tab identified by its visible text label.
*/
export async function clickSettingsTab(page: Page, label: string): Promise<void> {
const tab = page.locator(".vertical-tab-nav-item", { hasText: label });
await tab.first().click();
await page.waitForTimeout(300);
}
/**
* Opens Settings and navigates directly to the Self-hosted LiveSync tab.
*/
export async function openLiveSyncSettings(page: Page): Promise<void> {
await openSettings(page);
await clickSettingsTab(page, "Self-hosted LiveSync");
}
/**
* Logs visible modal/dialog-like UI elements to help diagnose blocked flows.
*/
export async function logVisibleDialogs(page: Page, label = "dialogs"): Promise<void> {
const summaries = await page
.locator(".modal-container, [role='dialog'], .notice-container .notice")
.evaluateAll((nodes) => {
return nodes
.map((node) => {
const element = node as HTMLElement;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
rect.width > 0 &&
rect.height > 0 &&
!!element.textContent?.trim();
if (!visible) {
return undefined;
}
return {
classes: element.className,
text: element.textContent?.replace(/\s+/g, " ").trim().slice(0, 240) ?? "",
};
})
.filter((item): item is { classes: string; text: string } => !!item);
});
if (summaries.length === 0) {
console.log(`[obsidian:${label}] no visible dialogs`);
return;
}
for (const [index, summary] of summaries.entries()) {
console.log(`[obsidian:${label}] #${index + 1} class=${summary.classes} text=${summary.text}`);
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-restricted-globals */
import type { App } from "obsidian";
declare global {
var app: App & {
plugins: {
enabledPlugins: Set<string>;
enablePlugin: (name: string) => Promise<void>;
};
};
}
export const enablePlugin = async (pluginName: string) => {
return await window.app.plugins.enablePlugin(pluginName);
};
export const isPluginEnabled = (pluginName: string) => {
return window.app.plugins.enabledPlugins.has(pluginName);
};

165
test_e2e/helpers/vault.ts Normal file
View File

@@ -0,0 +1,165 @@
/* eslint-disable obsidianmd/prefer-window-timers */
// This file is a test helper and is allowed to use Node.js modules.
/* eslint-disable obsidianmd/hardcoded-config-path */
// This file is a test helper and is allowed to use Node.js modules.
/* eslint-disable import/no-nodejs-modules */
/**
* helpers/vault.ts
*
* Creates a fully-isolated, throwaway Obsidian vault for each test run.
*
* Directory layout produced by `setupTestVault()`:
*
* <tmpdir>/livesync-e2e-<id>/
* obsidian.json <- registered vault list (Obsidian userData config)
* vault/
* .obsidian/
* app.json <- safe-mode disabled
* community-plugins.json
* plugins/
* obsidian-livesync/
* main.js <- built plugin (copied from repo root)
* manifest.json
* styles.css
*/
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import path from "node:path";
import os from "node:os";
/** Absolute path to the repository root (two levels above helpers/). */
// eslint-disable-next-line no-undef
const REPO_ROOT = path.resolve(__dirname, "../..");
export interface VaultSetupResult {
/** The vault directory that Obsidian will open. */
vaultDir: string;
/**
* The directory used as `--user-data-dir` for the Obsidian process.
* Obsidian reads its vault registry from `<fakeAppData>/obsidian.json`.
*/
fakeAppData: string;
/** Removes the entire temporary tree. */
cleanup: () => void;
}
export interface VaultSettingsOptions {
/** Optional custom app.json content under <vault>/.obsidian/app.json */
appJson?: Record<string, unknown>;
/** Community plugin IDs to mark as enabled. */
communityPlugins?: string[];
/** Per-plugin configuration keyed by plugin ID. */
pluginData?: Record<string, unknown>;
}
/**
* Creates a throw-away vault with the built plugin pre-installed and
* registered in an isolated Obsidian configuration directory.
*
* Call `cleanup()` (or use `test.afterAll`) to delete the temporary files.
*/
export function setupTestVault(): VaultSetupResult {
return setupTestVaultWithSettings({});
}
/**
* Creates a throw-away vault with optional initial Obsidian/plugin settings.
*
* This helper is intended for real-Obsidian e2e tests that need to open a
* vault in a known configuration state.
*/
export function setupTestVaultWithSettings(options: VaultSettingsOptions = {}): VaultSetupResult {
const id = randomBytes(4).toString("hex");
const baseDir = path.join(os.tmpdir(), `livesync-e2e-${id}`);
const fakeAppData = baseDir;
const vaultDir = path.join(baseDir, "vault");
// ------------------------------------------------------------------ vault
const dotObsidian = path.join(vaultDir, ".obsidian");
const pluginDir = path.join(dotObsidian, "plugins", "obsidian-livesync");
mkdirSync(pluginDir, { recursive: true });
// Copy the built plugin artefacts from the repository root.
for (const file of ["main.js", "manifest.json", "styles.css"]) {
const src = path.join(REPO_ROOT, file);
if (existsSync(src)) {
copyFileSync(src, path.join(pluginDir, file));
} else {
console.warn(`[vault setup] Expected file not found: ${src}`);
}
}
// Disable Obsidian safe mode so community plugins are allowed to load.
writeFileSync(
path.join(dotObsidian, "app.json"),
JSON.stringify({ promptDelete: false, ...(options.appJson ?? {}) }, null, 2),
"utf-8"
);
// Tell Obsidian which community plugins are enabled.
writeFileSync(
path.join(dotObsidian, "community-plugins.json"),
// JSON.stringify(options.communityPlugins ?? ["obsidian-livesync"], null, 2),
// You should enable the plugin(s) explicitly
JSON.stringify(options.communityPlugins ?? [], null, 2),
"utf-8"
);
if (options.pluginData) {
for (const [pluginId, value] of Object.entries(options.pluginData)) {
const target = path.join(dotObsidian, "plugins", pluginId, "data.json");
mkdirSync(path.dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(value, null, 2), "utf-8");
}
}
// ------------------------------------------------ Obsidian global config
// With --user-data-dir=<fakeAppData>, Obsidian reads its vault registry
// directly from <fakeAppData>/obsidian.json.
mkdirSync(fakeAppData, { recursive: true });
const vaultId = randomBytes(8).toString("hex");
writeFileSync(
path.join(fakeAppData, "obsidian.json"),
JSON.stringify(
{
vaults: {
[vaultId]: {
path: vaultDir,
ts: Date.now(),
open: true,
},
},
updateDisabled: true,
},
null,
2
),
"utf-8"
);
return {
vaultDir,
fakeAppData,
cleanup: () =>
void (async () => {
for (let attempt = 1; attempt <= 5; attempt++) {
try {
rmSync(baseDir, { recursive: true, force: true });
console.log(`[vault cleanup] Successfully removed temporary directory: ${baseDir}`);
return;
} catch {
console.warn(
`[vault cleanup] Attempt ${attempt} failed to remove temporary directory: ${baseDir}`
);
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
}
}
console.error(
`[vault cleanup] Failed to remove temporary directory after multiple attempts: ${baseDir}`
);
})(),
};
}

View File

@@ -0,0 +1,3 @@
// Example wrapper for Playwright test functions and assertions, this file is not used in Self-hosted LiveSync.
// eslint-disable-next-line import/no-extraneous-dependencies
export { test, expect } from "playwright/test";

3
test_e2e/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "commonjs"
}

View File

@@ -0,0 +1,24 @@
import { defineConfig } from "playwright/test";
import path from "node:path";
export default defineConfig({
testDir: path.join(__dirname, "tests"),
outputDir: path.join(__dirname, "test-results"),
// Each test may need to cold-start Obsidian and wait for the vault to load.
timeout: 120_000,
expect: { timeout: 20_000 },
// Tests are stateful (one Obsidian process per test file), so no parallelism.
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"], ["html", { open: "never", outputFolder: path.join(__dirname, "playwright-report") }]],
use: {
// Artefacts are kept only when a test fails.
screenshot: "only-on-failure",
video: "retain-on-failure",
trace: "retain-on-failure",
},
});

View File

@@ -0,0 +1,69 @@
/**
* tests/sample.spec.ts
*
* Example e2e test that opens a vault with pre-seeded settings.
*/
import {
getMainWindow,
waitForVaultReady,
enablePluginInObsidian,
isPluginEnabledInObsidian,
} from "../helpers/obsidian";
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
import { PartialMessages } from "@lib/common/messages/def";
import { locateModalByTitle, withSeededVault } from "test_e2e/helpers/helpers";
import { test, expect } from "test_e2e/helpers/wrapper";
const def = PartialMessages.def;
test("show Welcome when isConfigured is false", async () => {
await withSeededVault(
{
appJson: {
promptDelete: false,
},
communityPlugins: [],
pluginData: {
"obsidian-livesync": {
deviceAndVaultName: "e2e-configured-device",
isConfigured: true,
notifyThresholdOfRemoteStorageSize: 10000,
} satisfies Partial<ObsidianLiveSyncSettings>,
},
},
async ({ app }) => {
const page = await getMainWindow(app);
await waitForVaultReady(page);
await expect(enablePluginInObsidian(page, "obsidian-livesync")).resolves.not.toThrow();
expect(isPluginEnabledInObsidian(page, "obsidian-livesync")).toBeTruthy();
const welcome = locateModalByTitle(page, def["moduleMigration.titleWelcome"]);
await expect(welcome).toBeHidden({ timeout: 1_000 });
}
);
});
test("does not show Welcome when isConfigured is true", async () => {
await withSeededVault(
{
appJson: {
promptDelete: false,
},
communityPlugins: [],
pluginData: {
"obsidian-livesync": {
deviceAndVaultName: "e2e-configured-device",
isConfigured: true,
notifyThresholdOfRemoteStorageSize: 10000,
} satisfies Partial<ObsidianLiveSyncSettings>,
},
},
async ({ app }) => {
const page = await getMainWindow(app);
await waitForVaultReady(page);
await expect(enablePluginInObsidian(page, "obsidian-livesync")).resolves.not.toThrow();
expect(isPluginEnabledInObsidian(page, "obsidian-livesync")).toBeTruthy();
const welcome = locateModalByTitle(page, def["moduleMigration.titleWelcome"]);
await expect(welcome).toBeHidden({ timeout: 1_000 });
}
);
});