From bba0a2773542cf13955780fcc647612dca498f44 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 14 May 2026 16:06:17 +0100 Subject: [PATCH] WIP: Add test --- src/lib | 2 +- test_e2e/helpers/helpers.ts | 35 ++++++++++ test_e2e/helpers/obsidian.ts | 93 +++++++++++++++++++++++---- test_e2e/helpers/obsidianFunctions.ts | 19 ++++++ test_e2e/helpers/vault.ts | 62 +++++++++++++++++- test_e2e/helpers/wrapper.ts | 3 + test_e2e/playwright.config.ts | 1 - test_e2e/tests/basic.spec.ts | 82 ----------------------- test_e2e/tests/dialogue1.spec.ts | 69 ++++++++++++++++++++ 9 files changed, 268 insertions(+), 98 deletions(-) create mode 100644 test_e2e/helpers/helpers.ts create mode 100644 test_e2e/helpers/obsidianFunctions.ts create mode 100644 test_e2e/helpers/wrapper.ts delete mode 100644 test_e2e/tests/basic.spec.ts create mode 100644 test_e2e/tests/dialogue1.spec.ts diff --git a/src/lib b/src/lib index f6a6c2d..ed4502e 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit f6a6c2dff0e2865317021d5d155f80974e3ec15d +Subproject commit ed4502e0035bfee88eca5f311d09ffc239ab9734 diff --git a/test_e2e/helpers/helpers.ts b/test_e2e/helpers/helpers.ts new file mode 100644 index 0000000..ec076bd --- /dev/null +++ b/test_e2e/helpers/helpers.ts @@ -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 +): Promise { + 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 }); +} diff --git a/test_e2e/helpers/obsidian.ts b/test_e2e/helpers/obsidian.ts index 8cd27a4..94aafaf 100644 --- a/test_e2e/helpers/obsidian.ts +++ b/test_e2e/helpers/obsidian.ts @@ -1,3 +1,6 @@ +/* eslint-disable obsidianmd/prefer-window-timers */ +/* eslint-disable import/no-nodejs-modules */ +/* eslint-disable import/no-extraneous-dependencies */ /** * helpers/obsidian.ts * @@ -22,7 +25,8 @@ 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 // --------------------------------------------------------------------------- @@ -67,7 +71,7 @@ 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) => { + const req = http.get(`http://127.0.0.1:${port}/json/version`, (res: http.IncomingMessage) => { res.resume(); resolve(res.statusCode === 200); }); @@ -111,6 +115,29 @@ export async function launchObsidian(fakeAppData: string, vaultDir: string): Pro await waitForCDP(CDP_PORT, 60_000); const browser: Browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`); + const waitForProcessExit = async (): Promise => { + if (proc.exitCode !== null || proc.killed) { + return; + } + + await new Promise((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 () => { @@ -124,6 +151,7 @@ export async function launchObsidian(fakeAppData: string, vaultDir: string): Pro } catch { /* ignore */ } + await waitForProcessExit(); }, firstWindow: async (): Promise => { const deadline = Date.now() + 30_000; @@ -169,19 +197,31 @@ export async function waitForVaultReady(page: Page): Promise { // 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(300); + 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 { + const handled = page.evaluate(isPluginEnabled, pluginName); + return handled; +} + // --------------------------------------------------------------------------- // Settings modal helpers // --------------------------------------------------------------------------- @@ -212,12 +252,43 @@ export async function openLiveSyncSettings(page: Page): Promise { await clickSettingsTab(page, "Self-hosted LiveSync"); } -// --------------------------------------------------------------------------- -// Selectors -// --------------------------------------------------------------------------- +/** + * Logs visible modal/dialog-like UI elements to help diagnose blocked flows. + */ +export async function logVisibleDialogs(page: Page, label = "dialogs"): Promise { + 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(); -/** CSS selector for the settings-tab content area. */ -export const SELECTOR_SETTINGS_CONTENT = ".vertical-tab-content-container"; + if (!visible) { + return undefined; + } -/** CSS selector for Obsidian notice toasts. */ -export const SELECTOR_NOTICE = ".notice-container .notice"; + 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}`); + } +} diff --git a/test_e2e/helpers/obsidianFunctions.ts b/test_e2e/helpers/obsidianFunctions.ts new file mode 100644 index 0000000..81eb7ec --- /dev/null +++ b/test_e2e/helpers/obsidianFunctions.ts @@ -0,0 +1,19 @@ +/* eslint-disable no-restricted-globals */ +import type { App } from "obsidian"; + +declare global { + var app: App & { + plugins: { + enabledPlugins: Set; + enablePlugin: (name: string) => Promise; + }; + }; +} + +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); +}; diff --git a/test_e2e/helpers/vault.ts b/test_e2e/helpers/vault.ts index e9345d4..5523924 100644 --- a/test_e2e/helpers/vault.ts +++ b/test_e2e/helpers/vault.ts @@ -1,3 +1,8 @@ +/* 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 * @@ -24,6 +29,7 @@ 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 { @@ -38,6 +44,15 @@ export interface VaultSetupResult { cleanup: () => void; } +export interface VaultSettingsOptions { + /** Optional custom app.json content under /.obsidian/app.json */ + appJson?: Record; + /** Community plugin IDs to mark as enabled. */ + communityPlugins?: string[]; + /** Per-plugin configuration keyed by plugin ID. */ + pluginData?: Record; +} + /** * Creates a throw-away vault with the built plugin pre-installed and * registered in an isolated Obsidian configuration directory. @@ -45,6 +60,16 @@ export interface VaultSetupResult { * 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; @@ -66,15 +91,29 @@ export function setupTestVault(): VaultSetupResult { } // 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"); + 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(["obsidian-livesync"], null, 2), + // 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=, Obsidian reads its vault registry // directly from /obsidian.json. @@ -104,6 +143,23 @@ export function setupTestVault(): VaultSetupResult { return { vaultDir, fakeAppData, - cleanup: () => rmSync(baseDir, { recursive: true, force: true }), + 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}` + ); + })(), }; } diff --git a/test_e2e/helpers/wrapper.ts b/test_e2e/helpers/wrapper.ts new file mode 100644 index 0000000..98798c6 --- /dev/null +++ b/test_e2e/helpers/wrapper.ts @@ -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"; diff --git a/test_e2e/playwright.config.ts b/test_e2e/playwright.config.ts index 0bb889c..3ce202d 100644 --- a/test_e2e/playwright.config.ts +++ b/test_e2e/playwright.config.ts @@ -15,7 +15,6 @@ export default defineConfig({ 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", diff --git a/test_e2e/tests/basic.spec.ts b/test_e2e/tests/basic.spec.ts deleted file mode 100644 index cb161ff..0000000 --- a/test_e2e/tests/basic.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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 }); -}); diff --git a/test_e2e/tests/dialogue1.spec.ts b/test_e2e/tests/dialogue1.spec.ts new file mode 100644 index 0000000..a7a1768 --- /dev/null +++ b/test_e2e/tests/dialogue1.spec.ts @@ -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, + }, + }, + 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, + }, + }, + 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 }); + } + ); +});