mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-11 10:11:54 +00:00
Compare commits
1 Commits
p2p-rpc
...
test_real_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/
|
||||||
@@ -54,7 +54,10 @@
|
|||||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
"test:docker-all: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"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "vorotamoroz",
|
"author": "vorotamoroz",
|
||||||
|
|||||||
223
test_e2e/helpers/obsidian.ts
Normal file
223
test_e2e/helpers/obsidian.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* helpers/obsidian.ts
|
||||||
|
*
|
||||||
|
* Launch / teardown helpers for the Obsidian Electron application and
|
||||||
|
* common UI interactions needed across test files.
|
||||||
|
*
|
||||||
|
* Launch strategy
|
||||||
|
* ---------------
|
||||||
|
* Playwright's `_electron.launch()` cannot reliably connect to Obsidian.exe
|
||||||
|
* via CDP because Obsidian's startup sequence does not expose the DevTools
|
||||||
|
* URL on stdout/stderr in a way Playwright can detect. Instead, we:
|
||||||
|
* 1. Spawn Obsidian with a fixed `--remote-debugging-port`.
|
||||||
|
* 2. Poll `http://127.0.0.1:<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";
|
||||||
109
test_e2e/helpers/vault.ts
Normal file
109
test_e2e/helpers/vault.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
3
test_e2e/package.json
Normal file
3
test_e2e/package.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "commonjs"
|
||||||
|
}
|
||||||
25
test_e2e/playwright.config.ts
Normal file
25
test_e2e/playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "playwright/test";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: path.join(__dirname, "tests"),
|
||||||
|
outputDir: path.join(__dirname, "test-results"),
|
||||||
|
|
||||||
|
// Each test may need to cold-start Obsidian and wait for the vault to load.
|
||||||
|
timeout: 120_000,
|
||||||
|
expect: { timeout: 20_000 },
|
||||||
|
|
||||||
|
// Tests are stateful (one Obsidian process per test file), so no parallelism.
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
|
||||||
|
reporter: [["list"], ["html", { open: "never", outputFolder: path.join(__dirname, "playwright-report") }]],
|
||||||
|
|
||||||
|
use: {
|
||||||
|
// Artefacts are kept only when a test fails.
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
});
|
||||||
82
test_e2e/tests/basic.spec.ts
Normal file
82
test_e2e/tests/basic.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* tests/basic.spec.ts
|
||||||
|
*
|
||||||
|
* Smoke tests for the Self-hosted LiveSync plugin running inside the real
|
||||||
|
* Obsidian desktop application.
|
||||||
|
*
|
||||||
|
* What these tests verify
|
||||||
|
* -----------------------
|
||||||
|
* 1. Obsidian can launch with a fresh vault that has the plugin pre-installed.
|
||||||
|
* 2. The vault workspace loads without errors.
|
||||||
|
* 3. The plugin's settings tab is reachable via Settings > Self-hosted LiveSync.
|
||||||
|
* 4. The initial (unconfigured) setup screen is displayed on the first open.
|
||||||
|
*
|
||||||
|
* Prerequisites
|
||||||
|
* -------------
|
||||||
|
* - `main.js` must exist at the repository root (run `npm run buildDev` first).
|
||||||
|
* - Obsidian must be installed at the default path, or `OBSIDIAN_PATH` must be set.
|
||||||
|
*
|
||||||
|
* How to run
|
||||||
|
* ----------
|
||||||
|
* npm run test:obsidian:e2e
|
||||||
|
* npm run test:obsidian:e2e:headed
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from "playwright/test";
|
||||||
|
import { setupTestVault } from "../helpers/vault";
|
||||||
|
import type { VaultSetupResult } from "../helpers/vault";
|
||||||
|
import {
|
||||||
|
launchObsidian,
|
||||||
|
getMainWindow,
|
||||||
|
waitForVaultReady,
|
||||||
|
openLiveSyncSettings,
|
||||||
|
SELECTOR_SETTINGS_CONTENT,
|
||||||
|
} from "../helpers/obsidian";
|
||||||
|
import type { ObsidianHandle } from "../helpers/obsidian";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let app: ObsidianHandle;
|
||||||
|
let vault: VaultSetupResult;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
vault = setupTestVault();
|
||||||
|
app = await launchObsidian(vault.fakeAppData, vault.vaultDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (app) {
|
||||||
|
await app.close().catch(() => {});
|
||||||
|
}
|
||||||
|
vault?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 1 – basic launch
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("Obsidian launches and vault workspace loads", async () => {
|
||||||
|
const page = await getMainWindow(app);
|
||||||
|
await waitForVaultReady(page);
|
||||||
|
|
||||||
|
const title = await page.title();
|
||||||
|
expect(title).toBeTruthy();
|
||||||
|
expect(title.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test 2 – settings tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test("Self-hosted LiveSync settings tab is accessible", async () => {
|
||||||
|
const page = await getMainWindow(app);
|
||||||
|
await waitForVaultReady(page);
|
||||||
|
|
||||||
|
await openLiveSyncSettings(page);
|
||||||
|
|
||||||
|
const content = page.locator(SELECTOR_SETTINGS_CONTENT);
|
||||||
|
await expect(content).toBeVisible();
|
||||||
|
await expect(content.filter({ hasText: "Self-hosted LiveSync" })).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user