mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-16 04:21:16 +00:00
Compare commits
11 Commits
0.25.61
...
test_real_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
502ebafdda | ||
|
|
bba0a27735 | ||
|
|
91c9746886 | ||
|
|
75b44b1636 | ||
|
|
f1fe48c1ee | ||
|
|
437e7c0d9c | ||
|
|
02580b2cad | ||
|
|
5ffa7ec7ee | ||
|
|
13bb44c9bb | ||
|
|
eeb508ed32 | ||
|
|
edf85184c1 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,4 +28,8 @@ data.json
|
|||||||
cov_profile/**
|
cov_profile/**
|
||||||
|
|
||||||
coverage
|
coverage
|
||||||
src/apps/cli/dist/*
|
src/apps/cli/dist/*
|
||||||
|
|
||||||
|
# Obsidian E2E test artefacts
|
||||||
|
test_e2e/playwright-report/
|
||||||
|
test_e2e/test-results/
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-livesync",
|
"id": "obsidian-livesync",
|
||||||
"name": "Self-hosted LiveSync",
|
"name": "Self-hosted LiveSync",
|
||||||
"version": "0.25.61",
|
"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",
|
||||||
|
|||||||
40
package-lock.json
generated
40
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.25.61",
|
"version": "0.25.62",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.25.61",
|
"version": "0.25.62",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.808.0",
|
"@aws-sdk/client-s3": "^3.808.0",
|
||||||
@@ -6211,9 +6211,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/basic-ftp": {
|
"node_modules/basic-ftp": {
|
||||||
"version": "5.3.0",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
||||||
"integrity": "sha512-5K9eNNn7ywHPsYnFwjKgYH8Hf8B5emh7JKcPaVjjrMJFQQwGpwowEnZNEtHs7DfR7hCZsmaK3VA4HUK0YarT+w==",
|
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -8744,9 +8744,9 @@
|
|||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.5",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||||
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
|
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -8755,7 +8755,8 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-expression-matcher": "^1.1.3"
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"xml-naming": "^0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-xml-parser": {
|
"node_modules/fast-xml-parser": {
|
||||||
@@ -9668,9 +9669,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -16798,6 +16799,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-livesync",
|
"name": "obsidian-livesync",
|
||||||
"version": "0.25.61",
|
"version": "0.25.62",
|
||||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -54,7 +54,11 @@
|
|||||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
"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: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"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
294
test_e2e/helpers/obsidian.ts
Normal file
294
test_e2e/helpers/obsidian.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
};
|
||||||
165
test_e2e/helpers/vault.ts
Normal file
165
test_e2e/helpers/vault.ts
Normal 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}`
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
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";
|
||||||
3
test_e2e/package.json
Normal file
3
test_e2e/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
24
test_e2e/playwright.config.ts
Normal file
24
test_e2e/playwright.config.ts
Normal 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
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 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,6 +3,14 @@ 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.
|
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.
|
||||||
|
|
||||||
|
## 0.25.62
|
||||||
|
|
||||||
|
14th May, 2026
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660).
|
||||||
|
|
||||||
## 0.25.61
|
## 0.25.61
|
||||||
|
|
||||||
13th May, 2026
|
13th May, 2026
|
||||||
|
|||||||
17
version-bump.mjs
Normal file
17
version-bump.mjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync, writeFileSync } from "fs";
|
||||||
|
|
||||||
|
const targetVersion = process.env.npm_package_version;
|
||||||
|
|
||||||
|
// read minAppVersion from manifest.json and bump version to target version
|
||||||
|
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
|
||||||
|
const { minAppVersion } = manifest;
|
||||||
|
manifest.version = targetVersion;
|
||||||
|
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
|
||||||
|
|
||||||
|
// update versions.json with target version and minAppVersion from manifest.json
|
||||||
|
// but only if the target version is not already in versions.json
|
||||||
|
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
|
||||||
|
if (!Object.values(versions).includes(minAppVersion)) {
|
||||||
|
versions[targetVersion] = minAppVersion;
|
||||||
|
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user