mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-14 19:41:16 +00:00
WIP: Add test
This commit is contained in:
2
src/lib
2
src/lib
Submodule src/lib updated: f6a6c2dff0...ed4502e003
35
test_e2e/helpers/helpers.ts
Normal file
35
test_e2e/helpers/helpers.ts
Normal 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 });
|
||||
}
|
||||
@@ -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<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: 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<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 () => {
|
||||
@@ -124,6 +151,7 @@ export async function launchObsidian(fakeAppData: string, vaultDir: string): Pro
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await waitForProcessExit();
|
||||
},
|
||||
firstWindow: async (): Promise<Page> => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
@@ -169,19 +197,31 @@ export async function waitForVaultReady(page: Page): Promise<void> {
|
||||
// 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<boolean> {
|
||||
const handled = page.evaluate(isPluginEnabled, pluginName);
|
||||
return handled;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings modal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,12 +252,43 @@ export async function openLiveSyncSettings(page: Page): Promise<void> {
|
||||
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<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();
|
||||
|
||||
/** 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
19
test_e2e/helpers/obsidianFunctions.ts
Normal file
19
test_e2e/helpers/obsidianFunctions.ts
Normal 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);
|
||||
};
|
||||
@@ -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 <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.
|
||||
@@ -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=<fakeAppData>, Obsidian reads its vault registry
|
||||
// directly from <fakeAppData>/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}`
|
||||
);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
3
test_e2e/helpers/wrapper.ts
Normal file
3
test_e2e/helpers/wrapper.ts
Normal 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";
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
69
test_e2e/tests/dialogue1.spec.ts
Normal file
69
test_e2e/tests/dialogue1.spec.ts
Normal 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 });
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user