mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-11 10:11:54 +00:00
Compare commits
1 Commits
test_real_
...
p2p-rpc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6be20695a |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,8 +28,4 @@ data.json
|
||||
cov_profile/**
|
||||
|
||||
coverage
|
||||
src/apps/cli/dist/*
|
||||
|
||||
# Obsidian E2E test artefacts
|
||||
test_e2e/playwright-report/
|
||||
test_e2e/test-results/
|
||||
src/apps/cli/dist/*
|
||||
@@ -54,10 +54,7 @@
|
||||
"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: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"
|
||||
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 97530553a6...6a2dc6777f
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: 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<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}`);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
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.
|
||||
}
|
||||
|
||||
// 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<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");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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";
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* 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/). */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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=<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: () => rmSync(baseDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -3,6 +3,13 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### P2P Synchronisation
|
||||
|
||||
Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation.
|
||||
This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated.
|
||||
|
||||
## 0.25.60
|
||||
|
||||
29th April, 2026
|
||||
|
||||
30
vitest.config.rpc-unit.ts
Normal file
30
vitest.config.rpc-unit.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vitest.config.common";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: "",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: "rpc-unit-tests",
|
||||
include: ["src/lib/src/rpc/**/*.unit.spec.ts"],
|
||||
exclude: ["test/**"],
|
||||
coverage: {
|
||||
include: ["src/lib/src/rpc/**/*.ts"],
|
||||
exclude: ["**/*.unit.spec.ts", "**/index.ts"],
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html", ["text", { file: "coverage-rpc-text.txt" }]],
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
functions: 90,
|
||||
branches: 75,
|
||||
statements: 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user