diff --git a/.gitignore b/.gitignore index 43e267e..c7cfbd9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,8 @@ data.json cov_profile/** coverage -src/apps/cli/dist/* \ No newline at end of file +src/apps/cli/dist/* + +# Obsidian E2E test artefacts +test_e2e/playwright-report/ +test_e2e/test-results/ \ No newline at end of file diff --git a/package.json b/package.json index 479780a..02522ea 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,10 @@ "test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init", "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: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" }, "keywords": [], "author": "vorotamoroz", diff --git a/test_e2e/helpers/obsidian.ts b/test_e2e/helpers/obsidian.ts new file mode 100644 index 0000000..8cd27a4 --- /dev/null +++ b/test_e2e/helpers/obsidian.ts @@ -0,0 +1,223 @@ +/** + * 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:/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"; + +// --------------------------------------------------------------------------- +// 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; + /** Closes the CDP connection and kills the Obsidian process. */ + close(): Promise; +} + +/** Poll `http://127.0.0.1:/json/version` until Obsidian is ready. */ +async function waitForCDP(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const ready = await new Promise((resolve) => { + const req = http.get(`http://127.0.0.1:${port}/json/version`, (res: any) => { + 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 { + 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}`); + + return { + close: async () => { + try { + await browser.close(); + } catch { + /* ignore */ + } + try { + proc.kill(); + } catch { + /* ignore */ + } + }, + firstWindow: async (): Promise => { + 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 { + 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 { + // 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. + } + + // 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(300); + } catch { + // Modal not shown. + } + + await page.waitForSelector(".workspace-ribbon", { timeout: 60_000 }); +} + +// --------------------------------------------------------------------------- +// 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 { + 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 { + 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 { + await openSettings(page); + await clickSettingsTab(page, "Self-hosted LiveSync"); +} + +// --------------------------------------------------------------------------- +// 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"; diff --git a/test_e2e/helpers/vault.ts b/test_e2e/helpers/vault.ts new file mode 100644 index 0000000..e9345d4 --- /dev/null +++ b/test_e2e/helpers/vault.ts @@ -0,0 +1,109 @@ +/** + * helpers/vault.ts + * + * Creates a fully-isolated, throwaway Obsidian vault for each test run. + * + * Directory layout produced by `setupTestVault()`: + * + * /livesync-e2e-/ + * 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/). */ +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 `/obsidian.json`. + */ + fakeAppData: string; + /** Removes the entire temporary tree. */ + cleanup: () => void; +} + +/** + * 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 { + 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 }, null, 2), "utf-8"); + + // Tell Obsidian which community plugins are enabled. + writeFileSync( + path.join(dotObsidian, "community-plugins.json"), + JSON.stringify(["obsidian-livesync"], null, 2), + "utf-8" + ); + + // ------------------------------------------------ Obsidian global config + // With --user-data-dir=, Obsidian reads its vault registry + // directly from /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: () => rmSync(baseDir, { recursive: true, force: true }), + }; +} diff --git a/test_e2e/package.json b/test_e2e/package.json new file mode 100644 index 0000000..1cd945a --- /dev/null +++ b/test_e2e/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/test_e2e/playwright.config.ts b/test_e2e/playwright.config.ts new file mode 100644 index 0000000..0bb889c --- /dev/null +++ b/test_e2e/playwright.config.ts @@ -0,0 +1,25 @@ +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", + }, +}); diff --git a/test_e2e/tests/basic.spec.ts b/test_e2e/tests/basic.spec.ts new file mode 100644 index 0000000..cb161ff --- /dev/null +++ b/test_e2e/tests/basic.spec.ts @@ -0,0 +1,82 @@ +/** + * tests/basic.spec.ts + * + * Smoke tests for the Self-hosted LiveSync plugin running inside the real + * Obsidian desktop application. + * + * What these tests verify + * ----------------------- + * 1. Obsidian can launch with a fresh vault that has the plugin pre-installed. + * 2. The vault workspace loads without errors. + * 3. The plugin's settings tab is reachable via Settings > Self-hosted LiveSync. + * 4. The initial (unconfigured) setup screen is displayed on the first open. + * + * Prerequisites + * ------------- + * - `main.js` must exist at the repository root (run `npm run buildDev` first). + * - Obsidian must be installed at the default path, or `OBSIDIAN_PATH` must be set. + * + * How to run + * ---------- + * npm run test:obsidian:e2e + * npm run test:obsidian:e2e:headed + */ + +import { test, expect } from "playwright/test"; +import { setupTestVault } from "../helpers/vault"; +import type { VaultSetupResult } from "../helpers/vault"; +import { + launchObsidian, + getMainWindow, + waitForVaultReady, + openLiveSyncSettings, + SELECTOR_SETTINGS_CONTENT, +} from "../helpers/obsidian"; +import type { ObsidianHandle } from "../helpers/obsidian"; + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +let app: ObsidianHandle; +let vault: VaultSetupResult; + +test.beforeAll(async () => { + vault = setupTestVault(); + app = await launchObsidian(vault.fakeAppData, vault.vaultDir); +}); + +test.afterAll(async () => { + if (app) { + await app.close().catch(() => {}); + } + vault?.cleanup(); +}); + +// --------------------------------------------------------------------------- +// Test 1 – basic launch +// --------------------------------------------------------------------------- + +test("Obsidian launches and vault workspace loads", async () => { + const page = await getMainWindow(app); + await waitForVaultReady(page); + + const title = await page.title(); + expect(title).toBeTruthy(); + expect(title.length).toBeGreaterThan(0); +}); + +// --------------------------------------------------------------------------- +// Test 2 – settings tab +// --------------------------------------------------------------------------- + +test("Self-hosted LiveSync settings tab is accessible", async () => { + const page = await getMainWindow(app); + await waitForVaultReady(page); + + await openLiveSyncSettings(page); + + const content = page.locator(SELECTOR_SETTINGS_CONTENT); + await expect(content).toBeVisible(); + await expect(content.filter({ hasText: "Self-hosted LiveSync" })).toBeVisible({ timeout: 10_000 }); +});