From 5b61ea50097c711fca61a12dfd9aa389638404af Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 10 Jul 2026 12:07:15 +0000 Subject: [PATCH 001/170] test: share Obsidian E2E session infrastructure --- package-lock.json | 14 ++ package.json | 1 + test/e2e-obsidian/README.md | 4 +- test/e2e-obsidian/runner/cli.ts | 109 +---------- test/e2e-obsidian/runner/environment.ts | 156 +-------------- test/e2e-obsidian/runner/launch.ts | 204 ++------------------ test/e2e-obsidian/runner/pluginInstaller.ts | 44 +---- test/e2e-obsidian/runner/readiness.ts | 42 +--- test/e2e-obsidian/runner/session.ts | 109 +---------- test/e2e-obsidian/runner/ui.ts | 77 +------- test/e2e-obsidian/runner/vault.ts | 100 +--------- 11 files changed, 82 insertions(+), 778 deletions(-) diff --git a/package-lock.json b/package-lock.json index 566c7cba..eb1393e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", + "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4944,6 +4945,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vrtmrz/obsidian-test-session": { + "version": "0.0.0", + "resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", + "integrity": "sha512-XHWFORR8Q7vQsbKxrj8RBgpyC7DHFw5PSUXkBOKJoHnx9abgCkuQp4gYYa64RO7sOdRx/Xj799JOop+McQSqsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "playwright": ">=1.50.0" + } + }, "node_modules/@wdio/config": { "version": "9.27.0", "resolved": "https://registry.npmjs.org/@wdio/config/-/config-9.27.0.tgz", diff --git a/package.json b/package.json index c1e15dd9..d6438c69 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", + "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index ca321892..a0b77c3c 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -2,6 +2,8 @@ This directory contains the experimental real Obsidian end-to-end runner. +The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository. + The current smoke runner verifies only the launch path: 1. create a temporary vault, @@ -18,7 +20,7 @@ The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. R Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/community-plugins.json`. The smoke runner enables it through `app.plugins.setEnable(true)` after the vault window is available. -Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. +Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here. Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry. diff --git a/test/e2e-obsidian/runner/cli.ts b/test/e2e-obsidian/runner/cli.ts index 052f4105..56c3c087 100644 --- a/test/e2e-obsidian/runner/cli.ts +++ b/test/e2e-obsidian/runner/cli.ts @@ -1,103 +1,6 @@ -import { spawn } from "node:child_process"; - -export type ObsidianCliResult = { - code: number | null; - signal: NodeJS.Signals | null; - stdout: string; - stderr: string; -}; - -function parseEvalJson(stdout: string): unknown { - const marker = "=> "; - const markerIndex = stdout.indexOf(marker); - const text = markerIndex >= 0 ? stdout.slice(markerIndex + marker.length) : stdout; - return JSON.parse(text.trim()); -} - -export async function runObsidianCli( - cliBinary: string, - args: string[], - env: NodeJS.ProcessEnv = process.env, - timeoutMs = Number(process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ?? 10000) -): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(cliBinary, args, { - stdio: ["ignore", "pipe", "pipe"], - env, - }); - let stdout = ""; - let stderr = ""; - const timeout = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`Obsidian CLI timed out: ${cliBinary} ${args.join(" ")}`)); - }, timeoutMs); - - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.on("error", (error) => { - clearTimeout(timeout); - reject(error); - }); - child.on("exit", (code, signal) => { - clearTimeout(timeout); - resolve({ code, signal, stdout, stderr }); - }); - }); -} - -export async function openVaultWithObsidianCli( - cliBinary: string, - vaultPath: string, - env: NodeJS.ProcessEnv = process.env -): Promise { - const result = await runObsidianCli(cliBinary, [`obsidian://open?path=${encodeURIComponent(vaultPath)}`], env); - if (result.code !== 0) { - throw new Error( - [ - `Failed to open Obsidian vault through CLI. code=${result.code}, signal=${result.signal}`, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} - -export async function evalObsidianJson( - cliBinary: string, - code: string, - env: NodeJS.ProcessEnv = process.env, - timeoutMs?: number -): Promise { - const result = await runObsidianCli(cliBinary, ["eval", `code=${code}`], env, timeoutMs); - if (result.code !== 0) { - throw new Error( - [ - `Failed to evaluate Obsidian JavaScript through CLI. code=${result.code}, signal=${result.signal}`, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } - try { - return parseEvalJson(result.stdout) as T; - } catch (error) { - throw new Error( - [ - `Failed to parse Obsidian CLI eval JSON. code=${result.code}, signal=${result.signal}`, - error instanceof Error ? `parse error: ${error.message}` : undefined, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} +export { + evalObsidianJson, + openVaultWithObsidianCli, + runObsidianCli, + type ObsidianCliResult, +} from "@vrtmrz/obsidian-test-session"; diff --git a/test/e2e-obsidian/runner/environment.ts b/test/e2e-obsidian/runner/environment.ts index 46b08a64..35511c40 100644 --- a/test/e2e-obsidian/runner/environment.ts +++ b/test/e2e-obsidian/runner/environment.ts @@ -1,149 +1,7 @@ -import { accessSync, constants, existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { platform } from "node:process"; - -export type ObsidianDiscoveryResult = { - binary?: string; - source?: string; - checked: string[]; -}; - -const defaultCandidatesByPlatform: Record = { - aix: [], - android: [], - darwin: [ - "/Applications/Obsidian.app/Contents/MacOS/Obsidian", - "/Applications/Obsidian.app/Contents/MacOS/obsidian", - ], - freebsd: [], - haiku: [], - linux: [ - "_testdata/obsidian/squashfs-root/obsidian", - "_testdata/obsidian/squashfs-root/AppRun", - "_testdata/obsidian/Obsidian-1.12.7-arm64.AppImage", - "_testdata/obsidian/Obsidian-1.12.7-x86_64.AppImage", - "/usr/bin/obsidian", - "/usr/local/bin/obsidian", - "/snap/bin/obsidian", - "/opt/Obsidian/obsidian", - "/opt/obsidian/obsidian", - "/app/bin/obsidian", - ], - openbsd: [], - sunos: [], - win32: ["C:\\Program Files\\Obsidian\\Obsidian.exe", "C:\\Program Files (x86)\\Obsidian\\Obsidian.exe"], - cygwin: [], - netbsd: [], -}; - -const defaultCliCandidatesByPlatform: Record = { - aix: [], - android: [], - darwin: [ - "/Applications/Obsidian.app/Contents/MacOS/obsidian-cli", - "/Applications/Obsidian.app/Contents/Resources/obsidian-cli", - ], - freebsd: [], - haiku: [], - linux: [ - "_testdata/obsidian/squashfs-root/obsidian-cli", - "/usr/bin/obsidian-cli", - "/usr/local/bin/obsidian-cli", - "/snap/bin/obsidian-cli", - "/opt/Obsidian/obsidian-cli", - "/opt/obsidian/obsidian-cli", - ], - openbsd: [], - sunos: [], - win32: ["C:\\Program Files\\Obsidian\\obsidian-cli.exe", "C:\\Program Files (x86)\\Obsidian\\obsidian-cli.exe"], - cygwin: [], - netbsd: [], -}; - -function isUsableFile(path: string): boolean { - const resolvedPath = resolve(path); - if (!existsSync(resolvedPath)) { - return false; - } - if (platform === "win32") { - return true; - } - try { - accessSync(resolvedPath, constants.X_OK); - return true; - } catch { - return false; - } -} - -export function discoverObsidianBinary(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult { - const checked: string[] = []; - const envBinary = env.OBSIDIAN_BINARY?.trim(); - if (envBinary) { - checked.push(envBinary); - if (isUsableFile(envBinary)) { - return { - binary: resolve(envBinary), - source: "OBSIDIAN_BINARY", - checked, - }; - } - } - - const candidates = defaultCandidatesByPlatform[platform] ?? []; - for (const candidate of candidates) { - checked.push(candidate); - if (isUsableFile(candidate)) { - return { - binary: resolve(candidate), - source: "default-path", - checked, - }; - } - } - - return { checked }; -} - -export function requireObsidianBinary(env: NodeJS.ProcessEnv = process.env): string { - const result = discoverObsidianBinary(env); - if (!result.binary) { - throw new Error( - [ - "Could not find an Obsidian executable.", - "Set OBSIDIAN_BINARY to the installed Obsidian executable path.", - `Checked paths: ${result.checked.length > 0 ? result.checked.join(", ") : "(none)"}`, - ].join("\n") - ); - } - return result.binary; -} - -export function discoverObsidianCli(env: NodeJS.ProcessEnv = process.env): ObsidianDiscoveryResult { - const checked: string[] = []; - const envBinary = env.OBSIDIAN_CLI?.trim(); - if (envBinary) { - checked.push(envBinary); - if (isUsableFile(envBinary)) { - return { - binary: resolve(envBinary), - source: "OBSIDIAN_CLI", - checked, - }; - } - } - - const candidates = defaultCliCandidatesByPlatform[platform] ?? []; - for (const candidate of candidates) { - checked.push(candidate); - if (isUsableFile(candidate)) { - return { - binary: resolve(candidate), - source: "default-path", - checked, - }; - } - } - - return { checked }; -} +export { + discoverObsidianBinary, + discoverObsidianCli, + requireObsidianBinary, + requireObsidianCli, + type ObsidianDiscoveryResult, +} from "@vrtmrz/obsidian-test-session"; diff --git a/test/e2e-obsidian/runner/launch.ts b/test/e2e-obsidian/runner/launch.ts index 3fe745fe..db02d74a 100644 --- a/test/e2e-obsidian/runner/launch.ts +++ b/test/e2e-obsidian/runner/launch.ts @@ -1,196 +1,26 @@ -import { execFile, spawn, type ChildProcess } from "node:child_process"; -import { once } from "node:events"; -import { existsSync } from "node:fs"; -import { dirname } from "node:path"; -import { platform } from "node:process"; -import { promisify } from "node:util"; +import { + cleanupStaleObsidianE2EProcesses as cleanupStaleProcesses, + launchObsidian as launchObsidianSession, + type LaunchObsidianOptions, + type ObsidianProcess, + type ObsidianProcessOutput, +} from "@vrtmrz/obsidian-test-session"; -export type ObsidianProcess = { - process: ChildProcess; - output: () => { stdout: string; stderr: string }; - stop: () => Promise; -}; +export type { LaunchObsidianOptions, ObsidianProcess, ObsidianProcessOutput }; -export type LaunchObsidianOptions = { - binary: string; - vaultPath: string; - homePath?: string; - xdgConfigPath?: string; - xdgCachePath?: string; - xdgDataPath?: string; - userDataPath?: string; - startupGraceMs?: number; -}; - -const execFileAsync = promisify(execFile); - -function splitArgs(args: string): string[] { - return args.split(" ").filter((arg) => arg.length > 0); -} - -function launchArgs(options: LaunchObsidianOptions): string[] { - const explicitArgs = process.env.E2E_OBSIDIAN_ARGS; - if (explicitArgs) { - return splitArgs(explicitArgs); - } - return [ - "--no-sandbox", - "--disable-gpu", - "--disable-software-rasterizer", - ...(process.env.E2E_OBSIDIAN_USE_USER_DATA_DIR !== "false" && options.userDataPath - ? [`--user-data-dir=${options.userDataPath}`] - : []), - ...(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT - ? [`--remote-debugging-port=${process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT}`] - : []), - `obsidian://open?path=${encodeURIComponent(options.vaultPath)}`, - ]; -} - -function shouldUseXvfb(): boolean { - if (process.env.E2E_OBSIDIAN_USE_XVFB === "false") { - return false; - } - if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) { - return false; - } - return platform === "linux" && existsSync("/usr/bin/xvfb-run"); -} - -async function listChildPids(pid: number): Promise { - if (platform === "win32") { - return []; - } - const { stdout } = await execFileAsync("ps", ["-o", "pid=", "--ppid", String(pid)]).catch(() => ({ - stdout: "", - })); - const directChildren = stdout - .split("\n") - .map((line) => Number(line.trim())) - .filter((childPid) => Number.isInteger(childPid) && childPid > 0); - const descendants = await Promise.all(directChildren.map((childPid) => listChildPids(childPid))); - return [...directChildren, ...descendants.flat()]; -} - -async function killPids(pids: number[], signal: NodeJS.Signals): Promise { - for (const pid of pids) { - if (pid === process.pid) { - continue; - } - try { - process.kill(pid, signal); - } catch { - // The process may have exited between discovery and signalling. - } - } -} - -async function waitForExit(exitPromise: Promise, timeoutMs: number): Promise<"exited" | "timeout"> { - const stopTimer = new Promise<"timeout">((resolve) => { - setTimeout(() => resolve("timeout"), timeoutMs); - }); - const stopResult = await Promise.race([exitPromise.then(() => "exited" as const), stopTimer]); - return stopResult; -} +const STALE_PROCESS_PATTERN = "obsidian-livesync-e2e-state"; export async function cleanupStaleObsidianE2EProcesses(): Promise { - if (process.env.E2E_OBSIDIAN_CLEANUP_STALE_PROCESSES === "false" || platform === "win32") { - return; - } - const { stdout } = await execFileAsync("pgrep", ["-f", "obsidian-livesync-e2e-state"]).catch(() => ({ - stdout: "", - })); - const pids = stdout - .split("\n") - .map((line) => Number(line.trim())) - .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); - if (pids.length === 0) { - return; - } - await killPids(pids, "SIGTERM"); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await killPids(pids, "SIGKILL"); + await cleanupStaleProcesses(STALE_PROCESS_PATTERN); } export async function launchObsidian(options: LaunchObsidianOptions): Promise { - await cleanupStaleObsidianE2EProcesses(); - const startupGraceMs = options.startupGraceMs ?? 1000; - const args = launchArgs(options); - const useXvfb = shouldUseXvfb(); - const command = useXvfb ? "/usr/bin/xvfb-run" : options.binary; - const commandArgs = useXvfb ? ["-a", options.binary, ...args] : args; - const child = spawn(command, commandArgs, { - cwd: dirname(options.binary), - detached: true, - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - ...(options.homePath ? { HOME: options.homePath } : {}), - ...(options.xdgConfigPath ? { XDG_CONFIG_HOME: options.xdgConfigPath } : {}), - ...(options.xdgCachePath ? { XDG_CACHE_HOME: options.xdgCachePath } : {}), - ...(options.xdgDataPath ? { XDG_DATA_HOME: options.xdgDataPath } : {}), - OBSIDIAN_DISABLE_GPU: process.env.OBSIDIAN_DISABLE_GPU ?? "1", - }, + const configuredPort = + options.env?.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT; + return await launchObsidianSession({ + ...options, + remoteDebuggingPort: + options.remoteDebuggingPort ?? (configuredPort === undefined ? undefined : Number(configuredPort)), + staleProcessPattern: options.staleProcessPattern ?? STALE_PROCESS_PATTERN, }); - - let stderr = ""; - let stdout = ""; - child.stderr?.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - child.stdout?.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - - const exitPromise = once(child, "exit").then(([code, signal]) => ({ code, signal })); - const timer = new Promise<"timeout">((resolve) => { - setTimeout(() => resolve("timeout"), startupGraceMs); - }); - const firstResult = await Promise.race([exitPromise, timer]); - if (firstResult !== "timeout") { - throw new Error( - [ - `Obsidian exited before the smoke timeout. code=${firstResult.code}, signal=${firstResult.signal}`, - stdout ? `stdout:\n${stdout}` : undefined, - stderr ? `stderr:\n${stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } - - return { - process: child, - output: () => ({ stdout, stderr }), - stop: async () => { - if (child.exitCode !== null || child.signalCode !== null) { - return; - } - const descendantPids = child.pid ? await listChildPids(child.pid) : []; - if (child.pid) { - try { - process.kill(-child.pid, "SIGTERM"); - } catch { - child.kill("SIGTERM"); - } - } else { - child.kill("SIGTERM"); - } - await killPids(descendantPids.reverse(), "SIGTERM"); - const stopResult = await waitForExit(exitPromise, 5000); - if (stopResult === "timeout") { - if (child.pid) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { - child.kill("SIGKILL"); - } - } else { - child.kill("SIGKILL"); - } - await killPids(descendantPids, "SIGKILL"); - await exitPromise; - } - }, - }; } diff --git a/test/e2e-obsidian/runner/pluginInstaller.ts b/test/e2e-obsidian/runner/pluginInstaller.ts index db28cc9f..48579354 100644 --- a/test/e2e-obsidian/runner/pluginInstaller.ts +++ b/test/e2e-obsidian/runner/pluginInstaller.ts @@ -1,39 +1,13 @@ -import { copyFile, mkdir, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { + installBuiltPlugin as installGenericBuiltPlugin, + type PluginInstallResult, +} from "@vrtmrz/obsidian-test-session"; -export type PluginInstallResult = { - pluginDir: string; - copied: string[]; -}; - -const pluginId = "obsidian-livesync"; +export type { PluginInstallResult }; export async function installBuiltPlugin(vaultPath: string, rootDir = process.cwd()): Promise { - const pluginDir = join(vaultPath, ".obsidian", "plugins", pluginId); - const copied: string[] = []; - await mkdir(pluginDir, { recursive: true }); - - const requiredArtifacts = ["main.js", "manifest.json"]; - for (const artifact of requiredArtifacts) { - const source = resolve(rootDir, artifact); - if (!existsSync(source)) { - throw new Error(`Required plug-in artifact is missing: ${source}`); - } - await copyFile(source, join(pluginDir, artifact)); - copied.push(artifact); - } - - const optionalArtifacts = ["styles.css"]; - for (const artifact of optionalArtifacts) { - const source = resolve(rootDir, artifact); - if (!existsSync(source)) { - continue; - } - await copyFile(source, join(pluginDir, artifact)); - copied.push(artifact); - } - - await writeFile(join(vaultPath, ".obsidian", "community-plugins.json"), JSON.stringify([pluginId], null, 4)); - return { pluginDir, copied }; + return await installGenericBuiltPlugin(vaultPath, { + pluginId: "obsidian-livesync", + artifactRoot: rootDir, + }); } diff --git a/test/e2e-obsidian/runner/readiness.ts b/test/e2e-obsidian/runner/readiness.ts index a6fa5a9c..df187a03 100644 --- a/test/e2e-obsidian/runner/readiness.ts +++ b/test/e2e-obsidian/runner/readiness.ts @@ -1,41 +1 @@ -import { evalObsidianJson } from "./cli.ts"; - -export type PluginReadiness = { - status: "ready"; - pluginId: string; - pluginVersion: string; - vaultName: string; -}; - -export async function waitForPluginReady( - cliBinary: string, - env: NodeJS.ProcessEnv, - timeoutMs = Number(process.env.E2E_OBSIDIAN_READY_TIMEOUT_MS ?? 20000) -): Promise { - const deadline = Date.now() + timeoutMs; - let lastOutput = ""; - while (Date.now() < deadline) { - try { - const readiness = await evalObsidianJson( - cliBinary, - [ - "(async()=>JSON.stringify({", - "status:!!app.plugins.plugins['obsidian-livesync']?'ready':'pending',", - "pluginId:'obsidian-livesync',", - "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version,", - "vaultName:app.vault.getName()", - "}))()", - ].join(""), - env - ); - if (readiness.status === "ready") { - return readiness; - } - } catch (error) { - lastOutput = error instanceof Error ? error.message : String(error); - // Keep polling until Obsidian exposes the vault-side CLI and plug-in state. - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for Self-hosted LiveSync readiness through Obsidian CLI.\n${lastOutput}`); -} +export { waitForPluginReady, type PluginReadiness } from "@vrtmrz/obsidian-test-session"; diff --git a/test/e2e-obsidian/runner/session.ts b/test/e2e-obsidian/runner/session.ts index 5d7bdd1d..ede769da 100644 --- a/test/e2e-obsidian/runner/session.ts +++ b/test/e2e-obsidian/runner/session.ts @@ -1,16 +1,7 @@ -import { evalObsidianJson, openVaultWithObsidianCli, runObsidianCli } from "./cli.ts"; -import { launchObsidian, type ObsidianProcess } from "./launch.ts"; -import { installBuiltPlugin, type PluginInstallResult } from "./pluginInstaller.ts"; -import { waitForPluginReady, type PluginReadiness } from "./readiness.ts"; +import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session"; import type { TemporaryVault } from "./vault.ts"; -import { obsidianRemoteDebuggingPort, preseedTrustedVaultState, trustVaultIfPrompted } from "./ui.ts"; -export type ObsidianLiveSyncSession = { - app: ObsidianProcess; - cliEnv: NodeJS.ProcessEnv; - install: PluginInstallResult; - readiness: PluginReadiness; -}; +export type ObsidianLiveSyncSession = ObsidianPluginSession; export type StartObsidianLiveSyncSessionOptions = { binary: string; @@ -19,101 +10,15 @@ export type StartObsidianLiveSyncSessionOptions = { startupGraceMs?: number; }; -async function waitForPluginCatalogue(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS ?? 60000); - let lastOutput = ""; - while (Date.now() < deadline) { - try { - const result = await evalObsidianJson<{ hasLiveSync: boolean }>( - cliBinary, - ["JSON.stringify({", "hasLiveSync:!!app.plugins?.manifests?.['obsidian-livesync']", "})"].join(""), - env - ); - lastOutput = JSON.stringify(result); - if (result.hasLiveSync) { - return; - } - } catch (error) { - lastOutput = error instanceof Error ? error.message : String(error); - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for Obsidian plug-in catalogue through CLI.\n${lastOutput}`); -} - -async function enableCommunityPlugins(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const result = await runObsidianCli(cliBinary, ["eval", "code=(async()=>app.plugins.setEnable(true))()"], env); - if (result.code !== 0 || result.stdout.includes("Error:")) { - throw new Error( - [ - `Failed to enable Obsidian community plug-ins through CLI. code=${result.code}, signal=${result.signal}`, - result.stdout ? `stdout:\n${result.stdout}` : undefined, - result.stderr ? `stderr:\n${result.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} - -async function reloadLiveSyncPlugin(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - const reload = await runObsidianCli(cliBinary, ["plugin:reload", "id=obsidian-livesync"], env); - if (reload.code !== 0 || !reload.stdout.includes("Reloaded: obsidian-livesync")) { - throw new Error( - [ - `Failed to reload Self-hosted LiveSync through Obsidian CLI. code=${reload.code}, signal=${reload.signal}`, - reload.stdout ? `stdout:\n${reload.stdout}` : undefined, - reload.stderr ? `stderr:\n${reload.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } -} - export async function startObsidianLiveSyncSession( options: StartObsidianLiveSyncSessionOptions ): Promise { - const install = await installBuiltPlugin(options.vault.path); - const remoteDebuggingPort = obsidianRemoteDebuggingPort(); - const app = await launchObsidian({ + return await startObsidianPluginSession({ binary: options.binary, - vaultPath: options.vault.path, - homePath: options.vault.homePath, - xdgConfigPath: options.vault.xdgConfigPath, - xdgCachePath: options.vault.xdgCachePath, - xdgDataPath: options.vault.xdgDataPath, - userDataPath: options.vault.userDataPath, + cliBinary: options.cliBinary, + vault: options.vault, + pluginId: "obsidian-livesync", + artifactRoot: process.cwd(), startupGraceMs: options.startupGraceMs, }); - const cliEnv = { - ...process.env, - HOME: options.vault.homePath, - XDG_CONFIG_HOME: options.vault.xdgConfigPath, - XDG_CACHE_HOME: options.vault.xdgCachePath, - XDG_DATA_HOME: options.vault.xdgDataPath, - }; - - try { - await preseedTrustedVaultState(remoteDebuggingPort, options.vault.id); - await openVaultWithObsidianCli(options.cliBinary, options.vault.path, cliEnv); - await trustVaultIfPrompted(remoteDebuggingPort); - await waitForPluginCatalogue(options.cliBinary, cliEnv); - await enableCommunityPlugins(options.cliBinary, cliEnv); - await reloadLiveSyncPlugin(options.cliBinary, cliEnv); - const readiness = await waitForPluginReady(options.cliBinary, cliEnv); - return { app, cliEnv, install, readiness }; - } catch (error) { - const output = app.output(); - await app.stop(); - throw new Error( - [ - error instanceof Error ? error.message : String(error), - output.stdout ? `Obsidian stdout:\n${output.stdout}` : undefined, - output.stderr ? `Obsidian stderr:\n${output.stderr}` : undefined, - ] - .filter(Boolean) - .join("\n") - ); - } } diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index d6c88488..8c4f36d5 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -1,74 +1,11 @@ -import { chromium, type Page } from "playwright"; +import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; -export function obsidianRemoteDebuggingPort(): number { - const port = Number(process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT ?? 9222); - process.env.E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT = String(port); - return port; -} - -async function waitForCdp(port: number): Promise { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000); - while (Date.now() < deadline) { - try { - const response = await fetch(`http://127.0.0.1:${port}/json/version`); - if (response.ok) { - return; - } - } catch { - // Keep polling until Obsidian exposes the debugging endpoint. - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - throw new Error(`Timed out waiting for Obsidian DevTools endpoint on port ${port}`); -} - -export async function withObsidianPage(port: number, operation: (page: Page) => Promise): Promise { - await waitForCdp(port); - const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); - try { - const context = browser.contexts()[0]; - const page = context.pages()[0] ?? (await context.waitForEvent("page", { timeout: 10000 })); - return await operation(page); - } finally { - await browser.close(); - } -} - -export async function preseedTrustedVaultState(port: number, vaultId: string): Promise { - await withObsidianPage(port, async (page) => { - await page.evaluate((id) => { - localStorage.setItem(`enable-plugin-${id}`, "true"); - }, vaultId); - await page.reload({ waitUntil: "domcontentloaded", timeout: 10000 }).catch(() => undefined); - await page.waitForTimeout(1000); - }); -} - -export async function trustVaultIfPrompted(port: number): Promise { - await withObsidianPage(port, async (page) => { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_TRUST_PROMPT_TIMEOUT_MS ?? 30000); - while (Date.now() < deadline) { - const yesButton = page.getByRole("button", { name: "Yes" }); - if (await yesButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await yesButton.click(); - await page.waitForTimeout(500); - continue; - } - - const trustButton = page.getByText("Trust author and enable plugins"); - if (await trustButton.isVisible({ timeout: 1000 }).catch(() => false)) { - await trustButton.click(); - await page.waitForTimeout(500); - continue; - } - - const workspace = page.locator(".workspace"); - if (await workspace.isVisible({ timeout: 1000 }).catch(() => false)) { - return; - } - } - }); -} +export { + obsidianRemoteDebuggingPort, + preseedTrustedVaultState, + trustVaultIfPrompted, + withObsidianPage, +} from "@vrtmrz/obsidian-test-session"; export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise { await withObsidianPage(port, async (page) => { diff --git a/test/e2e-obsidian/runner/vault.ts b/test/e2e-obsidian/runner/vault.ts index 6c75f954..53d645fe 100644 --- a/test/e2e-obsidian/runner/vault.ts +++ b/test/e2e-obsidian/runner/vault.ts @@ -1,94 +1,14 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { + createTemporaryVault as createGenericTemporaryVault, + type TemporaryVault, +} from "@vrtmrz/obsidian-test-session"; -export type TemporaryVault = { - path: string; - name: string; - id: string; - homePath: string; - xdgConfigPath: string; - xdgCachePath: string; - xdgDataPath: string; - userDataPath: string; - dispose: () => Promise; -}; +export type { TemporaryVault }; export async function createTemporaryVault(prefix = "obsidian-livesync-e2e-"): Promise { - const vaultPath = await mkdtemp(join(tmpdir(), prefix)); - const statePath = await mkdtemp(join(tmpdir(), `${prefix}state-`)); - const name = vaultPath.split(/[\\/]/).pop() ?? "obsidian-livesync-e2e"; - await mkdir(join(vaultPath, ".obsidian"), { recursive: true }); - const homePath = join(statePath, "home"); - const xdgConfigPath = join(statePath, "xdg-config"); - const xdgCachePath = join(statePath, "xdg-cache"); - const xdgDataPath = join(statePath, "xdg-data"); - const userDataPath = join(statePath, "user-data"); - const id = `livesync-e2e-${Date.now()}`; - await mkdir(homePath, { recursive: true }); - await mkdir(xdgConfigPath, { recursive: true }); - await mkdir(xdgCachePath, { recursive: true }); - await mkdir(xdgDataPath, { recursive: true }); - await mkdir(userDataPath, { recursive: true }); - await writeFile( - join(vaultPath, ".obsidian", "app.json"), - JSON.stringify({ legacyEditor: false, safeMode: false }, null, 4) - ); - await writeFile( - join(vaultPath, ".obsidian", "community-plugins.json"), - JSON.stringify(["obsidian-livesync"], null, 4) - ); - await writeObsidianVaultRegistry(id, vaultPath, name, homePath, xdgConfigPath, userDataPath); - - return { - path: vaultPath, - name, - id, - homePath, - xdgConfigPath, - xdgCachePath, - xdgDataPath, - userDataPath, - dispose: async () => { - if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true") { - console.log(`Keeping temporary vault: ${vaultPath}`); - console.log(`Keeping temporary Obsidian state: ${statePath}`); - return; - } - await Promise.all([ - rm(vaultPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), - rm(statePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }), - ]); - }, - }; -} - -async function writeObsidianVaultRegistry( - vaultId: string, - vaultPath: string, - vaultName: string, - homePath: string, - xdgConfigPath: string, - userDataPath: string -): Promise { - const vaultRecord = { - path: vaultPath, - ts: Date.now(), - open: true, - name: vaultName, - }; - const registry = { - cli: true, - vaults: { - [vaultId]: vaultRecord, - }, - }; - const registryText = JSON.stringify(registry, null, 4); - for (const configRoot of [join(homePath, ".config"), xdgConfigPath]) { - const obsidianConfigDir = join(configRoot, "obsidian"); - await mkdir(obsidianConfigDir, { recursive: true }); - await writeFile(join(obsidianConfigDir, "obsidian.json"), registryText); - } - await writeFile(join(userDataPath, "obsidian.json"), registryText); - await writeFile(join(userDataPath, `${vaultId}.json`), JSON.stringify(vaultRecord, null, 4)); + return await createGenericTemporaryVault({ + prefix, + pluginIds: ["obsidian-livesync"], + idPrefix: "livesync-e2e", + }); } From c0780d19baaed9336331a05347a5958590cc1b73 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 10 Jul 2026 15:17:01 +0000 Subject: [PATCH 002/170] build: update Obsidian test session preview --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index eb1393e3..03ff3e94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", + "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.5/vrtmrz-obsidian-test-session-0.1.0.tgz", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4946,9 +4946,9 @@ } }, "node_modules/@vrtmrz/obsidian-test-session": { - "version": "0.0.0", - "resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", - "integrity": "sha512-XHWFORR8Q7vQsbKxrj8RBgpyC7DHFw5PSUXkBOKJoHnx9abgCkuQp4gYYa64RO7sOdRx/Xj799JOop+McQSqsg==", + "version": "0.1.0", + "resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.5/vrtmrz-obsidian-test-session-0.1.0.tgz", + "integrity": "sha512-IUM31s7jjhj7S2X6qM7QIvgJX/XzK35878phq/wuLI849BdwfXIliUcnAdqcDN9ajGbLuMkFHy1h2Q2LB0A93A==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index d6438c69..de6b38fb 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.2/vrtmrz-obsidian-test-session-0.0.0.tgz", + "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.5/vrtmrz-obsidian-test-session-0.1.0.tgz", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", From 78eb6ec3bcf5bd6f4cb986d21b282fdd3a69471b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 06:04:11 +0000 Subject: [PATCH 003/170] build: use stable Obsidian test session --- package-lock.json | 8 +++++--- package.json | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03ff3e94..44ef1e46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.5/vrtmrz-obsidian-test-session-0.1.0.tgz", + "@vrtmrz/obsidian-test-session": "0.1.0", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4181,6 +4181,7 @@ "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -4947,14 +4948,15 @@ }, "node_modules/@vrtmrz/obsidian-test-session": { "version": "0.1.0", - "resolved": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.5/vrtmrz-obsidian-test-session-0.1.0.tgz", - "integrity": "sha512-IUM31s7jjhj7S2X6qM7QIvgJX/XzK35878phq/wuLI849BdwfXIliUcnAdqcDN9ajGbLuMkFHy1h2Q2LB0A93A==", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz", + "integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==", "dev": true, "license": "MIT", "engines": { "node": ">=20" }, "peerDependencies": { + "@types/node": ">=20", "playwright": ">=1.50.0" } }, diff --git a/package.json b/package.json index de6b38fb..126450f2 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "https://github.com/vrtmrz/fancy-kit/releases/download/consumer-preview-2026-07-10.5/vrtmrz-obsidian-test-session-0.1.0.tgz", + "@vrtmrz/obsidian-test-session": "0.1.0", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", From ef655a297644546399eb1c87a35197bd113f4f3a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 06:30:12 +0000 Subject: [PATCH 004/170] Add Compose-based CLI network benchmarks --- .dockerignore | 4 +- .github/workflows/cli-p2p-compose-smoke.yml | 70 +++++++ src/apps/cli/testdeno/bench-couchdb.ts | 24 ++- src/apps/cli/testdeno/bench-latency-sweep.ts | 133 +++++++++++++ src/apps/cli/testdeno/bench-network-cases.ts | 198 +++++++++++++++++++ src/apps/cli/testdeno/bench-p2p.ts | 37 +++- src/apps/cli/testdeno/deno.json | 2 + src/apps/cli/testdeno/helpers/net.ts | 2 +- src/apps/cli/testdeno/helpers/p2p.ts | 12 +- test/bench-network/.gitignore | 1 + test/bench-network/Dockerfile.runner | 34 ++++ test/bench-network/README.md | 90 +++++++++ test/bench-network/compose.yml | 93 +++++++++ test/bench-network/run-bench.sh | 16 ++ 14 files changed, 704 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/cli-p2p-compose-smoke.yml create mode 100644 src/apps/cli/testdeno/bench-latency-sweep.ts create mode 100644 src/apps/cli/testdeno/bench-network-cases.ts create mode 100644 test/bench-network/.gitignore create mode 100644 test/bench-network/Dockerfile.runner create mode 100644 test/bench-network/README.md create mode 100644 test/bench-network/compose.yml create mode 100644 test/bench-network/run-bench.sh diff --git a/.dockerignore b/.dockerignore index 76fcffa9..ec80feb0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,7 +16,9 @@ pouchdb-browser.js production/ # Test coverage and reports -coverage/ +coverage/ +test/bench-network/bench-results/ +src/apps/cli/testdeno/bench-results/ # Local environment / secrets .env diff --git a/.github/workflows/cli-p2p-compose-smoke.yml b/.github/workflows/cli-p2p-compose-smoke.yml new file mode 100644 index 00000000..60455d5a --- /dev/null +++ b/.github/workflows/cli-p2p-compose-smoke.yml @@ -0,0 +1,70 @@ +# Run the Compose-packaged CLI P2P smoke benchmark. +# +# This workflow is intentionally manual/non-required at first. It exercises the +# local Compose package for CouchDB + Nostr relay + CLI runner, and uploads the +# benchmark JSON results for inspection. +name: cli-p2p-compose-smoke + +on: + workflow_dispatch: + inputs: + cases: + description: 'Comma-separated benchmark cases' + required: false + default: 'couchdb-baseline,p2p-direct-local' + md_files: + description: 'Markdown file count' + required: false + default: '2' + bin_files: + description: 'Binary file count' + required: false + default: '1' + couchdb_rtt_ms: + description: 'Requested CouchDB RTT in milliseconds' + required: false + default: '20' + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Run Compose P2P smoke benchmark + env: + BENCH_CASES: ${{ inputs.cases }} + BENCH_MD_FILE_COUNT: ${{ inputs.md_files }} + BENCH_MD_MIN_SIZE_BYTES: '128' + BENCH_MD_MAX_SIZE_BYTES: '256' + BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files }} + BENCH_BIN_SIZE_BYTES: '512' + BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms }} + BENCH_SYNC_TIMEOUT: '180' + BENCH_PEERS_TIMEOUT: '90' + BENCH_LIVESYNC_TEST_TEE: '0' + run: docker compose -f test/bench-network/compose.yml run --rm bench-runner + + - name: Upload benchmark results + if: always() + uses: actions/upload-artifact@v4 + with: + name: cli-p2p-compose-smoke-results + path: test/bench-network/bench-results/** + if-no-files-found: warn + + - name: Stop Compose services + if: always() + run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/src/apps/cli/testdeno/bench-couchdb.ts index 6ed6df73..255aaafd 100644 --- a/src/apps/cli/testdeno/bench-couchdb.ts +++ b/src/apps/cli/testdeno/bench-couchdb.ts @@ -1,10 +1,11 @@ import { TempDir } from "./helpers/temp.ts"; import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; -import { startCouchdb, stopCouchdb } from "./helpers/docker.ts"; +import { createCouchdbDatabase, startCouchdb, stopCouchdb } from "./helpers/docker.ts"; import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; type BenchmarkConfig = { + caseName: string; couchdbBackendUri: string; couchdbProxyUri: string; couchdbUser: string; @@ -21,6 +22,7 @@ type BenchmarkConfig = { requestedRttMs: number; passphrase: string; encrypt: boolean; + managedCouchdb: boolean; }; function readEnvString(name: string, fallback: string): string { @@ -70,6 +72,7 @@ function formatBytes(value: number): string { function buildConfig(): BenchmarkConfig { return { + caseName: readEnvString("BENCH_CASE", "couchdb-baseline"), couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"), couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"), couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")), @@ -86,6 +89,7 @@ function buildConfig(): BenchmarkConfig { requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)), passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), encrypt: readEnvBool("BENCH_ENCRYPT", true), + managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true), }; } @@ -200,7 +204,17 @@ async function main(): Promise { await initSettingsFile(settingsA); await initSettingsFile(settingsB); - await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname); + if (config.managedCouchdb) { + await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname); + } else { + console.log(`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`); + await createCouchdbDatabase( + config.couchdbBackendUri, + config.couchdbUser, + config.couchdbPassword, + config.couchdbDbname + ); + } const proxy = startCouchdbProxy({ backendUri: config.couchdbBackendUri, @@ -265,10 +279,12 @@ async function main(): Promise { } const result = { + caseName: config.caseName, mode: "couchdb-cli-benchmark", couchdbBackendUri: config.couchdbBackendUri, couchdbProxyUri: config.couchdbProxyUri, couchdbDbname: config.couchdbDbname, + managedCouchdb: config.managedCouchdb, rttRequestedMs: config.requestedRttMs, proxyApplied: proxy.applied, proxyNote: proxy.note, @@ -300,7 +316,9 @@ async function main(): Promise { ); } finally { await proxy.stop(); - await stopCouchdb().catch(() => {}); + if (config.managedCouchdb) { + await stopCouchdb().catch(() => {}); + } } } diff --git a/src/apps/cli/testdeno/bench-latency-sweep.ts b/src/apps/cli/testdeno/bench-latency-sweep.ts new file mode 100644 index 00000000..d69de28c --- /dev/null +++ b/src/apps/cli/testdeno/bench-latency-sweep.ts @@ -0,0 +1,133 @@ +type SweepResult = { + name: string; + runner: "p2p" | "couchdb"; + rttMs?: number; + result: Record; +}; + +function readEnvString(name: string, fallback: string): string { + const value = Deno.env.get(name)?.trim(); + return value && value.length > 0 ? value : fallback; +} + +function timestamp(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` + + `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` + ); +} + +function parseRttList(raw: string): number[] { + const values = raw + .split(",") + .map((value) => Number(value.trim())) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.floor(value)); + if (values.length === 0) { + throw new Error(`BENCH_SWEEP_RTT_MS must contain at least one positive number, got '${raw}'`); + } + return values; +} + +function buildBaseEnv(): Record { + return { + BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"), + BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"), + BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"), + BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"), + BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"), + BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), + BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"), + BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"), + }; +} + +async function runBenchmark(options: { + taskName: "bench:p2p" | "bench:couchdb"; + name: string; + outputDir: string; + env: Record; +}): Promise> { + const resultPath = `${options.outputDir}/${options.name}.json`; + const env = { + ...Deno.env.toObject(), + ...options.env, + BENCH_RESULT_JSON: resultPath, + }; + + console.log(`[latency-sweep] running ${options.name}`); + const child = new Deno.Command("deno", { + args: ["task", options.taskName], + cwd: import.meta.dirname, + env, + stdin: "null", + stdout: "inherit", + stderr: "inherit", + }).spawn(); + const status = await child.status; + if (status.code !== 0) { + throw new Error(`benchmark failed: ${options.name} (exit ${status.code})`); + } + return JSON.parse(await Deno.readTextFile(resultPath)) as Record; +} + +async function main(): Promise { + const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`); + const outputDir = `${outRoot}/latency-sweep-${timestamp()}`; + const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300")); + const base = buildBaseEnv(); + + await Deno.mkdir(outputDir, { recursive: true }); + + const results: SweepResult[] = []; + if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") { + const p2pResult = await runBenchmark({ + taskName: "bench:p2p", + name: "p2p-direct-local", + outputDir, + env: { + ...base, + BENCH_CASE: "p2p-direct-local", + BENCH_TURN_SERVERS: "", + }, + }); + results.push({ name: "p2p-direct-local", runner: "p2p", result: p2pResult }); + } + + for (const rtt of rtts) { + const name = `couchdb-rtt-${rtt}ms`; + const couchdbResult = await runBenchmark({ + taskName: "bench:couchdb", + name, + outputDir, + env: { + ...base, + BENCH_CASE: name, + BENCH_COUCHDB_RTT_MS: String(rtt), + }, + }); + results.push({ name, runner: "couchdb", rttMs: rtt, result: couchdbResult }); + } + + const summary = { + generatedAt: new Date().toISOString(), + outputDir, + note: + "This sweep models additional remote CouchDB request latency through the existing HTTP proxy. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.", + rtts, + results, + }; + await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); + console.log(JSON.stringify(summary, null, 2)); + console.log(`[latency-sweep] result directory: ${outputDir}`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error("[Fatal Error]", error); + Deno.exit(1); + }); +} diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts new file mode 100644 index 00000000..7997845e --- /dev/null +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -0,0 +1,198 @@ +type BenchmarkCase = { + name: string; + runner: "p2p" | "couchdb"; + description: string; + dataPath: string; + trustBoundary: string; + env: Record; +}; + +function readEnvString(name: string, fallback: string): string { + const value = Deno.env.get(name)?.trim(); + return value && value.length > 0 ? value : fallback; +} + +function timestamp(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return ( + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` + + `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` + ); +} + +function buildBaseEnv(): Record { + return { + BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"), + BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"), + BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"), + BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"), + BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"), + BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), + BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"), + BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"), + }; +} + +function buildCases(): BenchmarkCase[] { + const base = buildBaseEnv(); + const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20"); + const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120"); + const localTurnServers = readEnvString("BENCH_LOCAL_TURN_SERVERS", "turn:127.0.0.1:3478"); + + return [ + { + name: "couchdb-baseline", + runner: "couchdb", + description: "Standard self-hosted CouchDB path through a local latency proxy.", + dataPath: "Device A -> CouchDB -> Device B", + trustBoundary: "CouchDB operator and network path", + env: { + ...base, + BENCH_CASE: "couchdb-baseline", + BENCH_COUCHDB_RTT_MS: couchdbRtt, + }, + }, + { + name: "p2p-direct-local", + runner: "p2p", + description: "Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.", + dataPath: "Device A -> Device B", + trustBoundary: "Nostr relay for signalling metadata; no TURN relay", + env: { + ...base, + BENCH_CASE: "p2p-direct-local", + BENCH_TURN_SERVERS: "", + }, + }, + { + name: "couchdb-tethering-vpn-proxy", + runner: "couchdb", + description: + "Approximate smartphone tethering/VPN remote-database path using an HTTP latency proxy. This does not model loss, jitter, MTU, or VPN encapsulation.", + dataPath: "Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B", + trustBoundary: "VPN/network path and CouchDB operator", + env: { + ...base, + BENCH_CASE: "couchdb-tethering-vpn-proxy", + BENCH_COUCHDB_RTT_MS: tetheringVpnRtt, + }, + }, + { + name: "p2p-smartphone-vpn-direct", + runner: "p2p", + description: + "Direct P2P case name for smartphone tethering/VPN measurements. In this local runner it is unshaped and should be treated as a wiring check unless executed on that network.", + dataPath: "Device A -> Device B when WebRTC direct connectivity succeeds", + trustBoundary: "Smartphone/VPN routing policy plus Nostr signalling metadata", + env: { + ...base, + BENCH_CASE: "p2p-smartphone-vpn-direct", + BENCH_TURN_SERVERS: "", + }, + }, + { + name: "p2p-user-turn", + runner: "p2p", + description: "Optional fallback path through a local user-controlled TURN server.", + dataPath: "Device A -> user-controlled TURN -> Device B", + trustBoundary: "User-controlled TURN server", + env: { + ...base, + BENCH_CASE: "p2p-user-turn", + BENCH_TURN_SERVERS: localTurnServers, + }, + }, + ]; +} + +async function runCase(testCase: BenchmarkCase, outputDir: string): Promise> { + const resultPath = `${outputDir}/${testCase.name}.json`; + const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb"; + const env = { + ...Deno.env.toObject(), + ...testCase.env, + BENCH_RESULT_JSON: resultPath, + }; + + console.log(`[bench-cases] running ${testCase.name}: ${testCase.description}`); + const command = new Deno.Command("deno", { + args: ["task", taskName], + cwd: import.meta.dirname, + env, + stdin: "null", + stdout: "inherit", + stderr: "inherit", + }); + + const child = command.spawn(); + const status = await child.status; + if (status.code !== 0) { + throw new Error(`case failed: ${testCase.name} (exit ${status.code})`); + } + + const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record; + return { + ...testCase, + result, + }; +} + +function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { + const requested = readEnvString("BENCH_CASES", "couchdb-baseline,p2p-direct-local"); + const names = requested + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); + const byName = new Map(allCases.map((c) => [c.name, c])); + return names.map((name) => { + const found = byName.get(name); + if (!found) { + throw new Error(`Unknown BENCH_CASES entry '${name}'. Available: ${allCases.map((c) => c.name).join(", ")}`); + } + return found; + }); +} + +async function main(): Promise { + const outRoot = readEnvString("BENCH_CASES_ROOT", `${import.meta.dirname}/bench-results`); + const outputDir = `${outRoot}/cases-${timestamp()}`; + await Deno.mkdir(outputDir, { recursive: true }); + + const allCases = buildCases(); + const cases = selectCases(allCases); + await Deno.writeTextFile( + `${outputDir}/case-manifest.json`, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + selectedCases: cases, + availableCases: allCases, + }, + null, + 2 + ) + ); + + const results: Record[] = []; + for (const testCase of cases) { + results.push(await runCase(testCase, outputDir)); + } + + const summary = { + generatedAt: new Date().toISOString(), + outputDir, + results, + }; + await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); + console.log(JSON.stringify(summary, null, 2)); + console.log(`[bench-cases] result directory: ${outputDir}`); +} + +if (import.meta.main) { + main().catch((error) => { + console.error("[Fatal Error]", error); + Deno.exit(1); + }); +} diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts index cbc920f3..e2494287 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -1,15 +1,23 @@ import { TempDir } from "./helpers/temp.ts"; import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; -import { discoverPeer, maybeStartLocalRelay, stopLocalRelayIfStarted } from "./helpers/p2p.ts"; +import { + discoverPeer, + maybeStartCoturn, + maybeStartLocalRelay, + stopCoturnIfStarted, + stopLocalRelayIfStarted, +} from "./helpers/p2p.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; type BenchmarkConfig = { + caseName: string; relay: string; appId: string; roomId: string; passphrase: string; + turnServers: string; datasetDirName: string; datasetSeed: string; mdFileCount: number; @@ -61,10 +69,12 @@ function formatBytes(value: number): string { function buildConfig(): BenchmarkConfig { return { + caseName: readEnvString("BENCH_CASE", "p2p-direct-local"), relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"), appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"), roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`), passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), + turnServers: readEnvString("BENCH_TURN_SERVERS", ""), datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), @@ -107,6 +117,7 @@ async function main(): Promise { const resultPath = readOptionalResultPath(); const relayStarted = await maybeStartLocalRelay(config.relay); + const coturnStarted = await maybeStartCoturn(config.turnServers); await using workDir = await TempDir.create("livesync-cli-p2p-bench"); const hostVault = workDir.join("vault-host"); @@ -122,8 +133,24 @@ async function main(): Promise { ]); await Promise.all([ - applyP2pSettings(hostSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"), - applyP2pSettings(clientSettings, config.roomId, config.passphrase, config.appId, config.relay, "~.*"), + applyP2pSettings( + hostSettings, + config.roomId, + config.passphrase, + config.appId, + config.relay, + "~.*", + config.turnServers + ), + applyP2pSettings( + clientSettings, + config.roomId, + config.passphrase, + config.appId, + config.relay, + "~.*", + config.turnServers + ), ]); await Promise.all([ @@ -179,8 +206,11 @@ async function main(): Promise { } const result = { + caseName: config.caseName, mode: "p2p-cli-benchmark", relay: config.relay, + turnServers: config.turnServers, + turnEnabled: config.turnServers.trim().length > 0, appId: config.appId, roomId: config.roomId, datasetSeed: config.datasetSeed, @@ -211,6 +241,7 @@ async function main(): Promise { ); } finally { await host.stop(); + await stopCoturnIfStarted(coturnStarted); await stopLocalRelayIfStarted(relayStarted); } } diff --git a/src/apps/cli/testdeno/deno.json b/src/apps/cli/testdeno/deno.json index 2fd51833..095ab2a9 100644 --- a/src/apps/cli/testdeno/deno.json +++ b/src/apps/cli/testdeno/deno.json @@ -17,6 +17,8 @@ "test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts", "bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts", "bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts", + "bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts", + "bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts", "bench:item1": "bash ./bench-run-item1.sh", "bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh", "test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts", diff --git a/src/apps/cli/testdeno/helpers/net.ts b/src/apps/cli/testdeno/helpers/net.ts index fa5debd0..35a9d473 100644 --- a/src/apps/cli/testdeno/helpers/net.ts +++ b/src/apps/cli/testdeno/helpers/net.ts @@ -9,7 +9,7 @@ function sleep(ms: number): Promise { } async function connectWithTimeout(hostname: string, port: number, timeoutMs: number): Promise { - let timer: number | undefined; + let timer: ReturnType | undefined; try { const connPromise = Deno.connect({ hostname, port }); const timeoutPromise = new Promise((_, reject) => { diff --git a/src/apps/cli/testdeno/helpers/p2p.ts b/src/apps/cli/testdeno/helpers/p2p.ts index a04b080b..6be7ebcf 100644 --- a/src/apps/cli/testdeno/helpers/p2p.ts +++ b/src/apps/cli/testdeno/helpers/p2p.ts @@ -76,8 +76,10 @@ export async function discoverPeer( } export async function maybeStartLocalRelay(relay: string): Promise { - if (!isLocalP2pRelay(relay)) return false; - await startP2pRelay(); + const shouldStart = isLocalP2pRelay(relay); + if (shouldStart) { + await startP2pRelay(); + } const endpoint = parseRelayEndpoint(relay); await waitForPort(endpoint.hostname, endpoint.port, { timeoutMs: Number(Deno.env.get("LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS") ?? "15000"), @@ -86,8 +88,10 @@ export async function maybeStartLocalRelay(relay: string): Promise { }); // Docker proxy accepts TCP connections instantly before the container's internal process is fully ready. // Wait an additional few seconds to ensure strfry is actually accepting WebSockets. - await sleep(3000); - return true; + if (shouldStart) { + await sleep(3000); + } + return shouldStart; } export async function stopLocalRelayIfStarted(started: boolean): Promise { diff --git a/test/bench-network/.gitignore b/test/bench-network/.gitignore new file mode 100644 index 00000000..21f92d7c --- /dev/null +++ b/test/bench-network/.gitignore @@ -0,0 +1 @@ +bench-results/ diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner new file mode 100644 index 00000000..759be55e --- /dev/null +++ b/test/bench-network/Dockerfile.runner @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ \ + && rm -rf /var/lib/apt/lists/* + +ENV DENO_INSTALL=/usr/local +RUN curl -fsSL https://deno.land/install.sh | sh + +WORKDIR /workspace + +COPY package.json package-lock.json ./ +COPY src/apps/cli/package.json ./src/apps/cli/package.json +COPY src/apps/webapp/package.json ./src/apps/webapp/package.json +COPY src/apps/webpeer/package.json ./src/apps/webpeer/package.json +RUN npm ci + +COPY . . +RUN npm run build -w self-hosted-livesync-cli + +WORKDIR /workspace/src/apps/cli/testdeno + +RUN deno cache --lock=deno.lock \ + bench-network-cases.ts \ + bench-latency-sweep.ts \ + bench-p2p.ts \ + bench-couchdb.ts + +COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench +RUN chmod +x /usr/local/bin/run-livesync-bench + +CMD ["run-livesync-bench"] diff --git a/test/bench-network/README.md b/test/bench-network/README.md new file mode 100644 index 00000000..7ccb166a --- /dev/null +++ b/test/bench-network/README.md @@ -0,0 +1,90 @@ +# Network benchmark package + +This directory packages the CLI benchmark cases with Docker Compose. It is +intended for reproducible local benchmark runs where CouchDB, the Nostr +signalling relay, optional TURN, and the benchmark runner are fixed by the +Compose file. + +## Quick smoke run + +From the repository root: + +```bash +docker compose -f test/bench-network/compose.yml run --rm bench-runner +``` + +By default this runs: + +- `couchdb-baseline` +- `p2p-direct-local` + +The dataset is intentionally small by default. Results are written to +`test/bench-network/bench-results/`. + +## GitHub Actions smoke run + +`.github/workflows/cli-p2p-compose-smoke.yml` provides a manual +`workflow_dispatch` smoke run for the same Compose package. It is intentionally +not a required check yet, because WebRTC peer discovery can still be slow or +environment-sensitive on GitHub-hosted runners. Keep the dataset small and use +the uploaded JSON artefact to inspect whether failures are caused by peer +discovery, synchronisation, CouchDB startup, or Docker networking. + +## Select cases + +```bash +BENCH_CASES=couchdb-baseline,p2p-direct-local,p2p-user-turn \ +docker compose -f test/bench-network/compose.yml --profile turn run --rm bench-runner +``` + +Available local cases: + +- `couchdb-baseline` +- `p2p-direct-local` +- `couchdb-tethering-vpn-proxy` +- `p2p-smartphone-vpn-direct` +- `p2p-user-turn` + +`p2p-smartphone-vpn-direct` is a structural case name. When it is run inside +this Compose package it is not a real smartphone tethering/VPN measurement; it +uses the local Compose network. Use it only for wiring checks unless the runner +is executed in an actual tethered/VPN environment. + +## Dataset and latency controls + +```bash +BENCH_MD_FILE_COUNT=100 \ +BENCH_MD_MIN_SIZE_BYTES=512 \ +BENCH_MD_MAX_SIZE_BYTES=2048 \ +BENCH_BIN_FILE_COUNT=25 \ +BENCH_BIN_SIZE_BYTES=8192 \ +BENCH_COUCHDB_RTT_MS=20 \ +BENCH_PEERS_TIMEOUT=60 \ +docker compose -f test/bench-network/compose.yml run --rm bench-runner +``` + +The current CouchDB latency model is the existing HTTP proxy inside +`bench-couchdb.ts`. It models a remote database path with additional request +latency, but it does not model packet loss, jitter, MTU, bandwidth limits, +bufferbloat, or VPN encapsulation. + +## Latency sweep + +To run P2P once and CouchDB at several requested RTT values: + +```bash +BENCH_COMMAND=latency-sweep \ +BENCH_SWEEP_RTT_MS=20,50,100,150,300 \ +BENCH_MD_FILE_COUNT=100 \ +BENCH_MD_MIN_SIZE_BYTES=512 \ +BENCH_MD_MAX_SIZE_BYTES=2048 \ +BENCH_BIN_FILE_COUNT=25 \ +BENCH_BIN_SIZE_BYTES=8192 \ +BENCH_SYNC_TIMEOUT=300 \ +BENCH_PEERS_TIMEOUT=60 \ +docker compose -f test/bench-network/compose.yml run --rm bench-runner +``` + +This sweep is useful for finding where the remote CouchDB path falls behind the +local direct P2P path in the current HTTP-proxy latency model. It should not be +presented as a full smartphone/VPN model. diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml new file mode 100644 index 00000000..e54ec0e9 --- /dev/null +++ b/test/bench-network/compose.yml @@ -0,0 +1,93 @@ +services: + couchdb: + image: couchdb:3.5.0 + environment: + COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin} + COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword} + COUCHDB_SINGLE_NODE: "true" + healthcheck: + test: + [ + "CMD-SHELL", + "curl -fsS -u ${BENCH_COUCHDB_USER:-admin}:${BENCH_COUCHDB_PASSWORD:-testpassword} http://127.0.0.1:5984/_up >/dev/null", + ] + interval: 2s + timeout: 5s + retries: 30 + + nostr-relay: + image: ghcr.io/hoytech/strfry:latest + entrypoint: sh + command: + - -lc + - | + cat > /tmp/strfry.conf <<'EOF' + db = "./strfry-db/" + + relay { + bind = "0.0.0.0" + port = 7777 + nofiles = 100000 + + info { + name = "livesync bench relay" + description = "local relay for livesync compose benchmarks" + } + + maxWebsocketPayloadSize = 131072 + autoPingSeconds = 55 + + writePolicy { + plugin = "" + } + } + EOF + exec /app/strfry --config /tmp/strfry.conf relay + tmpfs: + - /app/strfry-db:rw,size=256m + + coturn: + image: coturn/coturn:latest + command: + - --log-file=stdout + - --listening-port=3478 + - --user=${BENCH_TURN_USERNAME:-testuser}:${BENCH_TURN_CREDENTIAL:-testpass} + - --realm=${BENCH_TURN_REALM:-livesync.test} + profiles: + - turn + + bench-runner: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.runner + depends_on: + couchdb: + condition: service_healthy + nostr-relay: + condition: service_started + environment: + BENCH_COMMAND: ${BENCH_COMMAND:-cases} + BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local} + BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300} + BENCH_SWEEP_INCLUDE_P2P: ${BENCH_SWEEP_INCLUDE_P2P:-true} + BENCH_COUCHDB_MANAGED: "false" + BENCH_COUCHDB_BACKEND_URI: http://couchdb:5984 + BENCH_COUCHDB_URI: http://127.0.0.1:15989 + BENCH_COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin} + BENCH_COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword} + BENCH_RELAY: ws://nostr-relay:7777/ + BENCH_LOCAL_TURN_SERVERS: turn:coturn:3478 + BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20} + BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512} + BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} + BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} + BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} + BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-20} + BENCH_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120} + BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} + BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} + BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + volumes: + - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results diff --git a/test/bench-network/run-bench.sh b/test/bench-network/run-bench.sh new file mode 100644 index 00000000..4ca9eee3 --- /dev/null +++ b/test/bench-network/run-bench.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh +set -eu + +case "${BENCH_COMMAND:-cases}" in + cases) + exec deno task bench:cases + ;; + latency-sweep) + exec deno task bench:latency-sweep + ;; + *) + echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2 + echo "Expected one of: cases, latency-sweep" >&2 + exit 2 + ;; +esac From 18e8b239bfac7350b2e0f9d4dd04442ffafd443b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 06:35:26 +0000 Subject: [PATCH 005/170] Run CLI network smoke on pull requests --- .github/workflows/cli-p2p-compose-smoke.yml | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cli-p2p-compose-smoke.yml b/.github/workflows/cli-p2p-compose-smoke.yml index 60455d5a..e5230640 100644 --- a/.github/workflows/cli-p2p-compose-smoke.yml +++ b/.github/workflows/cli-p2p-compose-smoke.yml @@ -1,11 +1,18 @@ # Run the Compose-packaged CLI P2P smoke benchmark. # -# This workflow is intentionally manual/non-required at first. It exercises the -# local Compose package for CouchDB + Nostr relay + CLI runner, and uploads the +# This workflow is intentionally non-required at first. It exercises the local +# Compose package for CouchDB + Nostr relay + CLI runner, and uploads the # benchmark JSON results for inspection. name: cli-p2p-compose-smoke on: + pull_request: + paths: + - '.github/workflows/cli-p2p-compose-smoke.yml' + - 'package.json' + - 'package-lock.json' + - 'src/apps/cli/**' + - 'test/bench-network/**' workflow_dispatch: inputs: cases: @@ -45,13 +52,13 @@ jobs: - name: Run Compose P2P smoke benchmark env: - BENCH_CASES: ${{ inputs.cases }} - BENCH_MD_FILE_COUNT: ${{ inputs.md_files }} + BENCH_CASES: ${{ inputs.cases || 'couchdb-baseline,p2p-direct-local' }} + BENCH_MD_FILE_COUNT: ${{ inputs.md_files || '2' }} BENCH_MD_MIN_SIZE_BYTES: '128' BENCH_MD_MAX_SIZE_BYTES: '256' - BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files }} + BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files || '1' }} BENCH_BIN_SIZE_BYTES: '512' - BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms }} + BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms || '20' }} BENCH_SYNC_TIMEOUT: '180' BENCH_PEERS_TIMEOUT: '90' BENCH_LIVESYNC_TEST_TEE: '0' From 2fcdbbc4da4b1cbdb35c5305e28c2dab9422cf5e Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 06:48:17 +0000 Subject: [PATCH 006/170] Stabilise CLI P2P smoke readiness --- .github/workflows/cli-p2p-compose-smoke.yml | 9 ++++++++- src/apps/cli/testdeno/bench-p2p.ts | 9 ++++++--- test/bench-network/README.md | 4 ++++ test/bench-network/compose.yml | 8 +++++++- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli-p2p-compose-smoke.yml b/.github/workflows/cli-p2p-compose-smoke.yml index e5230640..85716039 100644 --- a/.github/workflows/cli-p2p-compose-smoke.yml +++ b/.github/workflows/cli-p2p-compose-smoke.yml @@ -60,10 +60,17 @@ jobs: BENCH_BIN_SIZE_BYTES: '512' BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms || '20' }} BENCH_SYNC_TIMEOUT: '180' - BENCH_PEERS_TIMEOUT: '90' + BENCH_PEERS_TIMEOUT: '20' + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' BENCH_LIVESYNC_TEST_TEE: '0' run: docker compose -f test/bench-network/compose.yml run --rm bench-runner + - name: Show Compose diagnostics + if: failure() + run: | + docker compose -f test/bench-network/compose.yml ps + docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay + - name: Upload benchmark results if: always() uses: actions/upload-artifact@v4 diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts index e2494287..2dfb2f57 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -179,9 +179,9 @@ async function main(): Promise { await host.waitUntilContains("P2P host is running", 20000); const hostReadyElapsed = nowMs() - hostReadyStart; - const peerDiscoveryStart = nowMs(); + const peerDiscoveryCommandStart = nowMs(); const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); - const peerDiscoveryElapsed = nowMs() - peerDiscoveryStart; + const peerDiscoveryCommandElapsed = nowMs() - peerDiscoveryCommandStart; const syncStart = nowMs(); await runCliOrFail( @@ -223,7 +223,10 @@ async function main(): Promise { binFileCount: seedFiles.binCount, mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), - peerDiscoveryElapsedMs: Number(peerDiscoveryElapsed.toFixed(1)), + peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, + peerDiscoveryCommandElapsedMs: Number(peerDiscoveryCommandElapsed.toFixed(1)), + peerDiscoveryNote: + "p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.", syncElapsedMs: Number(syncElapsed.toFixed(1)), throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)), throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)), diff --git a/test/bench-network/README.md b/test/bench-network/README.md index 7ccb166a..e702e7eb 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -68,6 +68,10 @@ The current CouchDB latency model is the existing HTTP proxy inside latency, but it does not model packet loss, jitter, MTU, bandwidth limits, bufferbloat, or VPN encapsulation. +For P2P runs, `BENCH_PEERS_TIMEOUT` is passed to `p2p-peers`. That command waits +for the requested observation window before printing discovered peers, so the +reported peer discovery command time should not be read as first-peer latency. + ## Latency sweep To run P2P once and CouchDB at several requested RTT values: diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index e54ec0e9..ca0f6a28 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -45,6 +45,11 @@ services: exec /app/strfry --config /tmp/strfry.conf relay tmpfs: - /app/strfry-db:rw,size=256m + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"] + interval: 2s + timeout: 5s + retries: 30 coturn: image: coturn/coturn:latest @@ -64,7 +69,7 @@ services: couchdb: condition: service_healthy nostr-relay: - condition: service_started + condition: service_healthy environment: BENCH_COMMAND: ${BENCH_COMMAND:-cases} BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local} @@ -88,6 +93,7 @@ services: BENCH_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120} BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} volumes: - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results From 870ac93cacabe2c3e5c7669560db25722375f81b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 06:54:42 +0000 Subject: [PATCH 007/170] Lower benchmark relay file descriptor limit --- test/bench-network/compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index ca0f6a28..3427d483 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -27,7 +27,7 @@ services: relay { bind = "0.0.0.0" port = 7777 - nofiles = 100000 + nofiles = 65536 info { name = "livesync bench relay" From 33cd6a3b516ffa03a6ab33fd8a19bc0cee34dacb Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 07:03:23 +0000 Subject: [PATCH 008/170] Document CLI benchmark comparison model --- test/bench-network/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/bench-network/README.md b/test/bench-network/README.md index e702e7eb..0cc89f4e 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -50,6 +50,20 @@ this Compose package it is not a real smartphone tethering/VPN measurement; it uses the local Compose network. Use it only for wiring checks unless the runner is executed in an actual tethered/VPN environment. +## Comparison model + +The primary local comparison is between a remote-database path and a direct P2P +path: + +| Case | Data path | What is measured | What is not measured | +| --- | --- | --- | --- | +| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention | +| `p2p-direct-local` | Device A -> Device B after Nostr signalling | One CLI P2P synchronisation command over WebRTC DataChannel with TURN disabled | Public relay operation, mobile carrier behaviour, TURN relay throughput, and first-peer discovery latency | + +Use the CouchDB result as the remote-store baseline and the P2P result as the +direct-transfer comparison. The Nostr relay is used for signalling in the P2P +case, but synchronised note content is transferred over the WebRTC DataChannel. + ## Dataset and latency controls ```bash From 7b480d4c1ddf79e8bcd3f282c13863ec8ded22b0 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 07:38:54 +0000 Subject: [PATCH 009/170] Add netem smoke fixture for benchmark simulation --- test/bench-network/Dockerfile.netem | 8 ++++ test/bench-network/README.md | 24 ++++++++++ test/bench-network/compose.yml | 20 ++++++++ test/bench-network/netem-smoke.sh | 71 +++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 test/bench-network/Dockerfile.netem create mode 100644 test/bench-network/netem-smoke.sh diff --git a/test/bench-network/Dockerfile.netem b/test/bench-network/Dockerfile.netem new file mode 100644 index 00000000..108bb826 --- /dev/null +++ b/test/bench-network/Dockerfile.netem @@ -0,0 +1,8 @@ +FROM alpine:3.22 + +RUN apk add --no-cache iproute2 + +COPY test/bench-network/netem-smoke.sh /usr/local/bin/livesync-netem-smoke +RUN chmod +x /usr/local/bin/livesync-netem-smoke + +CMD ["livesync-netem-smoke"] diff --git a/test/bench-network/README.md b/test/bench-network/README.md index 0cc89f4e..e0fe6b7c 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -106,3 +106,27 @@ docker compose -f test/bench-network/compose.yml run --rm bench-runner This sweep is useful for finding where the remote CouchDB path falls behind the local direct P2P path in the current HTTP-proxy latency model. It should not be presented as a full smartphone/VPN model. + +## Network emulation smoke + +The optional `netem` profile checks whether a Linux runner can apply traffic +shaping inside a Compose-managed container. This is a fixture smoke test for a +second-tier simulation design; it does not produce synchronisation performance +results by itself. + +```bash +docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke +``` + +The smoke writes `tc qdisc`, route, and interface details under +`test/bench-network/bench-results/`. Profile parameters can be overridden: + +```bash +NETEM_PROFILE=tethering-vpn \ +NETEM_DELAY_MS=140 \ +NETEM_JITTER_MS=50 \ +NETEM_LOSS_PERCENT=1.0 \ +NETEM_BANDWIDTH_MBIT=10 \ +NETEM_MTU=1380 \ +docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke +``` diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index 3427d483..23c91eb8 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -97,3 +97,23 @@ services: BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} volumes: - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + + netem-smoke: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.netem + profiles: + - netem + cap_add: + - NET_ADMIN + environment: + NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi} + NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0} + NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20} + NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5} + NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1} + NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100} + NETEM_MTU: ${NETEM_MTU:-1500} + NETEM_RESULT_ROOT: /bench-results + volumes: + - ./bench-results:/bench-results diff --git a/test/bench-network/netem-smoke.sh b/test/bench-network/netem-smoke.sh new file mode 100644 index 00000000..96e5e019 --- /dev/null +++ b/test/bench-network/netem-smoke.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env sh +set -eu + +profile="${NETEM_PROFILE:-home-wifi}" +iface="${NETEM_INTERFACE:-eth0}" +delay_ms="${NETEM_DELAY_MS:-20}" +jitter_ms="${NETEM_JITTER_MS:-5}" +loss_percent="${NETEM_LOSS_PERCENT:-0.1}" +bandwidth_mbit="${NETEM_BANDWIDTH_MBIT:-100}" +mtu="${NETEM_MTU:-1500}" +out_root="${NETEM_RESULT_ROOT:-/bench-results}" +timestamp="$(date -u +%Y%m%d-%H%M%S)" +out_dir="${out_root}/netem-smoke-${timestamp}" +out_file="${out_dir}/summary.json" + +mkdir -p "$out_dir" + +if ! ip link show "$iface" >/dev/null 2>&1; then + echo "Network interface '$iface' was not found" >&2 + ip addr >&2 + exit 2 +fi + +ip link set dev "$iface" mtu "$mtu" +tc qdisc del dev "$iface" root >/dev/null 2>&1 || true +tc qdisc add dev "$iface" root netem \ + delay "${delay_ms}ms" "${jitter_ms}ms" \ + loss "${loss_percent}%" \ + rate "${bandwidth_mbit}mbit" + +json_lines() { + awk ' + { + gsub(/\\/, "\\\\"); + gsub(/"/, "\\\""); + printf "%s \"%s\"", (NR == 1 ? "" : ",\n"), $0; + } + ' +} + +ip_addr="$(ip addr show "$iface" | json_lines)" +ip_route="$(ip route | json_lines)" +tc_qdisc="$(tc qdisc show dev "$iface" | json_lines)" + +cat > "$out_file" < Date: Wed, 8 Jul 2026 10:02:41 +0000 Subject: [PATCH 010/170] Add CouchDB netem shim benchmark --- src/apps/cli/testdeno/bench-couchdb.ts | 9 +++ src/apps/cli/testdeno/bench-network-cases.ts | 35 +++++++++ test/bench-network/Dockerfile.shim | 10 +++ test/bench-network/README.md | 30 ++++++++ test/bench-network/compose.yml | 62 +++++++++++++++ test/bench-network/netem-tcp-shim.sh | 80 ++++++++++++++++++++ 6 files changed, 226 insertions(+) create mode 100644 test/bench-network/Dockerfile.shim create mode 100644 test/bench-network/netem-tcp-shim.sh diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/src/apps/cli/testdeno/bench-couchdb.ts index 255aaafd..d337efa2 100644 --- a/src/apps/cli/testdeno/bench-couchdb.ts +++ b/src/apps/cli/testdeno/bench-couchdb.ts @@ -23,6 +23,9 @@ type BenchmarkConfig = { passphrase: string; encrypt: boolean; managedCouchdb: boolean; + simulationTier: string; + networkProfile: string; + networkModel: string; }; function readEnvString(name: string, fallback: string): string { @@ -90,6 +93,9 @@ function buildConfig(): BenchmarkConfig { passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), encrypt: readEnvBool("BENCH_ENCRYPT", true), managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true), + simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"), + networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "http-latency-proxy"), + networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"), }; } @@ -285,6 +291,9 @@ async function main(): Promise { couchdbProxyUri: config.couchdbProxyUri, couchdbDbname: config.couchdbDbname, managedCouchdb: config.managedCouchdb, + simulationTier: config.simulationTier, + networkProfile: config.networkProfile, + networkModel: config.networkModel, rttRequestedMs: config.requestedRttMs, proxyApplied: proxy.applied, proxyNote: proxy.note, diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts index 7997845e..c4d5072d 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -40,6 +40,7 @@ function buildCases(): BenchmarkCase[] { const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20"); const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120"); const localTurnServers = readEnvString("BENCH_LOCAL_TURN_SERVERS", "turn:127.0.0.1:3478"); + const shimCouchdbUri = readEnvString("BENCH_SHIM_COUCHDB_URI", "http://couchdb-shim:5984"); return [ { @@ -79,6 +80,40 @@ function buildCases(): BenchmarkCase[] { BENCH_COUCHDB_RTT_MS: tetheringVpnRtt, }, }, + { + name: "couchdb-netem-home-wifi", + runner: "couchdb", + description: + "Tier 2 CouchDB path through the Compose netem TCP shim using the home-wifi profile.", + dataPath: "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", + trustBoundary: "CouchDB operator and constrained network shim", + env: { + ...base, + BENCH_CASE: "couchdb-netem-home-wifi", + BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri, + BENCH_COUCHDB_RTT_MS: "1", + BENCH_SIMULATION_TIER: "2", + BENCH_NETWORK_PROFILE: "home-wifi", + BENCH_NETWORK_MODEL: "compose-netem-tcp-shim", + }, + }, + { + name: "couchdb-netem-tethering-vpn", + runner: "couchdb", + description: + "Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.", + dataPath: "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", + trustBoundary: "CouchDB operator and constrained smartphone/VPN-like network shim", + env: { + ...base, + BENCH_CASE: "couchdb-netem-tethering-vpn", + BENCH_COUCHDB_BACKEND_URI: shimCouchdbUri, + BENCH_COUCHDB_RTT_MS: "1", + BENCH_SIMULATION_TIER: "2", + BENCH_NETWORK_PROFILE: "tethering-vpn", + BENCH_NETWORK_MODEL: "compose-netem-tcp-shim", + }, + }, { name: "p2p-smartphone-vpn-direct", runner: "p2p", diff --git a/test/bench-network/Dockerfile.shim b/test/bench-network/Dockerfile.shim new file mode 100644 index 00000000..3abc08c9 --- /dev/null +++ b/test/bench-network/Dockerfile.shim @@ -0,0 +1,10 @@ +# syntax=docker/dockerfile:1 + +FROM alpine:3.22 + +RUN apk add --no-cache iproute2 socat + +COPY test/bench-network/netem-tcp-shim.sh /usr/local/bin/livesync-netem-tcp-shim +RUN chmod +x /usr/local/bin/livesync-netem-tcp-shim + +CMD ["livesync-netem-tcp-shim"] diff --git a/test/bench-network/README.md b/test/bench-network/README.md index e0fe6b7c..afc6f517 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -42,6 +42,8 @@ Available local cases: - `couchdb-baseline` - `p2p-direct-local` - `couchdb-tethering-vpn-proxy` +- `couchdb-netem-home-wifi` +- `couchdb-netem-tethering-vpn` - `p2p-smartphone-vpn-direct` - `p2p-user-turn` @@ -130,3 +132,31 @@ NETEM_BANDWIDTH_MBIT=10 \ NETEM_MTU=1380 \ docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke ``` + +## Shimmed CouchDB benchmark + +The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding +container that applies `tc netem`. This is a manual Tier 2 synchronisation +measurement path; it is intentionally separate from required pull-request CI. + +```bash +docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-runner-shim +``` + +The default profile is `home-wifi`. A smartphone/VPN-like profile can be +requested by overriding both the shim parameters and the benchmark case: + +```bash +NETEM_PROFILE=tethering-vpn \ +NETEM_DELAY_MS=140 \ +NETEM_JITTER_MS=50 \ +NETEM_LOSS_PERCENT=1.0 \ +NETEM_BANDWIDTH_MBIT=10 \ +NETEM_MTU=1380 \ +BENCH_CASES=couchdb-netem-tethering-vpn \ +docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-runner-shim +``` + +The benchmark result records `simulationTier`, `networkProfile`, and +`networkModel`. The shim also writes its applied `tc qdisc`, route, and +interface state under `test/bench-network/bench-results/`. diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index 23c91eb8..29572c69 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -98,6 +98,68 @@ services: volumes: - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + couchdb-shim: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.shim + profiles: + - shim + depends_on: + couchdb: + condition: service_healthy + cap_add: + - NET_ADMIN + environment: + NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi} + NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0} + NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20} + NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5} + NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1} + NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100} + NETEM_MTU: ${NETEM_MTU:-1500} + NETEM_RESULT_ROOT: /bench-results + SHIM_LISTEN_PORT: 5984 + SHIM_TARGET_HOST: couchdb + SHIM_TARGET_PORT: 5984 + volumes: + - ./bench-results:/bench-results + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 5984"] + interval: 2s + timeout: 5s + retries: 30 + + bench-runner-shim: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.runner + profiles: + - shim + depends_on: + couchdb-shim: + condition: service_healthy + environment: + BENCH_COMMAND: ${BENCH_COMMAND:-cases} + BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi} + BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_COUCHDB_MANAGED: "false" + BENCH_COUCHDB_BACKEND_URI: http://couchdb-shim:5984 + BENCH_SHIM_COUCHDB_URI: http://couchdb-shim:5984 + BENCH_COUCHDB_URI: http://127.0.0.1:15989 + BENCH_COUCHDB_USER: ${BENCH_COUCHDB_USER:-admin} + BENCH_COUCHDB_PASSWORD: ${BENCH_COUCHDB_PASSWORD:-testpassword} + BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20} + BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512} + BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} + BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} + BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} + BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-1} + BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} + BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + volumes: + - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + netem-smoke: build: context: ../.. diff --git a/test/bench-network/netem-tcp-shim.sh b/test/bench-network/netem-tcp-shim.sh new file mode 100644 index 00000000..35c0b0b4 --- /dev/null +++ b/test/bench-network/netem-tcp-shim.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env sh +set -eu + +profile="${NETEM_PROFILE:-home-wifi}" +iface="${NETEM_INTERFACE:-eth0}" +delay_ms="${NETEM_DELAY_MS:-20}" +jitter_ms="${NETEM_JITTER_MS:-5}" +loss_percent="${NETEM_LOSS_PERCENT:-0.1}" +bandwidth_mbit="${NETEM_BANDWIDTH_MBIT:-100}" +mtu="${NETEM_MTU:-1500}" +listen_port="${SHIM_LISTEN_PORT:-5984}" +target_host="${SHIM_TARGET_HOST:-couchdb}" +target_port="${SHIM_TARGET_PORT:-5984}" +out_root="${NETEM_RESULT_ROOT:-/bench-results}" +timestamp="$(date -u +%Y%m%d-%H%M%S)" +out_dir="${out_root}/netem-shim-${profile}-${timestamp}" +out_file="${out_dir}/summary.json" + +json_lines() { + awk ' + { + gsub(/\\/, "\\\\"); + gsub(/"/, "\\\""); + printf "%s \"%s\"", (NR == 1 ? "" : ",\n"), $0; + } + ' +} + +mkdir -p "$out_dir" + +if ! ip link show "$iface" >/dev/null 2>&1; then + echo "Network interface '$iface' was not found" >&2 + ip addr >&2 + exit 2 +fi + +ip link set dev "$iface" mtu "$mtu" +tc qdisc del dev "$iface" root >/dev/null 2>&1 || true +tc qdisc add dev "$iface" root netem \ + delay "${delay_ms}ms" "${jitter_ms}ms" \ + loss "${loss_percent}%" \ + rate "${bandwidth_mbit}mbit" + +ip_addr="$(ip addr show "$iface" | json_lines)" +ip_route="$(ip route | json_lines)" +tc_qdisc="$(tc qdisc show dev "$iface" | json_lines)" + +cat > "$out_file" < Date: Wed, 8 Jul 2026 10:03:11 +0000 Subject: [PATCH 011/170] Record P2P selected candidate paths --- src/apps/cli/commands/p2p.ts | 78 +++++ src/apps/cli/testdeno/bench-network-cases.ts | 14 + src/apps/cli/testdeno/bench-p2p.ts | 305 ++++++++++++------- test/bench-network/README.md | 7 + 4 files changed, 293 insertions(+), 111 deletions(-) diff --git a/src/apps/cli/commands/p2p.ts b/src/apps/cli/commands/p2p.ts index 2ba76dd0..e77a0cda 100644 --- a/src/apps/cli/commands/p2p.ts +++ b/src/apps/cli/commands/p2p.ts @@ -4,12 +4,21 @@ import type { ServiceContext } from "@lib/services/base/ServiceBase"; import { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; import { LiveSyncError } from "@lib/common/LSError"; +import { getPeerConnectionStats } from "@lib/rpc/transports/DiagRTCPeerConnections.utils"; +import { appendFile } from "node:fs/promises"; type CLIP2PPeer = { peerId: string; name: string; }; +type CandidateSummary = { + id: string | "unknown"; + candidateType: string | "unknown"; + protocol: string | "unknown"; + relayProtocol: string | "unknown"; +}; + function delay(ms: number): Promise { return new Promise((resolve) => compatGlobal.setTimeout(resolve, ms)); } @@ -81,6 +90,74 @@ function resolvePeer(peers: CLIP2PPeer[], peerToken: string): CLIP2PPeer | undef return undefined; } +function getReportValue( + report: Record | undefined, + key: string +): T | "unknown" { + const value = report?.[key]; + return typeof value === "string" || typeof value === "number" ? (value as T) : "unknown"; +} + +function summariseCandidate(reports: unknown[], candidateId: string | "unknown"): CandidateSummary | undefined { + if (candidateId === "unknown") { + return undefined; + } + const report = reports.map((r) => r as Record).find((r) => r.id === candidateId); + if (!report) { + return undefined; + } + return { + id: candidateId, + candidateType: getReportValue(report, "candidateType"), + protocol: getReportValue(report, "protocol"), + relayProtocol: getReportValue(report, "relayProtocol"), + }; +} + +async function writePeerConnectionStatsIfRequested( + replicator: LiveSyncTrysteroReplicator, + peer: CLIP2PPeer +): Promise { + const outputPath = process.env.LIVESYNC_P2P_STATS_JSONL?.trim(); + if (!outputPath) { + return; + } + + const peerConnection = replicator.rawHost?.room?.getPeers()[peer.peerId]; + const stats = peerConnection ? await getPeerConnectionStats(`cli-p2p-${peer.peerId}`, peerConnection) : undefined; + const localCandidate = summariseCandidate(stats?.reports ?? [], stats?.localCandidateId ?? "unknown"); + const remoteCandidate = summariseCandidate(stats?.reports ?? [], stats?.remoteCandidateId ?? "unknown"); + const selectedPath = + localCandidate && remoteCandidate + ? `${localCandidate.candidateType}<->${remoteCandidate.candidateType}` + : "unknown"; + + const payload = { + generatedAt: new Date().toISOString(), + command: "p2p-sync", + peerId: peer.peerId, + peerName: peer.name, + candidatePathCollected: !!stats?.selectedPair, + selectedPath, + selectedPair: stats + ? { + id: stats.selectedPairId, + state: stats.state, + currentRoundTripTime: stats.currentRoundTripTime, + totalRoundTripTime: stats.totalRoundTripTime, + requestsSent: stats.requestsSent, + responsesReceived: stats.responsesReceived, + packetsDiscardedOnSend: stats.packetsDiscardedOnSend, + bytesSent: stats.bytesSent, + bytesReceived: stats.bytesReceived, + } + : undefined, + localCandidate, + remoteCandidate, + }; + await appendFile(outputPath, `${JSON.stringify(payload)}\n`, "utf8"); +} + export async function syncWithPeer( core: LiveSyncBaseCore, peerToken: string, @@ -118,6 +195,7 @@ export async function syncWithPeer( : LiveSyncError.fromError(err ?? "P2P sync failed while requesting remote sync"); } + await writePeerConnectionStatsIfRequested(replicator, targetPeer); return targetPeer; } finally { await replicator.close(); diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts index c4d5072d..c460bd11 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -65,6 +65,10 @@ function buildCases(): BenchmarkCase[] { ...base, BENCH_CASE: "p2p-direct-local", BENCH_TURN_SERVERS: "", + BENCH_SIMULATION_TIER: "1", + BENCH_NETWORK_PROFILE: "local-direct", + BENCH_NETWORK_MODEL: "local-runner-webrtc", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "turn-disabled-but-selected-ice-pair-not-collected", }, }, { @@ -125,6 +129,11 @@ function buildCases(): BenchmarkCase[] { ...base, BENCH_CASE: "p2p-smartphone-vpn-direct", BENCH_TURN_SERVERS: "", + BENCH_SIMULATION_TIER: "unmeasured", + BENCH_NETWORK_PROFILE: "smartphone-vpn-direct-placeholder", + BENCH_NETWORK_MODEL: "local-runner-no-netem", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: + "structural-placeholder-only; selected ICE pair may be collected, but the path is not shaped", }, }, { @@ -137,6 +146,11 @@ function buildCases(): BenchmarkCase[] { ...base, BENCH_CASE: "p2p-user-turn", BENCH_TURN_SERVERS: localTurnServers, + BENCH_SIMULATION_TIER: "1", + BENCH_NETWORK_PROFILE: "local-turn-fallback", + BENCH_NETWORK_MODEL: "local-runner-webrtc-turn-configured", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: + "turn-configured; selected ICE pair may still be direct or relayed, so interpret the recorded candidate types", }, }, ]; diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts index 2dfb2f57..2bbf4e5a 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -27,6 +27,42 @@ type BenchmarkConfig = { binSizeBytes: number; peersTimeoutSeconds: number; syncTimeoutSeconds: number; + simulationTier: string; + networkProfile: string; + networkModel: string; + candidatePathVerification: string; +}; + +type P2PConnectionStats = { + generatedAt: string; + command: string; + peerId: string; + peerName: string; + candidatePathCollected: boolean; + selectedPath: string; + selectedPair?: { + id: string; + state: string; + currentRoundTripTime: number | "unknown"; + totalRoundTripTime: number | "unknown"; + requestsSent: number | "unknown"; + responsesReceived: number | "unknown"; + packetsDiscardedOnSend: number | "unknown"; + bytesSent: number | "unknown"; + bytesReceived: number | "unknown"; + }; + localCandidate?: { + id: string; + candidateType: string; + protocol: string; + relayProtocol: string; + }; + remoteCandidate?: { + id: string; + candidateType: string; + protocol: string; + relayProtocol: string; + }; }; function readEnvString(name: string, fallback: string): string { @@ -84,6 +120,10 @@ function buildConfig(): BenchmarkConfig { binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)), peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20), syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240), + simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"), + networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"), + networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-runner-webrtc"), + candidatePathVerification: readEnvString("BENCH_P2P_CANDIDATE_PATH_VERIFICATION", "not-collected"), }; } @@ -112,6 +152,22 @@ function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { return [...unique.values()]; } +async function readLatestP2PConnectionStats(statsPath: string): Promise { + try { + const text = await Deno.readTextFile(statsPath); + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (lines.length === 0) { + return undefined; + } + return JSON.parse(lines[lines.length - 1]) as P2PConnectionStats; + } catch { + return undefined; + } +} + async function main(): Promise { const config = buildConfig(); const resultPath = readOptionalResultPath(); @@ -124,126 +180,153 @@ async function main(): Promise { const clientVault = workDir.join("vault-client"); const hostSettings = workDir.join("settings-host.json"); const clientSettings = workDir.join("settings-client.json"); + const p2pStatsPath = workDir.join("p2p-connection-stats.jsonl"); + const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL"); + Deno.env.set("LIVESYNC_P2P_STATS_JSONL", p2pStatsPath); - await Promise.all([ - Deno.mkdir(hostVault, { recursive: true }), - Deno.mkdir(clientVault, { recursive: true }), - initSettingsFile(hostSettings), - initSettingsFile(clientSettings), - ]); - - await Promise.all([ - applyP2pSettings( - hostSettings, - config.roomId, - config.passphrase, - config.appId, - config.relay, - "~.*", - config.turnServers - ), - applyP2pSettings( - clientSettings, - config.roomId, - config.passphrase, - config.appId, - config.relay, - "~.*", - config.turnServers - ), - ]); - - await Promise.all([ - applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase), - applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase), - ]); - - const seedFiles = await createDeterministicDataset({ - rootDir: hostVault, - datasetDirName: config.datasetDirName, - seed: config.datasetSeed, - mdCount: config.mdFileCount, - mdMinSizeBytes: config.mdMinSizeBytes, - mdMaxSizeBytes: config.mdMaxSizeBytes, - binCount: config.binFileCount, - binSizeBytes: config.binSizeBytes, - }); - - const mirrorStart = nowMs(); - await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); - const mirrorElapsed = nowMs() - mirrorStart; - - const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); try { - const hostReadyStart = nowMs(); - await host.waitUntilContains("P2P host is running", 20000); - const hostReadyElapsed = nowMs() - hostReadyStart; + await Promise.all([ + Deno.mkdir(hostVault, { recursive: true }), + Deno.mkdir(clientVault, { recursive: true }), + initSettingsFile(hostSettings), + initSettingsFile(clientSettings), + ]); - const peerDiscoveryCommandStart = nowMs(); - const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); - const peerDiscoveryCommandElapsed = nowMs() - peerDiscoveryCommandStart; + await Promise.all([ + applyP2pSettings( + hostSettings, + config.roomId, + config.passphrase, + config.appId, + config.relay, + "~.*", + config.turnServers + ), + applyP2pSettings( + clientSettings, + config.roomId, + config.passphrase, + config.appId, + config.relay, + "~.*", + config.turnServers + ), + ]); - const syncStart = nowMs(); - await runCliOrFail( - clientVault, - "--settings", - clientSettings, - "p2p-sync", - peer.id, - String(config.syncTimeoutSeconds) - ); - const syncElapsed = nowMs() - syncStart; + await Promise.all([ + applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase), + applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase), + ]); - const sampleFiles = pickSampleFiles(seedFiles.entries); - for (const sample of sampleFiles) { - const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`); - await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath); - await assertFilesEqual( - sample.absolutePath, - pulledPath, - `sample file mismatch after sync: ${sample.relativePath}` - ); - } - - const result = { - caseName: config.caseName, - mode: "p2p-cli-benchmark", - relay: config.relay, - turnServers: config.turnServers, - turnEnabled: config.turnServers.trim().length > 0, - appId: config.appId, - roomId: config.roomId, - datasetSeed: config.datasetSeed, + const seedFiles = await createDeterministicDataset({ + rootDir: hostVault, datasetDirName: config.datasetDirName, - peerId: peer.id, - peerName: peer.name, - totalFiles: seedFiles.totalFiles, - totalBytes: seedFiles.totalBytes, - mdFileCount: seedFiles.mdCount, - binFileCount: seedFiles.binCount, - mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), - hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), - peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, - peerDiscoveryCommandElapsedMs: Number(peerDiscoveryCommandElapsed.toFixed(1)), - peerDiscoveryNote: - "p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.", - syncElapsedMs: Number(syncElapsed.toFixed(1)), - throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)), - throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)), - }; + seed: config.datasetSeed, + mdCount: config.mdFileCount, + mdMinSizeBytes: config.mdMinSizeBytes, + mdMaxSizeBytes: config.mdMaxSizeBytes, + binCount: config.binFileCount, + binSizeBytes: config.binSizeBytes, + }); - if (resultPath) { - await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2)); + const mirrorStart = nowMs(); + await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); + const mirrorElapsed = nowMs() - mirrorStart; + + const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); + try { + const hostReadyStart = nowMs(); + await host.waitUntilContains("P2P host is running", 20000); + const hostReadyElapsed = nowMs() - hostReadyStart; + + const peerDiscoveryCommandStart = nowMs(); + const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); + const peerDiscoveryCommandElapsed = nowMs() - peerDiscoveryCommandStart; + + const syncStart = nowMs(); + await runCliOrFail( + clientVault, + "--settings", + clientSettings, + "p2p-sync", + peer.id, + String(config.syncTimeoutSeconds) + ); + const syncElapsed = nowMs() - syncStart; + + const sampleFiles = pickSampleFiles(seedFiles.entries); + for (const sample of sampleFiles) { + const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`); + await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath); + await assertFilesEqual( + sample.absolutePath, + pulledPath, + `sample file mismatch after sync: ${sample.relativePath}` + ); + } + + const p2pConnectionStats = await readLatestP2PConnectionStats(p2pStatsPath); + const result = { + caseName: config.caseName, + mode: "p2p-cli-benchmark", + relay: config.relay, + turnServers: config.turnServers, + turnEnabled: config.turnServers.trim().length > 0, + simulationTier: config.simulationTier, + networkProfile: config.networkProfile, + networkModel: config.networkModel, + p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true, + p2pCandidatePathVerification: p2pConnectionStats?.candidatePathCollected + ? "selected ICE candidate pair collected from RTCPeerConnection.getStats" + : config.candidatePathVerification, + p2pCandidatePathNote: p2pConnectionStats?.candidatePathCollected + ? "The selected ICE candidate pair was collected by the CLI benchmark. Interpret the path from the candidate types; do not infer TURN use from configuration alone." + : config.turnServers.trim().length > 0 + ? "TURN is configured, so the selected WebRTC path may be direct, server-reflexive, or relayed. The selected ICE candidate pair was not exported by this run." + : "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.", + p2pConnectionStats, + appId: config.appId, + roomId: config.roomId, + datasetSeed: config.datasetSeed, + datasetDirName: config.datasetDirName, + peerId: peer.id, + peerName: peer.name, + totalFiles: seedFiles.totalFiles, + totalBytes: seedFiles.totalBytes, + mdFileCount: seedFiles.mdCount, + binFileCount: seedFiles.binCount, + mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), + hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), + peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, + peerDiscoveryCommandElapsedMs: Number(peerDiscoveryCommandElapsed.toFixed(1)), + peerDiscoveryNote: + "p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.", + syncElapsedMs: Number(syncElapsed.toFixed(1)), + throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)), + throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)), + }; + + if (resultPath) { + await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2)); + } + + console.log(JSON.stringify(result, null, 2)); + console.error( + `[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes( + seedFiles.totalBytes + )}) in ${formatMs(mirrorElapsed)}, ` + + `synced in ${formatMs(syncElapsed)} ` + + `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` + ); + } finally { + await host.stop(); } - - console.log(JSON.stringify(result, null, 2)); - console.error( - `[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs(mirrorElapsed)}, ` + - `synced in ${formatMs(syncElapsed)} ` + - `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` - ); } finally { - await host.stop(); + if (previousStatsPath === undefined) { + Deno.env.delete("LIVESYNC_P2P_STATS_JSONL"); + } else { + Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath); + } await stopCoturnIfStarted(coturnStarted); await stopLocalRelayIfStarted(relayStarted); } diff --git a/test/bench-network/README.md b/test/bench-network/README.md index afc6f517..a2545d70 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -65,6 +65,11 @@ path: Use the CouchDB result as the remote-store baseline and the P2P result as the direct-transfer comparison. The Nostr relay is used for signalling in the P2P case, but synchronised note content is transferred over the WebRTC DataChannel. +The P2P result JSON records the selected WebRTC ICE candidate pair when the CLI +can collect it from `RTCPeerConnection.getStats()`. Interpret P2P paths from +the recorded candidate types rather than from TURN configuration alone. Do not +report P2P runs as Tier 2 constrained-network measurements until host and +client are captured under an equivalent shaped topology. ## Dataset and latency controls @@ -160,3 +165,5 @@ docker compose -f test/bench-network/compose.yml --profile shim run --rm bench-r The benchmark result records `simulationTier`, `networkProfile`, and `networkModel`. The shim also writes its applied `tc qdisc`, route, and interface state under `test/bench-network/bench-results/`. +This shim currently measures the CouchDB path only. It does not shape or verify +the WebRTC P2P data path. From 008a5ace06362fdf71ade9c5994f5aa1b4cda7c8 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 10:03:35 +0000 Subject: [PATCH 012/170] Add P2P split-container netem stress fixture --- src/apps/cli/testdeno/bench-p2p-split-node.ts | 435 ++++++++++++++++++ src/apps/cli/testdeno/deno.json | 1 + test/bench-network/Dockerfile.runner | 3 +- test/bench-network/README.md | 61 ++- test/bench-network/compose.yml | 88 ++++ test/bench-network/run-bench.sh | 5 +- 6 files changed, 588 insertions(+), 5 deletions(-) create mode 100644 src/apps/cli/testdeno/bench-p2p-split-node.ts diff --git a/src/apps/cli/testdeno/bench-p2p-split-node.ts b/src/apps/cli/testdeno/bench-p2p-split-node.ts new file mode 100644 index 00000000..14bc9456 --- /dev/null +++ b/src/apps/cli/testdeno/bench-p2p-split-node.ts @@ -0,0 +1,435 @@ +import { join } from "@std/path"; +import { startCliInBackground } from "./helpers/backgroundCli.ts"; +import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; +import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; +import { discoverPeer } from "./helpers/p2p.ts"; +import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; + +type Role = "host" | "client"; + +type NetemSummary = { + enabled: boolean; + profile: string; + interface: string; + delayMs: number; + jitterMs: number; + lossPercent: number; + bandwidthMbit: number; + mtu: number; + tcQdisc?: string; + ipAddr?: string; + ipRoute?: string; +}; + +type HostReady = { + generatedAt: string; + totalFiles: number; + totalBytes: number; + mdFileCount: number; + binFileCount: number; + mirrorElapsedMs: number; + netem: NetemSummary; +}; + +type P2PConnectionStats = { + candidatePathCollected: boolean; + selectedPath: string; + localCandidate?: { candidateType: string; protocol: string; relayProtocol: string }; + remoteCandidate?: { candidateType: string; protocol: string; relayProtocol: string }; +}; + +function errorToRecord(error: unknown): Record { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + }; + } + return { + name: "UnknownError", + message: String(error), + }; +} + +function readEnvString(name: string, fallback: string): string { + const value = Deno.env.get(name)?.trim(); + return value && value.length > 0 ? value : fallback; +} + +function readEnvNumber(name: string, fallback: number): number { + const raw = Deno.env.get(name); + if (raw === undefined || raw.trim() === "") { + return fallback; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative number, got '${raw}'`); + } + return parsed; +} + +function nowMs(): number { + return performance.now(); +} + +async function commandOutput(command: string, args: string[]): Promise { + const output = await new Deno.Command(command, { + args, + stdin: "null", + stdout: "piped", + stderr: "piped", + }).output(); + const stdout = new TextDecoder().decode(output.stdout); + const stderr = new TextDecoder().decode(output.stderr); + if (!output.success) { + throw new Error(`${command} ${args.join(" ")} failed\nstdout: ${stdout}\nstderr: ${stderr}`); + } + return stdout.trim(); +} + +async function commandOk(command: string, args: string[]): Promise { + await commandOutput(command, args); +} + +async function applyNetemIfRequested(): Promise { + const enabled = readEnvString("BENCH_NETEM_ENABLED", "0") === "1"; + const profile = readEnvString("NETEM_PROFILE", "home-wifi"); + const iface = readEnvString("NETEM_INTERFACE", "eth0"); + const delayMs = readEnvNumber("NETEM_DELAY_MS", 20); + const jitterMs = readEnvNumber("NETEM_JITTER_MS", 5); + const lossPercent = readEnvNumber("NETEM_LOSS_PERCENT", 0.1); + const bandwidthMbit = readEnvNumber("NETEM_BANDWIDTH_MBIT", 100); + const mtu = readEnvNumber("NETEM_MTU", 1500); + + const summary: NetemSummary = { + enabled, + profile, + interface: iface, + delayMs, + jitterMs, + lossPercent, + bandwidthMbit, + mtu, + }; + + if (!enabled) { + return summary; + } + + await commandOk("ip", ["link", "set", "dev", iface, "mtu", String(mtu)]); + await new Deno.Command("tc", { args: ["qdisc", "del", "dev", iface, "root"] }).output(); + await commandOk("tc", [ + "qdisc", + "add", + "dev", + iface, + "root", + "netem", + "delay", + `${delayMs}ms`, + `${jitterMs}ms`, + "loss", + `${lossPercent}%`, + "rate", + `${bandwidthMbit}mbit`, + ]); + summary.tcQdisc = await commandOutput("tc", ["qdisc", "show", "dev", iface]); + summary.ipAddr = await commandOutput("ip", ["addr", "show", iface]); + summary.ipRoute = await commandOutput("ip", ["route"]); + return summary; +} + +async function waitForFile(path: string, timeoutMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const stat = await Deno.stat(path); + if (stat.isFile) { + return; + } + } catch { + // wait + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}`); +} + +async function readJsonFile(path: string): Promise { + return JSON.parse(await Deno.readTextFile(path)) as T; +} + +function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { + const unique = new Map(); + for (const entry of [entries.find((e) => e.kind === "md"), entries.find((e) => e.kind === "bin"), entries.at(-1)]) { + if (entry) { + unique.set(entry.relativePath, entry); + } + } + return [...unique.values()]; +} + +async function readLatestP2PConnectionStats(path: string): Promise { + try { + const lines = (await Deno.readTextFile(path)) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return lines.length === 0 ? undefined : (JSON.parse(lines.at(-1)!) as P2PConnectionStats); + } catch { + return undefined; + } +} + +function buildCommonConfig() { + const runId = readEnvString("BENCH_SPLIT_RUN_ID", readEnvString("BENCH_ROOM_ID", "bench-split-run")); + const baseWorkRoot = readEnvString("BENCH_SPLIT_WORK_ROOT", "/p2p-work"); + return { + runId, + workRoot: join(baseWorkRoot, runId), + resultRoot: readEnvString("BENCH_SPLIT_RESULT_ROOT", "/workspace/src/apps/cli/testdeno/bench-results"), + relay: readEnvString("BENCH_RELAY", "ws://nostr-relay:7777/"), + appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"), + roomId: readEnvString("BENCH_ROOM_ID", "bench-split-room"), + passphrase: readEnvString("BENCH_PASSPHRASE", "bench-split-passphrase"), + turnServers: readEnvString("BENCH_TURN_SERVERS", ""), + datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), + datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 20)), + mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 512)), + mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 2048)), + binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 5)), + binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 8192)), + peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 60), + syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 300), + nodeTimeoutMs: readEnvNumber("BENCH_SPLIT_NODE_TIMEOUT_MS", 360_000), + profile: readEnvString("BENCH_NETWORK_PROFILE", readEnvString("NETEM_PROFILE", "split-compose")), + }; +} + +async function prepareP2PSettings( + settingsPath: string, + peerName: string, + config: ReturnType +) { + await initSettingsFile(settingsPath); + await applyP2pSettings( + settingsPath, + config.roomId, + config.passphrase, + config.appId, + config.relay, + "~.*", + config.turnServers + ); + await applyP2pTestTweaks(settingsPath, peerName, config.passphrase); +} + +async function runHost(): Promise { + const config = buildCommonConfig(); + const netem = await applyNetemIfRequested(); + await Deno.mkdir(config.workRoot, { recursive: true }); + await Deno.mkdir(config.resultRoot, { recursive: true }); + + const hostVault = join(config.workRoot, "vault-host"); + const hostSettings = join(config.workRoot, "settings-host.json"); + await Deno.mkdir(hostVault, { recursive: true }); + await prepareP2PSettings(hostSettings, "p2p-split-host", config); + + const seedFiles = await createDeterministicDataset({ + rootDir: hostVault, + datasetDirName: config.datasetDirName, + seed: config.datasetSeed, + mdCount: config.mdFileCount, + mdMinSizeBytes: config.mdMinSizeBytes, + mdMaxSizeBytes: config.mdMaxSizeBytes, + binCount: config.binFileCount, + binSizeBytes: config.binSizeBytes, + }); + await Deno.writeTextFile( + join(config.workRoot, "sample-files.json"), + JSON.stringify(pickSampleFiles(seedFiles.entries), null, 2) + ); + + const mirrorStart = nowMs(); + await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); + const mirrorElapsedMs = Number((nowMs() - mirrorStart).toFixed(1)); + const hostReady: HostReady = { + generatedAt: new Date().toISOString(), + totalFiles: seedFiles.totalFiles, + totalBytes: seedFiles.totalBytes, + mdFileCount: seedFiles.mdCount, + binFileCount: seedFiles.binCount, + mirrorElapsedMs, + netem, + }; + await Deno.writeTextFile(join(config.workRoot, "host-ready.json"), JSON.stringify(hostReady, null, 2)); + + const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); + try { + await host.waitUntilContains("P2P host is running", 20_000); + await Deno.writeTextFile( + join(config.workRoot, "p2p-host-ready.json"), + JSON.stringify({ generatedAt: new Date().toISOString() }) + ); + await waitForFile(join(config.workRoot, "client-done.json"), config.nodeTimeoutMs); + } finally { + await host.stop(); + } +} + +async function runClient(): Promise { + const config = buildCommonConfig(); + const netem = await applyNetemIfRequested(); + await Deno.mkdir(config.resultRoot, { recursive: true }); + await waitForFile(join(config.workRoot, "host-ready.json"), config.nodeTimeoutMs); + await waitForFile(join(config.workRoot, "p2p-host-ready.json"), config.nodeTimeoutMs); + + const clientVault = join(config.workRoot, "vault-client"); + const clientSettings = join(config.workRoot, "settings-client.json"); + const statsPath = join(config.workRoot, "p2p-connection-stats.jsonl"); + await Deno.mkdir(clientVault, { recursive: true }); + await prepareP2PSettings(clientSettings, "p2p-split-client", config); + + const hostReady = await readJsonFile(join(config.workRoot, "host-ready.json")); + const timestamp = new Date().toISOString().replace(/[-:]/g, "").slice(0, 15); + const outputDir = join(config.resultRoot, `p2p-split-${config.profile}-${timestamp}`); + await Deno.mkdir(outputDir, { recursive: true }); + + const previousStatsPath = Deno.env.get("LIVESYNC_P2P_STATS_JSONL"); + Deno.env.set("LIVESYNC_P2P_STATS_JSONL", statsPath); + let stage = "peer-discovery"; + let peerDiscoveryCommandElapsedMs: number | undefined; + let syncElapsedMs: number | undefined; + try { + const peerDiscoveryCommandStart = nowMs(); + const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); + peerDiscoveryCommandElapsedMs = Number((nowMs() - peerDiscoveryCommandStart).toFixed(1)); + + stage = "p2p-sync"; + const syncStart = nowMs(); + await runCliOrFail( + clientVault, + "--settings", + clientSettings, + "p2p-sync", + peer.id, + String(config.syncTimeoutSeconds) + ); + syncElapsedMs = Number((nowMs() - syncStart).toFixed(1)); + + stage = "sample-verification"; + const samples = await readJsonFile(join(config.workRoot, "sample-files.json")); + for (const sample of samples) { + const pulledPath = join(config.workRoot, `pulled-${sample.relativePath.replaceAll("/", "_")}`); + await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath); + await assertFilesEqual( + sample.absolutePath, + pulledPath, + `sample file mismatch after split sync: ${sample.relativePath}` + ); + } + + const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath); + const result = { + ok: true, + generatedAt: new Date().toISOString(), + caseName: "p2p-split-compose", + mode: "p2p-split-compose-benchmark", + runId: config.runId, + simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1", + networkProfile: config.profile, + networkModel: + Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem", + relay: config.relay, + turnServers: config.turnServers, + turnEnabled: config.turnServers.trim().length > 0, + p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true, + p2pConnectionStats, + hostNetem: hostReady.netem, + clientNetem: netem, + totalFiles: hostReady.totalFiles, + totalBytes: hostReady.totalBytes, + mdFileCount: hostReady.mdFileCount, + binFileCount: hostReady.binFileCount, + mirrorElapsedMs: hostReady.mirrorElapsedMs, + peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, + peerDiscoveryCommandElapsedMs, + syncElapsedMs, + throughputBytesPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000)).toFixed(2)), + throughputMiBPerSec: Number((hostReady.totalBytes / (syncElapsedMs / 1000) / 1024 / 1024).toFixed(4)), + }; + await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2)); + await Deno.writeTextFile( + join(config.workRoot, "client-done.json"), + JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: true }) + ); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + const p2pConnectionStats = await readLatestP2PConnectionStats(statsPath); + const result = { + ok: false, + generatedAt: new Date().toISOString(), + caseName: "p2p-split-compose", + mode: "p2p-split-compose-benchmark", + runId: config.runId, + simulationTier: Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "2" : "1", + networkProfile: config.profile, + networkModel: + Deno.env.get("BENCH_NETEM_ENABLED") === "1" ? "split-compose-egress-netem" : "split-compose-no-netem", + relay: config.relay, + turnServers: config.turnServers, + turnEnabled: config.turnServers.trim().length > 0, + p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true, + p2pConnectionStats, + hostNetem: hostReady.netem, + clientNetem: netem, + totalFiles: hostReady.totalFiles, + totalBytes: hostReady.totalBytes, + mdFileCount: hostReady.mdFileCount, + binFileCount: hostReady.binFileCount, + mirrorElapsedMs: hostReady.mirrorElapsedMs, + peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, + peerDiscoveryCommandElapsedMs, + syncElapsedMs, + failure: { + stage, + ...errorToRecord(error), + }, + }; + await Deno.writeTextFile(join(outputDir, "summary.json"), JSON.stringify(result, null, 2)); + await Deno.writeTextFile( + join(config.workRoot, "client-done.json"), + JSON.stringify({ generatedAt: new Date().toISOString(), outputDir, ok: false }) + ); + console.log(JSON.stringify(result, null, 2)); + throw error; + } finally { + if (previousStatsPath === undefined) { + Deno.env.delete("LIVESYNC_P2P_STATS_JSONL"); + } else { + Deno.env.set("LIVESYNC_P2P_STATS_JSONL", previousStatsPath); + } + } +} + +async function main(): Promise { + const role = readEnvString("BENCH_P2P_SPLIT_ROLE", "") as Role; + if (role === "host") { + await runHost(); + return; + } + if (role === "client") { + await runClient(); + return; + } + throw new Error("BENCH_P2P_SPLIT_ROLE must be 'host' or 'client'"); +} + +if (import.meta.main) { + main().catch((error) => { + console.error("[Fatal Error]", error); + Deno.exit(1); + }); +} diff --git a/src/apps/cli/testdeno/deno.json b/src/apps/cli/testdeno/deno.json index 095ab2a9..3a369e35 100644 --- a/src/apps/cli/testdeno/deno.json +++ b/src/apps/cli/testdeno/deno.json @@ -19,6 +19,7 @@ "bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts", "bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts", "bench:latency-sweep": "deno run --env-file=.test.env -A --no-check bench-latency-sweep.ts", + "bench:p2p-split-node": "deno run --env-file=.test.env -A --no-check bench-p2p-split-node.ts", "bench:item1": "bash ./bench-run-item1.sh", "bench:item1:full": "BENCH_MD_FILE_COUNT=1500 BENCH_MD_MIN_SIZE_BYTES=1024 BENCH_MD_MAX_SIZE_BYTES=20480 BENCH_BIN_FILE_COUNT=500 BENCH_BIN_SIZE_BYTES=102400 BENCH_COUCHDB_RTT_MS=50 bash ./bench-run-item1.sh", "test:e2e-couchdb": "deno test --env-file=.test.env -A --no-check test-e2e-two-vaults-couchdb.ts", diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner index 759be55e..25afbd63 100644 --- a/test/bench-network/Dockerfile.runner +++ b/test/bench-network/Dockerfile.runner @@ -3,7 +3,7 @@ FROM node:24-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ \ + && apt-get install -y --no-install-recommends ca-certificates curl unzip python3 make g++ iproute2 \ && rm -rf /var/lib/apt/lists/* ENV DENO_INSTALL=/usr/local @@ -25,6 +25,7 @@ WORKDIR /workspace/src/apps/cli/testdeno RUN deno cache --lock=deno.lock \ bench-network-cases.ts \ bench-latency-sweep.ts \ + bench-p2p-split-node.ts \ bench-p2p.ts \ bench-couchdb.ts diff --git a/test/bench-network/README.md b/test/bench-network/README.md index a2545d70..61e172c4 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -57,9 +57,9 @@ is executed in an actual tethered/VPN environment. The primary local comparison is between a remote-database path and a direct P2P path: -| Case | Data path | What is measured | What is not measured | -| --- | --- | --- | --- | -| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention | +| Case | Data path | What is measured | What is not measured | +| ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention | | `p2p-direct-local` | Device A -> Device B after Nostr signalling | One CLI P2P synchronisation command over WebRTC DataChannel with TURN disabled | Public relay operation, mobile carrier behaviour, TURN relay throughput, and first-peer discovery latency | Use the CouchDB result as the remote-store baseline and the P2P result as the @@ -138,6 +138,61 @@ NETEM_MTU=1380 \ docker compose -f test/bench-network/compose.yml --profile netem run --rm netem-smoke ``` +## Split-container P2P emulation + +The optional `p2p-split` profile runs the P2P host and client in separate +Compose services. Each service can apply `tc netem` to its own egress interface +and the client result records the selected WebRTC ICE candidate pair. + +```bash +BENCH_MD_FILE_COUNT=2 \ +BENCH_BIN_FILE_COUNT=1 \ +BENCH_PEERS_TIMEOUT=10 \ +BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \ +docker compose -f test/bench-network/compose.yml --profile p2p-split up \ + --abort-on-container-exit --exit-code-from p2p-split-client \ + p2p-split-host p2p-split-client +``` + +By default this uses the `home-wifi` profile (`20 ms` delay, `5 ms` jitter, +`0.1%` loss, `100 Mbit`, and `1500` MTU) on both P2P containers. Override the +same `NETEM_*` variables used by the TCP shim to model a stricter profile. + +```bash +BENCH_MD_FILE_COUNT=100 \ +BENCH_MD_MIN_SIZE_BYTES=512 \ +BENCH_MD_MAX_SIZE_BYTES=2048 \ +BENCH_BIN_FILE_COUNT=25 \ +BENCH_BIN_SIZE_BYTES=8192 \ +BENCH_PEERS_TIMEOUT=60 \ +BENCH_SYNC_TIMEOUT=420 \ +BENCH_SPLIT_RUN_ID="$(date -u +%Y%m%d%H%M%S)" \ +BENCH_NETWORK_PROFILE=tethering-vpn \ +NETEM_PROFILE=tethering-vpn \ +NETEM_DELAY_MS=140 \ +NETEM_JITTER_MS=50 \ +NETEM_LOSS_PERCENT=1.0 \ +NETEM_BANDWIDTH_MBIT=10 \ +NETEM_MTU=1380 \ +docker compose -f test/bench-network/compose.yml --profile p2p-split up \ + --abort-on-container-exit --exit-code-from p2p-split-client \ + p2p-split-host p2p-split-client +``` + +This is a Linux-only manual benchmark fixture, not a required pull-request CI +job. It shapes each P2P container's egress path, including signalling traffic, +and should be reported separately from the CouchDB TCP-shim measurements. The +result JSON includes `ok: true` for completed runs; failed runs still write a +summary with `ok: false` and a `failure` object before returning a non-zero +exit code. + +Remove the shared work volume between repeated manual runs when you do not use +a unique `BENCH_SPLIT_RUN_ID`: + +```bash +docker compose -f test/bench-network/compose.yml --profile p2p-split down --volumes +``` + ## Shimmed CouchDB benchmark The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index 29572c69..e444ab57 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -160,6 +160,91 @@ services: volumes: - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + p2p-split-host: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.runner + profiles: + - p2p-split + depends_on: + nostr-relay: + condition: service_healthy + cap_add: + - NET_ADMIN + environment: + BENCH_COMMAND: p2p-split-node + BENCH_P2P_SPLIT_ROLE: host + BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run} + BENCH_SPLIT_WORK_ROOT: /p2p-work + BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_RELAY: ws://nostr-relay:7777/ + BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark} + BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room} + BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase} + BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-} + BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20} + BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512} + BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} + BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} + BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} + BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} + BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} + BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1} + BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi} + NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi} + NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0} + NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20} + NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5} + NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1} + NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100} + NETEM_MTU: ${NETEM_MTU:-1500} + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} + LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + volumes: + - p2p-split-work:/p2p-work + - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + + p2p-split-client: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.runner + profiles: + - p2p-split + depends_on: + nostr-relay: + condition: service_healthy + p2p-split-host: + condition: service_started + cap_add: + - NET_ADMIN + environment: + BENCH_COMMAND: p2p-split-node + BENCH_P2P_SPLIT_ROLE: client + BENCH_SPLIT_RUN_ID: ${BENCH_SPLIT_RUN_ID:-bench-split-run} + BENCH_SPLIT_WORK_ROOT: /p2p-work + BENCH_SPLIT_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_RELAY: ws://nostr-relay:7777/ + BENCH_APP_ID: ${BENCH_APP_ID:-self-hosted-livesync-cli-benchmark} + BENCH_ROOM_ID: ${BENCH_ROOM_ID:-bench-split-room} + BENCH_PASSPHRASE: ${BENCH_PASSPHRASE:-bench-split-passphrase} + BENCH_TURN_SERVERS: ${BENCH_TURN_SERVERS:-} + BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} + BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} + BENCH_NETEM_ENABLED: ${BENCH_NETEM_ENABLED:-1} + BENCH_NETWORK_PROFILE: ${BENCH_NETWORK_PROFILE:-home-wifi} + NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi} + NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0} + NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20} + NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5} + NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1} + NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100} + NETEM_MTU: ${NETEM_MTU:-1500} + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} + LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + volumes: + - p2p-split-work:/p2p-work + - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + netem-smoke: build: context: ../.. @@ -179,3 +264,6 @@ services: NETEM_RESULT_ROOT: /bench-results volumes: - ./bench-results:/bench-results + +volumes: + p2p-split-work: diff --git a/test/bench-network/run-bench.sh b/test/bench-network/run-bench.sh index 4ca9eee3..32272054 100644 --- a/test/bench-network/run-bench.sh +++ b/test/bench-network/run-bench.sh @@ -8,9 +8,12 @@ case "${BENCH_COMMAND:-cases}" in latency-sweep) exec deno task bench:latency-sweep ;; + p2p-split-node) + exec deno task bench:p2p-split-node + ;; *) echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2 - echo "Expected one of: cases, latency-sweep" >&2 + echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2 exit 2 ;; esac From 7d26f0ae354b04469b8e4b78f0643863b2bff358 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 8 Jul 2026 10:04:03 +0000 Subject: [PATCH 013/170] Add P2P signalling-only netem benchmark --- src/apps/cli/testdeno/bench-network-cases.ts | 73 ++++++++++++++++++-- test/bench-network/README.md | 39 +++++++++++ test/bench-network/compose.yml | 60 ++++++++++++++++ 3 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts index c460bd11..dfb6e3ff 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -12,6 +12,15 @@ function readEnvString(name: string, fallback: string): string { return value && value.length > 0 ? value : fallback; } +function readEnvInteger(name: string, fallback: number): number { + const value = readEnvString(name, String(fallback)); + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer, got '${value}'`); + } + return parsed; +} + function timestamp(): string { const d = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); @@ -41,6 +50,7 @@ function buildCases(): BenchmarkCase[] { const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120"); const localTurnServers = readEnvString("BENCH_LOCAL_TURN_SERVERS", "turn:127.0.0.1:3478"); const shimCouchdbUri = readEnvString("BENCH_SHIM_COUCHDB_URI", "http://couchdb-shim:5984"); + const signallingShimRelay = readEnvString("BENCH_SIGNAL_SHIM_RELAY", "ws://p2p-signalling-shim:7777/"); return [ { @@ -136,6 +146,44 @@ function buildCases(): BenchmarkCase[] { "structural-placeholder-only; selected ICE pair may be collected, but the path is not shaped", }, }, + { + name: "p2p-signalling-netem-home-wifi", + runner: "p2p", + description: + "Tier 2 P2P path with only the Nostr signalling relay accessed through the home-wifi netem shim.", + dataPath: "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim", + trustBoundary: "Nostr signalling metadata through constrained network shim; no TURN relay", + env: { + ...base, + BENCH_CASE: "p2p-signalling-netem-home-wifi", + BENCH_RELAY: signallingShimRelay, + BENCH_TURN_SERVERS: "", + BENCH_SIMULATION_TIER: "2", + BENCH_NETWORK_PROFILE: "home-wifi", + BENCH_NETWORK_MODEL: "compose-netem-signalling-shim", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: + "selected ICE pair collected; only Nostr signalling path is shaped", + }, + }, + { + name: "p2p-signalling-netem-tethering-vpn", + runner: "p2p", + description: + "Tier 2 P2P path with only the Nostr signalling relay accessed through the tethering-vpn netem shim.", + dataPath: "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim", + trustBoundary: "Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay", + env: { + ...base, + BENCH_CASE: "p2p-signalling-netem-tethering-vpn", + BENCH_RELAY: signallingShimRelay, + BENCH_TURN_SERVERS: "", + BENCH_SIMULATION_TIER: "2", + BENCH_NETWORK_PROFILE: "tethering-vpn", + BENCH_NETWORK_MODEL: "compose-netem-signalling-shim", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: + "selected ICE pair collected; only Nostr signalling path is shaped", + }, + }, { name: "p2p-user-turn", runner: "p2p", @@ -156,16 +204,25 @@ function buildCases(): BenchmarkCase[] { ]; } -async function runCase(testCase: BenchmarkCase, outputDir: string): Promise> { - const resultPath = `${outputDir}/${testCase.name}.json`; +async function runCase( + testCase: BenchmarkCase, + outputDir: string, + repeatIndex: number, + repeatCount: number +): Promise> { + const suffix = repeatCount > 1 ? `-r${String(repeatIndex).padStart(2, "0")}` : ""; + const resultPath = `${outputDir}/${testCase.name}${suffix}.json`; const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb"; const env = { ...Deno.env.toObject(), ...testCase.env, BENCH_RESULT_JSON: resultPath, + BENCH_REPEAT_INDEX: String(repeatIndex), + BENCH_REPEAT_COUNT: String(repeatCount), }; - console.log(`[bench-cases] running ${testCase.name}: ${testCase.description}`); + const repeatLabel = repeatCount > 1 ? ` (${repeatIndex}/${repeatCount})` : ""; + console.log(`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`); const command = new Deno.Command("deno", { args: ["task", taskName], cwd: import.meta.dirname, @@ -184,6 +241,9 @@ async function runCase(testCase: BenchmarkCase, outputDir: string): Promise; return { ...testCase, + repeatIndex, + repeatCount, + resultPath, result, }; } @@ -211,11 +271,13 @@ async function main(): Promise { const allCases = buildCases(); const cases = selectCases(allCases); + const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1); await Deno.writeTextFile( `${outputDir}/case-manifest.json`, JSON.stringify( { generatedAt: new Date().toISOString(), + repeatCount, selectedCases: cases, availableCases: allCases, }, @@ -226,12 +288,15 @@ async function main(): Promise { const results: Record[] = []; for (const testCase of cases) { - results.push(await runCase(testCase, outputDir)); + for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { + results.push(await runCase(testCase, outputDir, repeatIndex, repeatCount)); + } } const summary = { generatedAt: new Date().toISOString(), outputDir, + repeatCount, results, }; await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); diff --git a/test/bench-network/README.md b/test/bench-network/README.md index 61e172c4..5b93a64b 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -47,6 +47,10 @@ Available local cases: - `p2p-smartphone-vpn-direct` - `p2p-user-turn` +Set `BENCH_REPEAT_COUNT` to run each selected case more than once. Repeated +results are written with suffixes such as `-r01`, `-r02`, and `-r03`, and the +summary records the repeat index for each run. + `p2p-smartphone-vpn-direct` is a structural case name. When it is run inside this Compose package it is not a real smartphone tethering/VPN measurement; it uses the local Compose network. Use it only for wiring checks unless the runner @@ -193,6 +197,41 @@ a unique `BENCH_SPLIT_RUN_ID`: docker compose -f test/bench-network/compose.yml --profile p2p-split down --volumes ``` +## P2P Signalling-Only Emulation + +The optional `signalling-shim` profile shapes only the Nostr signalling relay +path. The P2P host and client run in the benchmark runner as usual, and the +configured relay URL points at a TCP netem shim in front of `nostr-relay`. +This is the preferred fixture when evaluating the hypothesis that P2P avoids a +constrained remote database data path while still depending on a signalling +server for rendezvous. + +```bash +BENCH_CASES=p2p-signalling-netem-home-wifi \ +docker compose -f test/bench-network/compose.yml --profile signalling-shim run --rm \ + bench-runner-signalling-shim +``` + +For a stricter signalling path: + +```bash +NETEM_PROFILE=tethering-vpn \ +NETEM_DELAY_MS=140 \ +NETEM_JITTER_MS=50 \ +NETEM_LOSS_PERCENT=1.0 \ +NETEM_BANDWIDTH_MBIT=10 \ +NETEM_MTU=1380 \ +BENCH_CASES=p2p-signalling-netem-tethering-vpn \ +docker compose -f test/bench-network/compose.yml --profile signalling-shim run --rm \ + bench-runner-signalling-shim +``` + +Use this separately from `p2p-split`. The `p2p-split` profile shapes each peer's +egress path, so it constrains both signalling and the selected WebRTC data +path. The `signalling-shim` profile constrains only relay access, which keeps +it focused on peer-to-signalling-server reachability rather than peer-to-peer +note-data transfer. + ## Shimmed CouchDB benchmark The optional `shim` profile runs a CouchDB benchmark through a TCP forwarding diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index e444ab57..3f3167ef 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -73,6 +73,7 @@ services: environment: BENCH_COMMAND: ${BENCH_COMMAND:-cases} BENCH_CASES: ${BENCH_CASES:-couchdb-baseline,p2p-direct-local} + BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results BENCH_SWEEP_RTT_MS: ${BENCH_SWEEP_RTT_MS:-20,50,100,150,300} @@ -141,6 +142,7 @@ services: environment: BENCH_COMMAND: ${BENCH_COMMAND:-cases} BENCH_CASES: ${BENCH_CASES:-couchdb-netem-home-wifi} + BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results BENCH_SWEEP_ROOT: /workspace/src/apps/cli/testdeno/bench-results BENCH_COUCHDB_MANAGED: "false" @@ -160,6 +162,64 @@ services: volumes: - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + p2p-signalling-shim: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.shim + profiles: + - signalling-shim + depends_on: + nostr-relay: + condition: service_healthy + cap_add: + - NET_ADMIN + environment: + NETEM_PROFILE: ${NETEM_PROFILE:-home-wifi} + NETEM_INTERFACE: ${NETEM_INTERFACE:-eth0} + NETEM_DELAY_MS: ${NETEM_DELAY_MS:-20} + NETEM_JITTER_MS: ${NETEM_JITTER_MS:-5} + NETEM_LOSS_PERCENT: ${NETEM_LOSS_PERCENT:-0.1} + NETEM_BANDWIDTH_MBIT: ${NETEM_BANDWIDTH_MBIT:-100} + NETEM_MTU: ${NETEM_MTU:-1500} + NETEM_RESULT_ROOT: /bench-results + SHIM_LISTEN_PORT: 7777 + SHIM_TARGET_HOST: nostr-relay + SHIM_TARGET_PORT: 7777 + volumes: + - ./bench-results:/bench-results + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"] + interval: 2s + timeout: 5s + retries: 30 + + bench-runner-signalling-shim: + build: + context: ../.. + dockerfile: test/bench-network/Dockerfile.runner + profiles: + - signalling-shim + depends_on: + p2p-signalling-shim: + condition: service_healthy + environment: + BENCH_COMMAND: ${BENCH_COMMAND:-cases} + BENCH_CASES: ${BENCH_CASES:-p2p-signalling-netem-home-wifi} + BENCH_REPEAT_COUNT: ${BENCH_REPEAT_COUNT:-1} + BENCH_CASES_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_SIGNAL_SHIM_RELAY: ws://p2p-signalling-shim:7777/ + BENCH_MD_FILE_COUNT: ${BENCH_MD_FILE_COUNT:-20} + BENCH_MD_MIN_SIZE_BYTES: ${BENCH_MD_MIN_SIZE_BYTES:-512} + BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} + BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} + BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} + BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} + BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} + BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + volumes: + - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results + p2p-split-host: build: context: ../.. From af72cac4e32685571432e9e692a6d5cce1ae5adb Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 9 Jul 2026 01:47:34 +0000 Subject: [PATCH 014/170] Add scoped CLI benchmark metadata --- docs/terms.md | 3 +- src/apps/cli/testdeno/bench-couchdb.ts | 162 +++++++++--- src/apps/cli/testdeno/bench-network-cases.ts | 237 ++++++++++++++---- src/apps/cli/testdeno/bench-p2p.ts | 174 ++++++++++--- src/apps/cli/testdeno/deno.json | 1 + .../cli/testdeno/test-benchmark-contract.ts | 134 ++++++++++ 6 files changed, 593 insertions(+), 118 deletions(-) create mode 100644 src/apps/cli/testdeno/test-benchmark-contract.ts diff --git a/docs/terms.md b/docs/terms.md index ac34ef08..c8809c85 100644 --- a/docs/terms.md +++ b/docs/terms.md @@ -65,7 +65,7 @@ All guidelines and conventions listed below are disclosed and maintained solely - livesync-serverpeer / webpeer - Pseudo-clients that assist in WebRTC peer-to-peer communication. - Metadata (File metadata) - - A database document that stores properties of a file, including its filename, path, size, modification time, conflict history, and references (hashes) of the chunks that comprise the file's content. In Self-hosted LiveSync, metadata is stored separately from the actual file content to enable efficient synchronisation and versioning. + - A database document that stores properties of a file, including its filename, path, size, modification time, and references (hashes) of the chunks that comprise the file's content. Conflict state is carried by the surrounding PouchDB/CouchDB revision metadata rather than by a separate history field inside the file metadata document. In Self-hosted LiveSync, file metadata is stored separately from the actual file content to enable efficient synchronisation and versioning. - OneShot Sync - A single, immediate bidirectional synchronisation (pull then push) triggered on demand or on specific events, as opposed to continuous (live) replication. - Overwrite Server Data with This Device's Files @@ -100,4 +100,3 @@ All guidelines and conventions listed below are disclosed and maintained solely - An optimisation that groups multiple local file edits together over a short delay before committing them to the local database, reducing the number of database write operations. - WebRTC P2P (Peer-to-Peer) - A synchronisation method enabling direct communication between devices without a central server database. - diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/src/apps/cli/testdeno/bench-couchdb.ts index d337efa2..cdf50d8c 100644 --- a/src/apps/cli/testdeno/bench-couchdb.ts +++ b/src/apps/cli/testdeno/bench-couchdb.ts @@ -1,8 +1,18 @@ import { TempDir } from "./helpers/temp.ts"; -import { applyRemoteSyncSettings, initSettingsFile } from "./helpers/settings.ts"; +import { + applyRemoteSyncSettings, + initSettingsFile, +} from "./helpers/settings.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; -import { createCouchdbDatabase, startCouchdb, stopCouchdb } from "./helpers/docker.ts"; -import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; +import { + createCouchdbDatabase, + startCouchdb, + stopCouchdb, +} from "./helpers/docker.ts"; +import { + createDeterministicDataset, + type DatasetEntry, +} from "./helpers/dataset.ts"; type BenchmarkConfig = { caseName: string; @@ -26,6 +36,8 @@ type BenchmarkConfig = { simulationTier: string; networkProfile: string; networkModel: string; + measurementScope: string; + limitations: string[]; }; function readEnvString(name: string, fallback: string): string { @@ -54,6 +66,30 @@ function readEnvBool(name: string, fallback: boolean): boolean { return /^(1|true|yes|on)$/i.test(raw.trim()); } +function readEnvStringArray(name: string, fallback: string[]): string[] { + const raw = Deno.env.get(name)?.trim(); + if (!raw) { + return fallback; + } + + try { + const parsed = JSON.parse(raw); + if ( + Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ) { + return parsed; + } + } catch { + // Fall through to pipe-separated parsing for hand-written invocations. + } + + return raw + .split("|") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + function nowMs(): number { return performance.now(); } @@ -76,26 +112,57 @@ function formatBytes(value: number): string { function buildConfig(): BenchmarkConfig { return { caseName: readEnvString("BENCH_CASE", "couchdb-baseline"), - couchdbBackendUri: readEnvString("BENCH_COUCHDB_BACKEND_URI", "http://127.0.0.1:5989"), - couchdbProxyUri: readEnvString("BENCH_COUCHDB_URI", "http://127.0.0.1:15989"), - couchdbUser: readEnvString("BENCH_COUCHDB_USER", readEnvString("username", "admin")), - couchdbPassword: readEnvString("BENCH_COUCHDB_PASSWORD", readEnvString("password", "password")), - couchdbDbname: readEnvString("BENCH_COUCHDB_DBNAME", `bench-couchdb-${Date.now()}`), + couchdbBackendUri: readEnvString( + "BENCH_COUCHDB_BACKEND_URI", + "http://127.0.0.1:5989", + ), + couchdbProxyUri: readEnvString( + "BENCH_COUCHDB_URI", + "http://127.0.0.1:15989", + ), + couchdbUser: readEnvString( + "BENCH_COUCHDB_USER", + readEnvString("username", "admin"), + ), + couchdbPassword: readEnvString( + "BENCH_COUCHDB_PASSWORD", + readEnvString("password", "password"), + ), + couchdbDbname: readEnvString( + "BENCH_COUCHDB_DBNAME", + `bench-couchdb-${Date.now()}`, + ), datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), - mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)), - mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)), + mdMinSizeBytes: Math.floor( + readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024), + ), + mdMaxSizeBytes: Math.floor( + readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024), + ), binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)), - binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)), + binSizeBytes: Math.floor( + readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024), + ), syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240), requestedRttMs: Math.floor(readEnvNumber("BENCH_COUCHDB_RTT_MS", 50)), passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), encrypt: readEnvBool("BENCH_ENCRYPT", true), managedCouchdb: readEnvBool("BENCH_COUCHDB_MANAGED", true), simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"), - networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "http-latency-proxy"), + networkProfile: readEnvString( + "BENCH_NETWORK_PROFILE", + "http-latency-proxy", + ), networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-http-proxy"), + measurementScope: readEnvString( + "BENCH_MEASUREMENT_SCOPE", + "Two one-shot synchronisation phases through a CouchDB-compatible remote-store path.", + ), + limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ + "This benchmark result is scoped to the configured dataset, remote store, and network model.", + ]), }; } @@ -130,7 +197,9 @@ type ProxyHandle = { note: string; }; -function startCouchdbProxy(options: { backendUri: string; proxyUri: string; requestedRttMs: number }): ProxyHandle { +function startCouchdbProxy( + options: { backendUri: string; proxyUri: string; requestedRttMs: number }, +): ProxyHandle { const backend = new URL(options.backendUri); const proxy = new URL(options.proxyUri); const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2)); @@ -182,12 +251,13 @@ function startCouchdbProxy(options: { backendUri: string; proxyUri: string; requ statusText: upstream.statusText, headers: responseHeaders, }); - } + }, ); return { applied: true, - note: `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`, + note: + `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`, stop: async () => { controller.abort(); await listener.finished.catch(() => {}); @@ -211,14 +281,21 @@ async function main(): Promise { await initSettingsFile(settingsB); if (config.managedCouchdb) { - await startCouchdb(config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, config.couchdbDbname); + await startCouchdb( + config.couchdbBackendUri, + config.couchdbUser, + config.couchdbPassword, + config.couchdbDbname, + ); } else { - console.log(`[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`); + console.log( + `[INFO] using externally managed CouchDB: ${config.couchdbBackendUri}`, + ); await createCouchdbDatabase( config.couchdbBackendUri, config.couchdbUser, config.couchdbPassword, - config.couchdbDbname + config.couchdbDbname, ); } @@ -275,12 +352,21 @@ async function main(): Promise { const sampleFiles = pickSampleFiles(seedFiles.entries); for (const sample of sampleFiles) { - const pulledPath = workDir.join(`pulled-${sample.relativePath.split("/").join("_")}`); - await runCliOrFail(vaultB, "--settings", settingsB, "pull", sample.relativePath, pulledPath); + const pulledPath = workDir.join( + `pulled-${sample.relativePath.split("/").join("_")}`, + ); + await runCliOrFail( + vaultB, + "--settings", + settingsB, + "pull", + sample.relativePath, + pulledPath, + ); await assertFilesEqual( sample.absolutePath, pulledPath, - `sample file mismatch after CouchDB sync: ${sample.relativePath}` + `sample file mismatch after CouchDB sync: ${sample.relativePath}`, ); } @@ -294,6 +380,8 @@ async function main(): Promise { simulationTier: config.simulationTier, networkProfile: config.networkProfile, networkModel: config.networkModel, + measurementScope: config.measurementScope, + limitations: config.limitations, rttRequestedMs: config.requestedRttMs, proxyApplied: proxy.applied, proxyNote: proxy.note, @@ -306,22 +394,40 @@ async function main(): Promise { mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), syncAElapsedMs: Number(syncAElapsed.toFixed(1)), syncBElapsedMs: Number(syncBElapsed.toFixed(1)), - totalSyncElapsedMs: Number((syncAElapsed + syncBElapsed).toFixed(1)), - throughputBytesPerSec: Number((seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)).toFixed(2)), + totalSyncElapsedMs: Number( + (syncAElapsed + syncBElapsed).toFixed(1), + ), + throughputBytesPerSec: Number( + (seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000)) + .toFixed( + 2, + ), + ), throughputMiBPerSec: Number( - (seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / 1024 / 1024).toFixed(4) + (seedFiles.totalBytes / ((syncAElapsed + syncBElapsed) / 1000) / + 1024 / + 1024).toFixed(4), ), }; if (resultPath) { - await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2)); + await Deno.writeTextFile( + resultPath, + JSON.stringify(result, null, 2), + ); } console.log(JSON.stringify(result, null, 2)); console.error( - `[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${formatBytes(seedFiles.totalBytes)}) in ${formatMs( - mirrorElapsed - )}, synced in ${formatMs(syncAElapsed + syncBElapsed)} (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` + `[Benchmark] couchdb mirrored ${seedFiles.totalFiles} files (${ + formatBytes(seedFiles.totalBytes) + }) in ${ + formatMs( + mirrorElapsed, + ) + }, synced in ${ + formatMs(syncAElapsed + syncBElapsed) + } (${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`, ); } finally { await proxy.stop(); diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts index dfb6e3ff..689939af 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -1,9 +1,11 @@ -type BenchmarkCase = { +export type BenchmarkCase = { name: string; runner: "p2p" | "couchdb"; description: string; dataPath: string; trustBoundary: string; + measurementScope: string; + limitations: string[]; env: Record; }; @@ -25,16 +27,26 @@ function timestamp(): string { const d = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); return ( - `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}-` + - `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}` + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${ + pad(d.getUTCDate()) + }-` + + `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${ + pad(d.getUTCSeconds()) + }` ); } function buildBaseEnv(): Record { return { BENCH_MD_FILE_COUNT: readEnvString("BENCH_MD_FILE_COUNT", "20"), - BENCH_MD_MIN_SIZE_BYTES: readEnvString("BENCH_MD_MIN_SIZE_BYTES", "512"), - BENCH_MD_MAX_SIZE_BYTES: readEnvString("BENCH_MD_MAX_SIZE_BYTES", "2048"), + BENCH_MD_MIN_SIZE_BYTES: readEnvString( + "BENCH_MD_MIN_SIZE_BYTES", + "512", + ), + BENCH_MD_MAX_SIZE_BYTES: readEnvString( + "BENCH_MD_MAX_SIZE_BYTES", + "2048", + ), BENCH_BIN_FILE_COUNT: readEnvString("BENCH_BIN_FILE_COUNT", "5"), BENCH_BIN_SIZE_BYTES: readEnvString("BENCH_BIN_SIZE_BYTES", "8192"), BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), @@ -44,33 +56,74 @@ function buildBaseEnv(): Record { }; } -function buildCases(): BenchmarkCase[] { +function withScopeEnv( + env: Record, + options: Pick, +): Record { + return { + ...env, + BENCH_MEASUREMENT_SCOPE: options.measurementScope, + BENCH_LIMITATIONS_JSON: JSON.stringify(options.limitations), + }; +} + +function defineCase(testCase: BenchmarkCase): BenchmarkCase { + return { + ...testCase, + env: withScopeEnv(testCase.env, testCase), + }; +} + +export function buildCases(): BenchmarkCase[] { const base = buildBaseEnv(); const couchdbRtt = readEnvString("BENCH_COUCHDB_RTT_MS", "20"); const tetheringVpnRtt = readEnvString("BENCH_TETHERING_VPN_RTT_MS", "120"); - const localTurnServers = readEnvString("BENCH_LOCAL_TURN_SERVERS", "turn:127.0.0.1:3478"); - const shimCouchdbUri = readEnvString("BENCH_SHIM_COUCHDB_URI", "http://couchdb-shim:5984"); - const signallingShimRelay = readEnvString("BENCH_SIGNAL_SHIM_RELAY", "ws://p2p-signalling-shim:7777/"); + const localTurnServers = readEnvString( + "BENCH_LOCAL_TURN_SERVERS", + "turn:127.0.0.1:3478", + ); + const shimCouchdbUri = readEnvString( + "BENCH_SHIM_COUCHDB_URI", + "http://couchdb-shim:5984", + ); + const signallingShimRelay = readEnvString( + "BENCH_SIGNAL_SHIM_RELAY", + "ws://p2p-signalling-shim:7777/", + ); return [ - { + defineCase({ name: "couchdb-baseline", runner: "couchdb", - description: "Standard self-hosted CouchDB path through a local latency proxy.", + description: + "Standard self-hosted CouchDB path through a local latency proxy.", dataPath: "Device A -> CouchDB -> Device B", trustBoundary: "CouchDB operator and network path", + measurementScope: + "Two one-shot synchronisation phases through a CouchDB-compatible remote-store path with a local HTTP latency proxy.", + limitations: [ + "This is not a full netem model of packet loss, jitter, MTU, bandwidth limits, or VPN encapsulation.", + "This result should be compared with P2P only as a remote-store baseline under the same deterministic dataset.", + ], env: { ...base, BENCH_CASE: "couchdb-baseline", BENCH_COUCHDB_RTT_MS: couchdbRtt, }, - }, - { + }), + defineCase({ name: "p2p-direct-local", runner: "p2p", - description: "Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.", + description: + "Preferred direct WebRTC P2P path with Nostr signalling and TURN disabled.", dataPath: "Device A -> Device B", trustBoundary: "Nostr relay for signalling metadata; no TURN relay", + measurementScope: + "One CLI P2P synchronisation phase over a local WebRTC DataChannel after Nostr signalling, with TURN disabled.", + limitations: [ + "This does not measure first-peer discovery latency, public relay operation, mobile carrier behaviour, or TURN-relayed throughput.", + "This small-dataset run should not be treated as a WAN, VPN, or large binary initial synchronisation measurement.", + ], env: { ...base, BENCH_CASE: "p2p-direct-local", @@ -78,29 +131,44 @@ function buildCases(): BenchmarkCase[] { BENCH_SIMULATION_TIER: "1", BENCH_NETWORK_PROFILE: "local-direct", BENCH_NETWORK_MODEL: "local-runner-webrtc", - BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "turn-disabled-but-selected-ice-pair-not-collected", + BENCH_P2P_CANDIDATE_PATH_VERIFICATION: + "turn-disabled-but-selected-ice-pair-not-collected", }, - }, - { + }), + defineCase({ name: "couchdb-tethering-vpn-proxy", runner: "couchdb", description: "Approximate smartphone tethering/VPN remote-database path using an HTTP latency proxy. This does not model loss, jitter, MTU, or VPN encapsulation.", - dataPath: "Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B", + dataPath: + "Device A -> VPN/network path -> CouchDB -> VPN/network path -> Device B", trustBoundary: "VPN/network path and CouchDB operator", + measurementScope: + "Two one-shot CouchDB synchronisation phases with additional requested RTT through the local HTTP proxy.", + limitations: [ + "This approximates request latency only and does not model loss, jitter, MTU, bandwidth limits, carrier NAT, or VPN encapsulation.", + "Use the Tier 2 netem shim cases for a stronger constrained-network fixture.", + ], env: { ...base, BENCH_CASE: "couchdb-tethering-vpn-proxy", BENCH_COUCHDB_RTT_MS: tetheringVpnRtt, }, - }, - { + }), + defineCase({ name: "couchdb-netem-home-wifi", runner: "couchdb", description: "Tier 2 CouchDB path through the Compose netem TCP shim using the home-wifi profile.", - dataPath: "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", + dataPath: + "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", trustBoundary: "CouchDB operator and constrained network shim", + measurementScope: + "Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the home-wifi netem profile.", + limitations: [ + "This shapes the CouchDB TCP path, not the WebRTC P2P data path.", + "The fixture remains a reproducible network emulation, not a field measurement on a real home network.", + ], env: { ...base, BENCH_CASE: "couchdb-netem-home-wifi", @@ -110,14 +178,22 @@ function buildCases(): BenchmarkCase[] { BENCH_NETWORK_PROFILE: "home-wifi", BENCH_NETWORK_MODEL: "compose-netem-tcp-shim", }, - }, - { + }), + defineCase({ name: "couchdb-netem-tethering-vpn", runner: "couchdb", description: "Tier 2 CouchDB path through the Compose netem TCP shim using a tethering-vpn profile.", - dataPath: "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", - trustBoundary: "CouchDB operator and constrained smartphone/VPN-like network shim", + dataPath: + "Device A -> netem TCP shim -> CouchDB -> netem TCP shim -> Device B", + trustBoundary: + "CouchDB operator and constrained smartphone/VPN-like network shim", + measurementScope: + "Tier 2 CouchDB synchronisation through a Compose TCP shim that applies the tethering-vpn netem profile.", + limitations: [ + "This shapes the CouchDB TCP path, not the WebRTC P2P data path.", + "The profile approximates smartphone/VPN constraints but is not a field measurement on a real tethered VPN connection.", + ], env: { ...base, BENCH_CASE: "couchdb-netem-tethering-vpn", @@ -127,14 +203,22 @@ function buildCases(): BenchmarkCase[] { BENCH_NETWORK_PROFILE: "tethering-vpn", BENCH_NETWORK_MODEL: "compose-netem-tcp-shim", }, - }, - { + }), + defineCase({ name: "p2p-smartphone-vpn-direct", runner: "p2p", description: "Direct P2P case name for smartphone tethering/VPN measurements. In this local runner it is unshaped and should be treated as a wiring check unless executed on that network.", - dataPath: "Device A -> Device B when WebRTC direct connectivity succeeds", - trustBoundary: "Smartphone/VPN routing policy plus Nostr signalling metadata", + dataPath: + "Device A -> Device B when WebRTC direct connectivity succeeds", + trustBoundary: + "Smartphone/VPN routing policy plus Nostr signalling metadata", + measurementScope: + "Structural placeholder for direct P2P measurements on a real smartphone tethering/VPN path.", + limitations: [ + "In the local runner this is unshaped and must not be reported as smartphone, VPN, WAN, or Tier 2 evidence.", + "Use only when the command is executed on the intended real network path and the selected ICE candidate pair is recorded.", + ], env: { ...base, BENCH_CASE: "p2p-smartphone-vpn-direct", @@ -145,14 +229,22 @@ function buildCases(): BenchmarkCase[] { BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "structural-placeholder-only; selected ICE pair may be collected, but the path is not shaped", }, - }, - { + }), + defineCase({ name: "p2p-signalling-netem-home-wifi", runner: "p2p", description: "Tier 2 P2P path with only the Nostr signalling relay accessed through the home-wifi netem shim.", - dataPath: "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim", - trustBoundary: "Nostr signalling metadata through constrained network shim; no TURN relay", + dataPath: + "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim", + trustBoundary: + "Nostr signalling metadata through constrained network shim; no TURN relay", + measurementScope: + "Tier 2 P2P synchronisation where only the Nostr signalling path is shaped by the home-wifi netem profile.", + limitations: [ + "This does not shape the selected WebRTC DataChannel note-data path.", + "This supports only the claim that constrained signalling access does not place note data on the relay path when a non-relayed ICE path is selected.", + ], env: { ...base, BENCH_CASE: "p2p-signalling-netem-home-wifi", @@ -164,14 +256,22 @@ function buildCases(): BenchmarkCase[] { BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "selected ICE pair collected; only Nostr signalling path is shaped", }, - }, - { + }), + defineCase({ name: "p2p-signalling-netem-tethering-vpn", runner: "p2p", description: "Tier 2 P2P path with only the Nostr signalling relay accessed through the tethering-vpn netem shim.", - dataPath: "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim", - trustBoundary: "Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay", + dataPath: + "Device A -> Device B over WebRTC DataChannel; Nostr signalling through netem shim", + trustBoundary: + "Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay", + measurementScope: + "Tier 2 P2P synchronisation where only the Nostr signalling path is shaped by the tethering-vpn netem profile.", + limitations: [ + "This does not shape the selected WebRTC DataChannel note-data path.", + "The profile approximates constrained relay access and is not a field measurement on a real tethered VPN connection.", + ], env: { ...base, BENCH_CASE: "p2p-signalling-netem-tethering-vpn", @@ -183,13 +283,20 @@ function buildCases(): BenchmarkCase[] { BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "selected ICE pair collected; only Nostr signalling path is shaped", }, - }, - { + }), + defineCase({ name: "p2p-user-turn", runner: "p2p", - description: "Optional fallback path through a local user-controlled TURN server.", + description: + "Optional fallback path through a local user-controlled TURN server.", dataPath: "Device A -> user-controlled TURN -> Device B", trustBoundary: "User-controlled TURN server", + measurementScope: + "Optional local TURN fallback wiring check with a user-controlled TURN server configured.", + limitations: [ + "TURN configuration does not prove that the selected ICE path was relayed; interpret the recorded candidate pair.", + "This is not evidence for public TURN relay privacy, throughput, or availability.", + ], env: { ...base, BENCH_CASE: "p2p-user-turn", @@ -200,7 +307,7 @@ function buildCases(): BenchmarkCase[] { BENCH_P2P_CANDIDATE_PATH_VERIFICATION: "turn-configured; selected ICE pair may still be direct or relayed, so interpret the recorded candidate types", }, - }, + }), ]; } @@ -208,9 +315,11 @@ async function runCase( testCase: BenchmarkCase, outputDir: string, repeatIndex: number, - repeatCount: number + repeatCount: number, ): Promise> { - const suffix = repeatCount > 1 ? `-r${String(repeatIndex).padStart(2, "0")}` : ""; + const suffix = repeatCount > 1 + ? `-r${String(repeatIndex).padStart(2, "0")}` + : ""; const resultPath = `${outputDir}/${testCase.name}${suffix}.json`; const taskName = testCase.runner === "p2p" ? "bench:p2p" : "bench:couchdb"; const env = { @@ -221,8 +330,12 @@ async function runCase( BENCH_REPEAT_COUNT: String(repeatCount), }; - const repeatLabel = repeatCount > 1 ? ` (${repeatIndex}/${repeatCount})` : ""; - console.log(`[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`); + const repeatLabel = repeatCount > 1 + ? ` (${repeatIndex}/${repeatCount})` + : ""; + console.log( + `[bench-cases] running ${testCase.name}${repeatLabel}: ${testCase.description}`, + ); const command = new Deno.Command("deno", { args: ["task", taskName], cwd: import.meta.dirname, @@ -238,7 +351,10 @@ async function runCase( throw new Error(`case failed: ${testCase.name} (exit ${status.code})`); } - const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record; + const result = JSON.parse(await Deno.readTextFile(resultPath)) as Record< + string, + unknown + >; return { ...testCase, repeatIndex, @@ -249,7 +365,10 @@ async function runCase( } function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { - const requested = readEnvString("BENCH_CASES", "couchdb-baseline,p2p-direct-local"); + const requested = readEnvString( + "BENCH_CASES", + "couchdb-baseline,p2p-direct-local", + ); const names = requested .split(",") .map((v) => v.trim()) @@ -258,14 +377,21 @@ function selectCases(allCases: BenchmarkCase[]): BenchmarkCase[] { return names.map((name) => { const found = byName.get(name); if (!found) { - throw new Error(`Unknown BENCH_CASES entry '${name}'. Available: ${allCases.map((c) => c.name).join(", ")}`); + throw new Error( + `Unknown BENCH_CASES entry '${name}'. Available: ${ + allCases.map((c) => c.name).join(", ") + }`, + ); } return found; }); } async function main(): Promise { - const outRoot = readEnvString("BENCH_CASES_ROOT", `${import.meta.dirname}/bench-results`); + const outRoot = readEnvString( + "BENCH_CASES_ROOT", + `${import.meta.dirname}/bench-results`, + ); const outputDir = `${outRoot}/cases-${timestamp()}`; await Deno.mkdir(outputDir, { recursive: true }); @@ -282,14 +408,16 @@ async function main(): Promise { availableCases: allCases, }, null, - 2 - ) + 2, + ), ); const results: Record[] = []; for (const testCase of cases) { for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { - results.push(await runCase(testCase, outputDir, repeatIndex, repeatCount)); + results.push( + await runCase(testCase, outputDir, repeatIndex, repeatCount), + ); } } @@ -299,7 +427,10 @@ async function main(): Promise { repeatCount, results, }; - await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); + await Deno.writeTextFile( + `${outputDir}/summary.json`, + JSON.stringify(summary, null, 2), + ); console.log(JSON.stringify(summary, null, 2)); console.log(`[bench-cases] result directory: ${outputDir}`); } diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts index 2bbf4e5a..e763e9dc 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -1,5 +1,9 @@ import { TempDir } from "./helpers/temp.ts"; -import { applyP2pSettings, applyP2pTestTweaks, initSettingsFile } from "./helpers/settings.ts"; +import { + applyP2pSettings, + applyP2pTestTweaks, + initSettingsFile, +} from "./helpers/settings.ts"; import { startCliInBackground } from "./helpers/backgroundCli.ts"; import { discoverPeer, @@ -9,7 +13,10 @@ import { stopLocalRelayIfStarted, } from "./helpers/p2p.ts"; import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; -import { createDeterministicDataset, type DatasetEntry } from "./helpers/dataset.ts"; +import { + createDeterministicDataset, + type DatasetEntry, +} from "./helpers/dataset.ts"; type BenchmarkConfig = { caseName: string; @@ -31,6 +38,8 @@ type BenchmarkConfig = { networkProfile: string; networkModel: string; candidatePathVerification: string; + measurementScope: string; + limitations: string[]; }; type P2PConnectionStats = { @@ -83,6 +92,30 @@ function readEnvNumber(name: string, fallback: number): number { return parsed; } +function readEnvStringArray(name: string, fallback: string[]): string[] { + const raw = Deno.env.get(name)?.trim(); + if (!raw) { + return fallback; + } + + try { + const parsed = JSON.parse(raw); + if ( + Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ) { + return parsed; + } + } catch { + // Fall through to comma-separated parsing for hand-written invocations. + } + + return raw + .split("|") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + function nowMs(): number { return performance.now(); } @@ -107,23 +140,45 @@ function buildConfig(): BenchmarkConfig { return { caseName: readEnvString("BENCH_CASE", "p2p-direct-local"), relay: readEnvString("BENCH_RELAY", "ws://localhost:4000/"), - appId: readEnvString("BENCH_APP_ID", "self-hosted-livesync-cli-benchmark"), + appId: readEnvString( + "BENCH_APP_ID", + "self-hosted-livesync-cli-benchmark", + ), roomId: readEnvString("BENCH_ROOM_ID", `bench-room-${Date.now()}`), passphrase: readEnvString("BENCH_PASSPHRASE", `bench-${Date.now()}`), turnServers: readEnvString("BENCH_TURN_SERVERS", ""), datasetDirName: readEnvString("BENCH_DATASET_DIR", "bench-dataset"), datasetSeed: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), mdFileCount: Math.floor(readEnvNumber("BENCH_MD_FILE_COUNT", 1500)), - mdMinSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024)), - mdMaxSizeBytes: Math.floor(readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024)), + mdMinSizeBytes: Math.floor( + readEnvNumber("BENCH_MD_MIN_SIZE_BYTES", 1024), + ), + mdMaxSizeBytes: Math.floor( + readEnvNumber("BENCH_MD_MAX_SIZE_BYTES", 20 * 1024), + ), binFileCount: Math.floor(readEnvNumber("BENCH_BIN_FILE_COUNT", 500)), - binSizeBytes: Math.floor(readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024)), + binSizeBytes: Math.floor( + readEnvNumber("BENCH_BIN_SIZE_BYTES", 100 * 1024), + ), peersTimeoutSeconds: readEnvNumber("BENCH_PEERS_TIMEOUT", 20), syncTimeoutSeconds: readEnvNumber("BENCH_SYNC_TIMEOUT", 240), simulationTier: readEnvString("BENCH_SIMULATION_TIER", "1"), networkProfile: readEnvString("BENCH_NETWORK_PROFILE", "local-direct"), - networkModel: readEnvString("BENCH_NETWORK_MODEL", "local-runner-webrtc"), - candidatePathVerification: readEnvString("BENCH_P2P_CANDIDATE_PATH_VERIFICATION", "not-collected"), + networkModel: readEnvString( + "BENCH_NETWORK_MODEL", + "local-runner-webrtc", + ), + candidatePathVerification: readEnvString( + "BENCH_P2P_CANDIDATE_PATH_VERIFICATION", + "not-collected", + ), + measurementScope: readEnvString( + "BENCH_MEASUREMENT_SCOPE", + "One CLI P2P synchronisation phase over WebRTC DataChannel after signalling.", + ), + limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ + "This benchmark result is scoped to the configured dataset, network model, and selected ICE path.", + ]), }; } @@ -152,7 +207,9 @@ function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { return [...unique.values()]; } -async function readLatestP2PConnectionStats(statsPath: string): Promise { +async function readLatestP2PConnectionStats( + statsPath: string, +): Promise { try { const text = await Deno.readTextFile(statsPath); const lines = text @@ -200,7 +257,7 @@ async function main(): Promise { config.appId, config.relay, "~.*", - config.turnServers + config.turnServers, ), applyP2pSettings( clientSettings, @@ -209,13 +266,21 @@ async function main(): Promise { config.appId, config.relay, "~.*", - config.turnServers + config.turnServers, ), ]); await Promise.all([ - applyP2pTestTweaks(hostSettings, "p2p-bench-host", config.passphrase), - applyP2pTestTweaks(clientSettings, "p2p-bench-client", config.passphrase), + applyP2pTestTweaks( + hostSettings, + "p2p-bench-host", + config.passphrase, + ), + applyP2pTestTweaks( + clientSettings, + "p2p-bench-client", + config.passphrase, + ), ]); const seedFiles = await createDeterministicDataset({ @@ -233,15 +298,25 @@ async function main(): Promise { await runCliOrFail(hostVault, "--settings", hostSettings, "mirror"); const mirrorElapsed = nowMs() - mirrorStart; - const host = startCliInBackground(hostVault, "--settings", hostSettings, "p2p-host"); + const host = startCliInBackground( + hostVault, + "--settings", + hostSettings, + "p2p-host", + ); try { const hostReadyStart = nowMs(); await host.waitUntilContains("P2P host is running", 20000); const hostReadyElapsed = nowMs() - hostReadyStart; const peerDiscoveryCommandStart = nowMs(); - const peer = await discoverPeer(clientVault, clientSettings, config.peersTimeoutSeconds); - const peerDiscoveryCommandElapsed = nowMs() - peerDiscoveryCommandStart; + const peer = await discoverPeer( + clientVault, + clientSettings, + config.peersTimeoutSeconds, + ); + const peerDiscoveryCommandElapsed = nowMs() - + peerDiscoveryCommandStart; const syncStart = nowMs(); await runCliOrFail( @@ -250,22 +325,33 @@ async function main(): Promise { clientSettings, "p2p-sync", peer.id, - String(config.syncTimeoutSeconds) + String(config.syncTimeoutSeconds), ); const syncElapsed = nowMs() - syncStart; const sampleFiles = pickSampleFiles(seedFiles.entries); for (const sample of sampleFiles) { - const pulledPath = workDir.join(`pulled-${sample.relativePath.replaceAll("/", "_")}`); - await runCliOrFail(clientVault, "--settings", clientSettings, "pull", sample.relativePath, pulledPath); + const pulledPath = workDir.join( + `pulled-${sample.relativePath.replaceAll("/", "_")}`, + ); + await runCliOrFail( + clientVault, + "--settings", + clientSettings, + "pull", + sample.relativePath, + pulledPath, + ); await assertFilesEqual( sample.absolutePath, pulledPath, - `sample file mismatch after sync: ${sample.relativePath}` + `sample file mismatch after sync: ${sample.relativePath}`, ); } - const p2pConnectionStats = await readLatestP2PConnectionStats(p2pStatsPath); + const p2pConnectionStats = await readLatestP2PConnectionStats( + p2pStatsPath, + ); const result = { caseName: config.caseName, mode: "p2p-cli-benchmark", @@ -275,15 +361,19 @@ async function main(): Promise { simulationTier: config.simulationTier, networkProfile: config.networkProfile, networkModel: config.networkModel, - p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true, - p2pCandidatePathVerification: p2pConnectionStats?.candidatePathCollected - ? "selected ICE candidate pair collected from RTCPeerConnection.getStats" - : config.candidatePathVerification, + measurementScope: config.measurementScope, + limitations: config.limitations, + p2pCandidatePathVerified: + p2pConnectionStats?.candidatePathCollected === true, + p2pCandidatePathVerification: + p2pConnectionStats?.candidatePathCollected + ? "selected ICE candidate pair collected from RTCPeerConnection.getStats" + : config.candidatePathVerification, p2pCandidatePathNote: p2pConnectionStats?.candidatePathCollected ? "The selected ICE candidate pair was collected by the CLI benchmark. Interpret the path from the candidate types; do not infer TURN use from configuration alone." : config.turnServers.trim().length > 0 - ? "TURN is configured, so the selected WebRTC path may be direct, server-reflexive, or relayed. The selected ICE candidate pair was not exported by this run." - : "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.", + ? "TURN is configured, so the selected WebRTC path may be direct, server-reflexive, or relayed. The selected ICE candidate pair was not exported by this run." + : "TURN is disabled, so a TURN-relayed path is not expected. The selected ICE candidate pair was not exported by this run.", p2pConnectionStats, appId: config.appId, roomId: config.roomId, @@ -298,25 +388,39 @@ async function main(): Promise { mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, - peerDiscoveryCommandElapsedMs: Number(peerDiscoveryCommandElapsed.toFixed(1)), + peerDiscoveryCommandElapsedMs: Number( + peerDiscoveryCommandElapsed.toFixed(1), + ), peerDiscoveryNote: "p2p-peers waits for the requested timeout before printing discovered peers, so this is command duration, not first-peer latency.", syncElapsedMs: Number(syncElapsed.toFixed(1)), - throughputBytesPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2)), - throughputMiBPerSec: Number((seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024).toFixed(4)), + throughputBytesPerSec: Number( + (seedFiles.totalBytes / (syncElapsed / 1000)).toFixed(2), + ), + throughputMiBPerSec: Number( + (seedFiles.totalBytes / (syncElapsed / 1000) / 1024 / 1024) + .toFixed( + 4, + ), + ), }; if (resultPath) { - await Deno.writeTextFile(resultPath, JSON.stringify(result, null, 2)); + await Deno.writeTextFile( + resultPath, + JSON.stringify(result, null, 2), + ); } console.log(JSON.stringify(result, null, 2)); console.error( - `[Benchmark] mirrored ${seedFiles.totalFiles} files (${formatBytes( - seedFiles.totalBytes - )}) in ${formatMs(mirrorElapsed)}, ` + + `[Benchmark] mirrored ${seedFiles.totalFiles} files (${ + formatBytes( + seedFiles.totalBytes, + ) + }) in ${formatMs(mirrorElapsed)}, ` + `synced in ${formatMs(syncElapsed)} ` + - `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)` + `(${result.throughputBytesPerSec} B/s, ${result.throughputMiBPerSec} MiB/s)`, ); } finally { await host.stop(); diff --git a/src/apps/cli/testdeno/deno.json b/src/apps/cli/testdeno/deno.json index 3a369e35..7845ba1b 100644 --- a/src/apps/cli/testdeno/deno.json +++ b/src/apps/cli/testdeno/deno.json @@ -15,6 +15,7 @@ "test:p2p-sync": "deno test --env-file=.test.env -A --no-check test-p2p-sync.ts", "test:p2p-three-nodes": "deno test --env-file=.test.env -A --no-check test-p2p-three-nodes-conflict.ts", "test:p2p-upload-download": "deno test --env-file=.test.env -A --no-check test-p2p-upload-download-repro.ts", + "test:benchmark-contract": "deno test --env-file=.test.env -A --no-check test-benchmark-contract.ts", "bench:p2p": "deno run --env-file=.test.env -A --no-check bench-p2p.ts", "bench:couchdb": "deno run --env-file=.test.env -A --no-check bench-couchdb.ts", "bench:cases": "deno run --env-file=.test.env -A --no-check bench-network-cases.ts", diff --git a/src/apps/cli/testdeno/test-benchmark-contract.ts b/src/apps/cli/testdeno/test-benchmark-contract.ts new file mode 100644 index 00000000..a2ad593f --- /dev/null +++ b/src/apps/cli/testdeno/test-benchmark-contract.ts @@ -0,0 +1,134 @@ +import { assert, assertEquals, assertStringIncludes } from "@std/assert"; +import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts"; + +function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase { + const found = cases.find((testCase) => testCase.name === name); + assert(found, `missing benchmark case: ${name}`); + return found; +} + +function parsedLimitations(testCase: BenchmarkCase): string[] { + const raw = testCase.env.BENCH_LIMITATIONS_JSON; + assert( + raw, + `${testCase.name} must pass BENCH_LIMITATIONS_JSON to benchmark result output`, + ); + const parsed = JSON.parse(raw); + assert( + Array.isArray(parsed), + `${testCase.name} limitations must be an array`, + ); + assert( + parsed.every((item) => + typeof item === "string" && item.trim().length > 0 + ), + ); + return parsed; +} + +Deno.test("benchmark cases record scope and limitations for paper use", () => { + const cases = buildCases(); + assert(cases.length > 0); + + for (const testCase of cases) { + assert( + testCase.description.trim().length > 0, + `${testCase.name} must describe the case`, + ); + assert( + testCase.dataPath.trim().length > 0, + `${testCase.name} must describe the data path`, + ); + assert( + testCase.trustBoundary.trim().length > 0, + `${testCase.name} must describe the trust boundary`, + ); + assert( + testCase.measurementScope.trim().length > 0, + `${testCase.name} must describe the measurement scope`, + ); + assert( + testCase.limitations.length > 0, + `${testCase.name} must list limitations`, + ); + assertEquals( + testCase.env.BENCH_MEASUREMENT_SCOPE, + testCase.measurementScope, + ); + assertEquals(parsedLimitations(testCase), testCase.limitations); + } +}); + +Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => { + const cases = buildCases(); + for ( + const name of [ + "p2p-signalling-netem-home-wifi", + "p2p-signalling-netem-tethering-vpn", + ] + ) { + const testCase = getCase(cases, name); + assertEquals(testCase.runner, "p2p"); + assertEquals(testCase.env.BENCH_TURN_SERVERS, ""); + assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2"); + assertEquals( + testCase.env.BENCH_NETWORK_MODEL, + "compose-netem-signalling-shim", + ); + assertStringIncludes(testCase.dataPath, "WebRTC DataChannel"); + assertStringIncludes(testCase.dataPath, "Nostr signalling"); + assert( + testCase.limitations.some((limitation) => + limitation.includes("does not shape the selected WebRTC") + ), + `${name} must avoid claiming that the P2P note-data path was shaped`, + ); + } +}); + +Deno.test("placeholder and TURN cases are clearly non-evidence for broad P2P performance", () => { + const cases = buildCases(); + + const smartphone = getCase(cases, "p2p-smartphone-vpn-direct"); + assertEquals(smartphone.env.BENCH_SIMULATION_TIER, "unmeasured"); + assertEquals(smartphone.env.BENCH_NETWORK_MODEL, "local-runner-no-netem"); + assert( + smartphone.limitations.some((limitation) => + limitation.includes("must not be reported as smartphone") + ), + "smartphone/VPN placeholder must not be usable as field evidence by accident", + ); + + const turn = getCase(cases, "p2p-user-turn"); + assertStringIncludes(turn.env.BENCH_TURN_SERVERS, "turn:"); + assert( + turn.limitations.some((limitation) => + limitation.includes( + "does not prove that the selected ICE path was relayed", + ) + ), + "TURN case must require selected ICE candidate interpretation", + ); +}); + +Deno.test("CouchDB netem cases are marked as remote-store baselines", () => { + const cases = buildCases(); + for ( + const name of ["couchdb-netem-home-wifi", "couchdb-netem-tethering-vpn"] + ) { + const testCase = getCase(cases, name); + assertEquals(testCase.runner, "couchdb"); + assertEquals(testCase.env.BENCH_SIMULATION_TIER, "2"); + assertEquals( + testCase.env.BENCH_NETWORK_MODEL, + "compose-netem-tcp-shim", + ); + assertStringIncludes(testCase.measurementScope, "CouchDB"); + assert( + testCase.limitations.some((limitation) => + limitation.includes("not the WebRTC P2P data path") + ), + `${name} must remain scoped to the CouchDB remote-store path`, + ); + } +}); From b9a27ffef9c1cf6cecbc75ed15dae3590ff66942 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 9 Jul 2026 02:02:02 +0000 Subject: [PATCH 015/170] Run CLI P2P E2E in Compose CI --- .github/workflows/cli-deno-tests.yml | 44 +++++++++++++++++++++ .github/workflows/cli-p2p-compose-smoke.yml | 36 +++++++++++------ test/bench-network/Dockerfile.runner | 3 +- test/bench-network/compose.yml | 7 ++++ test/bench-network/run-bench.sh | 5 ++- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-deno-tests.yml index eff411c0..10ba1f5a 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-deno-tests.yml @@ -9,6 +9,9 @@ on: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - 'src/lib/src/API/processSetting.ts' + - 'src/lib/src/replication/trystero/**' + - 'src/lib/src/rpc/**' + - 'test/bench-network/**' - 'package.json' - 'package-lock.json' pull_request: @@ -16,6 +19,9 @@ on: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - 'src/lib/src/API/processSetting.ts' + - 'src/lib/src/replication/trystero/**' + - 'src/lib/src/rpc/**' + - 'test/bench-network/**' - 'package.json' - 'package-lock.json' workflow_dispatch: @@ -155,3 +161,41 @@ jobs: run: | docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true + + compose-p2p-e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Run Compose CLI P2P E2E + env: + BENCH_COMMAND: cli-p2p-e2e + CLI_P2P_E2E_TASK: test:p2p-sync + RELAY: ws://nostr-relay:7777/ + PEERS_TIMEOUT: '20' + SYNC_TIMEOUT: '60' + LIVESYNC_USE_COTURN: '0' + TURN_SERVERS: none + LIVESYNC_P2P_PEERS_RETRY: '1' + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' + BENCH_LIVESYNC_TEST_TEE: '0' + run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner + + - name: Show Compose diagnostics + if: failure() + run: | + docker compose -f test/bench-network/compose.yml ps + docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true + + - name: Stop Compose services + if: always() + run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans diff --git a/.github/workflows/cli-p2p-compose-smoke.yml b/.github/workflows/cli-p2p-compose-smoke.yml index 85716039..882c8592 100644 --- a/.github/workflows/cli-p2p-compose-smoke.yml +++ b/.github/workflows/cli-p2p-compose-smoke.yml @@ -1,24 +1,21 @@ # Run the Compose-packaged CLI P2P smoke benchmark. # -# This workflow is intentionally non-required at first. It exercises the local -# Compose package for CouchDB + Nostr relay + CLI runner, and uploads the -# benchmark JSON results for inspection. +# This workflow is intentionally manual-only. It exercises the local Compose +# package for CouchDB + Nostr relay + CLI runner, and uploads the benchmark JSON +# results for inspection without adding benchmark work to pull-request CI. name: cli-p2p-compose-smoke on: - pull_request: - paths: - - '.github/workflows/cli-p2p-compose-smoke.yml' - - 'package.json' - - 'package-lock.json' - - 'src/apps/cli/**' - - 'test/bench-network/**' workflow_dispatch: inputs: cases: description: 'Comma-separated benchmark cases' required: false default: 'couchdb-baseline,p2p-direct-local' + signalling_cases: + description: 'Comma-separated signalling-shim P2P benchmark cases' + required: false + default: 'p2p-signalling-netem-home-wifi' md_files: description: 'Markdown file count' required: false @@ -63,13 +60,28 @@ jobs: BENCH_PEERS_TIMEOUT: '20' LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' BENCH_LIVESYNC_TEST_TEE: '0' - run: docker compose -f test/bench-network/compose.yml run --rm bench-runner + run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner + + - name: Run Compose P2P signalling-shim smoke benchmark + env: + BENCH_CASES: ${{ inputs.signalling_cases || 'p2p-signalling-netem-home-wifi' }} + BENCH_MD_FILE_COUNT: ${{ inputs.md_files || '2' }} + BENCH_MD_MIN_SIZE_BYTES: '128' + BENCH_MD_MAX_SIZE_BYTES: '256' + BENCH_BIN_FILE_COUNT: ${{ inputs.bin_files || '1' }} + BENCH_BIN_SIZE_BYTES: '512' + BENCH_SYNC_TIMEOUT: '180' + BENCH_PEERS_TIMEOUT: '60' + LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' + BENCH_LIVESYNC_TEST_TEE: '0' + NETEM_PROFILE: 'home-wifi' + run: docker compose -f test/bench-network/compose.yml --profile signalling-shim run --build --rm bench-runner-signalling-shim - name: Show Compose diagnostics if: failure() run: | docker compose -f test/bench-network/compose.yml ps - docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay + docker compose -f test/bench-network/compose.yml --profile signalling-shim logs --no-color couchdb nostr-relay p2p-signalling-shim || true - name: Upload benchmark results if: always() diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner index 25afbd63..c3e72589 100644 --- a/test/bench-network/Dockerfile.runner +++ b/test/bench-network/Dockerfile.runner @@ -27,7 +27,8 @@ RUN deno cache --lock=deno.lock \ bench-latency-sweep.ts \ bench-p2p-split-node.ts \ bench-p2p.ts \ - bench-couchdb.ts + bench-couchdb.ts \ + test-p2p-sync.ts COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench RUN chmod +x /usr/local/bin/run-livesync-bench diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index 3f3167ef..7606a863 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -96,6 +96,13 @@ services: BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + CLI_P2P_E2E_TASK: ${CLI_P2P_E2E_TASK:-test:p2p-sync} + RELAY: ${RELAY:-ws://nostr-relay:7777/} + PEERS_TIMEOUT: ${PEERS_TIMEOUT:-20} + SYNC_TIMEOUT: ${SYNC_TIMEOUT:-60} + LIVESYNC_USE_COTURN: ${LIVESYNC_USE_COTURN:-0} + TURN_SERVERS: ${TURN_SERVERS:-none} + LIVESYNC_P2P_PEERS_RETRY: ${LIVESYNC_P2P_PEERS_RETRY:-1} volumes: - ./bench-results:/workspace/src/apps/cli/testdeno/bench-results diff --git a/test/bench-network/run-bench.sh b/test/bench-network/run-bench.sh index 32272054..95c052a6 100644 --- a/test/bench-network/run-bench.sh +++ b/test/bench-network/run-bench.sh @@ -11,9 +11,12 @@ case "${BENCH_COMMAND:-cases}" in p2p-split-node) exec deno task bench:p2p-split-node ;; + cli-p2p-e2e) + exec deno task "${CLI_P2P_E2E_TASK:-test:p2p-sync}" + ;; *) echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2 - echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2 + echo "Expected one of: cases, latency-sweep, p2p-split-node, cli-p2p-e2e" >&2 exit 2 ;; esac From fd3e8416b7c8a51725e7e8973132acbf2f54424f Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 9 Jul 2026 02:22:40 +0000 Subject: [PATCH 016/170] Move CLI E2E runner into CLI tests --- .github/workflows/cli-deno-tests.yml | 5 ++--- src/apps/cli/testdeno/run-cli-e2e.sh | 15 +++++++++++++++ test/bench-network/Dockerfile.runner | 3 ++- test/bench-network/compose.yml | 2 +- test/bench-network/run-bench.sh | 5 +---- 5 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 src/apps/cli/testdeno/run-cli-e2e.sh diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-deno-tests.yml index 10ba1f5a..afcf60e2 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-deno-tests.yml @@ -178,8 +178,7 @@ jobs: - name: Run Compose CLI P2P E2E env: - BENCH_COMMAND: cli-p2p-e2e - CLI_P2P_E2E_TASK: test:p2p-sync + CLI_E2E_TASK: test:p2p-sync RELAY: ws://nostr-relay:7777/ PEERS_TIMEOUT: '20' SYNC_TIMEOUT: '60' @@ -188,7 +187,7 @@ jobs: LIVESYNC_P2P_PEERS_RETRY: '1' LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000' BENCH_LIVESYNC_TEST_TEE: '0' - run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner + run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e - name: Show Compose diagnostics if: failure() diff --git a/src/apps/cli/testdeno/run-cli-e2e.sh b/src/apps/cli/testdeno/run-cli-e2e.sh new file mode 100644 index 00000000..bdc9e14e --- /dev/null +++ b/src/apps/cli/testdeno/run-cli-e2e.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +TASK="${CLI_E2E_TASK:-test:p2p-sync}" + +case "$TASK" in + test:p2p-host|test:p2p-peers|test:p2p-sync|test:p2p-three-nodes|test:p2p-upload-download) + exec deno task "$TASK" + ;; + *) + echo "Unknown CLI_E2E_TASK: $TASK" >&2 + echo "Expected one of: test:p2p-host, test:p2p-peers, test:p2p-sync, test:p2p-three-nodes, test:p2p-upload-download" >&2 + exit 2 + ;; +esac diff --git a/test/bench-network/Dockerfile.runner b/test/bench-network/Dockerfile.runner index c3e72589..04d387c3 100644 --- a/test/bench-network/Dockerfile.runner +++ b/test/bench-network/Dockerfile.runner @@ -31,6 +31,7 @@ RUN deno cache --lock=deno.lock \ test-p2p-sync.ts COPY test/bench-network/run-bench.sh /usr/local/bin/run-livesync-bench -RUN chmod +x /usr/local/bin/run-livesync-bench +COPY src/apps/cli/testdeno/run-cli-e2e.sh /usr/local/bin/run-livesync-cli-e2e +RUN chmod +x /usr/local/bin/run-livesync-bench /usr/local/bin/run-livesync-cli-e2e CMD ["run-livesync-bench"] diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index 7606a863..db9d621c 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -96,7 +96,7 @@ services: BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} - CLI_P2P_E2E_TASK: ${CLI_P2P_E2E_TASK:-test:p2p-sync} + CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p-sync} RELAY: ${RELAY:-ws://nostr-relay:7777/} PEERS_TIMEOUT: ${PEERS_TIMEOUT:-20} SYNC_TIMEOUT: ${SYNC_TIMEOUT:-60} diff --git a/test/bench-network/run-bench.sh b/test/bench-network/run-bench.sh index 95c052a6..32272054 100644 --- a/test/bench-network/run-bench.sh +++ b/test/bench-network/run-bench.sh @@ -11,12 +11,9 @@ case "${BENCH_COMMAND:-cases}" in p2p-split-node) exec deno task bench:p2p-split-node ;; - cli-p2p-e2e) - exec deno task "${CLI_P2P_E2E_TASK:-test:p2p-sync}" - ;; *) echo "Unknown BENCH_COMMAND: ${BENCH_COMMAND}" >&2 - echo "Expected one of: cases, latency-sweep, p2p-split-node, cli-p2p-e2e" >&2 + echo "Expected one of: cases, latency-sweep, p2p-split-node" >&2 exit 2 ;; esac From a60932d9e4c54c64fc3369c8ac0080ab71d460f7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 10:54:16 +0000 Subject: [PATCH 017/170] Strengthen CLI benchmark verification --- src/apps/cli/testdeno/bench-couchdb.ts | 103 ++++++++++-------- src/apps/cli/testdeno/bench-latency-sweep.ts | 90 +++++++++++---- src/apps/cli/testdeno/bench-network-cases.ts | 12 +- src/apps/cli/testdeno/bench-p2p.ts | 77 ++++++------- .../testdeno/helpers/benchmarkVerification.ts | 80 ++++++++++++++ .../cli/testdeno/test-benchmark-contract.ts | 89 +++++++++++++++ test/bench-network/README.md | 25 +++-- test/bench-network/compose.yml | 2 + 8 files changed, 360 insertions(+), 118 deletions(-) create mode 100644 src/apps/cli/testdeno/helpers/benchmarkVerification.ts diff --git a/src/apps/cli/testdeno/bench-couchdb.ts b/src/apps/cli/testdeno/bench-couchdb.ts index cdf50d8c..dfe78aa1 100644 --- a/src/apps/cli/testdeno/bench-couchdb.ts +++ b/src/apps/cli/testdeno/bench-couchdb.ts @@ -11,8 +11,12 @@ import { } from "./helpers/docker.ts"; import { createDeterministicDataset, - type DatasetEntry, } from "./helpers/dataset.ts"; +import { + type BenchmarkVerificationMode, + parseBenchmarkVerificationMode, + verifyBenchmarkDataset, +} from "./helpers/benchmarkVerification.ts"; type BenchmarkConfig = { caseName: string; @@ -38,6 +42,9 @@ type BenchmarkConfig = { networkModel: string; measurementScope: string; limitations: string[]; + verificationMode: BenchmarkVerificationMode; + repeatIndex: number; + repeatCount: number; }; function readEnvString(name: string, fallback: string): string { @@ -163,6 +170,11 @@ function buildConfig(): BenchmarkConfig { limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ "This benchmark result is scoped to the configured dataset, remote store, and network model.", ]), + verificationMode: parseBenchmarkVerificationMode( + Deno.env.get("BENCH_VERIFY_MODE"), + ), + repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), + repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), }; } @@ -174,35 +186,27 @@ function readOptionalResultPath(): string | undefined { return raw; } -function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { - if (entries.length === 0) { - return []; - } - const md = entries.find((e) => e.kind === "md"); - const bin = entries.find((e) => e.kind === "bin"); - const middle = entries[Math.floor(entries.length / 2)]; - const last = entries[entries.length - 1]; - const unique = new Map(); - for (const entry of [md, bin, middle, last]) { - if (entry) { - unique.set(entry.relativePath, entry); - } - } - return [...unique.values()]; -} - -type ProxyHandle = { +export type CouchdbProxyHandle = { stop: () => Promise; applied: boolean; note: string; + directionalDelayMs: number; }; -function startCouchdbProxy( - options: { backendUri: string; proxyUri: string; requestedRttMs: number }, -): ProxyHandle { +export function startCouchdbProxy( + options: { + backendUri: string; + proxyUri: string; + requestedRttMs: number; + delay?: (milliseconds: number) => Promise; + }, +): CouchdbProxyHandle { const backend = new URL(options.backendUri); const proxy = new URL(options.proxyUri); - const halfDelayMs = Math.max(1, Math.floor(options.requestedRttMs / 2)); + const halfDelayMs = options.requestedRttMs / 2; + const delay = options.delay ?? + ((milliseconds: number) => + new Promise((resolve) => setTimeout(resolve, milliseconds))); const controller = new AbortController(); const listener = Deno.serve( @@ -216,7 +220,7 @@ function startCouchdbProxy( }, }, async (request) => { - await new Promise((resolve) => setTimeout(resolve, halfDelayMs)); + await delay(halfDelayMs); const targetUrl = new URL(request.url); targetUrl.protocol = backend.protocol; @@ -245,6 +249,7 @@ function startCouchdbProxy( const responseHeaders = new Headers(upstream.headers); responseHeaders.delete("content-length"); const responseBody = await upstream.arrayBuffer(); + await delay(halfDelayMs); return new Response(responseBody, { status: upstream.status, @@ -256,8 +261,9 @@ function startCouchdbProxy( return { applied: true, + directionalDelayMs: halfDelayMs, note: - `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms pre-forward delay`, + `local reverse proxy on ${proxy.origin} with ${halfDelayMs}ms request-path and ${halfDelayMs}ms response-path delay`, stop: async () => { controller.abort(); await listener.finished.catch(() => {}); @@ -350,25 +356,28 @@ async function main(): Promise { await runCliOrFail(vaultB, "--settings", settingsB, "sync"); const syncBElapsed = nowMs() - syncBStart; - const sampleFiles = pickSampleFiles(seedFiles.entries); - for (const sample of sampleFiles) { - const pulledPath = workDir.join( - `pulled-${sample.relativePath.split("/").join("_")}`, - ); - await runCliOrFail( - vaultB, - "--settings", - settingsB, - "pull", - sample.relativePath, - pulledPath, - ); - await assertFilesEqual( - sample.absolutePath, - pulledPath, - `sample file mismatch after CouchDB sync: ${sample.relativePath}`, - ); - } + const verification = await verifyBenchmarkDataset( + seedFiles.entries, + config.verificationMode, + async (entry) => { + const pulledPath = workDir.join( + `pulled-${entry.relativePath.split("/").join("_")}`, + ); + await runCliOrFail( + vaultB, + "--settings", + settingsB, + "pull", + entry.relativePath, + pulledPath, + ); + await assertFilesEqual( + entry.absolutePath, + pulledPath, + `file mismatch after CouchDB sync: ${entry.relativePath}`, + ); + }, + ); const result = { caseName: config.caseName, @@ -382,15 +391,21 @@ async function main(): Promise { networkModel: config.networkModel, measurementScope: config.measurementScope, limitations: config.limitations, + repeatIndex: config.repeatIndex, + repeatCount: config.repeatCount, rttRequestedMs: config.requestedRttMs, proxyApplied: proxy.applied, proxyNote: proxy.note, + proxyDirectionalDelayMs: proxy.directionalDelayMs, + proxyConfiguredRttMs: proxy.directionalDelayMs * 2, + proxyDelayApplication: "request-and-response", datasetSeed: config.datasetSeed, datasetDirName: config.datasetDirName, totalFiles: seedFiles.totalFiles, totalBytes: seedFiles.totalBytes, mdFileCount: seedFiles.mdCount, binFileCount: seedFiles.binCount, + ...verification, mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), syncAElapsedMs: Number(syncAElapsed.toFixed(1)), syncBElapsedMs: Number(syncBElapsed.toFixed(1)), diff --git a/src/apps/cli/testdeno/bench-latency-sweep.ts b/src/apps/cli/testdeno/bench-latency-sweep.ts index d69de28c..3624b1d5 100644 --- a/src/apps/cli/testdeno/bench-latency-sweep.ts +++ b/src/apps/cli/testdeno/bench-latency-sweep.ts @@ -2,6 +2,8 @@ type SweepResult = { name: string; runner: "p2p" | "couchdb"; rttMs?: number; + repeatIndex: number; + repeatCount: number; result: Record; }; @@ -19,6 +21,15 @@ function timestamp(): string { ); } +function readEnvInteger(name: string, fallback: number): number { + const raw = readEnvString(name, String(fallback)); + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer, got '${raw}'`); + } + return parsed; +} + function parseRttList(raw: string): number[] { const values = raw .split(",") @@ -41,6 +52,7 @@ function buildBaseEnv(): Record { BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"), BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"), LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"), }; } @@ -50,12 +62,19 @@ async function runBenchmark(options: { name: string; outputDir: string; env: Record; + repeatIndex: number; + repeatCount: number; }): Promise> { - const resultPath = `${options.outputDir}/${options.name}.json`; + const suffix = options.repeatCount > 1 + ? `-r${String(options.repeatIndex).padStart(2, "0")}` + : ""; + const resultPath = `${options.outputDir}/${options.name}${suffix}.json`; const env = { ...Deno.env.toObject(), ...options.env, BENCH_RESULT_JSON: resultPath, + BENCH_REPEAT_INDEX: String(options.repeatIndex), + BENCH_REPEAT_COUNT: String(options.repeatCount), }; console.log(`[latency-sweep] running ${options.name}`); @@ -78,46 +97,69 @@ async function main(): Promise { const outRoot = readEnvString("BENCH_SWEEP_ROOT", `${import.meta.dirname}/bench-results`); const outputDir = `${outRoot}/latency-sweep-${timestamp()}`; const rtts = parseRttList(readEnvString("BENCH_SWEEP_RTT_MS", "20,50,100,150,300")); + const repeatCount = readEnvInteger("BENCH_REPEAT_COUNT", 1); const base = buildBaseEnv(); await Deno.mkdir(outputDir, { recursive: true }); const results: SweepResult[] = []; if (readEnvString("BENCH_SWEEP_INCLUDE_P2P", "true") !== "false") { - const p2pResult = await runBenchmark({ - taskName: "bench:p2p", - name: "p2p-direct-local", - outputDir, - env: { - ...base, - BENCH_CASE: "p2p-direct-local", - BENCH_TURN_SERVERS: "", - }, - }); - results.push({ name: "p2p-direct-local", runner: "p2p", result: p2pResult }); + for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { + const p2pResult = await runBenchmark({ + taskName: "bench:p2p", + name: "p2p-direct-local", + outputDir, + repeatIndex, + repeatCount, + env: { + ...base, + BENCH_CASE: "p2p-direct-local", + BENCH_TURN_SERVERS: "", + }, + }); + results.push({ + name: "p2p-direct-local", + runner: "p2p", + repeatIndex, + repeatCount, + result: p2pResult, + }); + } } for (const rtt of rtts) { const name = `couchdb-rtt-${rtt}ms`; - const couchdbResult = await runBenchmark({ - taskName: "bench:couchdb", - name, - outputDir, - env: { - ...base, - BENCH_CASE: name, - BENCH_COUCHDB_RTT_MS: String(rtt), - }, - }); - results.push({ name, runner: "couchdb", rttMs: rtt, result: couchdbResult }); + for (let repeatIndex = 1; repeatIndex <= repeatCount; repeatIndex++) { + const couchdbResult = await runBenchmark({ + taskName: "bench:couchdb", + name, + outputDir, + repeatIndex, + repeatCount, + env: { + ...base, + BENCH_CASE: name, + BENCH_COUCHDB_RTT_MS: String(rtt), + }, + }); + results.push({ + name, + runner: "couchdb", + rttMs: rtt, + repeatIndex, + repeatCount, + result: couchdbResult, + }); + } } const summary = { generatedAt: new Date().toISOString(), outputDir, note: - "This sweep models additional remote CouchDB request latency through the existing HTTP proxy. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.", + "This sweep applies half of each requested CouchDB RTT before forwarding requests and half before returning responses. It is not a full netem model of jitter, loss, MTU, bandwidth, or VPN encapsulation.", rtts, + repeatCount, results, }; await Deno.writeTextFile(`${outputDir}/summary.json`, JSON.stringify(summary, null, 2)); diff --git a/src/apps/cli/testdeno/bench-network-cases.ts b/src/apps/cli/testdeno/bench-network-cases.ts index 689939af..5795800b 100644 --- a/src/apps/cli/testdeno/bench-network-cases.ts +++ b/src/apps/cli/testdeno/bench-network-cases.ts @@ -52,6 +52,7 @@ function buildBaseEnv(): Record { BENCH_SYNC_TIMEOUT: readEnvString("BENCH_SYNC_TIMEOUT", "300"), BENCH_PEERS_TIMEOUT: readEnvString("BENCH_PEERS_TIMEOUT", "60"), BENCH_SEED: readEnvString("BENCH_SEED", "livesync-benchmark-seed"), + BENCH_VERIFY_MODE: readEnvString("BENCH_VERIFY_MODE", "all"), LIVESYNC_TEST_TEE: readEnvString("BENCH_LIVESYNC_TEST_TEE", "0"), }; } @@ -119,9 +120,10 @@ export function buildCases(): BenchmarkCase[] { dataPath: "Device A -> Device B", trustBoundary: "Nostr relay for signalling metadata; no TURN relay", measurementScope: - "One CLI P2P synchronisation phase over a local WebRTC DataChannel after Nostr signalling, with TURN disabled.", + "One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment, with TURN disabled; the earlier peer-list observation command is excluded.", limitations: [ - "This does not measure first-peer discovery latency, public relay operation, mobile carrier behaviour, or TURN-relayed throughput.", + "The timed command includes its own signalling and connection establishment, but not the earlier peer-list observation window.", + "This does not measure public relay operation, mobile carrier behaviour, or TURN-relayed throughput.", "This small-dataset run should not be treated as a WAN, VPN, or large binary initial synchronisation measurement.", ], env: { @@ -240,8 +242,9 @@ export function buildCases(): BenchmarkCase[] { trustBoundary: "Nostr signalling metadata through constrained network shim; no TURN relay", measurementScope: - "Tier 2 P2P synchronisation where only the Nostr signalling path is shaped by the home-wifi netem profile.", + "One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the home-wifi netem profile; the selected WebRTC note-data path is unshaped.", limitations: [ + "The timed p2p-sync command includes signalling and WebRTC connection establishment.", "This does not shape the selected WebRTC DataChannel note-data path.", "This supports only the claim that constrained signalling access does not place note data on the relay path when a non-relayed ICE path is selected.", ], @@ -267,8 +270,9 @@ export function buildCases(): BenchmarkCase[] { trustBoundary: "Nostr signalling metadata through constrained smartphone/VPN-like network shim; no TURN relay", measurementScope: - "Tier 2 P2P synchronisation where only the Nostr signalling path is shaped by the tethering-vpn netem profile.", + "One fresh CLI p2p-sync command where only Nostr signalling access is shaped by the tethering-vpn netem profile; the selected WebRTC note-data path is unshaped.", limitations: [ + "The timed p2p-sync command includes signalling and WebRTC connection establishment.", "This does not shape the selected WebRTC DataChannel note-data path.", "The profile approximates constrained relay access and is not a field measurement on a real tethered VPN connection.", ], diff --git a/src/apps/cli/testdeno/bench-p2p.ts b/src/apps/cli/testdeno/bench-p2p.ts index e763e9dc..05512694 100644 --- a/src/apps/cli/testdeno/bench-p2p.ts +++ b/src/apps/cli/testdeno/bench-p2p.ts @@ -15,8 +15,12 @@ import { import { assertFilesEqual, runCliOrFail } from "./helpers/cli.ts"; import { createDeterministicDataset, - type DatasetEntry, } from "./helpers/dataset.ts"; +import { + type BenchmarkVerificationMode, + parseBenchmarkVerificationMode, + verifyBenchmarkDataset, +} from "./helpers/benchmarkVerification.ts"; type BenchmarkConfig = { caseName: string; @@ -40,6 +44,9 @@ type BenchmarkConfig = { candidatePathVerification: string; measurementScope: string; limitations: string[]; + verificationMode: BenchmarkVerificationMode; + repeatIndex: number; + repeatCount: number; }; type P2PConnectionStats = { @@ -174,11 +181,16 @@ function buildConfig(): BenchmarkConfig { ), measurementScope: readEnvString( "BENCH_MEASUREMENT_SCOPE", - "One CLI P2P synchronisation phase over WebRTC DataChannel after signalling.", + "One fresh CLI p2p-sync command, including process start-up and WebRTC connection establishment; the earlier peer-list observation command is excluded.", ), limitations: readEnvStringArray("BENCH_LIMITATIONS_JSON", [ "This benchmark result is scoped to the configured dataset, network model, and selected ICE path.", ]), + verificationMode: parseBenchmarkVerificationMode( + Deno.env.get("BENCH_VERIFY_MODE"), + ), + repeatIndex: Math.floor(readEnvNumber("BENCH_REPEAT_INDEX", 1)), + repeatCount: Math.floor(readEnvNumber("BENCH_REPEAT_COUNT", 1)), }; } @@ -190,23 +202,6 @@ function readOptionalResultPath(): string | undefined { return raw; } -function pickSampleFiles(entries: DatasetEntry[]): DatasetEntry[] { - if (entries.length === 0) { - return []; - } - const md = entries.find((e) => e.kind === "md"); - const bin = entries.find((e) => e.kind === "bin"); - const middle = entries[Math.floor(entries.length / 2)]; - const last = entries[entries.length - 1]; - const unique = new Map(); - for (const entry of [md, bin, middle, last]) { - if (entry) { - unique.set(entry.relativePath, entry); - } - } - return [...unique.values()]; -} - async function readLatestP2PConnectionStats( statsPath: string, ): Promise { @@ -329,25 +324,28 @@ async function main(): Promise { ); const syncElapsed = nowMs() - syncStart; - const sampleFiles = pickSampleFiles(seedFiles.entries); - for (const sample of sampleFiles) { - const pulledPath = workDir.join( - `pulled-${sample.relativePath.replaceAll("/", "_")}`, - ); - await runCliOrFail( - clientVault, - "--settings", - clientSettings, - "pull", - sample.relativePath, - pulledPath, - ); - await assertFilesEqual( - sample.absolutePath, - pulledPath, - `sample file mismatch after sync: ${sample.relativePath}`, - ); - } + const verification = await verifyBenchmarkDataset( + seedFiles.entries, + config.verificationMode, + async (entry) => { + const pulledPath = workDir.join( + `pulled-${entry.relativePath.replaceAll("/", "_")}`, + ); + await runCliOrFail( + clientVault, + "--settings", + clientSettings, + "pull", + entry.relativePath, + pulledPath, + ); + await assertFilesEqual( + entry.absolutePath, + pulledPath, + `file mismatch after P2P sync: ${entry.relativePath}`, + ); + }, + ); const p2pConnectionStats = await readLatestP2PConnectionStats( p2pStatsPath, @@ -363,6 +361,8 @@ async function main(): Promise { networkModel: config.networkModel, measurementScope: config.measurementScope, limitations: config.limitations, + repeatIndex: config.repeatIndex, + repeatCount: config.repeatCount, p2pCandidatePathVerified: p2pConnectionStats?.candidatePathCollected === true, p2pCandidatePathVerification: @@ -385,6 +385,7 @@ async function main(): Promise { totalBytes: seedFiles.totalBytes, mdFileCount: seedFiles.mdCount, binFileCount: seedFiles.binCount, + ...verification, mirrorElapsedMs: Number(mirrorElapsed.toFixed(1)), hostReadyElapsedMs: Number(hostReadyElapsed.toFixed(1)), peerDiscoveryTimeoutSeconds: config.peersTimeoutSeconds, diff --git a/src/apps/cli/testdeno/helpers/benchmarkVerification.ts b/src/apps/cli/testdeno/helpers/benchmarkVerification.ts new file mode 100644 index 00000000..ace22ed3 --- /dev/null +++ b/src/apps/cli/testdeno/helpers/benchmarkVerification.ts @@ -0,0 +1,80 @@ +import type { DatasetEntry } from "./dataset.ts"; + +export type BenchmarkVerificationMode = "all" | "sample"; + +export type BenchmarkVerificationResult = { + verificationMode: BenchmarkVerificationMode; + verifiedFiles: number; + verificationComplete: boolean; + datasetDigestSha256: string; +}; + +function toHex(bytes: ArrayBuffer): string { + return [...new Uint8Array(bytes)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); +} + +async function sha256(bytes: Uint8Array): Promise { + const input = new ArrayBuffer(bytes.byteLength); + new Uint8Array(input).set(bytes); + return toHex(await crypto.subtle.digest("SHA-256", input)); +} + +export function parseBenchmarkVerificationMode( + raw: string | undefined, + fallback: BenchmarkVerificationMode = "sample", +): BenchmarkVerificationMode { + const value = raw?.trim().toLowerCase(); + if (!value) return fallback; + if (value === "all" || value === "sample") return value; + throw new Error(`BENCH_VERIFY_MODE must be 'all' or 'sample', got '${raw}'`); +} + +export function selectVerificationEntries( + entries: DatasetEntry[], + mode: BenchmarkVerificationMode, +): DatasetEntry[] { + if (mode === "all" || entries.length === 0) return [...entries]; + + const md = entries.find((entry) => entry.kind === "md"); + const bin = entries.find((entry) => entry.kind === "bin"); + const middle = entries[Math.floor(entries.length / 2)]; + const last = entries[entries.length - 1]; + const selected = new Map(); + for (const entry of [md, bin, middle, last]) { + if (entry) selected.set(entry.relativePath, entry); + } + return [...selected.values()]; +} + +export async function computeDatasetDigestSha256( + entries: DatasetEntry[], +): Promise { + const manifest: string[] = []; + for (const entry of entries) { + const contentDigest = await sha256(await Deno.readFile(entry.absolutePath)); + manifest.push( + `${entry.kind}\t${entry.relativePath}\t${entry.size}\t${contentDigest}`, + ); + } + return await sha256(new TextEncoder().encode(manifest.join("\n"))); +} + +export async function verifyBenchmarkDataset( + entries: DatasetEntry[], + mode: BenchmarkVerificationMode, + verifyEntry: (entry: DatasetEntry) => Promise, +): Promise { + const selected = selectVerificationEntries(entries, mode); + for (const entry of selected) { + await verifyEntry(entry); + } + + return { + verificationMode: mode, + verifiedFiles: selected.length, + verificationComplete: selected.length === entries.length, + datasetDigestSha256: await computeDatasetDigestSha256(entries), + }; +} diff --git a/src/apps/cli/testdeno/test-benchmark-contract.ts b/src/apps/cli/testdeno/test-benchmark-contract.ts index a2ad593f..6b189219 100644 --- a/src/apps/cli/testdeno/test-benchmark-contract.ts +++ b/src/apps/cli/testdeno/test-benchmark-contract.ts @@ -1,5 +1,20 @@ import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { type BenchmarkCase, buildCases } from "./bench-network-cases.ts"; +import { startCouchdbProxy } from "./bench-couchdb.ts"; +import { + parseBenchmarkVerificationMode, + selectVerificationEntries, +} from "./helpers/benchmarkVerification.ts"; +import type { DatasetEntry } from "./helpers/dataset.ts"; + +function getFreePort(): number { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + try { + return (listener.addr as Deno.NetAddr).port; + } finally { + listener.close(); + } +} function getCase(cases: BenchmarkCase[], name: string): BenchmarkCase { const found = cases.find((testCase) => testCase.name === name); @@ -56,9 +71,76 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => { testCase.measurementScope, ); assertEquals(parsedLimitations(testCase), testCase.limitations); + assertEquals( + testCase.env.BENCH_VERIFY_MODE, + "all", + `${testCase.name} must verify the complete dataset`, + ); } }); +Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => { + const backendPort = getFreePort(); + const proxyPort = getFreePort(); + const delays: number[] = []; + const backend = Deno.serve( + { + hostname: "127.0.0.1", + port: backendPort, + onListen() {}, + }, + () => new Response("ok"), + ); + const proxy = startCouchdbProxy({ + backendUri: `http://127.0.0.1:${backendPort}`, + proxyUri: `http://127.0.0.1:${proxyPort}`, + requestedRttMs: 20, + delay: (milliseconds) => { + delays.push(milliseconds); + return Promise.resolve(); + }, + }); + + try { + const response = await fetch(`http://127.0.0.1:${proxyPort}/probe`); + assertEquals(await response.text(), "ok"); + assertEquals(proxy.directionalDelayMs, 10); + assertEquals(delays, [10, 10]); + } finally { + await proxy.stop(); + await backend.shutdown(); + } + + const halfMillisecondProxy = startCouchdbProxy({ + backendUri: "http://127.0.0.1:1", + proxyUri: `http://127.0.0.1:${getFreePort()}`, + requestedRttMs: 1, + delay: () => Promise.resolve(), + }); + try { + assertEquals(halfMillisecondProxy.directionalDelayMs, 0.5); + } finally { + await halfMillisecondProxy.stop(); + } +}); + +Deno.test("benchmark verification mode selects either all files or a labelled sample", () => { + const entries: DatasetEntry[] = [ + { kind: "md", relativePath: "a.md", absolutePath: "/a", size: 1 }, + { kind: "md", relativePath: "b.md", absolutePath: "/b", size: 1 }, + { kind: "bin", relativePath: "c.bin", absolutePath: "/c", size: 1 }, + { kind: "md", relativePath: "d.md", absolutePath: "/d", size: 1 }, + { kind: "bin", relativePath: "e.bin", absolutePath: "/e", size: 1 }, + ]; + + assertEquals(parseBenchmarkVerificationMode("ALL"), "all"); + assertEquals(selectVerificationEntries(entries, "all").length, entries.length); + const sample = selectVerificationEntries(entries, "sample"); + assert(sample.length > 0 && sample.length < entries.length); + assert(sample.some((entry) => entry.kind === "md")); + assert(sample.some((entry) => entry.kind === "bin")); +}); + Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", () => { const cases = buildCases(); for ( @@ -77,6 +159,13 @@ Deno.test("P2P signalling-shim cases do not claim to shape the note-data path", ); assertStringIncludes(testCase.dataPath, "WebRTC DataChannel"); assertStringIncludes(testCase.dataPath, "Nostr signalling"); + assertStringIncludes(testCase.measurementScope, "fresh CLI p2p-sync"); + assert( + testCase.limitations.some((limitation) => + limitation.includes("connection establishment") + ), + `${name} must state that connection establishment is timed`, + ); assert( testCase.limitations.some((limitation) => limitation.includes("does not shape the selected WebRTC") diff --git a/test/bench-network/README.md b/test/bench-network/README.md index 5b93a64b..330b48a1 100644 --- a/test/bench-network/README.md +++ b/test/bench-network/README.md @@ -64,16 +64,24 @@ path: | Case | Data path | What is measured | What is not measured | | ------------------ | ------------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | | `couchdb-baseline` | Device A -> CouchDB -> Device B | Two one-shot CLI synchronisation commands through a local HTTP latency proxy | Real WAN jitter, packet loss, bandwidth limits, VPN encapsulation, and server contention | -| `p2p-direct-local` | Device A -> Device B after Nostr signalling | One CLI P2P synchronisation command over WebRTC DataChannel with TURN disabled | Public relay operation, mobile carrier behaviour, TURN relay throughput, and first-peer discovery latency | +| `p2p-direct-local` | Device A -> Device B using Nostr signalling | One fresh CLI `p2p-sync` command, including process start-up and WebRTC connection establishment, with TURN disabled | Public relay operation, mobile carrier behaviour, and TURN relay throughput | Use the CouchDB result as the remote-store baseline and the P2P result as the direct-transfer comparison. The Nostr relay is used for signalling in the P2P case, but synchronised note content is transferred over the WebRTC DataChannel. -The P2P result JSON records the selected WebRTC ICE candidate pair when the CLI +The earlier `p2p-peers` observation command is excluded from the P2P timing, +but the timed `p2p-sync` command performs its own signalling and connection +establishment. The P2P result JSON records the selected WebRTC ICE candidate pair when the CLI can collect it from `RTCPeerConnection.getStats()`. Interpret P2P paths from the recorded candidate types rather than from TURN configuration alone. Do not -report P2P runs as Tier 2 constrained-network measurements until host and -client are captured under an equivalent shaped topology. +report a signalling-only Tier 2 run as though the selected note-data path were +also shaped. + +Benchmark cases use `BENCH_VERIFY_MODE=all` by default. After the timed phase, +the runner retrieves and compares every generated file and records the verified +file count, whether verification was complete, and a SHA-256 digest of the +deterministic dataset. Set `BENCH_VERIFY_MODE=sample` only for exploratory +large-dataset runs where the additional verification time is impractical. ## Dataset and latency controls @@ -88,10 +96,10 @@ BENCH_PEERS_TIMEOUT=60 \ docker compose -f test/bench-network/compose.yml run --rm bench-runner ``` -The current CouchDB latency model is the existing HTTP proxy inside -`bench-couchdb.ts`. It models a remote database path with additional request -latency, but it does not model packet loss, jitter, MTU, bandwidth limits, -bufferbloat, or VPN encapsulation. +The CouchDB latency model is the HTTP proxy inside `bench-couchdb.ts`. It adds +half of the requested RTT before forwarding each request and the other half +before returning its response. It does not model packet loss, jitter, MTU, +bandwidth limits, bufferbloat, or VPN encapsulation. For P2P runs, `BENCH_PEERS_TIMEOUT` is passed to `p2p-peers`. That command waits for the requested observation window before printing discovered peers, so the @@ -104,6 +112,7 @@ To run P2P once and CouchDB at several requested RTT values: ```bash BENCH_COMMAND=latency-sweep \ BENCH_SWEEP_RTT_MS=20,50,100,150,300 \ +BENCH_REPEAT_COUNT=3 \ BENCH_MD_FILE_COUNT=100 \ BENCH_MD_MIN_SIZE_BYTES=512 \ BENCH_MD_MAX_SIZE_BYTES=2048 \ diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml index db9d621c..7add3d30 100644 --- a/test/bench-network/compose.yml +++ b/test/bench-network/compose.yml @@ -90,6 +90,7 @@ services: BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} + BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all} BENCH_COUCHDB_RTT_MS: ${BENCH_COUCHDB_RTT_MS:-20} BENCH_TETHERING_VPN_RTT_MS: ${BENCH_TETHERING_VPN_RTT_MS:-120} BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} @@ -220,6 +221,7 @@ services: BENCH_MD_MAX_SIZE_BYTES: ${BENCH_MD_MAX_SIZE_BYTES:-2048} BENCH_BIN_FILE_COUNT: ${BENCH_BIN_FILE_COUNT:-5} BENCH_BIN_SIZE_BYTES: ${BENCH_BIN_SIZE_BYTES:-8192} + BENCH_VERIFY_MODE: ${BENCH_VERIFY_MODE:-all} BENCH_SYNC_TIMEOUT: ${BENCH_SYNC_TIMEOUT:-300} BENCH_PEERS_TIMEOUT: ${BENCH_PEERS_TIMEOUT:-60} LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: ${LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS:-60000} From dbb8d2be228486f9d725424038c2ada913e3057a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 08:52:43 +0000 Subject: [PATCH 018/170] test: establish storage adapter contracts --- .../adapters/NodeStorageAdapter.unit.spec.ts | 12 ++- src/apps/storageAdapterContract.ts | 77 ++++++++++++++++++ .../webapp/adapters/FSAPIStorageAdapter.ts | 24 ++++-- .../adapters/FSAPIStorageAdapter.unit.spec.ts | 81 +++++++++++++++++++ src/lib | 2 +- 5 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 src/apps/storageAdapterContract.ts create mode 100644 src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts diff --git a/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts index a1b09778..c3ad4621 100644 --- a/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts @@ -2,9 +2,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { storageAdapterContractCases } from "@/apps/storageAdapterContract"; import { NodeStorageAdapter } from "./NodeStorageAdapter"; -describe("NodeStorageAdapter binary I/O", () => { +describe("NodeStorageAdapter", () => { const tempDirs: string[] = []; async function createAdapter() { @@ -17,6 +18,12 @@ describe("NodeStorageAdapter binary I/O", () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); + for (const contractCase of storageAdapterContractCases) { + it(contractCase.name, async () => { + await contractCase.run(await createAdapter()); + }); + } + it("writes and reads binary data without corruption", async () => { const adapter = await createAdapter(); const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]); @@ -37,4 +44,7 @@ describe("NodeStorageAdapter binary I/O", () => { expect(result.byteLength).toBe(expected.byteLength); expect(Array.from(new Uint8Array(result))).toEqual([0x10, 0x20, 0x30]); }); + + it.todo("rejects paths that escape the configured root"); + it.todo("rejects removing the configured root through an empty path"); }); diff --git a/src/apps/storageAdapterContract.ts b/src/apps/storageAdapterContract.ts new file mode 100644 index 00000000..21880eaa --- /dev/null +++ b/src/apps/storageAdapterContract.ts @@ -0,0 +1,77 @@ +import type { IStorageAdapter } from "@lib/serviceModules/adapters"; + +/** One platform-neutral storage adapter contract case. */ +export interface StorageAdapterContractCase { + readonly name: string; + run(adapter: IStorageAdapter): Promise; +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`${message}\nactual=${JSON.stringify(actual)}\nexpected=${JSON.stringify(expected)}`); + } +} + +/** Passing baseline shared by Node, FSAPI, and future storage adapters. */ +export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [ + { + name: "reports missing paths consistently", + async run(adapter) { + assertEqual(await adapter.exists("missing.txt"), false, "missing path should not exist"); + assertEqual(await adapter.stat("missing.txt"), null, "missing stat should be null"); + assertEqual(await adapter.trystat("missing.txt"), null, "missing trystat should be null"); + }, + }, + { + name: "creates parent directories for nested text writes", + async run(adapter) { + await adapter.write("notes/nested/note.md", "hello"); + assertEqual(await adapter.read("notes/nested/note.md"), "hello", "text should round-trip"); + assert(await adapter.exists("notes/nested/note.md"), "written text path should exist"); + assertEqual((await adapter.stat("notes/nested/note.md"))?.type, "file", "written path should be a file"); + }, + }, + { + name: "round-trips exact binary bytes", + async run(adapter) { + const expected = Uint8Array.from([0x00, 0x7f, 0x80, 0xff, 0x42]); + await adapter.writeBinary("binary/blob.bin", expected.buffer.slice(0)); + const result = await adapter.readBinary("binary/blob.bin"); + assertEqual([...new Uint8Array(result)], [...expected], "binary data should round-trip exactly"); + assertEqual(result.byteLength, expected.byteLength, "binary result should have the exact visible length"); + }, + }, + { + name: "creates and extends text through append", + async run(adapter) { + await adapter.append("logs/events.log", "first"); + await adapter.append("logs/events.log", ":second"); + assertEqual(await adapter.read("logs/events.log"), "first:second", "append should create then extend text"); + }, + }, + { + name: "lists direct files and folders", + async run(adapter) { + await adapter.mkdir("listing/folder"); + await adapter.write("listing/file.txt", "content"); + const listed = await adapter.list("listing"); + assertEqual([...listed.files].sort(), ["listing/file.txt"], "list should contain the direct file"); + assertEqual([...listed.folders].sort(), ["listing/folder"], "list should contain the direct folder"); + }, + }, + { + name: "removes files and directory trees", + async run(adapter) { + await adapter.write("remove/file.txt", "content"); + await adapter.write("remove/folder/nested.txt", "content"); + await adapter.remove("remove/file.txt"); + assertEqual(await adapter.exists("remove/file.txt"), false, "file should be removed"); + await adapter.remove("remove/folder"); + assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed"); + }, + }, +]; diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.ts index 4cd79d6f..f48a20d8 100644 --- a/src/apps/webapp/adapters/FSAPIStorageAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.ts @@ -70,6 +70,20 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } } + /** Resolve a writable file path after creating its parent directories. */ + private async resolveWritablePath(p: string): Promise<{ + dirHandle: FileSystemDirectoryHandle; + fileName: string; + } | null> { + const parts = p.split("/").filter((part) => part !== ""); + if (parts.length === 0) return null; + const fileName = parts.pop()!; + const parentPath = parts.join("/"); + await this.mkdir(parentPath); + const dirHandle = await this.getDirectoryHandle(parentPath); + return dirHandle ? { dirHandle, fileName } : null; + } + async exists(p: string): Promise { const fileHandle = await this.getFileHandle(p); if (fileHandle) return true; @@ -146,14 +160,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } async write(p: string, data: string, options?: UXDataWriteOptions): Promise { - const resolved = await this.resolvePath(p); + const resolved = await this.resolveWritablePath(p); if (!resolved) { throw new Error(`Invalid path: ${p}`); } - // Ensure parent directory exists - await this.mkdir(p.split("/").slice(0, -1).join("/")); - const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(data); @@ -161,14 +172,11 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { - const resolved = await this.resolvePath(p); + const resolved = await this.resolveWritablePath(p); if (!resolved) { throw new Error(`Invalid path: ${p}`); } - // Ensure parent directory exists - await this.mkdir(p.split("/").slice(0, -1).join("/")); - const fileHandle = await resolved.dirHandle.getFileHandle(resolved.fileName, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(data); diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts new file mode 100644 index 00000000..f741820e --- /dev/null +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts @@ -0,0 +1,81 @@ +import { describe, it } from "vitest"; +import { storageAdapterContractCases } from "@/apps/storageAdapterContract"; +import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter"; + +class MemoryFileHandle { + readonly kind = "file"; + private data = new Uint8Array(); + + constructor(readonly name: string) {} + + async getFile(): Promise { + return new File([this.data], this.name, { lastModified: 1 }); + } + + async createWritable(): Promise { + const handle = this; + return { + async write(data: FileSystemWriteChunkType) { + if (typeof data === "string") { + handle.data = new TextEncoder().encode(data); + } else if (data instanceof ArrayBuffer) { + handle.data = new Uint8Array(data.slice(0)); + } else if (ArrayBuffer.isView(data)) { + handle.data = new Uint8Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)); + } else { + throw new TypeError("Unsupported in-memory write type"); + } + }, + async close() {}, + } as FileSystemWritableFileStream; + } +} + +class MemoryDirectoryHandle { + readonly kind = "directory"; + private readonly children = new Map(); + + constructor(readonly name: string) {} + + async getDirectoryHandle(name: string, options?: FileSystemGetDirectoryOptions): Promise { + const existing = this.children.get(name); + if (existing instanceof MemoryDirectoryHandle) return existing as unknown as FileSystemDirectoryHandle; + if (existing !== undefined || !options?.create) throw new DOMException("Directory not found", "NotFoundError"); + const directory = new MemoryDirectoryHandle(name); + this.children.set(name, directory); + return directory as unknown as FileSystemDirectoryHandle; + } + + async getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise { + const existing = this.children.get(name); + if (existing instanceof MemoryFileHandle) return existing as unknown as FileSystemFileHandle; + if (existing !== undefined || !options?.create) throw new DOMException("File not found", "NotFoundError"); + const file = new MemoryFileHandle(name); + this.children.set(name, file); + return file as unknown as FileSystemFileHandle; + } + + async removeEntry(name: string, options?: FileSystemRemoveOptions): Promise { + const existing = this.children.get(name); + if (existing === undefined) throw new DOMException("Entry not found", "NotFoundError"); + if (existing instanceof MemoryDirectoryHandle && !options?.recursive && existing.children.size > 0) { + throw new DOMException("Directory is not empty", "InvalidModificationError"); + } + this.children.delete(name); + } + + async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> { + for (const [name, entry] of this.children) { + yield [name, entry as unknown as FileSystemHandle]; + } + } +} + +describe("FSAPIStorageAdapter", () => { + for (const contractCase of storageAdapterContractCases) { + it(contractCase.name, async () => { + const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle; + await contractCase.run(new FSAPIStorageAdapter(root)); + }); + } +}); diff --git a/src/lib b/src/lib index a0efb727..b62c00c1 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit a0efb7274e15c4ae0f0e0740670a1ad2699031e9 +Subproject commit b62c00c1d679853c0a5620a79455cf016ddfe981 From 57e26f10791e499e76ca7d521b46a098ce5d0451 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 09:40:17 +0000 Subject: [PATCH 019/170] Enforce rooted storage path contracts --- .../{ => _test}/storageAdapterContract.ts | 31 +++++++++++++++++++ src/apps/cli/adapters/NodeStorageAdapter.ts | 25 ++++++++------- .../adapters/NodeStorageAdapter.unit.spec.ts | 5 +-- src/apps/storagePath.ts | 26 ++++++++++++++++ .../webapp/adapters/FSAPIStorageAdapter.ts | 13 +++++--- .../adapters/FSAPIStorageAdapter.unit.spec.ts | 2 +- 6 files changed, 81 insertions(+), 21 deletions(-) rename src/apps/{ => _test}/storageAdapterContract.ts (68%) create mode 100644 src/apps/storagePath.ts diff --git a/src/apps/storageAdapterContract.ts b/src/apps/_test/storageAdapterContract.ts similarity index 68% rename from src/apps/storageAdapterContract.ts rename to src/apps/_test/storageAdapterContract.ts index 21880eaa..5693e198 100644 --- a/src/apps/storageAdapterContract.ts +++ b/src/apps/_test/storageAdapterContract.ts @@ -16,6 +16,15 @@ function assertEqual(actual: unknown, expected: unknown, message: string): void } } +async function assertRejects(operation: () => Promise, message: string): Promise { + try { + await operation(); + } catch { + return; + } + throw new Error(message); +} + /** Passing baseline shared by Node, FSAPI, and future storage adapters. */ export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [ { @@ -74,4 +83,26 @@ export const storageAdapterContractCases: readonly StorageAdapterContractCase[] assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed"); }, }, + { + name: "keeps operations inside the configured root", + async run(adapter) { + await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected"); + await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected"); + await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected"); + await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected"); + await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected"); + await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected"); + }, + }, + { + name: "uses the empty path only for root-safe operations", + async run(adapter) { + await adapter.mkdir(""); + assertEqual(await adapter.exists(""), true, "the configured root should exist"); + assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder"); + assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable"); + await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected"); + await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected"); + }, + }, ]; diff --git a/src/apps/cli/adapters/NodeStorageAdapter.ts b/src/apps/cli/adapters/NodeStorageAdapter.ts index 49a3f84d..2f48f6ca 100644 --- a/src/apps/cli/adapters/NodeStorageAdapter.ts +++ b/src/apps/cli/adapters/NodeStorageAdapter.ts @@ -2,20 +2,22 @@ import type { UXDataWriteOptions } from "@lib/common/types"; import type { IStorageAdapter } from "@lib/serviceModules/adapters"; import type { NodeStat } from "./NodeTypes"; import { fsPromises as fs, path } from "@/apps/cli/node-compat"; +import { validateStoragePath } from "@/apps/storagePath"; /** * Storage adapter implementation for Node.js */ export class NodeStorageAdapter implements IStorageAdapter { - constructor(private basePath: string) {} + constructor(private readonly basePath: string) {} - private resolvePath(p: string): string { - return path.join(this.basePath, p); + private resolvePath(p: string, allowRoot: boolean = true): string { + return path.join(this.basePath, validateStoragePath(p, allowRoot)); } async exists(p: string): Promise { + const fullPath = this.resolvePath(p); try { - await fs.access(this.resolvePath(p)); + await fs.access(fullPath); return true; } catch { return false; @@ -23,8 +25,9 @@ export class NodeStorageAdapter implements IStorageAdapter { } async trystat(p: string): Promise { + const fullPath = this.resolvePath(p); try { - const stat = await fs.stat(this.resolvePath(p)); + const stat = await fs.stat(fullPath); return { size: stat.size, mtime: Math.floor(stat.mtimeMs), @@ -45,7 +48,7 @@ export class NodeStorageAdapter implements IStorageAdapter { } async remove(p: string): Promise { - const fullPath = this.resolvePath(p); + const fullPath = this.resolvePath(p, false); const stat = await fs.stat(fullPath); if (stat.isDirectory()) { await fs.rm(fullPath, { recursive: true, force: true }); @@ -55,17 +58,17 @@ export class NodeStorageAdapter implements IStorageAdapter { } async read(p: string): Promise { - return await fs.readFile(this.resolvePath(p), "utf-8"); + return await fs.readFile(this.resolvePath(p, false), "utf-8"); } async readBinary(p: string): Promise { - const buffer = await fs.readFile(this.resolvePath(p)); + const buffer = await fs.readFile(this.resolvePath(p, false)); // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; } async write(p: string, data: string, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(p); + const fullPath = this.resolvePath(p, false); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, data, "utf-8"); @@ -77,7 +80,7 @@ export class NodeStorageAdapter implements IStorageAdapter { } async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(p); + const fullPath = this.resolvePath(p, false); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.writeFile(fullPath, new Uint8Array(data)); @@ -89,7 +92,7 @@ export class NodeStorageAdapter implements IStorageAdapter { } async append(p: string, data: string, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(p); + const fullPath = this.resolvePath(p, false); await fs.mkdir(path.dirname(fullPath), { recursive: true }); await fs.appendFile(fullPath, data, "utf-8"); diff --git a/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts index c3ad4621..c32486ee 100644 --- a/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeStorageAdapter.unit.spec.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { storageAdapterContractCases } from "@/apps/storageAdapterContract"; +import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract"; import { NodeStorageAdapter } from "./NodeStorageAdapter"; describe("NodeStorageAdapter", () => { @@ -44,7 +44,4 @@ describe("NodeStorageAdapter", () => { expect(result.byteLength).toBe(expected.byteLength); expect(Array.from(new Uint8Array(result))).toEqual([0x10, 0x20, 0x30]); }); - - it.todo("rejects paths that escape the configured root"); - it.todo("rejects removing the configured root through an empty path"); }); diff --git a/src/apps/storagePath.ts b/src/apps/storagePath.ts new file mode 100644 index 00000000..06bcda40 --- /dev/null +++ b/src/apps/storagePath.ts @@ -0,0 +1,26 @@ +/** + * Validate the platform-neutral path vocabulary used by rooted storage adapters. + * + * Paths are slash-separated and relative to the root bound to the adapter. Root + * selection and authorisation happen before the adapter is constructed. + */ +export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string { + if (storagePath === "") { + if (allowRoot) return storagePath; + throw new Error("The storage root is not a valid entry path"); + } + + if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) { + throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`); + } + if (storagePath.includes("\\")) { + throw new Error(`Storage paths must use forward slashes: ${storagePath}`); + } + + const segments = storagePath.split("/"); + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`); + } + + return storagePath; +} diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.ts index f48a20d8..259d656a 100644 --- a/src/apps/webapp/adapters/FSAPIStorageAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.ts @@ -1,12 +1,13 @@ import type { UXDataWriteOptions } from "@lib/common/types"; import type { IStorageAdapter } from "@lib/serviceModules/adapters"; import type { FSAPIStat } from "./FSAPITypes"; +import { validateStoragePath } from "@/apps/storagePath"; /** * Storage adapter implementation for FileSystem API */ export class FSAPIStorageAdapter implements IStorageAdapter { - constructor(private rootHandle: FileSystemDirectoryHandle) {} + constructor(private readonly rootHandle: FileSystemDirectoryHandle) {} /** * Resolve a path to directory and file handles @@ -15,11 +16,9 @@ export class FSAPIStorageAdapter implements IStorageAdapter { dirHandle: FileSystemDirectoryHandle; fileName: string; } | null> { + validateStoragePath(p, false); try { const parts = p.split("/").filter((part) => part !== ""); - if (parts.length === 0) { - return null; - } let currentHandle = this.rootHandle; const fileName = parts[parts.length - 1]; @@ -39,6 +38,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter { * Get file handle for a given path */ private async getFileHandle(p: string): Promise { + validateStoragePath(p); + if (p === "") return null; const resolved = await this.resolvePath(p); if (!resolved) return null; @@ -53,6 +54,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter { * Get directory handle for a given path */ private async getDirectoryHandle(p: string): Promise { + validateStoragePath(p); try { const parts = p.split("/").filter((part) => part !== ""); if (parts.length === 0) { @@ -75,8 +77,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter { dirHandle: FileSystemDirectoryHandle; fileName: string; } | null> { + validateStoragePath(p, false); const parts = p.split("/").filter((part) => part !== ""); - if (parts.length === 0) return null; const fileName = parts.pop()!; const parentPath = parts.join("/"); await this.mkdir(parentPath); @@ -124,6 +126,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter { } async mkdir(p: string): Promise { + validateStoragePath(p); const parts = p.split("/").filter((part) => part !== ""); let currentHandle = this.rootHandle; diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts index f741820e..dde5f912 100644 --- a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts @@ -1,5 +1,5 @@ import { describe, it } from "vitest"; -import { storageAdapterContractCases } from "@/apps/storageAdapterContract"; +import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract"; import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter"; class MemoryFileHandle { From d16e66c67aefccb8f8e6c2fb20498ecd5334948b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 10:22:20 +0000 Subject: [PATCH 020/170] Track unreleased storage contract changes --- devs.md | 6 ++++++ updates.md | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/devs.md b/devs.md index bb74a1c0..b31ee71b 100644 --- a/devs.md +++ b/devs.md @@ -47,6 +47,12 @@ npm test # Run Harness based vitest tests (requires Docker services) Use CLI E2E tests or real Obsidian E2E scripts instead of `npm test` when the behaviour can be verified outside the browser harness. +### Unreleased change notes + +Keep changes that may belong in a future release under `## Unreleased` at the top of `updates.md` when they do not justify an immediate release. Do not add a date to this virtual version. Move relevant entries under the real version and ordinal release date when preparing that release, then leave an empty `## Unreleased` section for subsequent work. + +Use this section for durable release-note candidates, including compatibility-relevant internal maintenance, rather than tasks, local diagnostics, or implementation journals. Categorise user-visible behaviour separately from internal changes and testing. + ### Auto-copy to test vaults To facilitate development and testing, the build process can automatically copy the built plugin to specified test vault diff --git a/updates.md b/updates.md index 94167ae6..4fbb79b5 100644 --- a/updates.md +++ b/updates.md @@ -3,6 +3,21 @@ 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 + +### Improved (CLI and Webapp) + +- Rooted storage adapters now reject absolute, drive-qualified, backslash-separated, and traversal paths. They also prevent file writes, appends, and removal from targeting the configured root itself. +- File System Access API storage can now create files below previously missing parent directories, matching the existing Node behaviour. + +### Testing + +- Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling. + +### Miscellaneous + +- Split the internal storage adapter contract into focused capability views without changing existing runtime behaviour. + ## 0.25.80 7th July, 2026 From 4df87cc96a9495fcfe277b8cfa99a798938f255b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 10:40:16 +0000 Subject: [PATCH 021/170] Explain portable storage path policy --- src/apps/storagePath.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apps/storagePath.ts b/src/apps/storagePath.ts index 06bcda40..1e696688 100644 --- a/src/apps/storagePath.ts +++ b/src/apps/storagePath.ts @@ -10,6 +10,8 @@ export function validateStoragePath(storagePath: string, allowRoot: boolean = tr throw new Error("The storage root is not a valid entry path"); } + // LiveSync normally rejects ':' in portable filenames before paths reach storage. A leading letter and colon + // therefore represents drive-qualified input or a leaked non-storage namespace, not a supported physical path. if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) { throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`); } From a6a5f7af5365bb7babee82c9d03ed02abccf44e6 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 11:03:25 +0000 Subject: [PATCH 022/170] Use merged storage capability contracts --- src/lib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib b/src/lib index b62c00c1..1cb156d4 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit b62c00c1d679853c0a5620a79455cf016ddfe981 +Subproject commit 1cb156d463ba91669f6490b23c98fbc3fff603fd From 42a333a57124a03232e9b9585c689119fe79f017 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 11:08:59 +0000 Subject: [PATCH 023/170] Record P2P validation infrastructure --- updates.md | 1 + 1 file changed, 1 insertion(+) diff --git a/updates.md b/updates.md index 4fbb79b5..1b6892bd 100644 --- a/updates.md +++ b/updates.md @@ -13,6 +13,7 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid ### Testing - Added shared Node and File System Access API storage contract coverage for metadata, text and binary operations, append, listing, removal, path containment, and empty-root handling. +- Added a Compose-based P2P end-to-end smoke test and repeatable network benchmark cases for local performance investigations. ### Miscellaneous From 9c375bd6fde0fd24611485d6c5f44fd328e535d0 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 12 Jul 2026 12:05:05 +0000 Subject: [PATCH 024/170] Deploy QR aggregator with GitHub Pages --- .github/workflows/deploy-pages.yml | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/deploy-pages.yml diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 00000000..e96c4c62 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,67 @@ +name: Deploy GitHub Pages + +on: + workflow_dispatch: + push: + branches: + - main + paths: + - 'aggregator.html' + - '.github/workflows/deploy-pages.yml' + pull_request: + paths: + - 'aggregator.html' + - '.github/workflows/deploy-pages.yml' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Validate and package Pages site + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate aggregator + run: | + test -s aggregator.html + grep -Fq '' aggregator.html + grep -Fq 'obsidian://setuplivesync?settingsQR=' aggregator.html + sed -n '/ + +
  • + {renderIcon(item)} + +
  • + + diff --git a/src/apps/browser/ui/MenuSeparatorView.svelte b/src/apps/browser/ui/MenuSeparatorView.svelte new file mode 100644 index 00000000..071a14d4 --- /dev/null +++ b/src/apps/browser/ui/MenuSeparatorView.svelte @@ -0,0 +1,10 @@ + + +
    diff --git a/src/apps/browser/ui/MenuView.svelte b/src/apps/browser/ui/MenuView.svelte new file mode 100644 index 00000000..53b70b19 --- /dev/null +++ b/src/apps/browser/ui/MenuView.svelte @@ -0,0 +1,89 @@ + + + + + +
    closeMenu()} onkeydown={handleKey} role="none">
    + + diff --git a/src/apps/browser/ui/MessageBox.svelte b/src/apps/browser/ui/MessageBox.svelte new file mode 100644 index 00000000..bcd308ad --- /dev/null +++ b/src/apps/browser/ui/MessageBox.svelte @@ -0,0 +1,127 @@ + + + +
    {title}
    +
    {@html renderedMessage}
    +
    + {#each buttons as button} + + {/each} +
    +
    +
    commit("")} onkeydown={handleEsc} role="none">
    + + diff --git a/src/apps/browser/ui/TextInputBox.svelte b/src/apps/browser/ui/TextInputBox.svelte new file mode 100644 index 00000000..96a53415 --- /dev/null +++ b/src/apps/browser/ui/TextInputBox.svelte @@ -0,0 +1,122 @@ + + + +
    {title}
    +
    +
    {message}
    +
    + +
    +
    + +
    + + +
    +
    +
    + + diff --git a/src/apps/browser/ui/renderMessageMarkdown.ts b/src/apps/browser/ui/renderMessageMarkdown.ts new file mode 100644 index 00000000..8e937561 --- /dev/null +++ b/src/apps/browser/ui/renderMessageMarkdown.ts @@ -0,0 +1,21 @@ +import MarkdownIt from "markdown-it"; + +const markdownRenderer = new MarkdownIt({ + html: false, + breaks: true, + linkify: true, +}); + +const defaultLinkOpenRenderer = + markdownRenderer.renderer.rules.link_open ?? + ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); + +markdownRenderer.renderer.rules.link_open = (tokens, idx, options, env, self) => { + tokens[idx].attrSet("target", "_blank"); + tokens[idx].attrSet("rel", "noopener noreferrer"); + return defaultLinkOpenRenderer(tokens, idx, options, env, self); +}; + +export function renderMessageMarkdown(message: string): string { + return markdownRenderer.render(message); +} diff --git a/src/apps/browser/ui/renderMessageMarkdown.unit.spec.ts b/src/apps/browser/ui/renderMessageMarkdown.unit.spec.ts new file mode 100644 index 00000000..cb5a53a6 --- /dev/null +++ b/src/apps/browser/ui/renderMessageMarkdown.unit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { renderMessageMarkdown } from "./renderMessageMarkdown"; + +describe("renderMessageMarkdown", () => { + it("renders basic markdown features used by browser dialogues", () => { + const html = renderMessageMarkdown("# Title\n\n| left | right |\n| --- | --- |\n| a | b |\n"); + + expect(html).toContain("

    Title

    "); + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("escapes inline HTML instead of rendering it", () => { + const html = renderMessageMarkdown("BeforeAfter"); + + expect(html).not.toContain(" + +
    + +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Check.svelte b/src/modules/services/LiveSyncUI/components/Check.svelte new file mode 100644 index 00000000..aa5e8862 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Check.svelte @@ -0,0 +1,53 @@ + + + +
    + + {#if value && noteOnSelected} + {@render noteOnSelected()} + {:else if !value && noteOnUnselected} + {@render noteOnUnselected()} + {/if} + {@render children?.()} +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Decision.svelte b/src/modules/services/LiveSyncUI/components/Decision.svelte new file mode 100644 index 00000000..ee4cdaa0 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Decision.svelte @@ -0,0 +1,24 @@ + + + diff --git a/src/modules/services/LiveSyncUI/components/DialogHeader.svelte b/src/modules/services/LiveSyncUI/components/DialogHeader.svelte new file mode 100644 index 00000000..c717d30a --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/DialogHeader.svelte @@ -0,0 +1,41 @@ + + +
    +

    {translatedTitle}

    + {#if translatedSubtitle} +

    {translatedSubtitle}

    + {/if} +
    + + diff --git a/src/modules/services/LiveSyncUI/components/ExtraItems.svelte b/src/modules/services/LiveSyncUI/components/ExtraItems.svelte new file mode 100644 index 00000000..80a8eda5 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/ExtraItems.svelte @@ -0,0 +1,17 @@ + + +
    + {translatedTitle} +
    + {@render children?.()} +
    +
    diff --git a/src/modules/services/LiveSyncUI/components/Guidance.svelte b/src/modules/services/LiveSyncUI/components/Guidance.svelte new file mode 100644 index 00000000..a2ab5e76 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Guidance.svelte @@ -0,0 +1,21 @@ + + +
    + {#if translatedTitle} +

    {translatedTitle}

    + {/if} + {@render children?.()} +
    diff --git a/src/modules/services/LiveSyncUI/components/InfoNote.svelte b/src/modules/services/LiveSyncUI/components/InfoNote.svelte new file mode 100644 index 00000000..3d1fac4c --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/InfoNote.svelte @@ -0,0 +1,87 @@ + + +{#if visible === undefined || visible === true} +
    + {#if signalWordText} +
    {signalWordText}
    + {/if} + {#if translatedTitle}

    {translatedTitle}

    {/if} + {#if translatedMessage}

    {translatedMessage}

    {/if} + {@render children?.()} +
    +{/if} diff --git a/src/modules/services/LiveSyncUI/components/InfoTable.svelte b/src/modules/services/LiveSyncUI/components/InfoTable.svelte new file mode 100644 index 00000000..e779e624 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/InfoTable.svelte @@ -0,0 +1,74 @@ + + +
    +
    + {#each infoEntries as [key, value]} +
    +
    {key}
    +
    +
    +
    {value}
    +
    + {/each} +
    +
    + + diff --git a/src/modules/services/LiveSyncUI/components/InputRow.svelte b/src/modules/services/LiveSyncUI/components/InputRow.svelte new file mode 100644 index 00000000..ae42ad90 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/InputRow.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/modules/services/LiveSyncUI/components/Instruction.svelte b/src/modules/services/LiveSyncUI/components/Instruction.svelte new file mode 100644 index 00000000..fde612f5 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Instruction.svelte @@ -0,0 +1,10 @@ + + +
    + {@render children?.()} +
    diff --git a/src/modules/services/LiveSyncUI/components/Option.svelte b/src/modules/services/LiveSyncUI/components/Option.svelte new file mode 100644 index 00000000..0edf9768 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Option.svelte @@ -0,0 +1,81 @@ + + +
    + +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Options.svelte b/src/modules/services/LiveSyncUI/components/Options.svelte new file mode 100644 index 00000000..678a37fd --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Options.svelte @@ -0,0 +1,14 @@ + + +
    + {@render children?.()} +
    diff --git a/src/modules/services/LiveSyncUI/components/Password.svelte b/src/modules/services/LiveSyncUI/components/Password.svelte new file mode 100644 index 00000000..8b635051 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Password.svelte @@ -0,0 +1,34 @@ + + + + diff --git a/src/modules/services/LiveSyncUI/components/Question.svelte b/src/modules/services/LiveSyncUI/components/Question.svelte new file mode 100644 index 00000000..3956bf11 --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/Question.svelte @@ -0,0 +1,26 @@ + + +
    + {#if question}

    {@render question?.()}

    {/if} +
    + {@render children?.()} +
    +
    + + diff --git a/src/modules/services/LiveSyncUI/components/UserDecisions.svelte b/src/modules/services/LiveSyncUI/components/UserDecisions.svelte new file mode 100644 index 00000000..7aadf81c --- /dev/null +++ b/src/modules/services/LiveSyncUI/components/UserDecisions.svelte @@ -0,0 +1,12 @@ + + +
    + {#if children} + {@render children()} + {/if} +
    diff --git a/src/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte b/src/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte new file mode 100644 index 00000000..cdd112cb --- /dev/null +++ b/src/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte @@ -0,0 +1,59 @@ + + + + + + + + + + + Your {title || "data"} has been copied to the clipboard. + + + + + + diff --git a/src/modules/services/LiveSyncUI/svelteDialog.ts b/src/modules/services/LiveSyncUI/svelteDialog.ts new file mode 100644 index 00000000..648e4057 --- /dev/null +++ b/src/modules/services/LiveSyncUI/svelteDialog.ts @@ -0,0 +1,14 @@ +export type { + HasSetResult, + HasGetInitialData, + ComponentHasResult, + GuestDialogProps, + DialogSvelteComponentBaseProps, + DialogControlBase, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +export { + CONTEXT_DIALOG_CONTROLS, + setupDialogContext, + getDialogContext, + SvelteDialogManagerBase, +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; diff --git a/src/modules/services/ObsidianAPIService.ts b/src/modules/services/ObsidianAPIService.ts index daa0a895..c455d172 100644 --- a/src/modules/services/ObsidianAPIService.ts +++ b/src/modules/services/ObsidianAPIService.ts @@ -1,11 +1,11 @@ -import { InjectableAPIService } from "@lib/services/implements/injectable/InjectableAPIService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { InjectableAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAPIService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { Platform, type Command, type ViewCreator } from "@/deps.ts"; import { ObsHttpHandler } from "@/modules/essentialObsidian/APILib/ObsHttpHandler"; import { ObsidianConfirm } from "./ObsidianConfirm"; -import type { Confirm } from "@lib/interfaces/Confirm"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; import { requestUrl, type RequestUrlParam } from "@/deps"; -import { compatGlobal } from "@lib/common/coreEnvFunctions"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; // All Services will be migrated to be based on Plain Services, not Injectable Services. // This is a migration step. diff --git a/src/modules/services/ObsidianAppLifecycleService.ts b/src/modules/services/ObsidianAppLifecycleService.ts index 6c730e62..e2888a7a 100644 --- a/src/modules/services/ObsidianAppLifecycleService.ts +++ b/src/modules/services/ObsidianAppLifecycleService.ts @@ -1,5 +1,5 @@ -import { AppLifecycleServiceBase } from "@lib/services/implements/injectable/InjectableAppLifecycleService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { AppLifecycleServiceBase } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableAppLifecycleService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; declare module "obsidian" { interface App { commands: { diff --git a/src/modules/services/ObsidianConfirm.ts b/src/modules/services/ObsidianConfirm.ts index ca4f9c1a..5941c6d8 100644 --- a/src/modules/services/ObsidianConfirm.ts +++ b/src/modules/services/ObsidianConfirm.ts @@ -1,8 +1,8 @@ import { type App, type Plugin, Notice } from "@/deps"; import { scheduleTask, memoIfNotExist, memoObject, retrieveMemoObject, disposeMemoObject } from "@/common/utils"; -import { $msg } from "@lib/common/i18n"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { askYesNo, askString, diff --git a/src/modules/services/ObsidianDatabaseService.ts b/src/modules/services/ObsidianDatabaseService.ts index 39759f9d..2768a601 100644 --- a/src/modules/services/ObsidianDatabaseService.ts +++ b/src/modules/services/ObsidianDatabaseService.ts @@ -1,8 +1,8 @@ import { initializeStores } from "@/common/stores"; // import { InjectableDatabaseService } from "@/lib/src/services/implements/injectable/InjectableDatabaseService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import { DatabaseService, type DatabaseServiceDependencies } from "@lib/services/base/DatabaseService.ts"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import { DatabaseService, type DatabaseServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/DatabaseService"; export class ObsidianDatabaseService extends DatabaseService { private __onOpenDatabase(vaultName: string) { diff --git a/src/modules/services/ObsidianPathService.ts b/src/modules/services/ObsidianPathService.ts index ed479e03..12dee2b0 100644 --- a/src/modules/services/ObsidianPathService.ts +++ b/src/modules/services/ObsidianPathService.ts @@ -1,6 +1,6 @@ -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { normalizePath } from "@/deps"; -import { PathService } from "@lib/services/base/PathService"; +import { PathService } from "@vrtmrz/livesync-commonlib/compat/services/base/PathService"; import { type BASE_IS_NEW, @@ -11,7 +11,7 @@ import { compareFileFreshness, isMarkedAsSameChanges, } from "@/common/utils"; -import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@lib/common/types"; +import type { UXFileInfo, AnyEntry, UXFileInfoStub, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; export class ObsidianPathService extends PathService { override markChangesAreSame( old: UXFileInfo | AnyEntry | FilePathWithPrefix, diff --git a/src/modules/services/ObsidianServiceContext.ts b/src/modules/services/ObsidianServiceContext.ts new file mode 100644 index 00000000..fe2d1dd1 --- /dev/null +++ b/src/modules/services/ObsidianServiceContext.ts @@ -0,0 +1,19 @@ +import type ObsidianLiveSyncPlugin from "@/main"; +import type { App, Plugin } from "@/deps"; +import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { eventHub } from "@/common/events"; +import { translateLiveSyncMessage } from "@/common/translation"; + +/** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */ +export class ObsidianServiceContext extends ServiceContext { + app: App; + plugin: Plugin; + liveSyncPlugin: ObsidianLiveSyncPlugin; + + constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin) { + super({ events: eventHub, translate: translateLiveSyncMessage }); + this.app = app; + this.plugin = plugin; + this.liveSyncPlugin = liveSyncPlugin; + } +} diff --git a/src/modules/services/ObsidianServiceContext.unit.spec.ts b/src/modules/services/ObsidianServiceContext.unit.spec.ts new file mode 100644 index 00000000..ffefbec9 --- /dev/null +++ b/src/modules/services/ObsidianServiceContext.unit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { eventHub } from "@/common/events"; +import { translateLiveSyncMessage } from "@/common/translation"; +import { observeServiceContext } from "../../../test/contracts/serviceContext"; +import { ObsidianServiceContext } from "./ObsidianServiceContext"; + +const TRANSLATION_KEY = "Replicator.Message.InitialiseFatalError"; + +describe("ObsidianServiceContext contract", () => { + it("preserves the plug-in capabilities and host-neutral API results", () => { + type Parameters = ConstructorParameters; + const app = {} as Parameters[0]; + const plugin = {} as Parameters[1]; + const liveSyncPlugin = {} as Parameters[2]; + const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin); + + expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({ + translation: translateLiveSyncMessage(TRANSLATION_KEY), + receivedEvents: ["context-contract-event"], + }); + expect(context.events).toBe(eventHub); + expect(context.app).toBe(app); + expect(context.plugin).toBe(plugin); + expect(context.liveSyncPlugin).toBe(liveSyncPlugin); + }); +}); diff --git a/src/modules/services/ObsidianServiceHub.ts b/src/modules/services/ObsidianServiceHub.ts index 917637a2..bca05290 100644 --- a/src/modules/services/ObsidianServiceHub.ts +++ b/src/modules/services/ObsidianServiceHub.ts @@ -1,6 +1,6 @@ -import { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; -import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type { ServiceInstances } from "@lib/services/ServiceHub"; +import { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; +import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import type { ServiceInstances } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub"; import type ObsidianLiveSyncPlugin from "@/main"; import { ObsidianConflictService, @@ -23,6 +23,8 @@ import { ObsidianPathService } from "./ObsidianPathService"; import { ObsidianVaultService } from "./ObsidianVaultService"; import { ObsidianUIService } from "./ObsidianUIService"; import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock"; +import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; +import { OpenKeyValueDatabase } from "@/common/KeyValueDB"; // InjectableServiceHub @@ -43,6 +45,7 @@ export class ObsidianServiceHub extends InjectableServiceHub {} diff --git a/src/modules/services/ObsidianSettingService.ts b/src/modules/services/ObsidianSettingService.ts index f3a32eef..4bfc8cbb 100644 --- a/src/modules/services/ObsidianSettingService.ts +++ b/src/modules/services/ObsidianSettingService.ts @@ -1,19 +1,18 @@ -import { compatGlobal } from "@lib/common/coreEnvFunctions"; -import { type ObsidianLiveSyncSettings } from "@lib/common/types"; -import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@lib/events/coreEvents"; -import { eventHub } from "@lib/hub/hub"; -import { SettingService, type SettingServiceDependencies } from "@lib/services/base/SettingService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { EVENT_REQUEST_RELOAD_SETTING_TAB, EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { SettingService, type SettingServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; export class ObsidianSettingService extends SettingService { constructor(context: T, dependencies: SettingServiceDependencies) { super(context, dependencies); this.onSettingSaved.addHandler((settings) => { - eventHub.emitEvent(EVENT_SETTING_SAVED, settings); + this.context.events.emitEvent(EVENT_SETTING_SAVED, settings); return Promise.resolve(true); }); this.onSettingLoaded.addHandler((settings) => { - eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB); + this.context.events.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB); return Promise.resolve(true); }); } diff --git a/src/modules/services/ObsidianUIService.ts b/src/modules/services/ObsidianUIService.ts index 45975af2..c5abf23f 100644 --- a/src/modules/services/ObsidianUIService.ts +++ b/src/modules/services/ObsidianUIService.ts @@ -1,11 +1,11 @@ -import type { ConfigService } from "@lib/services/base/ConfigService"; -import type { AppLifecycleService } from "@lib/services/base/AppLifecycleService"; -import type { ReplicatorService } from "@lib/services/base/ReplicatorService"; -import { UIService } from "@lib/services/implements/base/UIService"; -import { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; +import type { ConfigService } from "@vrtmrz/livesync-commonlib/compat/services/base/ConfigService"; +import type { AppLifecycleService } from "@vrtmrz/livesync-commonlib/compat/services/base/AppLifecycleService"; +import type { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; +import { UIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/base/UIService"; +import { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; import { ObsidianSvelteDialogManager } from "./SvelteDialogObsidian"; -import DialogToCopy from "@lib/UI/dialogues/DialogueToCopy.svelte"; -import type { IAPIService, IControlService } from "@lib/services/base/IService"; +import DialogToCopy from "@/modules/services/LiveSyncUI/dialogues/DialogueToCopy.svelte"; +import type { IAPIService, IControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; export type ObsidianUIServiceDependencies = { appLifecycle: AppLifecycleService; config: ConfigService; @@ -28,7 +28,6 @@ export class ObsidianUIService extends UIService { control: dependents.control, }); super(context, { - appLifecycle: dependents.appLifecycle, dialogManager: obsidianSvelteDialogManager, APIService: dependents.APIService, }); diff --git a/src/modules/services/ObsidianVaultService.ts b/src/modules/services/ObsidianVaultService.ts index d86c7b19..e3bd3a91 100644 --- a/src/modules/services/ObsidianVaultService.ts +++ b/src/modules/services/ObsidianVaultService.ts @@ -1,7 +1,7 @@ import { getPathFromTFile, isValidPath } from "@/common/utils"; -import { InjectableVaultService } from "@lib/services/implements/injectable/InjectableVaultService"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import type { FilePath } from "@lib/common/types"; +import { InjectableVaultService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableVaultService"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; declare module "obsidian" { interface DataAdapter { diff --git a/src/modules/services/SvelteDialogObsidian.ts b/src/modules/services/SvelteDialogObsidian.ts index 95c9ba23..65cd3988 100644 --- a/src/modules/services/SvelteDialogObsidian.ts +++ b/src/modules/services/SvelteDialogObsidian.ts @@ -5,9 +5,9 @@ import { SvelteDialogMixIn, type ComponentHasResult, type SvelteDialogManagerDependencies, -} from "@lib/services/implements/base/SvelteDialog"; -import type { ObsidianServiceContext } from "@lib/services/implements/obsidian/ObsidianServiceContext"; -import DialogHost from "@lib/UI/DialogHost.svelte"; +} from "@vrtmrz/livesync-commonlib/compat/services/implements/base/SvelteDialog"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import DialogHost from "@/modules/services/LiveSyncUI/DialogHost.svelte"; export const SvelteDialogBase = SvelteDialogMixIn(Modal, DialogHost); export class SvelteDialogObsidian< T, diff --git a/src/rabinKarpBom.unit.spec.ts b/src/rabinKarpBom.unit.spec.ts index 56596aa1..67e4781f 100644 --- a/src/rabinKarpBom.unit.spec.ts +++ b/src/rabinKarpBom.unit.spec.ts @@ -1,4 +1,4 @@ -import { splitPiecesRabinKarp } from "@lib/string_and_binary/chunks.ts"; +import { splitPiecesRabinKarp } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/chunks"; import { describe, expect, it } from "vitest"; describe("Rabin-Karp text splitting", () => { diff --git a/src/serviceFeatures/onLayoutReady/enablei18n.ts b/src/serviceFeatures/onLayoutReady/enablei18n.ts index fe8e98a8..dacb2d06 100644 --- a/src/serviceFeatures/onLayoutReady/enablei18n.ts +++ b/src/serviceFeatures/onLayoutReady/enablei18n.ts @@ -1,7 +1,7 @@ import { getLanguage } from "@/deps"; -import { createServiceFeature } from "@lib/interfaces/ServiceModule"; -import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta"; -import { $msg, __onMissingTranslation, setLang } from "@lib/common/i18n"; +import { createServiceFeature } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@vrtmrz/livesync-commonlib/compat/common/rosetta"; +import { $msg, __onMissingTranslation, setLang } from "@vrtmrz/livesync-commonlib/compat/common/i18n"; function tryGetLanguage() { try { diff --git a/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 981556d6..95b0fa48 100644 --- a/src/serviceFeatures/redFlag.simpleFetch.ts +++ b/src/serviceFeatures/redFlag.simpleFetch.ts @@ -1,7 +1,7 @@ import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type LogFunction } from "@lib/services/lib/logUtils"; -import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { type LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager"; import { ExtraOnLocal, ExtraOnRemote, @@ -9,7 +9,7 @@ import { normaliseFullScanOptions, synchroniseAllFilesBetweenDBandStorage, type FullScanOptions, -} from "@lib/serviceFeatures/offlineScanner"; +} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { adjustSettingToRemoteIfNeeded, processVaultInitialisation } from "./redFlag"; export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files"; @@ -215,7 +215,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( await host.serviceModules.rebuilder.$fetchLocalDBFast(false); // 2. Call the extended synchroniseAllFilesBetweenDBandStorage to reflect changes in storage - const errorManager = new UnresolvedErrorManager(host.services.appLifecycle); + const errorManager = new UnresolvedErrorManager(host.services.appLifecycle, host.services.context.events); const syncResult = await synchroniseAllFilesBetweenDBandStorage( host, log, diff --git a/src/serviceFeatures/redFlag.ts b/src/serviceFeatures/redFlag.ts index 4fe9d8d8..5b1576fd 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -1,20 +1,20 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { createInstanceLogFunction, type LogFunction } from "@lib/services/lib/logUtils"; -import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { createInstanceLogFunction, type LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; import FetchEverything from "@/modules/features/SetupWizard/dialogs/FetchEverything.svelte"; import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEverything.svelte"; import { extractObject } from "octagonal-wheels/object"; -import { REMOTE_MINIO, REMOTE_P2P } from "@lib/common/models/setting.const"; -import type { ObsidianLiveSyncSettings } from "@lib/common/models/setting.type"; -import { TweakValuesShouldMatchedTemplate } from "@lib/common/models/tweak.definition"; +import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; +import { TweakValuesShouldMatchedTemplate } from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition"; import type { FetchEverythingResult, RebuildEverythingResult, } from "@/modules/features/SetupWizard/dialogs/setupDialogTypes"; import { askAndPerformFastSetupOnScheduledFetchAll } from "./redFlag.simpleFetch"; -import { ConnectionStringParser } from "@lib/common/ConnectionString"; -import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; /** * Flag file handler interface, similar to target filter pattern. diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index f94103e0..408e3e75 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import type { LogFunction } from "@lib/services/lib/logUtils"; -import { FlagFilesHumanReadable, FlagFilesOriginal } from "@lib/common/models/redflag.const"; -import { REMOTE_MINIO } from "@lib/common/models/setting.const"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { FlagFilesHumanReadable, FlagFilesOriginal } from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const"; +import { REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; import { createFetchAllFlagHandler, createRebuildFlagHandler, @@ -18,12 +19,12 @@ import { TweakValuesRecommendedTemplate, TweakValuesShouldMatchedTemplate, TweakValuesTemplate, -} from "@lib/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ExtraOnLocal, FullScanModes, synchroniseAllFilesBetweenDBandStorage, -} from "@lib/serviceFeatures/offlineScanner"; +} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { SIMPLE_FETCH_STAGE1_LEGACY, SIMPLE_FETCH_STAGE1_NEWER_WINS, @@ -36,9 +37,9 @@ import { askAndPerformFastSetupOnScheduledFetchAll, askSimpleFetchMode, } from "./redFlag.simpleFetch"; -import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig"; //Mock synchroniseAllFilesBetweenDBandStorage -vi.mock("@/lib/src/serviceFeatures/offlineScanner", async (importOriginal) => { +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", async (importOriginal) => { const originalModule = (await importOriginal()) as any; return { ...originalModule, @@ -46,7 +47,7 @@ vi.mock("@/lib/src/serviceFeatures/offlineScanner", async (importOriginal) => { }; }); -vi.mock("@lib/serviceFeatures/remoteConfig", () => { +vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => { return { activateRemoteConfiguration: vi.fn((settings: any, configurationId: string) => { if (!settings?.remoteConfigurations?.[configurationId]) return false; @@ -159,6 +160,7 @@ const createHostMock = () => { return { services: { + context: createServiceContext(), setting: settingMock, appLifecycle: appLifecycleMock, UI: uiMock, diff --git a/src/serviceFeatures/setupObsidian/qrCode.ts b/src/serviceFeatures/setupObsidian/qrCode.ts new file mode 100644 index 00000000..6498c1fb --- /dev/null +++ b/src/serviceFeatures/setupObsidian/qrCode.ts @@ -0,0 +1,75 @@ +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { + encodeQR, + encodeSettingsToQRCodeData, + OutputFormat, +} from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { EVENT_REQUEST_SHOW_SETUP_QR } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { SetupFeatureHost } from "./types"; + +export async function encodeSetupSettingsAsQR(host: SetupFeatureHost) { + const settingString = encodeSettingsToQRCodeData(host.services.setting.currentSettings()); + const result = encodeQR(settingString, OutputFormat.SVG); + if (result === "") { + return ""; + } + + if (typeof result === "string") { + const msg = host.services.context.translate("Setup.QRCode", { qr_image: result }); + await host.services.UI.confirm.confirmWithMessage("Settings QR Code", msg, ["OK"], "OK"); + return result; + } else { + // Multi-page QR code + let currentIndex = 0; + while (currentIndex < result.total) { + const msg = `The setting is too large for a single QR code. +We are using the aggregator to combine multiple QR codes. +Your settings will not be sent to any server; they will be processed only on your device. +Please scan this QR code with your mobile's camera, and open the page in your browser. +After all parts are collected, the page will navigate you back to Obsidian with the aggregated settings. + +Progress: ${currentIndex + 1} / ${result.total} +${result.parts[currentIndex]}`; + + const buttons = []; + if (currentIndex > 0) buttons.push("Back"); + if (currentIndex < result.total - 1) { + buttons.push("Next"); + buttons.push("Cancel"); + } else { + buttons.push("Done"); + } + + const choice = await host.services.UI.confirm.confirmWithMessage( + "Settings QR Code (Aggregated)", + msg, + buttons, + buttons[buttons.indexOf("Next") !== -1 ? buttons.indexOf("Next") : buttons.indexOf("Done")] + ); + + if (choice === "Next") { + currentIndex++; + } else if (choice === "Back") { + currentIndex--; + } else { + break; + } + } + return result.parts[0]; // Return the first one for compatibility + } +} + +export function useSetupQRCodeFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) { + host.services.appLifecycle.onLoaded.addHandler(() => { + host.services.API.addCommand({ + id: "livesync-setting-qr", + name: "Show settings as a QR code", + callback: () => fireAndForget(encodeSetupSettingsAsQR(host)), + }); + host.services.context.events.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => + fireAndForget(() => encodeSetupSettingsAsQR(host)) + ); + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts new file mode 100644 index 00000000..766d8ec4 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { EVENT_REQUEST_SHOW_SETUP_QR } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase"; +import { encodeSetupSettingsAsQR, useSetupQRCodeFeature } from "./qrCode"; +import { encodeQR, encodeSettingsToQRCodeData } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; + +vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => { + return { + encodeQR: vi.fn(), + encodeSettingsToQRCodeData: vi.fn(), + OutputFormat: { + SVG: "svg", + }, + }; +}); + +describe("setupObsidian/qrCode", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("encodeSetupSettingsAsQR should return empty string when QR generation fails", async () => { + const confirmWithMessage = vi.fn(); + const host = { + services: { + context: createServiceContext(), + setting: { + currentSettings: vi.fn(() => ({ any: "settings" })), + }, + UI: { + confirm: { + confirmWithMessage, + }, + }, + }, + } as any; + + vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings"); + vi.mocked(encodeQR).mockReturnValue(""); + + const result = await encodeSetupSettingsAsQR(host); + + expect(result).toBe(""); + expect(confirmWithMessage).not.toHaveBeenCalled(); + }); + + it("encodeSetupSettingsAsQR should show confirm dialog when QR is generated", async () => { + const confirmWithMessage = vi.fn(() => true); + const translate = vi.fn(() => "qr-message"); + const host = { + services: { + context: createServiceContext({ translate }), + setting: { + currentSettings: vi.fn(() => ({ any: "settings" })), + }, + UI: { + confirm: { + confirmWithMessage, + }, + }, + }, + } as any; + + vi.mocked(encodeSettingsToQRCodeData).mockReturnValue("encoded-settings"); + vi.mocked(encodeQR).mockReturnValue(""); + + const result = await encodeSetupSettingsAsQR(host); + + expect(result).toBe(""); + expect(translate).toHaveBeenCalledWith("Setup.QRCode", { qr_image: "" }); + expect(confirmWithMessage).toHaveBeenCalledWith("Settings QR Code", "qr-message", ["OK"], "OK"); + }); + + it("useSetupQRCodeFeature should register onLoaded handler that wires command and event", async () => { + const addHandler = vi.fn(); + const addCommand = vi.fn(); + const context = createServiceContext(); + const onEventSpy = vi.spyOn(context.events, "onEvent"); + + const host = { + services: { + context, + API: { + addCommand, + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => ({ any: "settings" })), + }, + UI: { + confirm: { + confirmWithMessage: vi.fn(), + }, + }, + }, + } as any; + + useSetupQRCodeFeature(host); + expect(addHandler).toHaveBeenCalledTimes(1); + + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + expect(addCommand).toHaveBeenCalledWith( + expect.objectContaining({ + id: "livesync-setting-qr", + name: "Show settings as a QR code", + }) + ); + expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_SHOW_SETUP_QR, expect.any(Function)); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts index 4eb9e425..0eae88c1 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts @@ -1,9 +1,8 @@ import { type SetupManager, UserMode } from "@/modules/features/SetupManager"; -import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; -import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@lib/events/coreEvents"; -import { eventHub } from "@lib/hub/hub"; -import { fireAndForget } from "@lib/common/utils"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types"; +import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; export async function openSetupURI(setupManager: SetupManager) { await setupManager.onUseSetupURI(UserMode.Unknown); @@ -24,8 +23,10 @@ export function useSetupManagerHandlersFeature( callback: () => fireAndForget(openSetupURI(setupManager)), }); - eventHub.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => fireAndForget(() => openSetupURI(setupManager))); - eventHub.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () => + host.services.context.events.onEvent(EVENT_REQUEST_OPEN_SETUP_URI, () => + fireAndForget(() => openSetupURI(setupManager)) + ); + host.services.context.events.onEvent(EVENT_REQUEST_OPEN_P2P_SETTINGS, () => fireAndForget(() => openP2PSettings(host, setupManager)) ); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts index 067d860c..b45ad3ea 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi, afterEach } from "vitest"; -import { eventHub } from "@lib/hub/hub"; -import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@lib/events/coreEvents"; +import { EVENT_REQUEST_OPEN_P2P_SETTINGS, EVENT_REQUEST_OPEN_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers"; vi.mock("@/modules/features/SetupManager", () => { @@ -47,10 +46,11 @@ describe("setupObsidian/setupManagerHandlers", () => { it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => { const addHandler = vi.fn(); const addCommand = vi.fn(); - const onEventSpy = vi.spyOn(eventHub, "onEvent"); + const events = { onEvent: vi.fn() }; const host = { services: { + context: { events }, API: { addCommand, }, @@ -81,7 +81,7 @@ describe("setupObsidian/setupManagerHandlers", () => { name: "Use the copied setup URI (Formerly Open setup URI)", }) ); - expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_SETUP_URI, expect.any(Function)); - expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_P2P_SETTINGS, expect.any(Function)); + expect(events.onEvent).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_SETUP_URI, expect.any(Function)); + expect(events.onEvent).toHaveBeenCalledWith(EVENT_REQUEST_OPEN_P2P_SETTINGS, expect.any(Function)); }); }); diff --git a/src/serviceFeatures/setupObsidian/setupProtocol.ts b/src/serviceFeatures/setupObsidian/setupProtocol.ts index 5310fbf8..3c3566ff 100644 --- a/src/serviceFeatures/setupObsidian/setupProtocol.ts +++ b/src/serviceFeatures/setupObsidian/setupProtocol.ts @@ -1,9 +1,9 @@ -import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@lib/common/types"; -import type { LogFunction } from "@lib/services/lib/logUtils"; -import { createInstanceLogFunction } from "@lib/services/lib/logUtils"; -import type { SetupFeatureHost } from "@lib/serviceFeatures/setupObsidian/types"; +import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import type { SetupFeatureHost } from "@/serviceFeatures/setupObsidian/types"; import { configURIBase } from "@/common/types"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { type SetupManager, UserMode } from "@/modules/features/SetupManager"; async function handleSetupProtocol(setupManager: SetupManager, conf: Record) { diff --git a/src/serviceFeatures/setupObsidian/setupUri.ts b/src/serviceFeatures/setupObsidian/setupUri.ts new file mode 100644 index 00000000..9a8d8c65 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupUri.ts @@ -0,0 +1,73 @@ +import { LOG_LEVEL_NOTICE, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { createInstanceLogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils"; +import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; +import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import type { SetupFeatureHost } from "./types"; + +export async function askEncryptingPassphrase(host: SetupFeatureHost): Promise { + return await host.services.UI.confirm.askString( + "Encrypt your settings", + "The passphrase to encrypt the setup URI", + "", + true + ); +} + +export async function copySetupURI(host: SetupFeatureHost, log: LogFunction, stripExtra = true) { + const encryptingPassphrase = await askEncryptingPassphrase(host); + if (encryptingPassphrase === false) return; + const encryptedURI = await encodeSettingsToSetupURI( + host.services.setting.currentSettings(), + encryptingPassphrase, + [...((stripExtra ? ["pluginSyncExtendedSetting"] : []) as (keyof ObsidianLiveSyncSettings)[])], + true + ); + if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) { + log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE); + } +} + +export async function copySetupURIFull(host: SetupFeatureHost, log: LogFunction) { + const encryptingPassphrase = await askEncryptingPassphrase(host); + if (encryptingPassphrase === false) return; + const encryptedURI = await encodeSettingsToSetupURI( + host.services.setting.currentSettings(), + encryptingPassphrase, + [], + false + ); + if (await host.services.UI.promptCopyToClipboard("Setup URI", encryptedURI)) { + log("Setup URI copied to clipboard", LOG_LEVEL_NOTICE); + } +} + +export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setting" | "appLifecycle", never>) { + const log = createInstanceLogFunction("SF:SetupURI", host.services.API); + host.services.appLifecycle.onLoaded.addHandler(() => { + host.services.API.addCommand({ + id: "livesync-copysetupuri", + name: "Copy settings as a new setup URI", + callback: () => fireAndForget(copySetupURI(host, log)), + }); + + host.services.API.addCommand({ + id: "livesync-copysetupuri-short", + name: "Copy settings as a new setup URI (With customization sync)", + callback: () => fireAndForget(copySetupURI(host, log, false)), + }); + + host.services.API.addCommand({ + id: "livesync-copysetupurifull", + name: "Copy settings as a new setup URI (Full)", + callback: () => fireAndForget(copySetupURIFull(host, log)), + }); + + host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => + fireAndForget(() => copySetupURI(host, log)) + ); + return Promise.resolve(true); + }); +} diff --git a/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts new file mode 100644 index 00000000..64d3b80b --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { EVENT_REQUEST_COPY_SETUP_URI } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase"; +import { askEncryptingPassphrase, copySetupURI, copySetupURIFull, useSetupURIFeature } from "./setupUri"; +import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; + +vi.mock("@vrtmrz/livesync-commonlib/compat/API/processSetting", () => { + return { + encodeSettingsToSetupURI: vi.fn(), + }; +}); + +describe("setupObsidian/setupUri", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it("askEncryptingPassphrase should delegate to confirm.askString", async () => { + const askString = vi.fn(() => "secret"); + const host = { + services: { + UI: { + confirm: { + askString, + }, + }, + }, + } as any; + + const result = await askEncryptingPassphrase(host); + expect(result).toBe("secret"); + expect(askString).toHaveBeenCalled(); + }); + + it("copySetupURI should return early when user cancels passphrase", async () => { + const promptCopyToClipboard = vi.fn(); + const host = { + services: { + setting: { + currentSettings: vi.fn(() => ({ foo: "bar" })), + }, + UI: { + confirm: { + askString: vi.fn(() => false), + }, + promptCopyToClipboard, + }, + }, + } as any; + const log = vi.fn(); + + await copySetupURI(host, log); + + expect(encodeSettingsToSetupURI).not.toHaveBeenCalled(); + expect(promptCopyToClipboard).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); + + it("copySetupURI should encode with short mode by default", async () => { + const promptCopyToClipboard = vi.fn(() => true); + const currentSettings = { pluginSyncExtendedSetting: true, x: 1 }; + const host = { + services: { + setting: { + currentSettings: vi.fn(() => currentSettings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard, + }, + }, + } as any; + const log = vi.fn(); + vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://value" as any); + + await copySetupURI(host, log); + + expect(encodeSettingsToSetupURI).toHaveBeenCalledWith( + currentSettings, + "pass", + ["pluginSyncExtendedSetting"], + true + ); + expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://value"); + expect(log).toHaveBeenCalled(); + }); + + it("copySetupURIFull should encode with full mode", async () => { + const promptCopyToClipboard = vi.fn(() => true); + const currentSettings = { pluginSyncExtendedSetting: true, x: 1 }; + const host = { + services: { + setting: { + currentSettings: vi.fn(() => currentSettings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass-full"), + }, + promptCopyToClipboard, + }, + }, + } as any; + const log = vi.fn(); + vi.mocked(encodeSettingsToSetupURI).mockResolvedValue("uri://full" as any); + + await copySetupURIFull(host, log); + + expect(encodeSettingsToSetupURI).toHaveBeenCalledWith(currentSettings, "pass-full", [], false); + expect(promptCopyToClipboard).toHaveBeenCalledWith("Setup URI", "uri://full"); + expect(log).toHaveBeenCalled(); + }); + + it("useSetupURIFeature should register onLoaded handler that wires commands and event", async () => { + const addHandler = vi.fn(); + const addCommand = vi.fn(); + const context = createServiceContext(); + const onEventSpy = vi.spyOn(context.events, "onEvent"); + + const host = { + services: { + context, + API: { + addCommand, + addLog: vi.fn(), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => ({ x: 1 })), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard: vi.fn(() => true), + }, + }, + } as any; + + useSetupURIFeature(host); + expect(addHandler).toHaveBeenCalledTimes(1); + + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + expect(addCommand).toHaveBeenCalledTimes(3); + expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupuri" })); + expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupuri-short" })); + expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupurifull" })); + expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_COPY_SETUP_URI, expect.any(Function)); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/types.ts b/src/serviceFeatures/setupObsidian/types.ts new file mode 100644 index 00000000..0e15898e --- /dev/null +++ b/src/serviceFeatures/setupObsidian/types.ts @@ -0,0 +1,3 @@ +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; + +export type SetupFeatureHost = NecessaryServices<"API" | "UI" | "setting", never>; diff --git a/src/serviceFeatures/useP2PReplicatorUI.ts b/src/serviceFeatures/useP2PReplicatorUI.ts index 8dc5a86e..39cca2c0 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -1,8 +1,8 @@ import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events"; import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import type { NecessaryServices } from "@lib/interfaces/ServiceModule"; -import { type UseP2PReplicatorResult } from "@lib/replication/trystero/UseP2PReplicatorResult"; -import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector"; +import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; +import { type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; +import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; import { P2PServerStatusPaneView, @@ -10,7 +10,7 @@ import { } from "@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView"; import type { LiveSyncCore } from "@/main"; import type { WorkspaceLeaf } from "@/deps"; -import { REMOTE_P2P } from "@lib/common/models/setting.const"; +import { REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; /** * ServiceFeature: P2P Replicator lifecycle management. @@ -54,7 +54,7 @@ export function useP2PReplicatorUI( // const env: LiveSyncTrysteroReplicatorEnv = { services: host.services as any }; const getReplicator = () => replicator.replicator; - const p2pLogCollector = new P2PLogCollector(); + const p2pLogCollector = new P2PLogCollector(host.services.context.events); const storeP2PStatusLine = reactiveSource(""); p2pLogCollector.p2pReplicationLine.onChanged((line) => { storeP2PStatusLine.value = line.value; diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts index 5495d4d3..f860c6f4 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; vi.mock("@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView", () => ({ P2PReplicatorPaneView: class {}, @@ -19,6 +20,7 @@ describe("useP2PReplicatorUI commands", () => { const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task()); const host = { services: { + context: createServiceContext(), API: { showWindow: vi.fn(async () => undefined), registerWindow: vi.fn(), diff --git a/src/serviceModules/DatabaseFileAccess.ts b/src/serviceModules/DatabaseFileAccess.ts index 645888ef..93bdb0a3 100644 --- a/src/serviceModules/DatabaseFileAccess.ts +++ b/src/serviceModules/DatabaseFileAccess.ts @@ -1,8 +1,8 @@ -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess.ts"; -import { ServiceDatabaseFileAccessBase } from "@lib/serviceModules/ServiceDatabaseFileAccessBase"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import { ServiceDatabaseFileAccessBase } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceDatabaseFileAccessBase"; // markChangesAreSame uses persistent data implicitly, we should refactor it too. // For now, to make the refactoring done once, we just use them directly. -// Hence it is not on /src/lib/src/serviceModules. (markChangesAreSame is using indexedDB). +// Hence it remains in the plug-in rather than Commonlib. (markChangesAreSame is using indexedDB). // Refactored, now migrating... export class ServiceDatabaseFileAccess extends ServiceDatabaseFileAccessBase implements DatabaseFileAccess {} diff --git a/src/serviceModules/FileAccessObsidian.ts b/src/serviceModules/FileAccessObsidian.ts index 5b318cc8..2d5cacb0 100644 --- a/src/serviceModules/FileAccessObsidian.ts +++ b/src/serviceModules/FileAccessObsidian.ts @@ -1,5 +1,5 @@ import { type App } from "@/deps"; -import { FileAccessBase, type FileAccessBaseDependencies } from "@lib/serviceModules/FileAccessBase.ts"; +import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter"; /** diff --git a/src/serviceModules/FileHandler.ts b/src/serviceModules/FileHandler.ts index c47cc337..46febb4a 100644 --- a/src/serviceModules/FileHandler.ts +++ b/src/serviceModules/FileHandler.ts @@ -1,7 +1,7 @@ -import { ServiceFileHandlerBase } from "@lib/serviceModules/ServiceFileHandlerBase"; +import { ServiceFileHandlerBase } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileHandlerBase"; // markChangesAreSame uses persistent data implicitly, we should refactor it too. // also, compareFileFreshness depends on marked changes, so we should refactor it as well. For now, to make the refactoring done once, we just use them directly. -// Hence it is not on /src/lib/src/serviceModules. (markChangesAreSame is using indexedDB). +// Hence it remains in the plug-in rather than Commonlib. (markChangesAreSame is using indexedDB). // Refactored: markChangesAreSame, unmarkChanges, compareFileFreshness, isMarkedAsSameChanges are now moved to PathService export class ServiceFileHandler extends ServiceFileHandlerBase {} diff --git a/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts index 02957595..179d4d9d 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianConversionAdapter.ts @@ -1,5 +1,5 @@ -import type { UXFileInfoStub, UXFolderInfo } from "@lib/common/types"; -import type { IConversionAdapter } from "@lib/serviceModules/adapters"; +import type { UXFileInfoStub, UXFolderInfo } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IConversionAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import { TFileToUXFileInfoStub, TFolderToUXFileInfoStub } from "@/modules/coreObsidian/storageLib/utilObsidian"; import type { TFile, TFolder } from "obsidian"; diff --git a/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts index 58f1f694..534d1063 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianFileSystemAdapter.ts @@ -1,4 +1,4 @@ -import type { FilePath, UXStat } from "@lib/common/types"; +import type { FilePath, UXStat } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { IFileSystemAdapter, IPathAdapter, @@ -6,7 +6,7 @@ import type { IConversionAdapter, IStorageAdapter, IVaultAdapter, -} from "@lib/serviceModules/adapters"; +} from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { TAbstractFile, TFile, TFolder, Stat, App } from "obsidian"; import { ObsidianConversionAdapter } from "./ObsidianConversionAdapter"; import { ObsidianPathAdapter } from "./ObsidianPathAdapter"; diff --git a/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts index a21ced14..6557c7b0 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianPathAdapter.ts @@ -1,6 +1,6 @@ import { type TAbstractFile, normalizePath } from "@/deps"; -import type { FilePath } from "@lib/common/types"; -import type { IPathAdapter } from "@lib/serviceModules/adapters"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IPathAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; /** * Path adapter implementation for Obsidian diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts index a9133018..2cec3048 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts @@ -1,6 +1,6 @@ -import type { UXDataWriteOptions } from "@lib/common/types"; -import type { IStorageAdapter } from "@lib/serviceModules/adapters"; -import { toArrayBuffer } from "@lib/serviceModules/FileAccessBase"; +import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; +import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import type { Stat, App } from "obsidian"; /** diff --git a/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts index 74e05bd5..656fabc2 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianTypeGuardAdapter.ts @@ -1,4 +1,4 @@ -import type { ITypeGuardAdapter } from "@lib/serviceModules/adapters"; +import type { ITypeGuardAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import { TFile, TFolder } from "obsidian"; /** diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts index 81d51e04..0cc05d71 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts @@ -1,6 +1,6 @@ -import type { UXDataWriteOptions } from "@lib/common/types"; -import type { IVaultAdapter } from "@lib/serviceModules/adapters"; -import { toArrayBuffer } from "@lib/serviceModules/FileAccessBase"; +import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; +import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import type { TFile, App, TFolder } from "obsidian"; /** diff --git a/src/serviceModules/ServiceFileAccessImpl.ts b/src/serviceModules/ServiceFileAccessImpl.ts index 204855ef..c2b40187 100644 --- a/src/serviceModules/ServiceFileAccessImpl.ts +++ b/src/serviceModules/ServiceFileAccessImpl.ts @@ -1,4 +1,4 @@ -import { ServiceFileAccessBase } from "@lib/serviceModules/ServiceFileAccessBase"; +import { ServiceFileAccessBase } from "@vrtmrz/livesync-commonlib/compat/serviceModules/ServiceFileAccessBase"; import type { ObsidianFileSystemAdapter } from "./FileSystemAdapters/ObsidianFileSystemAdapter"; // For now, this is just a re-export of ServiceFileAccess with the Obsidian-specific adapter type. diff --git a/src/types.ts b/src/types.ts index b4c40bfd..bb3bb5c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ -import type { DatabaseFileAccess } from "@lib/interfaces/DatabaseFileAccess"; -import type { Rebuilder } from "@lib/interfaces/DatabaseRebuilder"; -import type { IFileHandler } from "@lib/interfaces/FileHandler"; -import type { StorageAccess } from "@lib/interfaces/StorageAccess"; -import type { IServiceHub } from "@lib/services/base/IService"; +import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; +import type { Rebuilder } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseRebuilder"; +import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { IServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; export interface ServiceModules { storageAccess: StorageAccess; diff --git a/test/contracts/serviceContext.ts b/test/contracts/serviceContext.ts new file mode 100644 index 00000000..9779e195 --- /dev/null +++ b/test/contracts/serviceContext.ts @@ -0,0 +1,75 @@ +import type { ServiceContextContract } from "@vrtmrz/livesync-commonlib/context"; +import type { ServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/ServiceHub"; + +export const SERVICE_CONTEXT_MEMBERS = [ + "API", + "path", + "database", + "databaseEvents", + "replicator", + "fileProcessing", + "replication", + "remote", + "conflict", + "appLifecycle", + "setting", + "tweakValue", + "vault", + "test", + "UI", + "config", + "keyValueDB", + "control", +] as const satisfies readonly Exclude[]; + +export type ServiceContextMember = (typeof SERVICE_CONTEXT_MEMBERS)[number]; +type MissingServiceContextMember = Exclude, ServiceContextMember>; +const serviceContextMembersAreExhaustive: [MissingServiceContextMember] extends [never] ? true : never = true; +void serviceContextMembersAreExhaustive; + +export type ServiceContextResult = { + translation: string; + receivedEvents: string[]; +}; + +export type ServiceCompositionResult = { + hubUsesExpectedContext: boolean; + servicesUsingExpectedContext: Record; +}; + +/** + * Observe the host-neutral results promised by ServiceContextContract. + * + * The caller chooses the translation key because translated text is + * host-configured. Event delivery itself is shared behaviour. + */ +export function observeServiceContext(context: ServiceContextContract, translationKey: string): ServiceContextResult { + const receivedEvents: string[] = []; + const unsubscribe = context.events.onEvent("hello", (value) => receivedEvents.push(value)); + try { + context.events.emitEvent("hello", "context-contract-event"); + } finally { + unsubscribe(); + } + return { + translation: context.translate(translationKey), + receivedEvents, + }; +} + +/** + * Inspect whether a Service Hub and all public services preserve one exact + * context object instead of silently constructing or substituting another. + */ +export function observeServiceComposition( + hub: { readonly context: ServiceContextContract }, + expectedContext: ServiceContextContract +): ServiceCompositionResult { + const members = hub as unknown as Record; + return { + hubUsesExpectedContext: hub.context === expectedContext, + servicesUsingExpectedContext: Object.fromEntries( + SERVICE_CONTEXT_MEMBERS.map((member) => [member, members[member].context === expectedContext]) + ) as Record, + }; +} diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 56008f2f..0da649e3 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -4,7 +4,7 @@ This directory contains the experimental real Obsidian end-to-end runner. The generic application discovery, isolated-vault, plug-in installation, process lifecycle, CLI, CDP, and readiness implementation comes from `@vrtmrz/obsidian-test-session`. The small modules under `runner/` preserve LiveSync's existing imports and supply its plug-in ID and artefact location. LiveSync-specific fixtures, services, settings, workflows, and assertions remain in this repository. -The current smoke runner verifies only the launch path: +The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition: 1. create a temporary vault, 2. install the built Self-hosted LiveSync plug-in artifacts, @@ -13,8 +13,10 @@ The current smoke runner verifies only the launch path: 5. enable Obsidian community plug-ins for the temporary app profile, 6. reload Self-hosted LiveSync through `obsidian-cli`, 7. verify through `obsidian-cli eval` that the plug-in is loaded, -8. optionally drive a real vault or CouchDB workflow through Obsidian's own API, -9. terminate Obsidian and remove the temporary vault. +8. observe event and translation results from the actual `ObsidianServiceContext`, +9. verify that the Service Hub and every exposed service retain that exact Context, +10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and +11. terminate Obsidian and remove the temporary vault. The runner does not require Self-hosted LiveSync to expose an E2E-only bridge. Readiness is checked from outside the plug-in through Obsidian's own CLI. @@ -45,6 +47,10 @@ These tests are intended for local verification, not the default CI gate. Reuse ## Commands ```bash +npm run test:contract:contexts +npm run test:contract:context:webapp +npm run test:contract:context:cli +npm run test:contract:context:obsidian npm run test:e2e:obsidian:install-appimage npm run test:e2e:obsidian:discover npm run test:e2e:obsidian:cli-help -- vaults verbose @@ -62,6 +68,10 @@ npm run test:e2e:obsidian:local-suite npm run test:e2e:obsidian:local-suite:services ``` +`test:contract:contexts` runs the directly observable host contract against the Obsidian, CLI, and Webapp compositions. It verifies event and translation results, host-specific capabilities, and that the CLI and Webapp Service Hubs pass one exact Context to all exposed services. `test:contract:context:webapp` runs only the Webapp part. + +`test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change. + `test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, vault reflection, CouchDB upload, CLI-to-Obsidian synchronisation, Object Storage upload, startup scan, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB and MinIO fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. `test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, configures Self-hosted LiveSync through `obsidian-cli eval`, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts index ef842c30..cd85854a 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -1,4 +1,5 @@ import { evalObsidianJson } from "./cli.ts"; +import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts"; import type { CouchDbConfig } from "./couchdb.ts"; import type { ObjectStorageConfig } from "./objectStorage.ts"; @@ -20,6 +21,17 @@ export type CoreReadiness = { appReady: boolean; }; +export type ObsidianServiceContextContractResult = { + contextType: string; + eventResult: string[]; + translationResult: string; + hubUsesContext: boolean; + serviceContextMismatches: string[]; + appCapabilityMatches: boolean; + pluginCapabilityMatches: boolean; + liveSyncPluginCapabilityMatches: boolean; +}; + export type LocalDatabaseEntry = { id: string; rev: string; @@ -172,6 +184,68 @@ export async function waitForLiveSyncCoreReady( throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`); } +/** + * Inspect the actual Obsidian composition through Obsidian's CLI. + * + * This observes public Context results and verifies that the Hub and every + * exposed service retain the exact Context created by the plug-in host. + */ +export async function inspectObsidianServiceContextContract( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const services=plugin.core.services;", + "const context=services.context;", + `const serviceNames=${JSON.stringify(SERVICE_CONTEXT_MEMBERS)};`, + "const eventResult=[];", + "const unsubscribe=context.events.onEvent('hello',(value)=>eventResult.push(value));", + "try{context.events.emitEvent('hello','context-contract-event');}finally{unsubscribe();}", + "return JSON.stringify({", + "contextType:context.constructor.name,", + "eventResult,", + "translationResult:context.translate('Replicator.Message.InitialiseFatalError'),", + "hubUsesContext:services.context===context,", + "serviceContextMismatches:serviceNames.filter((name)=>services[name].context!==context),", + "appCapabilityMatches:context.app===app,", + "pluginCapabilityMatches:context.plugin===plugin,", + "liveSyncPluginCapabilityMatches:context.liveSyncPlugin===plugin,", + "});", + "})()", + ].join(""), + env + ); +} + +export function assertObsidianServiceContextContract(result: ObsidianServiceContextContractResult): void { + assertEqual(result.contextType, "ObsidianServiceContext", "Unexpected Obsidian service Context type."); + assertEqual(result.hubUsesContext, true, "The Obsidian Service Hub substituted its host Context."); + assertEqual( + result.serviceContextMismatches.length, + 0, + `Services used a different Context: ${result.serviceContextMismatches.join(", ")}` + ); + assertEqual( + JSON.stringify(result.eventResult), + JSON.stringify(["context-contract-event"]), + "The Obsidian Context event API returned an unexpected result." + ); + if (result.translationResult.length === 0) { + throw new Error("The Obsidian Context translator returned an empty result."); + } + assertEqual(result.appCapabilityMatches, true, "The Obsidian Context lost its App capability."); + assertEqual(result.pluginCapabilityMatches, true, "The Obsidian Context lost its Plugin capability."); + assertEqual( + result.liveSyncPluginCapabilityMatches, + true, + "The Obsidian Context lost its Self-hosted LiveSync plug-in capability." + ); +} + export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, diff --git a/test/e2e-obsidian/scripts/smoke.ts b/test/e2e-obsidian/scripts/smoke.ts index c00644f3..8dee624a 100644 --- a/test/e2e-obsidian/scripts/smoke.ts +++ b/test/e2e-obsidian/scripts/smoke.ts @@ -1,4 +1,8 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertObsidianServiceContextContract, + inspectObsidianServiceContextContract, +} from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; @@ -25,6 +29,11 @@ async function main(): Promise { console.log( `Obsidian plug-in ready: ${readiness.pluginId}@${readiness.pluginVersion} in ${readiness.vaultName}` ); + const contextContract = await inspectObsidianServiceContextContract(cli.binary, session.cliEnv); + assertObsidianServiceContextContract(contextContract); + console.log( + `Obsidian service Context contract passed: ${contextContract.contextType}, ${contextContract.serviceContextMismatches.length} mismatches.` + ); await new Promise((resolve) => setTimeout(resolve, Number(process.env.E2E_OBSIDIAN_SMOKE_TIMEOUT_MS ?? 1000))); console.log("Obsidian stayed alive after the plug-in readiness check."); } finally { diff --git a/test/harness/harness.ts b/test/harness/harness.ts index ef2d20eb..732493f3 100644 --- a/test/harness/harness.ts +++ b/test/harness/harness.ts @@ -1,10 +1,10 @@ import { App } from "@/deps.ts"; import ObsidianLiveSyncPlugin from "@/main"; -import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; -import { LOG_LEVEL_VERBOSE, setGlobalLogFunction } from "@lib/common/logger"; +import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { LOG_LEVEL_VERBOSE, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { SettingCache } from "./obsidian-mock"; import { delay, fireAndForget, promiseWithResolvers } from "octagonal-wheels/promises"; -import { EVENT_PLATFORM_UNLOADED } from "@lib/events/coreEvents"; +import { EVENT_PLATFORM_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import { EVENT_LAYOUT_READY, eventHub } from "@/common/events"; import { env } from "../suite/variables"; diff --git a/test/lib/commands.ts b/test/lib/commands.ts index 762b5c0c..c8655380 100644 --- a/test/lib/commands.ts +++ b/test/lib/commands.ts @@ -1,4 +1,4 @@ -import type { P2PSyncSetting } from "@/lib/src/common/types"; +import type { P2PSyncSetting } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { delay } from "octagonal-wheels/promises"; import type { BrowserContext, Page } from "playwright"; import type { Plugin } from "vitest/config"; diff --git a/test/lib/ui.ts b/test/lib/ui.ts index 3d2381a6..b4071cf5 100644 --- a/test/lib/ui.ts +++ b/test/lib/ui.ts @@ -1,5 +1,5 @@ import { page } from "vitest/browser"; -import { delay } from "@/lib/src/common/utils"; +import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils"; export async function waitForDialogShown(dialogText: string, timeout = 500) { const ttl = Date.now() + timeout; diff --git a/test/lib/util.ts b/test/lib/util.ts index 502d0d2c..23a8ce65 100644 --- a/test/lib/util.ts +++ b/test/lib/util.ts @@ -1,4 +1,4 @@ -import { delay } from "@/lib/src/common/utils"; +import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils"; export async function waitTaskWithFollowups( task: Promise, diff --git a/test/suite/db_common.ts b/test/suite/db_common.ts index f81f084f..d6e82012 100644 --- a/test/suite/db_common.ts +++ b/test/suite/db_common.ts @@ -1,7 +1,7 @@ import { compareMTime, EVEN } from "@/common/utils"; import { TFile, type DataWriteOptions } from "@/deps"; -import type { FilePath } from "@/lib/src/common/types"; -import { isDocContentSame, readContent } from "@/lib/src/common/utils"; +import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { isDocContentSame, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; import { expect } from "vitest"; diff --git a/test/suite/onlylocaldb.test.ts b/test/suite/onlylocaldb.test.ts index acfbb65b..a140dd58 100644 --- a/test/suite/onlylocaldb.test.ts +++ b/test/suite/onlylocaldb.test.ts @@ -1,8 +1,8 @@ import { beforeAll, describe, expect, it, test } from "vitest"; import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; import { TFile } from "@/deps.ts"; -import { DEFAULT_SETTINGS, type FilePath, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; -import { isDocContentSame, readContent } from "@/lib/src/common/utils"; +import { DEFAULT_SETTINGS, type FilePath, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { isDocContentSame, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { DummyFileSourceInisialised, generateBinaryFile, generateFile, init } from "../utils/dummyfile"; const localdb_test_setting = { diff --git a/test/suite/sync.senario.basic.ts b/test/suite/sync.senario.basic.ts index 3eafe3cb..2c8b301f 100644 --- a/test/suite/sync.senario.basic.ts +++ b/test/suite/sync.senario.basic.ts @@ -3,7 +3,7 @@ // and edge, resolving conflicts, etc. will be covered in separate test suites. import { afterAll, beforeAll, describe, expect, it, test } from "vitest"; import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type FilePath, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; +import { RemoteTypes, type FilePath, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DummyFileSourceInisialised, @@ -13,7 +13,7 @@ import { generateFile, } from "../utils/dummyfile"; import { checkStoredFileInDB, testFileRead, testFileWrite } from "./db_common"; -import { delay } from "@/lib/src/common/utils"; +import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { commands } from "vitest/browser"; import { closeReplication, performReplication, prepareRemote } from "./sync_common"; import type { DataWriteOptions } from "@/deps.ts"; diff --git a/test/suite/sync.single.test.ts b/test/suite/sync.single.test.ts index 9be98b44..a03f3bb0 100644 --- a/test/suite/sync.single.test.ts +++ b/test/suite/sync.single.test.ts @@ -7,7 +7,7 @@ import { PREFERRED_SETTING_SELF_HOSTED, RemoteTypes, type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { defaultFileOption } from "./db_common"; import { syncBasicCase } from "./sync.senario.basic.ts"; diff --git a/test/suite/sync.test.ts b/test/suite/sync.test.ts index aa284c17..6873f121 100644 --- a/test/suite/sync.test.ts +++ b/test/suite/sync.test.ts @@ -7,7 +7,7 @@ import { PREFERRED_SETTING_SELF_HOSTED, RemoteTypes, type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { defaultFileOption } from "./db_common"; import { syncBasicCase } from "./sync.senario.basic.ts"; diff --git a/test/suite/sync_common.ts b/test/suite/sync_common.ts index 74da8664..82647d78 100644 --- a/test/suite/sync_common.ts +++ b/test/suite/sync_common.ts @@ -1,10 +1,10 @@ import { expect } from "vitest"; import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; +import { RemoteTypes, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { delay, fireAndForget } from "@/lib/src/common/utils"; +import { delay, fireAndForget } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { commands } from "vitest/browser"; -import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; +import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; import { waitTaskWithFollowups } from "../lib/util"; async function waitForP2PPeers(harness: LiveSyncHarness) { if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { diff --git a/test/suite/variables.ts b/test/suite/variables.ts index f55cce26..6207c86a 100644 --- a/test/suite/variables.ts +++ b/test/suite/variables.ts @@ -1,10 +1,10 @@ -import { DoctorRegulation } from "@/lib/src/common/configForDoc"; +import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc"; import { DEFAULT_SETTINGS, ChunkAlgorithms, AutoAccepting, type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; export const env = (import.meta as any).env; export const settingBase = { ...DEFAULT_SETTINGS, diff --git a/test/suitep2p/sync_common_p2p.ts b/test/suitep2p/sync_common_p2p.ts index 53009894..3b37f814 100644 --- a/test/suitep2p/sync_common_p2p.ts +++ b/test/suitep2p/sync_common_p2p.ts @@ -7,9 +7,9 @@ */ import { expect } from "vitest"; import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; -import { delay } from "@/lib/src/common/utils"; -import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; +import { RemoteTypes, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; import { waitTaskWithFollowups } from "../lib/util"; const P2P_REPLICATION_TIMEOUT_MS = 180000; diff --git a/test/suitep2p/syncp2p.p2p-down.test.ts b/test/suitep2p/syncp2p.p2p-down.test.ts index 7f3b77f7..4d1c4e17 100644 --- a/test/suitep2p/syncp2p.p2p-down.test.ts +++ b/test/suitep2p/syncp2p.p2p-down.test.ts @@ -15,10 +15,10 @@ import { type FilePath, type ObsidianLiveSyncSettings, AutoAccepting, -} from "@/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile"; import { defaultFileOption, testFileRead } from "../suite/db_common"; -import { delay } from "@/lib/src/common/utils"; +import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { closeReplication, performReplication } from "./sync_common_p2p"; import { settingBase } from "../suite/variables"; diff --git a/test/suitep2p/syncp2p.p2p-up.test.ts b/test/suitep2p/syncp2p.p2p-up.test.ts index 7c463eb3..de5d5cef 100644 --- a/test/suitep2p/syncp2p.p2p-up.test.ts +++ b/test/suitep2p/syncp2p.p2p-up.test.ts @@ -17,7 +17,7 @@ import { RemoteTypes, type ObsidianLiveSyncSettings, AutoAccepting, -} from "@/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DummyFileSourceInisialised, FILE_SIZE_BINS, @@ -26,7 +26,7 @@ import { generateFile, } from "../utils/dummyfile"; import { checkStoredFileInDB, defaultFileOption, testFileWrite } from "../suite/db_common"; -import { delay } from "@/lib/src/common/utils"; +import { delay } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { closeReplication, performReplication } from "./sync_common_p2p"; import { settingBase } from "../suite/variables"; diff --git a/test/suitep2p/syncp2p.test.ts b/test/suitep2p/syncp2p.test.ts index 08c2c101..416ab1f1 100644 --- a/test/suitep2p/syncp2p.test.ts +++ b/test/suitep2p/syncp2p.test.ts @@ -7,7 +7,7 @@ import { PREFERRED_SETTING_SELF_HOSTED, RemoteTypes, type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { settingBase } from "../suite/variables.ts"; import { defaultFileOption } from "../suite/db_common"; diff --git a/test/unit/dialog.test.ts b/test/unit/dialog.test.ts index 86c424cc..8ebbf12c 100644 --- a/test/unit/dialog.test.ts +++ b/test/unit/dialog.test.ts @@ -3,12 +3,12 @@ import { beforeAll, describe, expect, it } from "vitest"; import { commands } from "vitest/browser"; import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { ChunkAlgorithms, DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; +import { ChunkAlgorithms, DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { DummyFileSourceInisialised } from "../utils/dummyfile"; import { page } from "vitest/browser"; -import { DoctorRegulation } from "@/lib/src/common/configForDoc"; +import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc"; import { waitForDialogHidden, waitForDialogShown } from "../lib/ui"; const env = (import.meta as any).env; const dialog_setting_base = { diff --git a/test/utils/dummyfile.ts b/test/utils/dummyfile.ts index ab4b8b3f..66cf6878 100644 --- a/test/utils/dummyfile.ts +++ b/test/utils/dummyfile.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SETTINGS } from "@/lib/src/common/types.ts"; +import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { readFile } from "../utils/fileapi.vite.ts"; let charset = ""; export async function init() { diff --git a/tsconfig.json b/tsconfig.json index 80944670..e30457f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,8 +19,7 @@ "strictBindCallApply": true, "strictFunctionTypes": true, "paths": { - "@/*": ["./src/*"], - "@lib/*": ["./src/lib/src/*", "./_types/src/lib/src/*"] + "@/*": ["./src/*"] } }, "include": ["**/*.ts", "test/**/*.test.ts", "**/*.unit.spec.ts", "**/*.svelte"], diff --git a/tsconfig.types.json b/tsconfig.types.json deleted file mode 100644 index 089dbfa5..00000000 --- a/tsconfig.types.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "emitDeclarationOnly": true, - "outDir": "./_types", - "rootDir": "." - }, - "include": ["src/lib/**/*.ts"], - "exclude": [ - "_types", - "pouchdb-browser-webpack", - "utils", - "src/apps", - "src/**/*.test.ts", - "src/lib/_tools", - "src/lib/apps", - "src/lib/src/cli", - "**/_test/**", - "utilsdeno", - "node_modules", - "test/**/*.test.ts", - "**/*.unit.spec.ts" - ] -} diff --git a/updates.md b/updates.md index 36b28f43..e9d1f9cf 100644 --- a/updates.md +++ b/updates.md @@ -5,6 +5,15 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid ## Unreleased +### Miscellaneous + +- Replaced the embedded Commonlib source and generated fallback declarations with a locked compiled package, reducing duplicated release and repository-scanner inputs without changing synchronisation behaviour. + +### Testing + +- Added packed-package and downstream checks for Commonlib entry points, including isolated Node and browser File System Access API storage implementations. +- Added reusable Context result contracts for Obsidian, CLI, and Webapp compositions, including a real-Obsidian smoke assertion that every service retains the host-provided Context. + ## 0.25.83 16th July, 2026 diff --git a/utils/bench/splitPiecesRabinKarp.ts b/utils/bench/splitPiecesRabinKarp.ts index 1c4642c7..f529b448 100644 --- a/utils/bench/splitPiecesRabinKarp.ts +++ b/utils/bench/splitPiecesRabinKarp.ts @@ -2,15 +2,19 @@ import { glob } from "glob"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promises as fs } from "node:fs"; -import { isPlainText, shouldSplitAsPlainText } from "../../src/lib/src/string_and_binary/path"; -import { splitPiecesRabinKarp } from "../../src/lib/src/string_and_binary/chunks"; +import { isPlainText, shouldSplitAsPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import { splitPiecesRabinKarp } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/chunks"; import { PREFERRED_BASE, PREFERRED_JOURNAL_SYNC, PREFERRED_SETTING_CLOUDANT, PREFERRED_SETTING_SELF_HOSTED, -} from "../../src/lib/src/common/models/setting.const.preferred"; -import { type ObsidianLiveSyncSettings, DEFAULT_SETTINGS, MAX_DOC_SIZE_BIN } from "../../src/lib/src/common/types"; +} from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const.preferred"; +import { + type ObsidianLiveSyncSettings, + DEFAULT_SETTINGS, + MAX_DOC_SIZE_BIN, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; async function blobFromString(content: string): Promise { return new Blob([content], { type: "text/plain" }); diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index 94c9c125..5c3194f0 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -130,20 +130,13 @@ describe("release notes", () => { }); describe("release workflow", () => { - it("regenerates and stages fallback type definitions", () => { + it("uses the locked Commonlib package instead of generated fallback declarations", () => { const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); - expect(workflow).toContain("npm run build:lib:types"); - expect(workflow).toMatch(/git add[^\n]*_types/); - }); - - it("installs Deno before post-processing fallback type definitions", () => { - const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); - const setupDeno = workflow.indexOf("denoland/setup-deno@v2"); - const buildTypes = workflow.indexOf("npm run build:lib:types"); - - expect(setupDeno).toBeGreaterThan(-1); - expect(setupDeno).toBeLessThan(buildTypes); + expect(workflow).not.toContain("npm run build:lib:types"); + expect(workflow).not.toMatch(/git add[^\n]*_types/); + expect(workflow).toMatch(/git add[^\n]*package-lock\.json/); + expect(workflow).toContain("locked Commonlib package version"); }); it("keeps the release PR in draft until BRAT validation", () => { diff --git a/utilsdeno/README.md b/utilsdeno/README.md index dc46fc49..515c2239 100644 --- a/utilsdeno/README.md +++ b/utilsdeno/README.md @@ -32,7 +32,7 @@ Converts standard global variable usages to compatibility wrappers to ensure saf * **Targets**: `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `requestAnimationFrame`, `cancelAnimationFrame`, `localStorage`, `navigator`, `location`, `window`, `globalThis`, and `document`. * **Actions**: * Replaces global namespace references (like `window` and `globalThis`) with `compatGlobal`. - * Replaces `document` with `_activeDocument` (from `@lib/common/coreEnvFunctions.ts`). +* Replaces `document` with `_activeDocument` from the Commonlib compatibility entry. * Injects or updates the necessary imports in modified files. * **Command**: ```bash @@ -86,29 +86,15 @@ Scans the codebase and logs all occurrences of explicit `any` types. ``` ### 6. Import Normalisation (`normalise-imports.ts`) -Ensures that all import statements are standardised across the codebase, resolving paths to aliases such as `@lib/` and `@/` where applicable. +Ensures that internal plug-in import statements are standardised to the `@/` alias where applicable. Commonlib imports remain explicit package subpaths and are not rewritten. * **Command**: ```bash deno run --allow-read --allow-write --allow-env normalise-imports.ts ``` -### 7. CLI Node.js Import Redirection (`refactor-cli-node-imports.ts`) -Redirects direct Node.js built-in module imports (like `fs` and `path`) within the CLI codebase to use a single barrel file (`src/apps/cli/node-compat.ts`). - -* **Actions**: - * Finds imports of Node.js built-in APIs (`fs`, `fs/promises`, `path`, and `readline/promises`) in CLI source files. - * Replaces them with imports from the local `node-compat.ts` barrel file. - * This eliminates duplicate browser-targeted linter warnings on Node.js built-ins in the CLI workspace, keeping linter ignores consolidated. -* **Command**: - ```bash - deno run --allow-read --allow-write --allow-env refactor-cli-node-imports.ts - ``` - ---- - ## Safety and Exclusions * **Tests Excluded**: All scripts automatically skip files located in `_test/` or `testdeno/` folders, as well as files ending with `.spec.ts` or `.test.ts`. -* **Submodule Caution**: Some tools will run against the `src/lib/` submodule. Ensure you verify changes inside the submodule prior to committing. +* **Package Boundary**: These tools operate on this repository only. Changes to Commonlib belong in its own repository and must be validated with its package checks. * **Verification**: Always run `npm run check` and `npm run test:unit` after performing refactoring tasks to verify that type safety and tests remain intact. diff --git a/utilsdeno/normalise-imports.ts b/utilsdeno/normalise-imports.ts index 11c3d153..c23ee0eb 100644 --- a/utilsdeno/normalise-imports.ts +++ b/utilsdeno/normalise-imports.ts @@ -1,4 +1,4 @@ -// Normalise import and export paths in the codebase to use @lib/ and @/ aliases correctly. +// Normalise import and export paths in the codebase to use the @/ alias correctly. // Use this script by running `deno run --allow-read --allow-write normalise-imports.ts` from the utilsdeno directory. // Set the --run flag to apply changes: `deno run --allow-read --allow-write normalise-imports.ts --run` // Set the --all-alias flag to also normalise sibling/child imports (starting with ./): `deno run --allow-read --allow-write normalise-imports.ts --all-alias` @@ -39,12 +39,9 @@ function toPosixPath(filePath: string): string { const posixProjectRoot = toPosixPath(projectRoot); const posixSrc = `${posixProjectRoot}/src`; -const posixLibSrc = `${posixProjectRoot}/src/lib/src`; -const posixSubrepo = `${posixProjectRoot}/src/lib`; console.log(`Project Root: ${posixProjectRoot}`); console.log(`Source Directory: ${posixSrc}`); -console.log(`Library Source Directory: ${posixLibSrc}`); console.log(""); let modifiedFilesCount = 0; @@ -87,7 +84,7 @@ for (const sourceFile of project.getSourceFiles()) { // Determine if it is an internal import. const isRelative = moduleSpecifier.startsWith("."); - const isAlias = moduleSpecifier.startsWith("@/") || moduleSpecifier.startsWith("@lib/"); + const isAlias = moduleSpecifier.startsWith("@/"); if (!isRelative && !isAlias) { // Skip external packages/modules. @@ -96,9 +93,7 @@ for (const sourceFile of project.getSourceFiles()) { // Resolve path to an absolute POSIX path. let resolvedPath = ""; - if (moduleSpecifier.startsWith("@lib/")) { - resolvedPath = `${posixLibSrc}/${moduleSpecifier.slice(5)}`; - } else if (moduleSpecifier.startsWith("@/")) { + if (moduleSpecifier.startsWith("@/")) { resolvedPath = `${posixSrc}/${moduleSpecifier.slice(2)}`; } else { // Relative path. @@ -107,14 +102,9 @@ for (const sourceFile of project.getSourceFiles()) { resolvedPath = toPosixPath(path.normalize(resolvedPath)); - // Keep relative sibling/child imports unchanged (e.g. ./utils) unless: - // 1. --all-alias is set, OR - // 2. the import crosses the subrepository boundary (src/lib/) + // Keep relative sibling/child imports unchanged (e.g. ./utils) unless --all-alias is set. const isSibling = isRelative && !moduleSpecifier.startsWith(".."); - const importerInsideSubrepo = posixFilePath.startsWith(posixSubrepo + "/"); - const targetInsideSubrepo = resolvedPath.startsWith(posixSubrepo + "/"); - const crossesSubrepo = importerInsideSubrepo !== targetInsideSubrepo; - if (isSibling && !allAlias && !crossesSubrepo) { + if (isSibling && !allAlias) { continue; } @@ -126,18 +116,7 @@ for (const sourceFile of project.getSourceFiles()) { moduleSpecifier.endsWith(".svelte") || moduleSpecifier.endsWith(".d.ts"); - if (resolvedPath.startsWith(posixLibSrc + "/")) { - let rel = resolvedPath.slice(posixLibSrc.length + 1); - if (!hasExtension && (rel.endsWith(".ts") || rel.endsWith(".js"))) { - // Strip extension if the original import did not have one. - if (rel.endsWith(".ts") && !rel.endsWith(".d.ts")) { - rel = rel.slice(0, -3); - } else if (rel.endsWith(".js")) { - rel = rel.slice(0, -3); - } - } - newSpecifier = `@lib/${rel}`; - } else if (resolvedPath.startsWith(posixSrc + "/")) { + if (resolvedPath.startsWith(posixSrc + "/")) { let rel = resolvedPath.slice(posixSrc.length + 1); if (!hasExtension && (rel.endsWith(".ts") || rel.endsWith(".js"))) { // Strip extension if the original import did not have one. diff --git a/utilsdeno/refactor-cli-node-imports.ts b/utilsdeno/refactor-cli-node-imports.ts deleted file mode 100644 index 36f88536..00000000 --- a/utilsdeno/refactor-cli-node-imports.ts +++ /dev/null @@ -1,132 +0,0 @@ -// Refactor Node.js imports in the CLI application to use the barrel compatibility file. -// Use this script by running `deno run --allow-read --allow-write --allow-env refactor-cli-node-imports.ts` from the utilsdeno directory. -// Run with --run flag to apply changes. -import { Project, SyntaxKind, Node } from "npm:ts-morph"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const isDryRun = !Deno.args.includes("--run"); - -if (isDryRun) { - console.log("=== DRY RUN MODE ==="); - console.log( - "To apply changes, run with: deno run --allow-read --allow-write --allow-env refactor-cli-node-imports.ts --run\n" - ); -} else { - console.log("=== RUN MODE: WILL MODIFY FILES ==="); -} - -const project = new Project({ tsConfigFilePath: "../tsconfig.json" }); -project.addSourceFilesAtPaths("../src/apps/cli/**/*.ts"); - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const projectRoot = path.resolve(__dirname, ".."); -const nodeCompatPath = path.resolve(projectRoot, "src", "apps", "cli", "node-compat.ts"); - -function toPosixPath(filePath: string): string { - return filePath.replace(/\\/g, "/"); -} - -const posixProjectRoot = toPosixPath(projectRoot); -const posixSrc = `${posixProjectRoot}/src`; - -function getRelativeImportPath(fromFile: string, toFile: string): string { - let rel = path.relative(path.dirname(fromFile), toFile); - rel = rel.replace(/\\/g, "/"); - if (!rel.startsWith(".") && !rel.startsWith("/")) { - rel = "./" + rel; - } - if (rel.endsWith(".ts")) { - rel = rel.slice(0, -3); - } - return rel; -} - -let modifiedFilesCount = 0; - -for (const sourceFile of project.getSourceFiles()) { - const filePath = sourceFile.getFilePath(); - const posixFilePath = toPosixPath(filePath); - - // Only process CLI source files under src/apps/cli/ - if (!posixFilePath.includes("/src/apps/cli/")) continue; - if ( - posixFilePath.endsWith("node-compat.ts") || - posixFilePath.endsWith("vite.config.ts") || - posixFilePath.endsWith(".spec.ts") || - posixFilePath.endsWith(".test.ts") || - posixFilePath.includes("/_test/") || - posixFilePath.includes("/testdeno/") || - posixFilePath.includes("/test/") - ) { - continue; - } - - const importDeclarations = sourceFile.getImportDeclarations(); - const targetImports: any[] = []; - const namedImportsToAdd: string[] = []; - - for (const impDecl of importDeclarations) { - const specifier = impDecl.getModuleSpecifierValue(); - - // Check if it's a Node.js built-in module we want to redirect - let exportedName = ""; - if (specifier === "fs/promises" || specifier === "node:fs/promises") { - exportedName = "fsPromises"; - } else if (specifier === "fs" || specifier === "node:fs") { - exportedName = "fs"; - } else if (specifier === "path" || specifier === "node:path") { - exportedName = "path"; - } else if (specifier === "node:readline/promises") { - exportedName = "readline"; - } - - if (exportedName) { - const localName = impDecl.getNamespaceImport()?.getText() || impDecl.getDefaultImport()?.getText(); - if (localName) { - targetImports.push({ impDecl, exportedName, localName }); - } - } - } - - if (targetImports.length > 0) { - console.log(`File: ${posixFilePath.slice(posixProjectRoot.length + 1)}`); - - for (const { impDecl, exportedName, localName } of targetImports) { - const { line } = sourceFile.getLineAndColumnAtPos(impDecl.getStart()); - console.log(` Line ${line}: Redirecting "${impDecl.getText()}"`); - - if (exportedName === localName) { - namedImportsToAdd.push(exportedName); - } else { - namedImportsToAdd.push(`${exportedName} as ${localName}`); - } - - if (!isDryRun) { - impDecl.remove(); - } - } - - const relImportPath = getRelativeImportPath(filePath, nodeCompatPath); - console.log(` Adding: import { ${namedImportsToAdd.join(", ")} } from "${relImportPath}"`); - - if (!isDryRun) { - sourceFile.addImportDeclaration({ - namedImports: namedImportsToAdd, - moduleSpecifier: relImportPath, - }); - } - - modifiedFilesCount++; - } -} - -console.log(`\nTotal files to modify: ${modifiedFilesCount}`); - -if (!isDryRun) { - project.saveSync(); - console.log("All changes successfully saved."); -} else { - console.log("Dry run complete. No changes were written to files."); -} diff --git a/utilsdeno/refactor-globals.ts b/utilsdeno/refactor-globals.ts index bd368dfd..ea54a1f4 100644 --- a/utilsdeno/refactor-globals.ts +++ b/utilsdeno/refactor-globals.ts @@ -32,7 +32,6 @@ function toPosixPath(filePath: string): string { const posixProjectRoot = toPosixPath(projectRoot); const posixSrc = `${posixProjectRoot}/src`; -const posixLibSrc = `${posixProjectRoot}/src/lib`; const TARGET_GLOBALS = new Set([ "setTimeout", @@ -191,7 +190,7 @@ for (const sourceFile of project.getSourceFiles()) { if (requiredImports.length > 0) { const existingImport = sourceFile.getImportDeclarations().find((imp) => { const spec = imp.getModuleSpecifierValue(); - return spec === "@lib/common/coreEnvFunctions" || spec === "@lib/common/coreEnvFunctions.ts"; + return spec === "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions" || spec === "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; }); if (existingImport) { @@ -206,7 +205,7 @@ for (const sourceFile of project.getSourceFiles()) { } else { sourceFile.addImportDeclaration({ namedImports: requiredImports, - moduleSpecifier: "@lib/common/coreEnvFunctions.ts", + moduleSpecifier: "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions", }); } } diff --git a/utilsdeno/refactor-import-utils.ts b/utilsdeno/refactor-import-utils.ts deleted file mode 100644 index 96e45dfd..00000000 --- a/utilsdeno/refactor-import-utils.ts +++ /dev/null @@ -1,187 +0,0 @@ -// Delete references to utils.ts and replace them with new imports based on the importMap. -// Use this script by running `deno run --allow-read --allow-write --allow-run refactor-import-utils.ts` from the utilsdeno directory. -import { Project } from "npm:ts-morph"; - -const isDryRun = !Deno.args.includes("--run"); - -if (isDryRun) { - console.log("=== DRY RUN MODE ==="); - console.log( - "To apply changes, run with: deno run --allow-read --allow-write --allow-run refactor-import-utils.ts --run\n" - ); -} - -// const project = new Project({ tsConfigFilePath: "../src/apps/cli/tsconfig.json" }); -const project = new Project({ tsConfigFilePath: "../tsconfig.json" }); - -const importMap = new Map(); - -const targetFiles = [ - "utils.concurrency.ts", - "utils.timer.ts", - "utils.notations.ts", - "utils.database.ts", - "utils.regexp.ts", - "utils.settings.ts", - "utils.patch.ts", - "utils.misc.ts", -]; - -// 1. Map exports from our newly created subfiles -for (const sourceFile of project.getSourceFiles()) { - const filePath = sourceFile.getFilePath(); - const fileName = sourceFile.getBaseName(); - if (filePath.includes("src/lib/src/common/") && targetFiles.includes(fileName)) { - const exports = sourceFile.getExportedDeclarations(); - for (const [name] of exports) { - const relativePath = filePath.split("src/lib/src/")[1].replace(/\.ts$/, ""); - importMap.set(name, `@lib/${relativePath}`); - } - } -} - -// 2. Map exports/imports of octagonal-wheels in utils.ts -const utilsFile = project.getSourceFile("src/lib/src/common/utils.ts"); -if (utilsFile) { - // Parse imports from octagonal-wheels - for (const imp of utilsFile.getImportDeclarations()) { - const moduleSpec = imp.getModuleSpecifierValue(); - if (moduleSpec.startsWith("octagonal-wheels")) { - for (const namedImport of imp.getNamedImports()) { - importMap.set(namedImport.getName(), moduleSpec); - } - } - } - // Parse export declarations from octagonal-wheels - for (const exp of utilsFile.getExportDeclarations()) { - const moduleSpec = exp.getModuleSpecifierValue(); - if (moduleSpec && moduleSpec.startsWith("octagonal-wheels")) { - for (const namedExport of exp.getNamedExports()) { - importMap.set(namedExport.getName(), moduleSpec); - } - } - } -} - -console.log(`Built importMap with ${importMap.size} mappings.\n`); - -let modifiedFilesCount = 0; - -// 3. Loop through all source files and replace imports -for (const sourceFile of project.getSourceFiles()) { - let fileModified = false; - const imports = sourceFile.getImportDeclarations(); - - for (const imp of imports) { - const moduleSpec = imp.getModuleSpecifierValue(); - const isUtilsImport = - moduleSpec === "@lib/common/utils" || - moduleSpec === "@lib/common/utils.ts" || - moduleSpec.endsWith("/common/utils") || - moduleSpec.endsWith("/common/utils.ts"); - - if (isUtilsImport) { - const namedImports = imp.getNamedImports(); - const defaultImport = imp.getDefaultImport(); - - const importsToReplace: Record = {}; - for (const namedImport of namedImports) { - const name = namedImport.getName(); - let newPath = importMap.get(name); - if (newPath) { - // If original ended with .ts and the new path starts with @lib, keep .ts - if (moduleSpec.endsWith(".ts") && newPath.startsWith("@lib/")) { - newPath = newPath + ".ts"; - } - if (!importsToReplace[newPath]) { - importsToReplace[newPath] = []; - } - importsToReplace[newPath].push({ - name, - newPath, - isTypeOnly: namedImport.isTypeOnly() || imp.isTypeOnly(), - }); - } - } - - if (Object.keys(importsToReplace).length > 0 || (defaultImport && importMap.has(defaultImport.getText()))) { - fileModified = true; - - console.log(`File: ${sourceFile.getFilePath().split("obsidian-livesync/")[1]}`); - console.log(` Old: ${imp.getText()}`); - } - - if (!isDryRun) { - // Apply replacements - for (const newPath in importsToReplace) { - const isTypeOnly = importsToReplace[newPath].filter((i) => i.isTypeOnly); - if (isTypeOnly.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isTypeOnly.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: true, - }); - } - const isValueImport = importsToReplace[newPath].filter((i) => !i.isTypeOnly); - if (isValueImport.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isValueImport.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: false, - }); - } - for (const { name } of importsToReplace[newPath]) { - const namedImport = imp.getNamedImports().find((ni) => ni.getName() === name); - if (namedImport) { - namedImport.remove(); - } - } - } - } else { - // In dry run, just print what it would do - for (const newPath in importsToReplace) { - const names = importsToReplace[newPath].map((i) => i.name).join(", "); - console.log(` -> Would import { ${names} } from "${newPath}"`); - } - } - - if (defaultImport) { - const name = defaultImport.getText(); - let newPath = importMap.get(name); - if (newPath) { - if (moduleSpec.endsWith(".ts") && newPath.startsWith("@lib/")) { - newPath = newPath + ".ts"; - } - if (!isDryRun) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - defaultImport: name, - moduleSpecifier: newPath, - isTypeOnly: imp.isTypeOnly(), - }); - imp.removeDefaultImport(); - } else { - console.log(` -> Would import default ${name} from "${newPath}"`); - } - } - } - - if (!isDryRun) { - if (imp.getNamedImports().length === 0 && !imp.getDefaultImport()) { - imp.remove(); - } - } - } - } - if (fileModified) { - modifiedFilesCount++; - } -} - -console.log(`\nTotal files to modify: ${modifiedFilesCount}`); - -if (!isDryRun) { - project.saveSync(); - console.log("All changes successfully saved."); -} else { - console.log("Dry run complete. No changes were written to files."); -} diff --git a/utilsdeno/refactor-imports.ts b/utilsdeno/refactor-imports.ts deleted file mode 100644 index b7d7a6a2..00000000 --- a/utilsdeno/refactor-imports.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Delete references to types.ts and replace them with new imports based on the importMap. It will also split imports if some are type-only and some are value imports. -// Use this script by running `deno run --allow-read --allow-write --allow-run refactor-imports.ts` from the utilsdeno directory. It will read all source files, find imports from types.ts, and replace them with the new paths based on the importMap. Make sure to review the changes before saving, as it will modify your source files. -import { Project } from "npm:ts-morph"; - -const isDryRun = !Deno.args.includes("--run"); - -if (isDryRun) { - console.log("=== DRY RUN MODE ==="); - console.log( - "To apply changes, run with: deno run --allow-read --allow-write --allow-run refactor-import-utils.ts --run\n" - ); -} - -// const project = new Project({ tsConfigFilePath: "../src/apps/cli/tsconfig.json" }); -const project = new Project({ tsConfigFilePath: "../tsconfig.json" }); - -const importMap = new Map(); -// Build a map of types moved out of Models. -// Under src/lib/src/common/models. -for (const sourceFile of project.getSourceFiles()) { - if (sourceFile.getFilePath().includes("src/lib/src/common/models")) { - const exports = sourceFile.getExportedDeclarations(); - for (const [name, declarations] of exports) { - for (const declaration of declarations) { - if ( - // declaration.getKindName() === "TypeAliasDeclaration" || - // declaration.getKindName() === "InterfaceDeclaration" || - // declaration.getKindName() === "EnumDeclaration" || - true - ) { - // console.log(`Found type export in ${sourceFile.getFilePath()}:`, name); - const relativePath = sourceFile.getFilePath().split("src/lib/src/")[1].replace(/\.ts$/, ""); - importMap.set(name, `@lib/${relativePath}`); - } - } - } - } -} -// Extras - -importMap.set("LOG_LEVEL_NOTICE", "@lib/common/logger"); -importMap.set("LOG_LEVEL_VERBOSE", "@lib/common/logger"); -importMap.set("LOG_LEVEL_INFO", "@lib/common/logger"); -importMap.set("LOG_LEVEL_DEBUG", "@lib/common/logger"); -importMap.set("LOG_LEVEL_URGENT", "@lib/common/logger"); -importMap.set("LOG_LEVEL", "@lib/common/logger"); -importMap.set("Logger", "@lib/common/logger"); - -// console.log("Import map:", importMap); - -// Loop through all files that import from types.ts. -for (const sourceFile of project.getSourceFiles()) { - const imports = sourceFile.getImportDeclarations(); - // if import from types.ts and the file is pointing `/lib/src/common/types.ts` (resolved), then we will check if the imported names exist in the importMap, if yes, we will replace the import path with the new path from importMap. - - for (const imp of imports) { - const moduleSpecifier = imp.getModuleSpecifierValue(); - if (moduleSpecifier.endsWith("types") || moduleSpecifier.endsWith("types.ts")) { - const filePath = sourceFile.getFilePath(); - const lineNumber = imp.getStartLineNumber(); - const resolvedModule = imp.getModuleSpecifierSourceFile(); - if (!resolvedModule || !resolvedModule.getFilePath().includes("/lib/src/common/types.ts")) { - continue; - } - - // Collect imports from types.ts. - const namedImports = imp.getNamedImports(); - const defaultImport = imp.getDefaultImport(); - console.log(`Found import in ${filePath} at line ${lineNumber}:`, { - namedImports: namedImports.map((ni) => ni.getText()), - defaultImport: defaultImport ? defaultImport.getText() : null, - }); - // Group imports by their names and generate new import paths based on the importMap - const importsToReplace: Record = {}; - for (const namedImport of namedImports) { - const name = namedImport.getName(); - const newPath = importMap.get(name); - if (newPath) { - console.log( - `Will replace import of ${name} in ${filePath} at line ${lineNumber} with new path:`, - newPath - ); - if (!importsToReplace[newPath]) { - importsToReplace[newPath] = []; - } - importsToReplace[newPath].push({ - name, - newPath, - isTypeOnly: namedImport.isTypeOnly() || imp.isTypeOnly(), - }); - } - } - - // For each import, generate a new path from importMap and replace it. - // Split the import when it needs to become multiple imports. - - for (const newPath in importsToReplace) { - // First, handle type-only imports. - const isTypeOnly = importsToReplace[newPath].filter((i) => i.isTypeOnly); - if (isTypeOnly.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isTypeOnly.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: true, - }); - } - // Then, handle non-type-only imports. - const isValueImport = importsToReplace[newPath].filter((i) => !i.isTypeOnly); - if (isValueImport.length > 0) { - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - namedImports: isValueImport.map((i) => i.name), - moduleSpecifier: newPath, - isTypeOnly: false, - }); - } - // Remove the replaced named imports from the old import. - for (const { name } of importsToReplace[newPath]) { - const namedImport = imp.getNamedImports().find((ni) => ni.getName() === name); - if (namedImport) { - namedImport.remove(); - } - } - } - // If there is also a default import and it exists in importMap, replace it too. - if (defaultImport) { - const name = defaultImport.getText(); - const newPath = importMap.get(name); - - if (newPath) { - console.log( - `Replacing default import of ${name} in ${filePath} at line ${lineNumber} with new path:`, - newPath - ); - // Add the new import statement. - sourceFile.insertImportDeclaration(imp.getChildIndex(), { - defaultImport: name, - moduleSpecifier: newPath, - isTypeOnly: imp.isTypeOnly(), - }); - // Remove the default import from the old import. - imp.removeDefaultImport(); - } - } - if (imp.getNamedImports().length === 0 && !imp.getDefaultImport()) { - // Delete the entire import statement if nothing remains. - imp.remove(); - } - } - } -} - -// Save everything at the end. -if (!isDryRun) { - project.saveSync(); -} diff --git a/utilsdeno/refactor-styles.ts b/utilsdeno/refactor-styles.ts index c9546d78..95f46d82 100644 --- a/utilsdeno/refactor-styles.ts +++ b/utilsdeno/refactor-styles.ts @@ -32,7 +32,6 @@ function toPosixPath(filePath: string): string { const posixProjectRoot = toPosixPath(projectRoot); const posixSrc = `${posixProjectRoot}/src`; -const posixLibSrc = `${posixProjectRoot}/src/lib`; function matchStyleAccess(node: Node): { element: Node; propertyName: string; isComputed: boolean } | undefined { if (Node.isPropertyAccessExpression(node)) { diff --git a/utilsdeno/types-add-ignore.ts b/utilsdeno/types-add-ignore.ts deleted file mode 100644 index 3a546525..00000000 --- a/utilsdeno/types-add-ignore.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Project, SyntaxKind } from "npm:ts-morph"; - -function processFile(filePath: string, origin: string, repoHash: string): string { - const project = new Project(); - const sourceFile = project.addSourceFileAtPath(filePath); - let updated = false; - - // 0. insert a commit hash comment at the top of the file - sourceFile.insertText(0, `// @ts-nocheck\n// REPO: ${origin} Commit hash: ${repoHash}\n`); - updated = true; - - // 1. Replacements for Uint8Array and DataView - let sourceText = sourceFile.getFullText(); - if (sourceText.includes("Uint8Array") || sourceText.includes("DataView")) { - sourceText = sourceText.replace(/Uint8Array/g, "Uint8Array"); - sourceText = sourceText.replace(/DataView/g, "DataView"); - sourceFile.replaceWithText(sourceText); - updated = true; - } - - // 2. Remove EventEmitter import from "events" and declare class EventEmitter inline - const imports = sourceFile.getImportDeclarations(); - imports.forEach((importDecl) => { - if (importDecl.getModuleSpecifierValue() === "events") { - const defaultImport = importDecl.getDefaultImport(); - if (defaultImport && defaultImport.getText() === "EventEmitter") { - importDecl.remove(); - sourceFile.addClass({ - name: "EventEmitter", - isExported: false, - methods: [ - { - name: "on", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "once", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "off", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "emit", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "args", isRestParameter: true, type: "any[]" }, - ], - returnType: "boolean", - }, - { - name: "addListener", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "removeListener", - parameters: [ - { name: "event", type: "string | symbol" }, - { name: "listener", type: "(...args: any[]) => void" }, - ], - returnType: "this", - }, - { - name: "removeAllListeners", - parameters: [{ name: "event", isOptional: true, type: "string | symbol" }], - returnType: "this", - }, - ], - }); - updated = true; - } - } - }); - - // 3. Collect targets for inline disable comments - const targetAnyLines = new Set(); - const targetEmptyObjectLines = new Set(); - const targetEmptyInterfaceLines = new Set(); - const targetDuplicateEnumLines = new Set(); - - // 3.1. 'any' type nodes - const anyTypeNodes = sourceFile.getDescendantsOfKind(SyntaxKind.AnyKeyword); - anyTypeNodes.forEach((anyNode: any) => { - const { line } = sourceFile.getLineAndColumnAtPos(anyNode.getStart()); - targetAnyLines.add(line - 1); - }); - - // 3.2. Empty object type literals {} - const typeLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.TypeLiteral); - typeLiterals.forEach((node) => { - if (node.getMembers().length === 0) { - const { line } = sourceFile.getLineAndColumnAtPos(node.getStart()); - targetEmptyObjectLines.add(line - 1); - } - }); - - // 3.3. Empty interfaces - const interfaces = sourceFile.getInterfaces(); - interfaces.forEach((node) => { - if (node.getMembers().length === 0) { - const { line } = sourceFile.getLineAndColumnAtPos(node.getStart()); - targetEmptyInterfaceLines.add(line - 1); - } - }); - - // 3.4. Duplicate enum member values - const enums = sourceFile.getEnums(); - enums.forEach((enumDecl) => { - const values = new Set(); - enumDecl.getMembers().forEach((member) => { - const initValue = member.getInitializer()?.getText(); - if (initValue) { - if (values.has(initValue)) { - const { line } = sourceFile.getLineAndColumnAtPos(member.getStart()); - targetDuplicateEnumLines.add(line - 1); - } else { - values.add(initValue); - } - } - }); - }); - - // 4. Inject ignore comments line by line - const finalSourceText = sourceFile.getFullText(); - const lineBreak = finalSourceText.includes("\r\n") ? "\r\n" : "\n"; - const lines = finalSourceText.split(/\r?\n/); - - // 4.1. Add inline disable to lines that contain 'any' - for (const lineIndex of targetAnyLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line @typescript-eslint/no-explicit-any")) continue; - lines[lineIndex] = `${line} // eslint-disable-line @typescript-eslint/no-explicit-any -- Only type declaration`; - updated = true; - } - - // 4.2. Add inline disable to lines that contain empty object {} - for (const lineIndex of targetEmptyObjectLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line") || line.includes("eslint-disable-next-line")) continue; - lines[lineIndex] = - `${line} // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/ban-types -- Empty object type`; - updated = true; - } - - // 4.3. Add inline disable to lines that contain empty interface - for (const lineIndex of targetEmptyInterfaceLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line") || line.includes("eslint-disable-next-line")) continue; - lines[lineIndex] = - `${line} // eslint-disable-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-empty-interface -- Empty interface`; - updated = true; - } - - // 4.4. Add inline disable to lines with duplicate enums - for (const lineIndex of targetDuplicateEnumLines) { - const line = lines[lineIndex]; - if (!line) continue; - if (line.includes("eslint-disable-line") || line.includes("eslint-disable-next-line")) continue; - lines[lineIndex] = - `${line} // eslint-disable-line @typescript-eslint/no-duplicate-enum-values -- Duplicate enum value`; - updated = true; - } - - const updatedSourceText = lines.join(lineBreak); - if (updated) { - console.log(`Processed file: ${filePath}`); - } - return updatedSourceText; -} - -const targetDir = `./_types`; - -async function processDir(dirPath: string) { - for await (const entry of Deno.readDir(dirPath)) { - if (entry.isDirectory) { - await processDir(`${dirPath}/${entry.name}`); - } - if (entry.isFile && entry.name.endsWith(".d.ts")) { - const filePath = `${dirPath}/${entry.name}`; - console.log(`Processing: ${filePath}`); - const updatedContent = processFile(filePath, repoRemoteOriginStr, gitCommitHashStr); - // Write the file. To revert, regenerate it with npm run lib:build:types. - await Deno.writeTextFile(filePath, updatedContent); - } - } -} - -const subDir = "./src/lib/"; -const repoRemoteOrigins = new Deno.Command("git", { - args: ["remote", "get-url", "origin"], - cwd: subDir, - stdout: "piped", -}).outputSync().stdout; -const repoRemoteOriginStr = new TextDecoder().decode(repoRemoteOrigins).trim(); -console.log(`STAMP: Git remote origin: ${repoRemoteOriginStr}`); -const gitCommitHashSub = new Deno.Command("git", { - args: ["rev-parse", "--short", "HEAD"], - cwd: subDir, - stdout: "piped", -}).outputSync().stdout; -const gitCommitHashStr = new TextDecoder().decode(gitCommitHashSub).trim(); -console.log(`STAMP: Git commit hash: ${gitCommitHashStr}`); -await processDir(targetDir); diff --git a/vite.config.ts b/vite.config.ts index a7c47aee..8b009f0a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -130,7 +130,6 @@ export default defineConfig(({ mode }) => { resolve: { alias: { "@": path.resolve(__dirname, "./src"), - "@lib": path.resolve(__dirname, "./src/lib/src"), src: path.resolve(__dirname, "./src"), }, }, diff --git a/vitest.config.common.ts b/vitest.config.common.ts index 73f023bf..0a7d7375 100644 --- a/vitest.config.common.ts +++ b/vitest.config.common.ts @@ -96,7 +96,6 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), - "@lib": path.resolve(__dirname, "./src/lib/src"), src: path.resolve(__dirname, "./src"), }, }, diff --git a/vitest.config.rpc-unit.ts b/vitest.config.rpc-unit.ts deleted file mode 100644 index d3c175e7..00000000 --- a/vitest.config.rpc-unit.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @file vitest.config.rpc-unit.ts - * @description Configuration for running RPC-specific unit tests (such as RpcRoom and transport layers) in Node.js, - * enforcing coverage thresholds on the RPC sub-module. - * This can be run manually to verify RPC-specific coverage, or is matched by the glob patterns in `npm run test:unit`. - */ -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, - }, - }, - }, - }) -); diff --git a/vitest.config.ts b/vitest.config.ts index a62992d3..6b632b72 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -38,8 +38,8 @@ export default mergeConfig( // environment: "browser", include: ["test/**/*.test.ts"], coverage: { - include: ["src/**/*.ts", "src/lib/src/**/*.ts", "src/**/*.svelte"], - exclude: ["**/*.test.ts", "src/lib/**"], + include: ["src/**/*.ts", "src/**/*.svelte"], + exclude: ["**/*.test.ts"], provider: "v8", reporter: ["text", "json", "html"], // ignoreEmptyLines: true, diff --git a/vitest.config.unit.ts b/vitest.config.unit.ts index 3012a075..2b1b243e 100644 --- a/vitest.config.unit.ts +++ b/vitest.config.unit.ts @@ -20,7 +20,7 @@ export default mergeConfig( // maxConcurrency: 2, name: "unit-tests", include: ["**/*unit.test.ts", "**/*.unit.spec.ts"], - exclude: ["test/**", "src/apps/**/testdeno/**"], + exclude: ["node_modules/**", "test/**", "src/apps/**/testdeno/**"], coverage: { include: ["src/**/*.ts"], exclude: [ @@ -28,12 +28,8 @@ export default mergeConfig( "**/*unit.test.ts", "**/*.unit.spec.ts", "test/**", - "src/lib/**/*.test.ts", "**/_*", "src/apps/**/testdeno/**", - // "src/apps/**", - // "src/cli/**", - "src/lib/src/cli/**", "**/*_obsolete.ts", ...importOnlyFiles, ], From 14da32cbab2790741241ebc629716a0c78312dff Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 17 Jul 2026 11:32:41 +0000 Subject: [PATCH 056/170] chore: refresh Commonlib package proof --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 40be13dc..b07c760e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4950,7 +4950,7 @@ "node_modules/@vrtmrz/livesync-commonlib": { "version": "0.1.0-package-proof.8", "resolved": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz", - "integrity": "sha512-XiS2BJGYQtBgTdLf9G577q+65wXKjdjFIzsLA1yWhSKgg+nyh/zREEjdAE0y07QyA0Sq8PiIruZB7cYTpqKjvQ==", + "integrity": "sha512-nzoyrszHxY9fLmYm5hsF14ZP3mjkKDcPngE8fJY9mWTFHQPtn0k7owlGQoP/pdqcVW2RowQXbWxkMwsBEn23bw==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", From d5754f1f5424253efa2e5f35979e6b41d0be3de1 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 17 Jul 2026 11:58:08 +0000 Subject: [PATCH 057/170] docs: keep package proof evidence durable --- docs/adr/2026_07_common_library_package_boundary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/2026_07_common_library_package_boundary.md b/docs/adr/2026_07_common_library_package_boundary.md index bcdddb36..61d5f177 100644 --- a/docs/adr/2026_07_common_library_package_boundary.md +++ b/docs/adr/2026_07_common_library_package_boundary.md @@ -235,7 +235,7 @@ Every retained barrel must correspond to an explicit `exports` entry, list named ## Implementation Proof -The local proof builds Commonlib `0.1.0-package-proof.8` as one compiled ESM package with a small root, `context`, `browser`, `node`, and `rpc` entries, plus 118 explicit compatibility exports required by the current downstream migration. It publishes neither raw TypeScript nor Svelte source. The reviewed tarball has integrity `sha512-XiS2BJGYQtBgTdLf9G577q+65wXKjdjFIzsLA1yWhSKgg+nyh/zREEjdAE0y07QyA0Sq8PiIruZB7cYTpqKjvQ==`. It can be installed into a clean consumer, imported in Node, type-checked from declarations, and bundled independently for browser context, browser storage, browser services, and workers. +The local package proof builds Commonlib as one compiled ESM package with a small root, `context`, `browser`, `node`, and `rpc` entries, plus the explicit compatibility exports required by the current downstream migration. It publishes neither raw TypeScript nor Svelte source. The generated tarball can be installed into a clean consumer, imported in Node, type-checked from declarations, and bundled independently for browser context, browser storage, browser services, and workers. Release validation records the immutable registry version and checksum separately. The proof found and fixed three boundary defects which source-alias consumption had hidden: compiled JSON imports required explicit output extensions, precompiled Svelte output could not safely be treated as source by the downstream Svelte pipeline, and Vite's default client conditions selected Commonlib's browser worker while building the Node CLI. Packed-consumer regressions cover the first two. The CLI now uses Vite's server conditions and treats every Node built-in reported by Commonlib's Node entry as external; the built CLI is exercised through Deno E2E. Importing root or context also no longer patches DOM prototypes, and translator injection prevents the context entry from loading the complete language catalogue. From 298738fc67c7c7d2cc93c6f6e9d6ec0aed4bd7f7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 17 Jul 2026 13:28:58 +0000 Subject: [PATCH 058/170] fix: keep packaged dialogs inside mobile safe areas --- package-lock.json | 16 +- package.json | 5 +- .../services/LiveSyncUI/DialogHost.svelte | 12 + src/modules/services/SvelteDialogObsidian.ts | 5 + test/e2e-obsidian/README.md | 10 +- test/e2e-obsidian/runner/session.ts | 2 + test/e2e-obsidian/runner/ui.ts | 38 +++ test/e2e-obsidian/scripts/dialog-mounts.ts | 297 ++++++++++++++++++ .../scripts/hidden-file-snippet-sync.ts | 5 +- test/e2e-obsidian/scripts/local-suite.ts | 1 + 10 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 test/e2e-obsidian/scripts/dialog-mounts.ts diff --git a/package-lock.json b/package-lock.json index b07c760e..7d19d734 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@trystero-p2p/nostr": "^0.24.0", - "@vrtmrz/livesync-commonlib": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.0", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", @@ -59,7 +59,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.1.0", + "@vrtmrz/obsidian-test-session": "0.2.0", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4948,9 +4948,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.0-package-proof.8", - "resolved": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz", - "integrity": "sha512-nzoyrszHxY9fLmYm5hsF14ZP3mjkKDcPngE8fJY9mWTFHQPtn0k7owlGQoP/pdqcVW2RowQXbWxkMwsBEn23bw==", + "version": "0.1.0-rc.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.0.tgz", + "integrity": "sha512-Aa+xC7bG78M7H8leIMmZdTRzA0Xh+ZgmiZJv0pXjy5OdZTPDu1H9M0eRcs5xL2pNBiOLKs0CKiEBql7JgunnQg==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", @@ -4995,9 +4995,9 @@ } }, "node_modules/@vrtmrz/obsidian-test-session": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz", - "integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.0.tgz", + "integrity": "sha512-Mnw1wide/KddJHfq6ulpwqdPLV8rIywsY013m9nQvn2GFm7XnBAqZjZpVEolMJH/mqbLlWvU3PhL1UWJLZsIJA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index f7fef73a..6dbfced5 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts", "test:e2e:obsidian:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts", "test:e2e:obsidian:smoke": "tsx test/e2e-obsidian/scripts/smoke.ts", + "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", "test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts", "test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts", "test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts", @@ -101,7 +102,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.1.0", + "@vrtmrz/obsidian-test-session": "0.2.0", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -151,7 +152,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@trystero-p2p/nostr": "^0.24.0", - "@vrtmrz/livesync-commonlib": "file:../livesync-commonlib-package-boundary/artifacts/vrtmrz-livesync-commonlib-0.1.0-package-proof.8.tgz", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.0", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", "idb": "^8.0.3", diff --git a/src/modules/services/LiveSyncUI/DialogHost.svelte b/src/modules/services/LiveSyncUI/DialogHost.svelte index 93495b2d..5c5483e2 100644 --- a/src/modules/services/LiveSyncUI/DialogHost.svelte +++ b/src/modules/services/LiveSyncUI/DialogHost.svelte @@ -42,6 +42,18 @@ \ No newline at end of file + diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte index af42fc80..308d777f 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPane.svelte @@ -33,16 +33,17 @@ let replicatingPeerId = $state(null); let communicatingUntil = $state>({}); const COMMUNICATION_HOLD_MS = 2500; - let syncOnReplicationSetting = $state(core.services.setting.currentSettings()?.P2P_SyncOnReplication ?? ""); + // Later setting changes arrive through EVENT_SETTING_SAVED; these values only seed local state at mount time. + const readCurrentSettings = () => core.services.setting.currentSettings(); + const initialSettings = readCurrentSettings(); + let syncOnReplicationSetting = $state(initialSettings?.P2P_SyncOnReplication ?? ""); type P2PRemoteOption = { id: string; name: string; roomSuffix: string; }; let p2pRemoteOptions = $state([]); - let selectedP2PRemoteConfigurationId = $state( - core.services.setting.currentSettings()?.P2P_ActiveRemoteConfigurationId ?? "" - ); + let selectedP2PRemoteConfigurationId = $state(initialSettings?.P2P_ActiveRemoteConfigurationId ?? ""); let selectingP2PRemote = $state(false); function addToList(item: string, list: string): string { diff --git a/src/modules/services/LiveSyncUI/DialogHost.svelte b/src/modules/services/LiveSyncUI/DialogHost.svelte index 5c5483e2..a60d9318 100644 --- a/src/modules/services/LiveSyncUI/DialogHost.svelte +++ b/src/modules/services/LiveSyncUI/DialogHost.svelte @@ -12,33 +12,33 @@ // */ // onSetupContext?(props: DialogSvelteComponentBaseProps): void; // }; - const { setTitle, closeDialog, setResult, mountComponent, getInitialData, onSetupContext }: DialogHostProps = - $props(); + const props: DialogHostProps = $props(); const contextProps = { - setTitle, - closeDialog, - setResult, - getInitialData, - } satisfies DialogSvelteComponentBaseProps + setTitle: (title: string) => props.setTitle(title), + closeDialog: () => props.closeDialog(), + setResult: (result: any) => props.setResult(result), + getInitialData: () => props.getInitialData?.(), + } satisfies DialogSvelteComponentBaseProps; - // Call the onSetupContext function to setup the dialog context - onSetupContext?.(contextProps); + // Context must be established during component initialisation. The callbacks retain live access to the host props. + const setupContext = () => props.onSetupContext?.(contextProps); + setupContext(); /** * Wrapper around setResult to also close the dialog * @param result */ const setResultWrapper = (result: any) => { - setResult(result); - closeDialog(); + props.setResult(result); + props.closeDialog(); }; - const Component = mountComponent; + const Component = $derived(props.mountComponent); let thisElement: HTMLElement;
    - +
    - - -

    LiveSync WebApp E2E

    -

    This page is used by Playwright tests only. window.livesyncTest is exposed by the script below.

    - -
    Loading…
    - - - diff --git a/src/apps/webapp/test/e2e.spec.ts b/src/apps/webapp/test/e2e.spec.ts deleted file mode 100644 index 70c55094..00000000 --- a/src/apps/webapp/test/e2e.spec.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * WebApp E2E tests – two-vault scenarios. - * - * Each vault (A and B) runs in its own browser context so that JavaScript - * global state (including Trystero's global signalling tables) is fully - * isolated. The two vaults communicate only through the shared remote - * CouchDB database. - * - * Vault storage is OPFS-backed – no file-picker interaction needed. - * - * Prerequisites: - * - A reachable CouchDB instance whose connection details are in .test.env - * (read automatically by playwright.config.ts). - * - * How to run: - * cd src/apps/webapp && npm run test:e2e - */ - -import { test, expect, type BrowserContext, type Page, type TestInfo } from "@playwright/test"; -import type { LiveSyncTestAPI } from "@/apps/webapp/test-entry"; -import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// --------------------------------------------------------------------------- -// Settings helpers -// --------------------------------------------------------------------------- - -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`Missing required env variable: ${name}`); - return v; -} - -async function ensureCouchDbDatabase(uri: string, user: string, pass: string, dbName: string): Promise { - const base = uri.replace(/\/+$/, ""); - const dbUrl = `${base}/${encodeURIComponent(dbName)}`; - const auth = Buffer.from(`${user}:${pass}`, "utf-8").toString("base64"); - const response = await fetch(dbUrl, { - method: "PUT", - headers: { - Authorization: `Basic ${auth}`, - }, - }); - - // 201: created, 202: accepted, 412: already exists - if (response.status === 201 || response.status === 202 || response.status === 412) { - return; - } - - const body = await response.text().catch(() => ""); - throw new Error(`Failed to ensure CouchDB database (${response.status}): ${body}`); -} - -function buildSettings(dbName: string): Record { - return { - // Remote database (shared between A and B – this is the replication target) - couchDB_URI: requireEnv("hostname").replace(/\/+$/, ""), - couchDB_USER: process.env["username"] ?? "", - couchDB_PASSWORD: process.env["password"] ?? "", - couchDB_DBNAME: dbName, - - // Core behaviour - isConfigured: true, - liveSync: false, - syncOnSave: false, - syncOnStart: false, - periodicReplication: false, - gcDelay: 0, - savingDelay: 0, - notifyThresholdOfRemoteStorageSize: 0, - - // Encryption off for test simplicity - encrypt: false, - - // Disable plugin/hidden-file sync (not needed in webapp) - usePluginSync: false, - autoSweepPlugins: false, - autoSweepPluginsPeriodic: false, - - //Auto accept perr - P2P_AutoAcceptingPeers: "~.*", - }; -} - -// --------------------------------------------------------------------------- -// Test-page helpers -// --------------------------------------------------------------------------- - -/** Navigate to the test entry page and wait for `window.livesyncTest`. */ -async function openTestPage(ctx: BrowserContext): Promise { - const page = await ctx.newPage(); - await page.goto("/test.html"); - await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 }); - return page; -} - -/** Type-safe wrapper – calls `window.livesyncTest.(...args)` in the page. */ -async function call( - page: Page, - method: M, - ...args: Parameters -): Promise>> { - const invoke = () => - page.evaluate(([m, a]) => (window as any).livesyncTest[m](...a), [method, args] as [ - string, - unknown[], - ]) as Promise>>; - - try { - return await invoke(); - } catch (ex: any) { - const message = String(ex?.message ?? ex); - // Some startup flows may trigger one page reload; recover once. - if ( - message.includes("Execution context was destroyed") || - message.includes("Most likely the page has been closed") - ) { - await page.waitForFunction(() => !!(window as any).livesyncTest, { timeout: 20_000 }); - return await invoke(); - } - throw ex; - } -} - -async function dumpCoverage(page: Page | undefined, label: string, testInfo: TestInfo): Promise { - if (!process.env.PW_COVERAGE || !page || page.isClosed()) { - return; - } - const cov = await page - .evaluate(() => { - const data = (window as any).__coverage__; - if (!data) return null; - // Reset between tests to avoid runaway accumulation. - (window as any).__coverage__ = {}; - return data; - }) - .catch((): null => null); - if (!cov) return; - if (typeof cov === "object" && Object.keys(cov as Record).length === 0) { - return; - } - - const outDir = path.resolve(__dirname, "../.nyc_output"); - fs.mkdirSync(outDir, { recursive: true }); - const name = `${testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${label}.json`; - fs.writeFileSync(path.join(outDir, name), JSON.stringify(cov), "utf-8"); -} - -// --------------------------------------------------------------------------- -// Two-vault E2E suite -// --------------------------------------------------------------------------- - -test.describe("WebApp two-vault E2E", () => { - let ctxA: BrowserContext; - let ctxB: BrowserContext; - let pageA: Page; - let pageB: Page; - - const DB_SUFFIX = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const dbName = `${requireEnv("dbname")}-${DB_SUFFIX}`; - const settings = buildSettings(dbName); - - test.beforeAll(async ({ browser }) => { - await ensureCouchDbDatabase( - String(settings.couchDB_URI ?? ""), - String(settings.couchDB_USER ?? ""), - String(settings.couchDB_PASSWORD ?? ""), - dbName - ); - - // Open Vault A and Vault B in completely separate browser contexts. - // Each context has its own JS runtime, IndexedDB and OPFS root, so - // Trystero global state and PouchDB instance names cannot collide. - ctxA = await browser.newContext(); - ctxB = await browser.newContext(); - - pageA = await openTestPage(ctxA); - pageB = await openTestPage(ctxB); - - await call(pageA, "init", "testvault_a", settings as any); - await call(pageB, "init", "testvault_b", settings as any); - }); - - test.afterAll(async () => { - await call(pageA, "shutdown").catch(() => {}); - await call(pageB, "shutdown").catch(() => {}); - await ctxA.close(); - await ctxB.close(); - }); - - test.afterEach(async ({}, testInfo) => { - await dumpCoverage(pageA, "vaultA", testInfo); - await dumpCoverage(pageB, "vaultB", testInfo); - }); - - // ----------------------------------------------------------------------- - // Case 1: Vault A writes a file and can read its metadata back from the - // local database (no replication yet). - // ----------------------------------------------------------------------- - test("Case 1: A writes a file and can get its info", async () => { - const FILE = "e2e/case1-a-only.md"; - const CONTENT = "hello from vault A"; - - const ok = await call(pageA, "putFile", FILE, CONTENT); - expect(ok).toBe(true); - - const info = await call(pageA, "getInfo", FILE); - expect(info).not.toBeNull(); - expect(info!.path).toBe(FILE); - expect(info!.revision).toBeTruthy(); - expect(info!.conflicts).toHaveLength(0); - }); - - // ----------------------------------------------------------------------- - // Case 2: Vault A writes a file, both vaults replicate, and Vault B ends - // up with the file in its local database. - // ----------------------------------------------------------------------- - test("Case 2: A writes a file, both replicate, B receives the file", async () => { - const FILE = "e2e/case2-sync.md"; - const CONTENT = "content from A – should appear in B"; - - await call(pageA, "putFile", FILE, CONTENT); - - // A pushes to remote, B pulls from remote. - await call(pageA, "replicate"); - await call(pageB, "replicate"); - - const infoB = await call(pageB, "getInfo", FILE); - expect(infoB).not.toBeNull(); - expect(infoB!.path).toBe(FILE); - }); - - // ----------------------------------------------------------------------- - // Case 3: Vault A deletes the file it synced in case 2. After both - // vaults replicate, Vault B no longer sees the file. - // ----------------------------------------------------------------------- - test("Case 3: A deletes the file, both replicate, B no longer sees it", async () => { - // This test depends on Case 2 having put e2e/case2-sync.md into both vaults. - const FILE = "e2e/case2-sync.md"; - - await call(pageA, "deleteFile", FILE); - - await call(pageA, "replicate"); - await call(pageB, "replicate"); - - const infoB = await call(pageB, "getInfo", FILE); - // The file should be gone (null means not found or deleted). - expect(infoB).toBeNull(); - }); - - // ----------------------------------------------------------------------- - // Case 4: A and B each independently edit the same file that was already - // synced. After both vaults replicate the editing cycle, both - // vaults report a conflict on that file. - // ----------------------------------------------------------------------- - test("Case 4: concurrent edits from A and B produce a conflict on both sides", async () => { - const FILE = "e2e/case4-conflict.md"; - - // 1) Write a baseline and synchronise so both vaults start from the - // same revision. - await call(pageA, "putFile", FILE, "base content"); - await call(pageA, "replicate"); - await call(pageB, "replicate"); - - // Confirm B has the base file with no conflicts yet. - const baseInfoB = await call(pageB, "getInfo", FILE); - expect(baseInfoB).not.toBeNull(); - expect(baseInfoB!.conflicts).toHaveLength(0); - - // 2) Both vaults write diverging content without syncing in between – - // this creates two competing revisions. - await call(pageA, "putFile", FILE, "content from A (conflict side)"); - await call(pageB, "putFile", FILE, "content from B (conflict side)"); - - // 3) Run replication on both sides. The order mirrors the pattern - // from the CLI two-vault tests (A → remote → B → remote → A). - await call(pageA, "replicate"); - await call(pageB, "replicate"); - await call(pageA, "replicate"); // re-check from A to pick up B's revision - - // 4) At least one side must report a conflict. - const hasConflictA = await call(pageA, "hasConflict", FILE); - const hasConflictB = await call(pageB, "hasConflict", FILE); - - expect( - hasConflictA || hasConflictB, - "Expected a conflict to appear on vault A or vault B after diverging edits" - ).toBe(true); - }); -}); diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index c0d8d2e5..8857acf9 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -import istanbul from "vite-plugin-istanbul"; import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "../../.."); @@ -15,35 +14,9 @@ function readVersion(filePath: string): string | undefined { const packageVersion = readVersion(path.resolve(repoRoot, "package.json")); const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json")); -const enableCoverage = process.env.PW_COVERAGE === "1"; // https://vite.dev/config/ export default defineConfig({ - plugins: [ - svelte(), - ...(enableCoverage - ? [ - istanbul({ - cwd: repoRoot, - include: ["src/**/*.ts", "src/**/*.svelte"], - exclude: [ - "node_modules", - "dist", - "test", - "coverage", - "src/apps/webapp/test/**", - "playwright.config.ts", - "vite.config.ts", - "**/*.spec.ts", - "**/*.test.ts", - ], - extension: [".js", ".ts", ".svelte"], - requireEnv: false, - cypress: false, - checkProd: false, - }), - ] - : []), - ], + plugins: [svelte()], resolve: { alias: { "@": path.resolve(__dirname, "../../"), @@ -55,12 +28,9 @@ export default defineConfig({ outDir: "dist", emptyOutDir: true, rollupOptions: { - // test.html is used by the Playwright dev-server; include it here - // so the production build doesn't emit warnings about unused inputs. input: { index: path.resolve(__dirname, "index.html"), webapp: path.resolve(__dirname, "webapp.html"), - test: path.resolve(__dirname, "test.html"), }, external: ["crypto"], }, diff --git a/src/common/databaseCompatibility.unit.spec.ts b/src/common/databaseCompatibility.unit.spec.ts index a94e6b5b..33f5a14e 100644 --- a/src/common/databaseCompatibility.unit.spec.ts +++ b/src/common/databaseCompatibility.unit.spec.ts @@ -139,7 +139,7 @@ describe("database compatibility evaluation", () => { }); }); - it("retains an existing legacy review when no structured reason can be reconstructed", () => { + it("compatibility: retains an earlier unstructured review when no structured reason can be reconstructed", () => { const result = evaluateCompatibilityPause({ acknowledgedVersion: "12", currentVersion: 12, @@ -159,7 +159,7 @@ describe("database compatibility evaluation", () => { }); }); - it("scopes the legacy marker to the Vault", () => { + it("compatibility: scopes the earlier review marker to the Vault", () => { expect(legacyDatabaseCompatibilityVersionKey("Example Vault")).toBe("obsidian-live-sync-verExample Vault"); }); }); diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts index b8f38cc7..66d00a29 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts @@ -134,7 +134,7 @@ describe("HiddenFileSync configuration-change notices", () => { expect(progress.done).toHaveBeenCalledOnce(); }); - it("does not surround the initialisation progress with separate gathering and restart Notices", async () => { + it("retirement guard: does not restore separate gathering and restart Notices", async () => { vi.mocked(configureHiddenFileSyncMode).mockImplementation(async (_mode, handlers) => { await handlers.enable(); await handlers.initialise("safe"); diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts index 0432d637..6bcef72a 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts @@ -88,7 +88,7 @@ describe("LocalDatabaseMaintenance prerequisites", () => { expect(applyPartial).not.toHaveBeenCalled(); }); - it("does not treat the obsolete fixed-revision key as a maintenance prerequisite", async () => { + it("retirement guard: ignores the obsolete fixed-revision key as a maintenance prerequisite", async () => { const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({ doNotUseFixedRevisionForChunks: false, readChunksOnline: false, diff --git a/src/modules/core/ModuleReplicator.ts b/src/modules/core/ModuleReplicator.ts index 57304ce0..7479df4c 100644 --- a/src/modules/core/ModuleReplicator.ts +++ b/src/modules/core/ModuleReplicator.ts @@ -145,7 +145,8 @@ export class ModuleReplicator extends AbstractModule { } /** - * obsolete method. No longer maintained and will be removed in the future. + * Reconciles local chunks when an older IndexedDB client reports that the remote database was cleaned. + * This compatibility path remains reachable while those clients can still set `remoteCleaned`. * @deprecated v0.24.17 * @param showMessage If true, show message to the user. */ diff --git a/src/modules/core/ModuleReplicator.unit.spec.ts b/src/modules/core/ModuleReplicator.unit.spec.ts index b4849707..d98e1fc5 100644 --- a/src/modules/core/ModuleReplicator.unit.spec.ts +++ b/src/modules/core/ModuleReplicator.unit.spec.ts @@ -116,7 +116,7 @@ describe("ModuleReplicator", () => { }); }); -describe("ModuleReplicator legacy cleanup", () => { +describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", () => { it("keeps its finite replication and balancing work inside the shared activity boundary", async () => { const activityFinished = vi.fn(); const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => { diff --git a/src/modules/features/SetupManager.unit.spec.ts b/src/modules/features/SetupManager.unit.spec.ts index fdbc8f57..b9eb45b6 100644 --- a/src/modules/features/SetupManager.unit.spec.ts +++ b/src/modules/features/SetupManager.unit.spec.ts @@ -147,7 +147,7 @@ describe("SetupManager", () => { expect(configureManually).toHaveBeenCalledWith(createNewVaultSettings(), UserMode.NewUser); }); - it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => { + it("compatibility: normalises imported flat remote settings from a Setup URI before applying", async () => { const { manager, setting, dialogManager } = createSetupManager(); dialogManager.openWithExplicitCancel .mockResolvedValueOnce(createLegacyRemoteSetting()) @@ -162,7 +162,7 @@ describe("SetupManager", () => { expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb"); }); - it("decodeQR should normalise imported legacy remote settings before applying", async () => { + it("compatibility: normalises imported flat remote settings from QR data before applying", async () => { const { manager, setting, dialogManager } = createSetupManager(); vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting()); dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user"); diff --git a/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 20170e8d..214cad2e 100644 --- a/src/serviceFeatures/redFlag.simpleFetch.ts +++ b/src/serviceFeatures/redFlag.simpleFetch.ts @@ -14,7 +14,7 @@ import { adjustSettingToRemoteIfNeeded, processVaultInitialisation } from "./red export const SIMPLE_FETCH_STAGE1_REMOTE_WINS = "Overwrite all with remote files"; export const SIMPLE_FETCH_STAGE1_NEWER_WINS = "Compare time and take newer"; -export const SIMPLE_FETCH_STAGE1_LEGACY = "Use the detailed flow"; +export const SIMPLE_FETCH_STAGE1_DETAILED = "Use the detailed flow"; export const SIMPLE_FETCH_STAGE1_CANCEL = "Cancel"; export const SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE = "Keep local files even if not on remote"; @@ -27,8 +27,8 @@ export const STAGE2_ABORT = "Cancel all and reboot"; const SIMPLE_FETCH_MODE_KEY = "simple-fetch-mode"; function buildSimpleFetchResult(stage1: string, stage2?: string) { - if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) { - return { mode: "legacy", options: {} }; + if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) { + return { mode: "detailed", options: {} }; } if (stage1 === SIMPLE_FETCH_STAGE1_REMOTE_WINS && stage2) { if (![SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL, SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE].includes(stage2)) { @@ -100,7 +100,7 @@ Firstly, how shall we handle the data retrieved from this remote source? - **${SIMPLE_FETCH_STAGE1_REMOTE_WINS}**: Remote data is the source of truth. If you are new to using Self-hosted LiveSync. This option may be easiest to understand and get started with. It will overwrite all your local files with the remote data, so please make sure you have a backup if there is any important data in your vault. -- **${SIMPLE_FETCH_STAGE1_LEGACY}**: Opens the detailed setup wizard. +- **${SIMPLE_FETCH_STAGE1_DETAILED}**: Opens the detailed setup wizard. If you want to have more control over the synchronisation process, or want to review the changes before applying, you can choose this option to use the detailed flow. `; const stage1 = await host.services.UI.confirm.confirmWithMessage( @@ -109,7 +109,7 @@ Firstly, how shall we handle the data retrieved from this remote source? [ SIMPLE_FETCH_STAGE1_NEWER_WINS, SIMPLE_FETCH_STAGE1_REMOTE_WINS, - SIMPLE_FETCH_STAGE1_LEGACY, + SIMPLE_FETCH_STAGE1_DETAILED, SIMPLE_FETCH_STAGE1_CANCEL, ], SIMPLE_FETCH_STAGE1_NEWER_WINS, @@ -118,7 +118,7 @@ Firstly, how shall we handle the data retrieved from this remote source? if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled"; - if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) { + if (stage1 === SIMPLE_FETCH_STAGE1_DETAILED) { return buildSimpleFetchResult(stage1)!; } @@ -204,8 +204,8 @@ export async function askAndPerformFastSetupOnScheduledFetchAll( host.services.appLifecycle.performRestart(); return false; } - if (result.mode === "legacy") { - return undefined; // Let the legacy flow handle it. + if (result.mode === "detailed") { + return undefined; // Let the detailed setup flow handle it. } return await processVaultInitialisation(host, log, async () => { diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index 9760622b..de8d998c 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -29,7 +29,7 @@ import { synchroniseAllFilesBetweenDBandStorage, } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { - SIMPLE_FETCH_STAGE1_LEGACY, + SIMPLE_FETCH_STAGE1_DETAILED, SIMPLE_FETCH_STAGE1_NEWER_WINS, SIMPLE_FETCH_STAGE1_REMOTE_WINS, SIMPLE_FETCH_STAGE2_NEWER_CLEANUP, @@ -476,12 +476,12 @@ describe("Red Flag Feature", () => { // but we can verify rebuilder was called. }); - it("should restore legacy fetch flow when requested", async () => { + it("opens the detailed Fetch flow when requested", async () => { const host = createHostMock(); const log = createLoggerMock(); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", backup: "backup_skipped", @@ -665,11 +665,11 @@ describe("Red Flag Feature", () => { await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled"); }); - it("should return legacy mode when selected", async () => { + it("selects the detailed Fetch flow", async () => { const host = createHostMock(); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); - await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "legacy", options: {} }); + await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "detailed", options: {} }); }); it("should return remote-only with keep-local option", async () => { @@ -818,12 +818,12 @@ describe("Red Flag Feature", () => { expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled(); }); - it("should return undefined when legacy mode is selected", async () => { + it("leaves the detailed Fetch flow to its existing handler", async () => { const host = createHostMock(); const log = createLoggerMock(); const cleanupFlag = vi.fn().mockResolvedValue(undefined); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag); @@ -1477,7 +1477,7 @@ describe("Red Flag Feature", () => { host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({}); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); const handler = createFetchAllFlagHandler(host as any, log); @@ -1559,7 +1559,7 @@ describe("Red Flag Feature", () => { } as any); host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL); - host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_LEGACY); + host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED); host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", extra: {} }); host.mocks.rebuilder.$fetchLocal.mockResolvedValueOnce(); const handler = createFetchAllFlagHandler(host as any, log); diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts index 242b478c..eaaf8950 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -309,7 +309,7 @@ describe("useP2PReplicatorUI commands", () => { expect(ribbon.remove).toHaveBeenCalledOnce(); }); - it("replaces a restored legacy P2P leaf with the current status view without opening another leaf", async () => { + it("compatibility: migrates a restored P2P leaf to the current status view without opening another leaf", async () => { let layoutReady: (() => Promise) | undefined; const legacyLeaf = { setViewState: vi.fn(async () => undefined), diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts index 5e80cbc7..17c4dea6 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -38,7 +38,7 @@ describe("compatibility marker persistence", () => { }); describe("configured CouchDB fixture", () => { - it("starts in the current remote-profile format instead of exercising legacy migration", () => { + it("uses a current remote profile for ordinary configured fixtures", () => { const pluginData = createE2eCouchDbPluginData({ uri: "https://couch.example", username: "alice", diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts index 1b925061..a0f1c791 100644 --- a/test/e2e-obsidian/scripts/settings-ui.ts +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -223,6 +223,7 @@ async function verifyEffectiveSettings(): Promise { .getByText("Keep empty folder", { exact: true }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); + // Retirement guard: the removed toggle must not reappear in the current settings pane. const obsoleteToggleCount = await deletionPanel.getByText("Use the trash bin", { exact: true }).count(); if (obsoleteToggleCount !== 0) { throw new Error( From 68d22ade76bd60ad408511a4fe41a5a3e6852b3f Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Thu, 23 Jul 2026 17:38:16 +0000 Subject: [PATCH 136/170] Use established terms in setup guidance --- README.md | 2 +- docs/adr/2026_06_real_obsidian_e2e.md | 2 +- docs/adr/2026_07_p2p_transport_lifecycle.md | 4 ++-- docs/p2p.md | 10 +++++----- docs/p2p_sync_updates_2026.md | 2 +- docs/quick_setup.md | 10 +++++----- docs/recovery.md | 4 ++-- docs/settings.md | 4 ++-- docs/setup_object_storage.md | 16 ++++++++-------- docs/setup_own_server.md | 6 +++--- docs/setup_p2p.md | 14 +++++++------- docs/tips/p2p-sync-tips.md | 2 +- docs/troubleshooting.md | 4 ++-- test/e2e-obsidian/README.md | 14 +++++++------- 14 files changed, 47 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 816388c1..b293b2b4 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Choose a synchronisation method, prepare its server where required, then follow 1. No central data-storage server is required. The project's public signalling relay requires no server provisioning; controlled deployments can provide another compatible relay. 2. Configure the clients by following [Peer-to-Peer Setup](docs/setup_p2p.md). -Each workflow establishes ordinary note synchronisation on the first device, generates the additional-device Setup URI from that working device, and verifies synchronisation in both directions. +Each workflow establishes ordinary note synchronisation on the first device, generates a Setup URI for each additional device from that working device, and verifies synchronisation in both directions. > [!TIP] > Fly.io is no longer free. Fortunately, we can still use IBM Cloudant despite some limitations. Refer to [Set up IBM Cloudant](docs/setup_cloudant.md). diff --git a/docs/adr/2026_06_real_obsidian_e2e.md b/docs/adr/2026_06_real_obsidian_e2e.md index 6682be9c..cade7701 100644 --- a/docs/adr/2026_06_real_obsidian_e2e.md +++ b/docs/adr/2026_06_real_obsidian_e2e.md @@ -200,7 +200,7 @@ Current implementation status: Current implementation status: - The mocked Vitest browser suites, their P2P runner, their root-level relay helpers, and the manual `harness-ci` workflow have been removed after maintained suites covered the critical flows. -- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including first-device URI generation, second-device import, and two-way Vault synchronisation. +- Headless CouchDB and Object Storage combinations, with and without encryption, remain owned by the CLI two-Vault matrix. P2P transport replacement and relay lifecycle remain owned by the CLI Compose E2E suite. Real Obsidian owns the visible CouchDB, Object Storage, and P2P Setup URI workflows, including URI generation on the first device, import on the second device, and two-way Vault synchronisation. - The Obsidian compatibility implementation still needed by the Webapp has moved to `src/apps/webapp/obsidianMock.ts`; it is not a retained browser E2E Harness. - Remaining high-value scenarios, including RedFlag and Fast Setup (Simple Fetch) variants, should be added according to their owning integration boundary rather than copied line by line from the retired suite. diff --git a/docs/adr/2026_07_p2p_transport_lifecycle.md b/docs/adr/2026_07_p2p_transport_lifecycle.md index adc53e77..e7f4b671 100644 --- a/docs/adr/2026_07_p2p_transport_lifecycle.md +++ b/docs/adr/2026_07_p2p_transport_lifecycle.md @@ -46,7 +46,7 @@ Lifecycle operations on one `LiveSyncTrysteroReplicator` are serialised. A close Relay sockets retain their Trystero-provided close handlers. LiveSync pauses relay reconnection, closes the sockets, and later resumes reconnection through Trystero's public functions. It does not replace `socket.onclose`, because Trystero uses that handler to retire and recreate shared relay clients correctly. -P2P setup follows the transport's actual ownership model. First-device initialisation resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present the central-server overwrite warnings or remote-configuration option. An additional device performs one explicit peer-selection and finite Fetch pass, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice. +P2P setup follows the transport's actual ownership model. Initialising the first device resets and scans the local database, but does not attempt to lock, reset, or upload to a non-existent central remote database. Its confirmation dialogues therefore describe preparing this device and do not present warnings about overwriting a central server or an option to fetch its configuration. An additional device selects a peer once, performs Fetch once, then resumes database and Vault reflection. The generic second convergence pass remains reserved for central remote types because repeating it for P2P would ask the user to select the same peer twice. ## Ownership @@ -74,7 +74,7 @@ This interferes with Trystero's shared relay clients. The public pause and resum ## Verification -Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, local-only first-device initialisation, and one-pass additional-device Fetch. +Commonlib unit tests prove that normal P2P host closure calls `room.leave()` without directly closing Trystero-owned peer connections. Additional package tests cover the action API, replaceable peer-event subscriptions, multiple RPC transport disposers, serialised open and close operations, initialisation of the first device without a central remote, and Fetch running once for an additional device. Self-hosted LiveSync unit tests prove that settings and database replacement leave panes on the current replicator, and that an explicit P2P rebuild bypasses the policy intended for ordinary replication. diff --git a/docs/p2p.md b/docs/p2p.md index 1b686815..4e7902d9 100644 --- a/docs/p2p.md +++ b/docs/p2p.md @@ -53,7 +53,7 @@ The **P2P Status** pane is the current Obsidian interface for P2P connections. - LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it. - Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed. -The active P2P remote is selected independently from the main CouchDB or Object Storage remote. Devices can therefore use P2P as an additional transport without replacing their main remote. +The active P2P remote is selected independently from the main CouchDB or Object Storage remote. Devices can therefore use P2P alongside their main remote without replacing it. ![P2P Status on desktop](../images/p2p-setup/p2p-status-pane.png) @@ -74,9 +74,9 @@ Every participating device must use the same signalling relay set, Group ID, and - A notification contains no Vault data. It only asks the following peer to fetch through the encrypted P2P connection. - Missing a notification does not make an explicit later synchronisation unsafe; **Replicate now** still compares the available data. -The peer's **More actions** menu contains persistent conveniences: +The peer's **More actions** menu can save these choices for that device: -- **Synchronise when this device connects** runs a finite synchronisation when that named peer is discovered. +- **Synchronise when this device connects** runs one synchronisation when that named peer is discovered. - **Follow whenever this device connects** restores following for that named peer. - **Include in the P2P synchronisation command** includes that peer when the command for registered targets is run. @@ -88,11 +88,11 @@ Configure these only after a manual round trip has succeeded. Device names used A device must approve a peer before serving its data. Permanent approval is stored; session approval lasts only for the current Obsidian session. Check the displayed device name before approving a request. -The encrypted Setup URI contains the shared P2P configuration but deliberately omits the device-specific name. Store the Setup URI and its passphrase separately, and generate the additional-device URI from a working first device. +The encrypted Setup URI contains the shared P2P configuration but deliberately omits the device-specific name. Store the Setup URI and its passphrase separately, and generate a Setup URI for another device from a first device which has completed setup. ## Operational limits - At least one device which already has the required data must be online while another device fetches it. - P2P does not provide the continuously available central copy offered by CouchDB or Object Storage. Keep independent backups. -- Mobile operating systems may pause Obsidian in the background. Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large finite synchronisation. +- Mobile operating systems may pause Obsidian in the background. Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation. - Changing from CouchDB to P2P is not a repair operation for a stopped CouchDB setup. Diagnose the existing transport first. diff --git a/docs/p2p_sync_updates_2026.md b/docs/p2p_sync_updates_2026.md index c6a31a9f..529d816f 100644 --- a/docs/p2p_sync_updates_2026.md +++ b/docs/p2p_sync_updates_2026.md @@ -2,6 +2,6 @@ This address is retained for links to an earlier P2P guide. The time-specific interface description has been replaced by stable documentation: -- [Set up peer-to-peer synchronisation](setup_p2p.md) for the first device, additional-device Setup URI, approval, and two-way verification. +- [Set up peer-to-peer synchronisation](setup_p2p.md) for configuring the first device, generating a Setup URI for another device, approving the connection, and verifying synchronisation in both directions. - [How peer-to-peer synchronisation works](p2p.md) for signalling, TURN, privacy, the P2P Status pane, and automatic behaviour. - [Peer-to-Peer Synchronisation Tips](tips/p2p-sync-tips.md) for connection troubleshooting. diff --git a/docs/quick_setup.md b/docs/quick_setup.md index b156bcbe..72fee5fe 100644 --- a/docs/quick_setup.md +++ b/docs/quick_setup.md @@ -50,13 +50,13 @@ Create an ordinary test note and allow it to upload before adding another device ## Create a Setup URI for another device -Generate the additional-device Setup URI from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the bootstrap URI produced during server provisioning. +Generate a Setup URI for another device from the working first device. This captures the settings which that device is actually using, rather than asking another device to reuse the Setup URI produced during server provisioning. 1. Open the Obsidian command palette on the first device. 2. Run `Self-hosted LiveSync: Copy settings as a new Setup URI`. 3. Enter a new passphrase which will protect this Setup URI, then select `OK`. - ![Masked passphrase for a new additional-device Setup URI](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png) + ![Masked passphrase for a new Setup URI for another device](../images/quick-setup/guide-quick-setup-copy-setup-uri-passphrase.png) 4. Copy the resulting Setup URI, then select `OK`. @@ -75,7 +75,7 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co 5. Paste the new Setup URI generated by the first device, enter its Setup URI passphrase, and select `Test Settings and Continue`. 6. Review `Setup Complete: Preparing to Fetch Synchronisation Data`, then select `Restart and Fetch Data`. - ![Additional-device Fetch confirmation](../images/quick-setup/guide-quick-setup-second-fetch.png) + ![Fetch confirmation on the additional device](../images/quick-setup/guide-quick-setup-second-fetch.png) 7. For a new or empty Vault, select `Overwrite all with remote files`. For a Vault with local work, stop and choose the appropriate strategy from the [Fast Setup guide](./tips/fast-setup.md). @@ -83,7 +83,7 @@ Start with a new or separately backed-up Vault. Do not use a production Vault co 8. When asked how to handle extra local files, the conservative choice is `Keep local files even if not on remote`. Select the delete option only when the local Vault is disposable and an exact remote copy is intended. - ![Additional-device local file policy](../images/quick-setup/guide-quick-setup-local-file-policy.png) + ![Local file policy on the additional device](../images/quick-setup/guide-quick-setup-local-file-policy.png) 9. Allow retrieval, file reflection, and any requested restart to finish. Keep Obsidian open until the LiveSync progress indicators have cleared. @@ -114,7 +114,7 @@ Use this path when CouchDB is ready but a Setup URI is unavailable. It configure 5. On `Choose a synchronisation remote`, select `CouchDB`, then select `Continue to CouchDB setup`. 6. Enter the complete CouchDB URL, username, password, and database name. - Obsidian Mobile requires HTTPS. Plain HTTP is suitable only for a trusted local connection from a desktop device. - - Use credentials which are allowed to connect to the selected database and, for this first-device path, create it when it does not exist. + - Use credentials which are allowed to connect to the selected database and, when configuring the first device, create it if it does not exist. 7. `Check server requirements` is optional. It sends the displayed credentials to the configured server through Obsidian's internal request API, and some checks require CouchDB administrator access. The initial check is read-only. If it offers a server change, review and confirm that individual change separately. 8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed. 9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`. diff --git a/docs/recovery.md b/docs/recovery.md index a4ca48af..9d1227af 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -14,7 +14,7 @@ Use the least destructive operation which matches the evidence: - If the correct data is uncertain, suspend all work with `redflag.md`, preserve every copy, and inspect them before proceeding. - If the central remote is healthy and should win, use **Reset Synchronisation on This Device** or `flag_fetch.md`. - If this device's Vault is healthy and should replace a damaged or unwanted central remote, use **Overwrite Server Data with This Device's Files** or `flag_rebuild.md`. -- If both the Vault and local database are healthy and the only concern is unused storage, Garbage Collection may be appropriate. It is not a damaged-database recovery operation. +- If both the Vault and local database are healthy and the only concern is unused storage, Garbage Collection may be appropriate. It does not repair a damaged database. Do not switch transport, enable P2P, or run Garbage Collection as a substitute for diagnosing a stopped CouchDB or Object Storage setup. @@ -58,7 +58,7 @@ The readable flag is `flag_rebuild.md`; the legacy name `redflag2.md` remains ac For CouchDB and Object Storage, this is destructive to the selected remote state. Other devices may still contain revisions or files which are not present in the authoritative Vault, so keep them stopped until the new remote has been verified and then reset them from that remote. -For a P2P-only setup, there is no central remote database to overwrite. The corresponding first-device preparation rebuilds this device's local LiveSync database from its Vault. +For a P2P-only setup, there is no central remote database to overwrite. Preparing the first device instead rebuilds its local LiveSync database from its Vault. ## Garbage Collection is not Rebuild diff --git a/docs/settings.md b/docs/settings.md index 999ab21a..4dd9521c 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -386,8 +386,8 @@ The subject (`sub`) claim of the JWT, which should match your CouchDB username. The action depends on why the dialogue was opened: -- First-device onboarding uses **Create or connect to database and continue**. It may create the database when it does not exist and the supplied account has permission. -- Additional-device onboarding uses **Connect to existing database and continue**. It does not create a missing database. +- Onboarding for the first device uses **Create or connect to database and continue**. It may create the database when it does not exist and the supplied account has permission. +- Onboarding for an additional device uses **Connect to existing database and continue**. It does not create a missing database. - Adding or editing a saved remote profile uses **Test connection and save**. It does not create a missing database. - Settings mode also offers **Save without connecting**. The existing profile is updated, but automatic synchronisation may fail until the connection is corrected. diff --git a/docs/setup_object_storage.md b/docs/setup_object_storage.md index 9c725cfe..f29f60f1 100644 --- a/docs/setup_object_storage.md +++ b/docs/setup_object_storage.md @@ -1,6 +1,6 @@ # Set up Object Storage -This guide establishes Object Storage synchronisation on a first device, generates an additional-device Setup URI from that working device, and verifies synchronisation in both directions. +This guide establishes Object Storage synchronisation on a first device, generates a Setup URI for another device from that working device, and verifies synchronisation in both directions. Object Storage uses the S3-compatible API. Prepare the following before starting: @@ -12,7 +12,7 @@ Object Storage uses the S3-compatible API. Prepare the following before starting Back up every Vault involved, and do not use Obsidian Sync, iCloud synchronisation, or another synchronisation service on the same Vault. -## Generate the bootstrap Setup URI +## Generate the initial Setup URI The public generator applies the Object Storage preset and records the connection as the selected remote profile. Run it from a trusted terminal: @@ -40,13 +40,13 @@ Use a new bucket prefix, or a prefix whose contents you deliberately intend to r 1. Install and enable Self-hosted LiveSync in the intended Vault. 2. Open onboarding from the `Welcome to Self-hosted LiveSync` Notice. 3. Select `I am setting this up for the first time`, then choose the recommended Setup URI method. -4. Paste the bootstrap Setup URI, enter its passphrase, and select `Test Settings and Continue`. +4. Paste the initial Setup URI, enter its passphrase, and select `Test Settings and Continue`. ![Object Storage Setup URI on the first device](../images/object-storage-setup/guide-object-storage-setup-first-setup-uri.png) 5. Select `Restart and Initialise Server`, then read and accept the final overwrite confirmation only when this Vault is the intended source of truth. - ![Object Storage first-device initialisation](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png) + ![Object Storage initialisation on the first device](../images/object-storage-setup/guide-object-storage-setup-first-initialise.png) ![Final Object Storage overwrite confirmation](../images/object-storage-setup/guide-object-storage-setup-first-rebuild-confirmation.png) @@ -64,7 +64,7 @@ Generate a fresh Setup URI from the working first device: 1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette. 2. Enter a new Setup URI passphrase. - ![Masked passphrase for the additional-device Object Storage Setup URI](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png) + ![Masked passphrase for the Object Storage Setup URI for another device](../images/object-storage-setup/guide-object-storage-setup-copy-setup-uri-passphrase.png) 3. Copy the resulting URI. @@ -80,7 +80,7 @@ Start with a new or separately backed-up Vault. 2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method. 3. Enter the URI generated by the first device and its passphrase. - ![First-device Object Storage Setup URI entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png) + ![Object Storage Setup URI from the first device entered on the second device](../images/object-storage-setup/guide-object-storage-setup-second-setup-uri.png) 4. Select `Restart and Fetch Data`. @@ -96,7 +96,7 @@ Start with a new or separately backed-up Vault. Confirm that the first device's test note appears unchanged. Create a second ordinary note on the new device, wait for its journal synchronisation to finish, and confirm that it reaches the first device. Configure optional features only after this two-way check passes. -![First-device note received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png) +![Note from the first device received by the second Object Storage device](../images/object-storage-setup/guide-object-storage-setup-first-to-second.png) ![Second-device note received by the first Object Storage device](../images/object-storage-setup/guide-object-storage-setup-second-to-first.png) @@ -104,5 +104,5 @@ Confirm that the first device's test note appears unchanged. Create a second ord - Treat the endpoint, bucket, prefix, access key, secret key, Vault passphrase, Setup URI, and Setup URI passphrase as sensitive. - Use a distinct prefix per synchronisation set unless shared data is explicitly intended. -- Do not select first-device initialisation against an existing prefix unless replacing its contents is deliberate. +- Do not initialise the first device against an existing prefix unless replacing its contents is deliberate. - Object Storage is not a Vault backup. Keep independent backups and test restoration separately. diff --git a/docs/setup_own_server.md b/docs/setup_own_server.md index 6268413f..5ac2b753 100644 --- a/docs/setup_own_server.md +++ b/docs/setup_own_server.md @@ -167,7 +167,7 @@ Now `https://tiles-photograph-routine-groundwater.trycloudflare.com` is our serv ## 4. Client Setup > [!TIP] -> A generated Setup URI is the recommended path because it carries the current new-Vault defaults and remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device. +> A generated Setup URI is the recommended path because it carries the current defaults for a new Vault and the selected remote profile. If a Setup URI cannot be generated, follow [Configure CouchDB manually on the first device](./quick_setup.md#configure-couchdb-manually-on-the-first-device), then generate a new Setup URI from that working device for every additional device. ### 1. Generate the setup URI on a desktop device or server ```bash @@ -185,7 +185,7 @@ deno run --minimum-dependency-age=0 --allow-env https://raw.githubusercontent.co > > If `uri_passphrase` is omitted, the generator creates a cryptographically random value and prints it once. -The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current new-Vault defaults, and encodes them with Commonlib's Setup URI contract. +The generator consumes the exact registry-pinned Commonlib release used by the provisioning utility. It creates a configured CouchDB remote profile, applies the current defaults for a new Vault, and encodes them with Commonlib's Setup URI contract. You will then get the following output: @@ -202,7 +202,7 @@ Store the Setup URI and its passphrase separately. Follow [Quick setup](./quick_setup.md#set-up-the-first-device) for the first device. It covers the current onboarding Notice, Setup URI import, server initialisation, and the safety prompts shown for a newly provisioned database. -After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the provisioning-time bootstrap URI. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md). +After ordinary note synchronisation works, [generate a new Setup URI on that first device](./quick_setup.md#create-a-setup-uri-for-another-device), then follow [Add another device](./quick_setup.md#add-another-device). Do not make the second device depend on retaining the initial Setup URI produced during provisioning. Configure optional features only after the normal path is verified; [Hidden File Sync has its own guide](./tips/hidden-file-sync.md). --- diff --git a/docs/setup_p2p.md b/docs/setup_p2p.md index a40d83ff..0bf049ec 100644 --- a/docs/setup_p2p.md +++ b/docs/setup_p2p.md @@ -47,7 +47,7 @@ On the working first device: 1. Run `Self-hosted LiveSync: Copy settings as a new Setup URI` from the command palette. 2. Enter a new Setup URI passphrase. - ![Masked passphrase for the additional-device P2P Setup URI](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png) + ![Masked passphrase for the P2P Setup URI for another device](../images/p2p-setup/guide-p2p-setup-copy-setup-uri-passphrase.png) 3. Copy the resulting URI. @@ -61,7 +61,7 @@ Keep the first device online. Store the new URI and its passphrase separately. 2. Open onboarding, select `I am adding a device to an existing synchronisation setup`, and choose the recommended Setup URI method. 3. Enter the Setup URI generated by the first device and its passphrase. - ![First-device P2P Setup URI entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png) + ![P2P Setup URI from the first device entered on the second device](../images/p2p-setup/guide-p2p-setup-second-setup-uri.png) 4. Select `Restart and Fetch Data`. @@ -73,7 +73,7 @@ Keep the first device online. Store the new URI and its passphrase separately. ![P2P local-file policy](../images/p2p-setup/guide-p2p-setup-local-file-policy.png) -6. In `P2P Rebuild`, confirm that the expected first-device name is shown, then select `Sync`. +6. In `P2P Rebuild`, confirm that the expected name of the first device is shown, then select `Sync`. ![Selecting the first device for P2P Rebuild](../images/p2p-setup/guide-p2p-setup-select-first-device.png) @@ -83,16 +83,16 @@ Keep the first device online. Store the new URI and its passphrase separately. 8. Keep both devices open until the test note appears on the second device. - ![First-device note received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png) + ![Note from the first device received by the second P2P device](../images/p2p-setup/guide-p2p-setup-first-to-second.png) ## Verify the return journey -Create a second ordinary note on the second device. Keep automatic announcements disabled and prove the next finite synchronisation explicitly: +Create a second ordinary note on the second device. Keep automatic announcements disabled, then run and verify the next synchronisation explicitly: 1. Open `P2P Status` on both devices. 2. If a peer no longer appears, select `Disconnect` and then `Open connection` on the first device, followed by the second device. The device which joins last is advertised to devices which are already in the room. 3. On the first device, select `Refresh`, verify the second-device name, then select `Replicate now`. -4. On the second device, verify the requesting first-device name and select `Accept` or `Accept Temporarily`. +4. On the second device, verify the name of the requesting first device and select `Accept` or `Accept Temporarily`. ![Explicit return-journey connection request on the second device](../images/p2p-setup/guide-p2p-setup-connection-request-2.png) @@ -119,7 +119,7 @@ An announcement contains no Vault data and does not transfer a change by itself. - Check signalling relay reachability separately from WebRTC connectivity. - Review VPN and TURN options in [Peer-to-Peer Synchronisation Tips](./tips/p2p-sync-tips.md). -## Controlled or self-hosted bootstrap +## Controlled or self-hosted setup The ordinary route above starts in the plug-in UI and can use the project's public signalling relay. For a controlled deployment, prepare your own Nostr-compatible relay and enter it in `Signalling relay URLs` on every device. diff --git a/docs/tips/p2p-sync-tips.md b/docs/tips/p2p-sync-tips.md index 6ce0c9a2..533935ed 100644 --- a/docs/tips/p2p-sync-tips.md +++ b/docs/tips/p2p-sync-tips.md @@ -52,7 +52,7 @@ If the device was asleep, Obsidian was in the background, or the peer disconnect ## Mobile limitations -Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large finite synchronisation. Wake Lock support is best effort and cannot prevent the operating system from suspending or terminating a background application. +Keep Obsidian visible and the device awake during initial transfer, rebuild, or a large synchronisation. Wake Lock support is best effort and cannot prevent the operating system from suspending or terminating a background application. ## Collect evidence diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4acae22d..be623d3f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -57,7 +57,7 @@ If the log reports missing chunks or a size mismatch: 2. on a device which has the correct file, run `Recreate missing chunks for all files`, then synchronise; and 3. if the mismatch remains, run `Verify and repair all files` from `Hatch` and review which copy is authoritative. -## A configuration-mismatch dialogue blocks synchronisation +## A configuration mismatch dialogue blocks synchronisation Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently. @@ -153,4 +153,4 @@ Follow [Recovery and flag files](recovery.md). A `redflag.md` emergency stop rem ## Further technical context -See [Technical Information](tech_info.md) for database and synchronisation internals. Current behaviour belongs in this guide; older defect-specific instructions remain in the release histories. +See [Technical Information](tech_info.md) for database and synchronisation internals. Current behaviour belongs in this guide; instructions for older defects remain in the release histories. diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index bd11e2a3..6fc6d483 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -99,11 +99,11 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe The same workflow checks the two remote-activity status boundaries. It first holds a real CouchDB request at the selected fetch implementation and confirms that `🌐N` is visible while `📲` is absent. It then holds the real one-shot replication immediately before its replicator call, confirms that `📲` is visible while no physical request is active, releases it, and requires the finite and bounded activity counts to return to zero, the request and response counts to balance, and both indicators to disappear. Finally, it creates a remote-only chunk, holds the real on-demand fetch immediately before its remote call, makes the same logical active and idle assertions, and verifies that the fetched chunk is written into the local database. These gates make the active states deterministic without replacing the remote request or operation. -`test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible first-device onboarding path without a bootstrap Setup URI. It enters end-to-end encryption and CouchDB details, runs the read-only server-requirements check, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After first-device Rebuild, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked. +`test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible onboarding path for the first device when no Setup URI is available. It enters end-to-end encryption and CouchDB details, runs the read-only `Check server requirements` step, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After Rebuild completes on the first device, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked. If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, and the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory. -The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a first-device decision. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode. +The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode. `test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise. @@ -124,19 +124,19 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- `test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance. -`test:e2e:obsidian:object-storage-setup-uri-workflow` generates a public Commonlib-backed bootstrap URI for a unique MinIO prefix, completes visible first-device initialisation, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. +`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. -`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated bootstrap URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both finite P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. +`test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. `test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file. -`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures the first data-less real Obsidian Vault through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the provisioning-time bootstrap URI. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets. +`test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets. `test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite. `test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. -`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so legacy-profile migration remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate mobile-safe action Notice with actionable touch targets; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. +`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. @@ -146,7 +146,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- The workflow first exercises a non-empty legacy settings document which has no `isConfigured` or file-name case value. It verifies that 0.25.83 treats a default-equivalent document as unconfigured. That release can persist the inferred boolean during a later, unrelated settings-save event, so the runner accepts either an absent value or the inferred `false` on disk, then restores the same minimal pre-flag document deliberately before installing 1.0. The target independently proves its direct migration: the Vault remains unconfigured instead of receiving new-Vault recommendations, case-insensitive handling becomes explicit, no compatibility pause or acknowledgement marker is created while onboarding remains pending, and a second 1.0 start is idempotent. The absent marker is deliberately deferred rather than accepted; a later configured start must evaluate it. This fixture rewrite is limited to the missing-flag boundary; the configured transport upgrades use only state created and saved by 0.25.83 itself. -For CouchDB and Object Storage, the workflow then configures 0.25.83 from its own defaults, saves the selected remote, and restarts that release with the same profile before creating history. This both verifies that the old settings persist and lets the old release initialise its replicator from the same saved state as an ordinary existing Vault. The runner waits for that release's asynchronously initialised persistent node identity, creates, edits, renames, and deletes notes, and synchronises each transition before installing the target. Every launch of the upgraded device uses the same isolated Obsidian profile. The session layer closes the renderer before its process-tree fallback, so Chromium persists the legacy compatibility marker naturally; the target must read and migrate that actual profile state to its current namespaced key. The final target restart likewise consumes the marker persisted by the preceding target session. The runner does not reconstruct that device's Vault data, plug-in settings, local database files, device-local state, or remote state. Before the target performs any synchronisation, it must retain the same Vault profile, local database, node identity, remote profile, local checkpoint, and remote milestone. The local node-info document is the identity source of truth; a transient replicator field is used only to confirm that the old asynchronous initialisation has completed. Its first synchronisation must be a no-op: CouchDB document revisions and `update_seq` must remain unchanged, while Object Storage must neither upload nor download journal bodies. The upgraded device then sends a new delta. A separate fresh 1.0 verifier starts from an explicit current-version settings and compatibility fixture, receives the complete surviving history, and returns another delta; it is not part of the legacy-profile migration assertion. The upgraded Vault receives that return journey and retains it across restart. +For CouchDB and Object Storage, the workflow then configures 0.25.83 from its own defaults, saves the selected remote, and restarts that release with the same profile before creating history. This both verifies that the old settings persist and lets the old release initialise its replicator from the same saved state as an ordinary existing Vault. The runner waits for that release's asynchronously initialised persistent node identity, creates, edits, renames, and deletes notes, and synchronises each transition before installing the target. Every launch of the upgraded device uses the same isolated Obsidian profile. The session layer closes the renderer before its process-tree fallback, so Chromium persists the legacy compatibility marker naturally; the target must read and migrate that actual profile state to its current namespaced key. The final target restart likewise consumes the marker persisted by the preceding target session. The runner does not reconstruct that device's Vault data, plug-in settings, local database files, device-local state, or remote state. Before the target performs any synchronisation, it must retain the same Vault profile, local database, node identity, remote profile, local checkpoint, and remote milestone. The local node-info document is the identity source of truth; a transient replicator field is used only to confirm that the old asynchronous initialisation has completed. Its first synchronisation must be a no-op: CouchDB document revisions and `update_seq` must remain unchanged, while Object Storage must neither upload nor download journal bodies. The upgraded device then sends a new delta. A separate fresh 1.0 verifier starts from an explicit fixture containing settings and compatibility state for the current version, receives the complete surviving history, and returns another delta; it is not part of the migration assertion for legacy remote settings. The upgraded Vault receives that return journey and retains it across restart. Before creating stable-release history, the runner waits until the remote Security Seed can be read and only then marks the remote as resolved. Completion of the old release's remote-creation method alone does not prove that this asynchronous fixture boundary is ready. From 24a4ebb8dd346947f024fd321f160c1426ee4e99 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 03:04:39 +0000 Subject: [PATCH 137/170] Use obsidian-test-session 0.2.5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index c0c8f2dc..531210de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "@types/transform-pouch": "^1.0.6", "@typescript-eslint/parser": "8.56.1", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.2.4", + "@vrtmrz/obsidian-test-session": "0.2.5", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4839,9 +4839,9 @@ } }, "node_modules/@vrtmrz/obsidian-test-session": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.4.tgz", - "integrity": "sha512-fyb/6xHea/w9WwWi5u9ZJol1rBnVEk+fa1yM1RzUcf6VUAzmwn5zELWtOw8ZsFTdo9QpwVuAYlRAs35AdmUzqQ==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz", + "integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index dbb67615..8ec2c8e5 100644 --- a/package.json +++ b/package.json @@ -113,7 +113,7 @@ "@types/transform-pouch": "^1.0.6", "@typescript-eslint/parser": "8.56.1", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.2.4", + "@vrtmrz/obsidian-test-session": "0.2.5", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", From bdc44920ac13aae5ae2f9ecf321d831e3b550956 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 05:37:13 +0000 Subject: [PATCH 138/170] Refresh setup guides and capture fixtures --- docs/quick_setup.md | 15 ++++ docs/tips/hidden-file-sync.md | 5 +- ...uide-couchdb-manual-connection-details.png | Bin 0 -> 58625 bytes ...guide-couchdb-manual-connection-method.png | Bin 0 -> 48228 bytes .../guide-couchdb-manual-encryption.png | Bin 0 -> 72751 bytes .../guide-couchdb-manual-remote-selection.png | Bin 0 -> 51248 bytes ...ide-couchdb-manual-server-requirements.png | Bin 0 -> 61972 bytes ...uide-hidden-file-initial-scan-progress.png | Bin 0 -> 30799 bytes ...age-setup-missing-remote-configuration.png | Bin 20113 -> 20728 bytes ...-object-storage-setup-retrieval-method.png | Bin 73017 -> 73011 bytes ...ick-setup-missing-remote-configuration.png | Bin 20113 -> 20728 bytes .../guide-quick-setup-retrieval-method.png | Bin 73017 -> 73011 bytes test/e2e-obsidian/scripts/p2p-pane.ts | 75 ++++++++++++------ 13 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 images/couchdb-manual/guide-couchdb-manual-connection-details.png create mode 100644 images/couchdb-manual/guide-couchdb-manual-connection-method.png create mode 100644 images/couchdb-manual/guide-couchdb-manual-encryption.png create mode 100644 images/couchdb-manual/guide-couchdb-manual-remote-selection.png create mode 100644 images/couchdb-manual/guide-couchdb-manual-server-requirements.png create mode 100644 images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png diff --git a/docs/quick_setup.md b/docs/quick_setup.md index 72fee5fe..f685db48 100644 --- a/docs/quick_setup.md +++ b/docs/quick_setup.md @@ -107,15 +107,30 @@ Use this path when CouchDB is ready but a Setup URI is unavailable. It configure 1. Install and enable Self-hosted LiveSync in the intended Vault. 2. Select the `Welcome to Self-hosted LiveSync` Notice, choose `I am setting this up for the first time`, then confirm that you want to set up a new synchronisation. 3. On `Connection Method`, select `Configure a remote manually`, then select `Proceed with manual configuration`. + + ![Manual remote configuration option during onboarding](../images/couchdb-manual/guide-couchdb-manual-connection-method.png) + 4. On `End-to-End Encryption`, decide how the synchronised data will be protected. - For an ordinary new Vault, enable `End-to-End Encryption` and enter a strong Vault encryption passphrase. - Enable `Obfuscate Properties` if remote document properties should also be concealed. - Store the Vault encryption passphrase securely. It is separate from the passphrase used to protect a Setup URI. + + ![CouchDB Vault encryption settings with the passphrase masked](../images/couchdb-manual/guide-couchdb-manual-encryption.png) + 5. On `Choose a synchronisation remote`, select `CouchDB`, then select `Continue to CouchDB setup`. + + ![CouchDB option in the synchronisation remote choices](../images/couchdb-manual/guide-couchdb-manual-remote-selection.png) + 6. Enter the complete CouchDB URL, username, password, and database name. - Obsidian Mobile requires HTTPS. Plain HTTP is suitable only for a trusted local connection from a desktop device. - Use credentials which are allowed to connect to the selected database and, when configuring the first device, create it if it does not exist. + + ![Manual CouchDB connection fields with the password masked](../images/couchdb-manual/guide-couchdb-manual-connection-details.png) + 7. `Check server requirements` is optional. It sends the displayed credentials to the configured server through Obsidian's internal request API, and some checks require CouchDB administrator access. The initial check is read-only. If it offers a server change, review and confirm that individual change separately. + + ![Successful optional CouchDB server requirements check](../images/couchdb-manual/guide-couchdb-manual-server-requirements.png) + 8. Select `Create or connect to database and continue`. Onboarding requires this connection test to succeed. 9. Review `Setup Complete: Preparing to Initialise Server`, then select `Restart and Initialise Server`. 10. Read the final overwrite warning. Select `I Understand, Overwrite Server` only when this device is intentionally the source of truth and a current backup exists. diff --git a/docs/tips/hidden-file-sync.md b/docs/tips/hidden-file-sync.md index 95094370..e84570d9 100644 --- a/docs/tips/hidden-file-sync.md +++ b/docs/tips/hidden-file-sync.md @@ -47,7 +47,10 @@ A pattern containing only `snippets` does not admit the `.obsidian` parent, so t ![Hidden File Sync initialisation choices](../../images/hidden-file-sync/guide-hidden-file-enable.png) 2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above. -3. Keep Obsidian open while the initial scan and synchronisation finish. +3. Keep Obsidian open while the initial scan and synchronisation finish. A progress Notice appears when preparation begins and remains visible while the initial scan is running. + + ![Hidden File Sync initial scan progress Notice](../../images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png) + 4. Restart Obsidian when the completion Notice recommends it. 5. Confirm that the expected hidden files, and only those files, are present in the remote synchronisation state. diff --git a/images/couchdb-manual/guide-couchdb-manual-connection-details.png b/images/couchdb-manual/guide-couchdb-manual-connection-details.png new file mode 100644 index 0000000000000000000000000000000000000000..27a3e54ee8657b94d000eb0b7c150a2d1ee7f026 GIT binary patch literal 58625 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

    S0dZHMy!mO%ftDJM-uPI40ZyVaY|=XWEHJ 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

    -muj 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 Vv# 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 FoijlJ^iH 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 HR$>1OHD;kP literal 0 HcmV?d00001 diff --git a/images/couchdb-manual/guide-couchdb-manual-remote-selection.png b/images/couchdb-manual/guide-couchdb-manual-remote-selection.png new file mode 100644 index 0000000000000000000000000000000000000000..21c02829c10f7afc1161e74bdbb145d94885b90c GIT binary patch literal 51248 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 literal 0 HcmV?d00001 diff --git a/images/couchdb-manual/guide-couchdb-manual-server-requirements.png b/images/couchdb-manual/guide-couchdb-manual-server-requirements.png new file mode 100644 index 0000000000000000000000000000000000000000..a08541aabf868321eaaa36c04e27908a68051011 GIT binary patch literal 61972 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 DxHh)1 literal 0 HcmV?d00001 diff --git a/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png b/images/object-storage-setup/guide-object-storage-setup-missing-remote-configuration.png index 512b3b6a41d77e736323d917ed9cab6fcc6afb87..79f205fc77c685bb7f15896a45081e60afd4ea4b 100644 GIT binary patch delta 19958 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

    l*=~a(xee;n3l2@E?dvX zoiy=`lFL|nITA=GY+K)Y|Gi72B4HlEEKoa-5rALQtg6zm6dxgrrl#{}@S<`!$w)DWj>GO0Mt-h+#zsnoG5~DRmWIazeIU|7VLh5z#dN}$5#^3yxaZs znv^UJhe*V!%aO~{(b3Sa7lx*+H#K5Sg72(T39eIzV=+^l_g($~VVMSJ=T-l0E{$gi zUZLma4X!xc5|2i9O9T?%rHeY|D3lHRO^_&@vb>*^*Cn!Bq`hG($|U12dd6$&%-8N+ zNg^$On!zpb&Xr7C521e}FPnzrGKDY2jU&Tyoanb=?7jEy9Y+EJU)I^omY;vF6drb0 zvYd<&rub8azx^s+(wmE}XXYa*3Ga=6&(}8f+ucv~FoV(0(0!nrGg=b8*-3mv+FY@0 zO%v^C%r>p851-xe!7}dN$8iKRS0m|Gk=o!*w0vw6 zvzV5Q(@<-v4g(q@c#y>i0M^CTMyiI{2ps=IQ{2#3k44M+<)ASfi*Sha=|JUI*(Fg@y*n^fh> zG*B7I4LG?y&mxm*{o?5*uT{U2a$i(oS|&f@>}yG6i`#5ka7OQ|aObX*{VjKstWJ>@ z=cSDI8$mu}uxXI~%Pj-n??hNwah=f~Cq&KrV}~43?+BS_RbJ6!OIK$oJO&6kF8{Y! z+brQ;R{m8}>mzS#T;~Q;X%_&DF3!a14B1bpE3G8Zi4$yx$744z(FW%@c*GI52LNJm zdP>YBmvK%lXsS(Q{osQ2d2g`nOR$2{-TcSPEKU%rZxXprSH+a#<1E=q!d8(O&)cE| z-S1B4-_}cI6zQef*Y;67u?{Vuegja!q1f0h3 z(<5)7!@x@{KobFf$l)qYm&~Tonfk;sG8Dog@~*RTXK9*1A4`B48kz-(|L8iqy8mP+ z4I$+}(8VWfToK!!e?my@%GAz;zcWF zPg>o6S#+;>blzf~%8E+^1mPdB?H^Uy3wx~(gXItm8lwl|aWUNxl@Z?=jm@90-wUta zl~t6go{!?i9_D+D2l^G(noBEP%YnBX_Jjt$bYwv6r2~yhU+3-3}&8ou7bS0ciYvra5q6}L{xeIG8pv& zC$H|H_a?pf$FhZSdQN+^OaBKSvvF?yzyU9wf8iWI+^yJxNY}wA%Txr68Xf$jd9ENLH0H ze?n@3965gv=rmXHuE$HpSBL9E+&3PJlK+kpJ@36%ldG@&BS(>v=>N~CrbXz8@J2(V z$#T9q6a<%g*zCZd^a@n)?MvO-;KRlvi|J@N>f}rTVk7lgkHia z`dJ$!H8OTVRIyTi3kr-F#9Af6|9ySK#J!8rmxT`K;pXJ$UxP1Y2k>KiP~2blJLylj z9nmHHKy%X8!$71polHJmIR(aIRS0Rq7r9`)Er1;LxbPmU&d%J|uXkSD0J<-<{}#lo zZo}m>kQ`_;ug>Ha?HpPP?gwgCVp5X_;QwdcknjOp zIDtWWG$$JLFGncUP$r(C9J?Wm-N_4*`+?_`i+8fEQt6Jv8D-P^Qnsc?${cx*{U?Y& z;JDFwqxFPSifUYiLdSF!`MZ2)ybU4g2~kc&lCHuTR*R@6NmLf^gZEb+y4MmoyRu+c zg28D=`41yvfuZ3^@qNGwk7``_?QR$ppAM^#d@`==Vp?z+2l2Vb`^PV3JRb#(ucQ~4?-NQw$+7{@m0LwIFVxn0t!ZiJE8YnWOhKc zM&9n+TZ|LT8lTt6Vv}1$oPtJ`b~fsr<#mESi?mZuVq)C29?Q9QYZv;)XN_bwPOY>E zy1kT~-I2GrLn05(V%qw2GwuY7kOhn2c)7dDa!IUVp;2d)9e}{)rh7$M+kbe(XA{lr z#c6H{-Q{s|wL+fyUwQXM{nKP9aJbH){Cr(|6+l2OppD4BGViTy380gzld3XI=hG%# zg=o0)2tDqBF<+C;B5OBqWUQ($#0@cCWuN=`%`3zyTGK=K!1Drt;3*~K(Nr_zL%%gp zZ1c(aWCNo93=&S`2|&9p{eNHSnvkfF;SkLf)NW zyq3$P!zJyqX3*nRR<3kH%zSx)$%d(47SQvEoe%enK&uI29LHdiAKS9doY&bwkcpuf z^hD@ALMU$z&HA_OB@G960uCX9l9Z;d-fXeQ_EZQOc=5~qgI3U>lBawEUNg2IfYMu) zgwko3=oaoj0_~Ko(>ivAXgXw8l{SH1axkC7o|uNZ5nqmhEwr!l3Z0JY~a_tT+bI;D?a!eYamYucVLPrzAll zf34`&t(#JOJP9>U6XdNP*Np?9?>(-v51mzK&ijaDuS02Nm}aC&D?9tRT?H+S4PFg^ z{OKU(M{;xYwGw99EY~QRX|zhyOT-dj3Y`q0(6k=UdAYp54k)6O<#+BBuwPpRo_r}& zXxsqIw$hkRp5L(_=|WNsdpKL5Ey`IZOPfRLTP`3b*te7#8S1j}nWI>iu z?5^Q1rv%gCr{>S{&>ln`(xTnGEqH3z>G||EW(691d4)-9UFbua7d?0kLwGTDEiF90 z#bJdb=C4gVMwf?jjsNAN-o!J6qtm;S1ZwLM`cv)Oz8y*+L6dAYlA>l{KU_})?UV)` zS=406VNVqh^$m>4v?@j0ewsS0Ay!*FBGY)RNh54ChFj-&5|YiS>liL=bT2&zrBNXg z9GGD5q6#F8`2QSfnpd=pwX9Ho%>&**N)ee@v1FS=qmw4^Jmu1XHH%)qV#X7*1 zX-Dx%a$|y7gEEkmW(z*w)?jALbGG?WXn1rADyrrKDPbw9WYI9oDnt)wy2&o8T+#&- z&<~1DiUSM0O;6MvDS0edi;t){DG7y%irWW@JW|UPtSy^|C`#N)s+rz_F?kx@7ky}N zB;uCe8q^XPLpax0dLx_t<>JFU#K;4WsHXUu_5)N=I_jU)T}&vwC|zkath)IyLR6-- zI2~9n-JGot7}Wgxc6SrxcX#xXektU335EFJ{l zb2Sq-t-pywWy&U^)1i3e-}=NW2CZq4d10>S(~ ztBOP^rgn6l>{lokj>wHYfjp942s|0DyWeL}C{+((Qz=r`^_dyZw?VOQ{^BTao(FJZ z+nIE1vxF{+dQb8EqjL6=N!*{#yVrE>M5>&=RJbO+?KOkEZ_j&tKDGtwC-XaHrdz$y zvFSA$@xQ4Uo>9?<`%p1!@x9?eJbj8Bb>gPNn5|Gzzm*HVU`29U6mAr{Swh{MTN`o2 zi#5E@y*Kp*{asJR?Q)@g!S4_8IN~^&r)^CrhJ|7dAL?QDuEO4`ktj7M%(+Rl;)r@xQ+R4 zjgP=Qolyn8|2|jD4CjE=T$3hRq1h-&@wI-HHyFnqejfvbXhXl9dy$eyvd8;2-$6;X z5iihe;(f}ae1_Ze+z>IWuqPJ!4Pd#pOtVKT`nb+1lNlO*vTu{|PjpP2;mC%`xKX0R zufaTb+iSrsWIzgcby(4-H9qyH;3CJevqxbWNmIGiM;mrynqR{|q?I0P`C9V0#$|b6 zIqTlaFzPkdT~@)#EWeQ2-XAy57__wj3We>$tc&pC+Or!*h`_Oi{|{bSH~6>+Tp7I` z8fNgkB)QvkC5Xnyo7G0~teRtyMXb*^g4=qc7%gMBo=yuTW&3GXzQk&NB{5wA>?G&a zl^tN)9XUguE9Wml?+2{Lifp1=_0zcgx7v{Q)=9Lf#`vu9jY9ds6kM!Y<8 z2ynW{P)6oW?H@99Z4+E<#t#V4K2oeGK$mv%f#{X+?7_(sj(b162DCG|Ty_QNluXIeO-wBec|@o<F<*w%ghT~s=_;jEc91}iCHbhm1m9qJ}0oVvZ1R3&B z(k6b3L#FW=v#p>2TDm@UNtsM>ZL#p^raMy*-l%sZD=N`{cH?Gtv+|(zkXjWm^dkTzC)YxtQgbz2Xk@`xj`j)I{(0*T#&=;31Nr)pm87!d64 zsFB&w6|t1>f8@G~4}Nz1o>JFM-39{+iM(_Qfqp<90Xz@G7+E)Nxf;)dZz~|zX+C=I z`TqWMi-siP%e^)d$O*SI0Tz6G!#^7lD-23P!o#Pd`8}|DVhHnQGzv^Ot2R`mxYv`U zS!tgN%0oogi3$!AHIq476>o>PP_7Ynl(XD60;&=;l5Xn6@}ufW(>==qGl0OMNg({~6yTmVnA z>O{n|iqyi~;>uFK-|J+vKXDD4Kgir${67@)t@K0Q;RHhFR?_QQH*UUty<&Bq6CKEA8!2eJ)}Eg|pA*=qJX!d9DLg^Rs5ZGK3HZ#j_JScbC5+M*Sy zm9?6snJV!*BqE6Qb?$K%;aVuU+9SnqXzI$MFc7-k*i{!>=4r`l6VQ zJLXrrjp_p>E158)gf+#IL+sIhPZ;=XD!wr==p6C6c@s665=K^jD=~4r5OR*~R8VNn z;Z@E&`|Pq(&?iveZ%~)Or{T1S&LsaRniW$N!N8!3cp|Ipt&*neEYZL6`ks*DDd|o?)QQ$mXVDujt3==bf9IM#;=E0~$lhBK7$s~PB%@}=(gVta z)nWPj1V1M~W^M3l#G`a@@WUfC-#(~7JY<;CIn&hN2lDKm#2=cqES?{vFQPMI&lNV} z?ON@sMSf|?q*0&u3zQ`7)H<&Xy)A(#gVtfO=P)y#7DC4+x6>q|LWkt^>>Gy3(s7r( zUwfX0JeQv}IKw%x!|zX@#8Z~k9EXo39T^%V79lI+jwm^TK^w(m*Ld8ci=g+$7gDxH zj^jdneFW;;_?!suV-=YK+@6U?MtuoNY=@eZHVqn-;St81G0!#A_)KJ;w=A`KU}Qzo z&otN<7bL3vb&6bUSGhsUG40Hj2XXOnRf<@eU86f6Z1J3IRzN%J`qE`!VCx#*9#@)X zV-lJ3WIU{$yd8@k&1@fGTa9o^>MTlPI>Wou0P+RIq*=(+DPLS>W<@w^RH|5wqNjLo z(TNy?5f|3LfW-hlkvmbipivo%oG26;NNSk8DfGG)e&R0vx*E&bNZ}&N=v^?sh$E~A zeiCIs-?d~ee0F9$qcU@JT#yI|3R4*b^D9JLyrBzIAp}k1J1tzzKIT;_4oMK5k9sA1 zj(%u7LFLgYaup9Hkw3cEy>O-Yg8mA_5oZuOnJB{J(Qvxto$bG-7(P+sn!7GLXwC#@ zFQV7SRbeso2b5<`KmD)4L8JB?xem)h7;C=~$MWz8`l{#RX+>)h&@-Tt5jcF}M+hN7 z{;2;|QcuHQg3pBiLE4_vuv{jeYC@fnIll{Z4bF#zZNIBHufD31B@z|OS?eK zV-VCR>I!5%yQ(==vTpKZ>z8l8g`miI4T^ctwA4M|?d)g4C1Hv`W^4Zd!XJisejb0C zH+*tHEj#e14i57-TMxBIzzEsaAyEY3pVw6^A3<}^_68_aG#nCm?y^@(#(U<~7BSiH z2rWIH_#gi_c`tJ77wdmh^TwzJdNzrdp zd4BDMl~e&p>F};1sS zAN1bQ+=3M$Z41Fj(Ts+PgU;WqN^$y>@5J_-F-cBuc)ugilT}tO*qW*#*#~p)xk0v$ z2MiE%U;OS^zxrr87K!qPP$Q&XzI5R3BB2S^IHs=?YuA9Ta0iq zIHJ(e5t&wzM<~Xp`jxhKrxox5Hr}NNLqxB%;eeo1vo{Q&MD#Za4bT;pX&)9Fjqs!} zp_{qirGeQcR=Eo1e3nw}J2oTYj_AC}`M;^7tau79H^moJzfzV3 z>_U`NVoL8-}17Z?S#9B?Q!}pU^@uycDdB8P<=5sg`vh5hb}vKDG;X8VpW*IQ*(hMa=Kak4j#x!oL|Xe|KyuD{1{26H zo%f5oPHGsChor^{NrB+*87%?_Id=pm*Mwur~ zfpv{u%iUz}6|K9G^A>?1p+Z9}IsY&RAvaCsHvkfehPD&MBjYVg?Ck(;Ah8!!Qxa{3 zolL@>@wtB+n!@eAj}WRKC9_fyt92wi#+irfaH`rOn|x@lhd1ne`DITaFw+!NpR;Cr z+Tv^h(PlutHQ4;1MS6h1-(6<$%INPMWg089PmjcDUU&&m$* z=7^5i_|kMV94(3bk4n9nqHB=6OA={ST4gzq%^!b-NH=}fL(oj3coD+$qvT2U5&&bi zH{1|H3_-_)sBBXtAvITy>+Kt+!DlO={Q``nDQQhlJ;YPwqDYHugM55q$PYWZb>?_XA=+_oBzQ z|ApHB0mhX8T_PsDSnZc$FO} z<4Gpn1rC@|=k!+@vRNz&=3q<8KkbdO>Ivt!+nOAAn&-!Yk=}`vsotR z2cMXbQzz}ZF5#!v?gZ+%wzM7fMGLE~2>nnCq}Xi(ih%gO??7C-8#U7{|Ci!(e@I4q zfk}gD6~)?BRmPmm#p}jLN{h7(rPWs4fB1T)d9toVzGLq-UK@&Y_)r@}_{>|Xd?n)Y zs$#J_p}FA4uGl6w#!zs-a_^+&5$XAq3+V1@#0URv+BkkEM1dpiIIhREC_E_j6E~={@c~x zTXy4u2;qu2qvH9Em$S~^r<$U#Sut90;ucrn5YGvsZ@w9iLS9J>8zOW=8vtJMczegn zTX((wIqmICx*PDM5=+g<*RZR04SUE0?8%|9o*TpjZ4NYWPedx zdgK;EMCz&-tEM9n>!O7*WI!T3%fu@01$Oi?b03gHYEYvF$K^Z#QUp5Sg;J9ZbtGvB zTZh0wv#6N;4cWiaBe<>Z?m-iICksQr7t6O}lVTn2-vg6wKq|%sX|yQ z5^c)*Oj`=VVS|WK@!3lQwR9#-=8{efNW<8g^;Ac9^?Zjq3`Yas9OIi5ZmeG^kHDqF zlyVWJ*!y2JoI4}q#U@B&m~99b@@6Ue%CZX;Y`6tx1$>>l8Okp=Ofx zzy`3seaq{PSZblcfl23x5>dfhwso#RW6t4hp|ppB=1(ct9dkIe{EVz|wcg-9a5qrP z4zY~R%T3mD^e?SNY#MBUqsC(w* z>mu4~Lz}S|<4IY}^tGJ7MmlFsncHW}COS+zrqsQ4r&+Rdoo4@;CFZJc>#RCAOnx^BHums$sO2L~HcyWn zDv=${f%0nf*&WIner{#^D%wrve3m+Oqch6vS!@|=>K`7u=NpX_s>J*-z7gM1_h#DY|BK+;7SvjST*_4>q4X!d08YSav3u%C)C)xAne@b_!E+YS!7! zDk`OHW{3c3+`;#&X3S)1{d%e`wN*ol3#-%@d zK{V7>dS-}8hgBKpYENOyxVm_Jou^24^o1=cadW(IEt~9Vx|Y6s-Mw3cC%4&$28u9+ z+!OtMW~O)R5J&Dh^W|w$JwJ_pHltNjNf}9o2}W+!*r)rr0$^^-rsP=LcKxsXqUZ33 z`j>k8p;*g%mfwQg9+4J2F=ZmGM2A5k7<|REb0WgwR56VTjuvq`p^hhxv&@+=Bn)-S$#%ccpi-&&SnO zSnJVu6Kmc~@UzCQh_c#VRnLMFd-#ql$?|j`eK^;v)4E_+2>mH4ae9MEF1y+TD{ymw zT8Q$7(Rt7H6IBu{5$=|HI7Y6SN&ZSl%}}c87XE?VG9$-H@>|5Iw~IM8ch6XUgTXUp zNE7AR94*^Wj|J3De+zbqi@WP_`^_wQsk*XPdwCrK`nCQ{o2(({3=ouFF%IN@id`Ro+( zlkV=CE8TFPq}P(9FaT8AelUnMG_u4l}SsW zAUqIFj?=n2u_WN>N`XcZ^}%tU^Tg{~VAJ)@Ik6|6LngGg4gqeXe`sxSpxl)|HU5kD zl(1h5t&Ck#!sM2=vv9s+Uvexf13|CvOp^U?4K(|DOpa>T^!($pxm$8%Um=sOX>0a6vWIzGGkil{30ryDi&NvRKG>AYv^Z*D}qxo zL=5pS&iqVa#&auhbjA2=U|wj4+VI_2tm>w(!*KU{(i_VSQwl~iU!S{5{+jRWHq0z~ z_;0ISZ!WeJ4)i#;vp}M3G|ke(XmsU*{lKhueVVQKk%ycP+srD?ALMq#(a-$Y{vJ=9a5>kobAwWqTG}hbnpBkHmi1Y_{E{} z;_-;chWNPL1#)cd#4+cmDJZYR_7U9?I}YkN zF|JVQ?7Gz-F0zW$)(g~?L%n(*$}&ib53hp?7z*~0fEv9fD*YRQJ)D(58ziYb2Se`i z)7}o*DkEO3pNrZ8pl3pSI-O}64-`&Z^shQ-{kZ|1O3tZ#|BDsomGmNEFVFvj0>&_@ zdQ_WZfM%PF{WnYxMf$&tJF&pn^}V~;Ysp#o&IozZ=+kjB%6BHR<%pl`P}HIJ$wX@y zq$k{TmN%iP_q~=hXz`$sHytXHHji>sG-*g#`0p2F%w*uV3qA2t`V%WGFW1}_8Fo;! zR^cx*u}sdSi5DILdtw&DSjG>2ymGEeVE31N0v~%NU`?%jBQHx-e?EQh^gW7H?hprV zc()%F63(6K-%iR6`hnM>w*H8Dpn2mze;nrCVyrNIac<@84A1B8rEBrIZWdgqh zDuG%64Y}Ze*<}-mD!c_yy5Bx&y7EbWXl+kdy>Hl2Ahd#Fl7gg#??G!0vXVkwJOtF_ zrx6T?TY{majaLDZs^txFI6*|P*2k;V6O5MOF)1Va-xe+@8v^p@9H{Q~nj2#oTuY=p zDBdLg$b}u)Akr08ii0mffsZz8NXu@e*(-2Hf;(06LC<+!+={tK5KT%p}DDlR^c zg@fb6ZELa4W}5j%vT^a3wCNENAm+Ejqljm7Uf;&3tymIWOD0WuH$f*#!`+amOqF&X z(6qmx#@~S(e0PO4I8E_^k&$F@U*XlBQqf%?7t1K7Uk}4!58f#^7VWH5n2B7VaxQnO znh2c)znA!z>wQlEY#C+KK!nq~Y4vUb&A@;41b(hvX^lrr?B#2N;uMn3n?aX@Fu`yw z_l7<^sh<``$6H*+7HcV7o=RVlGB1rDe^u0L%QEg5za9YM1thmO ze!7j)xba`j4>U?b0fK!0z}kci#vIqKuG`7x8|e^EcX^2TMFQ%*V*eZ$eaCecuu{T{ z<4;QH$0GQv6j{qBSf3GG$hqN2hrt|2R;KHhQO65dV#{c52mN}j_G4K~!n^3<;fKpl z7Sb1IrY*$)YBQP90E*2vszyxn;}s8K^Ca8XC>s;CQklR)&QBCd5oI*%kTS9;lKCRH z0KjV3tr~%1*F{mW!|4?;=@9Z&;QCL?y@i3jlzsDP{`h$&$j$41u846`VKINlMafLG z!xqXX@0mWyd#&4n!g<_v=c+XsF(G5((2aM5Puqy8bb^C{q{F92DkwPJR*7|Qs*jxa~F_7beBiA-TC4IHtRk4JzTUunzu}nX?;HDzLPTvq@foIFP>nuYFk5TaPKfJ@Va|RoonuymN+ZwXM>gmnF5W4?bxtvI}Ffo9_IaO#K z67bB@=X^&;5C-LVx$rVNb*PXTtJD27lk`_Zh|xs;&B=EOCBf&j<}kQ&U~YaXOWM*Q zvfLeTV;KQ~xiaT`HdIlt^@0n1hby-G{m~|D3tMB|ATvj zL1m`74r^6To6v%GAXJ7oV`o>YaE?3f(Gc?*H_}~|AbJ1KJ%2Az`Sm5J-DmvzFl%I1 z;v9)vCl_w96Mf6Xw099O>@@xq6C1CQRw?@o89K=#2XJ)Mk!D=x2&Ye>Mo6>MgkR%* zOPBH>V(TM^`9rl=vEwDEbMd;VtsZxg-}>C@&vj9_y+bO0qTb#j!w9Mg4EChD@w}<@ zRmQ!t5Jv1QjiOnQ&E%2=TbaJ^7RO_ z;5L@(lZu*jvtZW+%`>(tZ#*@GZ={>h)D{p4%H-akP7*%)w!kmSfj@xP4l>!Q+)+554crA`e*mNF2R_ItVba6d>?TxO0JN=UOLC@Lx6{dG1)Q{isI4~4BjW78j>-k+= zk*u>M=`uF7K|v{gjDta{9PGvRfuxv6+MniAm!w>;tr2I+hjf|s&?8mjZU&7kl3G0- zEO+~1cO&RVU=f$qEw|UHE_&ID`Wa*$uaRA!y5j%j**<{sDCms(kN#{M1gcgrl%j1| z6U*3Yz!L|#rP@~$z3c?PWQoo^(~28hC*JLTj7|3Bx!M}oQ7%j%aCM!C3<)mxJi9t?N*V5s99KFnh!S<7wDc!Tg zmrU#zh*cSaB!)dR&Z9v8PHB?l%{sbI-T#6F?82o_n^?|ZljTz@C|v~+Bj<_~Qi{lJ zN~U4_j!zWM)Kq}lKr~0dLkRO~;SeJAR-Pq6y@ zL!DdV*wazXn~VO>K6-xU1YHgTGB&)@`f2g8aqzadJXzziXh`4zrjYe0i&#NB5SKvQCrN+GSGzOoe^+M`5WPa2-J#Pcu8XvE$_}(E$ z?RtP%1}^4*RRGIng4C_?Tt_n92NYfubiC>Zz$A+hbD8Q4acAh@$^u_7c3yrj<G^mi%%IW^+~`l0%Ty$_S=_Pc5|e`37jt z*ItM>`@9vtll|@W8GQ00%pHWvlj0z?(`G_=$HTRI_v_mgOPHL)oOLZ9U027^OiIlf zvN<;s_SuO9=cBxdf7kqzdSw)C_Bc_(8HQLYSaJK#Ts(c7lp=6q4n-r~Iy($Co(=Lw zq#JG>V_H7njta~I@L4+yKV|tc&;f;sm#fx}(LphO`UH$Ohe#hjqzbnMtt6Je!QnGyGx&yEmUOg7+f3}Hg5x7gDLT)Da`3uA8^b%hbx3KiN&q2hl3KgAM! zq(4D`4%j8Q(y~8@@ZAD|Fz5un!CkBWhh|C*u0~u@wg`iPE_W^ zYB8u=qVpHhdF!cj%55coPImvJ&<>RMbb-}D0nYp#1hBIw z`fQH7Fnl!pd(Uo?m$4Y&^DQ*=ALd>Zl&a5do!#_m0Oy7bs}b;WA!8`$pUa=?yE0y? zZKjUdCV8N}8Sf2a8D5P=U1;{I0nY$iZ=|+vRJlYQrMFQU^v$Tm56kW58`fngT>(J`!MITbTfj-$fNo&_Z@9l^#JwHI6=b+ zi%C|!B^jj>8)k+2%lwI#q)R|XU68-?2sj=UvOa5>+XM)r(vYCu8^9xXwn?>srPvdiv zND{z=Qf_r#hjKt%r%?D|U%9pW1qpIb195G161zH0N|u0|8>$A)5gBcc3GR}p^9s5T z(GC{hOX}yg)XBPb)HCeka`|)ln@(>uy|t~d%?^+|A9y|tU+hreqr%R=eibI2zA!&T z{tbq%^yk}zzO*tird~SDPtc-&5S{JUU4mVTya)=gLwb~kYu?L5=Qpl4 zZ1@p``*U+$IrVAIi0)n5k=vhuL}@co^!FPwV%&kM23ay{&&UXgyBHT3KXRA7YpA2T3s^F3L&u-B&ik?Z}5SKE2b-dQuHN&JWDG7-sJ2o z8PlhuO0ul`rOx5+@0vQruPc9v9=?3DUK^{{`GYB^HogRI}>ZPyW-y@+mxb8kZxg!orCpIJ=Z2a-Kvr zKW#WuIV@i9%|e+Um)y+@bQbfh{0=(WoSLsqmqmj?S}tDB1#1x-+>;aTf0lRA1==-l z4TSJAi4iNQviGajoBAwm`0$UfxiKX<3;yghc!#t7+f7=SitXi)28fVA6W5v}6Tpjk zO_?%zq>0)i8%nLC6#K=xf{HanHM#avMR28{_4LKv8U`NE1whiJ&64{w$F+XRb5~+U z65F(2L;_PM%rL=xD=C_b=&?a8&(W)5WkH?yYFi$NX;FbUX0GIU1)f>)#SJG{#uZ3$K0SSeo zl<+WO|CRFG1tv|nN(8;!1;{}ihuB~pfnR>op%nCtPnxyk$Tq2|;R^VF>cFTDQ#Blh zS#!g&X%PUlp199Gq_;L`zr3>mN0_T(X7sCD^5ie_a*0}T@CunW3401cwJ;ma&jq{2 z>Wxh|!-V)xyCba1y{lK2Y2`gf(emjJZC}ArPpTXSy%FxQ`|q6|S)Crb$ZPcQ4crCw zoKz~$?TC0|Yjk&hVWyjWE;QdukqL`mSLI0GJz0}D6!=57qhFa<$MnVAl+-S*CZZAO zAryK4PJKbIncVqwy`VD#5!ScwFZ^nBi%HafBfkRgBJl~~Ya1W(&LzMqiiR=mu$Y}# ziS_5?=}hPPe9wSv1DWyF9>gwxF)4n|ghZfa|9g za+9z3IF@wk=DY6t-8eiZ-Y3yLOBR>Rojo7m!#UQ<4hf3BKs|IKr<3wF)IO`ILCJ~f zr~CEwZIYY%g+GHek~x(88boS{I|X7FJRxgy=Y`(DPX;gzr)voEfy5g1CoDiF23=h6 zC88&u@=wA$r^s#n&$N|%P~-#;Bnle5-_$hGCoNY2LU)&$l7qLq-0FdqF}Em+^?d)q z)vP~1^p_*NEh0#~Eq7Ij?m{kZ2*$yoJ)j<+sg=t%6P`=)Am_Q={!?W(BemOdG4sZqS?s6c(`q4VSXd4c(^fNu>aI)-f}}i^T(-kT{fd2c?CupE zQKexI`{JbkO0eItvO27QV!p` zfS;M>enG<<0fYcVvmhXyzC$Dt1ctVJW;52Az9hES zd%IVo@5m!&i(TVJ(=JBzZq3$TQ+A+pv7vrEA#{bwPsX)6BM)0dUSS@d<*k!y5Fm8k zHe!NsQxgacLXSXGA@_HCX^{F+Tj++kGF0BlcR@swH)O)+qDcM;hI(E3%BMf8NJv6iO7KK!09FVMrS$6N=Kld z2Em_TTLGUn^iX6MI->sGAeo0IWqKKU5!L#vW3qX;GSPM`#eFFYT`|P$ykZ+GM^S+< z`C%Cj#`4PezV>3e{%@Zs2lPWp{Cbq?+o2zzvX&gVxz2`H3t1zg*D`qkBYKDTPL9%8 z`j(}W2bV8_4RKS1A&U%kv^aI{Jb_q#1DC98Joo(mQ|~v6P|1nNw83~gy}Ex{@LHkJ zWEGbrxKAzR*a6v8faq)*Zk4Sm48*W~d+_jI^Wza52s6e0Z7J2>51>VCdA`%K91PO@ zDa|U1-+-truK`AL`Pg^_zjmg@!u<*rvnb>sy@a>gr;P>pKZs?1U#qm%p>84=L>6oh zFrk2cYA$pokS0d>Ik<0a0}Rjbuk8Gt{K{^{=X8*mH+}4>tdioINi-ZiW~Zuld(6D` zFX^7~B!)-`(zM_^TSH+UrXB(T&()S#^7w-U8gD?q@WBjcAdtP$nG_gV6YxJ1Pg_Xpekl_ z>QS-6DZXhsPZl=NXBzKb$T_QTde+JmG5?@bgO@%=k9sLNhY#R@z=Qb!+>~BDLY! zbIkj*J1}Kn&2v|G6eNdmTlenuM(+7*K@(xdh==3dU}$(#StI{*Or)wenx=LnA?&Fo ze*w<$lc_*44rBjv{bPHCg1oUF&YL`8lccn;co)g*%wf=uiD`o6(Thj!vh z+7Ac-oIii`*AK<*lRNgipVQ39#+bd6e_^u=BM-n>V2<#)^nK0wsdI-fiRqFak)=KD z4J+k-yRJWZ_mu~~7|3ruj^F26<(tW!QJ_~SU7-oLxF>q9R(kbwm=q}$r+l0#qtAlp z@a^5qJNmT1YSR9Ulgeq4@zwY-f8G&IJblLpHr$-esIBeY- zs#Ug6(D#7wM>$Y^pKoC4?|k?nLMT}iEs_n&e*(%dSLAk1(-nL0dPfMe<7O#B z89v}6fX!7QZl0Gex@bkSllVQJSae@~yN_w_C~x4Q7O^J(t-{}6(i4+Ndvy^+WX5OC_^YQ;i)i!RLTZk|YmqU1=($H_T8a$KjBb#o znq>3?Y?^5PO7z*}>$PndqJq^L)n)B(uNhs?Rv_ooKr8{H=YDWb9qmSis9nG%^r$zL z10ief!(@Smq;_%4Q{I+v$VEF4N@jY%dbR&%O!~nM1d9K0=oJDMrbKIrueQ=x87sdK zI}uIEPPua+*1NGok6bt{#&>(d@Y&SdwpNJEgzcUS_os-zB1==q73hR+@ub)s)-vw) zg_RaurAxoq%yM7*$aYps8ir>%9m+Qt{`S{YPN}3AN>E{V`5(DN6#f7SK<~%=k*<~k zV{7WrjZZ$IzS>y_KWf?Xv+!?M-DI?WE9b*dH7dpp7xg7CHCoYK-e_CJc}E$K_)M+2 zXU1f1OIK2Eb^CRNO`O-t3vnK)e>wY>D3Ur{zZc1f$KI~XOkH1ebRWCJ!!%O!<#qxR zt&==1g9&1JqCQLFnB3v~6d__gbCJ(P!oE}z`cN=sI`csu_ zaf}jq=T=L(W$6_%{~E>_sTxgRYGP--^8%{!JDc5McYi55of-6J@Jh-hEkGleX1LN* zt;#%W{`5s5b81*pMBLMcNG}OLzO9kXwJ7lxb7s!-Sj;dnH0{A4F4EO2@l{tXUx8=J zy&7~0C|kS_yGU^E3Sx&;ddYW(?l36DluPX3t}XdLPM-0sAtW0%4E~-@y+3OdE7nd% znUDrQ^{je~Dvp9!DEU6?#V*!n?I09O%ER+$|Be>%d~}j~USAg$^{T&%A~C(XCBq9- zGcQVm$Td7IlUjRbl|vqR_jYp@xx(u{|4XougkmU>`(gCMm#?)`2Gh}p9Z6m?e7PoHp2 zKU7L!*?JrZXD?0jXKD480{2)IOmo=%S@ofCKbnOR;IHp^F74#=nD#X4fywo1RKK|4 z108nz2NQ?l{#?~J%KqdoE;cQ>8F6!NmkZ!Lt63LgTA%9AUC$-*eP$TS30Jq?x2u>T z@Kj3Rf&+ukHSbxHe!mqp z#c^qKi_MN)^=$IG{UB$`c5UaCDsR4H{A>88Pq^G{Co56v%VL_I?Z}Z#ERz}GQy~|> zLc`iG*&d#Q3GAf3x|#37GUFO_^ONbu(d*GlhQ-$rg|q+ zp78(B&cke>F>(iCl310(p_Eq&mvRi&C1&s|G_du!>lY*Iw2296iF^C=LtJ~Xxq=;; zC?@Z6HJLWs5WOBf6Qke4u>>OAC8YF}ED}E|W!Q7&zRf=J;X#OcY=><*8kM2*8Nymo zS88oB&YyajQ3K-qIBDlI90)ac#5NP7+=Y@G7`vMWL*Wet>-VQK2Id*$#kXeBl0JmW ztR`k1Urqr!LESAutI#C%m`WIjr3=oitIRP0?7dD2I zt^sSpr?Wmx&(ay3#iOAHK0UwsPc234)_cLBEkw*s(9-3HeCaFrL^{>n@}5PcEPK?lJNDqhmed38)t}E1af}T!q0hn)mKq>X%<+YA zXOex3uF`jX`{%{wb`w-lkz(D+#NDn$O@{=lqilA+08GU=2Ypb2q5y+1Bik=xbqdu> z2rhy+fwi6TN5*`$NvD{aiVeoq=YL9KdCr{SbQVI|#aIgK_7fh_9#n#*D25YJJV31g zTNqMtlG=&e#70BYLvio){<@@m^xp$>913l*7hflPpv-r3mvQ|ihd|WT4T#&JnW^9P zHXs{i-8&OkZ~V2TYf6Fj;;|x8rcP}0mCsT+?Xeuh&?D!(kL3l}CotzAmWKp9aOfP4 zxC``9R-H2V_$9l1C)pCFw>c4Uwsa6k_VI^L6mSdRcuL4wh-1ePn3=af}VbT~d8^0!&IZv3zJ5@1?6;x-(~c&E|&bUl!f zgVq-vtwCXe)O9Gl1z%Nno*YMnd4l0=hiMhRQUy}SRSzMVtL?gH814w*yVpgEBdB;h z&s7LLwZhH>9|rTMTS%;DQ8y8L+XZi=hjBDsIstU*7!;EaL9T;j;3N6KB5Mu-U_Z@# znrg zDd@$%M8SelM@TkaEhKoqfKRFCNX0T8F|aSL zhjlDI@ylOXw*e^GKdL0Wa^(kKV_nh_?Mg+`{R^1tsTzx3$w2%cpr`N4RS75#KMHhe z1>^J=>LqY@tvY9c>T-*wAF#=cLk{(@KseY|Oks2mj58aP2mrPcO^;L=aZwEwzbUiu zz09Fq)!954CYP_glI?>!(EsTl2n{@lc%}Hh1SVH5cQn4&Hr_LXO}9)KxsnYtbfxbh zWdm#9Jl+EjbgBw6X}-PeKM$Td_||raIP&%SM8~V7hgoyav0Ng21#ODP- znBISC;%J1K*HrH@4vdA9A=f_6C1Ae*&)W+7lOF@m@qfH`?Ckg12ej@bJax0e^Aw5!@N`a~3NX|I#`!tKI>9Cxc&-(hrDk{YU=KDmG;gRw4q-^D6y(!kToWuLW+*QMuknL(9W0=IuRQu) zE%KLOx_Nzev`F#0vB=-MdlU=`!=X}_nrbj$RRbGe2I?aq4Jpiwx}F~)PQK>;P#B?* zP{xm-TxpM_s{+X`MYdBgRM%gjVzM{sl2kXz75@I(xws;3GX4dM*ZpRdAt08JIcyS7 z%|XyGEJ%_$*BbuQ6LlTb|DJN7vg(u@`(TA>sq_c;Dfcrg>KR9fNg37bSd4+9tkxEO zgR@>rKUGC8LhAu%CHS;^<|rsLxyVuWk;+H}jC!-HK7hbNK%Bz&99AbBq|4m>1v=po ztCRz~^-NU?3}lwU8|DMsqIim8As|`Z`w2W2c~3>E=}%-oK&pM$?^=!lvcx$3$Osmx zLPps$#1U|s9$?gbCQz#MW8Ic5Ni^)bPb2mNcArKDX655^Gh1nI>Dg_)KXzZrFio3H zU=C5flN$LcZdn5z(BRZ`0@;#1+up+@XEHy*@FzK)hMrSFbpCr2)*EsVN9k^pGTFza zp6&z-BtxMgZ+JWfyS{0rs4unqbRk=%R(}g3TeubDFn|0RRyrr2($nJ6+enUQksx`* z(E90lM1}FnpMTK|;_#UZE_NyJ{HVdN9J!Kr5oEwV<_rP8fe@E zZg4PjN984RXM%hc4i-uDq6VK8vEntsnF`tH3<0u(m*tmrx?rGHSCGvo?J- zeNvGyR=5k>tFL0rE?yAA&G0(gU=W{MSJzoGxK)}9IQCkR)a-FlE0IQG)G1cyW)@2M^_RQN=Q?jJ6RArV66YXwRAA9tz+;<7 zoP+RoQisGe2ASb#reBRwbx)4k^w?YO`uCH&U#r((H&R_<#n^a_aX%tCRa$YF($yuv zUJTI{dj1^wh)qp==!A8kh$&KV@cByhB&yPIZ{e08ZFZ+teNUge$oyAWk(WKvQ?2W~ zlx3JL>Z^U7oldbHz+e0o3NxS^q|)KX3M-f!2a4RQfl6TDAr;^E^UQ>oQN@@RW{PPT z@Qr6_f4Lg+7Ia~uT^AZtd0*;6>q${WCijFOiOb?pIADUY!z)TeUrXi1g~bIaU0GTO zYux073i_*nBF(b~*0?N3I)jS@hD#hd=QD7aHr{11R-!jumOVj1UqN2hLks;(xhi0l zVSSS;FKvT)*fo|%UF#dxk`*DM(?4Ak-DlqS0Tb&`X=HOdIi_Z6bFiUi{KLMhDLb}8 zP)VnUk_fPyq08tv`txoiG^jE2BN&edl>uf=Wmn1`h)om1C3mPH->T8@l; z7F^f9XU+V3<;iF+LxzbGg@SYLSq2eP}H}m7k?Yy@-G2-cevB z`)4S@I8EO|YZ`4uugpL--`cwe!? zxz8mO_(?B#E=&x6aLL~?{5U?9#1+t@U5gv#!RR|_oHKptx}bNgiHav5B-CNbYn+WI zmt2Fh-QEEe!8P*;mWW9^4KlqayUt=$ng)Ybc46fGMPY@xo^OaVhHW(IR8lE^bH7ry zoW71^atjAukQzuUPrn$!AkBplD|enG99#~^hPy{^**UZ-bgNL-&B>b!+q6lrVqJn% z{O*=E4$|?Z%H?5;Zq|?Vi!SxtW?L6uRb~J8@}pqRhTNz>to~i2 z;l_u0noN)B=5|31z@pPp7?s&ddAu&U^>s<*69hB+`Iq-?T{NX>=@k~1|8#P`P^&7J z;JkZW{FW9QMMlZ|`m8A7eO3#$(kEpS%IssU^G}`-wV8h=x3s5VDSCgyeacTy$LTlf zo0MMG!tiS(NQ7=^65tB0|x3LnN3y4C1O`>>@W+9o~BVPclBX@ zT_O2JCQ9ewhH{4XFk}oo!heYv1wPkRGdA(xV&bOQSMQFedG{-5qR~+Ty#N)CjjYk1 z{%y=NV|Jz@{W8f9Gh}@AB$5PyMceB1!RqE?#AU=vdG6AuSU5OHyeVUfP2C)kMn!+g z7Qp>NtR)t?drIXvRyCZBQZVe@bDBrf5A$>>;DatgK?tLHeZE z6HTvhjxED={(WkaXl)U4wX z-F>t=HLwEr-1e;vjC_ec4{qK+KvTDwv|NB?B@T}1+W6g6myw+I$T?kSi@O>6qN_%G z+1di0wrYQ*$ync?)LPZrUWJShQSat22gaeKPFcUlve?B9OgK~5sd9vVX?G?koBIXI zw91jtAZX9;V3HiALKMguoK*-nWA)76g{hq~)!yqJUi!%t5uG~txMIqMCSbtpiSKJl?D6AF&NOgsE08Wm3Dr-US3X=wzwJG6^48Att%!>Qjbh!HT0QWp?-aG32J4i z@Xbgq9c%wnAq!GTcIZ@A#IoB0dGcxAjkYm7$2l)bV4ZRAr422kmW6xcfZvG{WO&YQK|l42T?F(j5?+A}x~CQy#O?HEbz ze9$9a#|quu)n|_dV}2$%^d@mLY&ZT!ta{ew-kv9;yq40Kg>FC58kA*@e`gfgYvwl& z{L3K5Nxvc`Q2XiAkhsW@{b z>kVBA9c`yv>7_ZiiM2KCTMo*Ed0`BrN0BWj6SWxP_A~Ot57o7&Woxw-Psy}=Qd20= zX1eZ-|ENYY74@|%3{uw2mE}B_p1Tm@t;ovg`M^L5sd(lXxY`Y_Q&O={NwO!} zuU|RzB7poN1546@h@(t3NG>~fo?dCspPNi()Pl%K5JC8qUyS-}Kt@%_m9E(?WYzu5 zI^`OQ2z4lvy;^slQ(WonIAy+h4=dXqj1KZkc=79;JDoby+|kd1A*tEavb+8{7f z8k^sSJS#Y3w8?n!M~l5+H|1sQr|>nNogeVDp-$&tQ7sX*q(Bi2pTj*84%Ts6Nnmpt z&mt5Su^P8zTh75r$hj}`o(q~MF`M}(^hYRApn4sj^NzcegQnxaY(0rOS^>!pWM2VB0YT%kpynK*jk1Q$%K(e_8;epw*ftpE*H-Cq; zT7gmkjpeY{AJ|xpfv*Av`}+Xb6$!|*f^3zKr?pg~A>kZgWpnUVoxZ*~S_N%irdq~+ zO7$uA14|4@{Qr3dF8W8gf$JX{=zD2Uwax~q;aWi2`q;J(3`aSmSk9<(CY$7?Nf5aJ zLXk@c4ENJbo6f2*AdU8aeExh9Vo9M74&@Q>@DK^fPm+6yYNS+FMs|9-K_O zj54ejTUr1n@>^L4r|g>pw=4}|O^!p5gT(GOG>f&@Ffqyygh>SHGpE`b)l+;08T6s^ zi!*YnfITh3gZY2r{s;5Jj6Er80dpUu+Ezt2iI!V? zurnoJ6LSou)c6}1D&sff@OG>b{0JMm?JSgLLMAjj#R zdWO45->(u=`{0`Ca|+4}t(?{-!nlWAOrj9WZWsP%T{lvKN-6sn_}$?)^!)r_n|(Chk|w;g9)TeZS&-Ul?EH2xMB9BwD>J7}2%4Xa1)c=Sub!a=f&D zE`A>#Gm(DI{Kog+P1hOUefKRGbsSWqQnf$Sx%7f6=(WlgQ7YCklnt^*MOTj`vhG4g$Mla>PY}R6ZUuQz{C!&*SDjB za*POrnX%LjPr~{Xzr%xbTPwbnIoB{Fh-@X`N!v9WsrGd_jtAR4bQ0x~LF|7B|5SV= zb?D3W$EAU~S*;aA6vRCm){QhMJ6F!MbFtp{azaT(`)OSCQwsNw^yO|jApP~s$F=s5 z(;%KBrEAYj3Pw==f|ZLHN6u>8);sEYv(Tn3->#7wssw#;eIrZUF(dtEI8D~J}gAeH>(a7ET0~`U*3R~T)38>R{v73$gDyppU9`j30B7jvN#Onjv+Ia zCRiEiWlAXME<__X03$05xgvOi%W zjgxVABXdOrY7Iml?%X<9V|VaPrj>zt3 z&dXMAT??4Qu$1w%3sC73fNzCg-xf#C7dd|MYUV*#m>ApMV`HxoMUj@qhwd)v77$S>0RfRNNks$&ln_v9R2t5;_Wr&z z&iQA*dpO=#ajhrrYhH8yCfwI)rAkVITHDtoWi^DydUyn$IVU!v_>qPOqTB5vt4B#y zEL+s|jQ{?cQF4(O^8a(5)_J87`@!>dP7s~UphjfDO=fb%xUti@4K^Jco?WU9BfK4f#L# zS^0|Fd#bXT`Vo*8k-4b-OBGZCt61o@Sf&s$^U@*Higd-1bawoJD|Ex~v{JKTf3ez$ zpWv^)7?D_NvNm4GOZ%R`BHbRDPM_Gm0v*oah`oE9qV$u$%h)5LJu3M+8>v$BL#rr7 zmL*p|Rk?&89%PBDR@yi=1G69PB7I+>|8-u@wPy=%yb3w|HfaHZsD-OaVF(8Kka8!0cbpTz(hs*4$XQAaJ|`O)!m7lu1xKGXsn8jA_x^XiB?<6Lak5w z)SzV@g`$X4Er0~m#IZe8+3KJxbuI{2eg%8+yM)xjnC1+;GW$9zr5aS+|GtPzD-T_- zC!DK*b%_0c`H$&abMVqiPBKc~yBJz1jovhJJ=p&aFXjMD%JhzocIz7oX2yW$HD)-S zm?h9wT^~mZ=X`}1X)VAaByZj>(`nQFIjK44e{}(LB_VFD#Ip?5rBPaj$-quA&E(Q) z9D!CILIIkj;{D<+nITk}bs3Ma>lNx?V>SbNKcE?pM`B#lEtV^!PXO^0^zR3dH@97* ziZgA@4k(>M0Xx{EIY;tD?mbmaKqzbvv?W9ze#xv(ND8zgvG7tOeO@vyv{7c)AFm?A zms;xtYer3}IRN5Q=^2*PlCOh9dGmoU$ZxK*sd{TXYaRdr-|%}@SEMU^(u!|?wK#)z zbF#E|xj$h}z#&W1`u!N11qrSi4hm|H-&e_J`v)#^rX5EW`Z#G4gbe4n3ae$jP01GC z9dSuX@ZpzoTqR;ak@Q&oB|_NDTO)TgG4&ykMoPHZ{pEA-6|`x2c zy8H_eO5K07N**3uGd@!xdF%Cn<%rDLZZEu^MTXJLbi93tWj?|)r2{yOe-@#14XVAe zB7iiVhf^sf=J7j0-9(DW+FE9R4i1IifZ)i1mFDsExcYyN_#ZlMh{-yxT^4FAk*>Lz z5EDiCmlXrM3OhWcn&D8rn)ltMW6JnUG zWH|vP$tu6oaWD?f{aY0M{?>-D46KW*wqrm+;u$+^SPp?6KX=y|&i^$KsSV;3x&T1gy>KCF9S{fZDJ0Pmt{K1#6?=A=0pI4^!EEv_s z&kP|Q_T!)g7+RW;Gcb*U<60a1T!?}E)Nc$+4YV0(J@>jdbnl-Ky zd$No+(Fw{c1ihIF?d$oT10`B%uSJiaVqQeCU;ci=Ltx;VNL=1@x{YO z*w<)QAFCP4<@C)$!P>%8ElOsM+FJ+pQc0LTp$619-rtzNw+Dx`miwQOUv0l%mXqMv zy`yH#3axk3C2N%sWOMBmgebIg49%m?X9>nPHQ<=F1`E){mR}P=yN(*SviXLoTf5JU zs8c%T!LV=?=cc+7`D&9CSK=qUh+}So8N*=O0zUB7{BOH>N*g?#u*@sxaDQmV zh97M3Tjss+<0p`rc(KJh5(bFF<4Hc(50P{-m6<{cm{gXx+RT`)z$o+>;K7k{R2VB+ zg2nR4V>oD|KEnUhAN|@;Q5tc{@O55^dE<(7gEV^F>_@ zVKYoU00W@kgzO>awC*2USpQ81!1ezSNCmi^A~V9&6H*PFnADEpiAMZFi4Ynr7rshJ zmje-=p{%toDmBQEnwG3zDnc;cZh@`~1MC}cB?w4iT}!x#KVy2k*V%gx5Y7?LoRejk z58Hv$jZeGs+hIz`Ti`$2r8NncclxFU^UI+D9*um#i-_zW74I zj*o)lArMw(c|MOq88uY#JdT(crn71G94h$={ty_?4A)p%6cq;IHYX+SQ1V-F**-VU z6JO+v2n(hb25V+3rhwyE-t~i&PtH~^7YA8K;oKh-Q!5?eI$08S#A1L;^?S`muv;$V zRl63Riv}#S|CN$ZTS}0oFmo3qZV@S1qylk~cMxi)WF4yz9^AFmm zcd(EJ<6^vJW>>+VoD zJcGW0g{ogNz1Yx3li18$YK(M^Km5H*6>cD>cT@~lG(tS~7ZimA=@f7AfPDw!D;IL| zF4X;z;V2ABPBwLl%tkg>qDq%=J;f|@kKCzyoo{*POKeFs^SE4Gu3ExL{aK>zCLcKn zY)?yL_%oS2^=4dtAuv84k~NNH`zv8EkV00s%FvwjYOj&4RjsPUyd!|#glrs0b{8wS zSV|F*apH^I7vdVzpFrhV$)$KU(Kp6rClN!8wt|`9b)7;AHg%?s&-NcdW=T-SpU8;b z%%vzlLi%-ANH+~hMx67o?u+-N+c7CZyG;>#nkK9~X!Z-P=-qe8jOT7HD@sS19^8Bu zNB;O3vyMig(7}XPIzhd>kiFYhZmR-o^<60D7&t8w4d&Y+WMUp-=Hq{oYA%Wu?u^c> z{a#OoyCvEAs5|3QXereR*PF8YM^kU8E039qCN^AT_)3~K*xZw)eb`>K4JuTW7Y`Nu z&d_j7In40S#)hgq@IA!LR7hW>o5Z*1TU|l-XL5LY;7~1)j7IUm;o=#cK+0ghlKsuFMt8cUVZ$*PUFvEoj-i#%6-2Oq%fe!{#))p}ob6$z!~( z3}4pyZt5socy6lmF(^#5x;2N=n)#5v@AQ{_d0dI9yZVGiABj8|;?Y)%47)q1tbV;A zzMiK&63q<9dOnl!7Qreeo+?l>9-=dUC+` zJ+Nx0`-@UX+A!c4{gk2pm2g^g(~91TjK~jn{?V#grd(KFP=xUzaqEvFSb4(lrvy68&)R;g8+|QV=Va0$rN(TCKE=-uB*)sT_tD*l2$nbs6L&AU?(w$ZqGC z4@+7(oEE||4fHJ6>68y2`Wpv>(hFjPrdUdtr@~raCw->JmMUm#3 zC)Du>6Urb)6``?zXKEC1M{*ODuMlAxOC2(Yoh-XO^(G@)4iuB|Qw{gwZ>%+g`V^KU7s`M2m1=8;7^m*A_0zE-}#EEINZFw{|6dMO~Zveq70Ac=|N@ zJKpkuH)oPjpcx_Q<9FlX+HODz(ZBEC;Chfc+im|yy5I0(7wIK(?P}G-GR{ek|3f6# z(Cjlrx`z9N;1De(J;Z)^;m!KbJmz{crNnSEB@!~(p{J1#TGUx=a!cd`h}=SHAeqln zsnonYB%%4!%{Nf+X=kwR2}uGrI?sP!=R>#Fmq>ORncwh|ZaA)u$G@-Q*RT{G#Jmu~ z5N;UR9Ii(+d#~J6T=cv-#WNPOlUYq~;vi1R0`KVf!G~01UN+3J#i&j1gN z(0`eDLNs+rdJosTe+s?5(?8SSdsPYt3z}m^aWMDozJ;B9R=SqHTpdbI?Se-dy{gF% zKm+KRMQ3&wJbzz$RnyZLOn=GMjPJm97jFvsFf$THw*KH7?5xkh$+wgpsO18F!8LgM zip3*LpKx?ODBr)4m)JYChG{;g_dh{M=3D#U%K4$!OA7jO54EH9#B^HOxiYZjXc&Tm z|4XhBrw#)(nQ?F@s(9!JCzu8ExC|>|&quc42rBNoX-beD+|lK?zW)PxZyorP-P2!5p{pi72ae(;r-evAZXDRh=2l=^L;Z@Y zT{1d0VeEg}A@kWHw0iBqW|l*S1;$0qcvLMCWPFTZlmL-;J9Od)^qc-jmfmKX@CMbm z4Y6K60asq&pA7@>{r%6u4(xe(Z~s8fdlePP=!Z>duY_cdxVzQRcB=@aG3C#8 zBV-%Fe{9GLJyw-xk?9~xI}J0n|E;eP{oi@^)KTUhR@~+U9C7xhDXoKT?NGGyACGa& zpWeFO($)uXjz#$KV1_1S`M3rn!4E`6#L>oWSbAe;Em(L3gMwYpW6`~1ZvZ9a&FDXD!wI45(F;P;929YDj@Y@ z0|t7uqxX-IsDdUaqk=*hw}s3rB|lMqeB`c`m%WY*|{R^s}x z^4f|}@SeZJ8% z;0etUKgiCPp4<#|Wqox*OFpB_W*d7H_@$|t$I9v45raT=|6v!>@KugQo|45=sq^El? z#MmDOx?QwQENrS*YMftIK_RFC!n*wLz*OlyCKdE0(vDKt2iU}4$p0J1T&%2l`2F|z z!00mvklnZzW4UyUD_FRdqt!^$uU^O1tPvkEH>9aniBciYx)t$Eligd>iNEJj)sDOT z>&^x0e+c#V+kzn3OY7sU_6fNMul90hQhLGo1Oh1l0gh^^}UW_v{YHr|9m+>I_d{B+^e zcIMhECBvSNoEpAOl>`~5PrsYC*D6rlhe3%rU^ErXp&z&QpN(e6N`1sE7D4;{v*^ln zpR8&`lEfO%ew4eWFmrzW*JXJ!rEt?UpPy$5ClwP5nhIyn%o;h=sC4ji^sn_bP2 zf${Z^;A=0_U|X?(jI(}yt=vEVT@o0BVjI$oe-Mxj$RS^9Q*yAtjw#qu-lA+pmnz69 zsrw>?Co>Q-$;y&qMTvR!QCyG6&Z9IH6PH!~W+xZ8M znD*jxwe-eWI{S2sURlw_7f#$lpO7D))~eY#7|v5hDwYUXPHokBpBY{pq}JSdJGX|B{v5HiBb)-Fv_jDPturs#4H?}i*!$uQKjC8hy`2lRW&+Z9!^}7WQ;81?d8nPoH`kVkW&MP1_>0j3(l!qMPi+ zQJg!tV&}7=t0FIJR<3q!R5?&jPKirHifg69sVXMUSmjHbjW-7qgOTWLtUXgXAx330 zz64oM7;&m>l`yf62W^#kwU#-q|4}L<8oD90B zs(-A|8-1h~<<&xizV%nip`E!)H*{C}Xyj-#yI>B2g~iUBjFmX&%x#au*8!1mI^`3(1-i}Ib`ad7-_P|K7u&S(D0d>M9NnKN@2s7P0uWEFWQt+>A@A;o5 z{r*pW>Ku+d)~n+YBK{|_+K-0ooxYMrT(nGMS2ZfDhV~hu5Fa<`dTTRvXo!#n--_u| zM{i}EOXas4xSLDtk+UkCa+}QSMo`NaXKv<`9=*zCr0C3^R0(R+Y#xoKP8MZhU( zk8Tgt%c*Zul<4D_WNM$yvItNVhfAE-tjbu}xn9|bjkz_6C|RnLbZD7<;`0D(SYWSADS3!)&x;|Q9n=BPw`|+ z3wlOuax;x}iRNoA3PnHB_@q|5%zKE>DS5$-;N7F8QEG8lj<1uvMzQh$7ju_UFR)>a zAl;l2UJ)nLrm|drtP*c87Us3VFon@1JDPytiW}Vi@_YNMjiwE2+brXy=63YYrGExW za?C$D{^wITtl3ki*zABf*4EOBP`B|kkiA_=b^fFy?vIn;t06e(P5P~Z-&?2D9Pt8ywIBU*@46?h7@MG^r%uj|h6xgb-!g033dCH1U~JJgAl!|2 zh!0FWl3XuPX0Hz;rn}dU>V=X^qITto+-!pxxPeoTe$m6`RsSl15(y>`^{tUVvr1%w zKU3E*CCvhHOunezX%#;x@0?v08eW)e@gE@I?Zh$QbGPq_L2QhqC~?KK8J-U?#*qq9 zw^Bunu9kcv?cbvBys7lmQ8Ut;g~NhpyDwe-~Dp1zHIL2TF*0*BHNx>eS3;y}Aa zaiG@d+}vTyN)s#CvRIMHSd)u8PuK-P_C88{rmYptSEl$J#!wOs6NcFP;qy9+qe;no ztl4z`Slfl?``0~hXA50dPClLGUuxl>Ex<)I>bQT&E`xmW)I$z(E9UEfAyW3HD9$Br z_&tmFW2Px#obzJyJl0a8F5F|PmD{lmnWanwkHSY>iSSXOZ07r))?H?+1t?@V-q7MX zToqog=h45xAU}(v(ZyYufJw`da(jiyvYVVe9om3)g5%phX&Y#Btzz6JUGQa#L+pQP zrL5z_m_KMZJsmHE{~VtYYuGKbd=P$VPE0xbSiLbzW4N9BMei!cczvgo#xOnPu1+B| z($JQ?ZO(C>y=9U+WYr+5VwI389kaIm(eLbo$<(qwbg8E)GRJ%lRb269#7E4Q>m6Xb|Q`qQ)kQY2T1G}$NT}7VF5&CfK<*P=9&{? z&~eUX^LQgPAfQ*9aht3GRVz6Ng>@d^yI?^8g-n;FF$lvgJ-x%rY`=?@&CN}~!5B8w zS%Vg99lRIyme0ZJ7ieC-+w;ee4D_j5BhM3iC}r1&bC?SiXHlch9jZ>RU}Fju2`7SNR?@Y9dLO1)ZMPWC!!Y=aF_Vmi=l*z>X!oYPZ0uF zW`5`#_RiVhbd0+txTy4?HVv##uoC{2M)WmisW+y)7h*57prRJ1yo83=LwsEn%oRcs z@4tRka`Gg^&6}0iX(;2m#h*T*ho6|5IJ~hUq@BH$l$7eG--%rc4PJ1+T{zCi zAQvCvce|7uly?u_W0;VTNWgR`RB&jmzQc6IrcFMGC7~ zTE6=-203FMGM6Qk53oKJzd!86sy0QzZCC-@C5}PvwTVr7${AR=NX>}v&=db+l^nbEjKqh!8bUAiqcQI)BRpymWJ!pBRo49lwssIjTniUyQoW<5F}OK9V;Y zazP`6?ocAP=nvWmOp79bpMqt^*T<`cB=ejCuMYd)sCJzV!m}SW_T>oKgV8|CQNk4J zL&kh^4>A{FVW3JoW%!$cdLnp${Z{6^nc6u7%w^4BLW27l6o)4s)?Ml6AKCPBg`+aS z{v!l`up~z@H(Mm1XEUTdVSSFkg#tx_)#pRf@=bbHi-I3x)(b+9Q|4OBXjs(}p->F_ ze%!Spk#PxSxsiZdJ7N#N)Jc+4QUc0VE9irCF{(7sAOm(!%jox&)-h%H=HzvRX${m2 z$l0Gv25ST)RQ@p`SH;C1i^+_+cVQ17ZmfP(=AHlN|8~IR2mWa2Nw7JBXF#oZZJHsn6OvVKSF-Z8Z*2K%>!5YAXskCRBoNaP>@YN>SsywBk5 zE#b>qKtQHx{U-45{R~P_M2%AwaN@{fAZf!L9MHopk-D^ zyQg9E+q+9}Fv1r!$a6A*TzD_Xapskyo$Nr^6&Yy0_xa@lw7W*3qk0qJsYCtq_k)9Q z)U2P(;iOO^@~j6~nsW^V4=gMS;;30_ke6f;^yw*+sS84bumF3Kmv{f$izijE;bTl+ zehbOo^ugMPX5MSaHhhx4jdL*KB^6LxNz@n!2wE0h{t0oiqbBnJwy8Fb2b;A zVg-DjoqqvDc0oxe<#VqumLyZz1(YnsT5ubhcWPvzw3C&bOGD*=!v%0e>t7kN=nUso zINqL*K-CA->r*l60}iwPL*=A6;%PvDo04aE{7pGv$o7 z@B=mvB7}YNzdw~IMnGxJfW!z++qd6}haZNW3p}Isa{o|0bPlcN{D`bUv<_#+qf@ox z5BP4^tq&jUKRixvOyh?)=sgpYY)=+l)H%*`@hN^fM#g;+-w(ujm+vmm_4vg~S&X}- z#v~-$?kT~e4cr_Y%EZKSb_2J*8B!nKC46_`QzAW}+f|%`vdsuxdYyr2#Y(YpzlNOe zN^TILjKLkrQP>jkU25>rOZq~Ey8$^mZ43wMuaqE zKmCH0g}%Wo6lvmpw%w&o%Igo(FH~(bhOl#`Jz(3xAd=~75GUzjn1I)`0}cFE&TzEO zl*JV#Gy1sJY5$KY0maj^+1YZ6M)|V_hN%&avFFCmmM=1E7Cm~eaTy$E^yEG}lZ1!Y z(NW0&ChrJ;zC2y^64)?Y8x8!wTO!$vO2HuO(+OC#rY@lSb@p-Xx_qaIfy?&sh|H6q{K< z0~Wf*_ES2X;kri9o6qq0&ADDJME##gH^|&g^tS4MTWf)qCH8a(LgU7*9!sDBrzt9a z6iH^8zSj@-$@vy{Yqea1lJ+y!NYEi?H<{+%u+hePW^(<|$KDRFXmq4;=8a`oNq`8y zQaZRR(yAK}gfFub*6_dK;lQ06FQodkebwmJ_k>t4yehW|xl=xaN-!Lyittga%E{Un zh|katF5BYhKlYIxa~9TT`XpH7??WS=ngAwl>oWX$la`VadK;R(_>CJ$xdRA&8pbG#tIB{@;OsSeb`tazjYO2< zUfVd1S0yhrwLwuHkg6NmVDMeLzTzSdA^#FTdSV)GiQ3OIwGP6T#tlqG=X%BoBH_4~z6d2hUWIsz=w zet&yy9k=eJdpJ*-$KGdhE8En7e$CebO`zBa{#>8TKW@x4o-eU;pkPW=$QLD@qW9I~ zJgA9hSIzQpYyAtHeZiLFjJaVv+q>-|w{{I+Er&Z|z^?t6rw&B%O(T=nxKV!RW~*gM zb=qbvDqX==blX?oD2T^j=|SYO{)A>S)`hw5>NANext9Lp%Ekk@r3Sx!k@1VWS-fT~ zRA^_K*Hp!pHly!#*p(<)Bl=SWcs@`#wag)l2pincWWGj`D#god% zLXEBDwnjCzQ!JO#-T0%D-o1fTMZh#9U&HnN`Jw-vTT_B%%=zh|2y$a#-HhH;2IVvI z6b?fX2aF>$wO$ZU8f&M|8DSIPTr|yb+!&ex6Qj_!hT+QUNLj{Kl{T{s=REn>>lzA` zj9Fc>a7;Srywy3bB7wYYJG%DGE87W!qtt)g>S8Q%7LWLAZ$f#{L3uTzVY=ck zGq4wAI&M5+&5Q^|d6ZHaQS?Bh>^S2#2VUH*vVu*%hFOsuEK?q1J*%gT4f19ZTwW^< zF}7Fjw!c5Ph1UYp?-w^bE_Ea3CC>yOR4i*H&s>1sS@_{1jrx4DLjWkMcu5IDjJjD13Atx$W|!ZAZi79#PVL$AV;Hg zoZ11hHufX*cibY=c@o)@qI!H6De)_bK7V7BPNq3(yCA$*opU`_YmMRL%5U}kz#I() zjW{uM+`Gcv#Ckb$1(Xljm^$zF z!(}Wwp4u<&%Bus!a!qHNyJLk1AFpkx&P@{k#);n4J3O+BvGtzqVJCXcT>=r-)#R%8 zof;aZsa~O~+}L8#59E9PI(@-z_A;*` zH)db#ale2T=573n@mkS2-iFr3k(1CPwfm>@3z46DnSTphcPhQ>_b<~(cQ{TdeRYq^ zYq-Mg;wK&(VK*K*sV_edUIH5D2Z?MxDS`>osJ`Mz{W=1r#i$L&_eB)P5}F_0igp*( zH)`Bhb*32%7fP1-%k2yAX_n;$b0gznL_(VJEWt7*mU&m$b6KSpv-aBPqG0hmuP5Ak zB^tSyhg4YY-7e36S))HO%i@?ls&NgF+xQMFVf?en<+bGA?XnE^nUBM`e#f?TO=1_v&i2*<3s-%ma-GADC(P$thSh!cxbWg0u#tZ4LK9Go#QMTjFo*?b-stZ(lw%L;Fr0HDNoOe8ra1Xp`i*oBq~ywmE#0l+ z&?-w-x$oFEKQtyk`F~NSNYN!L+-g=oFaBFMX^%l4+g?_H6C->-ZKn9y4=eNw>40s# zO`r|5&Q#K-NwS#Zlodg7fpJQ1oZ8R1#wy%CU_!*QbewLtyWz8zT{w__k+oczWe+%E zttx*%6+H41Xb-b*YS}T7mm%pRv}c}XK`^v3)$}^Oesspp?@X5wZ;56n08hx4AbF~r zP=ihUdT=fB44mE2@Y}EJnbJaApP$2Ti|2EoH%)cUDPQIItAxZL4A>PozV)8XhP%sV zxTI)xvBxRlidTj)$Tv9A_e{(+uBG|;s+vt)?S}|GNe}uGa!1bLyq}q2(r<+veYRv*n_n<(K2f^;@%cx6BFxr#z4JM@!_X;b z`xH+_5!JhhajW?T*e83cJp3Fb)~wKquar4t5`UE(AhE_IH86lxE!}IpqiS)lXj#=n z=FT5-j0T9EpwRV)HfBzb6@T0d)c8Tqa)P(^33jQ79$Q{c{%O@_>x8colV&c9Hl?<| z*`;tuF4|E)ES8*P;oJ|%(({cQwFViOu^tosA&Qq+P5JYQR6^l(q`#KnQ0e>tjk^wp zbux#9to<~B`EaRn#VKC7W}=bwokp5CEx}kDnUkaoT zoUMe9njyr*({pdXQWAGY-E|C#K#!9`3~jpp?Dl=z7)6=4-AD@MY|R;o^)@Ss-za4* zGfom2XYm}%Pb-AtcaKC?8EEFQb^Wg-Ai4$4M&HwqqA?*s9pcgEYq-raA>rCj{F$B; ze9Pn66R78wahFP^)@U6*$J7z*kWQKd#||R}H*eaBk$;_6H5=i}R)pgZ>Eo;4;+rn^ zBSgmQatAq5FIitrhvf*KA~+QPtbd?whuW0a_4Y}`JB|{l)aavKR&)PehKWS&FUyZj zJ)BuiwOHWQZcZDr`?72ZWeTAw9FjG=>eYRW`*aTv^LWQk11!q}HqPlnH>q_aIWlOu zT?A|}QlsJ%)i6EK5O0!p^MAM~GmBV8>I?sF*XJx85)rP~oPOD*S$cG1k;q;QM%sH> zLv)Ca_>O~N8Fzj9yeD9s2NKihwfM|@aIFeYjf}ao)N?*)TRdtax#pY=UTG_Sg`x(I zg}}n6u>SWDt(IX(@@n`hBKb<5KFZ2Lhs9Y)QZl7hND>LXs+%wNLOq3H2tC;ahhDd7 z!R;N!^E-qp*XMLt`yNQ7%;HYxmR1E9XmN}JYO3xE91Yon4A5Dk=N^HLlJ$37TEm`w zagRFMt|}d2USeohW*TFwf5e7LxS%Dq@TOaBe5kLes;!d{(bORtlCXyHSRRdqTvCYf zSH$b3OQa*w^8}lFl$6Q8OQ!18yg+OZQ@L_%5p(dH9h8Eg78o%ctv=p5NwmL8DBk0x zl*fw1nFPq({W(znG3Vf(zAKJe#^dIHG4j)7F7H#B16;VJH2?Xt)Vz}kkIb~8Dal@4 z4|IbIMNX9cM@9`XMvviF8`L0geL z`R_mDt{XFRg9s!P#>1ux5)H?Y^E+i=o@bu%=>iX(k+S`z*!xD0ZCp2WGgO3_RWEVcLQC;C0&AEIzRj@x|@CWi}{7sTN+M3I5MChVAvP0 zzC4_%&F#3^=k@+Ziv<*kdhxjnG0%L4DF70KUs~!R?wR;ueo>SiTico2Jt@P!)2h)) z=d_KaKnySK(C7us`5>LT0}u{$e*|S6J2WR}?@+MV24$q$vi%0_20% zDYHWMpQhZ~N54%pzkwf#^Nm8mxlIz@5bTz*38?fTkbD@Dbnt5c+(75R%L2CABFF&u ztSjX};R9`OIA{wqZ|!Bidc%EjzK+ZD9R!BeKu}0ru<8?#+FCw02sGRYuTM-nJ+GQg z4>|eM?O!2`axJ-25ap~%p7kwSNW!xWTsFmDfYN7Wcuun(RRgdn2LKB@O0c~ufAJ3n z){WaI*}5xg>A0dcJ+9Pd99JH$+MN9PIS}x>kxkuMxb?szh|CpAZ2v2PXMr@-|}YohO+H{yi;~q~%WSKLBrPe0+p= zsH*l}eYye;#IbRZhh~uQ2zARO(A*b(6#qKPBN#Y#!&-N&!ABooHe5@QEhS}YMZrMa zL=t}t$UEhlG9MSd-*}}1!pd*oWTcdm07g$&8Feab=zzD~d>qC-ZWbG02EwaVESWD~ z2(iDagl8VM_#1i(L#AF6p+I>+%6jkM53q23`TfV9KmVbGhJ#G~mll#f6+v|y>lu1- zxhC?!5IcLHH9$g~PHb|ur;{y-p>mvnxQ!y<59B2EHAVT^Kgy#toiX+8u)!R=zR@Dt zuU48Z9;|`A63YaVt&mycuapC#-X6C7*5merh3J*z9Yg5Hg~X{M?m#o4S5S0>o?%76P%k*(Tc19lTIz~rreAeO{`>$ z-)sLpyHUoAe#Z1WdCh*Qub4QF@D7GYED|~91q>|tZfLvoVU|(9A1Zf&v2K6yMxsEF zBI--0%GyV)jA`JhMb?+Qn(h0}k8~x|*Y->KU$cSPMjXZAS_0Eoe>mTBJ4CxPQc0VT zScVPmg4@S)bb;No3kd%3R=hq>xpH)Ws1W=$b->UX2zbWciFHjyR7-MObYh9~-vGO# z$>gUGA~R$C6@?O>EMrfEB`1bhoVS94KzidxPC{elza+1f$A`0zR>q`th+#>8c{1C; zCRFAIJVi!`FkTa%|6VvcY_I){7M)NF=H+7mMSYil>mbNuFo8wbxc(t@4&~x_kzV!w=!ZJ2J>x zv!_>GseDe5kM0mIg-2gV9tEFPNFCdcZmGF+UewpL6GJ0o^S421^TAeJnT_$ti8FP% zn5(V`hb+s7m9*wieR3~Fbo6}|tF@;UQ!9F>|E$^gey37vc4(Ecr?Y-y!6$&m!*EN+ zg$Qp0QM?@$LS2a^%s; zQc0h8i}z%DSCI}{c&$e({F$BJOd~ZY58i%XKmP=Qg{@izp%I~SJ|63>O+rj3zW#V( z6?7JzzLv(}n&;;cY^zTvFcudc<`|J3E;it>J|pNqSfE|4thjh*?Z@D6xOPqiJG77MzB+{YKDjY zW!*XtH9B$1CkYDdYxsl+BXNne0;2UQ?*DvViWjzgM#W~*Q1^pL z79IWfZe2dmKm`q8aWH^2klT6oJ7Y`2RF17rNE)r-Z_-?PX67GY{Mo(NFX_9FlRS|x zo+2U9{31ANB&Vah++az$HuSH8>9;jzA3Z;*7zK)+_)FGrV%iwLCsu%Fy4Vb8&mFgDf|na9SmhbLWnH6%jUMJ{=6w7ING0rsJp-cZ<2L z;X`8bMrVo3G=`fp?%upv$S7|CtqGb9QsgLgu3J) z5;r@3(z(F}3hwMJ8(FAGTdMd%P8`LMGOU@q{X#cIEh;6_h;2$5Wu29iqj-?`ok$q# zts};vWNds}_wucGQfZCboniUq%7 zJ7HO_j@wRH<}jy6&tmKdv74%pKWN5Cll>j$Ioeg4F>kq~>rd5iV>{r7BKe9g5m~0P z-xuTUZi;8vJ#wRZsWY(H&p7`$HP<=y`-k8SaA3P`fgkD-o$e zm-Yh$3U!_rhPcW+2uM%Ditg(1wa*XZ?oWPxs_4v^%Dve@Jf1E3itok@?jH6jIR}?J%Ur_3{BgGscT#gLDfd(!O86Du4}J zqbG-C*9N@H@CuyE!?}Mw#w>7W{w74f!OK6eo)6w`T5K}B%YYxAo<}RXo5+(AmfJlC zmB;=>uo*5t7yZuicdaOBd&#^hPc!^^WNX!kG&?Yb?Z;wa0qU^~7tgQ+ z?wk5+Ld6U9_Q_`Tn6+{Dh^PdwNZ)1XBUekNi*vPm7s;J`jq4*Xm0g7Ssp^GK4E^N8 z>ZgzqCb_Tv;TY3U#-%oe^d^b;e2Fe2!pH5vMalG{gZ4T{wP_QCG*${bJ>g8eG39tfh#TGx2PA+vt0Hxh>KJB=-TZ_i_2P9g-5xfK^MSu#9IQ{Q zQ+O4*Pvyo+(j?0%Q?!bIidcdxi_wWm3I#9D9GyWwiL=5(%)Ew#9OD~qgw1+giWLau z$%RO@=Ws;>p=F5ZFS=9$+FtIRMame-Z6A{QZ?KFkdN7;6xnd)KtySZ#t)fn@Fjhhs z3FFQC#_LnOoiu-8lp@x zUL8{+8yPo7-l3pBmoO~_S_m$#w9EXjG61AZ$SvfRk3dVRC~!A3u#Z|^LMV6pGv2CH zdi~@oBingNTs%ti2U#D8!TA61muDS7NS~f7tP^6|540}!$-AXetz>wCh35kNWClt& z8Os+?{#xaR{h=D+H)NhB(e$3Bq9d3=P=XJhrWLa-9-XG9=H>AO65OE8O=4i9>9VV0PYGRRIecZ zQKehB`Mdh_D{BGjT>$w6!+CTOHjxFHPafFnizD}^?}4R%`;IUp>?vpKY~ibs+lzrU z);nO)}bj~X^zXwqc!7j&IYVP;8r-tAh24mCtxloBw0>%xL7C*LrDAM8nwhNNQTGtBV8 z+TaYM8r#G2)AG>XFb2D=9HcV40%^o=#zcSVh;GN%21#QtBSP0A9!4eKFEE8JK;pyg z7EvqNaL^EixbeWcYBIS}3b0(h!Qa3&3)ed2?{!P{-y76CYUNN}?J8rtY|wdnbDezS zaj$LNMW6VOgzeR0pArJ~+O$h0-&*QuI+s&8a|$wOoB0urfTc;q68W#EX$RoxA~LwR zxCN-NH%K!-n#Z?WSCZjEIRtkDs_`f%e3VG?61Y7w`$Mb*A{n@)0ifG_rJj5nV+1vXj=f*`&FwppJ!)r zNNrq7N(-*BMBS??wv$L6CA7LT==}-)Z0Yyrz2ak%zssGd_333NfCL82l+pi{S;Ds~vU)(&?mYT?X& zP7@I;>Z64jDPU=9WRIAJCw+!YpU&YeLhIqVkx7kZW1j=~jn2P`)6_lc>W?i25@=4( zv#cR?X4CU?T(NJ7_Ti`&xq14neaM(=O_GX=w(U>uFoGdmF%}2m$Z0f;NqaMuG$(R{vrVuxR&nq8|IiM9|0(} z{P>%Ya`b)hOT{9N=r4tj;1Awtg5yvcVJ$?mly}lP`wT&$y6Zr;N#mTfGyf)oCl}FY zVSkqjSBEyqS{s|}$`vC*1IKT_M_D=`RFQa)>^H3jRvlbZt+Hmo?=xkRaDkzuzw#6g zXgLYswZl6H7niTTY5hI=6^Gu@(NPCs6lxtsgOb&+bclFOoe87!cu$@CfvQ5}$9ODI-Y!u0kzZqN$+>oL7$7Tcno({F*3tNM#l%P>Y zltxLXB^+fFmzm0H5g~L>po^)W$~jG|#hWq`XGIAEBijQeZdt%S;8em2bfu5U&Gc!y zaX!DJ?ZGi1V8bVoMSABIkOOB>uZ}Z8NGS^GnBZ5aL@a!bwzli~aoG)>ICY5T#I4QikXbTp8-B!<4zYzQX>~l4rSm2S)p5O+F=9yWy} z*!?p_(AT~_pk9dc9_7p0yfNgMc%v=T{Al`yLzU&DPq)+8!x14P04F+!c)HKAO!fj{ zqB|p-a>-~3zJ}5}=)T5Xw+D)SDC3K7vP4(`{wsH#Sux9QjwxYvY=et4QR7)%i^j0P zsytDgP&S%i@^DwAH|LcsM-HZ*-I0f1^k)z~5HZ+0Lg|fR6@FQ27tS)X5MY+G2OJ%y zk?xxBzQBfK$6L}(R|yfM%GKi|LV|dx7#@+yEZ&S`gEIB&c~@}53;L%|;IXf-8%!T& z=WYAl(sqE3h8+}e9=oC~O`EhuGiRM~oV-lKo&35I4=)^6!6DAP}>h;w9 zZD!@GaV;^WF`~9;?*l4s-1uR1*^W&su;P=M8O=~;&NkR_7WZgp#}z9=(#dG!);rBV zz2z5jnECGL!e9R3-kOd!q0;HG=DHfm7ZE#{sM+n%Xzk-Xs8Bu3#319JI+-N>5Km-` zA!u5%EI091D#m@?1Rq8K7(3Z znz+Ak95PvySQJ{$Uu7xC>A1tgGVVq}wzICpk&5A-AEt9_KPhWhMBEFvf-{&FCMP26 zWZ9ic{W=?b&M7vptr4!i$Ff%S#LF4=1d~H^l5O0j*4%ec{y>$I}yRIII29sSA@rF2Ssg+X=jvD(Pa#WMYO@ zp`~xbj^H=x(ZHtFj%5Z16va-zC`{Y=1y~l%M{8u$n|Ath?$-ak{!(#4j4G%Tw zQblBvJQ)S9WW7KAn8EWUH3P%q(1`>t*>&b8eoviY7eH3qE+Y`h7Fp7KAq9AWw|)T^ zS!!q4yp24nCNKx=OE{TwR=^jFX9Q`yT23WCe3e3;&uP3ZGRw_RhWQd(Sd&zcTCIWU z>G=v`N|g70SF*=7=4uOyMwLgVV$m-fv%+fqKhSvl5Nve6ggzZVRAFU*#W{r>= zO$)DXtXhzqR9;NX`zquAz&^$Ges9@l)2NR>P{*oOS}e2S)gIB`Tc%~vDyOoM^HJ>_ z4VE`8X(|Q|z1nIT?sr{!&8DSn#qP?U0+{vpi-l+C9-Lre@vVuK7{iq?Q5pCPmrvg- z>I7mJkElEQO;!1HflyM=LNMPAWNJHqBND|b_OzU2L-~%;g|k&5j{cMv>H^9(W756W zL{Cm#5g<{O#=H+|Y~IFNzp)~`Tz2BZVJYozn(`G6N7|DlHuCR@Y`6-WKB!%#o)%)d zWfN&s9=}SY}B zniF}cm5Q%ZP#-%T!_NSgi&2}$xsS=i4i=&o}+PFN6X%iL!c{=CrNoD)8inpYxd6E#55=4T%SBtfM!yT^x}kZIA=eHTFHE@ z{C~}(T?irCs?{S=`4(}z#?cekNd}Eei((JrIYM;P`8)`z+ zO%WUTj@b_oE;Ab-F)uLO=*aG&7aGCS6@Sq#+f2CtegpRFU$TP;%tgl0l{nQebI}vS z4bzt^TY(ZVD)JIvJmUO-P_R{26UdvNA1t42SQ7pSBhbjf9K2(1%BaP!EoV~mAiv+7 zV!<26ERg0TYcpAYgblCk;>xSu&6n7Zd)mm_jEq>a#E~*Uma!L=#On^^JVAG)Tp)pb zuT!HTal&XAZcDYG3{LzV(MK&(u(LFw$ zrwbztGh8e1o_X>S^8^MgTy-3IGHp^bbR@*{^m|5bd^o%!%w-9~UN=L_6{F9reRi`q zpyd?WIcMOjNRkkJkjYNLOV@g8=u5_oV6Yo@oAF4zv>+Hkczx>WS z2nZ$)ld^zFw}Fh1z~q(^Haz4J6H?|m0DMFCXQ{MJE2{!=KBj{g=_Ov~zpD&%!fXp? zK5tQjX@a+>UpDpacbdzO0p5zA&twdOThogP6Vxxpb(D5|QA|vJSYoc^*yc*?m#o4aA|3=ztAh;`e~%7NxfF zEd!QM?)Y5FYchV67Vo|Ox zswpC0j<(QByJV}B*MW?BekUw?+!6*2+xna6)VJz!zI*KXU#uWSsdb#c`jcpRw2~yF z0uRAH@B48wb>FAaqRSK23f!OSCXOL+s5#ZQGYIZcC0rTY$#2I9)2ADnob6`{qqhX0 z-MRuy4!VWbycDOd2YIPhTl5tDpo*VZC%JO3s<@71aE@N!m94}e_a#A&kFV;exo&_E zVt2yb1UfH}akvNU*UeHb3aZN$;(8q~hC<}qKM=`>cy2w(fqr9JLV_Du?skW9kMmRC zrif#Z%&~7kj;a9u0W9L&6$ngOi|M$j9xHjU0XzM7z!L{XHb?|6gNoC`h4?>Bk@5=+xE80nn&8R+T6A12`D8% zum1%&>lr`|N%4kO=ubi7E)d1_}Ff?NmTMgwOilVv(-v9 zk%#(NHBI8q_unBhGkh+!y|_*C8?aTZ;JSDx3KDB7*AHT-oPtggq}kd!5$p2=kHK0Y zh>T770=)$Fgo*PAh-QCRP%e1zz9rrg?XZ*Rxzj>?-8-@4PW2ATtN zf;(ef^&NsR3U>I6W$4YCxo@p)q^Uatrk=$h-7^n32D-ZeSRBpPeSQnh4&Vkds&->y zF6O(qL5)omYoFA+++*FdBabJC`9};lQZ&!?6GNQ#0O*GoC$$pvxPPp3~7?ec`9z-ig~(@jqjb1d&gfTVLw8tYJS-S_=rumQrV&yHelc=T~4I z!+l7m#A{Hb$NP%=Yxa}V&iz4#JjWgy-2nAof6$NCW^y9jytHY{bj(;mI2<`A}d5J$+t+F%CCmQDydgaW7 zgblr|Y86EJ1bbXpUm?AeCt*AF_ga^NV{hVN^SNCtG=|nTvk;X#wkprA%eA8*`AA#T46F za!MEiMz8HK4lSZx zr$7>Qex8+|-cp~3i;EzUlrBVj+dbpOp^C4zVr-|DOs5n&adjjpPF<{1M2l=G{}wxui$Sd$FZ0U|)|hMQH{_mwQlt0f^SnVn@q(PWD@Xm8 zPrqob^wv7f`Syvr*+}t6#%!nc$L7K};xbrLvDoM?jhG?3QqH$9vvLnrl@S(~MO=4= zpr~E^tmxs%7wxDGA}vL%+9dM&_cYc*{qyJcxGWM=Qa;5Zgw-t!Mitf!_&>Bdpt51! zzw`uGC*H_*FK*(Ds;Fn-x7WK>R0nyx2`*n&Q6Lp;tB$>SBTW3{7j*& zo#KacrM(JFSNQkR<_)$ayNRCae&d~=@Nm&b<8ZTx=lVUVi%h5-6qD2c|)D5{B~9?_J1wFDF-SAn}@wp6noSE$>+B zXj_L|^gByO<$WIfTqkxZsd|{8UlQ@xZJcSLh`}qyi0rLqJ_ovekj?cNKC{o&%BquC z;nxX`YlZa#mB+Wr?Snm9`Uya(k0C7*Q_1S2I6;CZHRPddjGwTcBu4I$Buv7$@N0fk zy%xBWM8wIau3?>tR=-pJa)9Dv`b6C@L~Scc25c+7gGZC5A{56~=ictH7Tx{|FEfbC zJ;9MRM&z9MO7Wrt{nYe60&OE=OZmeHOHt@gCe(OS@O*r)P_wiVcaw#Y-uKTmK)63g z-Uh|C5dSED*4i(p_{HFo@R_fR4+Pe`2OAn7@ui zm$UEj&<~KEq-~7Cx=*9$CPwgDpqkwYUbA*mhA;c7haock9GA~iClf1$JU@J)VyqQE ztnlkEIqd&m$)H<6iDn6mGABw<=b__X?y)0N_*^-l-+nY&xy&CF9)1C|msu1%v9Ym` z%jXN=*=>kgWMgYZtEXMbDK0LC;_GkdCA3Vqs&n%4)?IE+>4TCK$Pw%^Git^a_LKEk z9R6KQ7H3!aS>Fy&Dyz-Sy$PxWA4S07yEje7lz$rB5%57Wi!V%6Bie8v8w`gPE2XHx z>%n!o?w^8MF+nv<*RQ+63X8I^PD(A}UN_Wfg7f0UrAUdWlx!8|m3rgyJ0iD8ftFEV_M`FJ3Sn{=^HxT{bqNuM=)<0Cr>$6fy^4YC*2jx`0o3u_d z6w2HtaC|jr<#VhqCYQROj>(~TyD+9&S?Va!7~9^S>NS6VJzC7+D2rzKPEx(m*J@X@ zz{>=NNj1C&dHh0pJYwE~*EVW31l0=@q78>vCz6gv#+JR@P<)jh1=R~f?5PgQ5^s=W z-}|RNu9-L&8kNe;gxsDL9X?hV>?alyK3*v@EJ{tfsT%vdc#rRG|AWno{A%2;GUFx% zBVV7^Z+&=aVA7%&(r^-cdmrP`Hvc|dNZM<0{Wb0*LG$Ejn*|N^MgO98Rj#|tYwt4g zMD2zJH!(Uh*2n7(iRbDH#)C(-A2}(HBlofg!ks>4pH~Wrjk9elHBL(+<#hSF6pFcQ z&BmAFW^=$Wf_ndf*Rm`B8D8q763eUl zy^14{=}C)z-kcpvt8e+{h>x7q`%dqiw4U{64`JR9nyANUZI(@+t`LpjY}bOZx0(bI zW2}!S<3Cy+O^#+6$_2RIm9P0QRg-fr5n<)?nB8#aUrtU*icxzUMiFJ}bUPMlJ+zHX zM&}-Fj4*{i8+gTDRV}smn-p*2nbX2v$nPEv^iaROSuh7&9?Kh+Ac@a|t=$hCcJ+&aUj>+MA$>%iDd{_6(zY*231&%kBDN zhUCm~j*jPsB7UV93i4;S-8pUdRCwR{@uhEnrr)aa6&d_Exe|WGx%%nIxq=8M!N{rC zczvpzGIPiMp7-UXNRPUux-u?%6KY%$=tP+;J3Q#8`aH^!I>>o{U+BU31WNB)2=YVU zqMyTq8_lK9*K>2^PhyDn-u_XGyR=K=)RP%9&ucVPg$5zj+VivOC32O*wgbtZLi5-& zKIXTz-DZL|r1y1|0axyms3$Q0Wa#3P_9^@t$R>d}Gs=y8i#7*c9~AbC*fO!iP%kug5raA`wfEstYo{pe(NwhMFp zBX>bTcMGGL6?0QUf^WwZ26cEqXMnP%;&!TzW$SJwC9bn{VcLBUb1dHOQ4vbU4Kt5_ zn@!}$qxVIZ4|Wd`X~h;tDw~4eb8dD7w)etH+H|*^|5s`ibtNoP}jH~0NQ%Ov*R#^K2iqF#lIRqX{QO8LHe zGkH2hnL(ODiIefD3e~%zD=+G0>R(-ro+u3*=y!koS>}cd?auq=4yn~ii&=ZpjZdn* z2|tS(cBuXB4IeSLXLlkc-OM)^t7DgI-&>`Q2|w7a3iy12!+|(E>C79*%DO4Xdkb;U zwKP0lr(o6sq#7xj5Xsc~(Zb%dVwsee8fTEdG~GSi($OV?vIxt0)KaA!xU%`@5|xW; zGkLRQsPo*f=jlT3k9!vCzpda#G`}&3cidqMK{w862pS&uK89mT(&A)C< ze`|;y#!sw6MW2`ZGpcxlL8`a9T1C)_M%L?iuBBE9lUjcuGCO)YaA&QH{i|%brTTfE zp<$i-MJYdhQn**2u>Q%SsbAQGSG|I(-+Au--cf(XGj-k;G%NpGU;tVNnih6Dn zEsA++X0h`0pz{R=ULM9AszPnmDd70p z<(stX++3y%h`QruQg%O5@Fw0}iMQd#dezK{E9)P8bV$-A7xnz3DGOanXo_P~H6pvy zYBD_n-_#DJRw4?W;b*z9E)-k_te4cUXj{2=>ImmjNSkIc+oxB6BE26{qjr9ZA8Pwr z78)FfU&nq{>rBl1(g`+;A<*tWRnBDco=ck@#? zQogZBNQwmUNC>$?PS4+(_Y|1D1pB}^7DZQp5h99}I1qb6Q%r7D6D_u$YfPDO*fY!u zkU@g}VW9uL*$mBk=kXyf;rA>1dhqgq(*KP~L)tUIWPozli&ngQH<`C72&|`!L1Dx` zS>dwW4-O7U6%a^>CAa&4oD9tYc~Jxs$md#b{)IW?Q3BvN0Ws=WJ6K4o0QKQ7ATO9j zlHs>QfSJPvgsFjvtS7s$qM{-&P{ik~iNUX)0jxzrB(r{$1G4jF8tWbfyun_vAH}f0 zrIzcYDU6O^^4x5tS_e)pHryQ>zyNUM4&Z*HKr{uhvNiBRyVLGHiyll-q}ymKgEjVfv;`^3aa65Z(v82 z8auX9?#zI}$XlQfVR=#QpvtH@LUzV}d@CBNb~LX~OG@hO1crpnJrKQY!&W5fo%*Lm zAkoCHO99E&yYha^xk#Wcir1CHj%>yV&V@nHLJ>Gy7~#a|2>=ss0at4B`?4S`Ak z!OK2XUPypNguJXBh&i9i`e_ploe0#+kQOXuIUAA_X!Zu-0QRzOc4rb`BpxDl@`ewS z+cKuiY2+MXB0+ZosvYst*_Lj0m4WA8)dj8)D|o*svlMzXQpMiE(*(iysu~&`74E>T zVoq}?bI6mAqJP|gO+5lMq#Iuub7hN2uDKJCMy3yc5@*4SIt#GwYC4qtcr~e<2!x5h z#}sR3HXFH5+^#|%jod&*yMK4DbfEE1s~rbJ3Y)>J=22jFa`BGF=@9P`%fw8k9J3jN zjJ##$ZO&M_iRW$SJ*dWbvFI&8r5*a3>G_wAVLbD03j;{@W1@B!>>K>dWHm|+Kg9OX z0KElVOP(>$`2%JjN@&A~2LXGO%DBI0C(VBhB5!_x5(peZq5b4+7jzydQBgjH*MJx% zAJ;#3k5lI4A>I|dBjsOT_Q4+`Ny_KRa(^z=q(BqQ!s*@*AXncI4H;iFkzjIDBWVK9 z+n8TC+kLm&%KKn0A<^*ArS5_KwL;2B>F)A?0;?-bNg)kP9XsojC!j5wbV9m$nV0sc zTG^=}c8@}KETH)y@=}1HI5UKTVX_nwgLg?{9aff4U;Y6$z&F`a05I~nyR_w(IAYfn zb*nZUB^ql$lM41KyQMDC-6Tix&izUpnSNS#L4f$WvobKw9uLniZuGEL{?VT z4uSm_MoE7!aq(*8y@liW!E&#~`rx6qAo+`!NV%ifF* zwK<$CL0nIL8V6?~shS_+_{?5Z6?O?kaRsVb-A@qW*(#w`$X@XA=V&K57l`?>dyLk#@l^ zrazG?H|H`Exs5%3pHxQBkao=97rOo8Ze?I;#*|OxgO?fQ!Oo-7BWq*~LwT36;{^?3 zN0m4s{}-}Uq~fiQjVde%)FF2Hc((!O!7GP8>=b$K77A@wWWO>PzS-506wt&op@xSh!zui@71HLlT zR2`gUEv$Bf*clKcO@_d%ZYymMUigBlwGS8yJAW;PyckuJsMT02XKh|f!L8fD8Tt%{ z_X!w|3z%Vop{s*?CwvWVzKN#dnsw=j^houpK&l|{sX zrv-8i=inE}A!mAlImL3VW#r}kE5Z96vWAK+4r{@2LC!6&sSZ&2d?nZJllw|Kl9TQm z%D1v!rN*sx3>bygLPe;wW`(;}g^K8pL$*Sc)hq<{skdBDx_ZBv#yaKx;gfJu%(H17 z`H0eOSOukpX^EP;?$mjdkB9lT5Sh+?X6Fo*{h!+FIr!NDhl-RYQ4JScXc+kUIpmlz zxSQ<8dF`|M+UkR#W&G7)!@K2vqEAv-CN*eDUP|eV>H#awzlan)iTXBYUlm8u>sQ`oRmg7frS`R?P=FFQnKq{CnA7jR=gJB-c?4Pe=S{X9ii7h8@E z|9^s3`F|dRFuTFUnEA8hMH8V!qLY)8)6z=#@7~6KQHQ-3h|sj)638hP6q&$C=L!o; zHrT%D-l$m|z+Q{NxkPW=)4a%uFVrz5^nFR_6^9vY)#7x)uQ7_RMK&#H6%)du{jUu2 z!(cE=PE+%5WV(8KdYt;wG2B?Vkfb(jZTg+WZuGY6EiB9@vb6rceGr#qhr7xZVOZhm zfx^PV30tUeJp|e)d%HInb1foiUL`0K=g-#x`Ly~oNe6ELaFpi|--Df>U^vzd&xh9M zX?R?OLdlBYip?TVpFKAYF!maFcWo~Zm8N%|#xg!3tj9W%4CmkXf;+_nF$wMHCLG}Q<^^D8y!pRaV0$^+;qT@J51 zG~=)~YHa&1z=BnW-e(WLaa}bucA-+JpNmzo>PcDt^#)rt0nX0o@$GLp@#0r^B7XxU zPpQL;dC&eMWfLz4F*q7_utZI%lP-XN0=7UnEV`uy)=4YpW`QvTPzlO^I|i+elibBvVw=X`ZpGlWS`JU2 zC|JLJh5wfBxsX(CLPJAyY=Q!F2x!1SVso#L&lx{xY=bm?`o}ef_p1<0cjE;8<;ip* za_ZCtloI-uuHbu64{ZYBK_pGkGSo5c#k}A>4Y^Jh*qiT#K>4@`tq1v7AO@787_&t` zz}djrW}U(kLSQ8)iHWdqM~Wa23??IeDD);BqU}#ZI!zkbyaMGGZuk`ruKQqzIetdY z32yvs#h(+G>8dfrP8@72ArT1Z&Z54D9$smGh6(xW9H0l!@>wey#J=*qnJijb5ML8l z%fZ3%YXbHZj`P^!VI}KYrEVE}US6I~58Q>|EFUOW5m+g}MASL0t>OWi^%rOoTi<0- z9#7JVIAB9)y}@hEX%gg;Jx8QJ=jXDc5UH*q$U9ZQ^0iCB#fp>Og@X#5m#8>%pp1*1 z1?ZiPojofvB5RHfO5#Jbp&`nNTvkd-ihemHs<|_SoTCcH0wyHzCv1DW{I!6*L{=ns z`qXqbqnjfmX73UMz6V}I^_>5eUd6pH|7Z0PsV3*#@JDx$cX{8xw7)3Y?ieico$M{L`mzt=0 ze6QINgtG}H_zbAm(3F~qe!-<<1%;}Ik%)GR{nvcb(fG5vq<0TNAuAB+)GRtcrN!+T zvBt!O2qwc2gF%Zutk9(D+c93ze@0-mk|X46+=k16Yyrs+^dX!H=&nT_wSM%Gj?kQ&fJ@h*o@8O`~)*A;;_ zUK9Cx>P7_<#`HFG_yR)=OAkA(^qF>;xCEzf`PkrIw;wLcbliy(dA)^7hkVqf1=zfJ{sz2HYeuWEUViMQZ2`%a>6wyY?hJBy>HFEd%Y-Qk}a7wKV^ zeCs$XA336qwf_sjVGadi!R1YjNK0##ZBD!;R%GXW^0f!e>4z$3zZjExnue0m0dOIJ zS3%5hCQ8BA3+;V@i~kI1`wTF^rc5Fo@zGbxhy(gC&Sp9RQ?z#NAn0GxVed~jjTgaL z-6mNhblU}DU-Cs{2aw{2XN~%c?+B&a0ue^EDpAu2Q$JeyfaP+tNFgbm?8TwtJI@fi z7FVp1?4sj3$^J~!Az1x^Go4o=pV4rz7zift&xP8Og+o>WX#@&Nt(w!1b-|LpE&-;x zUmOP-XCM>%bFP19b6F(nJ;k3~{z9eo1Uku_|UG8jN5#oy^?jwYJhk+ywu^ zH|G+HB<4aTZDIMm7;pXzHqB`6oe=r_pt$lqwo9zOLE!62W#sFM6*WyhZ6yAcwm_t^Ra8`dA#n{@EZ`KQc2M+WjjOG^ zyu8bKE_;TDjgm4=FWELp0>^*Ai| zD&9XxVufy(nB6I|kJvodw=fj@S>Z1Q%+ta31xy^2P$IzJ_g5Gb%V|$jAX6P&IZ&a( zH9A4Y&A#CC?FtEgYx(1N6dV09L^espSjS;Go<}b?V{*|54*~DIb`_ z9s*`|8&FMzf*;z_LEVssoD-*>40V#dklQrt%eV!#^E^(*CuwQap5eX%W2RirGI{2hAYz)Az47BytfI7l7>jmrbKJV2hV0l`0; zvz_&Fg-(11iL6j$I_qd_5Li}ZN;tUBy)y!{_18ziKJ197qLR|I357O1L^mFfvKq(G zO5^}A0qDZ#v;w2rs{a{_Kk`uIBtdy{qF_@1%bYPVRfMd24gm&Y@R)iUotyb>Bf(oe zf#0OwYj@4o5W5$X*NRladc9CVZ3y>WtXAA66;w4?FBAr8EFO}G=(_^7DF@~lD4QNp z1@eR*r>govqdL^6{j^>x@OfmC#9;EC&%7;?>L=R!dH zO%cpG$n-4Z2vV&n=3lbfLDndQ4qf$>b#vPR9vuiCz`N7(?;Ar4ZYNCI7@=VD@Fl9ZhIhg#x$W9k9P)K^^UjE|@A97)7Z zR8vZ7YP`Dn(SLuixw#ogNF{&Y4#dasOI#dwyg(fwVs^#f!)D}KSfKNww{|kmjk8c_ zo0*vbMPzq~cBm<MVFY|A3*`vF29@WcW?fx`SK>b?nWY1sYaJaO1R|3j zrRaZd4SR@H*!(>?)GbX(8-Zlk240+jGc;~*%v9NDKCjw6R!+c}hpB;!d66{V&aC!Vf9`d*i$t*QGL zW6#1p(gs3_AzeISd%!Z9AcTladiex=xKB=gj9wyuKb*tX@n=tsUVg&sdwm2%x2H}i M$*H4DWK8}44<;qu`v3p{ delta 69101 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≥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 z zAYWC8t>()9x(&Mp3At$qhlP998n)>0jN@(i8k*gR3#d0!ie2f6*pw;h@2AU!#ghc+ zc8A-P+sI!#5XF57iMcAwaevrKW>gFt5Q87anDd3X@N?&b_#OXfmfWqkF+iWX9f)4|sZuc&$P z)!4Ga5_Zq$kuY~$qQh^Pz1-SHec5j-h-dlLjNTLmt``?+Ro1o+oELRf1zz}FJX6e{ zE;xL<#et3ZVZ$eV|HI)^#;u++kT4!diko_T`J}CB9+Iu4bRT|Uz_Q50CjL~apNUNN z4I`T^wB=601NH1<{+YjO_PmvNv3vh`r-DDeJMX!?v2rV$H0!1AM&<OoniUZD3F}61 z@fL|bOG-Mrz>~~yL6>$hF1B%zw>0K!2_tc=g?>bv{8Jr*N-=Jl4hj**5qbj2{M9t3 zA3MWg1u?xg0{jr!;MZ}vYmACSX?rAJlEl=Ekt&-WWT`Q=O|Wvh=o3Y9)C!0%_PJ~% zu|$0RuoA&rP#K+YP^qF2JBYM>JZe3r68=~i@giDWpIY%%{2-xM^Ki%^QJhr#JmF#u z{tlt|T}c~mGXXW`@UL5?3b*Q#2^6tPR-Dwv6dX4d*ufrN{92q|48eD2VU=<5cml(o z-gYw@rIs31wl5g#r^;6msM`CRRAnAPGM*!!X@4WSoniTk{C*v+(f)Ps4%^u1&T_Zw z2t6s-bY!7MKlsP40Nu&FPQhv)WaqcMZu!eHs$+b>J?wv4K{`>w1(VbQ{g3n^eS984 ztk!aZvzn0>hz_v1nv=wiVl?S8(|GTuOi#aF=#7fhz44H|5SwW}%;+!-8kPS)ne37#-blE zaB~}SV+RJ8=uQ2265Uv}>AOEB%1JCVBU2x3*8If&Zr2G)4!dwGMn@l^&$mgi%ro60 z`Y=evXE~2<*@!3;{AeVzoKZINixE!V!O0*IyL;=3ZblHlV1rlUool#PV&@nvhw&%m zjCmT9D(BvgvF0yudY~R*Lt2~P{)YAjzgaa*`;^X)P+z5Z!`s^O``)$No}9~kbavfJ z(&!ND@lR+?^J7Y_WYFwZ`!w{jY(;HoG34`lxjo?>)-F=o3SYLFO|_qZV3~9ho8B{_ z$yN6P?Ok+s$LCIx?xjZJ1IIrM)4O+TGQAw>V@G{3jOSOhp4I6iy7`yOBBMxn4UAj} zoy=D49A3z8#Qyekj|qBDT0|1wLDbOGZ?IB>;6GICr&S<6`vz{AID2KCUdd+>kQhU%RTRqqu*(c9v6BLj{5!SE03M=sU|~dqPgELw?D`EPrfUqbL&t8 z2W7vD;RbQ&R`yN|7XnfcU`&j?L?eC$aZZ13J*#X#5!QV@%?^mQ!QhzTJJ%yYq|dht zNO~IbADC?=o>3M(oG*z1qOfsUCo+v&?YvIM$6B18k*WvOBd1ePldw$OOt{1}Jz5}^ z@num2boAR08iHF0)=PYaB}jaKmL8w{ZH6Y#Pqw-h0*xl3mvOny$(Zoa55e*`!EcX= zd`eO+j7wxEyQW%(R`Xt#KY=L_Ze<9Aps}B+{xYyKIg|t3V4^1FT_Ew(RD^3B{DE*r z_(d*t$Aim9QMA<5AK5PQ)R}sQ}%`I-006w=@XH1jdf9VIW>Y@Bo z_~OYSVPA8t|68Ssc`L2&5xms~kJ|Msi9mng-&|Si?XHXv!q6AGZdKU>6K%OZS!8y)migAd5Y|A3DStQ4tgeGz}cW%efNzLGcGf7IAkOZ@u~c zq(-8BR~u;dKm35TQb4JP0qGt>suOk2JrH=N_Xvj9=-O!U9kAf`eHef*`v&rhKuLp` z9zniCXti>Y4G`r3_cK#v&i8-mJ1yYnn8W=K^Vj=uhbYC|yAGVcBq44UB8x}HY*$qo z*vk%KC{cXQT4IG!AtfGm40UmDE9PWyKe~u1zTE6<-`qJm;dC;RJf(n!QM)!8;@=)w ztp9_7al_s#MYd!Bcd`GJ1yvs6aRa#herNEY2%2=F5jLJz|4Z(b{~^nOH9aT|5aMTG zeb$S1kVCio%+*)xV2ax7}0FgCilL3rh(^xP1!#)G>!n(i&`V*EA z2WV1v-O>49uCEo+8BhCg8=7#t%T)8>5MOcmLerF0SSqiWqBmSYP?o*~p|*ZQ{%)MD zpf%<{%vG{j?Ylv6QC#rYNZ{~I{5{9FqSp`lmWyTTQ@DA7K=2R3vzr*JEE>vcAeP-4 z>VgfE{A)>Mp$D5H{Yobz%MgvqeE`9hz8{AXISQAx?sPwbB^^9>vT)0^68bXE4>)Q2 z-*;tqKD~DhFJOMuo-_$kmm46_{evsB77W5o(@TizB}8MBohjX}*r@0;DN}~50wLJn z6do(tivg_|r+aS4^WaV_K26fq#D;lTL$ol!wpRX}?-&ncBLy;Ny1WB6z;1aDSPmd( zipw=mO}F@if%;CvpWQ9E=Y76}z}}>FMtfDK&Zu7Ua+?N*kBmV7m!F1=V}0>(^4l`8 zet3YlnhGKDS8ID_8ipV(a#LZ!Z3w{pw<174%NLXr-Q+~xM%cm?Hf8D79+j2VcSm_B zzoAEou2<@!*bPu=$^+t)o(e27-xs)$eS7Ws=!BM6woO<{({f9B-iy!-I=D+tQzf7><-e!9vO_BLly7G3xny?J~&lN~zppHuk0x{eA>X z3pAZ@{5zBrqqK5am3XGJt3xIM6KrrTS`ENNpEv@$pqB~tkh&~Lx0{?f=5nGP6ddCf z0g)h6!W{5!`(|}rZLe2TJXBeLzeF7Cs!jaEft?0HK=p?e(sZTD;hw5L<|_;tHia|F zjhC(}U1GNdcN1m8pqnxp-M62nT?T`e)A;gDw)DYJ!&SP@GU2ZAl^`q?>ke&pT z%64(taisx<{rF2_*X2wae$~x@5Pb1Z)P7|(fbx^NdSvs;?Qls-IM(7B0c_`tr<7u@ ztZHKf4%q?;&$~WoJB!OyKZZjnX)aU)^rE2y`|l!NPr)=j9oejbF4G|aTUVy6f(>H= zX1(+-IkVVwr4fcDoU}C%t{;%+S)R#KCbid*-;=)20h75;qlL2lO@rOI?K0#1UKtfH z-x9q6Q0OzqN zfgqb-S>#8WgWHbzq=bi!PmiK%C7MVGMXt?uW4><|mghPV-ICLwW3@rJUIIf!m3WUu z_up&xLj<3YZ^x0jt+q6u<`l*K8ctA8 z_l3E}%Q*Ga=IZzdfix^&^V+qr}7x&<)vC@^^sh+^xx{b^J~?j4q0y-KU!^Q z!MRWy$eZVaiZi)pzS%rNZfV;5SypJ4RM48R)79S37FVM*g|nPBTR#np(dS+jVJ4X9 zwCD~9*nl{oj+4O8B!84;npFfxZps$ppi4}Nn&$UR3ugMlQWtMaO&4L8Ee$OR3;r6D zUQNpzhpx7ko?Btkf|LUjbR4`(2>f_*ChbctB4W*T=OxIxvR_*DF&x25vkXz*WE|PU z?T8~^j1z}vcr`Kw6k_=9pq%>NKiM4uv_Slixc;>4m;+vqGRN@1XGOG8GPi_24g@<3 zx9b%Hfar{s{xmc|z3YqewEc-F`QLqOj|+)?0>z20MYOV)`BF2@n8*zB>YUdvev`;2 z7+{JbJnJ0I&=U{Hczw-1DR|Sz1{qFpdMka;p7T`zzVt?d(PS$i%Y zZKFGdjmUi2hcVOg5=?*8ZPb+S*XXz5wk2hBRPO8fqX)O<`6K#U`Ww{q`+|b*^_hid zG-OL%{!!z)UpEz&w1$swzWK|{GB~dv1S>+`C4buMec5Fn&$PY_)Y7|T=qD{?RLym- z;>NHNgyk_tD2_W3sCMf=4SaXWW^+>Z0G089yLVV0lf2GyyHh%Z+l`wI8P04iaZ$>I zK#neU7-v#qmRTD2|FeGO> zX~X5FaC5319toXceDi!j&Dg#DtCT~Ihh&mMFF|SiV4oEIzIGgo_XuqcauTa{fe%)< zjlCzuREPY0qvBTI{%IGP&f83QG0}S0%bcg1<9dVeaOi82l?`OA`UIhRLz1{BLH@1U zYp2|n|7%bi#OiOcv@?@+eifEZ#K973%x z^?zrL3@0l(%==%py4Tn=iJ|zi4o1p=HOU`T_n%-+%Q6BH#`TX-1~Q@8zEYW$SkCy?PTpqby8i6dDB6@t{uUH_Tjbk zboha_ZKNX$*~q?i_jz@&Bk|jPXM!0=f72`OqX=pAE)~AYer%a-y$ccMnXqrZ>6`v; zfIMHjb01-rV8XXUDagpE&yp>0_d=D%0gYdWv1gsvHU}zds_Ep3wivRfW$jiPO`;aL zptU9KUu1p2y>C~$UAXNo@`noyCu?thi$)0cCHwP~}NA@&bY2v7-Ewf(NTDrN> zJ`_Os%B0>neh!$kyap%%cKqj-T$>+@5vPoK z1R2^EPo_~zTO@ng2_IQ`k&VOD-xY24XgPL~apfYq^Bm>f3PVhLs)A5a`jFQ?cg}|_ zut92RrpBMd2I;{5Un*9X0m@8z=i~B6x%Hah7<_vZRpQc~-#=g=`_g8~+)e9_u#RzO z&RY>dKGSsVb|;>0TJk?D(O@Ec?bbt=x_grh)ay|2Wd}Q;I!VRV?V&zK?evmq#?4}M zoMZ342?)eUwU))G=sZ$PyB1($TiY>u3wyr*;N)L+5~J`J%5@?!24b>?wwtQUBi&(f zr8p$TW(-#cyh1)tF+ZBC-~eW@+uf|aMe~`GVkhQ!20NXXpg_HNVvn_nVaD^I!Zaw( z0~0myUeu2Sp3XE2gUt2}RNyz`EZ}4et+l6R#(URO?smrJ)4hPVM$i`jctk?@dsfFfQ4>-tBi3UX zAH!#`X)e5fK~6Lb2L88IS;q*vq$@V2BXa4oiaHPL!$j4mW47tHb0C6b&Eh%^Y@crS z#6D)y!*T1WYp!(3%2}`7G85T8QlF(nzx@q(geO)U(k^gBil2|nvXA~${_iJWLh?jb z0;;Xs%`hTU5`|*q;52?li65&Qn|+FL&ZpoS#qVV?iE2aAS<279BfpgS*Khymw3qwg za{Z60+pMw0do#dtGIc_zNShonFEm0U&Pu}_Cs=j!u%xQ0v#jNWQ_5kr=DZ)$fdY%4 z-a7Y`(!*Q~C1j{9?m|Po*F6U^)7BDHhyB!vY^!uErN7upcq_J{m^Ij7;DS z$Gyg$adxITU1M43X6dLSuhoktAew#p1HE|4`)QY#MEA6|Bd(S0oO|IcvQ)zVfX@4n zXI&{Frj9-7=1~&WE9hH3V`@C*Cj31+fhg1;x`X$I_(mKmPmDyX6ZRkx=xckXW@bQu z2dF8#iZYp<#PX>C2e6wakdF>aIq!N;0nY`!|3+({nL&n7vSDmzn5Y%W?a~3tMh-1W zNqSt}VXVL+4|N_Ty-yqc`L_{^ z_l3~eyaXINIcMcA4eK)Xn6A|!S}B!}j6`Yd0{3<11`5&+FH;n+#f4Jp{QF?Yi2oy9 zG1hXkX4<2P{leMzMz&$a(jfB z{&eR)jgxuVDh8mNWkcrI`i!Lgi#ZzgnO-<`R=OETqajs!SH{Lgk@wp_q04;)`w}pa zYmJ&ZIs*Nr?q+QZ=|_6>1t^ABAK?47XR;eIvF>cCYBfj+9_o~7MzAg zOeIu^3Af7UO}@szNf?{+=zRD5L)1~qs}(HR42Lntsksft4&9vjY}A_fvrlhOC*yId z2Gf2`G~k&(xyW1azJM_#VEL8nJ&rXh}hsn%;VEvcb z&n4I}i;jOUkANq}$N3ck=O2fdB5FUdn;A~$68Rf>%Fm`>-98sMk~w*e83G4}i!1BS zX8#8XDlSi=+*!uydQ`@zXd$-uJ@y-G0tKHd?eZcr(o*QU=p>#E{*}G2&*%j%Oq<4tY6#1a zpMB4o{B-03vTOirI3GZ9TZ&1O&Y6{qh_x$z2XB;5B#(L~2VI1K! zxY$A70r{sIBBOSHAXm`xKKMcKtKL}2--pn8{x5-0qwuC46gAj3Hm(O6ly_?hdKTWo zzV-+3{ER2&7=DxNhyB2iQ4fLVj}5mgdQJV@f<^nE?=?V;UaY}9rk65UH~e1xUxs0( zc)*rJ(i*pRm!4su{o^zHb87QJUwmfaHIE|~8vRxLvRP%0TUT%y-b0`{y>GjEgY!=v zYGfIFMfo#tyQSBci*I&dX>?_7e7lWfd|TC&%W#V!+*1z?FVg;wb>hFRa{Ifk*t_W~ z&AwEG7=C^IJGWi@KS8_*v;+^jXh>F072hnKmHz-H20fvwI}r*5k_i70&N z^P|l&6Wva{#rgpzb72?Wx2OuxVdbw+A_@Ukvkss0D`Aht%qH{@ffKSMbS?RG{ zy8QBeP;Y`j^8XNZT8tt0vK@cd%*`FT&im8!bpo6LO*a?*;evl!FUT4PAXM+-Xx*#l zeqMMPvIVg9ltf%`!O|Yu*6;M^9~Q7`JdqmV{E87RJ_qKJ$6a&WsB5EYRi*IWXGs&` z3CnBER$n%Tj4h6+12ijGHD`QZbtWkJbyl$%abZn-e;T| zZ;?Lmuso@G*0Aqn6oYwX1FSQk(O@wmQpOa$(x9GfHGu!Gfss!0`sWNqAgGyXQ<278 z5o*eQ84Q7#z}121OOo!DAOrXP=_}fq*mi-(2wq=t>if3-%ht`JT4H_WD%}JtjF|`E zIgIS2brBlM6F(= zl%}OxD9%x*ENknT$bQ#NJ=_X;#K-ZXMW!yBonNmAU75qRA;@0=X2t``E3uY%#67{4 zRpTmImRbh1Ey$59eLnV1=iW7eJ*FD}Zo%qYr?H2Oy^6Xs6V#vAUVd^g8nfQRdJ!)M zO|0&X>2oe$d_@csyl%XP1q6rN ziV6Q8RbL$zRonFoNOy%LInx{$=pxN37{#8+j|=K|hm8QYWn%Y>|IxRgZ8r9y#4>W(Wd zqf@xh@aAY%O=JOPhn+H}O<+lqFf6vjykxs=^Kt)jj9S$eJyM!`GHw%LjSi#4WF(jW z=K_`I)xcY&$vpMm!;nZpFC1+e_l5%LXm=CZ@E8=|qRqiI)td^}BvnuXC!3^&0QA9o z?)fE8+7^};j(C&V4-1vCFtaBm3_M2XD;P z5#Mgynqf*I${SLlsAS6OlGB-w8r%$l^EeMFZrJw28-dx+1LLj|7Ln3nPJ1JC6*IiU z--bsgK_HQa<%Fw97a2$W^iNJ6Vl~Dj(q$4T5@GA^FtmaEaMS4S{l0$Q0bkVWmagI< zZ-bwoD_F6lLx|KZ78l;p7VS`Z zKf7%}7V;g5@$XP>GE*(#`n?k-3Bcm191@bFZa&PSyZQ*cH|93JMHP~v#`l_BQ3$cI zFhu|;qg!UmxoNvN9g)?9znDU=14p9L+k-Xa48qvxlGH#Ck)T~9&Ib**3&?rgx!DRI zE9NrD?Lp%q;kRdZ&}wL+gT92}!l7eBB;H%|e&B1~q^tQ*@@{P%qcRGcBP>h(Q#6i^ zLAnsSDJ}I#q$r^`FA7BvcJ?X6%<=wBAfbKyHL9Wu)hloZ0yR2#o_Ra7uUG8U3Eq}7|Xgd{i09m*I{n4K9 z@%-!{!_)=on%~vLAcmP;4*F#mCl8|wUL_mq8wzhw#&3Zxes8iJuu`?pw(tT+*BiJK zv87H@><0xC#j2mMaK|pF)OVOTD1;u?i4MI4IlQo-q0n@mXj}utq5^kBcD!+oH3}QK~7rtqW)gy4-^s+(>PrD_;oV0&D84XJ{CQhLh=xh9Aa|?hk+lLYpiB`X=^-a zP&Aj&0`cLcXNqu2tLx1f9?spt4C=C8)EPK#!SHjvO;)gg{TwrdwEKLci1iE(CSQTt*tR0GTPFokGTdO0v0( zM8DBz-w~IX%1aZ^r>LQM$N>n=NYxZy@JtP0mJDYrcwKw*L4y8Uw+s<<4>shQ+;P8c z{E3Mfd(0Dxbzrc1c?5`gJ4~HSFlCG@34N{(HnWtjne;pUx12l%_t;O+R^Q-_3gi2z zstnE4(3zCK{^%!)SM+jQi7-P*r#8fwzN9wBuLc1Wtk%FYAESqwtc)%IjC6+)z(^ec zKQXA^pFx>I6=BCelL5-dfx8EWx8ypECPirp?q=(diBMrCl3e`ZZADhtT$Dv(TS)YC z%AFs4x|CtHSI@uodzofd&=mcq8}Omgf79W+S5b^wZsI^EwedxP?mj;i{1M3oXcH=w zuXD!QvpmiIrlsCBpl5u%crQa+VAHL>^1ZWV?#lVPsa^%O!WA4-Y%zeU{B zCB$7Yed=bTlUsYfi-{LMclhI-PMi&OC6mT;3E?Kg$e;@VE^IMioMaWU3JPF3fFo-Q z^5);To~&hn;$Qq1w|`(E5pSpe&}DaJ23fT)oAXN#*5$W?EN>>83G^(bzt6&E2e);I zL|k8uxwC=?>h6?Rm{*n;=q640NjHxM*BnH~N`Aj|e5)|4+-(YmQ5A+LRy3aUUm#GMEybRK0Yms@S+09b+#_fq0okt=y6PBJ?rZGscgto)tTfi^;%^s!?1qzh7k)9QIcK!J=;gG47uI;0>wz z@04w6kTg6ZXI$Xd?9ZAg1Yi9obIlfU4VedtG_{9oc^~XPNJXo>6l4$WOrjP_xXm zdtTX9^3M(VZM(`H@;>sSW?9H#a@%m*0)#z;u=Cpnw?J{G?l?MX<&(%tGTs7Zcrv~T z*EE)t(v0IJ$@F8?l=vRofD@mvC}#L)%&UZ_U3uxFP^K5LFVq{WrbDOG=6 zvx!xoo51E-Lt8;(rfLdOn)^qC*1Lq$2sG%)L#Th0Z7I^1f;u!&ZpEM6AtAdvyYQmjkjF zJJs_(D&%-l&sussXR}jHc!|~dr!_!GtQPN9udspQ!_b+Fcg1t0j^H&cC$IL!kXdP& z#d>Tvq0WE0{I_X6hjMU4@Mhr!{zkG`YYj!lYM&1NhYREfUvXFT$Ep0Gg z@>tf4I4SZ|T~)Qv!7mpgX=Es~@-`3U)+fIFhyIBLCi%;laZ3hWPj)RPww=7M*7XQo znxT>yl~o88m@aSRkW{WdqNvhJF{r=y8Rr4dH0c=ge&-5gnjm+bMDV+$$xA2wM5gGD zWFEHNYU5(rqHOv-Sj!WLb@K@K)fU3F*xGk7kRYVM`>ZasWJa^(hNQa@Gd6D5sa@MmH6g0kYUn{qmaQ${S;n)brOo*gH!-oT_v8@p^(VWo|Y_- zJE*b0BxSGs9otuNFSoLo?K$q-ssOO3Jq}vtu-f@(9mIh)1TC!&NeN=^-_a}CVbRZ~ z>5V_C%rms1)rtSR;t0{XpH?(&Y&9~jF97=fYIX#Z!}@*i;_c~#KeS>B(PR}01eGWu zW}2U@ZSNX9k15v)GKdphBGqsP#se8yXwb!aaEvE^w6$VNlB|A$$7$ZW5E*2@93(C4 z9{_nB=F0CCsm!fLkV?-$EP^jFxH`6OfaliBiy z`JJaLhTIj?A>Y`l)-;fc+bOah#j*V%Xz2RE)k(Kq zk<#}1$0Y6q|5gqu{@S>h;LOYfP5@T*J*T-A%qE8d8gx}3VLEOKIB}m_a+@MzEb2mE zk3&t8F1Q{W+E>dCYM78`&+>4x<^g?tF|}hiyN;+19r)!P^YRP@0K!a=TLEGDe=bCT z-J_pLoFi1%8MdylaGv(3w0ewvx~XVW8%B>)8^+G%pQK6f<#D1?r-(dik7XX?rbWQ6 zvR1fDU$R@uN6B-}C4fLu`O*T55WU_EUr1sMXxwjc246nO3Tt0|iE`cHV{ zLBohn%u?AUE4lhY-Cymka;lreT&~rd6N3M7S}LhuJe@)Z^8zZkmOr#JR7%i@ZNJy4 z+GCptan-K9Q50MpLI6Ll9JlX`m_Gw`Ur96?6n;1V7=}(*D(I0XzQNn00D+<20}Qa< z#14}wOm_CJycM=&84&|G^d+!CvTKj)YLhCLxuDoTtivSGHEdx&fJMPdhiF%gaHEMEw@q)3Q|~8 z_V9VBYZ&z8B<)qBVuM4~Imo1UIsvmj03DTE2ShaiYFFy>bRQg`9?%O3nH?1XM?-1u zx5g)yQ(}5lp8k+$O}G)xL}2SQU4deVzx!XPy1|AD+l@-_u+?O}pl$~0v*@|;09huP zW%hxo>=+QTDn}4FYdEh#o@82qZjDOzb3L8^CBx&d@-x`^ypO|s%a(lJY(Gq!{|_A2 zTEqd6wlhy=4SW4h;|yO&mpSAsk>B-K0x7AFox5a?zlVb69@MjqYp-)& zghYTwvydaA4oFZR71#z#eWu%Cm`}eFpAm`dn}Xw#bT#%EQ@nNO<))2rGZD5cgkqv9 zvdghf8eW-GDhNP1#RE_>A=}Ye>h8RE(i1ck_jO|ag0NRL$|>o=B=ecy0J9IZpv4&= z`wckZpUg{5^f@#lL~|YW!)iws&T3uvyn2I7V>44Tz_~~p-&)kG)BzUc5P=83X3A6m zlf)xZ;KF_iXe8=v7jKi^2oZu7%d9&v4v;>?2zc*r>&yC7Llu~S+}B6cBbaA7+=Kx&FyS}@7dW}V%r2N|O{=Uh zeZW{4ksxslO2nUE7S&0y`>=<*lVKi*-~vs%hdO(4QAfs(%^JCxfYbrYaBmQSt_3>c=e0J}R6vK8%;YhkPF1uw zXbM?F2m0MXpi=N$W+{xS@b%lqSPJ_ZlQcMMSZ;Zu7y07_5uu9~4e)o@0u<&P4B(|Y5VuUv_2M#I9Z$QdSe`gE2YBlMe1~nn9 zsHAJA36QB%=I3FE3bS)(3V~;cngeeZu?aAY9_bcIueby!U}rYO{PQr?-nqxmOk6>6 zo#+`_8WZa7=v#p^DjXBO<~m%EGXn*eH{r%ECTdfUGX|YjNlP<-d_bav?{pO9Mizfa zsHAFINdlQ-_%ZA$8Knv5kv(u9M7r48mNb##p?p;&BsvlaA+U>8HmLnO#>(d(N;&A_ zCfL5$9687?S)V#$Ju&z!w&Ue!yKX3A$FEQQL@AByntpN`LXm?&Jv1f^`I&J|fS1Iu zNP#^@<7L-WNlG(Ply0fz(@|r=y>DB@r^H7VqwV>Z@6HYv91H5d9BVx+I3Ij&LSBP~ z%HlLp1Q1UdOz3B8Ho-a!jCF-)z#A#YrNP64qm>@|sJkNNYSCh*iWcwCNxzjPtQunK ziS;4ET1^-@omV|JzECnu70O@HNJ`_jp0)$nY<70mxNUCLcga(bsHgJQ>QjkEO?7qa zUsRgO#ESHUgT^_Jet=iD)GMV06>p);m&hnis@iqFbuRQf;x2-B=_eIZ>YhQw(7C>A z#eHKoqIx)Pqof@M!3_V?`^3;eQz`F3$XHg;Ur!wj=jHsOE)^yZnv?EEWLDD0nMbZR^%gPEKeWz<648v~ z!{qx!O_hj`_)N}F2cb(RQL|cS{KPcG>-N+n1FM!m zs~wY|$r$Y+x=)jn61*Y(7SV&=k=J|hQVmFdScesE`e6v)^5iZm$ghC>iFHIfH!Fe~ z$(-h9otHTQYch_N>hJ zNq_WHMsAcT)5gL|0;#vEY4tzOZ}%nW@Q@?w36t#pg2ARO%-gISo$cE;1_c-wjm)!F z2Dgw}$gej-Y=L5}ZjPqF*Lv3IOCX_zxJy#`I?r~Z{@!E;J$F2J-jgrG6P$2GWq(>`+|Q_us%wqY(YjG&PL<2)bAGx=D{mXLx5g)Wi=3 zjt}JVd>#;25rmym6t1q?t$d*1MNt}vB)I-Lmi!VfxHoW=}pZassf_E=gVICF2 z#x(CGnjmwXq3|Rlh><9U0wOmb*f4PVEN zq}pJer8wweETbdecZ;eYPhY)95y-w&T;{->PjpeE6SjS;g)gDyo;C2ApgFzW>`!eP zdEgSa!I;5bfx=$(p2?W(KK&|%miZK9^quAoVE4};ocV=stb|4U!O@b4(=B4}O-{B6 zdoKJyGMbI8cRLjld_JsVjNH=VW=z|uZffShqV|YB>22T+c7J7Yd^7m~)qjjxN9PwK zirA9#SjZb0=fDdnAsL8qoUENtct5+5U`Ns#k4tbcOkaJiyCM#cm=-Iq|DM4@drK2e zM-&h8!X6?aBfs6+mRuooyLVJjM_1eLn{W)Tvme_(h%g;P2#SAKXhcA4cQz_)F8M`B zsFtK=)!TPb*S)D+Qd+H1wb0|1Gu!3=d5PRrW8*qzq`HVYuG!6{q*P;8uJN1jbi-PuWLi?T0G z#ieg0aZPK4z)(7VdDWP1UE1tmuf&-8<`SP$xzNy`D_2JzG(Ocv#eH3ArC3OKM5&|* zl9H+`o1{$t5|>N>yJ;tGjx$m#ACO2HY&XPzlRcR18vkbm)bkS?vzo?S6Owkcq63e` zBfhn`uE#Nor9S%coVT|CUNYkO9NJaM@&t!YS-Uv|Tyd2ia_oJ=IkzP}6MtaNv8?xn z<&yAY$S4DTLq0j9 zUe&ZX+g@X2gNMF;id(*)eI!M6LDhP-)lL8&Qs7vP2DU2|sYM4iB47{D1BS^?bI9pGxNMB>n>oKj1LoWr* z;ithk5d`*Z?=i|mn5^7C;Fw1{b{fBTnFIuBpUWM8x*D1yr@?ncy1TUBgw05a%FJ!z z8lj!th${DD@!(>^PlGH-vr-;BUu6+x8I?0|MZ8Fvxs6X+w*XVMZ9zY=7?pze0xaLN zsb!>3B&xpHxZ}RS$9hi8Y47jyf*{jV%_Ah2Uk01+PZ8gI&WWC-%WmfC;~!?ywh=Uz zVLa)L!b^%(ym_(kW=J$zNkl0{XA+yo$ve1@JIC0>u(NKC{!T_-@*;~`v|0gh^WI)$ zF1vsU*!Jx|%l=gdqZMrudZ8dbw~|fB3CxM2KRlSRV>g_H^;}ZN^LfxQC>D4Fpk$2R zX-xLCB0#Awd%rgWZW^~`ZXU^zmsawZLI`C1?Nyac4@weO2#)F`d8BmHh6xd!X=Xhg zc5cyajL$nK3&fZ#=^lk6zKrlTBy#tbX8WXkP-7hRu5`;+_bBMSP(B7OzaBWo3KHY& zkBHdcx5;#SpjR?yZS|SS^?)BtbrhPUr^)vjwwCvst_^3#AO-?mGIq@9Tt=~xGt_@s zcEJ{*FM%q6LXx+@nKizUj2+X$pY?JHcYipiyg`LJeAauN$wa=%X+BM4L2i?%ACiuK zVl&CF(+`u^|JuLdGDSzkk~&XQ`GWTjOMCA2c8 z`^YLUIG6HpQSSv`i+(JX(kuM;<-k4;Flx5EC}-!s%<#|Qi6M64A0}+!&s=g4!)L{_ z8YQa>Y17twm>VXR%q6R&K%bn?)ju@7uX-eq8k1u7BTaU%TtVL*k60aR8m97LKPNRk z8_c{()%*b38Ut!&JwoKw|NLr$W26>S#G|2ANT{wytsG6T=hJU8q+0`ejikFMmAnjb zF#|4EVHV%-!KBQ$2HmcgsvA=)?{1i=TeEmsEY4_U1sD$Y-57$u4kGDfkH4xYtl)IxHeoo#K_rDR70vw5H!0apZrgsT8eJz%abG42->h^ykPXCpm|G`7bdszzEFno`{P>g{8V^T)=32vI8$iG63YfXh^mnv-tG5z&e^=_(5y#;0a9LYLx} zk0{p^Lob*;O$k_A`qiUS($hf=mXRe?BM}+a|9^kl)JUbEgg~#OhdzN= zsw@m$=g~iG#JA3HU^XE*WVx_9Nt6uDhay&@)tcU3CG*Ce0t`>bn};tT=n7Ov{SQ** zCLo8OOj?LaMb=hXFRfVnnZ8{}3+FqtDZk>G$qat`0?&otvcizRy?i~4V7UC|gW!w@+yu#&b2$O9K` zC z;OvLOfWVPVVSp`OiZJn;HHQKsdSlKfWl; zn=d@D(&5(4isvcjiAfRGbdoJ;Q#SDDizx~VU03{|c;m7f4hyAng%5oH&wnnWE(78J zFA9rK4LD&zt=6*klhO;_rtF=?R$yRYa9;;`+>PdsDCYIiyx+2>VAp%Rp{`$ox>gq4^16020PSC%@!Xov+8dMFmOd)@n0-@JZ=y_xwL`lAyVVLB4(5ws@%wxc zFZE8!8+;gBAy03$(Hx%+m zv()XoZ9%*cSK21|3}$bpRx0g?=#qElU?>_QVSkOPT-i#J4*48bD1?)ImLYtoCNhmk zT=dM~0~}mx$`!)Gxwx!N54XReK#Txflr7EqhTNqjv{8PXz&Q5-?<~sJtPTWf(5I$= z32lC zY2LY0zdb!{aB;jsroOxQ=B}Xn_9+nN@vL%-Om67fv#l)Iz!p2Q2heVeLuc;VM{^gg zf4MlJdA=iw`qIU7nbgX>2f@$Hma*pFd4pIe6{B{)g2M8Xw$vZ+!MtBdkeYw@8n8uE zv{Bm{^dk%4g$3jqn|~E;`0$DDhEao0kE@`7R=~2O4U*|0qMNgY-83N#o;`?{%E_ds zX@Xwt>VbtJ=Cg;I)ZtKE+3@I_|8JDQB@w(UZrJqVe|S*`iV3!9xcvnzOEjQTBHnET z_3B02>0^L&c`)p*TXxYD2Ka1s!3~@i^f}LGk)c9k@vkNDaWGbx0VW9t62M-)*pZJL zpS<*kuem(G`|`=FxTgY3a{rU*q8E(L1!O^;d5dH=p3kBG}k zJQ-9k#oLdRGac6d6QY56kVLQWM)H}D6<0J(n-%C*pLEQ2P^JyuOgVzqXsg*AeCtF< zcukAMLqcjTjMg9Wv9qIJJ@EJ@{4}W#9b;H^C_Gg2^wa3q<1RihRF~ewgaRI;I18f94uvNLw$V1 zHMR$C-*d367(q@qyy{>bM$8XvVZ~m;3H$t^ZYMV+JOf!jP+*>#>Fay7c4}7g_Vlq? zS`q4=OS%Qo;fE6S)J)uz17`U|!jks+hj9Fk5Kd*r4FdKr=1n`Xf*pf}g+CcoDV*z;fjZfOI+NlNp7Hw^6x~2-yLcyUnx~NTN@%3HmtXOb zpPUtmucc8+-0g#yF@!p-1Zsw}awuAjZ=mS$g2N$At1q)?^8Ow#=Z>DMUMOZgM0F9)aUu|y3qjR6>FNDiE2xA)Oa}@v_7zT< z&4`2){9rd^Ke`Hj+SD=pQ$eQF0f9mCtoCo+a(Y%ChY|UE_k#>NneK$wfe2BFc=9bE z^>GG|Im1r@=Z_QZF^TUNR1ez4uMW{OoeIM>(!*WU;qDJ$m*9CO$ zOp~l*{ehUvCSf;?`cjHCB*f?aAu2ob>)FtzUAWNO1qRMzG%}05R!+$!M>A9FNOs8k zW-hF|^@87hLdB%C!i?QYR*=*u)Vz>U8N(V?py7Y}1KG@NF<+h}Mnr?|Q)*2dP_rX5 zgmqpUzx@EE2yqw}B@yg7uHK6EKdbJlJ~`(8`qreH{gVA^MP|R{gPE!qZtG;;#J3_w zOBBbw2Z@p;h;Lozpd7!3oFW!gqDBZP2aGTI+71>%8|6R*cB|*lWlQn7zRDi3PGq6d zxArSY`SalhUzEt|L_Mj zlIK%Bj`>4~Wc!r(VX)5U3!Wf0pG8752UVzCJ57pDnebP)qdwbpVt?@&#p_y60_di* zPc{}#^rqyH6>icjE5|bYj;}Y~C807EnsnoMiL9ZO;cVy{Rk#wTp0+ow=_bPn#74d5 zem6-rTfomm-RxG=BXmXBXXhqoxS75_q18<0T`}j5^L6n0x9iMnP z6Jf^;HD)pU{MuOuAbl^B=w8MvEGKtOe%zp$kH2D<^IY}9wNF zP7kucpuZe`GFnKs5+%l9Y=JDRC~hs*(O5A`a*J}FOUfUdO0`ZLykY+ag>O3cO>7+6 z7_$;VXynl`oiZKbpRmS`UhhybD)ZiQ4sLJpCwhqjye0e!92ZoG+W}9b!w2E>ic|Ur z=ZZG0w{WJ2y{~D*K8=aP@t4}*iY-3ty$j^=`sm5eh{GZmrzkIM#ly0v0o6Sa1hkYA zIY+IVP^#cQ@X6j1iR4$4>?}&V!q`*Ky5ujq^ZwQ#Lpo#;^1_k8t!Ijk7mtOy^DQ18)a#)CX=o?*6@$^f!aBR`;#c zQ&73CDk0SOe5sr5^u;edaVR6La0m&c=xDnQM;;3Bpa4ifEk zVgquKKP|GY#%4^B^+lrbwq5k9`TwX}Awz^>ck3>G^?kpCAkZNO=f~s%}7j)Cv`_e zL{Itw6?UR_YPC1sQ*=ME)f#y+$D}eVWei)13SN;A`&*RSJhNKcs6;#5^i|KI6l+w* zZyR38Ln+adND&NAUVDbBQsW^}{6HEwc;V^8pGsaka^HwditW9}4aWYIT78jBx24eM zU$h=ZZ+t%*WC%-O5_%weeqhkxD!h@!6Xukh+574b4p;)LSu&xnR-0{fTnFDEKqOta zTqN!}U4;d|%?ftL0$r6;^^43rS;C=Z!G#$^a@2i=G_tQ*{x+|lN*ht=oxkjkyV{*B z6oLxpz!^yUE>%iEsJNQ;;PN9bq zA^4MD>9(07eV+VjV$Dg`{w{=~Y(mQz7V3+v`WneaQ$;&0t>mArYv*oVP-ciGsP|Q3 zRA;~m`L&%jE*lQys5x5?g=ZlHLy&E4Q`R)_HGbxfR)!Ex%L%u0+(l}^ClRmlsPay5 zx?s1?NSN{Z?p9z&W0>@wsdljitzr@$PzC$G5h#fmlxi)ia16D%4uyW!!8<5E)|v|& zTzx7(8$zA%&cY!~@Sdc$TK&@Z_$NdCW!Erng$5<;x0=bdFVI`A6YHw}Quge3B_+q!V}7&17q5SQeo&5w zWxiE*;nMiD^G9vLQnp#DeJl+NAVCacxMEzk}i4w$$E-wf9 zUcx<`fGI}Fo_zLx@^9`1iRE&_nVB(SRs5pw3*B$Xl1WX+G0dWV z2g5+AR{E4J(e}Q_$D1g%+q7>t5m`Gsl_(hCg>ZCpHw&zfsu!!sJ@!V(NR&RG5 zsc~s{v=bSE&3kODNMb*;YuuZvHADDG+dJkmb8!)~P~ML;Q~5aY+eBdtk0eRZrIyak zN(p+v*~Vzu&#T8F3pMPp?`MfDKK`%G#Xm&&-xS`~w95x8F@x#H%HMbSiRU_aMRgma zvDG-~9Edj4U?ox!O6ew27o4 zCEi_g6qP@LT<8o&!*?;MHJHMioqBx-ckcm-vN?_K&J@d2I`36qN*^yKU zGQy~>9Wb9LmP^}i;pTU3dm5xsd?LIPX(sAhO-VVzw*7+%oay{6*zZQqd0(e};Lv)& zRe5VJDnfJ^++l)rY&xc?kK0ElnPmect{So=sz}cM$;$mwkf0z~!F z^I%f+Y_>p}Rr23nwb|opouy)oV|G%!J1V!WJ`9qXx5<05%V$;+GkA*|E8mvNE)uHE zjQnvL^j>)%^L+sZMNDoc+25|Y9Tdu!b;U(inNiybmA< z<{a%^c0C-9=0EWK+#?ySB@rK!-z&Y5W_jzmGVx%OACAX^PL5Eb;M=>xJAf8=Ls$0==TN&FwL?yl_GhVaAE zoJwTllYGc6M(EYgzix358rgOYqxWNhoLnQz6N9ufq7+h&sf!V1NtI5{Zt~s{LI;s+ zs>Fvl2cPBtKScJ1T^V32Oo6E#?ewVHZwSy3YdoXnK7&%96robsJh~Pel62rzTcjh) zA`YAS`8MkE;kQvbij?C4o@OOdDhJJfOjL}=V{guGnk8sG^2jWif1jFodwTy&;X-@- z2*eSabM-_5;C3v*I<&1u7gel$`7-hoK=6ot4PnlAk}U+a9&~wkmrzMP$hC_+fJp^T zr0fO?LBUZ9y@4u{5z)Ih#)Qq~E6sJB#>K7!O)t$I8BP zdH0Xl!)dL>t2U4vW>)$B!e27IF_Zi^lxj_YvGC8=8Hj^XdGPp2cFL8~+O)?0Az@1h zr!Ea#z}IFBA4HwLf~DRMaMDIlE&<37+NhVO0x^^pY}b7N1(~Y6e+ofTjX#sGxBMvj z_Ux2IRAZ9m6j`tc(QYMAeonngHD?=rR;7Yt2Rt$)cb8kf?>Oz~_w!nT@~vkOMLPB7 z?l!Fab%F)}NjU=$xZ3WGb;sk&M-J0^Mq1S3<;Br^*i7324yg;7Q8qTF6$hz3UzbV$ z;Q57t%Mi#!tfcj`Kuz{lJQh!DpXr&+lS(Eyw?L?)^9cPIy?PeEukaZn0c${{BEppN zWy1I=ET;$80yWFrIEJvV3RySA@lyqfxeEpU^IU2BYnoCl$}sQO)J`CV0+i;zc=rdu z!Di|9g%mRzL8o5wO`=M0k!0pcy4C&%>;eIBl)MupI#ntP90~Fikl^uKS-2J z7T1VC`eohfn^TaG1taU~%Qu2;J6OsScE&`S%*_0P zL}d7L_HAR){Qa8Lu z8CBCiW(r?HgulJL?X@{G(eYz0QVKo*8iwper1wWe%US*2H+i0-K?OezbdT$;Ng0!a zeF)LgdIO3d(~C8Y6?}Yke7-BzmMJP^29CJ+_hY61f%>c*c{L;DX8{_&S?4(FwAB)} zaTv>g^sWSD)DE7hPCu?`q1G(z7TrR}P|f=n?pibbh}6WTvX?FraF#8*;s3Y4{u> z$DW4ZFuAcJHQP%bzR>7(DZ$Pu$;BCE0#^cR<0oIDws-3Oy*10dhl7=HC8D|^GgDX$ z)3PMq%I1*cSSf`)sL5neUJ_V3VB+5c!oSzC2&sSb;DyX|*&5(fm;^!2G>tdwLq?L` z+@98ZZN9?mWS7fiLwt9>N)6s6rwRx^v9rIfLba7qem$MG^o9-AM4bKVMBOTC)y{95 zwm+kt4gIahY4{0VBY{(kU|Gvuv-O))w3|k&(4UEa{I;8>VTE?axD|K*rLIwn^fZ#i zF~5nb?6f@wX^+JER*Hlbq+Y-qkZ>#3E#T(rC}?S*P26auZHn?+2rhf}OD3P}jUB3| zD=8v;Kb(Iu{9d=2)XtSaC9i8{g@^#|IaEr`#Mon z)-#W9*T1)kbUA7suO#>?bIje1Vz*xFv`r@Ty!xhJ;sSdb7qxeH@A-IY-9w0-bk92F zWAXSZAvrV7IwEt6dlj2VVp;YMQTj_+laxKHJeQ&8=x^yrk%Xnn-xP6Jf2gD4%1KQ1%X02RaZP7NA^QiN%_F;U7c`Ylt~>c;W! ze>7+HxP&QxoffYndNu=FC^_Cgk@pBM5c0 z)cHhc*P`Sex4-^G+Rz8D#lTxnxWMfIuSbEDC%qfB;MJ2>n$;`auDL^R@GFtZirKg% zrL8C5)>Bl5e{N8MC^%b``%(2)c9?qSEagQLhoYM~{!-`cNkpi1Gp{JR4^g&F;smH0DAF}IC;s|Wm z_=MG>KYwSm{?Kd?ZF{}hLy^tj#raSzx?E58Xw776_&t!b9Qu*GkuUCqQ*e6YVTq>O zDC4tZCUY%+owfV?`9hv@Z*l{3drNHORrJ#*rl_!F!w8!|_mShe!MycZuEPea*LVT< z;%pXpx#u~L)1vt~3~h3@Q_JR`mdwlWbGA}-s2jgcI%#MhpgzTjr0I&h=$E2CQtRlW zZ*C2F@UtUBb#XW8k#HsYS{3;Jdd0(7>1gqSa_0?_?+rnR8qSg7i!sjV)_L*oZ2nWz z?@@mc+$Itn2|2OD%7v8sIFX6x$mj%(6!!}DO}#V5T(!~2{ZDECxn69SLg}x@D)Pi0 zT@4{n?C}?vxkK4ao5=$t!3x%jmgU9YA#WyrR?6kfJdD|Y{VwVn8t7unYI`1Co154J zkm#w(#Prp;L&08a@%u#i4#K$8pAA*Y!{5SKZuGAGhX|YN-wkl-TX+}x-u=wI{4O`)um9Pkf^v_y}N^%qttfS;R`Ty!6 z1IHHbtr-y3M20C3s?ghV6eP&+`$jAHg9DOHHTows;|wv zI+=g2)P`Jl->BNSK0>apxEXwuNy*P1bMtHPVE*4u35W0F>RXkr+|tCev`1}wY%y+S zkpv%JNIklCWEDGgg%ELdX?IF1Q|U&R!gA`%MGp05AMTCEZ}*sS&OXbdBqqWh3#~4^ zz-YiDV6!T%fAlr|^Qf#?e!cWiKbE4Eb!+v<&NQ2W&*z1;WP{Y+rudGU*>}@ve}^51 z2F3(G?>Xr9Q&j=B+a6RPTJMA&8DX*Eb9AV!%{sY(wkjW+btaiIgEVraSZ^tmT9LwL z|M2%0vgD8NWm~t>hQx4D4s6C%@%E~DOJ5mXHozSXCXofGjS|eeEv_#zYT6mwqqF&O zt~4dH65iS`yp&?f55XWwp8)|LiO`2h^}HP4pHB6IgV9l^oOQ$HO?XLyB~k|BF4cAj zLo-5Kw_1izqj6Ux^>9XO^}49N^EdV9`}YUv^U zffAe22TeYHJ;%=gEMAtX(b;M{*39OpuHPuke1>#tku;(t*fbW9Ib*9gpyB$M!J!v_#EpLv+E|&CNT9ZdC=i<|T_&g=ouM zUuRU})tfN{3=cf-MLC7wAWhQ!w~=d$dAaBzgV0wNsgm~~7-Oaiu;qzz-Ncqz&Vo;<&lgfPMYfruGayxnKJ`om+P}*$olA zmssI9AWbBZ2u5SpQ6jIsG$0E(BA=ihlc@05RTkF3vjFH0X*lv-k`PWpNlD4&*@m2~ zX=z#%PZgr|{l!1NNWr5x3PLT#fI;=v{D3|6;#Kk0_%C{Pj-!H_g}~N4Y1@s^bH6RS zpM-oS;xLj4qHySgYK2_5L$4{|2{VX&L18Eh1L=0_wygGt;d-HRM$}E`8?N7PAMs_8 z0VxTwLh#)b94i861=YGnrxTC}8Zg9};d;?cBp3k6%9|&!K;#ixVSfhGGCVQhe@Na3 zg6^ic47i;k+!u|UoW57T#n$zU3z3Xg(ZGhp1Z6r4=K|N?zy>FyP8??ZRa~8nL8P~1 z4)yo>T?@3+q}&L60+1fV;={t#t#p78Uz@I5dlAW!a%1v<<*)cCkz&_nyB!4PWJ8<* zi0?V*)aLo6q!>vM9PVI2eySwaB)HoNsGR7Mwdb(341Bg~FbD>pRx?p9K;7KHW{uo= zB~1rfq+51*Jq1G*;h=u1g;g!3Vd7k`bLlo&wfBgef#xJ?7RL+=^NR?kFztPyd7F9h z#B`pMA*BAm8oD+9;)?i7y!q%=8M|1ds;4q$ew_kiuI7 z{ZiI-)PDs`N06_^kRKL?t-a{^=?>s9?q-fY^KJvcPd^1IgB;v6TJr0Wxu~UZI<(Ou zB5NpYybVB_Kg2&4+okM9CgG^E3tE%eM_Wero1pD$P%z!e+&ot*@{Um3@ATy4U}t&g zP~IJo-4@aYoxQu@{T9OuCDe59z`$4RYfLj=oG1eoXkK8LJcHcm4)7$+ud%(4@^}08 zpR8TzR#aZ$N2o_i4|U~@nuE#?j~z@{I?sueY$sehv~^_Rcd+~Yo~wVObEqX=(E_HT zS*b<$VF56X+`hfP08c5f%LysZ;^mdDws2x~XH-_b5=QZN_5X$JO-KKZEkIqtSX-Z?Yn7rcR0z2nPzpy;e(K1^J-hN;gdg&OnUaUOM zK+JCG2X5^L6|GvEK@TgN*I~_ic@7V6A_9=m&0^ADQ5oawqwe^15b+lO`W- zj`(+-YV10jxJaL6+e5emfJPSP$Z;-uEJycNxy$gFWY99#X@beq|8( z;z-TXh8Mci1!@Sq<7@jNgh~Cm7K$f1#S(pYl;V5bPsw)+;*Bh?+4gdvumA@|zu$nA zb1~?0uq!zA%%H}K`~!X(s7Gu*1;T_jqO5NJDEyhT^ED}+3X{47w;F2)2M3X?>kwQ6 zBDN|P9)p^$IpPnnJ3Cs>G@h`tbwB6}BcKZTd}nUZnc#5m5{xq_A&Pho$7ZRAI;;Ha za+-ZtWv6k=bXM2S_-#-1?dEZ^-T|znjGTxZBue1SQm9hW7^JA|qOYT~O0tQo9enw< znMfhb?L{!ArUb0q=&WdL1K>N|Y{;MopTWDo4-=f5cpuzPTDpKi99G?E`m4anWk57Pl0G z^e?6ht@IP#6aIU*bg}#gZ6(U34PwRSI+TT<@}n^Kx9j7!dL-^Sm#xN+DYUA$EKvtj7Q;(Av~8U=k6h zepJGd>3_?4;gE}b%kNu2pTJm|rtE~ujq_0RJ4!-?_I~XdSIn;ZKW)8rTvTlrH7p8} z(kUg~paR0s(w!12NP|i@N*pAlyIVp8MOr~XkWjh=B&0(?kxmi#_V|3y+xI`-KNx1t zoVc#F*IsMwfMtoVDJeT8=hH#asV-uws&Yg_d0!<<&3Qw?fiwVkLjl z4XG=#bz%s=@^zW`7}^#Zhp2rrQQ3P4yMaCbjY7UlOrB}*fb~t&j&ru7?1jGHe{ZCH zK2AxBWI{7Y@j=BpLyG}T6YnJ}Z2CUi)}R({5MYX`6tjI3m8$h6uae%y0=ess3Rhq5 z#?s(M5;w$#c-r1$t+B2iCM)=4vn^n3L3L(BrASOm4Vps2B7~TPR>H58+w&n9F>eQl z#^Kvmv^o#mjm{!U4;d(M{L;0~(baG<&@6eC?2P!P;my%1*R=RMx=Xw%B#z>ExF?5& ziJBJ4uIq#ii^#ZTVY9gY6Zt{n-AzZO878t1hM$&Zr7p3WdmUmO+y{v&)|+f2Vjh~l z_7_Ko;fLN#>(ckY@{M$vl_)8+L_#HCRTeGhmaE1@108@u}p$+;MQ zl|=H>rw>pDgil-MQFo9k8Jb`22NB?lW1#05eb#R*hAU0W>ef8eNg~$WhiIA45}EMXEVr%5gtsEVbs`jxP|D9*L*go%j=1bn+6+239KoGg zWV@l&NVe|PN`+&bBAF?I!*$C;BWAm-In!*nkJj^M7&7_1NAEFHON~f>eTD6~O-09y z2O+}#y*S=Q(O5!xbAF2{pDjQK3F%l&oW1#?ieTc=nWW|P>~(AW;RH@L!9+2#eju$k zFY7;xsX;7Mf~!MWiNCNeC~0H5MF+ecwE`7aN|rd;HTHr5>d!b}d?0z-N^BWIt3*he z{mBT&`(-KrbByopqAhrrAIOd$bwV|gg#>`xrigLjJ5*>-vpl}tO+Zq}Yw0r;`WL^M zjj)`pFRC+mkMPkQiP2Wnn0Q8QrN>

    L@c(^$abPf=0u2wUiaRy9_PrxA>g}7{ub9 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

    l*=~a(xee;n3l2@E?dvX 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 M$*H4DWK8}44<;qu`v3p{ delta 69101 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≥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 z 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

    L@c(^$abPf=0u2wUiaRy9_PrxA>g}7{ub9 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 { throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot."); } -const basePluginData = createE2eCouchDbPluginData( - { - uri: "http://127.0.0.1:5984", - username: "", - password: "", - dbName: "p2p-pane-ui-only", - }, - { - notifyThresholdOfRemoteStorageSize: -1, - periodicReplication: false, - P2P_Enabled: false, - P2P_AutoStart: false, - syncAfterMerge: false, - syncOnEditorSave: false, - syncOnFileOpen: false, - syncOnSave: false, - syncOnStart: false, - } -); +function createBaseP2PPluginData(): Record { + return createE2eCouchDbPluginData( + { + uri: "http://127.0.0.1:5984", + username: "", + password: "", + dbName: "p2p-pane-ui-only", + }, + { + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + P2P_Enabled: false, + P2P_AutoStart: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + } + ); +} + +function createConfiguredP2PPluginData(): Record { + const pluginData = { + ...createBaseP2PPluginData(), + P2P_roomID: "configured-p2p-room", + P2P_passphrase: "configured-p2p-passphrase", + }; + upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "p2p", { + id: "e2e-p2p", + name: "P2P Remote", + activateForP2P: true, + }); + return pluginData; +} async function withP2PSession( binary: string, @@ -170,18 +199,14 @@ async function main(): Promise { throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); } - await withP2PSession(binary, cli.binary, basePluginData, async () => { + await withP2PSession(binary, cli.binary, createBaseP2PPluginData(), async () => { await assertP2PUIIsOptIn(); }); await withP2PSession( binary, cli.binary, - { - ...basePluginData, - P2P_roomID: "configured-p2p-room", - P2P_passphrase: "configured-p2p-passphrase", - }, + createConfiguredP2PPluginData(), async () => { await assertConfiguredP2PUIIsAvailable(); const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false); From 32b72e4b103b3b9bdd89a9f70285339976bb8bb7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 05:39:40 +0000 Subject: [PATCH 139/170] Normalise Chinese setup guide line endings --- docs/setup_own_server_cn.md | 304 ++++++++++++++++++------------------ 1 file changed, 152 insertions(+), 152 deletions(-) diff --git a/docs/setup_own_server_cn.md b/docs/setup_own_server_cn.md index 1c88ed61..809c02e3 100644 --- a/docs/setup_own_server_cn.md +++ b/docs/setup_own_server_cn.md @@ -1,152 +1,152 @@ -# 在你自己的服务器上设置 CouchDB - -## 目录 -- [配置 CouchDB](#配置-CouchDB) -- [运行 CouchDB](#运行-CouchDB) - - [Docker CLI](#docker-cli) - - [Docker Compose](#docker-compose) -- [创建数据库](#创建数据库) -- [从移动设备访问](#从移动设备访问) - - [移动设备测试](#移动设备测试) - - [设置你的域名](#设置你的域名) ---- - -> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档) - -## 配置 CouchDB - -设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)). - -需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下: - -``` -[couchdb] -single_node=true -max_document_size = 50000000 - -[chttpd] -require_valid_user = true -max_http_request_size = 4294967296 - -[chttpd_auth] -require_valid_user = true -authentication_redirect = /_utils/session.html - -[httpd] -WWW-Authenticate = Basic realm="couchdb" -enable_cors = true - -[cors] -origins = app://obsidian.md,capacitor://localhost,http://localhost -credentials = true -headers = accept, authorization, content-type, origin, referer -methods = GET, PUT, POST, HEAD, DELETE -max_age = 3600 -``` - -## 运行 CouchDB - -### Docker CLI - -你可以通过指定 `local.ini` 配置运行 CouchDB: - -``` -$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb -``` -*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* - -后台运行: -``` -$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb -``` -*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* - -### Docker Compose -创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下: -``` -obsidian-livesync -├── docker-compose.yml -└── local.ini -``` - -可以参照以下内容编辑 `docker-compose.yml`: -```yaml -services: - couchdb: - image: couchdb - container_name: obsidian-livesync - user: 1000:1000 - environment: - - COUCHDB_USER=admin - - COUCHDB_PASSWORD=password - volumes: - - ./data:/opt/couchdb/data - - ./local.ini:/opt/couchdb/etc/local.ini - ports: - - 5984:5984 - restart: unless-stopped -``` - -最后, 创建并启动容器: -``` -# -d will launch detached so the container runs in background -docker-compose up -d -``` - -## 创建数据库 - -CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步. - -1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面 -2. 点击 Create Database, 然后根据个人喜好创建数据库 - -## 从移动设备访问 -如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。 - -### 移动设备测试 -测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案) - -``` -$ ssh -R 80:localhost:5984 nokey@localhost.run -Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts. - -=============================================================================== -Welcome to localhost.run! - -Follow your favourite reverse tunnel at [https://twitter.com/localhost_run]. - -**You need a SSH key to access this service.** -If you get a permission denied follow Gitlab's most excellent howto: -https://docs.gitlab.com/ee/ssh/ -*Only rsa and ed25519 keys are supported* - -To set up and manage custom domains go to https://admin.localhost.run/ - -More details on custom domains (and how to enable subdomains of your custom -domain) at https://localhost.run/docs/custom-domains - -To explore using localhost.run visit the documentation site: -https://localhost.run/docs/ - -=============================================================================== - - -** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. ** - -xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run -Connection to localhost.run closed by remote host. -Connection to localhost.run closed. -``` - -https://xxxxxxxx.localhost.run 即为临时服务器地址。 - -### 设置你的域名 - -设置一个指向你服务器的 A 记录,并根据需要设置反向代理。 - -Note: 不推荐将 CouchDB 挂载到根目录 -可以使用 Caddy 很方便的给服务器加上 SSL 功能 - -提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。 - -注意检查服务器日志,当心恶意访问。 +# 在你自己的服务器上设置 CouchDB + +## 目录 +- [配置 CouchDB](#配置-CouchDB) +- [运行 CouchDB](#运行-CouchDB) + - [Docker CLI](#docker-cli) + - [Docker Compose](#docker-compose) +- [创建数据库](#创建数据库) +- [从移动设备访问](#从移动设备访问) + - [移动设备测试](#移动设备测试) + - [设置你的域名](#设置你的域名) +--- + +> 注:提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。推荐直接使用该 docker-compose 配置进行搭建。(若使用,请查阅链接中的文档,而不是这个文档) + +## 配置 CouchDB + +设置 CouchDB 的最简单方法是使用 [CouchDB docker image]((https://hub.docker.com/_/couchdb)). + +需要修改一些 `local.ini` 中的配置,以让它可以用于 Self-hosted LiveSync,如下: + +``` +[couchdb] +single_node=true +max_document_size = 50000000 + +[chttpd] +require_valid_user = true +max_http_request_size = 4294967296 + +[chttpd_auth] +require_valid_user = true +authentication_redirect = /_utils/session.html + +[httpd] +WWW-Authenticate = Basic realm="couchdb" +enable_cors = true + +[cors] +origins = app://obsidian.md,capacitor://localhost,http://localhost +credentials = true +headers = accept, authorization, content-type, origin, referer +methods = GET, PUT, POST, HEAD, DELETE +max_age = 3600 +``` + +## 运行 CouchDB + +### Docker CLI + +你可以通过指定 `local.ini` 配置运行 CouchDB: + +``` +$ docker run --rm -it -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb +``` +*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* + +后台运行: +``` +$ docker run -d --restart always -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password -v /path/to/local.ini:/opt/couchdb/etc/local.ini -p 5984:5984 couchdb +``` +*记得将上述命令中的 local.ini 挂载路径替换成实际的存放路径* + +### Docker Compose +创建一个文件夹, 将你的 `local.ini` 放在文件夹内, 然后在文件夹内创建 `docker-compose.yml`. 请确保对 `local.ini` 有读写权限并且确保在容器运行后能创建 `data` 文件夹. 文件夹结构大概如下: +``` +obsidian-livesync +├── docker-compose.yml +└── local.ini +``` + +可以参照以下内容编辑 `docker-compose.yml`: +```yaml +services: + couchdb: + image: couchdb + container_name: obsidian-livesync + user: 1000:1000 + environment: + - COUCHDB_USER=admin + - COUCHDB_PASSWORD=password + volumes: + - ./data:/opt/couchdb/data + - ./local.ini:/opt/couchdb/etc/local.ini + ports: + - 5984:5984 + restart: unless-stopped +``` + +最后, 创建并启动容器: +``` +# -d will launch detached so the container runs in background +docker-compose up -d +``` + +## 创建数据库 + +CouchDB 部署成功后, 需要手动创建一个数据库, 方便插件连接并同步. + +1. 访问 `http://localhost:5984/_utils`, 输入帐号密码后进入管理页面 +2. 点击 Create Database, 然后根据个人喜好创建数据库 + +## 从移动设备访问 +如果你想要从移动设备访问 Self-hosted LiveSync,你需要一个合法的 SSL 证书。 + +### 移动设备测试 +测试时,[localhost.run](http://localhost.run/) 这一类的反向隧道服务很实用。(非必须,只是用于终端设备不方便 ssh 的时候的备选方案) + +``` +$ ssh -R 80:localhost:5984 nokey@localhost.run +Warning: Permanently added the RSA host key for IP address '35.171.254.69' to the list of known hosts. + +=============================================================================== +Welcome to localhost.run! + +Follow your favourite reverse tunnel at [https://twitter.com/localhost_run]. + +**You need a SSH key to access this service.** +If you get a permission denied follow Gitlab's most excellent howto: +https://docs.gitlab.com/ee/ssh/ +*Only rsa and ed25519 keys are supported* + +To set up and manage custom domains go to https://admin.localhost.run/ + +More details on custom domains (and how to enable subdomains of your custom +domain) at https://localhost.run/docs/custom-domains + +To explore using localhost.run visit the documentation site: +https://localhost.run/docs/ + +=============================================================================== + + +** your connection id is xxxxxxxxxxxxxxxxxxxxxxxxxxxx, please mention it if you send me a message about an issue. ** + +xxxxxxxx.localhost.run tunneled with tls termination, https://xxxxxxxx.localhost.run +Connection to localhost.run closed by remote host. +Connection to localhost.run closed. +``` + +https://xxxxxxxx.localhost.run 即为临时服务器地址。 + +### 设置你的域名 + +设置一个指向你服务器的 A 记录,并根据需要设置反向代理。 + +Note: 不推荐将 CouchDB 挂载到根目录 +可以使用 Caddy 很方便的给服务器加上 SSL 功能 + +提供了 [docker-compose.yml 和 ini 文件](https://github.com/vrtmrz/self-hosted-livesync-server) 可以同时启动 Caddy 和 CouchDB。 + +注意检查服务器日志,当心恶意访问。 From 058da1f34ce16f296396c788a6ce5dfec98d4b02 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 05:55:37 +0000 Subject: [PATCH 140/170] Polish setup guide wording --- docs/quick_setup.md | 2 +- docs/tips/hidden-file-sync.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/quick_setup.md b/docs/quick_setup.md index f685db48..a77b3834 100644 --- a/docs/quick_setup.md +++ b/docs/quick_setup.md @@ -119,7 +119,7 @@ Use this path when CouchDB is ready but a Setup URI is unavailable. It configure 5. On `Choose a synchronisation remote`, select `CouchDB`, then select `Continue to CouchDB setup`. - ![CouchDB option in the synchronisation remote choices](../images/couchdb-manual/guide-couchdb-manual-remote-selection.png) + ![CouchDB option in the list of synchronisation remotes](../images/couchdb-manual/guide-couchdb-manual-remote-selection.png) 6. Enter the complete CouchDB URL, username, password, and database name. - Obsidian Mobile requires HTTPS. Plain HTTP is suitable only for a trusted local connection from a desktop device. diff --git a/docs/tips/hidden-file-sync.md b/docs/tips/hidden-file-sync.md index e84570d9..4fd89a09 100644 --- a/docs/tips/hidden-file-sync.md +++ b/docs/tips/hidden-file-sync.md @@ -47,7 +47,7 @@ A pattern containing only `snippets` does not admit the `.obsidian` parent, so t ![Hidden File Sync initialisation choices](../../images/hidden-file-sync/guide-hidden-file-enable.png) 2. Under `Enable Hidden File Sync`, select the initialisation direction chosen above. -3. Keep Obsidian open while the initial scan and synchronisation finish. A progress Notice appears when preparation begins and remains visible while the initial scan is running. +3. Keep Obsidian open while the initial scan and synchronisation finish. A progress Notice appears when preparation begins and remains visible until the initial scan has finished. ![Hidden File Sync initial scan progress Notice](../../images/hidden-file-sync/guide-hidden-file-initial-scan-progress.png) From d48945258367e00b2866e112f0440c0e7223135d Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 08:51:30 +0000 Subject: [PATCH 141/170] Improve troubleshooting and compatible setting handling --- CONTRIBUTING.md | 5 + _tools/inspect-troubleshooting-docs.ts | 138 +++++ .../inspect-troubleshooting-docs.unit.spec.ts | 17 + docs/settings.md | 6 + docs/troubleshooting.md | 13 +- docs/tweak_mismatch_dialogue.png | Bin 47151 -> 32256 bytes package.json | 1 + .../ModuleResolveMismatchedTweaks.ts | 47 +- ...ModuleResolveMismatchedTweaks.unit.spec.ts | 125 +++- .../SettingDialogue/LiveSyncSetting.ts | 3 +- .../features/SettingDialogue/PaneAdvanced.ts | 4 +- .../features/SettingDialogue/SettingPane.ts | 1 + test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/scripts/dialog-mounts.ts | 553 +++++++++++++++++- updates.md | 3 + 15 files changed, 868 insertions(+), 50 deletions(-) create mode 100644 _tools/inspect-troubleshooting-docs.ts create mode 100644 _tools/inspect-troubleshooting-docs.unit.spec.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1ca3b9f..a97333b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,11 @@ Before submitting a pull request, you must run verification scripts locally to e ```bash npm run test:unit ``` +- When changing the troubleshooting or recovery guides, inspect their current English UI labels and local references: + ```bash + npm run inspect:troubleshooting + ``` + This read-only Inspector prints JSON containing `ok`, `checkedFiles`, `checkedLocalReferences`, and `errors`, and exits unsuccessfully when a contract is stale. If you have the capability and a suitable environment (such as Linux and Docker), running the CLI End-to-End (E2E) tests is also highly appreciated. Instructions are detailed in [devs.md](devs.md). If you cannot run E2E tests locally, please explicitly ask to run the tests on the CI by stating 'Please run CI tests' in your pull request description. diff --git a/_tools/inspect-troubleshooting-docs.ts b/_tools/inspect-troubleshooting-docs.ts new file mode 100644 index 00000000..e7de1545 --- /dev/null +++ b/_tools/inspect-troubleshooting-docs.ts @@ -0,0 +1,138 @@ +import { access, readFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +type InspectionError = { + check: "current-label" | "local-reference" | "retired-label"; + file: string; + detail: string; +}; + +export type TroubleshootingDocsInspection = { + ok: boolean; + checkedFiles: string[]; + checkedLocalReferences: number; + errors: InspectionError[]; +}; + +const guidePaths = ["docs/troubleshooting.md", "docs/recovery.md", "docs/tips/p2p-sync-tips.md"] as const; +const messageCataloguePath = "src/common/messagesJson/en.json"; +const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu; + +function repositoryRootFromThisFile(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), ".."); +} + +function normaliseReferenceTarget(rawTarget: string): string { + const withoutAngles = rawTarget.startsWith("<") && rawTarget.endsWith(">") ? rawTarget.slice(1, -1) : rawTarget; + return decodeURIComponent(withoutAngles); +} + +function isExternalReference(target: string): boolean { + return /^(?:https?:|mailto:|obsidian:)/u.test(target); +} + +async function inspectLocalReferences( + repositoryRoot: string, + documentPath: string, + document: string, + errors: InspectionError[] +): Promise { + let checked = 0; + for (const match of document.matchAll(markdownLinkPattern)) { + const rawTarget = match[1]; + if (!rawTarget) continue; + const target = normaliseReferenceTarget(rawTarget); + if (isExternalReference(target) || target.startsWith("#")) continue; + + const [pathPart] = target.split("#", 1); + if (!pathPart) continue; + checked++; + const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart); + try { + await access(referencedPath); + } catch { + errors.push({ + check: "local-reference", + file: documentPath, + detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`, + }); + } + } + return checked; +} + +export async function inspectTroubleshootingDocs( + repositoryRoot = repositoryRootFromThisFile() +): Promise { + const errors: InspectionError[] = []; + const documents = new Map(); + for (const guidePath of guidePaths) { + documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8")); + } + + const troubleshooting = documents.get("docs/troubleshooting.md")!; + const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record< + string, + string + >; + const requiredMessageKeys = [ + "TweakMismatchResolve.Action.UseConfigured", + "TweakMismatchResolve.Action.UseMine", + "TweakMismatchResolve.Action.UseRemote", + "TweakMismatchResolve.Action.Dismiss", + "obsidianLiveSyncSettingTab.titleSyncSettingsViaMarkdown", + ] as const; + + for (const messageKey of requiredMessageKeys) { + const label = catalogue[messageKey]; + if (!label) { + errors.push({ + check: "current-label", + file: messageCataloguePath, + detail: `The English message catalogue does not define ${messageKey}.`, + }); + continue; + } + if (!troubleshooting.includes(label)) { + errors.push({ + check: "current-label", + file: "docs/troubleshooting.md", + detail: `The guide does not include the current UI label '${label}'.`, + }); + } + } + + for (const retiredLabel of ["`Update with mine`", "`Use configured`", "`Sync settings via Markdown files`"]) { + if (troubleshooting.includes(retiredLabel)) { + errors.push({ + check: "retired-label", + file: "docs/troubleshooting.md", + detail: `The guide still includes the retired label ${retiredLabel}.`, + }); + } + } + + let checkedLocalReferences = 0; + for (const [guidePath, document] of documents) { + checkedLocalReferences += await inspectLocalReferences(repositoryRoot, guidePath, document, errors); + } + + return { + ok: errors.length === 0, + checkedFiles: [...guidePaths], + checkedLocalReferences, + errors, + }; +} + +async function runCli(): Promise { + const result = await inspectTroubleshootingDocs(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.ok) process.exitCode = 1; +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; +if (invokedPath === import.meta.url) { + await runCli(); +} diff --git a/_tools/inspect-troubleshooting-docs.unit.spec.ts b/_tools/inspect-troubleshooting-docs.unit.spec.ts new file mode 100644 index 00000000..f184a162 --- /dev/null +++ b/_tools/inspect-troubleshooting-docs.unit.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { inspectTroubleshootingDocs } from "./inspect-troubleshooting-docs"; + +describe("troubleshooting documentation contract", () => { + it("uses current English UI labels and resolves every local guide reference", async () => { + const result = await inspectTroubleshootingDocs(); + + expect(result.checkedFiles).toEqual([ + "docs/troubleshooting.md", + "docs/recovery.md", + "docs/tips/p2p-sync-tips.md", + ]); + expect(result.checkedLocalReferences).toBeGreaterThan(0); + expect(result.errors).toEqual([]); + expect(result.ok).toBe(true); + }); +}); diff --git a/docs/settings.md b/docs/settings.md index 4dd9521c..16af6883 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -959,6 +959,12 @@ If disabled(toggled), chunks will be split on the UI thread (Previous behaviour) Setting key: processSmallFilesInUIThread If enabled, the file under 1kb will be processed in the UI thread. +#### Automatically align compatible chunk settings + +Setting key: autoAcceptCompatibleTweak + +Current releases enable this by default when the differences are limited to compatible chunk settings. The side with the newer recorded modification time is used for the chunk hash algorithm, chunk size, or splitter version; the remote value is used when neither side has a recorded time or the times are equal. No dialogue or database reconstruction is required. Existing content remains readable, but changing these values can reduce chunk reuse. Turn this off to review compatible differences manually. Any difference which also involves an incompatible setting always requires an explicit decision. + ### 8. Compatibility (Trouble addressed) #### Do not check configuration mismatch before replication diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index be623d3f..41812ed0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -61,9 +61,14 @@ If the log reports missing chunks or a size mismatch: Some settings must match across devices. LiveSync pauses synchronisation when the local and remote values differ rather than propagating an unexpected change silently. -- Choose `Update with mine` only when this device's setting is the intended shared value. -- Choose `Use configured` to accept the value already stored for the synchronisation group. -- `Dismiss` postpones the decision, but synchronisation remains paused until it is resolved. +Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision. + +The available actions depend on when the mismatch is found: + +- While checking a remote profile, `Use configured settings` accepts the shared values already stored in that remote. `Dismiss` leaves this device's settings unchanged. +- For a mismatch found before synchronisation, `Apply settings to this device` accepts the remote values. Choose `Update remote database settings` only when this device's values are intended to become the shared values. +- When the change requires local or remote reconstruction, the action itself states that Fetch or Rebuild will follow. Make sure that the intended authoritative copy is available before choosing it. +- `Dismiss` postpones a mismatch found before synchronisation. Synchronisation remains paused until the mismatch is resolved. ![Configuration mismatch dialogue](tweak_mismatch_dialogue.png) @@ -75,7 +80,7 @@ Historic defect notices and renamed controls are retained in the [0.25 release h Generate an encrypted Setup URI from a working device. This preserves the intended remote profiles and selections while allowing the additional device to keep its own device-specific name. Store the URI and its passphrase separately. -For deliberate setting changes during normal use, use `Sync settings via Markdown files` under `Sync settings`. +For deliberate setting changes during normal use, use `Sync Settings via Markdown` under `Sync settings`. ### Choose a Setup URI passphrase diff --git a/docs/tweak_mismatch_dialogue.png b/docs/tweak_mismatch_dialogue.png index 073a999be8fbdd8e47fd21221f3445a9834b023e..d25566cada62529216f7393b3cc07999510e2e40 100644 GIT binary patch literal 32256 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 literal 47151 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 vWiE;X3wT)ezaBawNMFW>zyk~E) { const items = Object.keys( TweakValuesShouldMatchedTemplate @@ -64,33 +62,17 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { ); if (!hasOnlyCompatibleLossyMismatches) return undefined; + let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak; if (this.settings.autoAcceptCompatibleTweak === undefined) { - if (this._hasNotifiedAutoAcceptCompatibleUndefined) { - return undefined; - } - this._hasNotifiedAutoAcceptCompatibleUndefined = true; - const CHOICE_ENABLE = $msg("TweakMismatchResolve.Action.EnableAutoAcceptCompatible"); - const CHOICE_DISABLE = $msg("TweakMismatchResolve.Action.DisableAutoAcceptCompatible"); - const CHOICES = [CHOICE_ENABLE, CHOICE_DISABLE] as const; - const message = $msg("TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined"); - const ret = await this.core.confirm.askSelectStringDialogue(message, CHOICES, { - title: $msg("TweakMismatchResolve.Title.AutoAcceptCompatible"), - timeout: 0, - defaultAction: CHOICE_ENABLE, - }); - if (ret !== CHOICE_ENABLE) { - return undefined; - } - await this.services.setting.applyPartial( - { - autoAcceptCompatibleTweak: true, - }, - true - ); - Logger("Auto-accept for compatible tweak mismatch has been enabled."); + // Keep the settings object stable: settings panes and an in-flight replication retry can + // retain this reference while the default is persisted. + this.settings.autoAcceptCompatibleTweak = true; + await this.services.setting.saveSettingData(); + autoAcceptCompatibleTweak = true; + Logger("Automatic alignment of compatible chunk settings has been enabled."); } - if (this.settings.autoAcceptCompatibleTweak !== true) return undefined; + if (autoAcceptCompatibleTweak !== true) return undefined; return this._selectNewerTweakSide(current, preferred); } @@ -215,7 +197,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { } else if (rebuildRecommended) { CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]); CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]); - CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [true, true]]); + CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]); CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]); } else { CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]); @@ -255,9 +237,16 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule { return "CHECKAGAIN"; } if (conf) { - this.settings = { ...this.settings, ...conf }; - await this.core.replicator.setPreferredRemoteTweakSettings(this.settings); + // ReplicationService retains the current settings object while it performs the immediate + // CHECKAGAIN retry. Update that object in place so the retry observes the accepted values. + Object.assign(this.settings, extractObject(TweakValuesTemplate, conf)); await this.services.setting.saveSettingData(); + if (!rebuildRequired) { + // The failed replication has settled before mismatch resolution runs. Reinitialise the + // chunk-generation managers now so hash and splitter changes take effect before retrying. + await this.localDatabase.managers.reinitialise(); + } + await this.core.replicator.setPreferredRemoteTweakSettings(this.settings); if (rebuildRequired) { await this.core.rebuilder.$fetchLocal(); } diff --git a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts index 68fc0661..21f773e0 100644 --- a/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleResolveMismatchedTweaks.unit.spec.ts @@ -1,9 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type RemoteDBSettings, type TweakValues } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + DEFAULT_SETTINGS, + REMOTE_COUCHDB, + type RemoteDBSettings, + type TweakValues, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks"; function createModule(settingsOverride: Partial = {}) { - const askSelectStringDialogue = vi.fn(async () => undefined); + const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise => undefined); + const applyPartial = vi.fn(async (_partial: Record): Promise => undefined); + const reinitialise = vi.fn(async () => undefined); const core = { _services: { API: { @@ -15,6 +22,12 @@ function createModule(settingsOverride: Partial = {}) { }, setting: { saveSettingData: vi.fn(async () => undefined), + applyPartial, + }, + }, + localDatabase: { + managers: { + reinitialise, }, }, settings: { @@ -26,6 +39,9 @@ function createModule(settingsOverride: Partial = {}) { askSelectStringDialogue, }, } as any; + applyPartial.mockImplementation(async (partial: Record) => { + core.settings = { ...core.settings, ...partial }; + }); Object.defineProperty(core, "services", { get() { @@ -34,10 +50,35 @@ function createModule(settingsOverride: Partial = {}) { }); const module = new ModuleResolvingMismatchedTweaks(core); - return { module, core, askSelectStringDialogue }; + return { module, core, askSelectStringDialogue, applyPartial, reinitialise }; } describe("ModuleResolvingMismatchedTweaks", () => { + it("should enable and auto-accept compatible mismatches when the preference is undefined", async () => { + const { module, core, askSelectStringDialogue, applyPartial } = createModule({ + autoAcceptCompatibleTweak: undefined, + hashAlg: "xxhash64", + tweakModified: 100, + }); + const initialSettings = core.settings; + + const preferred = { + ...(DEFAULT_SETTINGS as unknown as TweakValues), + hashAlg: "xxhash32", + tweakModified: 200, + } as Partial; + + const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred); + + expect(conf).toEqual(preferred); + expect(rebuild).toBe(false); + expect(core.settings).toBe(initialSettings); + expect(core.settings.autoAcceptCompatibleTweak).toBe(true); + expect(core._services.setting.saveSettingData).toHaveBeenCalledTimes(1); + expect(applyPartial).not.toHaveBeenCalled(); + expect(askSelectStringDialogue).not.toHaveBeenCalled(); + }); + it("should auto-accept compatible mismatches on connect check using newer remote tweakModified", async () => { const { module, askSelectStringDialogue } = createModule({ autoAcceptCompatibleTweak: true, @@ -58,6 +99,28 @@ describe("ModuleResolvingMismatchedTweaks", () => { expect(askSelectStringDialogue).not.toHaveBeenCalled(); }); + it.each([ + { label: "neither side has a recorded time", currentModified: 0, preferredModified: 0 }, + { label: "the recorded times are equal", currentModified: 200, preferredModified: 200 }, + ])("should use the remote compatible value when $label", async ({ currentModified, preferredModified }) => { + const { module, askSelectStringDialogue } = createModule({ + autoAcceptCompatibleTweak: true, + hashAlg: "xxhash64", + tweakModified: currentModified, + }); + const preferred = { + ...(DEFAULT_SETTINGS as unknown as TweakValues), + hashAlg: "xxhash32", + tweakModified: preferredModified, + } as Partial; + + const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred); + + expect(conf).toEqual(preferred); + expect(rebuild).toBe(false); + expect(askSelectStringDialogue).not.toHaveBeenCalled(); + }); + it("should fallback to manual confirmation when mismatches are mixed on connect check", async () => { const { module, askSelectStringDialogue } = createModule({ autoAcceptCompatibleTweak: true, @@ -80,6 +143,24 @@ describe("ModuleResolvingMismatchedTweaks", () => { expect(askSelectStringDialogue).toHaveBeenCalledTimes(1); }); + it("should fetch after applying a compatible remote setting when the user selects the rebuild option", async () => { + const { module, askSelectStringDialogue } = createModule({ + autoAcceptCompatibleTweak: false, + hashAlg: "xxhash64", + }); + askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again"); + + const preferred = { + ...(DEFAULT_SETTINGS as unknown as TweakValues), + hashAlg: "xxhash32", + } as TweakValues; + + const [conf, rebuild] = await module._checkAndAskResolvingMismatchedTweaks(preferred); + + expect(conf).toEqual(preferred); + expect(rebuild).toBe(true); + }); + it("should auto-accept compatible mismatches on remote-config check using newer local tweakModified", async () => { const { module, askSelectStringDialogue } = createModule({ autoAcceptCompatibleTweak: true, @@ -105,4 +186,42 @@ describe("ModuleResolvingMismatchedTweaks", () => { expect(result).toEqual({ result: false, requireFetch: false }); expect(askSelectStringDialogue).not.toHaveBeenCalled(); }); + + it("should apply remote compatible settings in place and reinitialise managers before retrying", async () => { + const { module, core, reinitialise } = createModule({ + autoAcceptCompatibleTweak: true, + hashAlg: "xxhash64", + tweakModified: 100, + }); + const initialSettings = core.settings; + const preferred = { + ...(DEFAULT_SETTINGS as unknown as TweakValues), + hashAlg: "xxhash32", + tweakModified: 200, + } as TweakValues; + const calls: string[] = []; + core._services.tweakValue = { + checkAndAskResolvingMismatched: vi.fn(async () => [preferred, false]), + }; + core._services.setting.saveSettingData = vi.fn(async () => { + calls.push("save"); + }); + core.replicator = { + tweakSettingsMismatched: true, + preferredTweakValue: preferred, + setPreferredRemoteTweakSettings: vi.fn(async () => { + calls.push("set-preferred"); + }), + }; + reinitialise.mockImplementation(async () => { + calls.push("reinitialise"); + }); + + const result = await module._askResolvingMismatchedTweaks(); + + expect(result).toBe("CHECKAGAIN"); + expect(core.settings).toBe(initialSettings); + expect(core.settings.hashAlg).toBe("xxhash32"); + expect(calls).toEqual(["save", "reinitialise", "set-preferred"]); + }); }); diff --git a/src/modules/features/SettingDialogue/LiveSyncSetting.ts b/src/modules/features/SettingDialogue/LiveSyncSetting.ts index 96ee66ef..b7876678 100644 --- a/src/modules/features/SettingDialogue/LiveSyncSetting.ts +++ b/src/modules/features/SettingDialogue/LiveSyncSetting.ts @@ -206,7 +206,8 @@ export class LiveSyncSetting extends Setting { const setValue = wrapMemo((value: boolean) => { toggle.setValue(opt?.invert ? !value : value); }); - this.invalidateValue = () => setValue(LiveSyncSetting.env.editingSettings[key] ?? false); + this.invalidateValue = () => + setValue(LiveSyncSetting.env.editingSettings[key] ?? opt?.defaultToggleValue ?? false); this.invalidateValue(); toggle.onChange(async (value) => { diff --git a/src/modules/features/SettingDialogue/PaneAdvanced.ts b/src/modules/features/SettingDialogue/PaneAdvanced.ts index 6c9c85a5..bfa0d02e 100644 --- a/src/modules/features/SettingDialogue/PaneAdvanced.ts +++ b/src/modules/features/SettingDialogue/PaneAdvanced.ts @@ -35,7 +35,9 @@ export function paneAdvanced(this: ObsidianLiveSyncSettingTab, paneEl: HTMLEleme clampMin: 10, onUpdate: this.onlyOnCouchDB, }); - new Setting(paneEl).setClass("wizardHidden").autoWireToggle("autoAcceptCompatibleTweak"); + new Setting(paneEl) + .setClass("wizardHidden") + .autoWireToggle("autoAcceptCompatibleTweak", { defaultToggleValue: true }); // new Setting(paneEl) // .setClass("wizardHidden") // .autoWireToggle("sendChunksBulk", { onUpdate: onlyOnCouchDB }) diff --git a/src/modules/features/SettingDialogue/SettingPane.ts b/src/modules/features/SettingDialogue/SettingPane.ts index e34fdfbe..b3cd4e1b 100644 --- a/src/modules/features/SettingDialogue/SettingPane.ts +++ b/src/modules/features/SettingDialogue/SettingPane.ts @@ -75,6 +75,7 @@ export type AutoWireOption = { holdValue?: boolean; isPassword?: boolean; invert?: boolean; + defaultToggleValue?: boolean; onUpdate?: OnUpdateFunc; obsolete?: boolean; }; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 6fc6d483..e0069e12 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -83,7 +83,7 @@ The underlying `test:e2e:obsidian:` scripts remain available for an im `test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. -`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, and the Setup URI controls. It captures the representative dialogues on desktop and mobile, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that the remote-selection promise settles without an error. These UI-only checks do not apply a remote configuration or contact a remote service. +`test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. `test:e2e:obsidian:settings-ui` starts with a pending compatibility review and verifies the dedicated pause summary, its detailed explanation, and the explicit resume action in a temporary real Obsidian session. It captures the desktop summary and the iPhone-sized summary and detail dialogues; the mobile checks cover viewport containment, horizontal overflow, safe-area containment, and the close control's touch target. It confirms that the acknowledged internal version advances only after the review is accepted, and checks that the Change Log contains no acknowledgement control. It then selects the Synchronisation Settings pane and verifies that the deletion panel still exposes the effective 'Keep empty folder' setting without presenting the legacy `trashInsteadDelete` control, whose value no longer changes Obsidian deletion behaviour. diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts index 2065b7de..c12586e1 100644 --- a/test/e2e-obsidian/scripts/dialog-mounts.ts +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -6,6 +6,7 @@ import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobile import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { captureObsidianDialogue, + captureObsidianElement, captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage, @@ -13,6 +14,7 @@ import { import { createTemporaryVault } from "../runner/vault.ts"; const dialogRunStateKey = "__livesyncE2EDialogMount"; +const repairRunStateKey = "__livesyncE2ETroubleshootingRepair"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_DIALOG_TIMEOUT_MS ?? 10000); type DialogueMode = "desktop" | "mobile"; @@ -20,6 +22,7 @@ type DialogueMode = "desktop" | "mobile"; type DialogueRunState = { done: boolean; error?: string; + expected?: unknown; kind: string; result?: unknown; }; @@ -27,18 +30,39 @@ type DialogueRunState = { type SetupManagerHandle = { constructor: { name: string }; onSelectServer?: (settings: unknown, remoteType: string) => Promise; + _askUseRemoteConfiguration?: (settings: unknown, preferred: unknown) => Promise; + _checkAndAskResolvingMismatchedTweaks?: (preferred: unknown) => Promise; + __addLog?: (message: string) => void; }; type LiveSyncTestPlugin = { core: { + fileHandler: { + createAllChunks(force: boolean): Promise; + }; modules: SetupManagerHandle[]; - settings: unknown; + settings: Record; }; }; +type ObsidianSettingsController = { + open(): void; + openTabById(tabId: string): void; +}; + +type ObsidianVaultFile = { + path: string; +}; + type ObsidianTestApp = { commands?: { executeCommandById(commandId: string): boolean }; plugins?: { plugins: Record }; + setting?: ObsidianSettingsController; + vault?: { + delete(file: ObsidianVaultFile, force: boolean): Promise; + getFiles(): ObsidianVaultFile[]; + read(file: ObsidianVaultFile): Promise; + }; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; @@ -78,7 +102,62 @@ async function openSetupUriDialogue(): Promise { } } -async function assertDialogueRunCompleted(): Promise { +async function openConfigurationMismatchDialogue( + kind: "connected" | "connected-rebuild-recommended" | "remote-configuration" +): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate( + ({ stateKey, kind }) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const resolver = plugin.core.modules.find( + (module) => module.constructor.name === "ModuleResolvingMismatchedTweaks" + ); + if (resolver === undefined) throw new Error("Could not find ModuleResolvingMismatchedTweaks"); + if (kind === "connected-rebuild-recommended") { + plugin.core.settings.autoAcceptCompatibleTweak = false; + } + + const preferred = + kind === "connected-rebuild-recommended" + ? { + ...plugin.core.settings, + hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32", + } + : { + ...plugin.core.settings, + enableCompression: !Boolean(plugin.core.settings.enableCompression), + }; + const state: DialogueRunState = { + kind: `configuration-mismatch-${kind}`, + done: false, + expected: preferred, + }; + (globalThis as unknown as Record)[stateKey] = state; + const operation = + kind === "remote-configuration" + ? resolver._askUseRemoteConfiguration?.(plugin.core.settings, preferred) + : resolver._checkAndAskResolvingMismatchedTweaks?.(preferred); + if (operation === undefined) { + throw new Error(`The configuration mismatch resolver does not support ${kind}.`); + } + void operation.then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ); + }, + { stateKey: dialogRunStateKey, kind } + ); + }); +} + +async function assertDialogueRunCompleted(): Promise { const state = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { await page.waitForFunction( (stateKey) => @@ -92,11 +171,12 @@ async function assertDialogueRunCompleted(): Promise { ); }); if (!state) { - throw new Error("The remote selection dialogue did not record its completion state."); + throw new Error("The mounted dialogue did not record its completion state."); } if (state.error) { - throw new Error(`The remote selection dialogue failed: ${state.error}`); + throw new Error(`The mounted dialogue failed: ${state.error}`); } + return state; } async function verifyRemoteSizeNoticeAndDialogue(): Promise<{ @@ -347,6 +427,442 @@ async function verifySetupUriDialogue(mode: DialogueMode): Promise { return screenshotPath; } +async function verifyCompatibleMismatchAutoAdjustment(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate((stateKey) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const resolver = plugin.core.modules.find( + (module) => module.constructor.name === "ModuleResolvingMismatchedTweaks" + ); + if (typeof resolver?._checkAndAskResolvingMismatchedTweaks !== "function") { + throw new Error("Could not find the configuration mismatch resolver"); + } + plugin.core.settings.autoAcceptCompatibleTweak = undefined; + const currentModified = + typeof plugin.core.settings.tweakModified === "number" ? plugin.core.settings.tweakModified : 0; + const preferred = { + ...plugin.core.settings, + hashAlg: plugin.core.settings.hashAlg === "xxhash32" ? "xxhash64" : "xxhash32", + tweakModified: currentModified + 1, + }; + const state: DialogueRunState = { + kind: "configuration-mismatch-compatible-auto-adjustment", + done: false, + expected: preferred, + }; + (globalThis as unknown as Record)[stateKey] = state; + void resolver._checkAndAskResolvingMismatchedTweaks(preferred).then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ); + }, dialogRunStateKey); + }); + + const state = await assertDialogueRunCompleted(); + if (!Array.isArray(state.result) || state.result.length !== 2) { + throw new Error("The compatible mismatch did not return its settings and rebuild decision."); + } + const [appliedSettings, shouldRebuild] = state.result; + const expectedSettings = state.expected; + if ( + typeof appliedSettings !== "object" || + appliedSettings === null || + typeof expectedSettings !== "object" || + expectedSettings === null || + !("hashAlg" in appliedSettings) || + !("hashAlg" in expectedSettings) || + appliedSettings.hashAlg !== expectedSettings.hashAlg || + shouldRebuild !== false + ) { + throw new Error("The compatible mismatch was not adjusted to the newer setting without a rebuild."); + } + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const autoAcceptEnabled = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + return plugin?.core.settings.autoAcceptCompatibleTweak; + }); + if (autoAcceptEnabled !== true) { + throw new Error("Compatible mismatch auto-adjustment was not persisted as the default."); + } + for (const title of ["Auto-Accept Available", "Configuration Mismatch Detected"]) { + const dialogue = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); + if ((await dialogue.count()) !== 0) { + throw new Error(`Compatible mismatch auto-adjustment unexpectedly opened '${title}'.`); + } + } + }); +} + +async function verifyCompatibleAlignmentSettingDefault(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const persistedValue = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + return plugin.core.settings.autoAcceptCompatibleTweak; + }); + if (persistedValue !== undefined) { + throw new Error( + `The default-display fixture expected an undefined preference, received ${persistedValue}.` + ); + } + + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Advanced"]').click({ timeout: uiTimeoutMs }); + const settingItem = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Auto-accept compatible tweak mismatches", { exact: true }), + }); + await settingItem.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const toggle = settingItem.locator(".checkbox-container"); + if (!(await toggle.evaluate((element) => element.classList.contains("is-enabled")))) { + throw new Error("The automatic compatible-setting policy was displayed as disabled while still undefined."); + } + }); +} + +async function verifyConfigurationMismatchDialogues(): Promise<{ general: string; fetch: string }> { + await verifyCompatibleMismatchAutoAdjustment(); + await openConfigurationMismatchDialogue("remote-configuration"); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Use Remote Configuration" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Use configured settings", exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Dismiss", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + + await openConfigurationMismatchDialogue("connected"); + const generalScreenshotPath = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-configuration-mismatch-dialogue.png", + async (page) => { + const container = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + const modal = container.locator(".modal").last(); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const action of ["Apply settings to this device", "Update remote database settings"]) { + await modal + .getByRole("button", { name: action, exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + await modal.getByRole("button", { name: /Dismiss$/u }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + for (const retiredAction of ["Use configured", "Update with mine"]) { + if ((await modal.getByRole("button", { name: retiredAction, exact: true }).count()) !== 0) { + throw new Error(`The mismatch dialogue still exposes the retired action '${retiredAction}'.`); + } + } + const actions = modal.locator(".setting-item-control").last(); + const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); + if (flexDirection !== "column") { + throw new Error(`Expected vertically stacked mismatch actions, received ${flexDirection}.`); + } + return modal; + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + await modal.getByRole("button", { name: /Dismiss$/u }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + + await openConfigurationMismatchDialogue("connected-rebuild-recommended"); + const fetchScreenshotPath = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-configuration-mismatch-fetch-dialogue.png", + async (page) => { + const container = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + const modal = container.locator(".modal").last(); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + return modal; + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Configuration Mismatch Detected" }), + }); + await modal + .getByRole("button", { name: "Apply settings to this device, and fetch again", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + const fetchResult = await assertDialogueRunCompleted(); + if (!Array.isArray(fetchResult.result) || fetchResult.result.length !== 2) { + throw new Error("The configuration-mismatch Fetch action did not return its settings and Fetch decision."); + } + const [appliedSettings, shouldFetch] = fetchResult.result; + const expectedSettings = fetchResult.expected; + if ( + typeof appliedSettings !== "object" || + appliedSettings === null || + typeof expectedSettings !== "object" || + expectedSettings === null || + !("hashAlg" in appliedSettings) || + !("hashAlg" in expectedSettings) || + appliedSettings.hashAlg !== expectedSettings.hashAlg || + shouldFetch !== true + ) { + throw new Error("The configuration-mismatch Fetch action did not apply the remote setting before Fetch."); + } + + return { general: generalScreenshotPath, fetch: fetchScreenshotPath }; +} + +async function executeRegisteredCommand(commandId: string): Promise { + const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + return await page.evaluate( + (id) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(id) === true, + commandId + ); + }); + if (!opened) { + throw new Error(`The command was not registered or could not be executed: ${commandId}`); + } +} + +async function verifyLogAndReportSurfaces(): Promise<{ log: string; report: string }> { + await executeRegisteredCommand("obsidian-livesync:view-log"); + const logScreenshot = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-show-log.png", + async (page) => { + const logPane = page.locator(".logpane"); + await logPane.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const label of ["Wrap", "Auto scroll", "Pause"]) { + await logPane.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + await logPane.getByRole("button", { name: "Close", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + return logPane; + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const logPane = page.locator(".logpane"); + await logPane.getByRole("button", { name: "Close", exact: true }).click({ timeout: uiTimeoutMs }); + await logPane.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + await executeRegisteredCommand("obsidian-livesync:dump-debug-info"); + const reportScreenshot = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-full-report.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + hasText: "Your Debug info is ready to be copied", + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const report = await modal.locator("textarea").inputValue({ timeout: uiTimeoutMs }); + if (!report.includes("# ---- Debug Info Dump ----")) { + throw new Error("The full-report dialogue did not contain the generated debug report."); + } + await modal.getByRole("button", { name: "OK", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + return modal.locator(".modal").last(); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + hasText: "Your Debug info is ready to be copied", + }); + await modal.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { log: logScreenshot, report: reportScreenshot }; +} + +async function verifyHatchSurfacesAndSafeActions(): Promise { + const screenshotPath = await captureObsidianElement( + obsidianRemoteDebuggingPort(), + "troubleshooting-hatch.png", + async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); + for (const label of [ + "Write logs into the file", + "Recreate missing chunks for all files", + "Verify and repair all files", + ]) { + await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await liveSyncSettings + .locator(".setting-item-name", { hasText: "Recreate missing chunks for all files" }) + .scrollIntoViewIfNeeded(); + return liveSyncSettings; + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const liveSyncSettings = page.locator(".sls-setting"); + const logSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Write logs into the file", { exact: true }), + }); + await logSetting.locator(".checkbox-container").click({ timeout: uiTimeoutMs }); + await page.waitForFunction( + () => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + return plugin?.core.settings.writeLogToTheFile === true; + }, + undefined, + { timeout: uiTimeoutMs } + ); + + const persistentLogMarker = "E2E persistent troubleshooting log"; + await page.evaluate((marker) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const module = plugin.core.modules.find((candidate) => candidate.constructor.name === "ModuleLog"); + if (typeof module?.__addLog !== "function") throw new Error("Could not find ModuleLog"); + module.__addLog(marker); + }, persistentLogMarker); + await page.waitForFunction( + async (marker) => { + const vault = (globalThis as ObsidianTestGlobal).app?.vault; + if (vault === undefined) return false; + const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_")); + if (logFile === undefined) return false; + return (await vault.read(logFile)).includes(marker); + }, + persistentLogMarker, + { timeout: uiTimeoutMs } + ); + + // Saving a toggle refreshes the settings pane. Resolve the visible control again so the + // second action does not target the detached pre-save element. + const refreshedLogToggle = page + .locator(".sls-setting:visible .setting-item:visible") + .filter({ + has: page.getByText("Write logs into the file", { exact: true }), + }) + .locator(".checkbox-container:visible") + .last(); + await refreshedLogToggle.click({ timeout: uiTimeoutMs }); + await page.waitForFunction( + () => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + return plugin?.core.settings.writeLogToTheFile === false; + }, + undefined, + { timeout: uiTimeoutMs } + ); + await page.evaluate(async () => { + const vault = (globalThis as ObsidianTestGlobal).app?.vault; + if (vault === undefined) throw new Error("Obsidian Vault is unavailable"); + const logFile = vault.getFiles().find((file) => file.path.startsWith("livesync_log_")); + if (logFile === undefined) throw new Error("The persistent troubleshooting log was not created"); + await vault.delete(logFile, true); + }); + await page.waitForFunction( + () => + !(globalThis as ObsidianTestGlobal).app?.vault + ?.getFiles() + .some((file) => file.path.startsWith("livesync_log_")), + undefined, + { timeout: uiTimeoutMs } + ); + + await page.evaluate((stateKey) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is not loaded"); + const original = plugin.core.fileHandler.createAllChunks.bind(plugin.core.fileHandler); + const state: DialogueRunState = { kind: "recreate-missing-chunks", done: false }; + (globalThis as unknown as Record)[stateKey] = state; + plugin.core.fileHandler.createAllChunks = async (force) => { + try { + state.result = await original(force); + } catch (error) { + state.error = error instanceof Error ? error.message : String(error); + } finally { + state.done = true; + plugin.core.fileHandler.createAllChunks = original; + } + }; + }, repairRunStateKey); + await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[stateKey]?.done === true, + repairRunStateKey, + { timeout: uiTimeoutMs } + ); + const repairState = await page.evaluate( + (stateKey) => (globalThis as unknown as Record)[stateKey], + repairRunStateKey + ); + if (repairState?.error) { + throw new Error(`Recreate missing chunks failed: ${repairState.error}`); + } + + await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await page + .locator(".notice") + .filter({ hasText: /^done$/u }) + .waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + + return screenshotPath; +} + async function verifyMobileStartupReviews(): Promise<{ compatibilityReview: string; remoteSizeReview: string }> { const compatibilityReviewScreenshot = await captureObsidianDialogue( obsidianRemoteDebuggingPort(), @@ -430,13 +946,14 @@ async function main(): Promise { dbName: "dialog-mounts-ui-only", }, { - notifyThresholdOfRemoteStorageSize: -1, - syncOnStart: false, - syncOnSave: false, - syncOnEditorSave: false, - syncOnFileOpen: false, - syncAfterMerge: false, - periodicReplication: false, + notifyThresholdOfRemoteStorageSize: -1, + syncOnStart: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + periodicReplication: false, + useAdvancedMode: true, } ), }); @@ -480,6 +997,20 @@ async function main(): Promise { ); const setupUriScreenshot = await verifySetupUriDialogue("desktop"); console.log(`Setup URI dialogue mounted and closed successfully. Screenshot: ${setupUriScreenshot}`); + await verifyCompatibleAlignmentSettingDefault(); + console.log("The undefined compatible-setting preference is displayed with its effective enabled default."); + const mismatchScreenshots = await verifyConfigurationMismatchDialogues(); + console.log( + `A mismatch limited to compatible chunk settings was adjusted without a dialogue, current manual mismatch actions mounted successfully, and the Fetch action applied the remote setting before scheduling Fetch. Screenshots: ${mismatchScreenshots.general}, ${mismatchScreenshots.fetch}` + ); + const troubleshootingScreenshots = await verifyLogAndReportSurfaces(); + console.log( + `Show log and the generated full-report dialogue were reached through their registered commands. Screenshots: ${troubleshootingScreenshots.log}, ${troubleshootingScreenshots.report}` + ); + const hatchScreenshot = await verifyHatchSurfacesAndSafeActions(); + console.log( + `Hatch repair controls were reachable, safe empty-fixture runs completed, and persistent logging was enabled, verified, disabled, and removed. Screenshot: ${hatchScreenshot}` + ); await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); try { diff --git a/updates.md b/updates.md index af3b5ee7..4853a544 100644 --- a/updates.md +++ b/updates.md @@ -19,10 +19,13 @@ Earlier releases remain available in the [0.25 release history](https://github.c - P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. - First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. - Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. +- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. ### Fixed - Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred. +- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. +- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. ### Testing From 6afeb0b4099d2a022ff4ad54e2f4009e4b479027 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 09:54:44 +0000 Subject: [PATCH 142/170] Record beta.2 history and remove release-note links --- updates.md | 30 +++++++++++++++++++++--------- versions.json | 3 ++- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/updates.md b/updates.md index 4853a544..4b3bd275 100644 --- a/updates.md +++ b/updates.md @@ -2,19 +2,18 @@ Well then, everyone: it has been roughly a year since I declared the 0.25 beta. During that time, we have concentrated mainly on fixing defects and completing the features that the project needed. -Version 1.0 has been in mind for some time. We have now brought together the work intended to make it possible: stronger CI, more detailed tests, an E2E runner suited to synchronisation, and testing tools for physical devices. These now form a coherent [Kit](https://github.com/vrtmrz/fancy-kit) rather than a collection of isolated pieces. With those foundations in place, it seems that the time has finally come to reshape the structure of this repository. +Version 1.0 has been in mind for some time. We have now brought together the work intended to make it possible: stronger CI, more detailed tests, an E2E runner suited to synchronisation, and testing tools for physical devices. These now form a coherent Kit rather than a collection of isolated pieces. With those foundations in place, it seems that the time has finally come to reshape the structure of this repository. -None of this would have been possible without your issue reports, pull requests, sponsorship, and the support provided through [OpenAI's Codex for Open Source](https://openai.com/form/codex-for-oss/). I would like to express my gratitude once again. As with every pull request contributed to the project, code produced with Codex and similar tools is reviewed and audited by me, vrtmrz. Anyone interested in how I manage that process can refer to [my dotfiles](https://github.com/vrtmrz/dotfiles). +None of this would have been possible without your issue reports, pull requests, sponsorship, and the support provided through OpenAI's Codex for Open Source. I would like to express my gratitude once again. As with every pull request contributed to the project, code produced with Codex and similar tools is reviewed and audited by me, vrtmrz. Anyone interested in how I manage that process can refer to my dotfiles. This will call for your help once again. I would be very grateful for your co-operation as we build a sounder foundation for the project and its future development. -Earlier releases remain available in the [0.25 release history](https://github.com/vrtmrz/obsidian-livesync/blob/1.0.0-beta.0/docs/releases/0.25.md) and the [legacy release history](https://github.com/vrtmrz/obsidian-livesync/blob/1.0.0-beta.0/docs/releases/legacy.md). +Earlier releases remain available in the 0.25 release history and the legacy release history. ## Unreleased ### Improved -- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved. - Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. - P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. - First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. @@ -23,15 +22,29 @@ Earlier releases remain available in the [0.25 release history](https://github.c ### Fixed -- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred. - Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. - Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. ### Testing -- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages. - Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. +## 1.0.0-beta.2 + +23rd July, 2026 + +### Improved + +- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved. + +### Fixed + +- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred. + +### Testing + +- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages. + ## 1.0.0-beta.1 22nd July, 2026 @@ -63,10 +76,10 @@ Earlier releases remain available in the [0.25 release history](https://github.c - An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically. - The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins. - Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow. -- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the [Data Compression specification](https://github.com/vrtmrz/obsidian-livesync/blob/1.0.0-beta.0/docs/specs_data_compression.md). +- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification. - Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive. - P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room. -- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555). +- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555. - Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer. - Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions. @@ -83,7 +96,6 @@ Earlier releases remain available in the [0.25 release history](https://github.c ### Miscellaneous - Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository. -- Detailed architecture, compatibility, and verification notes are available in [pull request #1033](https://github.com/vrtmrz/obsidian-livesync/pull/1033) and its linked specifications and decisions. ### Testing diff --git a/versions.json b/versions.json index 3c168a92..a0f3dfb2 100644 --- a/versions.json +++ b/versions.json @@ -7,5 +7,6 @@ "0.25.82": "1.7.2", "0.25.83": "1.7.2", "1.0.0-beta.0": "1.7.2", - "1.0.0-beta.1": "1.7.2" + "1.0.0-beta.1": "1.7.2", + "1.0.0-beta.2": "1.7.2" } From 0e5475b7e3421daebc4357d3733b085cea36c264 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 16:13:49 +0000 Subject: [PATCH 143/170] Limit commands to applicable contexts --- .../adr/2026_07_multiple_remote_onboarding.md | 2 +- ...elease_notes_and_database_compatibility.md | 2 +- docs/p2p.md | 2 +- docs/settings.md | 2 +- docs/setup_p2p.md | 2 +- .../messages/LiveSyncProvisionalMessages.ts | 2 + .../CmdConfigSync.command.unit.spec.ts | 96 ++++++++++++ src/features/ConfigSync/CmdConfigSync.ts | 10 +- .../HiddenFileSync/CmdHiddenFileSync.ts | 35 +++-- .../CmdHiddenFileSync.unit.spec.ts | 65 +++++++- .../CmdLocalDatabaseMainte.ts | 22 ++- .../CmdLocalDatabaseMainte.unit.spec.ts | 71 ++++++++- src/modules/essential/ModuleBasicMenu.ts | 21 ++- .../essential/ModuleBasicMenu.unit.spec.ts | 139 ++++++++++++++++++ .../features/SettingDialogue/PaneSetup.ts | 5 +- src/serviceFeatures/setupObsidian/qrCode.ts | 6 +- .../setupObsidian/qrCode.unit.spec.ts | 40 +++++ .../setupObsidian/setupManagerHandlers.ts | 5 - .../setupManagerHandlers.unit.spec.ts | 5 +- src/serviceFeatures/setupObsidian/setupUri.ts | 20 ++- .../setupObsidian/setupUri.unit.spec.ts | 53 +++++++ src/serviceFeatures/useP2PReplicatorUI.ts | 49 ++++-- .../useP2PReplicatorUI.unit.spec.ts | 91 +++++++++++- styles.css | 4 + test/e2e-obsidian/README.md | 2 +- .../scripts/onboarding-invitation.ts | 50 +++++-- updates.md | 1 + 27 files changed, 727 insertions(+), 75 deletions(-) create mode 100644 src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts create mode 100644 src/modules/essential/ModuleBasicMenu.unit.spec.ts diff --git a/docs/adr/2026_07_multiple_remote_onboarding.md b/docs/adr/2026_07_multiple_remote_onboarding.md index 3ad81596..db468c0d 100644 --- a/docs/adr/2026_07_multiple_remote_onboarding.md +++ b/docs/adr/2026_07_multiple_remote_onboarding.md @@ -77,7 +77,7 @@ Commonlib unit tests cover preserving existing profiles, opaque-ID insertion, ge Self-hosted LiveSync unit tests cover preserving modern Setup URI profiles and their active selection, retaining legacy Setup URI and QR migration, adding CouchDB and Object Storage profiles beside an existing profile, independent P2P selection, fresh P2P selection as both main and P2P remote, and cancellation without mutation. -The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and command reopening. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate. +The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, safe-area and touch-target checks, cancellation, and reopening from the Setup pane. It does not contact a remote or submit credentials. Remote connection correctness remains owned by the CouchDB, Object Storage, P2P, and two-Vault suites. The end-to-end Setup URI and provisioning acceptance workflow remains a separate release gate. ## Consequences diff --git a/docs/adr/2026_07_release_notes_and_database_compatibility.md b/docs/adr/2026_07_release_notes_and_database_compatibility.md index 15b21c1e..a444e40d 100644 --- a/docs/adr/2026_07_release_notes_and_database_compatibility.md +++ b/docs/adr/2026_07_release_notes_and_database_compatibility.md @@ -60,7 +60,7 @@ Keep configured-state inference separate from new-Vault initialisation. If an ex ### Onboarding activation and initialisation -- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice and the permanent command instead of opening a competing dialogue automatically. +- Keep an unconfigured Vault outside database initialisation, offline scanning, and configured-only checks. Offer setup through the long-lived onboarding Notice, and allow the wizard to be reopened from the Setup pane instead of opening a competing dialogue automatically. - For new-device onboarding, reserve Rebuild before enabling and saving the accepted settings. - For an unconfigured existing device, reserve Fetch before enabling and saving imported or manually confirmed settings. - Suspend the current runtime after the flag has been written, apply the accepted settings through the scheduler's preparation callback, and request restart only after that callback succeeds. diff --git a/docs/p2p.md b/docs/p2p.md index 4e7902d9..ff47468a 100644 --- a/docs/p2p.md +++ b/docs/p2p.md @@ -48,7 +48,7 @@ A TURN provider cannot read LiveSync's encrypted Vault contents, but it can obse The **P2P Status** pane is the current Obsidian interface for P2P connections. -- The command **Self-hosted LiveSync: P2P Sync : Open P2P Status** remains available from the command palette. +- After a P2P configuration exists, the command **Self-hosted LiveSync: P2P Sync : Open P2P Status** is available from the command palette. - The P2P ribbon icon appears only after a P2P configuration exists. - LiveSync does not open the pane merely because Obsidian has started. If the pane was already part of the saved Obsidian workspace, Obsidian may restore it. - Workspaces containing the retired P2P pane are migrated to the current status pane. The retired command is no longer exposed. diff --git a/docs/settings.md b/docs/settings.md index 16af6883..b371d330 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -40,7 +40,7 @@ Internal database or settings compatibility reviews use a separate safety dialog This pane is used for setting up Self-hosted LiveSync. There are several options to set up Self-hosted LiveSync. -An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action, and **Open onboarding wizard** remains available from the command palette after that Notice closes. +An unconfigured installation does not open the onboarding dialogue automatically or scan the Vault into the local database. A long-lived Notice offers the onboarding action. If the Notice is dismissed, open **Self-hosted LiveSync settings** → **Setup** → **Rerun Onboarding Wizard**. Choose the new-device path when this device owns the files which should initialise synchronisation. Choose the existing-device path when it should receive an established remote state. The wizard reserves Rebuild or Fetch respectively before enabling the settings and requesting a restart, so the selected initialisation runs before the ordinary start-up scan. diff --git a/docs/setup_p2p.md b/docs/setup_p2p.md index 0bf049ec..fb13a9c1 100644 --- a/docs/setup_p2p.md +++ b/docs/setup_p2p.md @@ -34,7 +34,7 @@ Before starting: ![P2P local database confirmation on the first device](../images/p2p-setup/guide-p2p-setup-first-rebuild-confirmation.png) 7. Keep optional features disabled until ordinary note synchronisation works. -8. Open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. After a P2P profile exists, the P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected. +8. After saving the P2P profile, open `Self-hosted LiveSync: P2P Sync : Open P2P Status` from the command palette. The P2P ribbon icon provides the same destination. Select `Open connection` if signalling is disconnected. ![First P2P device connected to the signalling relay](../images/p2p-setup/guide-p2p-setup-first-device-connected.png) diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 5d031336..0c9057ba 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -64,6 +64,8 @@ export const liveSyncProvisionalEnglishMessages = { "This file has unresolved conflicts.": "This file has unresolved conflicts.", "This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.": "This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", + "Sync now": "Sync now", + "Apply pending changes now": "Apply pending changes now", } as const; export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages; diff --git a/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts b/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts new file mode 100644 index 00000000..e09efe85 --- /dev/null +++ b/src/features/ConfigSync/CmdConfigSync.command.unit.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/deps.ts", () => ({ + addIcon: vi.fn(), + diff_match_patch: class DiffMatchPatch {}, + normalizePath: vi.fn((path: string) => path), + Notice: class Notice {}, + parseYaml: vi.fn(), + Platform: {}, +})); +vi.mock("./PluginDialogModal.ts", () => ({ + PluginDialogModal: class PluginDialogModal {}, +})); +vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ + JsonResolveModal: class JsonResolveModal {}, +})); +vi.mock("@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts", () => ({ + ConflictResolveModal: class ConflictResolveModal {}, +})); +vi.mock("@/features/LiveSyncCommands.ts", () => ({ + LiveSyncCommands: class LiveSyncCommands { + core!: { services: unknown }; + get services() { + return this.core.services; + } + }, +})); +vi.mock("@/common/types.ts", () => ({ + ICXHeader: "ix:", + PERIODIC_PLUGIN_SWEEP: 60, +})); +vi.mock("@/common/utils.ts", () => ({ + EVEN: Symbol("even"), + disposeMemoObject: vi.fn(), + isCustomisationSyncMetadata: vi.fn(), + isPluginMetadata: vi.fn(), + memoIfNotExist: vi.fn(), + memoObject: vi.fn(), + retrieveMemoObject: vi.fn(), + scheduleTask: vi.fn(), +})); +vi.mock("@/common/PeriodicProcessor.ts", () => ({ + PeriodicProcessor: class PeriodicProcessor {}, +})); +vi.mock("@/common/events.ts", () => ({ + EVENT_REQUEST_OPEN_PLUGIN_SYNC_DIALOG: "open-plugin-sync", + eventHub: { + onEvent: vi.fn(), + }, +})); +vi.mock("@/common/translation", () => ({ + $msg: vi.fn((message: string) => message), +})); +vi.mock("@/common/obsidianCommunityPlugins.ts", () => ({ + getObsidianCommunityPluginManager: vi.fn(), +})); + +import { ConfigSync } from "./CmdConfigSync"; + +describe("ConfigSync commands", () => { + it("shows the Customisation Sync command only whilst the feature is enabled", () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + usePluginSync: false, + }; + const showPluginSyncModal = vi.fn(); + const configSync = Object.create(ConfigSync.prototype) as ConfigSync; + Object.assign(configSync, { + core: { + settings, + services: { + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + }, + }, + addRibbonIcon: vi.fn(() => ({ + addClass: vi.fn(), + })), + showPluginSyncModal, + }); + + configSync.onload(); + + const command = commands.find(({ id }) => id === "livesync-plugin-dialog-ex"); + expect(command?.checkCallback?.(true)).toBe(false); + + settings.usePluginSync = true; + expect(command?.checkCallback?.(true)).toBe(true); + expect(command?.checkCallback?.(false)).toBe(true); + expect(showPluginSyncModal).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/ConfigSync/CmdConfigSync.ts b/src/features/ConfigSync/CmdConfigSync.ts index f5ef2c80..b4a40841 100644 --- a/src/features/ConfigSync/CmdConfigSync.ts +++ b/src/features/ConfigSync/CmdConfigSync.ts @@ -454,8 +454,14 @@ export class ConfigSync extends LiveSyncCommands { this.services.API.addCommand({ id: "livesync-plugin-dialog-ex", name: "Show customization sync dialog", - callback: () => { - this.showPluginSyncModal(); + checkCallback: (checking) => { + if (!this.isThisModuleEnabled()) { + return false; + } + if (!checking) { + this.showPluginSyncModal(); + } + return true; }, }); this.addRibbonIcon("custom-sync", $msg("cmdConfigSync.showCustomizationSync"), () => { diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.ts index 6d1147aa..92826376 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.ts @@ -54,10 +54,7 @@ import { EVENT_SETTING_SAVED, eventHub } from "@/common/events.ts"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import type { LiveSyncCore } from "@/main.ts"; import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; -import { - configureHiddenFileSyncMode, - type ConfigureHiddenFileSyncResult, -} from "./configureHiddenFileSyncMode.ts"; +import { configureHiddenFileSyncMode, type ConfigureHiddenFileSyncResult } from "./configureHiddenFileSyncMode.ts"; import type { OptionalSyncFeatureMode } from "@/features/optionalSyncFeatures.ts"; import { getObsidianCommunityPluginManager } from "@/common/obsidianCommunityPlugins.ts"; type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce"; @@ -109,37 +106,45 @@ export class HiddenFileSync extends LiveSyncCommands { this.services.API.addCommand({ id: "livesync-sync-internal", name: "(re)initialise hidden files between storage and database", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.initialiseInternalFileSync("safe", true); } + return true; }, }); this.services.API.addCommand({ id: "livesync-scaninternal-storage", name: "Scan hidden file changes on the storage", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.scanAllStorageChanges(true); } + return true; }, }); this.services.API.addCommand({ id: "livesync-scaninternal-database", name: "Scan hidden file changes on the local database", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.scanAllDatabaseChanges(true); } + return true; }, }); this.services.API.addCommand({ id: "livesync-internal-scan-offline-changes", name: "Scan and apply all offline hidden-file changes", - callback: () => { - if (this.isReady()) { + checkCallback: (checking) => { + if (!this.isManualCommandAvailable()) return false; + if (!checking) { void this.applyOfflineChanges(true); } + return true; }, }); eventHub.onEvent(EVENT_SETTING_SAVED, () => { @@ -191,12 +196,16 @@ export class HiddenFileSync extends LiveSyncCommands { } isReady() { - if (!this._isMainReady) return false; + if (!this._isMainReady()) return false; if (this._isMainSuspended()) return false; if (!this.isThisModuleEnabled()) return false; return true; } + private isManualCommandAvailable() { + return this.settings.useAdvancedMode && this.isReady() && this._isDatabaseReady(); + } + async performStartupScan(showNotice: boolean) { await this.applyOfflineChanges(showNotice); } diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts index 66d00a29..7e7685d2 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts @@ -8,13 +8,16 @@ vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ vi.mock("@/features/LiveSyncCommands.ts", () => ({ LiveSyncCommands: class LiveSyncCommands { plugin!: { app: unknown }; - core!: { services: unknown }; + core!: { services: unknown; settings: unknown }; get app() { return this.plugin.app; } get services() { return this.core.services; } + get settings() { + return this.core.settings; + } }, })); vi.mock("./configureHiddenFileSyncMode.ts", () => ({ @@ -25,6 +28,66 @@ import { HiddenFileSync } from "./CmdHiddenFileSync.ts"; import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; describe("HiddenFileSync configuration-change notices", () => { + it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + syncInternalFiles: false, + useAdvancedMode: false, + }; + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + core: { + settings, + services: { + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + }, + }, + _isMainReady: vi.fn(() => true), + _isMainSuspended: vi.fn(() => false), + _isDatabaseReady: vi.fn(() => true), + }); + + hiddenFileSync.onload(); + + const commandIds = [ + "livesync-sync-internal", + "livesync-scaninternal-storage", + "livesync-scaninternal-database", + "livesync-internal-scan-offline-changes", + ]; + for (const commandId of commandIds) { + const command = commands.find(({ id }) => id === commandId); + expect(command?.checkCallback?.(true)).toBe(false); + } + + settings.syncInternalFiles = true; + settings.useAdvancedMode = true; + for (const commandId of commandIds) { + const command = commands.find(({ id }) => id === commandId); + expect(command?.checkCallback?.(true)).toBe(true); + } + }); + + it("does not report Hidden File Sync as ready before the main runtime is ready", () => { + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + core: { + settings: { + syncInternalFiles: true, + }, + }, + _isMainReady: vi.fn(() => false), + _isMainSuspended: vi.fn(() => false), + }); + + expect(hiddenFileSync.isReady()).toBe(false); + }); + it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => { const noticeGroups = { setItem: vi.fn(), diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts index 05a32e8c..37a9c7e5 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts @@ -4,6 +4,8 @@ import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, + REMOTE_COUCHDB, + REMOTE_P2P, type DocumentID, type EntryDoc, type EntryLeaf, @@ -38,16 +40,28 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands { id: "analyse-database", name: "Analyse Database Usage (advanced)", icon: "database-search", - callback: async () => { - await this.analyseDatabase(); + checkCallback: (checking) => { + if (!this.settings.useAdvancedMode || !this._isDatabaseReady()) return false; + if (!checking) { + void this.analyseDatabase(); + } + return true; }, }); this.plugin.addCommand({ id: "gc-v3", name: "Garbage Collection V3 (advanced, beta)", icon: "trash-2", - callback: async () => { - await this.gcv3(); + checkCallback: (checking) => { + const isApplicableRemote = + this.settings.remoteType === REMOTE_COUCHDB || this.settings.remoteType === REMOTE_P2P; + if (!this.settings.useEdgeCaseMode || !this._isDatabaseReady() || !isApplicableRemote) { + return false; + } + if (!checking) { + void this.gcv3(); + } + return true; }, }); eventHub.onEvent(EVENT_ANALYSE_DB_USAGE, () => this.analyseDatabase()); diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts index 6bcef72a..19be24ee 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts @@ -1,5 +1,31 @@ import { describe, expect, it, vi } from "vitest"; -import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; + +vi.mock("octagonal-wheels/number", () => ({ + sizeToHumanReadable: vi.fn((value: number) => `${value} B`), +})); +vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({ + serialized: vi.fn((_key: string, task: () => unknown) => task()), +})); +vi.mock("octagonal-wheels/collection", () => ({ + arrayToChunkedArray: vi.fn((values: unknown[]) => [values]), +})); +vi.mock("@/features/LiveSyncCommands", () => ({ + LiveSyncCommands: class LiveSyncCommands { + core!: { settings: unknown }; + get settings() { + return this.core.settings; + } + }, +})); +vi.mock("@/common/events", () => ({ + EVENT_ANALYSE_DB_USAGE: "analyse", + EVENT_REQUEST_PERFORM_GC_V3: "gc", + eventHub: { + onEvent: vi.fn(), + }, +})); +import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte"; import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites"; function createPrerequisites(settingsOverride: Partial = {}) { @@ -22,6 +48,49 @@ function createPrerequisites(settingsOverride: Partial } describe("LocalDatabaseMaintenance prerequisites", () => { + it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings: { + useAdvancedMode: boolean; + useEdgeCaseMode: boolean; + remoteType: string; + } = { + useAdvancedMode: false, + useEdgeCaseMode: false, + remoteType: REMOTE_COUCHDB, + }; + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + Object.assign(maintenance, { + plugin: { + addCommand: vi.fn((command) => commands.push(command)), + }, + core: { + settings, + }, + _isDatabaseReady: vi.fn(() => true), + }); + + maintenance.onload(); + + const analyse = commands.find(({ id }) => id === "analyse-database"); + const garbageCollect = commands.find(({ id }) => id === "gc-v3"); + expect(analyse?.checkCallback?.(true)).toBe(false); + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + + settings.useAdvancedMode = true; + expect(analyse?.checkCallback?.(true)).toBe(true); + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + + settings.useEdgeCaseMode = true; + expect(garbageCollect?.checkCallback?.(true)).toBe(true); + + settings.remoteType = REMOTE_MINIO; + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + }); + it("asks to disable on-demand chunk fetching before maintenance actions", async () => { const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites(); diff --git a/src/modules/essential/ModuleBasicMenu.ts b/src/modules/essential/ModuleBasicMenu.ts index 2c67ab34..5eb6d235 100644 --- a/src/modules/essential/ModuleBasicMenu.ts +++ b/src/modules/essential/ModuleBasicMenu.ts @@ -2,13 +2,14 @@ import type { LiveSyncCore } from "@/main"; import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { fireAndForget } from "octagonal-wheels/promises"; import { AbstractModule } from "@/modules/AbstractModule"; +import { $msg } from "@/common/translation"; // Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes. // However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version. export class ModuleBasicMenu extends AbstractModule { _everyOnloadStart(): Promise { this.addCommand({ id: "livesync-replicate", - name: "Replicate now", + name: $msg("Sync now"), callback: async () => { await this.services.replication.replicate(); }, @@ -56,14 +57,18 @@ export class ModuleBasicMenu extends AbstractModule { this.addCommand({ id: "livesync-scan-files", name: "Scan storage and database again", - callback: async () => { - await this.services.vault.scanVault(true); + checkCallback: (checking) => { + if (!this.settings.useAdvancedMode) return false; + if (!checking) { + fireAndForget(() => this.services.vault.scanVault(true)); + } + return true; }, }); this.addCommand({ id: "livesync-runbatch", - name: "Run pended batch processes", + name: $msg("Apply pending changes now"), callback: async () => { await this.services.fileProcessing.commitPendingFileEvents(); }, @@ -73,8 +78,12 @@ export class ModuleBasicMenu extends AbstractModule { this.addCommand({ id: "livesync-abortsync", name: "Abort synchronization immediately", - callback: () => { - this.core.replicator.terminateSync(); + checkCallback: (checking) => { + if (!this.settings.useAdvancedMode) return false; + if (!checking) { + this.core.replicator.terminateSync(); + } + return true; }, }); return Promise.resolve(true); diff --git a/src/modules/essential/ModuleBasicMenu.unit.spec.ts b/src/modules/essential/ModuleBasicMenu.unit.spec.ts new file mode 100644 index 00000000..10a175e7 --- /dev/null +++ b/src/modules/essential/ModuleBasicMenu.unit.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Command } from "@/deps"; +import { ModuleBasicMenu } from "./ModuleBasicMenu"; + +type RegisteredCommand = Command & { + checkCallback?: (checking: boolean) => boolean | void; +}; + +function createFixture() { + const commands: RegisteredCommand[] = []; + const settings = { + liveSync: false, + useAdvancedMode: false, + enableDebugTools: false, + }; + const services = { + API: { + addLog: vi.fn(), + addCommand: vi.fn((command: RegisteredCommand) => { + commands.push(command); + return command; + }), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + replication: { + replicate: vi.fn(async () => undefined), + }, + vault: { + getActiveFilePath: vi.fn((): string | null => "note.md"), + scanVault: vi.fn(async () => undefined), + }, + control: { + applySettings: vi.fn(async () => undefined), + }, + setting: { + saveSettingData: vi.fn(async () => undefined), + }, + appLifecycle: { + isSuspended: vi.fn(() => false), + setSuspended: vi.fn(), + }, + fileProcessing: { + commitPendingFileEvents: vi.fn(async () => true), + }, + UI: { + promptCopyToClipboard: vi.fn(async (_title: string, _value: string) => true), + }, + path: { + path2id: vi.fn(async () => "f:note"), + }, + }; + const core = { + settings, + _services: services, + services, + localDatabase: { + getDBEntry: vi.fn(async () => false), + localDatabase: { + get: vi.fn(async () => ({ + _id: "f:note", + _rev: "2-current", + _conflicts: [], + path: "note.md", + ctime: 100, + mtime: 200, + size: 12, + type: "plain", + children: ["h:private-chunk-id"], + eden: {}, + })), + }, + getDBEntryMeta: vi.fn(async () => ({ + _id: "f:note", + _rev: "2-current", + _conflicts: [], + path: "note.md", + ctime: 100, + mtime: 200, + size: 12, + type: "plain", + datatype: "plain", + data: "", + children: ["h:private-chunk-id"], + eden: {}, + })), + allDocsRaw: vi.fn(async () => ({ + rows: [{ id: "h:private-chunk-id", key: "h:private-chunk-id", value: { rev: "1-chunk" } }], + })), + }, + storageAccess: { + isExistsIncludeHidden: vi.fn(async () => true), + statHidden: vi.fn(async () => ({ ctime: 100, mtime: 200, size: 12, type: "file" })), + }, + replicator: { + terminateSync: vi.fn(), + }, + }; + const module = new ModuleBasicMenu(core as never); + + return { + commands, + core, + module, + services, + settings, + getCommand(id: string) { + const command = commands.find((candidate) => candidate.id === id); + expect(command, `command ${id}`).toBeDefined(); + return command!; + }, + }; +} + +describe("ModuleBasicMenu command palette", () => { + it("uses clear user-facing names without changing the established command IDs", async () => { + const fixture = createFixture(); + + await fixture.module._everyOnloadStart(); + + expect(fixture.getCommand("livesync-replicate").name).toBe("Sync now"); + expect(fixture.getCommand("livesync-runbatch").name).toBe("Apply pending changes now"); + }); + + it("keeps maintenance commands out of the normal palette", async () => { + const fixture = createFixture(); + + await fixture.module._everyOnloadStart(); + + expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(false); + expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(false); + + fixture.settings.useAdvancedMode = true; + expect(fixture.getCommand("livesync-scan-files").checkCallback?.(true)).toBe(true); + expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true); + }); + +}); diff --git a/src/modules/features/SettingDialogue/PaneSetup.ts b/src/modules/features/SettingDialogue/PaneSetup.ts index 866d403b..1fd66675 100644 --- a/src/modules/features/SettingDialogue/PaneSetup.ts +++ b/src/modules/features/SettingDialogue/PaneSetup.ts @@ -12,7 +12,7 @@ import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts import type { PageFunctions } from "./SettingPane.ts"; import { visibleOnly } from "./SettingPane.ts"; import { request } from "@/deps.ts"; -import { SetupManager, UserMode } from "@/modules/features/SetupManager.ts"; +import { SetupManager } from "@/modules/features/SetupManager.ts"; import { LiveSyncError } from "@vrtmrz/livesync-commonlib/compat/common/LSError"; import { createCoreSettingsAfterFullReset, @@ -40,8 +40,7 @@ export function paneSetup( .addButton((text) => { text.setButtonText($msg("Rerun Wizard")).onClick(async () => { const setupManager = this.core.getModule(SetupManager); - await setupManager.onOnboard(UserMode.ExistingUser); - // await this.plugin.moduleSetupObsidian.onBoardingWizard(true); + await setupManager.startOnBoarding(); }); }); diff --git a/src/serviceFeatures/setupObsidian/qrCode.ts b/src/serviceFeatures/setupObsidian/qrCode.ts index 6498c1fb..844719c2 100644 --- a/src/serviceFeatures/setupObsidian/qrCode.ts +++ b/src/serviceFeatures/setupObsidian/qrCode.ts @@ -65,7 +65,11 @@ export function useSetupQRCodeFeature(host: NecessaryServices<"API" | "UI" | "se host.services.API.addCommand({ id: "livesync-setting-qr", name: "Show settings as a QR code", - callback: () => fireAndForget(encodeSetupSettingsAsQR(host)), + checkCallback: (checking) => { + if (!host.services.setting.currentSettings().isConfigured) return false; + if (!checking) fireAndForget(encodeSetupSettingsAsQR(host)); + return true; + }, }); host.services.context.events.onEvent(EVENT_REQUEST_SHOW_SETUP_QR, () => fireAndForget(() => encodeSetupSettingsAsQR(host)) diff --git a/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts index 26f7884d..61244f7c 100644 --- a/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts @@ -114,4 +114,44 @@ describe("setupObsidian/qrCode", () => { ); expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_SHOW_SETUP_QR, expect.any(Function)); }); + + it("keeps the QR command out of the palette until setup is complete", async () => { + const addHandler = vi.fn(); + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { isConfigured: false }; + const host = { + services: { + context: createServiceContext(), + API: { + addCommand: vi.fn((command) => commands.push(command)), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => settings), + }, + UI: { + confirm: { + confirmWithMessage: vi.fn(), + }, + }, + }, + } as any; + + useSetupQRCodeFeature(host); + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + const command = commands.find((candidate) => candidate.id === "livesync-setting-qr")!; + expect(command.checkCallback?.(true)).toBe(false); + + settings.isConfigured = true; + expect(command.checkCallback?.(true)).toBe(true); + }); }); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts index b12ae1da..d745a563 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts @@ -47,11 +47,6 @@ export function useSetupManagerHandlersFeature( setupManager: SetupManager ) { host.services.appLifecycle.onLoaded.addHandler(() => { - host.services.API.addCommand({ - id: "livesync-open-onboarding", - name: "Open onboarding wizard", - callback: () => fireAndForget(() => openOnboarding(setupManager)), - }); host.services.API.addCommand({ id: "livesync-opensetupuri", name: "Use the copied setup URI (Formerly Open setup URI)", diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts index a5493495..bdae06ca 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts @@ -109,7 +109,7 @@ describe("setupObsidian/setupManagerHandlers", () => { expect(preventDefault).toHaveBeenCalledOnce(); }); - it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => { + it("keeps onboarding out of the command palette while wiring the setup URI command and events", async () => { const addHandler = vi.fn(); const addCommand = vi.fn(); const events = { onEvent: vi.fn() }; @@ -147,10 +147,9 @@ describe("setupObsidian/setupManagerHandlers", () => { const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; await loadedHandler(); - expect(addCommand).toHaveBeenCalledWith( + expect(addCommand).not.toHaveBeenCalledWith( expect.objectContaining({ id: "livesync-open-onboarding", - name: "Open onboarding wizard", }) ); expect(addCommand).toHaveBeenCalledWith( diff --git a/src/serviceFeatures/setupObsidian/setupUri.ts b/src/serviceFeatures/setupObsidian/setupUri.ts index 9a8d8c65..a4fe4aec 100644 --- a/src/serviceFeatures/setupObsidian/setupUri.ts +++ b/src/serviceFeatures/setupObsidian/setupUri.ts @@ -50,19 +50,33 @@ export function useSetupURIFeature(host: NecessaryServices<"API" | "UI" | "setti host.services.API.addCommand({ id: "livesync-copysetupuri", name: "Copy settings as a new setup URI", - callback: () => fireAndForget(copySetupURI(host, log)), + checkCallback: (checking) => { + if (!host.services.setting.currentSettings().isConfigured) return false; + if (!checking) fireAndForget(copySetupURI(host, log)); + return true; + }, }); host.services.API.addCommand({ id: "livesync-copysetupuri-short", name: "Copy settings as a new setup URI (With customization sync)", - callback: () => fireAndForget(copySetupURI(host, log, false)), + checkCallback: (checking) => { + const settings = host.services.setting.currentSettings(); + if (!settings.isConfigured || !settings.usePluginSync) return false; + if (!checking) fireAndForget(copySetupURI(host, log, false)); + return true; + }, }); host.services.API.addCommand({ id: "livesync-copysetupurifull", name: "Copy settings as a new setup URI (Full)", - callback: () => fireAndForget(copySetupURIFull(host, log)), + checkCallback: (checking) => { + const settings = host.services.setting.currentSettings(); + if (!settings.isConfigured || !settings.useAdvancedMode) return false; + if (!checking) fireAndForget(copySetupURIFull(host, log)); + return true; + }, }); host.services.context.events.onEvent(EVENT_REQUEST_COPY_SETUP_URI, () => diff --git a/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts index 91b280a0..27ec04ef 100644 --- a/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts @@ -156,4 +156,57 @@ describe("setupObsidian/setupUri", () => { expect(addCommand).toHaveBeenCalledWith(expect.objectContaining({ id: "livesync-copysetupurifull" })); expect(onEventSpy).toHaveBeenCalledWith(EVENT_REQUEST_COPY_SETUP_URI, expect.any(Function)); }); + + it("shows Setup URI variants only when their configuration level is relevant", async () => { + const addHandler = vi.fn(); + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + const settings = { + isConfigured: false, + usePluginSync: false, + useAdvancedMode: false, + }; + const host = { + services: { + context: createServiceContext(), + API: { + addCommand: vi.fn((command) => commands.push(command)), + addLog: vi.fn(), + }, + appLifecycle: { + onLoaded: { + addHandler, + }, + }, + setting: { + currentSettings: vi.fn(() => settings), + }, + UI: { + confirm: { + askString: vi.fn(() => "pass"), + }, + promptCopyToClipboard: vi.fn(() => true), + }, + }, + } as any; + + useSetupURIFeature(host); + const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; + await loadedHandler(); + + const command = (id: string) => commands.find((candidate) => candidate.id === id)!; + expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(false); + + settings.isConfigured = true; + expect(command("livesync-copysetupuri").checkCallback?.(true)).toBe(true); + expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(false); + expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(false); + + settings.usePluginSync = true; + settings.useAdvancedMode = true; + expect(command("livesync-copysetupuri-short").checkCallback?.(true)).toBe(true); + expect(command("livesync-copysetupurifull").checkCallback?.(true)).toBe(true); + }); }); diff --git a/src/serviceFeatures/useP2PReplicatorUI.ts b/src/serviceFeatures/useP2PReplicatorUI.ts index 418c41fa..a30f6182 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -67,7 +67,12 @@ export function useP2PReplicatorUI( showWindow: (type: string) => Promise; showWindowOnRight?: (type: string) => Promise; registerWindow: (type: string, factory: (leaf: WorkspaceLeaf) => unknown) => void; - addCommand: (command: { id: string; name: string; callback: () => void }) => unknown; + addCommand: (command: { + id: string; + name: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean | void; + }) => unknown; addRibbonIcon: ( icon: string, title: string, @@ -146,8 +151,12 @@ export function useP2PReplicatorUI( api.addCommand({ id: "open-p2p-server-status", name: "P2P Sync : Open P2P Status", - callback: () => { - void openStatusPane(); + checkCallback: (checking) => { + if (!hasP2PConfiguration(host.services.setting.currentSettings())) return false; + if (!checking) { + void openStatusPane(); + } + return true; }, }); host.services.API.addCommand({ @@ -155,11 +164,15 @@ export function useP2PReplicatorUI( name: "Replicate P2P to default peer", checkCallback: (isChecking: boolean) => { const settings = host.services.setting.currentSettings(); - if (isChecking) { - if (settings.remoteType == REMOTE_P2P) return false; - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(settings) && + settings.remoteType !== REMOTE_P2P && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + runOpenReplication(); } - runOpenReplication(); + return true; }, }); host.services.API.addCommand({ @@ -167,11 +180,15 @@ export function useP2PReplicatorUI( name: "Replicate now by P2P", checkCallback: (isChecking: boolean) => { const settings = host.services.setting.currentSettings(); - if (isChecking) { - if (settings.remoteType == REMOTE_P2P) return false; - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(settings) && + settings.remoteType !== REMOTE_P2P && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + runOpenReplication(); } - runOpenReplication(); + return true; }, }); @@ -179,10 +196,14 @@ export function useP2PReplicatorUI( id: "p2p-sync-targets", name: "P2P: Sync with targets", checkCallback: (isChecking: boolean) => { - if (isChecking) { - return replicator.replicator?.server?.isServing ?? false; + const isAvailable = + hasP2PConfiguration(host.services.setting.currentSettings()) && + (replicator.replicator?.server?.isServing ?? false); + if (!isAvailable) return false; + if (!isChecking) { + void replicator.replicator?.replicateFromCommand(true); } - void replicator.replicator?.replicateFromCommand(true); + return true; }, }); diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts index eaaf8950..b37e3944 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -85,7 +85,12 @@ describe("useP2PReplicatorUI commands", () => { onSettingLoaded: { addHandler: vi.fn() }, onLayoutReady: { addHandler: vi.fn() }, }, - setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + P2P_Enabled: true, + })), + }, replicator: { runFiniteReplicationActivity }, }, } as any; @@ -143,7 +148,11 @@ describe("useP2PReplicatorUI commands", () => { }); it("retains only the current P2P status command and routes existing open requests to it", async () => { - const commands: Array<{ id: string; callback?: () => void }> = []; + const commands: Array<{ + id: string; + callback?: () => void; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; let initialise: (() => Promise) | undefined; const showWindow = vi.fn(async () => undefined); const showWindowOnRight = vi.fn(async () => undefined); @@ -183,12 +192,90 @@ describe("useP2PReplicatorUI commands", () => { expect(commands.map((command) => command.id)).not.toContain("open-p2p-replicator"); expect(commands.map((command) => command.id)).toContain("open-p2p-server-status"); + expect(commands.find((command) => command.id === "open-p2p-server-status")?.checkCallback?.(true)).toBe(false); eventHub.emitEvent(EVENT_REQUEST_OPEN_P2P); await vi.waitFor(() => expect(showWindowOnRight).toHaveBeenCalledWith("p2p-status")); expect(showWindow).not.toHaveBeenCalledWith("p2p"); }); + it("shows P2P commands only when a P2P configuration exists and their runtime prerequisites are met", async () => { + const commands: Array<{ + id: string; + checkCallback?: (checking: boolean) => boolean | void; + }> = []; + let initialise: (() => Promise) | undefined; + let settings: Record = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + }; + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + showWindowOnRight: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn((command) => commands.push(command)), + addRibbonIcon: vi.fn(), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => settings), + onSettingSaved: { addHandler: vi.fn() }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + const p2p = { + replicator: { + server: { isServing: true }, + openReplication: vi.fn(), + replicateFromCommand: vi.fn(), + }, + } as any; + + useP2PReplicatorUI(host, {} as any, p2p); + await initialise?.(); + + for (const commandId of [ + "open-p2p-server-status", + "replicate-now-by-p2p-default-peer", + "replicate-now-by-p2p", + "p2p-sync-targets", + ]) { + expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(false); + } + + settings = { + ...settings, + remoteConfigurations: { + peer: { + id: "peer", + name: "Peer", + uri: "sls+p2p://room?passphrase=secret", + isEncrypted: false, + }, + }, + }; + for (const commandId of [ + "open-p2p-server-status", + "replicate-now-by-p2p-default-peer", + "replicate-now-by-p2p", + "p2p-sync-targets", + ]) { + expect(commands.find(({ id }) => id === commandId)?.checkCallback?.(true)).toBe(true); + } + }); + it("does not open the P2P status pane automatically when the workspace becomes ready", async () => { let layoutReady: (() => Promise) | undefined; const showWindow = vi.fn(async () => undefined); diff --git a/styles.css b/styles.css index 3f315599..273fe736 100644 --- a/styles.css +++ b/styles.css @@ -355,6 +355,10 @@ body { justify-content: center; } +body:not(.is-mobile):has(.sls-setting) .notice:has(.sls-onboarding-invitation-action) { + margin-right: 96px; +} + .sls-review-harness { box-sizing: border-box; max-width: 100%; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index e0069e12..06b05ac2 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -81,7 +81,7 @@ The underlying `test:e2e:obsidian:` scripts remain available for an im `test:contract:context:cli` builds the Node CLI and runs its existing Deno setup, put, read, list, information, remove, conflict-resolution, and revision workflow. `test:contract:context:obsidian` builds the plug-in and runs the real-Obsidian smoke test, including the Context inspection. These runtime scripts are local validation entry points and are not added to the default CI gate by this change. -`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then uses the permanent command to reopen the wizard on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. +`test:e2e:obsidian:onboarding-invitation` starts an unconfigured temporary Vault with no plug-in data and verifies that startup selects Commonlib's new-Vault recommendations, offers the setup wizard without opening it, and does not scan Vault files automatically. It checks the invitation action and introduction in mobile test mode, then reopens the wizard from **Self-hosted LiveSync settings** → **Setup** on the desktop. This scenario owns the unconfigured-startup boundary only; configured compatibility review remains covered by `settings-ui`, and the setup workflows remain covered by their dedicated scenarios. `test:e2e:obsidian:dialog-mounts` starts a temporary real Obsidian session and exercises remote selection and CouchDB settings through `SetupManager`, plus Setup URI entry through the registered command. It verifies the compatibility pause and remote-size review, the distinction between a central data-storage server and P2P signalling, the explicit tested and untested CouchDB save actions, the internal-API warning, the Setup URI controls, automatic adjustment when differences are limited to compatible chunk settings, and both manual configuration-mismatch routes. The same session opens the live log and generated full report, reaches the `Hatch` recovery controls, writes and removes its own persistent log, and runs the missing-chunk recreation and file-verification actions against the empty disposable Vault. It captures representative desktop and mobile dialogues, checks the mobile layout and vertically stacked actions, closes each route through its normal controls, and verifies that each mounted operation settles without an error. It does not apply a remote configuration, contact a remote service, or claim to repair a deliberately damaged database. diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts index 82818632..8367fb82 100644 --- a/test/e2e-obsidian/scripts/onboarding-invitation.ts +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -26,7 +26,10 @@ type UnconfiguredStartupEvidence = { }; type ObsidianTestApp = { - commands?: { executeCommandById(commandId: string): boolean }; + setting?: { + open(): void; + openTabById(tabId: string): void; + }; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; @@ -151,14 +154,38 @@ async function captureAndCloseIntro(filename: string, mobile: boolean): Promise< return screenshot; } -async function openPermanentCommand(): Promise { - const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { - return await page.evaluate( - (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, - "obsidian-livesync:livesync-open-onboarding" - ); +async function openOnboardingFromSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + + const liveSyncSettings = page.locator(".sls-setting"); + await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click({ timeout: uiTimeoutMs }); + + const onboardingSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.locator(".setting-item-name").filter({ hasText: "Rerun Onboarding Wizard" }), + }); + await onboardingSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await onboardingSetting + .getByRole("button", { name: "Rerun Wizard", exact: true }) + .click({ timeout: uiTimeoutMs }); + await onboardingDialogue(page).waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function closeSettings(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const settingsContainer = page.locator(".modal-container").filter({ + has: page.locator(".sls-setting"), + }); + await settingsContainer.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); + await settingsContainer.waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); - if (!opened) throw new Error("The permanent onboarding command was not registered."); } async function main(): Promise { @@ -190,8 +217,9 @@ async function main(): Promise { console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`); const desktopInvitation = await captureDesktopInvitation(); - await openPermanentCommand(); - const commandIntro = await captureAndCloseIntro("onboarding-intro-command-desktop.png", false); + await openOnboardingFromSettings(); + const settingsIntro = await captureAndCloseIntro("onboarding-intro-settings-desktop.png", false); + await closeSettings(); const mobileInvitation = await captureAndSelectMobileInvitation(); const mobileIntro = await captureAndCloseIntro("onboarding-intro-mobile.png", true); @@ -200,7 +228,7 @@ async function main(): Promise { desktopInvitation, mobileInvitation, mobileIntro, - commandIntro, + settingsIntro, ].join(", ")}` ); } finally { diff --git a/updates.md b/updates.md index 4b3bd275..27f20885 100644 --- a/updates.md +++ b/updates.md @@ -14,6 +14,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved +- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. - P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. - First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. From 658ad6dfe4e46810bd8775bbadbf9da7e8ddba28 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 16:15:02 +0000 Subject: [PATCH 144/170] Describe saved connections consistently --- docs/adr/2026_07_multiple_remote_onboarding.md | 4 ++-- docs/settings.md | 6 +++--- src/common/messages/LiveSyncProvisionalMessages.ts | 2 ++ src/common/translation.unit.spec.ts | 2 ++ .../features/SettingDialogue/PaneRemoteConfig.ts | 4 ++-- test/e2e-obsidian/scripts/settings-ui.ts | 11 +++++++++++ 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/adr/2026_07_multiple_remote_onboarding.md b/docs/adr/2026_07_multiple_remote_onboarding.md index db468c0d..ff07cdec 100644 --- a/docs/adr/2026_07_multiple_remote_onboarding.md +++ b/docs/adr/2026_07_multiple_remote_onboarding.md @@ -65,7 +65,7 @@ The special meaning would duplicate `activeConfigurationId`, make a user-visible ### Add profile naming and full list editing to onboarding -That would make the first-run path longer and duplicate the established Remote Databases interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection. +That would make the first-run path longer and duplicate the established Saved connections interface. Automatic descriptive names and later renaming keep this change limited to data integrity and consistent selection. ### Replace the compatibility fields immediately @@ -81,7 +81,7 @@ The real-Obsidian onboarding E2E owns the invitation, dialogue presentation, saf ## Consequences -- Manual onboarding and the Remote Databases pane share one Commonlib profile contract. +- Manual onboarding and the Saved connections list share one Commonlib profile contract. - Existing profiles survive reconfiguration, and a newly configured connection becomes explicitly selectable. - Modern imports retain user-assigned profile identity and names. - Legacy Setup URIs continue to work through an isolated compatibility boundary. diff --git a/docs/settings.md b/docs/settings.md index b371d330..89836438 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -169,9 +169,9 @@ Show verbose log. Please enable when you report the logs ## 3. Remote Configuration -### 1. Remote Server +### 1. Connection settings -Self-hosted LiveSync supports multiple remote connection profiles under **Remote Server** -> **Remote Databases**. This allows you to save and switch between multiple databases or bucket configurations in a single vault. +Self-hosted LiveSync stores multiple remote connection profiles under **Connection settings** → **Saved connections**. Each profile represents a CouchDB database, an Object Storage connection, or a P2P configuration, and several profiles can be kept in one Vault. Each profile has an opaque identifier and a presentation name. The name does not need to be unique and is not used to select the profile. The main remote and the P2P remote are selected independently, so code and settings imports must preserve both selections rather than relying on a special identifier such as `default`. @@ -185,7 +185,7 @@ Each profile has an opaque identifier and a presentation name. The name does not Setting key: remoteType -The active remote server type. This is automatically projected to the legacy configuration when you activate a connection profile. +The active connection type. This is automatically projected to the legacy configuration when you activate a connection profile. ### 2. Notification diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 0c9057ba..ad064588 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -66,6 +66,8 @@ export const liveSyncProvisionalEnglishMessages = { "This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", "Sync now": "Sync now", "Apply pending changes now": "Apply pending changes now", + "Connection settings": "Connection settings", + "Saved connections": "Saved connections", } as const; export type LiveSyncProvisionalMessageKey = keyof typeof liveSyncProvisionalEnglishMessages; diff --git a/src/common/translation.unit.spec.ts b/src/common/translation.unit.spec.ts index 45fb4719..f2da8cee 100644 --- a/src/common/translation.unit.spec.ts +++ b/src/common/translation.unit.spec.ts @@ -30,6 +30,8 @@ describe("LiveSync-owned translation catalogue", () => { it("uses LiveSync-owned provisional English without extending Commonlib's message contract", () => { expect($msg("This file has unresolved conflicts.")).toBe("This file has unresolved conflicts."); expect($msg("More actions for ${DEVICE}", { DEVICE: "phone" })).toBe("More actions for phone"); + expect($msg("Connection settings")).toBe("Connection settings"); + expect($msg("Saved connections")).toBe("Saved connections"); expect( $msg("This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", { COUNT: "3", diff --git a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts index 4b1e9504..e3ae96f5 100644 --- a/src/modules/features/SettingDialogue/PaneRemoteConfig.ts +++ b/src/modules/features/SettingDialogue/PaneRemoteConfig.ts @@ -133,8 +133,8 @@ export function paneRemoteConfig( } { // TODO: very WIP. need to refactor the UI. - void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleRemoteServer"), () => {}).then((paneEl) => { - const actions = new Setting(paneEl).setName("Remote Databases"); + void addPanel(paneEl, $msg("Connection settings"), () => {}).then((paneEl) => { + const actions = new Setting(paneEl).setName($msg("Saved connections")); // actions.addButton((button) => // button // .setButtonText("Change Remote and Setup") diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts index a0f1c791..93b85786 100644 --- a/test/e2e-obsidian/scripts/settings-ui.ts +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -214,6 +214,17 @@ async function verifyEffectiveSettings(): Promise { throw new Error("The Change Log still contains a compatibility or release-note acknowledgement control."); } + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Remote Configuration"]').click(); + const connectionPanel = liveSyncSettings + .locator("h4.sls-setting-panel-title") + .filter({ hasText: "Connection settings" }) + .locator(".."); + await connectionPanel.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await connectionPanel.getByText("Saved connections", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click(); const deletionPanel = liveSyncSettings .locator("h4.sls-setting-panel-title") From 07cba4ce83ef37d0ced1307ff47c6305be39b177 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 16:15:46 +0000 Subject: [PATCH 145/170] Keep unreadable revisions for explicit repair --- docs/settings.md | 16 +- docs/specs_conflict_resolution.md | 20 +- docs/troubleshooting.md | 15 +- package.json | 1 + .../messages/LiveSyncProvisionalMessages.ts | 50 +- .../coreFeatures/ModuleConflictResolver.ts | 7 +- .../ModuleConflictResolver.unit.spec.ts | 23 + src/modules/essential/ModuleBasicMenu.ts | 12 +- .../essential/ModuleBasicMenu.unit.spec.ts | 31 ++ .../features/SettingDialogue/PaneHatch.ts | 499 ++++++++++++------ src/serviceFeatures/fileDatabaseInfo.ts | 454 ++++++++++++++++ .../fileDatabaseInfo.unit.spec.ts | 413 +++++++++++++++ src/serviceFeatures/fileRepair.ts | 109 ++++ src/serviceFeatures/fileRepair.unit.spec.ts | 172 ++++++ styles.css | 43 ++ test/e2e-obsidian/README.md | 5 +- test/e2e-obsidian/scripts/dialog-mounts.ts | 6 +- test/e2e-obsidian/scripts/local-suite.ts | 1 + test/e2e-obsidian/scripts/revision-repair.ts | 334 ++++++++++++ test/e2e-obsidian/scripts/run-focused.ts | 1 + updates.md | 4 +- 21 files changed, 2036 insertions(+), 180 deletions(-) create mode 100644 src/serviceFeatures/fileDatabaseInfo.ts create mode 100644 src/serviceFeatures/fileDatabaseInfo.unit.spec.ts create mode 100644 src/serviceFeatures/fileRepair.ts create mode 100644 src/serviceFeatures/fileRepair.unit.spec.ts create mode 100644 test/e2e-obsidian/scripts/revision-repair.ts diff --git a/docs/settings.md b/docs/settings.md index 89836438..c50558fe 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -698,6 +698,12 @@ Open the dialogue #### Make report to inform the issue +#### Copy database information for a file + +Select a file to copy its local database information. The command **Copy database information for the active file** performs the same inspection for the file open in the editor. + +The report includes the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote or include file contents. Paths and identifiers can still be private metadata, so review the report before sharing it. + #### Write logs into the file Setting key: writeLogToTheFile @@ -721,17 +727,19 @@ Stop reflecting database changes to storage files. ### 3. Recovery and Repair -#### Recreate missing chunks for all files +#### Recreate chunks for current Vault files -This will recreate chunks for all files. If there were missing chunks, this may fix the errors. +Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content. #### Resolve All conflicted files by the newer one -Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one. +After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable. #### Verify and repair all files -Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep. +Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded. + +`Retry reading revision` retries the configured chunk-retrieval path without changing the revision tree. `Discard unreadable revision` is offered only for an exact current live revision which remains unreadable; after confirmation, it creates a logical deletion for that revision. Prefer recovery from another replica or backup before discarding it. #### Check and convert non-path-obfuscated files diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md index 569105c6..cbeb048c 100644 --- a/docs/specs_conflict_resolution.md +++ b/docs/specs_conflict_resolution.md @@ -46,6 +46,22 @@ The all-branch history check prevents a resolved conflict from being recreated m The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general **Always overwrite with a newer file** option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision. +## Unreadable revisions and repair + +A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing. + +**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. It reports exact revision identifiers and local chunk availability separately: + +- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree. +- **Discard unreadable revision** is available only for a current winner or conflict revision which remains unreadable when the action is performed. It requires confirmation and creates a logical deletion for that exact revision. +- A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually. + +Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible. + +**Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision. + +A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted. + ### Two devices independently create the same path If two devices create the same full synchronised path before either device has @@ -220,8 +236,8 @@ Do not: ## Verification -Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks. +Commonlib's real-PouchDB and injected-boundary unit tests cover unequal branch lengths, exact shared ancestry, deterministic ordering of multiple live leaves, a sensible stage followed by reconstruction of a manual pair, content below a deleted losing leaf, recorded and reconstructed branch identity, ambiguous matches, conflict-time editing, missing-body preservation when parent metadata is available, refusal to invent a parent for a generation-one revision, logical deletion, case-only rename, cross-path rename, and safe unproven fallbacks. LiveSync's optional real-Obsidian two-Vault checks have two scopes. `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true` resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one live result to remain. `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other live branches remain intact. -The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. +The focused `test:e2e:obsidian:conflict-dialog-policy` scenario creates three live versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the live pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable live revision remains in the tree, and exercises explicit retry and discard controls without deleting another live revision. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 41812ed0..ee85985e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -53,9 +53,16 @@ Check Obsidian's `Detect all file extensions`, LiveSync selectors, ignore files, If the log reports missing chunks or a size mismatch: -1. restart Obsidian once to rule out an interrupted fetch; -2. on a device which has the correct file, run `Recreate missing chunks for all files`, then synchronise; and -3. if the mismatch remains, run `Verify and repair all files` from `Hatch` and review which copy is authoritative. +1. stop editing the affected file and keep a separate copy of any readable content; +2. restart Obsidian once to rule out an interrupted fetch; +3. synchronise a device or restore a backup which still has the correct content; +4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise; +5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; and +6. use `Discard unreadable revision` only after confirming that the exact revision is no longer recoverable or wanted. + +`Retry reading revision` does not change the revision tree. `Discard unreadable revision` creates a logical deletion for one current winner or conflict revision after rechecking it. It does not purge history or reconstruct missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions. + +`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision. ## A configuration mismatch dialogue blocks synchronisation @@ -110,6 +117,8 @@ Enable Obsidian's `Detect all file extensions`, then check LiveSync selectors, i Run `Generate full report for opening the issue with debug info` to copy the current settings summary and recent verbose log lines. Remove credentials, remote URLs, Vault names, file contents, and other private information before sharing it. +When a problem concerns one file, run **Copy database information for the active file**, or use **Hatch** → **Copy database information for a file** to select another file. The report describes this device's local database view, including the Vault-relative path, document and chunk identifiers, local database revisions, conflicts, and local chunk availability. It does not query the remote server or include file contents. Treat paths and identifiers as private metadata before sharing. + Use `Show log` for live inspection. Logs are intentionally kept in memory for a limited time to reduce accidental disclosure. Enable `Write logs into the file` only while reproducing a problem, then disable it and remove the file after review because persistent logging affects performance and may contain private data. ![Write logs into the file](../images/write_logs_into_the_file.png) diff --git a/package.json b/package.json index 00867d6b..ad11c68b 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "test:e2e:obsidian:onboarding-invitation": "tsx test/e2e-obsidian/scripts/onboarding-invitation.ts", "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", "test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts", + "test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index ad064588..4618edc9 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -9,8 +9,7 @@ export const liveSyncProvisionalEnglishMessages = { "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.": "This first setup has several short steps because it confirms encryption, the connection method, and which device provides the initial data. Once it is complete, additional devices can reuse a Setup URI.", - "Setup Complete: Preparing to Fetch from Another Device": - "Setup Complete: Preparing to Fetch from Another Device", + "Setup Complete: Preparing to Fetch from Another Device": "Setup Complete: Preparing to Fetch from Another Device", "The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.": "The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device.", "After restarting, select an online source device for the initial Fetch. The local LiveSync database on this device will be rebuilt from that source. Unsynchronised files in this Vault may conflict with the fetched data.": @@ -66,6 +65,53 @@ export const liveSyncProvisionalEnglishMessages = { "This file has ${COUNT} unresolved versions. They will be reviewed one pair at a time.", "Sync now": "Sync now", "Apply pending changes now": "Apply pending changes now", + "Copy database information for the active file": "Copy database information for the active file", + "Copy database information for a file": "Copy database information for a file", + "Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents.": + "Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents.", + "Choose file": "Choose file", + "Choose a file to inspect": "Choose a file to inspect", + "Database information for ${FILE}": "Database information for ${FILE}", + "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.": + "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.", + "Vault file: modified ${TIME}, size ${SIZE}": "Vault file: modified ${TIME}, size ${SIZE}", + "Vault file: missing": "Vault file: missing", + "Local database document: missing": "Local database document: missing", + "${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}", + "Winner revision": "Winner revision", + "Conflict revision": "Conflict revision", + "Unknown revision": "Unknown revision", + "Logical deletion": "Logical deletion", + "Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}": + "Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", + "Unreadable on this device; ${COUNT} referenced chunks are missing or deleted": + "Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", + "Matches the current Vault file": "Matches the current Vault file", + "Differs from the current Vault file": "Differs from the current Vault file", + "Retry reading revision": "Retry reading revision", + "Discard unreadable revision": "Discard unreadable revision", + "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.": + "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.", + "Revision metadata is unavailable on this device": "Revision metadata is unavailable on this device", + "Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.": + "Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.", + "No shared ancestor is available for this conflict. The live revisions remain available for explicit review.": + "No shared ancestor is available for this conflict. The live revisions remain available for explicit review.", + "Show revision history": "Show revision history", + "Use Vault file in local database": "Use Vault file in local database", + "Restore database winner to Vault": "Restore database winner to Vault", + "Copy database information": "Copy database information", + "Recreate chunks for current Vault files": "Recreate chunks for current Vault files", + "Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.": + "Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.", + "Recreate current chunks": "Recreate current chunks", + "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.": + "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.", + "Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version", + "Verify and repair all files": "Verify and repair all files", + "Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.": + "Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.", + "Verify all": "Verify all", "Connection settings": "Connection settings", "Saved connections": "Saved connections", } as const; diff --git a/src/modules/coreFeatures/ModuleConflictResolver.ts b/src/modules/coreFeatures/ModuleConflictResolver.ts index 3acb0f55..85d060a1 100644 --- a/src/modules/coreFeatures/ModuleConflictResolver.ts +++ b/src/modules/coreFeatures/ModuleConflictResolver.ts @@ -86,8 +86,11 @@ export class ModuleConflictResolver extends AbstractModule { return MISSING_OR_ERROR; } if (rightLeaf == false) { - // Conflicted item could not load, delete this. - return await this.services.conflict.resolveByDeletingRevision(path, rightRev, "MISSING OLD REV"); + // A locally unreadable conflict leaf may still be recoverable from another + // replica or backup. Keep it visible for explicit repair instead of treating + // missing chunks as evidence that the branch is obsolete. + this._log(`could not read conflicted revision ${rightRev}:${path}`, LOG_LEVEL_NOTICE); + return MISSING_OR_ERROR; } const isSame = leftLeaf.data == rightLeaf.data && leftLeaf.deleted == rightLeaf.deleted; diff --git a/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts b/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts index c1348c43..18bfb618 100644 --- a/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts +++ b/src/modules/coreFeatures/ModuleConflictResolver.unit.spec.ts @@ -4,6 +4,7 @@ import { DEFAULT_SETTINGS, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, + MISSING_OR_ERROR, type FilePathWithPrefix, type MetaEntry, } from "@vrtmrz/livesync-commonlib/compat/common/types"; @@ -189,6 +190,28 @@ describe("ModuleConflictResolver independent same-path creation", () => { }); describe("ModuleConflictResolver sensible merge hand-off", () => { + it("keeps an unreadable non-winner revision unresolved", async () => { + const path = "missing-conflict-body.md" as FilePathWithPrefix; + const { module, resolveByDeletingRevision, tryAutoMerge } = createModule(); + tryAutoMerge.mockResolvedValue({ + leftRev: "3-current", + rightRev: "2-unreadable", + leftLeaf: { + rev: "3-current", + data: "Readable current body\n", + ctime: 1, + mtime: 3, + deleted: false, + }, + rightLeaf: false, + }); + + const result = await module.checkConflictAndPerformAutoMerge(path); + + expect(result).toBe(MISSING_OR_ERROR); + expect(resolveByDeletingRevision).not.toHaveBeenCalled(); + }); + it("stores the merged body and removes the resolved conflict leaf", async () => { const path = "sensible.md" as FilePathWithPrefix; const { module, resolveByDeletingRevision, tryAutoMerge } = createModule(); diff --git a/src/modules/essential/ModuleBasicMenu.ts b/src/modules/essential/ModuleBasicMenu.ts index 5eb6d235..06d27450 100644 --- a/src/modules/essential/ModuleBasicMenu.ts +++ b/src/modules/essential/ModuleBasicMenu.ts @@ -3,6 +3,7 @@ import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { fireAndForget } from "octagonal-wheels/promises"; import { AbstractModule } from "@/modules/AbstractModule"; import { $msg } from "@/common/translation"; +import { copyFileDatabaseInfo } from "@/serviceFeatures/fileDatabaseInfo"; // Separated Module for basic menu commands, which are not related to obsidian specific features. It is expected to be used in other platforms with minimal changes. // However, it is odd that it has here at all; it really ought to be in each respective feature. It will likely be moved eventually. Until now, addCommand pointed to Obsidian's version. export class ModuleBasicMenu extends AbstractModule { @@ -16,11 +17,14 @@ export class ModuleBasicMenu extends AbstractModule { }); this.addCommand({ id: "livesync-dump", - name: "Dump information of this doc ", - callback: () => { + name: $msg("Copy database information for the active file"), + checkCallback: (checking) => { const file = this.services.vault.getActiveFilePath(); - if (!file) return; - fireAndForget(() => this.localDatabase.getDBEntry(file, {}, true, false)); + if (!file) return false; + if (!checking) { + fireAndForget(() => copyFileDatabaseInfo(this.core, file)); + } + return true; }, }); this.addCommand({ diff --git a/src/modules/essential/ModuleBasicMenu.unit.spec.ts b/src/modules/essential/ModuleBasicMenu.unit.spec.ts index 10a175e7..af5871d3 100644 --- a/src/modules/essential/ModuleBasicMenu.unit.spec.ts +++ b/src/modules/essential/ModuleBasicMenu.unit.spec.ts @@ -136,4 +136,35 @@ describe("ModuleBasicMenu command palette", () => { expect(fixture.getCommand("livesync-abortsync").checkCallback?.(true)).toBe(true); }); + it("keeps active-file database information available and opens it in a copy dialogue", async () => { + const fixture = createFixture(); + + await fixture.module._everyOnloadStart(); + + const command = fixture.getCommand("livesync-dump"); + expect(command.name).toBe("Copy database information for the active file"); + expect(command.checkCallback?.(true)).toBe(true); + + command.checkCallback?.(false); + + await vi.waitFor(() => { + expect(fixture.services.UI.promptCopyToClipboard).toHaveBeenCalledOnce(); + }); + const [title, report] = fixture.services.UI.promptCopyToClipboard.mock.calls[0]; + expect(title).toBe("Database information for note.md"); + expect(report).toContain("note.md"); + expect(report).toContain("2-current"); + expect(report).toContain("h:private-chunk-id"); + expect(report).toContain("1-chunk"); + expect(fixture.core.localDatabase.getDBEntry).not.toHaveBeenCalled(); + }); + + it("hides the active-file database report when no file is active", async () => { + const fixture = createFixture(); + fixture.services.vault.getActiveFilePath.mockReturnValue(null); + + await fixture.module._everyOnloadStart(); + + expect(fixture.getCommand("livesync-dump").checkCallback?.(true)).toBe(false); + }); }); diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 3897046c..4cf67e40 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -3,14 +3,13 @@ import { type DocumentID, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, - type LoadedEntry, type MetaEntry, type FilePath, type EntryDoc, } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { createBlob, getFileRegExp, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; -import { addPrefix, shouldBeIgnored, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { $msg } from "@/common/translation"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; @@ -21,12 +20,24 @@ import { EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub, } from "@/common/events.ts"; -import { ICHeader, ICXHeader, PSCHeader } from "@/common/types.ts"; +import { ICHeader } from "@/common/types.ts"; import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; +import { + chooseAndCopyFileDatabaseInfo, + collectFileDatabaseInfoPaths, + copyFileDatabaseInfo, + retryReadFileDatabaseRevision, +} from "@/serviceFeatures/fileDatabaseInfo.ts"; +import { + discardUnreadableLiveRevision, + inspectFileRepair, + type FileRepairInspection, + type FileRepairRevision, +} from "@/serviceFeatures/fileRepair.ts"; export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { // const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); // hatchWarn.addClass("op-warn-info"); @@ -67,6 +78,18 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info"); }) ); + new Setting(paneEl) + .setName($msg("Copy database information for a file")) + .setDesc( + $msg( + "Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents." + ) + ) + .addButton((button) => + button.setButtonText($msg("Choose file")).onClick(async () => { + await chooseAndCopyFileDatabaseInfo(this.core); + }) + ); new Setting(paneEl) .setName($msg("Analyse database usage")) .setDesc( @@ -99,132 +122,300 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, }); void addPanel(paneEl, "Recovery and Repair").then((paneEl) => { - const addResult = async (path: string, file: FilePathWithPrefix | false, fileOnDB: LoadedEntry | false) => { - const storageFileStat = file ? await this.core.storageAccess.statHidden(file) : null; - resultArea.appendChild( - this.createEl(resultArea, "div", {}, (el) => { - el.appendChild(this.createEl(el, "h6", { text: path })); - el.appendChild( - this.createEl(el, "div", {}, (infoGroupEl) => { - infoGroupEl.appendChild( - this.createEl(infoGroupEl, "div", { - text: `Storage : Modified: ${!storageFileStat ? `Missing:` : `${new Date(storageFileStat.mtime).toLocaleString()}, Size:${storageFileStat.size}`}`, - }) - ); - infoGroupEl.appendChild( - this.createEl(infoGroupEl, "div", { - text: `Database: Modified: ${!fileOnDB ? `Missing:` : `${new Date(fileOnDB.mtime).toLocaleString()}, Size:${fileOnDB.size} (actual size:${readAsBlob(fileOnDB).size})`}`, - }) - ); - }) + const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" }); + const addActionButton = ( + parent: HTMLElement, + text: string, + action: (button: HTMLButtonElement) => Promise | void, + warning = false + ) => { + this.createEl(parent, "button", { text }, (button) => { + if (warning) { + button.addClass("mod-warning"); + } + button.onClickEvent(async () => { + button.disabled = true; + try { + await action(button); + } finally { + if (button.isConnected) { + button.disabled = false; + } + } + }); + }); + }; + const storeStorageInDatabase = async (path: string): Promise => { + if (path.startsWith(".")) { + const addOn = this.core.getAddOn(HiddenFileSync.name); + if (!addOn) { + return false; + } + const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path); + if (!file) { + Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE); + return false; + } + return Boolean(await addOn.storeInternalFileToDatabase(file, true)); + } + return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true)); + }; + const applyWinnerToStorage = async ( + path: string, + revision: FileRepairRevision + ): Promise => { + if (revision.loadedEntry === false) { + return false; + } + if (revision.loadedEntry.path.startsWith(ICHeader)) { + const addOn = this.core.getAddOn(HiddenFileSync.name); + return addOn + ? Boolean(await addOn.extractInternalFileFromDatabase(path as FilePath, true)) + : false; + } + return Boolean(await this.core.fileHandler.dbToStorage(revision.loadedEntry as MetaEntry, null, true)); + }; + const addRepairResult = (inspection: FileRepairInspection) => { + const { information, revisions } = inspection; + const path = information.path; + const card = this.createEl(resultArea, "div", { cls: "sls-repair-result" }); + const refresh = async () => { + card.remove(); + const refreshed = await inspectFileRepair(this.core, path); + if (refreshed.requiresAttention) { + addRepairResult(refreshed); + } else { + Logger(`Verification no longer reports a problem for ${path}`, LOG_LEVEL_NOTICE); + } + }; + + this.createEl(card, "h6", { text: path }); + if (information.storage.exists) { + this.createEl(card, "div", { + text: $msg("Vault file: modified ${TIME}, size ${SIZE}", { + TIME: new Date(information.storage.mtime ?? 0).toLocaleString(), + SIZE: `${information.storage.size ?? 0}`, + }), + }); + } else { + this.createEl(card, "div", { text: $msg("Vault file: missing") }); + } + if (!information.database.exists) { + this.createEl(card, "div", { text: $msg("Local database document: missing") }); + } + + const addRevision = (revision: FileRepairRevision) => { + const { metadata } = revision; + const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" }); + this.createEl(revisionEl, "div", { + text: $msg("${ROLE}: ${REVISION}", { + ROLE: revision.role === "winner" ? $msg("Winner revision") : $msg("Conflict revision"), + REVISION: metadata.revision ?? $msg("Unknown revision"), + }), + cls: "sls-repair-revision-title", + }); + if (metadata.deleted) { + this.createEl(revisionEl, "div", { text: $msg("Logical deletion") }); + } else if (revision.contentReadable) { + this.createEl(revisionEl, "div", { + text: $msg("Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", { + RECORDED: `${metadata.recordedSize}`, + ACTUAL: `${revision.loadedEntry === false ? 0 : readAsBlob(revision.loadedEntry).size}`, + }), + }); + } else { + const missing = metadata.chunks.filter( + ({ embedded, localDatabaseState }) => + !embedded && localDatabaseState !== "available" ); - if (fileOnDB && file) { - el.appendChild( - this.createEl(el, "button", { text: "Show history" }, (buttonEl) => { - buttonEl.onClickEvent(() => { - eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, { - file: file, - fileOnDB: fileOnDB, - }); - }); - }) - ); + this.createEl(revisionEl, "div", { + text: $msg("Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", { + COUNT: `${missing.length}`, + }), + cls: "mod-warning", + }); + if (missing.length > 0) { + this.createEl(revisionEl, "code", { + text: missing + .slice(0, 3) + .map(({ id }) => id) + .join(", ") + (missing.length > 3 ? ", …" : ""), + }); } - if (file) { - el.appendChild( - this.createEl(el, "button", { text: "Storage -> Database" }, (buttonEl) => { - buttonEl.onClickEvent(async () => { - if (file.startsWith(".")) { - const addOn = this.core.getAddOn(HiddenFileSync.name); - if (addOn) { - const file = (await addOn.scanInternalFiles()).find((e) => e.path == path); - if (!file) { - Logger( - `Failed to find the file in the internal files: ${path}`, - LOG_LEVEL_NOTICE - ); - return; - } - if (!(await addOn.storeInternalFileToDatabase(file, true))) { - Logger( - `Failed to store the file to the database (Hidden file): ${file.path}`, - LOG_LEVEL_NOTICE - ); - return; - } - } - } else { - if (!(await this.core.fileHandler.storeFileToDB(file, true))) { - Logger( - `Failed to store the file to the database: ${file}`, - LOG_LEVEL_NOTICE - ); - return; + } + if (revision.contentMatchesStorage === true) { + this.createEl(revisionEl, "div", { text: $msg("Matches the current Vault file") }); + } else if (revision.contentMatchesStorage === false) { + this.createEl(revisionEl, "div", { text: $msg("Differs from the current Vault file") }); + } + + if (!metadata.deleted && !revision.contentReadable && metadata.revision) { + const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" }); + addActionButton(actions, $msg("Retry reading revision"), async () => { + const loaded = await retryReadFileDatabaseRevision(this.core, path, metadata.revision!); + Logger( + loaded + ? `Revision ${metadata.revision} of ${path} is readable after retry` + : `Revision ${metadata.revision} of ${path} remains unreadable`, + LOG_LEVEL_NOTICE + ); + await refresh(); + }); + addActionButton( + actions, + $msg("Discard unreadable revision"), + async () => { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.", + { + REVISION: metadata.revision!, + FILE: path, } + ), + { + title: $msg("Discard unreadable revision"), + defaultOption: "No", } - el.remove(); - }); - }) - ); - } - if (fileOnDB) { - el.appendChild( - this.createEl(el, "button", { text: "Database -> Storage" }, (buttonEl) => { - buttonEl.onClickEvent(async () => { - if (fileOnDB.path.startsWith(ICHeader)) { - const addOn = this.core.getAddOn(HiddenFileSync.name); - if (addOn) { - if ( - !(await addOn.extractInternalFileFromDatabase(path as FilePath, true)) - ) { - Logger( - `Failed to store the file to the database (Hidden file): ${file}`, - LOG_LEVEL_NOTICE - ); - return; - } - } - } else { - if ( - !(await this.core.fileHandler.dbToStorage( - fileOnDB as MetaEntry, - null, - true - )) - ) { - Logger( - `Failed to store the file to the storage: ${fileOnDB.path}`, - LOG_LEVEL_NOTICE - ); - return; - } + )) === "yes"; + if (!confirmed) { + return; + } + const result = await discardUnreadableLiveRevision( + this.core, + path, + metadata.revision! + ); + Logger( + `Discard unreadable revision ${metadata.revision} of ${path}: ${result}`, + result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE + ); + await refresh(); + }, + true + ); + } + }; + revisions.forEach(addRevision); + + for (const revision of information.database.unavailableConflictRevisions) { + const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" }); + this.createEl(revisionEl, "div", { + text: $msg("${ROLE}: ${REVISION}", { + ROLE: $msg("Conflict revision"), + REVISION: revision, + }), + cls: "sls-repair-revision-title", + }); + this.createEl(revisionEl, "div", { + text: $msg("Revision metadata is unavailable on this device"), + cls: "mod-warning", + }); + const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" }); + addActionButton(actions, $msg("Retry reading revision"), async () => { + await retryReadFileDatabaseRevision(this.core, path, revision); + await refresh(); + }); + addActionButton( + actions, + $msg("Discard unreadable revision"), + async () => { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.", + { + REVISION: revision, + FILE: path, } - el.remove(); - }); - }) - ); + ), + { + title: $msg("Discard unreadable revision"), + defaultOption: "No", + } + )) === "yes"; + if (!confirmed) { + return; + } + await discardUnreadableLiveRevision(this.core, path, revision); + await refresh(); + }, + true + ); + } + + for (const base of information.database.mergeBases) { + if (base.contentAvailableLocally) { + continue; + } + this.createEl(card, "div", { + text: base.revision + ? $msg( + "Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.", + { + REVISION: base.revision, + } + ) + : $msg( + "No shared ancestor is available for this conflict. The live revisions remain available for explicit review." + ), + cls: "sls-repair-ancestor-warning", + }); + } + + const winner = revisions.find(({ role }) => role === "winner"); + const actions = this.createEl(card, "div", { cls: "sls-repair-actions" }); + if (winner?.loadedEntry && information.storage.exists) { + const winnerEntry = winner.loadedEntry; + addActionButton(actions, $msg("Show revision history"), () => { + eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, { + file: path as FilePathWithPrefix, + fileOnDB: winnerEntry, + }); + }); + } + if ( + information.storage.exists && + information.database.conflictCount === 0 && + (!winner || winner.contentReadable) + ) { + addActionButton(actions, $msg("Use Vault file in local database"), async () => { + if (!(await storeStorageInDatabase(path))) { + Logger(`Failed to store the Vault file in the local database: ${path}`, LOG_LEVEL_NOTICE); + return; } - return el; - }) - ); + await refresh(); + }); + } + if ( + !information.storage.exists && + information.database.conflictCount === 0 && + winner?.loadedEntry + ) { + addActionButton(actions, $msg("Restore database winner to Vault"), async () => { + if (!(await applyWinnerToStorage(path, winner))) { + Logger(`Failed to restore the database winner to the Vault: ${path}`, LOG_LEVEL_NOTICE); + return; + } + await refresh(); + }); + } + addActionButton(actions, $msg("Copy database information"), async () => { + await copyFileDatabaseInfo(this.core, path); + }); }; - const checkBetweenStorageAndDatabase = async (file: FilePathWithPrefix, fileOnDB: LoadedEntry) => { - const dataContent = readAsBlob(fileOnDB); - const content = createBlob(await this.core.storageAccess.readHiddenFileBinary(file)); - if (await isDocContentSame(content, dataContent)) { - Logger(`Compare: SAME: ${file}`); - } else { - Logger(`Compare: CONTENT IS NOT MATCHED! ${file}`, LOG_LEVEL_NOTICE); - void addResult(file, file, fileOnDB); - } - }; new Setting(paneEl) - .setName("Recreate missing chunks for all files") - .setDesc("This will recreate chunks for all files. If there were missing chunks, this may fix the errors.") + .setName($msg("Recreate chunks for current Vault files")) + .setDesc( + $msg( + "Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content." + ) + ) .addButton((button) => button - .setButtonText("Recreate all") + .setButtonText($msg("Recreate current chunks")) .setCta() .onClick(async () => { await this.core.fileHandler.createAllChunks(true); @@ -240,40 +431,40 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, .setButtonText("Resolve All") .setCta() .onClick(async () => { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable." + ), + { + title: $msg("Resolve all conflicts by the newest version"), + defaultOption: "No", + } + )) === "yes"; + if (!confirmed) { + return; + } await this.services.conflict.resolveAllConflictedFilesByNewerOnes(); }) ); new Setting(paneEl) - .setName("Verify and repair all files") + .setName($msg("Verify and repair all files")) .setDesc( - "Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep." + $msg( + "Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision." + ) ) .addButton((button) => button - .setButtonText("Verify all") + .setButtonText($msg("Verify all")) .setDisabled(false) .setCta() .onClick(async () => { + resultArea.replaceChildren(); Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify"); - const ignorePatterns = getFileRegExp(this.core.settings, "syncInternalFilesIgnorePatterns"); - const targetPatterns = getFileRegExp(this.core.settings, "syncInternalFilesTargetPatterns"); this.core.localDatabase.clearCaches(); - Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify"); - const files = this.core.settings.syncInternalFiles - ? await this.core.storageAccess.getFilesIncludeHidden("/", targetPatterns, ignorePatterns) - : await this.core.storageAccess.getFileNames(); - const documents = [] as FilePath[]; - - const adn = this.core.localDatabase.findAllDocs(); - for await (const i of adn) { - const path = this.services.path.getPath(i); - if (path.startsWith(ICXHeader)) continue; - if (path.startsWith(PSCHeader)) continue; - if (!this.core.settings.syncInternalFiles && path.startsWith(ICHeader)) continue; - documents.push(stripAllPrefixes(path)); - } - const allPaths = [...new Set([...documents, ...files])]; + const allPaths = await collectFileDatabaseInfoPaths(this.core); let i = 0; const incProc = () => { i++; @@ -295,28 +486,21 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, : false; const fileOnStorage = stat != null ? stat : false; if (!(await this.services.vault.isTargetFile(path))) return incProc(); - const releaser = await semaphore.acquire(1); if (fileOnStorage && this.services.vault.isFileSizeTooLarge(fileOnStorage.size)) return incProc(); + const releaser = await semaphore.acquire(1); try { - const isHiddenFile = path.startsWith("."); - const dbPath = isHiddenFile ? addPrefix(path, ICHeader) : path; - const fileOnDB = await this.core.localDatabase.getDBEntry(dbPath); - if (fileOnDB && this.services.vault.isFileSizeTooLarge(fileOnDB.size)) + const inspection = await inspectFileRepair(this.core, path); + const winner = inspection.revisions.find(({ role }) => role === "winner"); + if ( + winner && + this.services.vault.isFileSizeTooLarge(winner.metadata.recordedSize) + ) return incProc(); - - if (!fileOnDB && fileOnStorage) { - Logger(`Compare: Not found on the local database: ${path}`, LOG_LEVEL_NOTICE); - void addResult(path, path, false); - return incProc(); - } - if (fileOnDB && !fileOnStorage) { - Logger(`Compare: Not found on the storage: ${path}`, LOG_LEVEL_NOTICE); - void addResult(path, false, fileOnDB); - return incProc(); - } - if (fileOnStorage && fileOnDB) { - await checkBetweenStorageAndDatabase(path, fileOnDB); + if (inspection.requiresAttention) { + addRepairResult(inspection); + } else { + Logger(`Compare: SAME: ${path}`); } } catch (ex) { Logger(`Error while processing ${path}`, LOG_LEVEL_NOTICE); @@ -335,7 +519,6 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, // Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed"); }) ); - const resultArea = paneEl.createDiv({ text: "" }); new Setting(paneEl) .setName("Check and convert non-path-obfuscated files") .setDesc("") diff --git a/src/serviceFeatures/fileDatabaseInfo.ts b/src/serviceFeatures/fileDatabaseInfo.ts new file mode 100644 index 00000000..40fc01e3 --- /dev/null +++ b/src/serviceFeatures/fileDatabaseInfo.ts @@ -0,0 +1,454 @@ +import { $msg } from "@/common/translation"; +import type { + FilePath, + FilePathWithPrefix, + LoadedEntry, + ObsidianLiveSyncSettings, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { getFileRegExp } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; +import { ICHeader, ICXHeader, PSCHeader } from "@vrtmrz/livesync-commonlib/compat/common/models/fileaccess.const"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import type { IPathService, IUIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService"; +import { addPrefix, stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; + +type DatabaseMeta = LoadedEntry & { + _rawStorageType: string | null; + _legacyBodyPresent: boolean; + _revs_info?: Array<{ + rev: string; + status: string; + }>; +}; + +export type FileDatabaseInfoCore = { + localDatabase: Pick< + LiveSyncLocalDB, + "allDocsRaw" | "findAllDocs" | "getDBEntryFromMeta" | "getDBEntry" | "localDatabase" + >; + services: { + path: Pick; + UI: IUIService; + }; + settings: ObsidianLiveSyncSettings; + storageAccess: Pick< + StorageAccess, + "getFileNames" | "getFilesIncludeHidden" | "isExistsIncludeHidden" | "statHidden" + >; +}; + +export type RevisionDatabaseInfo = { + documentId: string; + revision: string | null; + current: boolean; + deleted: boolean; + storageType: string; + storageLayout: "chunked" | "legacy-inline"; + ctime: number; + mtime: number; + recordedSize: number; + revisionHistory: Array<{ + revision: string; + status: string; + }>; + chunkReferences: number; + uniqueChunkReferences: number; + embeddedChunkReferences: number; + locallyStoredChunkReferences: number; + contentAvailableLocally: boolean; + chunks: Array<{ + id: string; + referenceCount: number; + embedded: boolean; + storedInLocalDatabase: boolean; + localDatabaseState: "available" | "deleted" | "missing"; + localDatabaseRevision: string | null; + }>; +}; + +export type FileDatabaseMergeBaseInfo = { + winnerRevision: string; + conflictRevision: string; + revision: string | null; + metadataAvailableLocally: boolean; + contentAvailableLocally: boolean; + missingChunkIds: string[]; + unavailableSharedRevisions: string[]; +}; + +export type FileDatabaseInfo = { + path: string; + databasePath: FilePathWithPrefix | FilePath; + storage: { + exists: boolean; + ctime?: number; + mtime?: number; + size?: number; + }; + database: { + source: "local database on this device"; + remoteQueried: false; + exists: boolean; + currentRevision: string | null; + conflictCount: number; + conflictRevisions: string[]; + unavailableConflictRevisions: string[]; + revisions: RevisionDatabaseInfo[]; + mergeBases: FileDatabaseMergeBaseInfo[]; + }; +}; + +const REPORT_WARNING = + "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted."; + +function toDatabasePath(path: string): FilePathWithPrefix | FilePath { + if (path.startsWith(".")) { + return addPrefix(path as FilePath, ICHeader); + } + return path as FilePath; +} + +type RawDatabaseDocument = { + _id: string; + _rev?: string; + _conflicts?: string[]; + _deleted?: boolean; + _revs_info?: Array<{ + rev: string; + status: string; + }>; + children?: string[]; + ctime?: number; + deleted?: boolean; + data?: string | string[]; + eden?: Record; + mtime?: number; + size?: number; + type?: string; +}; + +async function getLocalDatabaseMeta( + core: FileDatabaseInfoCore, + path: FilePathWithPrefix | FilePath, + options: PouchDB.Core.GetOptions +): Promise { + const documentId = await core.services.path.path2id(path); + let raw: RawDatabaseDocument; + try { + raw = await core.localDatabase.localDatabase.get(documentId, options); + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + throw error; + } + + if (raw.type === "leaf") { + return false; + } + if (raw.type && raw.type !== "notes" && raw.type !== "newnote" && raw.type !== "plain") { + return false; + } + + const rawStorageType = raw.type ?? null; + const legacy = rawStorageType === null || rawStorageType === "notes"; + const type = legacy ? "notes" : rawStorageType; + const legacyBodyPresent = + legacy && (typeof raw.data === "string" || (Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string"))); + return { + _id: raw._id, + _rev: raw._rev, + _conflicts: raw._conflicts, + _revs_info: raw._revs_info, + path, + data: legacyBodyPresent ? raw.data : "", + ctime: raw.ctime ?? 0, + mtime: raw.mtime ?? 0, + size: raw.size ?? 0, + children: type === "newnote" || type === "plain" ? (raw.children ?? []) : [], + datatype: type === "newnote" ? "newnote" : "plain", + deleted: raw.deleted ?? raw._deleted, + type, + eden: raw.eden ?? {}, + _rawStorageType: rawStorageType, + _legacyBodyPresent: legacyBodyPresent, + } as DatabaseMeta; +} + +async function collectRevisionDatabaseInfo( + core: FileDatabaseInfoCore, + meta: DatabaseMeta, + current: boolean +): Promise { + const legacy = meta._rawStorageType === null || meta._rawStorageType === "notes"; + const children = legacy ? [] : "children" in meta ? meta.children : []; + const uniqueChildren = [...new Set(children)]; + const referenceCounts = new Map(); + for (const child of children) { + referenceCounts.set(child, (referenceCounts.get(child) ?? 0) + 1); + } + const embeddedChildren = new Set( + Object.keys("eden" in meta && meta.eden ? meta.eden : {}).filter((id) => uniqueChildren.includes(id)) + ); + const localRows = + uniqueChildren.length === 0 + ? [] + : ( + await core.localDatabase.allDocsRaw({ + keys: uniqueChildren, + include_docs: false, + }) + ).rows; + const localChunkStates = new Map( + localRows + .filter((row) => "value" in row) + .map( + (row) => + [ + row.key, + { + state: row.value.deleted ? ("deleted" as const) : ("available" as const), + revision: row.value.rev, + }, + ] as const + ) + ); + + return { + documentId: meta._id, + revision: meta._rev ?? null, + current, + deleted: Boolean(meta.deleted ?? meta._deleted), + storageType: meta._rawStorageType ?? "absent", + storageLayout: legacy ? "legacy-inline" : "chunked", + ctime: meta.ctime, + mtime: meta.mtime, + recordedSize: meta.size, + revisionHistory: (meta._revs_info ?? []).map(({ rev, status }) => ({ + revision: rev, + status, + })), + chunkReferences: children.length, + uniqueChunkReferences: uniqueChildren.length, + embeddedChunkReferences: children.filter((id) => embeddedChildren.has(id)).length, + locallyStoredChunkReferences: children.filter((id) => localChunkStates.get(id)?.state === "available").length, + contentAvailableLocally: legacy + ? meta._legacyBodyPresent + : uniqueChildren.every( + (id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available" + ), + chunks: uniqueChildren.map((id) => { + const localState = localChunkStates.get(id); + return { + id, + referenceCount: referenceCounts.get(id) ?? 0, + embedded: embeddedChildren.has(id), + storedInLocalDatabase: localState?.state === "available", + localDatabaseState: localState?.state ?? "missing", + localDatabaseRevision: localState?.revision ?? null, + }; + }), + }; +} + +function revisionHistory(meta: DatabaseMeta): Array<{ revision: string; status: string }> { + const history = (meta._revs_info ?? []).map(({ rev, status }) => ({ + revision: rev, + status, + })); + if (meta._rev && !history.some(({ revision }) => revision === meta._rev)) { + history.unshift({ + revision: meta._rev, + status: "available", + }); + } + return history; +} + +function missingChunkIds(info: RevisionDatabaseInfo): string[] { + return info.chunks + .filter(({ embedded, localDatabaseState }) => !embedded && localDatabaseState !== "available") + .map(({ id }) => id); +} + +export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise { + const storageExists = await core.storageAccess.isExistsIncludeHidden(path); + const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null; + const databasePath = toDatabasePath(path); + const currentMeta = await getLocalDatabaseMeta(core, databasePath, { + conflicts: true, + revs: true, + revs_info: true, + }); + + const revisions: RevisionDatabaseInfo[] = []; + const conflictRevisions = currentMeta === false ? [] : (currentMeta._conflicts ?? []); + const unavailableConflictRevisions: string[] = []; + const mergeBases: FileDatabaseMergeBaseInfo[] = []; + const metadataByRevision = new Map(); + if (currentMeta !== false && currentMeta._rev) { + metadataByRevision.set(currentMeta._rev, currentMeta); + } + const getRevisionMeta = async (revision: string): Promise => { + const cached = metadataByRevision.get(revision); + if (cached !== undefined) { + return cached; + } + const meta = await getLocalDatabaseMeta(core, databasePath, { + rev: revision, + revs: true, + revs_info: true, + }); + metadataByRevision.set(revision, meta); + return meta; + }; + + if (currentMeta) { + revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true)); + for (const revision of conflictRevisions) { + const conflictMeta = await getRevisionMeta(revision); + if (conflictMeta) { + revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false)); + const winnerHistory = revisionHistory(currentMeta); + const conflictHistory = revisionHistory(conflictMeta); + const conflictHistoryByRevision = new Map( + conflictHistory.map(({ revision: historyRevision, status }) => [historyRevision, status]) + ); + const sharedHistory = winnerHistory.filter(({ revision: historyRevision }) => + conflictHistoryByRevision.has(historyRevision) + ); + const sharedRevision = sharedHistory[0]?.revision ?? null; + const unavailableSharedRevisions = sharedHistory + .filter( + ({ revision: historyRevision, status }) => + status !== "available" || + conflictHistoryByRevision.get(historyRevision) !== "available" + ) + .map(({ revision: historyRevision }) => historyRevision); + const sharedMeta = sharedRevision ? await getRevisionMeta(sharedRevision) : false; + const sharedInfo = sharedMeta + ? await collectRevisionDatabaseInfo(core, sharedMeta, false) + : undefined; + mergeBases.push({ + winnerRevision: currentMeta._rev ?? "", + conflictRevision: revision, + revision: sharedRevision, + metadataAvailableLocally: Boolean(sharedMeta), + contentAvailableLocally: sharedInfo?.contentAvailableLocally ?? false, + missingChunkIds: sharedInfo ? missingChunkIds(sharedInfo) : [], + unavailableSharedRevisions, + }); + } else { + unavailableConflictRevisions.push(revision); + } + } + } + + const report: FileDatabaseInfo = { + path, + databasePath, + storage: storageStat + ? { + exists: true, + ctime: storageStat.ctime, + mtime: storageStat.mtime, + size: storageStat.size, + } + : { + exists: false, + }, + database: { + source: "local database on this device", + remoteQueried: false, + exists: currentMeta !== false, + currentRevision: currentMeta ? (currentMeta._rev ?? null) : null, + conflictCount: conflictRevisions.length, + conflictRevisions, + unavailableConflictRevisions, + revisions, + mergeBases, + }, + }; + + return report; +} + +export async function readFileDatabaseRevisionLocally( + core: FileDatabaseInfoCore, + path: string, + revision: string +): Promise { + const databasePath = toDatabasePath(path); + const meta = await getLocalDatabaseMeta(core, databasePath, { + rev: revision, + revs: true, + revs_info: true, + }); + if (!meta) { + return false; + } + const info = await collectRevisionDatabaseInfo(core, meta, false); + if (info.deleted || !info.contentAvailableLocally) { + return false; + } + return await core.localDatabase.getDBEntryFromMeta(meta, false, false); +} + +export async function retryReadFileDatabaseRevision( + core: FileDatabaseInfoCore, + path: string, + revision: string +): Promise { + return await core.localDatabase.getDBEntry(toDatabasePath(path), { rev: revision }, false, true, true); +} + +export async function buildFileDatabaseInfoReport(core: FileDatabaseInfoCore, path: string): Promise { + const report = await inspectFileDatabaseInfo(core, path); + return `${$msg(REPORT_WARNING)} + +\`\`\`json +${JSON.stringify(report, null, 2)} +\`\`\``; +} + +export async function copyFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise { + const report = await buildFileDatabaseInfoReport(core, path); + return await core.services.UI.promptCopyToClipboard( + $msg("Database information for ${FILE}", { FILE: path }), + report + ); +} + +export async function collectFileDatabaseInfoPaths(core: FileDatabaseInfoCore): Promise { + const ignorePatterns = getFileRegExp(core.settings, "syncInternalFilesIgnorePatterns"); + const targetPatterns = getFileRegExp(core.settings, "syncInternalFilesTargetPatterns"); + const storagePaths = core.settings.syncInternalFiles + ? await core.storageAccess.getFilesIncludeHidden("/", targetPatterns, ignorePatterns) + : await core.storageAccess.getFileNames(); + const databasePaths: string[] = []; + + for await (const entry of core.localDatabase.findAllDocs()) { + const prefixedPath = entry.path; + if (prefixedPath.startsWith(ICXHeader) || prefixedPath.startsWith(PSCHeader)) { + continue; + } + if (!core.settings.syncInternalFiles && prefixedPath.startsWith(ICHeader)) { + continue; + } + databasePaths.push(stripAllPrefixes(prefixedPath)); + } + + return [...new Set([...storagePaths, ...databasePaths])].sort((left, right) => + left < right ? -1 : left > right ? 1 : 0 + ); +} + +export async function chooseAndCopyFileDatabaseInfo(core: FileDatabaseInfoCore): Promise { + const paths = await collectFileDatabaseInfoPaths(core); + const selected = await core.services.UI.confirm.askSelectString($msg("Choose a file to inspect"), paths); + if (!selected) { + return false; + } + return await copyFileDatabaseInfo(core, selected); +} diff --git a/src/serviceFeatures/fileDatabaseInfo.unit.spec.ts b/src/serviceFeatures/fileDatabaseInfo.unit.spec.ts new file mode 100644 index 00000000..1d1b6207 --- /dev/null +++ b/src/serviceFeatures/fileDatabaseInfo.unit.spec.ts @@ -0,0 +1,413 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildFileDatabaseInfoReport, + chooseAndCopyFileDatabaseInfo, + collectFileDatabaseInfoPaths, + inspectFileDatabaseInfo, + readFileDatabaseRevisionLocally, + retryReadFileDatabaseRevision, +} from "./fileDatabaseInfo"; + +async function* documents(paths: string[]) { + for (const path of paths) { + yield { + _id: `f:${path}`, + path, + }; + } +} + +function createCore() { + const current = { + _id: "f:note", + _rev: "3-current", + _conflicts: ["2-conflict"], + _revs_info: [ + { rev: "3-current", status: "available" }, + { rev: "2-parent", status: "missing" }, + ], + path: "note.md", + ctime: 100, + mtime: 300, + size: 42, + type: "plain", + datatype: "plain", + data: "secret current body", + children: ["h:private-current", "h:private-current", "h:private-embedded", "h:private-deleted"], + eden: { + "h:private-embedded": { + data: "secret embedded body", + epoch: 1, + }, + }, + }; + const conflict = { + ...current, + _rev: "2-conflict", + _conflicts: undefined, + _revs_info: [{ rev: "2-conflict", status: "available" }], + mtime: 200, + data: "secret conflict body", + children: ["h:private-missing"], + eden: {}, + }; + const promptCopyToClipboard = vi.fn(async (_title: string, _value: string) => true); + const askSelectString = vi.fn(async () => "db-only.md"); + const core = { + settings: { + syncInternalFiles: false, + syncInternalFilesIgnorePatterns: "", + syncInternalFilesTargetPatterns: "", + }, + storageAccess: { + isExistsIncludeHidden: vi.fn(async () => true), + statHidden: vi.fn(async () => ({ + ctime: 90, + mtime: 310, + size: 45, + type: "file", + })), + getFileNames: vi.fn(async () => ["z.md", "a.md"]), + getFilesIncludeHidden: vi.fn(async () => [".obsidian/app.json", "a.md"]), + }, + localDatabase: { + getDBEntryFromMeta: vi.fn(async (meta: typeof current) => ({ + ...meta, + data: ["loaded body"], + })), + getDBEntry: vi.fn(async () => current), + localDatabase: { + get: vi.fn(async (_id: string, options?: { rev?: string }) => + options?.rev === "2-conflict" ? conflict : current + ), + }, + allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({ + rows: [ + ...(keys.includes("h:private-current") + ? [ + { + id: "h:private-current", + key: "h:private-current", + value: { rev: "1-chunk" }, + }, + ] + : []), + ...(keys.includes("h:private-deleted") + ? [ + { + id: "h:private-deleted", + key: "h:private-deleted", + value: { rev: "4-deleted-chunk", deleted: true }, + }, + ] + : []), + ], + })), + findAllDocs: vi.fn(() => documents(["db-only.md", "i:.obsidian/app.json", "ix:ignore", "ps:setting"])), + }, + services: { + path: { + path2id: vi.fn(async () => "f:note"), + }, + UI: { + promptCopyToClipboard, + confirm: { + askSelectString, + }, + }, + }, + }; + return { + askSelectString, + conflict, + core, + current, + promptCopyToClipboard, + }; +} + +describe("file database information", () => { + it("reports document and chunk revisions without exposing file contents", async () => { + const { core } = createCore(); + + const report = await buildFileDatabaseInfoReport(core as never, "note.md"); + + expect(report).toContain('"path": "note.md"'); + expect(report).toContain('"documentId": "f:note"'); + expect(report).toContain('"revision": "3-current"'); + expect(report).toContain('"revision": "2-conflict"'); + expect(report).toContain('"storageType": "plain"'); + expect(report).toContain('"storageLayout": "chunked"'); + expect(report).toContain('"contentAvailableLocally": false'); + expect(report).toContain('"id": "h:private-current"'); + expect(report).toContain('"localDatabaseRevision": "1-chunk"'); + expect(report).toContain('"referenceCount": 2'); + expect(report).toContain('"id": "h:private-embedded"'); + expect(report).toContain('"embedded": true'); + expect(report).toContain('"id": "h:private-deleted"'); + expect(report).toContain('"localDatabaseState": "deleted"'); + expect(report).toContain('"localDatabaseRevision": "4-deleted-chunk"'); + expect(report).toContain('"id": "h:private-missing"'); + expect(report).toContain('"localDatabaseState": "missing"'); + expect(report).toContain('"localDatabaseRevision": null'); + expect(report).not.toContain("secret current body"); + expect(report).not.toContain("secret conflict body"); + expect(report).not.toContain("secret embedded body"); + }); + + it.each([ + { + name: "notes", + document: { + type: "notes", + data: "secret legacy body", + }, + storageType: "notes", + }, + { + name: "an absent type", + document: { + type: undefined, + data: ["secret", " legacy body"], + }, + storageType: "absent", + }, + ])("reports $name as legacy inline storage without exposing its body", async ({ document, storageType }) => { + const { core, current } = createCore(); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + ...document, + _conflicts: [], + children: ["h:must-not-be-treated-as-a-chunk"], + } as never); + + const info = await inspectFileDatabaseInfo(core as never, "note.md"); + const report = await buildFileDatabaseInfoReport(core as never, "note.md"); + + expect(info.database.revisions).toEqual([ + expect.objectContaining({ + storageType, + storageLayout: "legacy-inline", + chunkReferences: 0, + contentAvailableLocally: true, + }), + ]); + expect(report).not.toContain("secret legacy body"); + expect(report).not.toContain("h:must-not-be-treated-as-a-chunk"); + }); + + it("reports the exact shared ancestor and its missing chunks for each conflict", async () => { + const { conflict, core, current } = createCore(); + const parent = { + ...current, + _rev: "2-parent", + _conflicts: undefined, + _revs_info: [ + { rev: "2-parent", status: "available" }, + { rev: "1-root", status: "missing" }, + ], + children: ["h:missing-parent"], + eden: {}, + }; + core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => { + if (options?.rev === "2-conflict") { + return { + ...conflict, + _revs_info: [ + { rev: "2-conflict", status: "available" }, + { rev: "2-parent", status: "available" }, + { rev: "1-root", status: "missing" }, + ], + }; + } + if (options?.rev === "2-parent") { + return parent; + } + return { + ...current, + _revs_info: [ + { rev: "3-current", status: "available" }, + { rev: "2-parent", status: "available" }, + { rev: "1-root", status: "missing" }, + ], + }; + }); + + const info = await inspectFileDatabaseInfo(core as never, "note.md"); + + expect(info.database.mergeBases).toEqual([ + { + winnerRevision: "3-current", + conflictRevision: "2-conflict", + revision: "2-parent", + metadataAvailableLocally: true, + contentAvailableLocally: false, + missingChunkIds: ["h:missing-parent"], + unavailableSharedRevisions: ["1-root"], + }, + ]); + }); + + it("does not decode a revision whose chunks are not all available locally", async () => { + const { core } = createCore(); + + await expect(readFileDatabaseRevisionLocally(core as never, "note.md", "3-current")).resolves.toBe(false); + + expect(core.localDatabase.getDBEntryFromMeta).not.toHaveBeenCalled(); + }); + + it("decodes an exact revision after confirming that every chunk is available locally", async () => { + const { core, current } = createCore(); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + children: ["h:available"], + eden: {}, + } as never); + core.localDatabase.allDocsRaw.mockResolvedValue({ + rows: [ + { + id: "h:available", + key: "h:available", + value: { rev: "1-available" }, + }, + ], + }); + + await expect(readFileDatabaseRevisionLocally(core as never, "note.md", "3-current")).resolves.toEqual( + expect.objectContaining({ + data: ["loaded body"], + }) + ); + + expect(core.localDatabase.getDBEntryFromMeta).toHaveBeenCalledWith( + expect.objectContaining({ + _rev: "3-current", + }), + false, + false + ); + }); + + it("retries an exact revision through the configured chunk retrieval path", async () => { + const { core } = createCore(); + + await retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict"); + + expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith( + "note.md", + { rev: "2-conflict" }, + false, + true, + true + ); + }); + + it("reports the exact revision as locally available after retry recovers its missing chunk", async () => { + const { conflict, core } = createCore(); + let recovered = false; + core.localDatabase.getDBEntry.mockImplementation(async () => { + recovered = true; + return conflict as never; + }); + core.localDatabase.allDocsRaw.mockImplementation(async ({ keys }: { keys: string[] }) => ({ + rows: + recovered && keys.includes("h:private-missing") + ? [ + { + id: "h:private-missing", + key: "h:private-missing", + value: { rev: "1-recovered" }, + }, + ] + : [], + })); + + await expect( + retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict") + ).resolves.not.toBe(false); + const information = await inspectFileDatabaseInfo(core as never, "note.md"); + + expect( + information.database.revisions.find(({ revision }) => revision === "2-conflict") + ).toEqual( + expect.objectContaining({ + contentAvailableLocally: true, + chunks: [ + expect.objectContaining({ + id: "h:private-missing", + localDatabaseState: "available", + localDatabaseRevision: "1-recovered", + }), + ], + }) + ); + }); + + it("keeps the exact revision identifiers when conflict metadata is unavailable", async () => { + const { conflict, core, current } = createCore(); + core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => { + if (options?.rev === "2-unavailable") { + throw Object.assign(new Error("missing"), { status: 404 }); + } + if (options?.rev === "2-conflict") { + return conflict; + } + return { ...current, _conflicts: ["2-conflict", "2-unavailable"] }; + }); + + const report = await buildFileDatabaseInfoReport(core as never, "note.md"); + + expect(report).toContain('"conflictRevisions"'); + expect(report).toContain('"2-conflict"'); + expect(report).toContain('"2-unavailable"'); + expect(report).toContain('"unavailableConflictRevisions"'); + }); + + it("reads an existing local document even when current synchronisation filters exclude its path", async () => { + const { core, current } = createCore(); + core.services.path.path2id.mockResolvedValue("f:ignored"); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + _id: "f:ignored", + _rev: "5-ignored", + _conflicts: [], + _revs_info: [], + path: "ignored.md", + ctime: 10, + mtime: 20, + size: 30, + children: [], + }); + + const report = await buildFileDatabaseInfoReport(core as never, "ignored.md"); + + expect(report).toContain('"exists": true'); + expect(report).toContain('"documentId": "f:ignored"'); + expect(report).toContain('"revision": "5-ignored"'); + }); + + it("offers the union of storage and database paths and excludes inactive internal namespaces", async () => { + const { core } = createCore(); + + await expect(collectFileDatabaseInfoPaths(core as never)).resolves.toEqual(["a.md", "db-only.md", "z.md"]); + + core.settings.syncInternalFiles = true; + await expect(collectFileDatabaseInfoPaths(core as never)).resolves.toEqual([ + ".obsidian/app.json", + "a.md", + "db-only.md", + ]); + }); + + it("copies the selected file report through the existing copy dialogue", async () => { + const { askSelectString, core, promptCopyToClipboard } = createCore(); + + await expect(chooseAndCopyFileDatabaseInfo(core as never)).resolves.toBe(true); + + expect(askSelectString).toHaveBeenCalledWith("Choose a file to inspect", ["a.md", "db-only.md", "z.md"]); + expect(promptCopyToClipboard).toHaveBeenCalledWith( + "Database information for db-only.md", + expect.stringContaining('"path": "db-only.md"') + ); + }); +}); diff --git a/src/serviceFeatures/fileRepair.ts b/src/serviceFeatures/fileRepair.ts new file mode 100644 index 00000000..f5cef48b --- /dev/null +++ b/src/serviceFeatures/fileRepair.ts @@ -0,0 +1,109 @@ +import type { LoadedEntry } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createBlob, isDocContentSame, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; +import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; +import { + inspectFileDatabaseInfo, + readFileDatabaseRevisionLocally, + type FileDatabaseInfo, + type FileDatabaseInfoCore, + type RevisionDatabaseInfo, +} from "./fileDatabaseInfo"; + +export type FileRepairCore = FileDatabaseInfoCore & { + fileHandler: Pick; + storageAccess: FileDatabaseInfoCore["storageAccess"] & Pick; +}; + +export type FileRepairRevision = { + role: "winner" | "conflict"; + metadata: RevisionDatabaseInfo; + contentReadable: boolean; + contentMatchesStorage: boolean | null; + loadedEntry: LoadedEntry | false; +}; + +export type FileRepairInspection = { + information: FileDatabaseInfo; + revisions: FileRepairRevision[]; + requiresAttention: boolean; +}; + +export type DiscardUnreadableRevisionResult = + | "discarded" + | "failed" + | "no-longer-live" + | "revision-is-readable"; + +export async function inspectFileRepair(core: FileRepairCore, path: string): Promise { + const information = await inspectFileDatabaseInfo(core, path); + const storageContent = information.storage.exists + ? createBlob(await core.storageAccess.readHiddenFileBinary(path)) + : undefined; + const revisions: FileRepairRevision[] = []; + + for (const metadata of information.database.revisions) { + const loadedEntry = + metadata.deleted || !metadata.contentAvailableLocally + ? false + : await readFileDatabaseRevisionLocally(core, path, metadata.revision ?? ""); + const contentReadable = metadata.deleted || loadedEntry !== false; + const contentMatchesStorage = + storageContent && loadedEntry !== false + ? await isDocContentSame(storageContent, readAsBlob(loadedEntry)) + : null; + revisions.push({ + role: metadata.current ? "winner" : "conflict", + metadata, + contentReadable, + contentMatchesStorage, + loadedEntry, + }); + } + + const winner = revisions.find(({ role }) => role === "winner"); + const databaseAndStorageDiffer = + information.storage.exists !== information.database.exists || + (information.storage.exists && + winner !== undefined && + (winner.metadata.deleted || winner.contentMatchesStorage === false)) || + (!information.storage.exists && winner !== undefined && !winner.metadata.deleted); + const unreadableLiveRevision = + information.database.unavailableConflictRevisions.length > 0 || + revisions.some(({ contentReadable }) => !contentReadable); + const requiresAttention = + databaseAndStorageDiffer || + information.database.conflictCount > 0 || + unreadableLiveRevision || + (information.database.exists && winner === undefined); + + return { + information, + revisions, + requiresAttention, + }; +} + +export async function discardUnreadableLiveRevision( + core: FileRepairCore, + path: string, + revision: string +): Promise { + const latest = await inspectFileDatabaseInfo(core, path); + const liveRevisions = [ + latest.database.currentRevision, + ...latest.database.conflictRevisions, + ].filter((candidate): candidate is string => candidate !== null); + if (!liveRevisions.includes(revision)) { + return "no-longer-live"; + } + + const metadata = latest.database.revisions.find((candidate) => candidate.revision === revision); + const metadataUnavailable = latest.database.unavailableConflictRevisions.includes(revision); + if (!metadataUnavailable && (metadata?.deleted || metadata?.contentAvailableLocally)) { + return "revision-is-readable"; + } + + const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); + return deleted ? "discarded" : "failed"; +} diff --git a/src/serviceFeatures/fileRepair.unit.spec.ts b/src/serviceFeatures/fileRepair.unit.spec.ts new file mode 100644 index 00000000..9d066877 --- /dev/null +++ b/src/serviceFeatures/fileRepair.unit.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { + discardUnreadableLiveRevision, + inspectFileRepair, +} from "./fileRepair"; + +function createCore() { + const current = { + _id: "f:note", + _rev: "3-current", + _conflicts: ["2-conflict"], + _revs_info: [{ rev: "3-current", status: "available" }], + path: "note.md", + ctime: 1, + mtime: 3, + size: 7, + type: "plain", + children: ["h:current"], + eden: {}, + }; + const conflict = { + ...current, + _rev: "2-conflict", + _conflicts: undefined, + _revs_info: [{ rev: "2-conflict", status: "available" }], + mtime: 2, + children: ["h:missing-conflict"], + }; + const deleteRevisionFromDB = vi.fn(async () => true); + const core = { + settings: { + syncInternalFiles: false, + syncInternalFilesIgnorePatterns: "", + syncInternalFilesTargetPatterns: "", + }, + storageAccess: { + isExistsIncludeHidden: vi.fn(async () => true), + statHidden: vi.fn(async () => ({ + ctime: 1, + mtime: 3, + size: 7, + type: "file", + })), + readHiddenFileBinary: vi.fn(async () => new TextEncoder().encode("current").buffer), + getFileNames: vi.fn(async () => ["note.md"]), + getFilesIncludeHidden: vi.fn(async () => ["note.md"]), + }, + localDatabase: { + localDatabase: { + get: vi.fn(async (_id: string, options?: { rev?: string }) => + options?.rev === "2-conflict" ? conflict : current + ), + }, + allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({ + rows: keys.includes("h:current") + ? [ + { + id: "h:current", + key: "h:current", + value: { rev: "1-current" }, + }, + ] + : [], + })), + getDBEntryFromMeta: vi.fn(async (meta: typeof current) => ({ + ...meta, + data: [meta._rev === "3-current" ? "current" : "conflict"], + })), + getDBEntry: vi.fn(async () => false), + findAllDocs: vi.fn(async function* () { + yield current; + }), + }, + fileHandler: { + deleteRevisionFromDB, + }, + services: { + path: { + path2id: vi.fn(async () => "f:note"), + }, + UI: { + confirm: {}, + }, + }, + }; + return { + conflict, + core, + current, + deleteRevisionFromDB, + }; +} + +describe("file repair inspection", () => { + it("shows the winner and every conflict revision independently", async () => { + const { core } = createCore(); + + const inspection = await inspectFileRepair(core as never, "note.md"); + + expect(inspection.revisions).toEqual([ + expect.objectContaining({ + role: "winner", + contentReadable: true, + contentMatchesStorage: true, + metadata: expect.objectContaining({ + revision: "3-current", + }), + }), + expect.objectContaining({ + role: "conflict", + contentReadable: false, + contentMatchesStorage: null, + metadata: expect.objectContaining({ + revision: "2-conflict", + }), + }), + ]); + expect(inspection.requiresAttention).toBe(true); + }); + + it("rechecks liveness and readability before discarding an exact revision", async () => { + const { core, deleteRevisionFromDB } = createCore(); + + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") + ).resolves.toBe("discarded"); + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "3-current") + ).resolves.toBe("revision-is-readable"); + + expect(deleteRevisionFromDB).toHaveBeenCalledOnce(); + expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "2-conflict"); + }); + + it("allows an exact unreadable generation-one winner to be discarded explicitly", async () => { + const { core, current, deleteRevisionFromDB } = createCore(); + current._rev = "1-root"; + current._conflicts = []; + current.children = ["h:missing-root"]; + core.localDatabase.allDocsRaw.mockResolvedValue({ rows: [] }); + + const inspection = await inspectFileRepair(core as never, "note.md"); + + expect(inspection.revisions).toEqual([ + expect.objectContaining({ + role: "winner", + contentReadable: false, + metadata: expect.objectContaining({ + revision: "1-root", + }), + }), + ]); + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "1-root") + ).resolves.toBe("discarded"); + expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "1-root"); + }); + + it("refuses to discard a revision which stopped being a live leaf", async () => { + const { core, current, deleteRevisionFromDB } = createCore(); + core.localDatabase.localDatabase.get.mockResolvedValue({ + ...current, + _conflicts: [], + }); + + await expect( + discardUnreadableLiveRevision(core as never, "note.md", "2-conflict") + ).resolves.toBe("no-longer-live"); + + expect(deleteRevisionFromDB).not.toHaveBeenCalled(); + }); +}); diff --git a/styles.css b/styles.css index 273fe736..3d63babd 100644 --- a/styles.css +++ b/styles.css @@ -595,6 +595,49 @@ body.is-mobile .livesync-compatibility-review-notice { word-break: break-all; } +.sls-repair-results { + display: grid; + gap: var(--size-4-3); +} + +.sls-repair-result { + padding: var(--size-4-3); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-secondary); +} + +.sls-repair-revision { + margin-top: var(--size-4-2); + padding: var(--size-4-2); + border-left: 3px solid var(--background-modifier-border); + background: var(--background-primary-alt); +} + +.sls-repair-revision-title { + font-weight: var(--font-semibold); + overflow-wrap: anywhere; +} + +.sls-repair-revision code { + display: block; + margin-top: var(--size-4-1); + overflow-wrap: anywhere; + white-space: normal; +} + +.sls-repair-ancestor-warning { + margin-top: var(--size-4-2); + color: var(--text-warning); +} + +.sls-repair-actions { + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); + margin-top: var(--size-4-2); +} + /* Diff navigation */ .diff-options-row { display: flex; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 06b05ac2..f06c7b57 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -93,7 +93,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe `test:e2e:obsidian:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. -`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. +`test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. `test:e2e:obsidian:couchdb-upload` reuses the CouchDB variables from `.test.env` or the process environment. It expects a reachable CouchDB service, creates a unique database, starts from configured plug-in data without the device-local compatibility marker, and verifies the copied-or-restored Vault explanation in the actual compatibility dialogue. It captures the summary and details, resumes explicitly, confirms that the marker was recorded, creates a note in real Obsidian, commits the note into the local database, runs one-shot synchronisation, and verifies that the remote database contains both the metadata document and its chunk documents. @@ -136,6 +136,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- `test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. +`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. + `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. `test:e2e:obsidian:customisation-sync` runs a two-vault Customisation Sync workflow. It scans a real snippet CSS file, config JSON file, and sample plug-in fixture into per-file Customisation Sync data, synchronises the entries through CouchDB, applies them on the second vault, verifies the resulting `.obsidian` files, propagates a snippet update, and verifies deletion of the source-vault snippet sync data without confusing it with the target vault's own applied copy. @@ -187,6 +189,7 @@ Useful environment variables: - `E2E_OBSIDIAN_SKIP_EXTRACT=true`: download the AppImage without extracting it. - `E2E_OBSIDIAN_SMOKE_TIMEOUT_MS`: smoke timeout in milliseconds. - `E2E_OBSIDIAN_DIALOG_TIMEOUT_MS`: timeout for a representative Svelte dialogue to mount, expose its principal controls, and close; default is 10 seconds. +- `E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS`: timeout for each visible revision-repair control and result; default is 15 seconds. - `E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS`: timeout for the settings pane and its deletion controls to become visible; default is 10 seconds. - `E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS`: timeout for Review Harness view and action boundaries; default is 15 seconds. - `E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS`: timeout for the P2P status pane and its principal connection control; default is 10 seconds. diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts index c12586e1..f2109068 100644 --- a/test/e2e-obsidian/scripts/dialog-mounts.ts +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -722,7 +722,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); for (const label of [ "Write logs into the file", - "Recreate missing chunks for all files", + "Recreate chunks for current Vault files", "Verify and repair all files", ]) { await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({ @@ -730,7 +730,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { timeout: uiTimeoutMs, }); } - await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).waitFor({ + await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); @@ -739,7 +739,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { timeout: uiTimeoutMs, }); await liveSyncSettings - .locator(".setting-item-name", { hasText: "Recreate missing chunks for all files" }) + .locator(".setting-item-name", { hasText: "Recreate chunks for current Vault files" }) .scrollIntoViewIfNeeded(); return liveSyncSettings; } diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index de2ef89d..9dfc225f 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -15,6 +15,7 @@ const testSteps: Step[] = [ { name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] }, { name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] }, { name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] }, + { name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] }, { name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] }, { name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] }, { name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] }, diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts new file mode 100644 index 00000000..405c20c9 --- /dev/null +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -0,0 +1,334 @@ +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const path = "revision-repair.md"; +const baseContent = "Revision repair\n\nShared base.\n"; +const branchContents = [ + `Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`, + `Revision repair\n\nRight branch.\n${"R".repeat(4096)}\n`, +] as const; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS ?? 15000); + +type BrokenRevisionFixture = { + winnerRevision: string; + conflictRevision: string; + missingChunkId: string; +}; + +type RevisionTree = { + winnerRevision: string; + conflictRevisions: string[]; +}; + +type ObsidianSettingsController = { + open(): void; + openTabById(tabId: string): void; +}; + +type ObsidianTestGlobal = typeof globalThis & { + app?: { + setting?: ObsidianSettingsController; + }; +}; + +async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(baseContent)};`, + "let file=app.vault.getAbstractFileByPath(path);", + "if(!file) file=await app.vault.create(path,content);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function createBrokenConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + baseRevision: string +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const baseRevision=${JSON.stringify(baseRevision)};`, + `const contents=${JSON.stringify(branchContents)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "for(const [index,content] of contents.entries()){", + " const blob=new Blob([content],{type:'text/plain'});", + " const now=Date.now()+index;", + " const result=await core.localDatabase.putDBEntry({", + " _id:id,path,data:blob,ctime:now,mtime:now,", + " size:(await blob.arrayBuffer()).byteLength,children:[],", + " datatype:'plain',type:'plain',eden:{},", + " },false,baseRevision);", + " if(!result?.ok) throw new Error(`Could not create repair conflict: ${path}`);", + "}", + "const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});", + "const conflictRevision=tree._conflicts?.[0];", + "if(!tree._rev||!conflictRevision){", + " throw new Error(`Repair fixture did not produce two live revisions: ${path}`);", + "}", + "const conflict=await core.localDatabase.localDatabase.get(id,{rev:conflictRevision});", + "const embedded=new Set(Object.keys(conflict.eden??{}));", + "const missingChunkId=(conflict.children??[]).find((child)=>!embedded.has(child));", + "if(!missingChunkId){", + " throw new Error(`Repair fixture did not create an independent chunk: ${conflictRevision}`);", + "}", + "const chunk=await core.localDatabase.localDatabase.get(missingChunkId);", + "await core.localDatabase.localDatabase.remove(chunk);", + "core.localDatabase.clearCaches();", + "const unreadable=await core.localDatabase.getDBEntry(path,{rev:conflictRevision},false,true,true);", + "if(unreadable!==false){", + " throw new Error(`The selected revision remained readable after its chunk was removed: ${conflictRevision}`);", + "}", + "return JSON.stringify({", + " winnerRevision:tree._rev,", + " conflictRevision,", + " missingChunkId,", + "});", + "})()", + ].join(""), + env + ); +} + +async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const id=await core.services.path.path2id(path);", + "const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});", + "return JSON.stringify({", + " winnerRevision:tree._rev,", + " conflictRevisions:tree._conflicts??[],", + "});", + "})()", + ].join(""), + env + ); +} + +async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "core.localDatabase.clearCaches();", + "await core.services.conflict.queueCheckFor(path);", + "await core.services.conflict.ensureAllProcessed();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function main(): Promise { + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const cliBinary = cli.binary; + const vault = await createTemporaryVault("obsidian-livesync-revision-repair-"); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + isConfigured: true, + liveSync: false, + remoteType: "", + couchDB_URI: "", + couchDB_DBNAME: "", + couchDB_USER: "", + couchDB_PASSWORD: "", + remoteConfigurations: {}, + activeConfigurationId: "", + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + disableMarkdownAutoMerge: true, + showMergeDialogOnlyOnActive: true, + useEden: false, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev); + + await requestConflictCheck(cliBinary, session.cliEnv); + const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv); + if ( + afterAutomaticCheck.winnerRevision !== fixture.winnerRevision || + !afterAutomaticCheck.conflictRevisions.includes(fixture.conflictRevision) + ) { + throw new Error( + `Automatic conflict checking discarded the unreadable revision: ${JSON.stringify({ + fixture, + afterAutomaticCheck, + })}` + ); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.evaluate(() => { + const setting = (globalThis as ObsidianTestGlobal).app?.setting; + if (setting === undefined) throw new Error("Obsidian settings are unavailable"); + setting.open(); + setting.openTabById("obsidian-livesync"); + }); + const settings = page.locator(".sls-setting"); + await settings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); + const verifySetting = settings.locator(".setting-item").filter({ + has: page.getByText("Verify and repair all files", { exact: true }), + }); + await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({ + timeout: uiTimeoutMs, + }); + const card = settings.locator(".sls-repair-result").filter({ hasText: path }); + await card.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const brokenRevision = card + .locator(".sls-repair-revision") + .filter({ hasText: fixture.conflictRevision }); + await brokenRevision + .getByText(/Unreadable on this device/u) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await card.locator(".sls-repair-revision").count()) !== 2) { + throw new Error("Verify and Repair did not render the winner and conflict revision separately."); + } + + await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({ + timeout: uiTimeoutMs, + }); + await settings + .locator(".sls-repair-result") + .filter({ hasText: path }) + .locator(".sls-repair-revision") + .filter({ hasText: fixture.conflictRevision }) + .getByText(/Unreadable on this device/u) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + const afterRetry = await readRevisionTree(cliBinary, session.cliEnv); + if (!afterRetry.conflictRevisions.includes(fixture.conflictRevision)) { + throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`); + } + + const screenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-unreadable-conflict.png", + (page) => page.locator(".sls-repair-result").filter({ hasText: path }) + ); + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + const brokenRevision = () => + settings + .locator(".sls-repair-result") + .filter({ hasText: path }) + .locator(".sls-repair-revision") + .filter({ hasText: fixture.conflictRevision }); + await brokenRevision() + .getByRole("button", { name: "Discard unreadable revision", exact: true }) + .click({ timeout: uiTimeoutMs }); + const confirmation = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs }); + await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv); + if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) { + throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + const brokenRevision = settings + .locator(".sls-repair-result") + .filter({ hasText: path }) + .locator(".sls-repair-revision") + .filter({ hasText: fixture.conflictRevision }); + await brokenRevision + .getByRole("button", { name: "Discard unreadable revision", exact: true }) + .click({ timeout: uiTimeoutMs }); + const confirmation = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + await settings + .locator(".sls-repair-revision") + .filter({ hasText: fixture.conflictRevision }) + .waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv); + if ( + afterDiscard.winnerRevision !== fixture.winnerRevision || + afterDiscard.conflictRevisions.length !== 0 + ) { + throw new Error( + `Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({ + fixture, + afterDiscard, + })}` + ); + } + + console.log( + "Real Obsidian kept an unreadable conflict revision through automatic checking and retry, rendered every live revision separately, required confirmation, and discarded only the selected revision." + ); + console.log(`Repair screenshot: ${screenshot}`); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index f9e7dd93..7f3274d1 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -8,6 +8,7 @@ const focusedScenarios = new Set([ "smoke", "onboarding-invitation", "dialog-mounts", + "revision-repair", "settings-ui", "review-harness", "p2p-pane", diff --git a/updates.md b/updates.md index 27f20885..bacce5b9 100644 --- a/updates.md +++ b/updates.md @@ -14,6 +14,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved +- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. - P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. @@ -23,12 +24,13 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Fixed +- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. - Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. - Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. ### Testing -- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. +- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. ## 1.0.0-beta.2 From 20fb027055e904c97ac8a6d7f688ba894619f8e9 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 17:09:22 +0000 Subject: [PATCH 146/170] Protect conflict chunks during garbage collection --- docs/recovery.md | 2 +- docs/settings.md | 8 +- docs/specs_conflict_resolution.md | 2 + docs/specs_garbage_collection.md | 63 ++++++++ docs/troubleshooting.md | 2 +- .../CmdLocalDatabaseMainte.ts | 39 ++--- .../CmdLocalDatabaseMainte.unit.spec.ts | 135 +++++++++++++++++- .../SettingDialogue/PaneMaintenance.ts | 2 +- updates.md | 3 +- 9 files changed, 220 insertions(+), 36 deletions(-) create mode 100644 docs/specs_garbage_collection.md diff --git a/docs/recovery.md b/docs/recovery.md index 9d1227af..1d98ce04 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -69,7 +69,7 @@ Garbage Collection removes unreferenced chunks while preserving the current data - all relevant devices have synchronised; and - the remaining historical and deletion state is understood. -Deleted documents and tombstones are not free, and historical revisions may keep chunks reachable. Garbage Collection therefore cannot promise the smallest possible remote. +Deleted documents, tombstones, live conflicts, and retained metadata are not free. Live conflict branches keep the chunks needed for review, while an ordinary superseded linear revision does not protect its former chunks. Garbage Collection can therefore make old content unreadable and cannot promise the smallest possible remote. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it. Rebuild is a different operation. It reconstructs the database from a chosen authoritative state and is the more certain way to remove unwanted history or repair a damaged remote, but it is also more disruptive and can discard changes which exist only elsewhere. diff --git a/docs/settings.md b/docs/settings.md index c50558fe..08e545a4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -12,7 +12,7 @@ The following status applies to optional and compatibility features in the 1.0 l | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Supported, opt-in | Peer-to-Peer Synchronisation, Hidden File Sync, and Customisation Sync | Maintained and covered by focused real-runtime tests. Enable them only where their separate setup and operational constraints are acceptable. | | Maintained, advanced | Data Compression | Available as an explicit storage and bandwidth trade-off. It remains disabled by default because the measured processing and memory costs outweigh the mixed-dataset saving. | -| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. | +| Beta or experimental | JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 for CouchDB | Retained for explicit testing and specialised use. They remain disabled by default and are not part of the minimum supported setup. | | Compatibility only | V1 dynamic iteration counts, the old IndexedDB adapter, non-current hash algorithms, Eden chunks, and the stored `doNotUseFixedRevisionForChunks` key | Existing settings and data remain readable. New Vaults use the current defaults, and compatibility controls are shown only where a migration or recovery path still needs them. | | Icon | Description | @@ -1047,6 +1047,12 @@ Purge all download/upload cache. Delete all data on the remote server. +### 6. Garbage Collection V3 (CouchDB only) + +Garbage Collection V3 identifies chunk documents which are not reachable from any current file or live conflict branch, creates logical deletions for those chunks locally, propagates the deletions to CouchDB, and requests remote compaction. + +Use it only when the Vault, local database, and remote are healthy, and every relevant device has synchronised. It can make an ordinary superseded file revision unreadable when no live state still needs its chunks. It does not repair corruption or replace a deliberate rebuild. See the [Garbage Collection V3 specification](specs_garbage_collection.md). + ### 7. Reset #### Delete local database to reset or uninstall Self-hosted LiveSync diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md index cbeb048c..f74c137d 100644 --- a/docs/specs_conflict_resolution.md +++ b/docs/specs_conflict_resolution.md @@ -60,6 +60,8 @@ Logical deletion does not recreate missing bytes, purge the document history, or **Recreate chunks for current Vault files** can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision. +Garbage Collection V3 treats every live conflict revision and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the [Garbage Collection V3 specification](specs_garbage_collection.md). + A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that live revision. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted. ### Two devices independently create the same path diff --git a/docs/specs_garbage_collection.md b/docs/specs_garbage_collection.md new file mode 100644 index 00000000..c10647b5 --- /dev/null +++ b/docs/specs_garbage_collection.md @@ -0,0 +1,63 @@ +# Garbage Collection V3 + +Garbage Collection V3 is a beta maintenance operation for CouchDB remotes. It removes chunk documents which are no longer required by the current local revision tree, propagates those logical deletions to CouchDB, and then requests remote compaction. + +It is not a repair operation. Use it only when the Vault, the local LiveSync database, and the CouchDB remote are healthy, every relevant device has synchronised, and recoverable backups exist. + +## Supported scope + +Garbage Collection V3 is available in Edge Case mode for CouchDB. It is not offered for Object Storage or P2P: + +- Object Storage has a different journal and object lifecycle. +- P2P has no central database to compact and cannot provide the accepted-device progress information required by this workflow. + +The operation requires **Fetch chunks on demand** to be off so that its local reachability result is not confused with chunks which exist only on the remote. + +## Workflow + +After the user starts Garbage Collection V3, LiveSync: + +1. completes a one-shot bidirectional CouchDB synchronisation; +2. reads the accepted-device list and current progress recorded on the remote; +3. warns when an accepted device has no current information or device progress differs, then requires explicit confirmation; +4. computes the chunks reachable from the local PouchDB revision tree; +5. creates a logical deletion for each locally present chunk which is not reachable; +6. completes a push-only replication so that those deletions reach CouchDB; +7. requests CouchDB compaction and waits for it for up to two minutes; and +8. clears the local chunk caches. + +If the initial synchronisation, device inspection, or confirmation fails, the workflow stops before collection. If push-only replication fails, the local logical deletions have already been created, but remote compaction is not started; synchronise again before retrying or using another device. A compaction failure is reported separately. + +## Reachability rules + +A chunk remains reachable when it is referenced by any of the following: + +- the current database winner for a file; +- any other live conflict revision for that file; +- an available revision on either side of a live conflict which is required to describe the divergence; or +- the nearest available revision shared by both live conflict branches. + +Chunk identifiers are content-derived and shared between files. Reachability is therefore collected into one set across the database. A chunk used by two or more current files remains protected even when one file is updated or deleted. + +An ordinary superseded linear revision does not protect its former chunks. Once no current file or live conflict branch references a chunk, it can be collected. After a conflict is resolved, chunks unique to the discarded branch and to no-longer-needed merge ancestry can also become eligible. + +## Consequences + +Garbage Collection deliberately trades historical recoverability for storage. A metadata revision may remain in the revision tree after a chunk which only that superseded revision used has been collected, so that historical body can become unreadable. Remote compaction can then discard old CouchDB revision bodies. Tombstones and retained metadata also consume storage, so the operation does not promise the smallest possible database. + +Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available. + +Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Verify and repair all files**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required. + +## Verification + +Commonlib tests use real in-memory PouchDB revision trees to verify: + +- collection eligibility after a normal file update; +- protection of chunks shared by multiple current files; +- protection of all live conflict branches and their nearest available shared ancestor; +- eligibility of losing-branch and ancestor-only chunks after conflict resolution; +- propagation of chunk deletion to another PouchDB database; and +- recreation and propagation when the same content is written again. + +Self-hosted LiveSync tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction. CouchDB's own compaction implementation remains an external database boundary. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ee85985e..82931923 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -129,7 +129,7 @@ Browser security errors, particularly CORS failures, may reach the plug-in only LiveSync stores file metadata, chunks, revision history, conflicts, deletions, and tombstones. Deleting or shortening a file therefore does not immediately remove every object which once represented it. -Garbage Collection can remove unreferenced chunks, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Tombstones and retained revisions are not free, so Garbage Collection does not guarantee a minimal database. +Garbage Collection V3 can remove unreferenced chunks from a healthy CouchDB setup, but it is appropriate only when the Vault and local database are healthy and all relevant devices have synchronised. Current files and live conflict branches protect their required chunks; an ordinary superseded revision does not. Tombstones and retained metadata are not free, so Garbage Collection does not guarantee a minimal database. Review the [Garbage Collection V3 specification](specs_garbage_collection.md) before using it. `Overwrite Server Data with This Device's Files` is a separate rebuild operation and is the more certain way to reconstruct a central remote from a chosen authoritative Vault. It is also destructive and may discard changes which exist only on another device. Review [Recovery and flag files](recovery.md#garbage-collection-is-not-rebuild) before choosing between them. diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts index 37a9c7e5..8c2bbd81 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts @@ -5,7 +5,6 @@ import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, REMOTE_COUCHDB, - REMOTE_P2P, type DocumentID, type EntryDoc, type EntryLeaf, @@ -53,8 +52,7 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands { name: "Garbage Collection V3 (advanced, beta)", icon: "trash-2", checkCallback: (checking) => { - const isApplicableRemote = - this.settings.remoteType === REMOTE_COUCHDB || this.settings.remoteType === REMOTE_P2P; + const isApplicableRemote = this.settings.remoteType === REMOTE_COUCHDB; if (!this.settings.useEdgeCaseMode || !this._isDatabaseReady() || !isApplicableRemote) { return false; } @@ -464,7 +462,7 @@ Note: **Make sure to synchronise all devices before deletion.** const confirmMessage = `This function deletes unused chunks from the device. If there are differences between devices, some chunks may be missing when resolving conflicts. Be sure to synchronise before executing. -However, if you have deleted them, you may be able to recover them by performing Hatch -> Recreate missing chunks for all files. +If chunks used by current Vault files are deleted, Hatch -> Recreate chunks for current Vault files can recreate them only from files currently present in the Vault. It cannot recover unreadable historical or conflict content. Are you ready to delete unused chunks?`; @@ -926,41 +924,22 @@ This may indicate that some devices have not completed synchronisation, which co const gcStartTime = Date.now(); // Perform Garbage Collection (new implementation). const localDatabase = this.localDatabase.localDatabase; - const usedChunks = new Set(); - const allChunks = new Map(); - - const IDs = this.localDatabase.findEntryNames("", "", {}); - let i = 0; - const doc_count = (await localDatabase.info()).doc_count; - for await (const id of IDs) { - const doc = await this.localDatabase.getRaw(id as DocumentID); - i++; - if (i % 100 == 0) { - this._notice(`Garbage Collection: Scanned ${i} / ~${doc_count} `, "gc-scanning"); - } - if (!doc) continue; - if ("children" in doc) { - const children = (doc.children || []) as DocumentID[]; - for (const chunkId of children) { - usedChunks.add(chunkId); - } - } else if (doc.type === EntryTypes.CHUNK) { - allChunks.set(doc._id, doc._rev); - } - } + // Use the revision-aware reachability scan. Reading only winning revisions + // would make chunks used exclusively by live conflict branches look unused. + const { used: usedChunks, existing: allChunks } = await this.localDatabase.allChunks(); this._notice( `Garbage Collection: Scanning completed. Total chunks: ${allChunks.size}, Used chunks: ${usedChunks.size}`, "gc-scanning" ); - const unusedChunks = [...allChunks.keys()].filter((e) => !usedChunks.has(e)); + const unusedChunks = [...allChunks.entries()].filter(([chunkId]) => !usedChunks.has(chunkId)); this._notice(`Garbage Collection: Found ${unusedChunks.length} unused chunks to delete.`, "gc-scanning"); const deleteChunkDocs = unusedChunks.map( - (chunkId) => + ([chunkId, chunk]) => ({ - _id: chunkId, + _id: chunkId as DocumentID, _deleted: true, - _rev: allChunks.get(chunkId), + _rev: chunk._rev, }) as EntryLeaf ); const response = await localDatabase.bulkDocs(deleteChunkDocs); diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts index 19be24ee..55878d03 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts @@ -24,7 +24,12 @@ vi.mock("@/common/events", () => ({ onEvent: vi.fn(), }, })); -import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + DEFAULT_SETTINGS, + REMOTE_COUCHDB, + REMOTE_MINIO, + REMOTE_P2P, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte"; import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites"; @@ -87,6 +92,9 @@ describe("LocalDatabaseMaintenance prerequisites", () => { settings.useEdgeCaseMode = true; expect(garbageCollect?.checkCallback?.(true)).toBe(true); + settings.remoteType = REMOTE_P2P; + expect(garbageCollect?.checkCallback?.(true)).toBe(false); + settings.remoteType = REMOTE_MINIO; expect(garbageCollect?.checkCallback?.(true)).toBe(false); }); @@ -176,4 +184,129 @@ describe("LocalDatabaseMaintenance prerequisites", () => { expect(askSelectStringDialogue).not.toHaveBeenCalled(); expect(applyPartial).not.toHaveBeenCalled(); }); + + it("describes the current chunk-recreation action without promising historical recovery", async () => { + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + const askSelectStringDialogue = vi.fn().mockResolvedValue("Cancel"); + Object.assign(maintenance, { + core: { + confirm: { + askSelectStringDialogue, + }, + }, + _log: vi.fn(), + }); + vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true); + vi.spyOn(maintenance, "trackChanges").mockResolvedValue(undefined); + + await maintenance.performGC(); + + const message = vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0] as string; + expect(message).toContain("Hatch -> Recreate chunks for current Vault files"); + expect(message).toContain("only from files currently present in the Vault"); + expect(message).not.toContain("Recreate missing chunks for all files"); + }); +}); + +describe("LocalDatabaseMaintenance Garbage Collection V3", () => { + it("keeps chunks referenced by a live conflict revision and deletes only unreachable chunks", async () => { + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + const pushModes: string[] = []; + const deletedChunks: Array<{ _id: string; _rev?: string; _deleted?: boolean }> = []; + const allChunks = vi.fn(async () => ({ + used: new Set(["h:winner", "h:conflict"]), + existing: new Map([ + ["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }], + ["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }], + ["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }], + ]), + })); + const rawDocuments = new Map([ + [ + "note.md", + { + _id: "note.md", + _rev: "2-winner", + _conflicts: ["2-conflict"], + type: "plain", + children: ["h:winner"], + }, + ], + ["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }], + ["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }], + ["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }], + ]); + const findEntryNames = vi.fn(async function* () { + yield* rawDocuments.keys(); + }); + const getRaw = vi.fn(async (id: string) => rawDocuments.get(id)); + const localDatabase = { + allChunks, + localDatabase: { + info: vi.fn(async () => ({ doc_count: rawDocuments.size })), + bulkDocs: vi.fn(async (docs: Array<{ _id: string; _rev?: string; _deleted?: boolean }>) => { + deletedChunks.push(...docs); + return docs.map(({ _id }) => ({ ok: true, id: _id, rev: "2-deleted" })); + }), + }, + findEntryNames, + getRaw, + }; + const replicator = { + openOneShotReplication: vi.fn( + async ( + _settings: typeof DEFAULT_SETTINGS, + _showResult: boolean, + _ignoreCleanLock: boolean, + mode: string + ) => { + pushModes.push(mode); + return true; + } + ), + getConnectedDeviceList: vi.fn(async () => ({ + accepted_nodes: ["device-a"], + node_info: { + "device-a": { + progress: "10-local", + device_name: "Device A", + app_version: "1.12.7", + plugin_version: "1.0.0-beta.0", + }, + }, + })), + }; + Object.assign(maintenance, { + core: { + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + }, + replicator, + confirm: { + askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"), + }, + }, + localDatabase, + _notice: vi.fn(), + }); + vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true); + vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined); + vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined); + + await maintenance.gcv3(); + + expect(allChunks).toHaveBeenCalledOnce(); + expect(findEntryNames).not.toHaveBeenCalled(); + expect(getRaw).not.toHaveBeenCalled(); + expect(deletedChunks).toEqual([ + { + _id: "h:obsolete", + _rev: "1-obsolete", + _deleted: true, + }, + ]); + expect(pushModes).toEqual(["sync", "pushOnly"]); + expect(maintenance.compactDatabase).toHaveBeenCalledOnce(); + }); }); diff --git a/src/modules/features/SettingDialogue/PaneMaintenance.ts b/src/modules/features/SettingDialogue/PaneMaintenance.ts index 30a59323..47fd1424 100644 --- a/src/modules/features/SettingDialogue/PaneMaintenance.ts +++ b/src/modules/features/SettingDialogue/PaneMaintenance.ts @@ -187,7 +187,7 @@ export function paneMaintenance( ) .addOnUpdate(this.onlyOnMinIO); }); - void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnP2POrCouchDB).then((paneEl) => { + void addPanel(paneEl, "Garbage Collection V3 (Beta)", (e) => e, this.onlyOnCouchDB).then((paneEl) => { new Setting(paneEl) .setName("Perform Garbage Collection") .setDesc("Perform Garbage Collection to remove unused chunks and reduce database size.") diff --git a/updates.md b/updates.md index bacce5b9..7f1c10c2 100644 --- a/updates.md +++ b/updates.md @@ -25,12 +25,13 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Fixed - An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. +- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. - Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. - Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. ### Testing -- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. +- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, mobile dialogues, conflict-aware chunk reachability, shared chunks, collection propagation, and content-addressed chunk recreation. ## 1.0.0-beta.2 From 0a7a3b1635c04973089924acd15a0c22ef3ece41 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 17:53:22 +0000 Subject: [PATCH 147/170] Harden Garbage Collection V3 validation --- docs/specs_garbage_collection.md | 4 +- .../CmdLocalDatabaseMainte.ts | 13 ++- .../CmdLocalDatabaseMainte.unit.spec.ts | 109 ++++++++++++++++++ updates.md | 4 +- 4 files changed, 122 insertions(+), 8 deletions(-) diff --git a/docs/specs_garbage_collection.md b/docs/specs_garbage_collection.md index c10647b5..40f00c82 100644 --- a/docs/specs_garbage_collection.md +++ b/docs/specs_garbage_collection.md @@ -19,14 +19,14 @@ After the user starts Garbage Collection V3, LiveSync: 1. completes a one-shot bidirectional CouchDB synchronisation; 2. reads the accepted-device list and current progress recorded on the remote; -3. warns when an accepted device has no current information or device progress differs, then requires explicit confirmation; +3. requires parseable progress information, warns when an accepted device has no current information or device progress differs, then requires explicit confirmation; 4. computes the chunks reachable from the local PouchDB revision tree; 5. creates a logical deletion for each locally present chunk which is not reachable; 6. completes a push-only replication so that those deletions reach CouchDB; 7. requests CouchDB compaction and waits for it for up to two minutes; and 8. clears the local chunk caches. -If the initial synchronisation, device inspection, or confirmation fails, the workflow stops before collection. If push-only replication fails, the local logical deletions have already been created, but remote compaction is not started; synchronise again before retrying or using another device. A compaction failure is reported separately. +If the initial synchronisation, device inspection, or confirmation fails, the workflow stops before collection. Missing or invalid device progress is treated as a failed inspection rather than offered as a confirmation override. If push-only replication fails, the local logical deletions have already been created, but remote compaction is not started; synchronise again before retrying or using another device. A compaction failure or completion timeout is reported separately and is not also reported as a successful completion. ## Reachability rules diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts index 8c2bbd81..4c25400d 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.ts @@ -760,7 +760,7 @@ Success: ${successCount}, Errored: ${errored}`; timeout -= 2000; if (timeout <= 0) { this._notice("Compaction on remote database timed out.", "gc-compact"); - break; + return; } } else { break; @@ -883,9 +883,14 @@ It is preferable to update all devices if possible. If you have any devices that } //2. Check whether the progress values in NodeData are roughly the same (only the numerical part is needed). - const progressValues = Object.values(node_info) - .map((e) => e.progress.split("-")[0]) - .map((e) => parseInt(e)); + const progressValues = Object.values(node_info).map((entry) => { + const progress = typeof entry.progress === "string" ? entry.progress.split("-")[0] : ""; + return /^\d+$/u.test(progress) ? Number(progress) : Number.NaN; + }); + if (progressValues.length === 0 || progressValues.some((progress) => !Number.isSafeInteger(progress))) { + this._notice("No connected device information found. Cancelling Garbage Collection."); + return; + } const maxProgress = Math.max(...progressValues); const minProgress = Math.min(...progressValues); const progressDifference = maxProgress - minProgress; diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts index 55878d03..3ecc4180 100644 --- a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts @@ -9,6 +9,13 @@ vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({ vi.mock("octagonal-wheels/collection", () => ({ arrayToChunkedArray: vi.fn((values: unknown[]) => [values]), })); +vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + delay: vi.fn(async () => undefined), + }; +}); vi.mock("@/features/LiveSyncCommands", () => ({ LiveSyncCommands: class LiveSyncCommands { core!: { settings: unknown }; @@ -209,6 +216,108 @@ describe("LocalDatabaseMaintenance prerequisites", () => { }); describe("LocalDatabaseMaintenance Garbage Collection V3", () => { + it("does not report remote compaction as successful after its completion wait times out", async () => { + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + const notice = vi.fn(); + const remoteDatabase = { + compact: vi.fn(async () => ({ ok: true })), + info: vi.fn(async () => ({ compact_running: true })), + }; + Object.assign(maintenance, { + core: { + replicator: { + connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })), + }, + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + }, + }, + _notice: notice, + }); + + await maintenance.compactDatabase(); + + expect(notice).toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact"); + expect(notice).not.toHaveBeenCalledWith( + "Compaction on remote database completed successfully.", + "gc-compact" + ); + }); + + it.each([ + ["no device progress entries", {}], + [ + "an unparseable device progress entry", + { + "device-a": { + progress: "", + device_name: "Device A", + app_version: "1.12.7", + plugin_version: "1.0.0-beta.0", + }, + }, + ], + [ + "a missing device progress entry", + { + "device-a": { + device_name: "Device A", + app_version: "1.12.7", + plugin_version: "1.0.0-beta.0", + }, + }, + ], + ] as const)("cancels before collection when the milestone has %s", async (_case, nodeInfo) => { + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + const pushModes: string[] = []; + const allChunks = vi.fn(async () => ({ + used: new Set(), + existing: new Map(), + })); + const replicator = { + openOneShotReplication: vi.fn(async (...args: unknown[]) => { + pushModes.push(String(args[3])); + return true; + }), + getConnectedDeviceList: vi.fn(async () => ({ + accepted_nodes: Object.keys(nodeInfo), + node_info: nodeInfo, + })), + }; + const notice = vi.fn(); + Object.assign(maintenance, { + core: { + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + }, + replicator, + confirm: { + askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"), + }, + }, + localDatabase: { + allChunks, + localDatabase: { + bulkDocs: vi.fn(async () => []), + }, + }, + _notice: notice, + }); + vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true); + vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined); + vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined); + + await maintenance.gcv3(); + + expect(allChunks).not.toHaveBeenCalled(); + expect(pushModes).toEqual(["sync"]); + expect(notice).toHaveBeenCalledWith( + "No connected device information found. Cancelling Garbage Collection." + ); + }); + it("keeps chunks referenced by a live conflict revision and deletes only unreachable chunks", async () => { const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; const pushModes: string[] = []; diff --git a/updates.md b/updates.md index 7f1c10c2..2989226e 100644 --- a/updates.md +++ b/updates.md @@ -25,13 +25,13 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Fixed - An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. -- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. +- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message. - Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. - Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. ### Testing -- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, mobile dialogues, conflict-aware chunk reachability, shared chunks, collection propagation, and content-addressed chunk recreation. +- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, mobile dialogues, conflict-aware chunk reachability, device-progress safeguards, compaction timeouts, shared chunks, collection propagation, and content-addressed chunk recreation. ## 1.0.0-beta.2 From c8162fd0cd869aae8f75533bc12ecfa25d037c79 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 17:53:55 +0000 Subject: [PATCH 148/170] Make dialogue prose selectable --- .../services/LiveSyncUI/DialogHost.svelte | 2 + styles.css | 5 ++ test/e2e-obsidian/scripts/dialog-mounts.ts | 74 ++++++++++++++++--- updates.md | 3 +- 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/modules/services/LiveSyncUI/DialogHost.svelte b/src/modules/services/LiveSyncUI/DialogHost.svelte index 111dd4ae..6265c7bf 100644 --- a/src/modules/services/LiveSyncUI/DialogHost.svelte +++ b/src/modules/services/LiveSyncUI/DialogHost.svelte @@ -68,6 +68,8 @@ display: flex; flex-direction: column; padding-bottom: var(--keyboard-height, 0px); + user-select: text; + -webkit-user-select: text; } .dialog-host :global(button) { diff --git a/styles.css b/styles.css index 3d63babd..ab7ca645 100644 --- a/styles.css +++ b/styles.css @@ -12,6 +12,11 @@ background-color: var(--text-muted); } +.vpk-action-dialog__message { + user-select: text; + -webkit-user-select: text; +} + .conflict-dev-name { display: inline-block; min-width: 5em; diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts index f2109068..99d565de 100644 --- a/test/e2e-obsidian/scripts/dialog-mounts.ts +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -194,6 +194,28 @@ async function verifyRemoteSizeNoticeAndDialogue(): Promise<{ .filter({ hasText: "Synchronisation paused for compatibility review" }), }); await compatibilityReview.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const message = compatibilityReview.locator(".vpk-action-dialog__message"); + const textSelection = await message.evaluate((element) => { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + const selectedText = selection?.toString() ?? ""; + selection?.removeAllRanges(); + return { + selectedText, + userSelect: getComputedStyle(element).userSelect, + }; + }); + if ( + textSelection.userSelect !== "text" || + !textSelection.selectedText.includes("Remote synchronisation is paused on this device") + ) { + throw new Error( + `Expected the action dialogue message to be selectable, received user-select=${textSelection.userSelect} and selected text '${textSelection.selectedText}'.` + ); + } const actions = compatibilityReview.locator(".vpk-action-dialog__actions--vertical"); await actions.waitFor({ state: "visible", timeout: uiTimeoutMs }); const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); @@ -286,12 +308,32 @@ async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise { + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(element); + selection?.removeAllRanges(); + selection?.addRange(range); + const selectedText = selection?.toString() ?? ""; + selection?.removeAllRanges(); + return { + selectedText, + userSelect: getComputedStyle(element).userSelect, + }; + }); + if ( + textSelection.userSelect !== "text" || + !textSelection.selectedText.includes("signalling relay is required for peer discovery") + ) { + throw new Error( + `Expected Svelte dialogue prose to be selectable, received user-select=${textSelection.userSelect} and selected text '${textSelection.selectedText}'.` + ); + } await modal .getByRole("button", { name: "No, please take me back" }) .waitFor({ state: "visible", timeout: uiTimeoutMs }); @@ -831,9 +873,13 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { } }; }, repairRunStateKey); - await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).click({ - timeout: uiTimeoutMs, - }); + await page + .locator(".sls-setting:visible") + .last() + .getByRole("button", { name: "Recreate current chunks", exact: true }) + .click({ + timeout: uiTimeoutMs, + }); await page.waitForFunction( (stateKey) => (globalThis as unknown as Record)[stateKey]?.done === true, @@ -848,9 +894,13 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { throw new Error(`Recreate missing chunks failed: ${repairState.error}`); } - await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).click({ - timeout: uiTimeoutMs, - }); + await page + .locator(".sls-setting:visible") + .last() + .getByRole("button", { name: "Verify all", exact: true }) + .click({ + timeout: uiTimeoutMs, + }); await page .locator(".notice") .filter({ hasText: /^done$/u }) diff --git a/updates.md b/updates.md index 2989226e..7d0ee73a 100644 --- a/updates.md +++ b/updates.md @@ -21,6 +21,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel - First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. - Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. - Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. +- Text in setup and review dialogues can now be selected for copying or translation. ### Fixed @@ -31,7 +32,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Testing -- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, mobile dialogues, conflict-aware chunk reachability, device-progress safeguards, compaction timeouts, shared chunks, collection propagation, and content-addressed chunk recreation. +- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, selectable and mobile dialogues, conflict-aware chunk reachability, device-progress safeguards, compaction timeouts, shared chunks, collection propagation, and content-addressed chunk recreation. ## 1.0.0-beta.2 From c24fc1dc82e0fe1bb74e5438e2a694c34818da08 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 24 Jul 2026 19:06:53 +0000 Subject: [PATCH 149/170] Update Commonlib to 0.1.0-rc.12 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 531210de..e23e436a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.11", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.12", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", @@ -4764,9 +4764,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.0-rc.11", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.11.tgz", - "integrity": "sha512-o811duZFajxDFI6cy7zwtXPg6lihx5e1MH6Xse1sx4HseYurbaDf9DtZJdAtfkjTxl5wOZRSnVMoMLn+I/xD+g==", + "version": "0.1.0-rc.12", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.12.tgz", + "integrity": "sha512-pMiOL4x5pDKCYOwtH3nG+9Ra1sLjowItBUrYoHpvcrweV4NSN0OkAEMk1vxBQlKMmKtnrAdy0WuQvmsdLOWHqA==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index ad11c68b..cfcaff98 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.11", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.12", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", From 0d68b6530fcc4cb7bff75521b5eba7b2970586cf Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 25 Jul 2026 03:48:51 +0000 Subject: [PATCH 150/170] Test Garbage Collection V3 against CouchDB --- docs/specs_garbage_collection.md | 4 +- ...CmdLocalDatabaseMainte.integration.spec.ts | 257 ++++++++++++++++++ 2 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts diff --git a/docs/specs_garbage_collection.md b/docs/specs_garbage_collection.md index 40f00c82..8a75f975 100644 --- a/docs/specs_garbage_collection.md +++ b/docs/specs_garbage_collection.md @@ -60,4 +60,6 @@ Commonlib tests use real in-memory PouchDB revision trees to verify: - propagation of chunk deletion to another PouchDB database; and - recreation and propagation when the same content is written again. -Self-hosted LiveSync tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction. CouchDB's own compaction implementation remains an external database boundary. +Self-hosted LiveSync unit tests verify that Garbage Collection V3 uses Commonlib's revision-aware result, deletes only unreachable chunks, performs the initial bidirectional and final push-only replications in order, and requests remote compaction. + +A disposable real-CouchDB integration test verifies logical deletion on the server, retention of shared and conflict chunks, compaction completion, replication after collection, and recreation of a content-addressed chunk. CouchDB's choice and timing of physical byte reclamation remain an external database boundary, so the test does not assert a particular reduction in database size. diff --git a/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts new file mode 100644 index 00000000..84f94ed2 --- /dev/null +++ b/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.integration.spec.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from "vitest"; +import PouchDB from "pouchdb-core"; +import HttpPouch from "pouchdb-adapter-http"; +import MemoryAdapter from "pouchdb-adapter-memory"; +import replication from "pouchdb-replication"; + +vi.mock("@/features/LiveSyncCommands", () => ({ + LiveSyncCommands: class LiveSyncCommands { + core!: { settings: unknown }; + get settings() { + return this.core.settings; + } + }, +})); +vi.mock("@/common/events", () => ({ + EVENT_ANALYSE_DB_USAGE: "analyse", + EVENT_REQUEST_PERFORM_GC_V3: "gc", + eventHub: { + onEvent: vi.fn(), + }, +})); + +import { + DEFAULT_SETTINGS, + REMOTE_COUCHDB, + type DocumentID, + type EntryDoc, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { LiveSyncLocalDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/LiveSyncLocalDB"; +import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte"; + +PouchDB.plugin(HttpPouch).plugin(MemoryAdapter).plugin(replication); + +type FixtureContent = { + type: "leaf" | "plain"; + data?: string; + path?: string; + children?: string[]; + ctime?: number; + mtime?: number; + size?: number; + eden?: Record; +}; + +type FixtureDocument = PouchDB.Core.PutDocument & { + _id: DocumentID; + _revisions?: { + start: number; + ids: string[]; + }; +}; + +function chunk(id: string, data = id): FixtureDocument { + return { + _id: id as DocumentID, + type: "leaf", + data, + }; +} + +function revision(id: string, rev: string, history: string[], children: string[]): FixtureDocument { + return { + _id: id as DocumentID, + _rev: rev, + _revisions: { + start: Number(rev.split("-")[0]), + ids: history, + }, + type: "plain", + path: id, + children, + ctime: 1, + mtime: 1, + size: children.length, + eden: {}, + } as unknown as FixtureDocument; +} + +function liveSyncDatabaseFor(database: PouchDB.Database): LiveSyncLocalDB { + const subject = Object.create(LiveSyncLocalDB.prototype) as LiveSyncLocalDB; + Object.assign(subject, { + localDatabase: database as unknown as PouchDB.Database, + }); + return subject; +} + +function requiredEnvironment(name: "hostname" | "username" | "password"): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required integration-test environment variable: ${name}`); + } + return value; +} + +describe("LocalDatabaseMaintenance Garbage Collection V3 with CouchDB", () => { + it("propagates collection safely, completes compaction, and permits content-addressed chunk recreation", async () => { + const databaseName = `livesync-gcv3-${crypto.randomUUID()}`; + const local = new PouchDB(`${databaseName}-source`, { adapter: "memory" }); + const replica = new PouchDB(`${databaseName}-replica`, { adapter: "memory" }); + const remote = new PouchDB( + `${requiredEnvironment("hostname").replace(/\/+$/u, "")}/${databaseName}`, + { + adapter: "http", + auth: { + username: requiredEnvironment("username"), + password: requiredEnvironment("password"), + }, + } + ); + + try { + await remote.info(); + await local.bulkDocs([ + chunk("h:obsolete"), + chunk("h:current"), + chunk("h:shared"), + chunk("h:base"), + chunk("h:left"), + chunk("h:right"), + ]); + + const firstRevision = revision("first.md", "1-first", ["first"], ["h:obsolete"]); + delete firstRevision._rev; + delete firstRevision._revisions; + await local.put(firstRevision); + await local.put({ + ...(await local.get("first.md")), + children: ["h:current"], + }); + + for (const id of ["second.md", "third.md"]) { + const sharedRevision = revision(id, `1-${id}`, [id], ["h:shared"]); + delete sharedRevision._rev; + delete sharedRevision._revisions; + await local.put(sharedRevision); + } + + await local.bulkDocs( + [ + revision("conflicted.md", "1-base", ["base"], ["h:base"]), + revision("conflicted.md", "2-left", ["left", "base"], ["h:left"]), + revision("conflicted.md", "2-right", ["right", "base"], ["h:right"]), + ], + { new_edits: false } + ); + + const liveSyncDatabase = liveSyncDatabaseFor(local); + const replicationModes: string[] = []; + const replicator = { + openOneShotReplication: vi.fn( + async ( + _settings: typeof DEFAULT_SETTINGS, + _showResult: boolean, + _ignoreCleanLock: boolean, + mode: string + ) => { + replicationModes.push(mode); + if (mode === "sync") { + await local.sync(remote); + } else if (mode === "pushOnly") { + await local.replicate.to(remote); + } else { + throw new Error(`Unexpected replication mode: ${mode}`); + } + return true; + } + ), + getConnectedDeviceList: vi.fn(() => + Promise.resolve({ + accepted_nodes: ["integration-device"], + node_info: { + "integration-device": { + progress: "10-local", + device_name: "Integration device", + app_version: "1.12.7", + plugin_version: "1.0.0-beta.0", + }, + }, + }) + ), + connectRemoteCouchDBWithSetting: vi.fn(() => Promise.resolve({ db: remote })), + }; + const notice = vi.fn(); + const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance; + Object.assign(maintenance, { + core: { + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + }, + replicator, + confirm: { + askSelectStringDialogue: vi.fn(() => Promise.resolve("Proceed Garbage Collection")), + }, + }, + localDatabase: liveSyncDatabase, + _notice: notice, + }); + vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true); + const clearHash = vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined); + + await maintenance.gcv3(); + + expect(replicationModes).toEqual(["sync", "pushOnly"]); + expect(clearHash).toHaveBeenCalledOnce(); + expect(notice).toHaveBeenCalledWith("Compaction on remote database completed successfully.", "gc-compact"); + expect(notice).not.toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact"); + expect(notice).not.toHaveBeenCalledWith("Compaction on remote database failed.", "gc-compact"); + + const obsoleteRow = (await remote.allDocs({ keys: ["h:obsolete"] })).rows[0]; + expect(obsoleteRow).toMatchObject({ + id: "h:obsolete", + value: { + deleted: true, + }, + }); + + for (const retainedChunk of ["h:current", "h:shared", "h:base", "h:left", "h:right"]) { + await expect(remote.get(retainedChunk)).resolves.toMatchObject({ + _id: retainedChunk, + type: "leaf", + }); + } + await expect(remote.get("second.md")).resolves.toMatchObject({ children: ["h:shared"] }); + await expect(remote.get("third.md")).resolves.toMatchObject({ children: ["h:shared"] }); + await expect(remote.get("conflicted.md", { conflicts: true })).resolves.toMatchObject({ + _conflicts: [expect.any(String)], + }); + + await remote.replicate.to(replica); + await expect(replica.get("h:obsolete")).rejects.toMatchObject({ status: 404 }); + for (const retainedChunk of ["h:current", "h:shared", "h:base", "h:left", "h:right"]) { + await expect(replica.get(retainedChunk)).resolves.toMatchObject({ + _id: retainedChunk, + type: "leaf", + }); + } + + await local.put(chunk("h:obsolete", "recreated")); + await local.replicate.to(remote); + await remote.replicate.to(replica); + + await expect(remote.get("h:obsolete")).resolves.toMatchObject({ + _id: "h:obsolete", + type: "leaf", + data: "recreated", + }); + await expect(replica.get("h:obsolete")).resolves.toMatchObject({ + _id: "h:obsolete", + type: "leaf", + data: "recreated", + }); + } finally { + await Promise.all([local.destroy(), replica.destroy(), remote.destroy()]); + } + }, 30_000); +}); From a7bc337fd1dc59b185fd268e9d531a1cec3ef916 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 25 Jul 2026 03:49:18 +0000 Subject: [PATCH 151/170] Test Security Seed refresh in Real Obsidian --- package.json | 1 + test/e2e-obsidian/README.md | 13 +- test/e2e-obsidian/runner/couchdb.ts | 48 +- .../runner/liveSyncWorkflow.test.ts | 23 + test/e2e-obsidian/runner/liveSyncWorkflow.ts | 57 +- test/e2e-obsidian/runner/securitySeed.test.ts | 63 ++ test/e2e-obsidian/runner/securitySeed.ts | 77 ++ test/e2e-obsidian/scripts/run-focused.ts | 1 + .../scripts/security-seed-reconnect.ts | 745 ++++++++++++++++++ 9 files changed, 1005 insertions(+), 23 deletions(-) create mode 100644 test/e2e-obsidian/runner/securitySeed.test.ts create mode 100644 test/e2e-obsidian/runner/securitySeed.ts create mode 100644 test/e2e-obsidian/scripts/security-seed-reconnect.ts diff --git a/package.json b/package.json index cfcaff98..927ee1db 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "test:e2e:obsidian:startup-scan": "tsx test/e2e-obsidian/scripts/startup-scan.ts", "test:e2e:obsidian:setup-uri-workflow": "tsx test/e2e-obsidian/scripts/setup-uri-workflow.ts", "test:e2e:obsidian:two-vault-sync": "tsx test/e2e-obsidian/scripts/two-vault-sync.ts", + "test:e2e:obsidian:security-seed-reconnect": "tsx test/e2e-obsidian/scripts/security-seed-reconnect.ts", "test:e2e:obsidian:hidden-file-snippet-sync": "tsx test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts", "test:e2e:obsidian:customisation-sync": "tsx test/e2e-obsidian/scripts/customisation-sync.ts", "test:e2e:obsidian:setting-markdown-export": "tsx test/e2e-obsidian/scripts/setting-markdown-export.ts", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index f06c7b57..73b17e1e 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -55,6 +55,7 @@ After changing plug-in source, use the focused wrapper rather than invoking a sc ```bash npm run test:e2e:obsidian:focused -- settings-ui npm run test:e2e:obsidian:focused -- two-vault-sync +npm run test:e2e:obsidian:focused -- security-seed-reconnect ``` The wrapper accepts only maintained real-Obsidian scenario names; run it with `--help` for the current list. It deliberately does not manage CouchDB, Object Storage, or the P2P signalling relay. Start the required fixture first, or use the complete service-managed suite. @@ -101,7 +102,7 @@ The same workflow checks the two remote-activity status boundaries. It first hol `test:e2e:obsidian:couchdb-manual-setup-workflow` follows the visible onboarding path for the first device when no Setup URI is available. It enters end-to-end encryption and CouchDB details, runs the read-only `Check server requirements` step, requires the prepared fixture to pass without applying a server fix, and lets the onboarding connection test create the named database. After Rebuild completes on the first device, it creates an ordinary note, asks that working device to generate a Setup URI for a second device, completes Fetch there, and verifies a bidirectional note round-trip. The workflow captures each decision point and the expanded server-check result; password controls remain visually masked. -If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, and the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory. +If this status workflow fails while Obsidian is running, it writes a full-page screenshot and a JSON snapshot of the status text and counters under `/tmp/obsidian-livesync-e2e`. The dialogue-mount workflow leaves desktop and mobile screenshots for both representative Svelte routes, the Hidden File Sync workflow captures the successfully displayed JSON Resolve dialogue before selecting an option, and the Security Seed reconnect workflow captures each significant application state. The suite therefore records representative evidence without capturing every interaction. Set `E2E_OBSIDIAN_DIAGNOSTICS_DIR` to use another directory. The two-Vault workflow performs the missing-marker review once for each isolated Vault. Later process launches reuse the same profile-backed acknowledgement, rather than seeding a replacement or repeatedly applying a decision for the first device. The Hidden File Sync scenario is narrower: it starts from an explicitly acknowledged marker because it tests consumer-owned hidden-file behaviour, JSON resolution, target filtering, and grouped mobile Notices rather than duplicating the compatibility workflow. After `app.emulateMobile(true)`, its fixture operations use the active DevTools renderer because Obsidian can remove desktop-only CLI commands in mobile mode. @@ -134,6 +135,12 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- `test:e2e:obsidian:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, ordinary rename, a case-only file name change within the same directory, deletion, and a separate encrypted round-trip with Path Obfuscation enabled. Its target-filter scenario confirms that one Vault receives and checkpoints a remote document without reflecting it, restarts with the same profile and filter, and then reflects the stored document after the filter is broadened through the settings service. Directory case changes deliberately remain outside this scenario because they require directory-aware rename handling. The optional Markdown conflict check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`. It creates divergent revisions in two separate Vaults, performs a conservative merge on one Vault, edits that result again, and requires the other Vault to replace its known deleted losing revision without recreating the conflict. The separate `E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true` check keeps four conflicts active while one Vault edits, deletes, performs a case-only rename, and performs a cross-path rename. It asserts that each operation extends the revision displayed on that device, replicates the exact resulting revision tree, and preserves the other live branch. During focused development, `E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS=true` runs that self-contained scope without the ordinary, target-filter, or encrypted scenarios. Both conflict checks remain outside the default local suite. +`test:e2e:obsidian:security-seed-reconnect` is a focused CouchDB release-acceptance workflow. Device A first recognises an initial remote Security Seed, stops automatic replication while remaining open, and creates an unsent note. The runner replaces only the Security Seed in the managed remote synchronisation-parameter fixture. Device A must retain its deliberately stale cached value until the next one-shot synchronisation, refresh it before sending, and upload an HKDF-encrypted payload which uses the replacement value. A fresh device B must decrypt that note and send an encrypted note back; the original device A then receives the return journey with its Vault and isolated profile preserved. Desktop Obsidian may enforce a single application instance, so the two device sessions run sequentially after the same-process stale-cache assertion has completed. + +The workflow creates a random dedicated database, records only SHA-256 Seed fingerprints, and never writes a Seed, passphrase, or CouchDB credentials to its result. It also requires the remote Seed and all other synchronisation parameters to remain unchanged after the replacement revision, rejects HKDF and Seed errors from either session, writes `security-seed-reconnect-result.json`, and verifies that every Obsidian process, temporary Vault, isolated profile, and database has been removed. The result file and stage screenshots are retained in `E2E_OBSIDIAN_DIAGNOSTICS_DIR`; the screenshots show ordinary Vault content, not settings or secrets. The strict cleanup workflow rejects `E2E_OBSIDIAN_KEEP_VAULT` and `E2E_OBSIDIAN_KEEP_COUCHDB`. + +This proves in real Obsidian the plug-in behaviour shared by supported platforms, including the encrypted bidirectional round-trip and protection against a stale client restoring the old remote Seed. It does not verify iPadOS-specific background or reconnect lifecycle behaviour, and it does not count as Android device evidence. The workflow remains outside `test:e2e:obsidian:local-suite` because it is a focused release-acceptance check. + `test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. `test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. @@ -204,12 +211,14 @@ Useful environment variables: - `LIVESYNC_CLI_COMMAND`: optional LiveSync CLI executable and prefix arguments used by the CLI-to-Obsidian compatibility check. The default is the locally built CLI. - `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT`: optional cache directory containing the exact pinned 0.25.83 plug-in artefacts. Cached files are always checksum-verified. - `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT`: directory containing the built 1.0 target `main.js`, `manifest.json`, and `styles.css`; default is the repository root. +- `E2E_OBSIDIAN_ARTIFACT_ROOT`: directory containing the plug-in artefact installed by a direct scenario invocation; default is the repository root. +- `E2E_OBSIDIAN_ARTIFACT_REVISION`: exact source commit recorded by the Security Seed reconnect result when `E2E_OBSIDIAN_ARTIFACT_ROOT` is a downloaded artefact rather than a Git worktree. - `E2E_OBSIDIAN_FILE_TIMEOUT_MS`: timeout for waiting until a note created through Obsidian's vault API is reflected to disk. - `E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS`: timeout for waiting until Self-hosted LiveSync reports that its core lifecycle and local database are ready. - `E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS`: timeout for waiting until a file appears in Self-hosted LiveSync's local database. - `E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS`: timeout for waiting until CouchDB contains uploaded E2E documents. - `E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS`: timeout for an observed remote activity to enter or leave its status boundary; default is 30 seconds. -- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots captured after a remote-activity failure; default is `/tmp/obsidian-livesync-e2e`. +- `E2E_OBSIDIAN_DIAGNOSTICS_DIR`: directory for screenshots and status snapshots, including the Security Seed reconnect stages; default is `/tmp/obsidian-livesync-e2e`. - `E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS`: timeout for waiting until Object Storage contains uploaded E2E objects. - `E2E_OBSIDIAN_KEEP_COUCHDB=true`: keep the temporary CouchDB database for inspection. - `E2E_OBSIDIAN_KEEP_OBJECT_STORAGE=true`: keep the temporary Object Storage prefix for inspection. diff --git a/test/e2e-obsidian/runner/couchdb.ts b/test/e2e-obsidian/runner/couchdb.ts index 11672549..1ed61f88 100644 --- a/test/e2e-obsidian/runner/couchdb.ts +++ b/test/e2e-obsidian/runner/couchdb.ts @@ -42,6 +42,12 @@ export type CouchDbDatabaseInfo = { update_seq: number | string; }; +export type CouchDbPutResponse = { + ok: boolean; + id: string; + rev: string; +}; + function parseEnvFile(content: string): Record { const entries = content .split(/\r?\n/u) @@ -79,6 +85,14 @@ function databaseUrl(config: Pick, dbName: string, suffix return `${config.uri.replace(/\/+$/u, "")}/${encodeURIComponent(dbName)}${suffix}`; } +function documentSuffix(documentId: string): string { + const localPrefix = "_local/"; + if (documentId.startsWith(localPrefix)) { + return `/_local/${encodeURIComponent(documentId.slice(localPrefix.length))}`; + } + return `/${encodeURIComponent(documentId)}`; +} + async function couchDbRequest( config: Pick, path: string, @@ -146,8 +160,8 @@ export async function putCouchDbDocument( config: CouchDbConfig, dbName: string, document: CouchDbDocument -): Promise { - const response = await fetch(databaseUrl(config, dbName, `/${encodeURIComponent(document._id)}`), { +): Promise { + const response = await fetch(databaseUrl(config, dbName, documentSuffix(document._id)), { method: "PUT", headers: { authorization: authHeader(config), @@ -160,6 +174,23 @@ export async function putCouchDbDocument( `Failed to write CouchDB document ${document._id}. HTTP ${response.status}: ${await response.text()}` ); } + return (await response.json()) as CouchDbPutResponse; +} + +export async function fetchCouchDbDocument( + config: CouchDbConfig, + dbName: string, + documentId: string +): Promise { + const response = await fetch(databaseUrl(config, dbName, documentSuffix(documentId)), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error( + `Failed to read CouchDB document ${documentId}. HTTP ${response.status}: ${await response.text()}` + ); + } + return (await response.json()) as CouchDbDocument; } export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: string): Promise { @@ -174,6 +205,19 @@ export async function deleteCouchDbDatabase(config: CouchDbConfig, dbName: strin } } +export async function couchDbDatabaseExists(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName), { + headers: { authorization: authHeader(config) }, + }); + if (response.status === 404) { + return false; + } + if (!response.ok) { + throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`); + } + return true; +} + export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string): Promise { const response = await fetch(databaseUrl(config, dbName, "/_all_docs?include_docs=true"), { headers: { authorization: authHeader(config) }, diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts index 17c4dea6..c85d0163 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -10,6 +10,7 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson })); import { assertE2eCompatibilityMarker, createE2eCouchDbPluginData, + waitForLiveSyncCoreReady, type CompatibilityMarkerState, } from "./liveSyncWorkflow.ts"; @@ -54,3 +55,25 @@ describe("configured CouchDB fixture", () => { expect(pluginData.activeConfigurationId).toBe(Object.keys(remoteConfigurations ?? {})[0]); }); }); + +describe("Real Obsidian core readiness", () => { + it("retries while the plug-in core is temporarily unavailable during reload", async () => { + evalObsidianJson.mockReset(); + evalObsidianJson + .mockRejectedValueOnce(new Error("Cannot read properties of undefined (reading 'core')")) + .mockResolvedValueOnce({ + databaseReady: true, + appReady: true, + configured: true, + remoteType: "", + settingVersion: 10, + suspended: false, + }); + + await expect(waitForLiveSyncCoreReady("obsidian-cli", {}, 1000)).resolves.toMatchObject({ + databaseReady: true, + appReady: true, + }); + expect(evalObsidianJson).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts index 0827c269..ca10fd62 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -353,31 +353,50 @@ export async function waitForLiveSyncCoreReady( ): Promise { const deadline = Date.now() + timeoutMs; let lastReadiness: CoreReadiness | undefined; + let lastError: unknown; while (Date.now() < deadline) { - lastReadiness = await evalObsidianJson( - cliBinary, - [ - "(async()=>{", - "const core=app.plugins.plugins['obsidian-livesync'].core;", - "const settings=core.services.setting.currentSettings();", - "return JSON.stringify({", - "databaseReady:core.services.database.isDatabaseReady(),", - "appReady:core.services.appLifecycle.isReady(),", - "configured:settings?.isConfigured===true,", - "remoteType:settings?.remoteType??'',", - "settingVersion:settings?.settingVersion,", - "suspended:core.services.appLifecycle.isSuspended(),", - "});", - "})()", - ].join(""), - env - ); + try { + lastReadiness = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync']?.core;", + "if(!core) return JSON.stringify({databaseReady:false,appReady:false});", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "configured:settings?.isConfigured===true,", + "remoteType:settings?.remoteType??'',", + "settingVersion:settings?.settingVersion,", + "suspended:core.services.appLifecycle.isSuspended(),", + "});", + "})()", + ].join(""), + env + ); + lastError = undefined; + } catch (error) { + // Obsidian reloads the renderer while enabling the plug-in. During + // that short window the CLI can reach the Vault before the plug-in + // catalogue has exposed its core. This is a readiness state, not a + // failed scenario, so retain the error for the eventual timeout. + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 500)); + continue; + } if (lastReadiness.databaseReady && lastReadiness.appReady) { return lastReadiness; } await new Promise((resolve) => setTimeout(resolve, 500)); } - throw new Error(`Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}`); + const errorSuffix = + lastError === undefined + ? "" + : ` Last error: ${lastError instanceof Error ? lastError.message : String(lastError)}`; + throw new Error( + `Timed out waiting for Self-hosted LiveSync core readiness: ${JSON.stringify(lastReadiness)}${errorSuffix}` + ); } /** diff --git a/test/e2e-obsidian/runner/securitySeed.test.ts b/test/e2e-obsidian/runner/securitySeed.test.ts new file mode 100644 index 00000000..02966683 --- /dev/null +++ b/test/e2e-obsidian/runner/securitySeed.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + SECURITY_SEED_DOCUMENT_ID, + changedSynchronisationParameterFields, + fingerprintSecuritySeed, + replaceSecuritySeed, + requireSecuritySeedDocument, + snapshotSecuritySeedDocument, +} from "./securitySeed.ts"; + +const seedA = Buffer.alloc(32, 1).toString("base64"); +const seedB = Buffer.alloc(32, 2).toString("base64"); + +describe("Security Seed E2E evidence", () => { + it("reports stable, non-secret fingerprints", () => { + expect(fingerprintSecuritySeed(seedA)).toMatch(/^sha256:[0-9a-f]{16}$/u); + expect(fingerprintSecuritySeed(seedA)).toBe(fingerprintSecuritySeed(seedA)); + expect(fingerprintSecuritySeed(seedA)).not.toBe(fingerprintSecuritySeed(seedB)); + }); + + it("redacts the Seed from the machine-readable document snapshot", () => { + const document = requireSecuritySeedDocument({ + _id: SECURITY_SEED_DOCUMENT_ID, + _rev: "0-1", + type: "syncinfo", + protocolVersion: 2, + pbkdf2salt: seedA, + }); + + const snapshot = snapshotSecuritySeedDocument(document); + + expect(snapshot).toEqual({ + id: SECURITY_SEED_DOCUMENT_ID, + revision: "0-1", + fingerprint: fingerprintSecuritySeed(seedA), + fields: { + type: "syncinfo", + protocolVersion: 2, + }, + }); + expect(JSON.stringify(snapshot)).not.toContain(seedA); + }); + + it("replaces only the Seed and identifies later synchronisation-parameter changes", () => { + const before = requireSecuritySeedDocument({ + _id: SECURITY_SEED_DOCUMENT_ID, + _rev: "0-1", + type: "syncinfo", + protocolVersion: 2, + pbkdf2salt: seedA, + }); + const replaced = replaceSecuritySeed(before, seedB); + const laterRevision = { + ...replaced, + _rev: "0-3", + }; + + expect(before.pbkdf2salt).toBe(seedA); + expect(replaced.pbkdf2salt).toBe(seedB); + expect(changedSynchronisationParameterFields(before, replaced)).toEqual(["pbkdf2salt"]); + expect(changedSynchronisationParameterFields(replaced, laterRevision)).toEqual([]); + }); +}); diff --git a/test/e2e-obsidian/runner/securitySeed.ts b/test/e2e-obsidian/runner/securitySeed.ts new file mode 100644 index 00000000..5851b078 --- /dev/null +++ b/test/e2e-obsidian/runner/securitySeed.ts @@ -0,0 +1,77 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { CouchDbDocument } from "./couchdb.ts"; + +export const SECURITY_SEED_DOCUMENT_ID = "_local/obsidian_livesync_sync_parameters"; + +export type SecuritySeedDocument = CouchDbDocument & { + _id: typeof SECURITY_SEED_DOCUMENT_ID; + _rev: string; + pbkdf2salt: string; +}; + +export type SecuritySeedDocumentSnapshot = { + id: string; + revision: string; + fingerprint: string; + fields: Record; +}; + +function decodeSecuritySeed(seed: string): Buffer { + const bytes = Buffer.from(seed, "base64"); + if (seed.length === 0 || bytes.length === 0) { + throw new Error("The Security Seed is empty or is not valid base64."); + } + return bytes; +} + +export function createSecuritySeed(): string { + return randomBytes(32).toString("base64"); +} + +export function fingerprintSecuritySeed(seed: string): string { + const bytes = Uint8Array.from(decodeSecuritySeed(seed)); + return `sha256:${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`; +} + +export function requireSecuritySeedDocument(document: CouchDbDocument): SecuritySeedDocument { + if (document._id !== SECURITY_SEED_DOCUMENT_ID) { + throw new Error(`Unexpected synchronisation-parameter document: ${document._id}`); + } + if (typeof document._rev !== "string" || document._rev.length === 0) { + throw new Error("The synchronisation-parameter document does not have a revision."); + } + if (typeof document.pbkdf2salt !== "string") { + throw new Error("The synchronisation-parameter document does not have a Security Seed."); + } + decodeSecuritySeed(document.pbkdf2salt); + return document as SecuritySeedDocument; +} + +export function replaceSecuritySeed(document: SecuritySeedDocument, replacementSeed: string): SecuritySeedDocument { + decodeSecuritySeed(replacementSeed); + return { + ...document, + pbkdf2salt: replacementSeed, + }; +} + +export function snapshotSecuritySeedDocument(document: SecuritySeedDocument): SecuritySeedDocumentSnapshot { + const { _id, _rev, pbkdf2salt, ...fields } = document; + return { + id: _id, + revision: _rev, + fingerprint: fingerprintSecuritySeed(pbkdf2salt), + fields, + }; +} + +export function changedSynchronisationParameterFields( + before: SecuritySeedDocument, + after: SecuritySeedDocument +): string[] { + const ignoredFields = new Set(["_rev"]); + return [...new Set([...Object.keys(before), ...Object.keys(after)])] + .filter((key) => !ignoredFields.has(key)) + .filter((key) => JSON.stringify(before[key]) !== JSON.stringify(after[key])) + .sort(); +} diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index 7f3274d1..a46bc515 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -22,6 +22,7 @@ const focusedScenarios = new Set([ "startup-scan", "setup-uri-workflow", "two-vault-sync", + "security-seed-reconnect", "hidden-file-snippet-sync", "customisation-sync", "setting-markdown-export", diff --git a/test/e2e-obsidian/scripts/security-seed-reconnect.ts b/test/e2e-obsidian/scripts/security-seed-reconnect.ts new file mode 100644 index 00000000..e0027a23 --- /dev/null +++ b/test/e2e-obsidian/scripts/security-seed-reconnect.ts @@ -0,0 +1,745 @@ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + couchDbDatabaseExists, + createCouchDbDatabase, + deleteCouchDbDatabase, + fetchAllCouchDbDocs, + fetchCouchDbDocument, + loadCouchDbConfig, + makeUniqueDatabaseName, + putCouchDbDocument, + waitForCouchDbDocs, + type CouchDbConfig, + type CouchDbDocument, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, + type LocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { + SECURITY_SEED_DOCUMENT_ID, + changedSynchronisationParameterFields, + createSecuritySeed, + fingerprintSecuritySeed, + replaceSecuritySeed, + requireSecuritySeedDocument, + snapshotSecuritySeedDocument, + type SecuritySeedDocument, + type SecuritySeedDocumentSnapshot, +} from "../runner/securitySeed.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000"; +process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "15000"; + +const outboundPath = "E2E/security-seed/device-a.md"; +const returnPath = "E2E/security-seed/device-b.md"; +const hkdfErrorMessages = [ + "Encryption with HKDF failed", + "Decryption with HKDF failed", + "Failed to initialise the encryption key", + "Failed to obtain PBKDF2 salt", +] as const; + +type RunnerContext = { + binary: string; + cliBinary: string; + artifactRoot: string; + couchDb: CouchDbConfig; + dbName: string; + activeSessions: Set; + allSessions: ObsidianLiveSyncSession[]; + screenshots: string[]; +}; + +type DeviceLabel = "device-a" | "device-a-return" | "device-b"; + +type SourceEvidence = { + exactCommit: string; + revisionSource: "git-worktree" | "provided-artifact"; + workingTreeClean: boolean | null; + pluginVersion: string; + pluginArtifactSha256: string; +}; + +type ReplicationSettingsState = { + liveSync: boolean; + syncOnStart: boolean; + syncOnSave: boolean; + periodicReplication: boolean; + syncOnFileOpen: boolean; + syncOnEditorSave: boolean; +}; + +type SessionHealth = { + matchingErrorMessages: string[]; +}; + +type ScenarioEvidence = { + source: SourceEvidence; + securitySeed: { + initial: SecuritySeedDocumentSnapshot; + replacement: SecuritySeedDocumentSnapshot; + final: SecuritySeedDocumentSnapshot; + cachedBeforeReplacement: string; + cachedAfterRemoteReplacement: string; + cachedAfterReplication: string; + replacementChangedFields: string[]; + finalChangedFields: string[]; + }; + synchronisation: { + deviceAToDeviceB: boolean; + deviceBToDeviceA: boolean; + deviceAEncryptedPayload: boolean; + deviceBEncryptedPayload: boolean; + }; + health: { + deviceA: SessionHealth; + deviceB: SessionHealth; + }; + screenshots: string[]; +}; + +type TeardownEvidence = { + sessionsStopped: boolean; + vaultRemoved: boolean; + profileRemoved: boolean; + databaseRemoved: boolean; + remainingTrackedSessions: number; +}; + +class MultipleErrors extends Error { + readonly errors: unknown[]; + + constructor(message: string, errors: unknown[]) { + super(message); + this.name = "MultipleErrors"; + this.errors = errors; + } +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function inspectGitRevision( + artifactRoot: string +): Pick { + try { + const exactCommit = execFileSync("git", ["-C", artifactRoot, "rev-parse", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + const status = execFileSync("git", ["-C", artifactRoot, "status", "--porcelain"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return { + exactCommit, + revisionSource: "git-worktree", + workingTreeClean: status.length === 0, + }; + } catch { + const exactCommit = process.env.E2E_OBSIDIAN_ARTIFACT_REVISION?.trim(); + if (!exactCommit) { + throw new Error( + "E2E_OBSIDIAN_ARTIFACT_REVISION is required when the plug-in artefact is not in a Git worktree." + ); + } + return { + exactCommit, + revisionSource: "provided-artifact", + workingTreeClean: null, + }; + } +} + +async function inspectSourceEvidence(artifactRoot: string): Promise { + const manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf-8")) as { + version?: unknown; + }; + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("The plug-in manifest does not have a version."); + } + const mainJs = await readFile(join(artifactRoot, "main.js")); + return { + ...inspectGitRevision(artifactRoot), + pluginVersion: manifest.version, + pluginArtifactSha256: createHash("sha256").update(Uint8Array.from(mainJs)).digest("hex"), + }; +} + +function e2eeSettings(passphrase: string): Record { + return { + encrypt: true, + passphrase, + usePathObfuscation: true, + E2EEAlgorithm: "v2", + }; +} + +async function captureStage(context: RunnerContext, session: ObsidianLiveSyncSession, filename: string): Promise { + const screenshot = await captureObsidianPage(session.remoteDebuggingPort, filename, async () => undefined); + context.screenshots.push(screenshot); + console.log(`Security Seed E2E screenshot: ${screenshot}`); +} + +async function startConfiguredSession( + context: RunnerContext, + vault: TemporaryVault, + passphrase: string, + deviceLabel: DeviceLabel +): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const overrides = e2eeSettings(passphrase); + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + artifactRoot: context.artifactRoot, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + }); + context.activeSessions.add(session); + context.allSessions.push(session); + try { + await captureStage(context, session, `security-seed-${deviceLabel}-startup.png`); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + await captureStage(context, session, `security-seed-${deviceLabel}-configured.png`); + return session; + } catch (error) { + await captureStage(context, session, `security-seed-${deviceLabel}-setup-failure.png`).catch(() => undefined); + await stopTrackedSession(context, session); + throw error; + } +} + +async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) { + return; + } + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopTrackedSessions(context: RunnerContext): Promise { + const errors: unknown[] = []; + for (const session of [...context.activeSessions]) { + try { + await stopTrackedSession(context, session); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) { + throw new MultipleErrors("Could not stop every Real Obsidian session.", errors); + } +} + +async function pauseAutomaticReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const state = await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "core.services.replicator.getActiveReplicator()?.closeReplication();", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "liveSync:Boolean(settings.liveSync),", + "syncOnStart:Boolean(settings.syncOnStart),", + "syncOnSave:Boolean(settings.syncOnSave),", + "periodicReplication:Boolean(settings.periodicReplication),", + "syncOnFileOpen:Boolean(settings.syncOnFileOpen),", + "syncOnEditorSave:Boolean(settings.syncOnEditorSave),", + "});", + "})()", + ].join(""), + env + ); + for (const [name, enabled] of Object.entries(state)) { + if (enabled) { + throw new Error(`Automatic replication remained enabled through ${name}.`); + } + } + return state; +} + +async function cachedSecuritySeedFingerprint(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const result = await evalObsidianJson<{ fingerprint: string }>( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "const seed=await replicator.getReplicationPBKDF2Salt(settings,false);", + "const digest=await crypto.subtle.digest('SHA-256',seed);", + "const fingerprint='sha256:'+Array.from(new Uint8Array(digest))", + ".map((value)=>value.toString(16).padStart(2,'0')).join('').slice(0,16);", + "return JSON.stringify({fingerprint});", + "})()", + ].join(""), + env + ); + return result.fingerprint; +} + +async function writeNoteViaObsidian( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + content: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const content=${JSON.stringify(content)};`, + "const folder=path.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(path);", + "if(existing) await app.vault.modify(existing,content);", + "else await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function openNoteViaObsidian(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Could not find note to open: ${path}`);", + "await app.workspace.getLeaf(false).openFile(file);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } +} + +async function waitForPathContent( + vaultPath: string, + path: string, + expected: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 15000) +): Promise { + const fullPath = join(vaultPath, path); + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + if (await pathExists(fullPath)) { + lastContent = await readFile(fullPath, "utf-8"); + if (lastContent === expected) { + return; + } + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +function remoteContainsEntry(documents: CouchDbDocument[], entry: LocalDatabaseEntry): boolean { + const ids = new Set(documents.map((document) => document._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); +} + +async function assertEntryNotRemote(context: RunnerContext, entry: LocalDatabaseEntry): Promise { + const response = await fetchAllCouchDbDocs(context.couchDb, context.dbName); + const documents = response.rows.flatMap((row) => (row.doc ? [row.doc] : [])); + if (remoteContainsEntry(documents, entry)) { + throw new Error("The pending device-A document reached CouchDB before the Security Seed replacement."); + } +} + +async function waitForEncryptedRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise { + const documents = await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => + remoteContainsEntry(docs, entry) + ); + const byId = new Map(documents.map((document) => [document._id, document])); + const encrypted = entry.children.every((childId) => { + const data = byId.get(childId)?.data; + return typeof data === "string" && data.startsWith("%="); + }); + if (!encrypted) { + throw new Error("A replicated chunk did not use the expected HKDF-encrypted payload format."); + } + return true; +} + +async function inspectSessionHealth(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const patterns=${JSON.stringify(hkdfErrorMessages)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.API.showWindow('log-log');", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let text='';", + "for(let i=0;i<20;i++){", + "text=Array.from(document.querySelectorAll('.logpane .log pre'))", + ".map((element)=>element.textContent??'').join('\\n');", + "if(text.length>0) break;", + "await sleep(50);", + "}", + "const unresolved=JSON.stringify((await core.services.appLifecycle.getUnresolvedMessages()).flat());", + "const matchingErrorMessages=patterns.filter((pattern)=>text.includes(pattern)||unresolved.includes(pattern));", + "for(const leaf of app.workspace.getLeavesOfType('log-log')) leaf.detach();", + "return JSON.stringify({matchingErrorMessages});", + "})()", + ].join(""), + env + ); +} + +async function fetchSecuritySeedDocument(context: RunnerContext): Promise { + return requireSecuritySeedDocument( + await fetchCouchDbDocument(context.couchDb, context.dbName, SECURITY_SEED_DOCUMENT_ID) + ); +} + +async function replaceRemoteSecuritySeed( + context: RunnerContext, + before: SecuritySeedDocument, + replacementSeed: string +): Promise { + const replacement = replaceSecuritySeed(before, replacementSeed); + const putResult = await putCouchDbDocument(context.couchDb, context.dbName, replacement); + const after = await fetchSecuritySeedDocument(context); + assertEqual(after._rev, putResult.rev, "The replacement Security Seed revision was not stored."); + assertEqual( + fingerprintSecuritySeed(after.pbkdf2salt), + fingerprintSecuritySeed(replacementSeed), + "The replacement Security Seed was not stored." + ); + const changedFields = changedSynchronisationParameterFields(before, after); + assertEqual( + JSON.stringify(changedFields), + JSON.stringify(["pbkdf2salt"]), + "Replacing the remote Security Seed changed another synchronisation parameter." + ); + return after; +} + +async function runScenario( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const passphrase = `security-seed-e2e-${randomUUID()}`; + const source = await inspectSourceEvidence(context.artifactRoot); + let sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a"); + + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + await captureStage(context, sessionA, "security-seed-device-a-initial-sync.png"); + const initialDocument = await fetchSecuritySeedDocument(context); + const initial = snapshotSecuritySeedDocument(initialDocument); + const cachedBeforeReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); + assertEqual( + cachedBeforeReplacement, + initial.fingerprint, + "Device A did not cache the initial remote Security Seed." + ); + + await pauseAutomaticReplication(context.cliBinary, sessionA.cliEnv); + const outboundContent = `Encrypted from device A: ${randomUUID()}\n`; + await writeNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath, outboundContent); + const outboundEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionA.cliEnv, outboundPath); + await assertEntryNotRemote(context, outboundEntry); + + const replacementSeed = createSecuritySeed(); + const replacementDocument = await replaceRemoteSecuritySeed(context, initialDocument, replacementSeed); + const replacement = snapshotSecuritySeedDocument(replacementDocument); + await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, outboundPath); + await captureStage(context, sessionA, "security-seed-device-a-replacement-pending.png"); + const cachedAfterRemoteReplacement = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); + assertEqual( + cachedAfterRemoteReplacement, + initial.fingerprint, + "Device A did not retain the deliberately stale Security Seed before replication." + ); + assertEqual( + replacement.fingerprint, + fingerprintSecuritySeed(replacementSeed), + "The runner did not install the intended replacement Security Seed." + ); + + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + const cachedAfterReplication = await cachedSecuritySeedFingerprint(context.cliBinary, sessionA.cliEnv); + assertEqual( + cachedAfterReplication, + replacement.fingerprint, + "Device A did not refresh the Security Seed before replication." + ); + const deviceAEncryptedPayload = await waitForEncryptedRemoteEntry(context, outboundEntry); + await captureStage(context, sessionA, "security-seed-device-a-refreshed-sync.png"); + const deviceAHealthBeforeRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv); + await stopTrackedSession(context, sessionA); + + const sessionB = await startConfiguredSession(context, vaultB, passphrase, "device-b"); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + await waitForPathContent(vaultB.path, outboundPath, outboundContent); + await openNoteViaObsidian(context.cliBinary, sessionB.cliEnv, outboundPath); + await captureStage(context, sessionB, "security-seed-device-b-received.png"); + + await pauseAutomaticReplication(context.cliBinary, sessionB.cliEnv); + const returnContent = `Encrypted from device B: ${randomUUID()}\n`; + await writeNoteViaObsidian(context.cliBinary, sessionB.cliEnv, returnPath, returnContent); + const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, returnPath); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + const deviceBEncryptedPayload = await waitForEncryptedRemoteEntry(context, returnEntry); + const deviceBHealth = await inspectSessionHealth(context.cliBinary, sessionB.cliEnv); + await stopTrackedSession(context, sessionB); + + sessionA = await startConfiguredSession(context, vaultA, passphrase, "device-a-return"); + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + await waitForPathContent(vaultA.path, returnPath, returnContent); + await openNoteViaObsidian(context.cliBinary, sessionA.cliEnv, returnPath); + await captureStage(context, sessionA, "security-seed-device-a-return-received.png"); + + const finalDocument = await fetchSecuritySeedDocument(context); + const final = snapshotSecuritySeedDocument(finalDocument); + assertEqual( + final.fingerprint, + replacement.fingerprint, + "A client rolled the remote Security Seed back after reconnecting." + ); + const finalChangedFields = changedSynchronisationParameterFields(replacementDocument, finalDocument); + if (finalChangedFields.length > 0) { + throw new Error( + `A client rewrote unexpected synchronisation-parameter fields: ${finalChangedFields.join(", ")}` + ); + } + + const deviceAHealthAfterRestart = await inspectSessionHealth(context.cliBinary, sessionA.cliEnv); + const deviceAHealth = { + matchingErrorMessages: [ + ...new Set([ + ...deviceAHealthBeforeRestart.matchingErrorMessages, + ...deviceAHealthAfterRestart.matchingErrorMessages, + ]), + ], + }; + if (deviceAHealth.matchingErrorMessages.length > 0 || deviceBHealth.matchingErrorMessages.length > 0) { + throw new Error( + `HKDF or Security Seed errors were logged: ${JSON.stringify({ + deviceA: deviceAHealth.matchingErrorMessages, + deviceB: deviceBHealth.matchingErrorMessages, + })}` + ); + } + + return { + source, + securitySeed: { + initial, + replacement, + final, + cachedBeforeReplacement, + cachedAfterRemoteReplacement, + cachedAfterReplication, + replacementChangedFields: changedSynchronisationParameterFields(initialDocument, replacementDocument), + finalChangedFields, + }, + synchronisation: { + deviceAToDeviceB: true, + deviceBToDeviceA: true, + deviceAEncryptedPayload, + deviceBEncryptedPayload, + }, + health: { + deviceA: deviceAHealth, + deviceB: deviceBHealth, + }, + screenshots: [...context.screenshots], + }; +} + +async function cleanupResources( + context: RunnerContext, + vaults: TemporaryVault[], + databaseCreated: boolean +): Promise { + const errors: unknown[] = []; + try { + await stopTrackedSessions(context); + } catch (error) { + errors.push(error); + } + for (const vault of vaults) { + try { + await vault.dispose(); + } catch (error) { + errors.push(error); + } + } + if (databaseCreated) { + try { + await deleteCouchDbDatabase(context.couchDb, context.dbName); + } catch (error) { + errors.push(error); + } + } + + const sessionsStopped = context.allSessions.every( + (session) => session.app.process.exitCode !== null || session.app.process.signalCode !== null + ); + const vaultRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.path))))).every( + Boolean + ); + const profileRemoved = (await Promise.all(vaults.map(async (vault) => !(await pathExists(vault.statePath))))).every( + Boolean + ); + let databaseRemoved = !databaseCreated; + if (databaseCreated) { + try { + databaseRemoved = !(await couchDbDatabaseExists(context.couchDb, context.dbName)); + } catch (error) { + errors.push(error); + } + } + const evidence = { + sessionsStopped, + vaultRemoved, + profileRemoved, + databaseRemoved, + remainingTrackedSessions: context.activeSessions.size, + }; + if (!sessionsStopped || !vaultRemoved || !profileRemoved || !databaseRemoved || context.activeSessions.size > 0) { + errors.push(new Error(`Security Seed E2E teardown was incomplete: ${JSON.stringify(evidence)}`)); + } + if (errors.length > 0) { + throw Object.assign(new MultipleErrors("Security Seed E2E teardown failed.", errors), { + evidence, + }); + } + return evidence; +} + +async function writeResult(result: unknown): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const resultPath = join(outputDirectory, "security-seed-reconnect-result.json"); + await mkdir(outputDirectory, { recursive: true }); + await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8"); + return resultPath; +} + +async function main(): Promise { + if (process.env.E2E_OBSIDIAN_KEEP_VAULT === "true" || process.env.E2E_OBSIDIAN_KEEP_COUCHDB === "true") { + throw new Error("The Security Seed reconnect scenario requires strict Vault, profile, and database cleanup."); + } + + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + } + const artifactRoot = resolve(process.env.E2E_OBSIDIAN_ARTIFACT_ROOT ?? process.cwd()); + const couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "security-seed-reconnect"); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + artifactRoot, + couchDb, + dbName, + activeSessions: new Set(), + allSessions: [], + screenshots: [], + }; + const vaults: TemporaryVault[] = []; + let databaseCreated = false; + let evidence: ScenarioEvidence | undefined; + let scenarioError: unknown; + let teardown: TeardownEvidence | undefined; + let teardownError: unknown; + + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + databaseCreated = true; + vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-a-")); + vaults.push(await createTemporaryVault("obsidian-livesync-security-seed-b-")); + evidence = await runScenario(context, vaults[0], vaults[1]); + } catch (error) { + scenarioError = error; + } finally { + try { + teardown = await cleanupResources(context, vaults, databaseCreated); + } catch (error) { + teardownError = error; + } + } + + if (scenarioError !== undefined || teardownError !== undefined) { + const errors = [scenarioError, teardownError].filter((error) => error !== undefined); + if (errors.length === 1) { + throw errors[0]; + } + throw new MultipleErrors("Security Seed reconnect scenario and teardown both failed.", errors); + } + if (!evidence || !teardown) { + throw new Error("Security Seed reconnect evidence was not produced."); + } + + const result = { + scenario: "security-seed-reconnect", + ...evidence, + teardown, + limitations: { + platformCommonRealObsidian: true, + iPadOsBackgroundReconnect: false, + androidDeviceLifecycle: false, + }, + }; + const resultPath = await writeResult(result); + console.log(`Security Seed E2E result: ${resultPath}`); + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; +}); From 99eb9cd46f44d350d083de90815e6624ea06e402 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 25 Jul 2026 03:49:39 +0000 Subject: [PATCH 152/170] Record beta.3 and current validation --- updates.md | 28 ++++++++++++++++++++++------ versions.json | 3 ++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/updates.md b/updates.md index 7d0ee73a..6c612c98 100644 --- a/updates.md +++ b/updates.md @@ -16,23 +16,39 @@ Earlier releases remain available in the 0.25 release history and the legacy rel - **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. -- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. -- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. -- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. -- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. -- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. - Text in setup and review dialogues can now be selected for copying or translation. ### Fixed - An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. - Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message. + +### Testing + +- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts. +- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation. +- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip. + +## 1.0.0-beta.3 + +24th July, 2026 + +### Improved + +- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. +- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. +- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. +- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. +- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. + +### Fixed + - Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. - Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. ### Testing -- Added regressions for revision repair, P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, selectable and mobile dialogues, conflict-aware chunk reachability, device-progress safeguards, compaction timeouts, shared chunks, collection propagation, and content-addressed chunk recreation. +- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. ## 1.0.0-beta.2 diff --git a/versions.json b/versions.json index a0f3dfb2..a4585f26 100644 --- a/versions.json +++ b/versions.json @@ -8,5 +8,6 @@ "0.25.83": "1.7.2", "1.0.0-beta.0": "1.7.2", "1.0.0-beta.1": "1.7.2", - "1.0.0-beta.2": "1.7.2" + "1.0.0-beta.2": "1.7.2", + "1.0.0-beta.3": "1.7.2" } From bea0e68091a7bd4481829763a66e9b152d711c13 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 25 Jul 2026 04:34:53 +0000 Subject: [PATCH 153/170] Keep translation details out of startup path --- .../onLayoutReady/enablei18n.ts | 83 ++++++++++--- .../onLayoutReady/enablei18n.unit.spec.ts | 110 ++++++++++++++++++ test/e2e-obsidian/README.md | 18 +++ updates.md | 1 + 4 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts diff --git a/src/serviceFeatures/onLayoutReady/enablei18n.ts b/src/serviceFeatures/onLayoutReady/enablei18n.ts index 83bdb9bc..5a83eafb 100644 --- a/src/serviceFeatures/onLayoutReady/enablei18n.ts +++ b/src/serviceFeatures/onLayoutReady/enablei18n.ts @@ -1,4 +1,4 @@ -import { getLanguage, requireApiVersion } from "@/deps"; +import { getLanguage, Notice, requireApiVersion } from "@/deps"; import { createServiceFeature } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule"; import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@/common/rosetta"; import { $msg, __onMissingTranslation, setLang } from "@/common/translation"; @@ -15,7 +15,40 @@ function tryGetLanguage(onError: (error: unknown) => void) { return "en"; } -export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => { +class ObsidianLanguageAppliedNotice { + private reminder: Notice | undefined; + + show(openDetails: () => void): void { + this.clear(); + let reminderAnchor: HTMLAnchorElement | undefined; + const appliedMessage = + $msg("dialog.yourLanguageAvailable") + .split(/\r?\n\s*\r?\n/u, 1)[0] + ?.trim() ?? $msg("Display Language"); + const fragment = createFragment((documentFragment) => { + documentFragment.createSpan({ + text: `${appliedMessage} `, + }); + documentFragment.createEl("a", { text: $msg("Open the dialog") }, (anchor) => { + reminderAnchor = anchor; + anchor.addEventListener("click", (event) => { + event.preventDefault(); + this.clear(); + openDetails(); + }); + }); + }); + this.reminder = new Notice(fragment, 0); + reminderAnchor?.closest(".notice")?.classList.add("livesync-language-applied-notice"); + } + + clear(): void { + this.reminder?.hide(); + this.reminder = undefined; + } +} + +export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => { // Clear missing translation handler to avoid unnecessary warnings. __onMissingTranslation(() => {}); let isChanged = false; @@ -36,26 +69,48 @@ export const enableI18nFeature = createServiceFeature(async ({ services: { setti // settings.displayLanguage = obsidianLanguage as I18N_LANGS; await setting.applyPartial({ displayLanguage: obsidianLanguage as I18N_LANGS }); isChanged = true; - setLang(settings.displayLanguage); + setLang(obsidianLanguage as I18N_LANGS); } else if (settings.displayLanguage == "") { // settings.displayLanguage = "def"; await setting.applyPartial({ displayLanguage: "def" }); - setLang(settings.displayLanguage); + setLang("def"); await setting.saveSettingData(); } } if (isChanged) { - const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault"); - if ( - (await API.confirm.askSelectStringDialogue($msg(`dialog.yourLanguageAvailable`), ["OK", revert], { - defaultAction: "OK", - title: $msg(`dialog.yourLanguageAvailable.Title`), - })) == revert - ) { - await setting.applyPartial({ displayLanguage: "def" }); - setLang(settings.displayLanguage); - } await setting.saveSettingData(); + const reminder = new ObsidianLanguageAppliedNotice(); + appLifecycle.onUnload.addHandler(() => { + reminder.clear(); + return Promise.resolve(true); + }); + reminder.show(() => { + void (async () => { + try { + const revert = $msg("dialog.yourLanguageAvailable.btnRevertToDefault"); + if ( + (await API.confirm.askSelectStringDialogue( + $msg(`dialog.yourLanguageAvailable`), + ["OK", revert], + { + defaultAction: "OK", + title: $msg("Display Language"), + } + )) == revert + ) { + await setting.applyPartial({ displayLanguage: "def" }); + setLang("def"); + await setting.saveSettingData(); + } + } catch (error) { + API.addLog( + `Failed to open translation details: ${String(error)}`, + LOG_LEVEL_VERBOSE, + "i18n-language" + ); + } + })(); + }); } return true; }); diff --git a/src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts b/src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts new file mode 100644 index 00000000..8f88eb62 --- /dev/null +++ b/src/serviceFeatures/onLayoutReady/enablei18n.unit.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const noticeState = vi.hoisted(() => ({ + instances: [] as Array<{ hide: ReturnType; duration: number }>, + spanTexts: [] as string[], +})); + +vi.mock("@/deps", () => ({ + getLanguage: () => "ja", + requireApiVersion: () => true, + Notice: class { + hide = vi.fn(); + + constructor(_fragment: unknown, duration: number) { + noticeState.instances.push({ hide: this.hide, duration }); + } + }, +})); + +vi.mock("@/common/translation", () => ({ + $msg: (key: string) => + ({ + "dialog.yourLanguageAvailable": "Translation has been applied.\n\nMore details.", + "dialog.yourLanguageAvailable.btnRevertToDefault": "Keep Default", + "dialog.yourLanguageAvailable.Title": "Translation is available!", + "Display Language": "Display language", + "Open the dialog": "Open the dialogue", + })[key] ?? key, + __onMissingTranslation: vi.fn(), + setLang: vi.fn(), +})); + +import { enableI18nFeature } from "./enablei18n.ts"; + +describe("automatic display language", () => { + let clickDetails: ((event: { preventDefault(): void }) => void) | undefined; + + beforeEach(() => { + noticeState.instances.length = 0; + noticeState.spanTexts.length = 0; + clickDetails = undefined; + vi.stubGlobal("createFragment", (build: (fragment: unknown) => void) => { + const anchor = { + addEventListener: (_event: string, listener: (event: { preventDefault(): void }) => void) => { + clickDetails = listener; + }, + closest: () => ({ classList: { add: vi.fn() } }), + }; + const fragment = { + createSpan: ({ text }: { text: string }) => noticeState.spanTexts.push(text), + createEl: (_tag: string, _options: unknown, configure: (element: typeof anchor) => void) => { + configure(anchor); + return anchor; + }, + }; + build(fragment); + return fragment; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("lets start-up continue and opens translation details only from a persistent Notice", async () => { + const settings = { displayLanguage: "" }; + const applyPartial = vi.fn(async (partial: Partial) => Object.assign(settings, partial)); + const saveSettingData = vi.fn().mockResolvedValue(undefined); + const askSelectStringDialogue = vi.fn().mockResolvedValue("Keep Default"); + const unloadHandlers: Array<() => Promise> = []; + const host = { + services: { + setting: { + currentSettings: () => settings, + applyPartial, + saveSettingData, + }, + API: { + addLog: vi.fn(), + confirm: { askSelectStringDialogue }, + }, + appLifecycle: { + onUnload: { + addHandler: (handler: () => Promise) => unloadHandlers.push(handler), + }, + }, + }, + }; + + await expect(enableI18nFeature(host as never)).resolves.toBe(true); + + expect(settings.displayLanguage).toBe("ja"); + expect(saveSettingData).toHaveBeenCalledOnce(); + expect(askSelectStringDialogue).not.toHaveBeenCalled(); + expect(noticeState.instances).toHaveLength(1); + expect(noticeState.instances[0]?.duration).toBe(0); + expect(noticeState.spanTexts).toEqual(["Translation has been applied. "]); + expect(clickDetails).toBeTypeOf("function"); + + clickDetails?.({ preventDefault: vi.fn() }); + await vi.waitFor(() => expect(askSelectStringDialogue).toHaveBeenCalledOnce()); + expect(askSelectStringDialogue.mock.calls[0]?.[2]).toMatchObject({ title: "Display language" }); + await vi.waitFor(() => expect(settings.displayLanguage).toBe("def")); + expect(saveSettingData).toHaveBeenCalledTimes(2); + + await expect(unloadHandlers[0]?.()).resolves.toBe(true); + expect(noticeState.instances[0]?.hide).toHaveBeenCalled(); + }); +}); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 73b17e1e..f7a452f8 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -30,6 +30,24 @@ On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile Multi-session workflows must keep each started Obsidian session tracked until its stop operation completes. If a scenario throws, teardown stops every active session before disposing its temporary Vault and profile, so a failed CLI or synchronisation operation cannot leave Obsidian using directories which have already been removed. +## Observing and diagnosing a scenario + +Use externally visible behaviour as the pass condition: Vault files, remote-service state, revision data, or visible Obsidian UI. A log line can explain a failure, but should not replace an assertion about the resulting behaviour. + +The maintained runner provides several complementary observation paths: + +- `evalObsidianJson()` and `obsidian-cli eval` can read a small, explicitly selected piece of LiveSync or Obsidian state. +- `withObsidianPage()` can inspect the active renderer, invoke a registered command, or interact with visible UI through CDP. `captureObsidianPage()`, `captureObsidianDialogue()`, and `captureObsidianElement()` retain screenshots; the capture helpers also write a full-page `.failure.png` before rethrowing a UI assertion failure. +- `session.app.output()` returns the standard output and standard error captured from the isolated Obsidian process. This is especially useful when the renderer or CLI becomes unreachable. +- **Show log** (`obsidian-livesync:view-log`) exposes the recent LiveSync log, while **Copy full report to clipboard** (`obsidian-livesync:dump-debug-info`) opens the generated diagnostic report. `dialog-mounts.ts` verifies both surfaces, and focused scenarios may inspect the log pane and `appLifecycle.getUnresolvedMessages()` for a bounded set of expected errors. +- Renderer `console` messages and uncaught page errors are not retained automatically. A focused investigation can attach `page.on("console", ...)` and `page.on("pageerror", ...)` while it owns a `withObsidianPage()` callback. That observer ends when the callback closes its CDP connection, so use it around the action under investigation rather than treating it as a session-wide audit trail. + +If a scenario times out or appears to do nothing, capture the visible page before teardown, then record a bounded state snapshot and the relevant tail of the LiveSync log, unresolved messages, and process output. If an unexplained Notice appears, retain a screenshot while it is still visible before opening or dismissing it, then use the log or full report to identify its source. A Notice alone is not enough evidence for its cause. + +Set `showVerboseLog: true` only in isolated plug-in data when a focused investigation needs it. Keep captured output short and redact it before retaining or sharing it: logs and reports can contain Vault paths, document names, endpoints, credentials, Setup URIs, passphrases, or Security Seed material. Do not collect verbose logs from an ordinary user Vault. + +Collect evidence before cleanup, and keep process, Vault, profile, and remote-fixture cleanup in `finally`. After `app.emulateMobile(true)`, use the active CDP renderer for fixture operations because Obsidian may remove desktop-only CLI commands. Visually inspect screenshots before copying selected images into user documentation; a passing locator assertion does not establish that a dialogue is readable or unobstructed. + ## Local Setup Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. Set `OBSIDIAN_CLI` as well when its companion executable is outside the built-in discovery paths. diff --git a/updates.md b/updates.md index 6c612c98..002bc6a8 100644 --- a/updates.md +++ b/updates.md @@ -17,6 +17,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel - **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Text in setup and review dialogues can now be selected for copying or translation. +- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. ### Fixed From 12fc43a69c94f5d42c8008baa5c773e47e3d04bf Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 25 Jul 2026 09:01:25 +0000 Subject: [PATCH 154/170] Improve recovery diagnostics and actions --- docs/specs_conflict_resolution.md | 19 +- docs/troubleshooting.md | 8 +- .../messages/LiveSyncProvisionalMessages.ts | 52 +- .../HiddenFileSync/CmdHiddenFileSync.ts | 125 +++- .../CmdHiddenFileSync.unit.spec.ts | 200 +++++- .../ConflictResolveModal.ts | 75 +- .../ConflictResolveModal.unit.spec.ts | 61 ++ .../features/SettingDialogue/PaneHatch.ts | 669 ++++++++++++++---- src/serviceFeatures/fileRepair.ts | 31 +- src/serviceFeatures/fileRepair.unit.spec.ts | 56 ++ src/serviceFeatures/fileRepairPresentation.ts | 144 ++++ .../fileRepairPresentation.unit.spec.ts | 230 ++++++ styles.css | 55 +- test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/scripts/revision-repair.ts | 494 +++++++++++-- updates.md | 2 +- 16 files changed, 1980 insertions(+), 243 deletions(-) create mode 100644 src/serviceFeatures/fileRepairPresentation.ts create mode 100644 src/serviceFeatures/fileRepairPresentation.unit.spec.ts diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md index f74c137d..25dfd012 100644 --- a/docs/specs_conflict_resolution.md +++ b/docs/specs_conflict_resolution.md @@ -50,11 +50,24 @@ The compatibility implementation currently selects the newer modification time f A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing. -**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. It reports exact revision identifiers and local chunk availability separately: +**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved. +Each reported live revision has a compact **…** menu. The available actions depend on the exact revision and current Vault state: + +- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files. +- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation. +- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection. +- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch. +- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance. - **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree. -- **Discard unreadable revision** is available only for a current winner or conflict revision which remains unreadable when the action is performed. It requires confirmation and creates a logical deletion for that exact revision. -- A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually. +- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file. +- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains. + +Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch. + +The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win. + +A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually. Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 82931923..95434b6c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -57,10 +57,12 @@ If the log reports missing chunks or a size mismatch: 2. restart Obsidian once to rule out an interrupted fetch; 3. synchronise a device or restore a backup which still has the correct content; 4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise; -5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; and -6. use `Discard unreadable revision` only after confirming that the exact revision is no longer recoverable or wanted. +5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; use each revision's **…** menu to compare readable text, apply that exact revision to the Vault, store the Vault file as its child, or record an exact byte-for-byte match; and +6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf. -`Retry reading revision` does not change the revision tree. `Discard unreadable revision` creates a logical deletion for one current winner or conflict revision after rechecking it. It does not purge history or reconstruct missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions. +The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted. + +`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions. `Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 4618edc9..6353e8d8 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -74,21 +74,51 @@ export const liveSyncProvisionalEnglishMessages = { "Database information for ${FILE}": "Database information for ${FILE}", "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.": "All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.", - "Vault file: modified ${TIME}, size ${SIZE}": "Vault file: modified ${TIME}, size ${SIZE}", - "Vault file: missing": "Vault file: missing", - "Local database document: missing": "Local database document: missing", + "📁 Vault: ${SIZE} B · ${TIME}": "📁 Vault: ${SIZE} B · ${TIME}", + "📁 Vault: missing": "📁 Vault: missing", + "🗄️ Local DB: missing": "🗄️ Local DB: missing", + "Vault and database revision": "Vault and database revision", + "Vault file": "Vault file", + "Database revision": "Database revision", + "Vault file is newer": "Vault file is newer", + "Database revision is newer": "Database revision is newer", + "Within the two-second comparison window": "Within the two-second comparison window", + "Timestamp comparison unavailable": "Timestamp comparison unavailable", "${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}", "Winner revision": "Winner revision", "Conflict revision": "Conflict revision", "Unknown revision": "Unknown revision", - "Logical deletion": "Logical deletion", + "🗑️ Logical deletion": "🗑️ Logical deletion", "Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}": "Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", - "Unreadable on this device; ${COUNT} referenced chunks are missing or deleted": - "Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", - "Matches the current Vault file": "Matches the current Vault file", - "Differs from the current Vault file": "Differs from the current Vault file", + "🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}", + "📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B": + "📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B", + "📦 DB: recorded ${RECORDED} B · decoded unavailable": + "📦 DB: recorded ${RECORDED} B · decoded unavailable", + "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B": + "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B", + "🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})": + "🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})", + "✅ Matches Vault": "✅ Matches Vault", + "⚠️ Differs from Vault": "⚠️ Differs from Vault", + "✅ Vault matches winner": "✅ Vault matches winner", + "⚠️ Conflicts: ${COUNT}": "⚠️ Conflicts: ${COUNT}", + "Compare with Vault": "Compare with Vault", + "Apply this revision to Vault": "Apply this revision to Vault", + "Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.": + "Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.", + "Apply database revision to Vault": "Apply database revision to Vault", + "Mark this revision as the Vault version": "Mark this revision as the Vault version", + "Store Vault file as a child of this revision": "Store Vault file as a child of this revision", + "Apply logical deletion to Vault": "Apply logical deletion to Vault", + "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.": + "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.", "Retry reading revision": "Retry reading revision", + "Discard this branch": "Discard this branch", + "Discard branch": "Discard branch", + "Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.": + "Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.", "Discard unreadable revision": "Discard unreadable revision", "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.": "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.", @@ -97,9 +127,11 @@ export const liveSyncProvisionalEnglishMessages = { "Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.", "No shared ancestor is available for this conflict. The live revisions remain available for explicit review.": "No shared ancestor is available for this conflict. The live revisions remain available for explicit review.", + "More actions for revision ${REVISION}": "More actions for revision ${REVISION}", + "More actions for ${FILE}": "More actions for ${FILE}", "Show revision history": "Show revision history", - "Use Vault file in local database": "Use Vault file in local database", - "Restore database winner to Vault": "Restore database winner to Vault", + "Store Vault file as a new local database document": + "Store Vault file as a new local database document", "Copy database information": "Copy database information", "Recreate chunks for current Vault files": "Recreate chunks for current Vault files", "Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.": diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.ts index 92826376..ca21b320 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.ts @@ -1530,6 +1530,29 @@ Offline Changed files: ${files.length}`; } } + private async getLiveInternalRevision( + prefixedFileName: FilePathWithPrefix, + revision: string + ): Promise { + const [selected, current, conflicts] = await Promise.all([ + this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true), + this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true), + this.core.databaseFileAccess.getConflictedRevs(prefixedFileName), + ]); + const liveRevisions = new Set([ + ...(current && current._rev ? [current._rev] : []), + ...conflicts, + ]); + if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) { + this._log( + `Could not use hidden-file revision ${revision} of ${stripAllPrefixes(prefixedFileName)}; the selected revision is no longer live`, + LOG_LEVEL_NOTICE + ); + return false; + } + return selected; + } + async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) { const storeFilePath = stripAllPrefixes(file.path); const storageFilePath = file.path; @@ -1581,6 +1604,79 @@ Offline Changed files: ${files.length}`; }); } + async storeInternalFileToDatabaseWithBaseRevision( + file: InternalFileInfo | UXFileInfo, + baseRevision: string, + createIfDifferent = true + ): Promise { + const storeFilePath = stripAllPrefixes(file.path); + const storageFilePath = file.path; + if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { + return false; + } + const prefixedFileName = addPrefix(storeFilePath, ICHeader); + + return await serialized("file-" + prefixedFileName, async () => { + try { + const baseData = await this.getLiveInternalRevision(prefixedFileName, baseRevision); + if (baseData === false) { + return false; + } + const fileInfo = "stat" in file && "body" in file ? file : await this.loadFileWithInfo(storeFilePath); + if (fileInfo.deleted) { + throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`); + } + if (!baseData.deleted && !baseData._deleted) { + const loadedBase = await this.core.databaseFileAccess.fetchEntryFromMeta(baseData, true, true); + if (loadedBase && (await isDocContentSame(readAsBlob(loadedBase), fileInfo.body))) { + this.updateLastProcessed(storeFilePath, baseData, fileInfo.stat); + return true; + } + } + if (!createIfDifferent) { + this._log( + `Could not mark hidden file ${storeFilePath} as revision ${baseRevision}; the storage content differs`, + LOG_LEVEL_NOTICE + ); + return false; + } + + const storedRevision = await this.core.databaseFileAccess.storeWithBaseRevision( + { + ...fileInfo, + path: storeFilePath, + name: fileInfo.name || storeFilePath.split("/").pop() || "", + isInternal: true, + }, + baseRevision, + true + ); + if (storedRevision === false) { + return false; + } + this.updateLastProcessed( + storeFilePath, + { + ...baseData, + _rev: storedRevision, + path: prefixedFileName, + ctime: fileInfo.stat.ctime, + mtime: fileInfo.stat.mtime, + size: fileInfo.stat.size, + deleted: false, + }, + fileInfo.stat + ); + this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Done`); + return true; + } catch (ex) { + this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Failed`); + this._log(ex, LOG_LEVEL_VERBOSE); + return false; + } + }); + } + async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) { const storeFilePath = filenameSrc; const storageFilePath = filenameSrc; @@ -1644,7 +1740,8 @@ Offline Changed files: ${files.length}`; metaEntry?: MetaEntry | LoadedEntry, preventDoubleProcess = true, onlyNew = false, - includeDeletion = true + includeDeletion = true, + requiredLiveRevision?: string ) { const prefixedFileName = addPrefix(storageFilePath, ICHeader); if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) { @@ -1653,9 +1750,11 @@ Offline Changed files: ${files.length}`; return await serialized("file-" + prefixedFileName, async () => { try { // Check conflicted status - const metaOnDB = metaEntry - ? metaEntry - : await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true); + const metaOnDB = requiredLiveRevision + ? await this.getLiveInternalRevision(prefixedFileName, requiredLiveRevision) + : metaEntry + ? metaEntry + : await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true); if (metaOnDB === false) throw new Error(`File not found on database.:${storageFilePath}`); // Prevent overwrite for Prevent overwriting while some conflicted revision exists. if (metaOnDB?._conflicts?.length) { @@ -1729,6 +1828,24 @@ Offline Changed files: ${files.length}`; }); } + async extractInternalFileRevisionFromDatabase( + storageFilePath: FilePath, + revision: string, + force = false + ): Promise { + return Boolean( + await this.extractInternalFileFromDatabase( + storageFilePath, + force, + undefined, + true, + false, + true, + revision + ) + ); + } + async __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise { try { const storageContent = await this.core.storageAccess.readHiddenFileAuto(storageFilePath); diff --git a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts index 7e7685d2..ad664a0f 100644 --- a/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts +++ b/src/features/HiddenFileSync/CmdHiddenFileSync.unit.spec.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + type DocumentID, + LOG_LEVEL_NOTICE, + type FilePath, + type FilePathWithPrefix, + type MetaEntry, + type UXFileInfo, +} from "@vrtmrz/livesync-commonlib/compat/common/types"; vi.mock("@/deps.ts", () => ({})); vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({ @@ -27,6 +34,70 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({ import { HiddenFileSync } from "./CmdHiddenFileSync.ts"; import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts"; +function createHiddenRevisionOperation() { + const path = ".obsidian/plugins/example/data.json" as FilePath; + const file = { + path, + name: "data.json", + isInternal: true, + body: new Blob(["{\"value\":\"vault\"}"]), + stat: { + ctime: 1, + mtime: 2, + size: 17, + type: "file", + }, + } as UXFileInfo; + const selected = { + _id: "i:example" as DocumentID, + _rev: "2-selected", + path: `i:${path}` as FilePathWithPrefix, + ctime: 1, + mtime: 2, + size: 17, + type: "plain", + datatype: "plain", + children: [], + eden: {}, + deleted: false, + } as MetaEntry; + const winner = { + ...selected, + _rev: "3-winner", + } as MetaEntry; + const databaseFileAccess = { + fetchEntryMeta: vi.fn( + async (_path: unknown, revision?: string) => + revision === selected._rev ? selected : winner + ), + getConflictedRevs: vi.fn(async () => [selected._rev]), + fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })), + storeWithBaseRevision: vi.fn(async () => "3-vault-child"), + }; + const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync; + Object.assign(hiddenFileSync, { + core: { + services: { + vault: { + isIgnoredByIgnoreFile: vi.fn(async () => false), + }, + }, + databaseFileAccess, + }, + loadFileWithInfo: vi.fn(async () => file), + updateLastProcessed: vi.fn(), + _log: vi.fn(), + }); + return { + hiddenFileSync, + path, + file, + selected, + winner, + databaseFileAccess, + }; +} + describe("HiddenFileSync configuration-change notices", () => { it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => { const commands: Array<{ @@ -270,3 +341,130 @@ describe("HiddenFileSync configuration-change notices", () => { expect(progress.done).toHaveBeenCalledWith("Failed"); }); }); + +describe("HiddenFileSync exact revision repair operations", () => { + it("stores the current hidden Vault file as a child of the selected live revision", async () => { + const { + hiddenFileSync, + file, + selected, + databaseFileAccess, + } = createHiddenRevisionOperation(); + + await expect( + hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!) + ).resolves.toBe(true); + + expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith( + expect.objectContaining({ + path: file.path, + body: file.body, + isInternal: true, + }), + selected._rev, + true + ); + expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith( + file.path, + expect.objectContaining({ _rev: "3-vault-child" }), + file.stat + ); + }); + + it("refuses to extend a hidden-file revision which is no longer live", async () => { + const { + hiddenFileSync, + file, + selected, + databaseFileAccess, + } = createHiddenRevisionOperation(); + databaseFileAccess.getConflictedRevs.mockResolvedValue([]); + + await expect( + hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!) + ).resolves.toBe(false); + + expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); + expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled(); + }); + + it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => { + const { + hiddenFileSync, + file, + selected, + databaseFileAccess, + } = createHiddenRevisionOperation(); + + await expect( + hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision( + file, + selected._rev!, + false + ) + ).resolves.toBe(false); + + expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); + expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled(); + }); + + it("marks a matching hidden-file revision without creating a child", async () => { + const { + hiddenFileSync, + file, + selected, + databaseFileAccess, + } = createHiddenRevisionOperation(); + databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({ + ...selected, + data: "{\"value\":\"vault\"}", + }); + + await expect( + hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision( + file, + selected._rev!, + false + ) + ).resolves.toBe(true); + + expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled(); + expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith( + file.path, + selected, + file.stat + ); + }); + + it("applies the selected live hidden-file revision through the existing extraction path", async () => { + const { + hiddenFileSync, + path, + selected, + } = createHiddenRevisionOperation(); + const extract = vi.fn(async () => true); + hiddenFileSync.extractInternalFileFromDatabase = extract; + + await expect( + hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true) + ).resolves.toBe(true); + + expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev); + }); + + it("does not apply a hidden-file revision which ceased to be live", async () => { + const { + hiddenFileSync, + path, + selected, + databaseFileAccess, + } = createHiddenRevisionOperation(); + databaseFileAccess.getConflictedRevs.mockResolvedValue([]); + + await expect( + hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true) + ).resolves.toBe(false); + + expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts index 8e53f4a9..838f7be1 100644 --- a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts +++ b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts @@ -13,6 +13,13 @@ export const POSTPONED = Symbol("postponed"); export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string; +export type ConflictResolveModalOptions = { + readOnly?: boolean; + title?: string; + localName?: string; + remoteName?: string; +}; + export class ConflictResolveModal extends Modal { result: diff_result; filename: FilePathWithPrefix; @@ -25,6 +32,7 @@ export class ConflictResolveModal extends Modal { title: string = "Conflicting changes"; pluginPickMode: boolean = false; + readOnly: boolean = false; localName: string = "Base"; remoteName: string = "Conflicted"; offEvent?: ReturnType; @@ -37,16 +45,22 @@ export class ConflictResolveModal extends Modal { filename: FilePathWithPrefix, diff: diff_result, pluginPickMode?: boolean, - remoteName?: string + remoteName?: string, + options?: ConflictResolveModalOptions ) { super(app); this.result = diff; this.filename = filename; this.pluginPickMode = pluginPickMode || false; + this.readOnly = options?.readOnly ?? false; if (this.pluginPickMode) { this.title = "Pick a version"; this.remoteName = `${remoteName || "Remote"}`; this.localName = "Local"; + } else if (this.readOnly) { + this.title = options?.title ?? "Vault and database revision"; + this.localName = options?.localName ?? "Vault file"; + this.remoteName = options?.remoteName ?? "Database revision"; } } @@ -101,16 +115,18 @@ export class ConflictResolveModal extends Modal { if (this.offEvent) { this.offEvent(); } - // Cancel an older dialogue for this path before subscribing this - // instance. Emitting after subscription would close the replacement - // itself; the instance-owned result promise then completes the older - // caller even when it only begins waiting after this event. - eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename); - this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => { - if (path === this.filename) { - this.sendResponse(CANCELLED); - } - }); + if (!this.readOnly) { + // Cancel an older dialogue for this path before subscribing this + // instance. Emitting after subscription would close the replacement + // itself; the instance-owned result promise then completes the older + // caller even when it only begins waiting after this event. + eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename); + this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => { + if (path === this.filename) { + this.sendResponse(CANCELLED); + } + }); + } this.titleEl.setText(this.title); contentEl.empty(); const diffOptionsRow = contentEl.createDiv(""); @@ -159,24 +175,31 @@ export class ConflictResolveModal extends Modal { this.appendVersionInfo(div2, "deleted", this.localName, date1); this.appendVersionInfo(div2, "added", this.remoteName, date2); const actionContainer = contentEl.createDiv("conflict-action-container"); - actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => { - e.addClass("conflict-action-button"); - e.addEventListener("click", () => this.sendResponse(this.result.right.rev)); - }); - actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => { - e.addClass("conflict-action-button"); - e.addEventListener("click", () => this.sendResponse(this.result.left.rev)); - }); - if (!this.pluginPickMode) { - actionContainer.createEl("button", { text: "Concat both" }, (e) => { + if (this.readOnly) { + actionContainer.createEl("button", { text: "Close" }, (e) => { e.addClass("conflict-action-button"); - e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT)); + e.addEventListener("click", () => this.sendResponse(CANCELLED)); + }); + } else { + actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => { + e.addClass("conflict-action-button"); + e.addEventListener("click", () => this.sendResponse(this.result.right.rev)); + }); + actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => { + e.addClass("conflict-action-button"); + e.addEventListener("click", () => this.sendResponse(this.result.left.rev)); + }); + if (!this.pluginPickMode) { + actionContainer.createEl("button", { text: "Concat both" }, (e) => { + e.addClass("conflict-action-button"); + e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT)); + }); + } + actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => { + e.addClass("conflict-action-button"); + e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED)); }); } - actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => { - e.addClass("conflict-action-button"); - e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED)); - }); if (diffLength > 100 * 1024) { this.diffView.empty(); this.diffView.setText("(Too large diff to display)"); diff --git a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.unit.spec.ts b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.unit.spec.ts index aaf154d7..ded31a0a 100644 --- a/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.unit.spec.ts +++ b/src/modules/features/InteractiveConflictResolving/ConflictResolveModal.unit.spec.ts @@ -5,6 +5,8 @@ import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/li vi.mock("@/deps.ts", () => ({ App: class App {}, Modal: class Modal { + createdButtons: string[] = []; + private createElement(): Record { const element: Record = { addClass: vi.fn(), @@ -22,6 +24,14 @@ vi.mock("@/deps.ts", () => ({ }; element.createDiv = vi.fn(() => this.createElement()); element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => { + if ( + _tag === "button" && + typeof _options === "object" && + _options !== null && + "text" in _options + ) { + this.createdButtons.push(String((_options as { text: unknown }).text)); + } const child = this.createElement(); callback?.(child); return child; @@ -82,4 +92,55 @@ describe("ConflictResolveModal result lifecycle", () => { expect(previousResult).toBe(CANCELLED); expect(replacementState).toBe("still-open"); }); + + it("renders a read-only comparison with no resolution actions", () => { + const ReadOnlyModal = ConflictResolveModal as unknown as new ( + ...args: unknown[] + ) => ConflictResolveModal & { createdButtons: string[] }; + const modal = new ReadOnlyModal( + {}, + "repair-preview.md", + conflict, + false, + undefined, + { + readOnly: true, + title: "Vault and database revision", + localName: "Vault file", + remoteName: "Database revision", + } + ); + + modal.onOpen(); + + expect(modal.createdButtons).toContain("Close"); + expect(modal.createdButtons).not.toContain("Use Vault file"); + expect(modal.createdButtons).not.toContain("Use Database revision"); + expect(modal.createdButtons).not.toContain("Concat both"); + expect(modal.createdButtons).not.toContain("Not now"); + modal.close(); + }); + + it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => { + const filename = "repair-alongside-conflict.md" as FilePathWithPrefix; + const previous = new ConflictResolveModal({} as never, filename, conflict); + const ReadOnlyModal = ConflictResolveModal as unknown as new ( + ...args: unknown[] + ) => ConflictResolveModal; + const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, { + readOnly: true, + }); + previous.onOpen(); + + comparison.onOpen(); + const previousState = await Promise.race([ + previous.waitForResult(), + new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)), + ]); + + previous.sendResponse(CANCELLED); + comparison.close(); + + expect(previousState).toBe("still-open"); + }); }); diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 4cf67e40..63b5c79f 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -3,13 +3,14 @@ import { type DocumentID, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, - type MetaEntry, type FilePath, type EntryDoc, + type diff_result, } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import { Menu, diff_match_patch } from "@/deps.ts"; import { $msg } from "@/common/translation"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; @@ -20,7 +21,6 @@ import { EVENT_REQUEST_RUN_FIX_INCOMPLETE, eventHub, } from "@/common/events.ts"; -import { ICHeader } from "@/common/types.ts"; import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts"; import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts"; import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; @@ -33,11 +33,17 @@ import { retryReadFileDatabaseRevision, } from "@/serviceFeatures/fileDatabaseInfo.ts"; import { + discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, type FileRepairInspection, type FileRepairRevision, } from "@/serviceFeatures/fileRepair.ts"; +import { + getFileRepairRevisionActions, + getFileRepairRevisionComparison, +} from "@/serviceFeatures/fileRepairPresentation.ts"; +import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts"; export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void { // const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` }); // hatchWarn.addClass("op-warn-info"); @@ -123,57 +129,195 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, void addPanel(paneEl, "Recovery and Repair").then((paneEl) => { const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" }); - const addActionButton = ( + type RepairMenuAction = { + title: string; + run: () => Promise | void; + warning?: boolean; + }; + const addActionMenu = ( parent: HTMLElement, - text: string, - action: (button: HTMLButtonElement) => Promise | void, - warning = false + label: string, + actions: RepairMenuAction[] ) => { - this.createEl(parent, "button", { text }, (button) => { - if (warning) { - button.addClass("mod-warning"); - } - button.onClickEvent(async () => { - button.disabled = true; - try { - await action(button); - } finally { - if (button.isConnected) { - button.disabled = false; - } + if (actions.length === 0) { + return; + } + this.createEl(parent, "button", { text: "…", cls: "sls-repair-action-menu" }, (button) => { + button.setAttr("aria-label", label); + button.setAttr("title", label); + button.onClickEvent(() => { + const menu = new Menu(); + for (const action of actions) { + menu.addItem((item) => { + item.setTitle(action.title); + if (action.warning) { + item.setWarning(true); + } + item.onClick(() => { + button.disabled = true; + void Promise.resolve() + .then(() => action.run()) + .catch((error) => { + Logger(error, LOG_LEVEL_VERBOSE); + Logger( + `Repair action '${action.title}' failed`, + LOG_LEVEL_NOTICE + ); + }) + .finally(() => { + if (button.isConnected) { + button.disabled = false; + } + }); + }); + }); } + const rect = button.getBoundingClientRect(); + menu.showAtPosition({ x: rect.left, y: rect.bottom }); }); }); }; + const findHiddenFile = async (path: string) => { + const addOn = this.core.getAddOn(HiddenFileSync.name); + if (!addOn) { + return false; + } + const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path); + if (!file) { + Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE); + return false; + } + return { addOn, file }; + }; const storeStorageInDatabase = async (path: string): Promise => { if (path.startsWith(".")) { - const addOn = this.core.getAddOn(HiddenFileSync.name); - if (!addOn) { - return false; - } - const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path); - if (!file) { - Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE); - return false; - } - return Boolean(await addOn.storeInternalFileToDatabase(file, true)); + const hidden = await findHiddenFile(path); + return hidden + ? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true)) + : false; } return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true)); }; - const applyWinnerToStorage = async ( + const storeStorageOnRevision = async ( path: string, - revision: FileRepairRevision + revision: string, + createIfDifferent = true ): Promise => { - if (revision.loadedEntry === false) { - return false; - } - if (revision.loadedEntry.path.startsWith(ICHeader)) { - const addOn = this.core.getAddOn(HiddenFileSync.name); - return addOn - ? Boolean(await addOn.extractInternalFileFromDatabase(path as FilePath, true)) + if (path.startsWith(".")) { + const hidden = await findHiddenFile(path); + return hidden + ? Boolean( + await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision( + hidden.file, + revision, + createIfDifferent + ) + ) : false; } - return Boolean(await this.core.fileHandler.dbToStorage(revision.loadedEntry as MetaEntry, null, true)); + return Boolean( + await this.core.fileHandler.storeFileToDBWithBaseRevision( + path as FilePath, + revision, + createIfDifferent + ) + ); + }; + const applyRevisionToStorage = async ( + path: string, + revision: string, + force: boolean + ): Promise => { + if (path.startsWith(".")) { + const addOn = this.core.getAddOn(HiddenFileSync.name); + return addOn + ? Boolean( + await addOn.extractInternalFileRevisionFromDatabase( + path as FilePath, + revision, + force + ) + ) + : false; + } + return Boolean( + await this.core.fileHandler.dbToStorageWithSpecificRev( + path as FilePath, + revision, + force + ) + ); + }; + const openRevisionComparison = async ( + path: string, + selectedRevision: string + ): Promise => { + const latest = await inspectFileRepair(this.core, path); + const revision = latest.revisions.find( + ({ metadata }) => metadata.revision === selectedRevision + ); + if ( + !latest.information.storage.exists || + !revision || + revision.loadedEntry === false + ) { + Logger( + `Could not compare ${path} revision ${selectedRevision}; the Vault file or selected live revision is no longer readable`, + LOG_LEVEL_NOTICE + ); + return false; + } + const vaultText = await createBlob( + await this.core.storageAccess.readHiddenFileBinary(path) + ).text(); + const databaseText = await readAsBlob(revision.loadedEntry).text(); + const dmp = new diff_match_patch(); + const diff = dmp.diff_main(vaultText, databaseText); + dmp.diff_cleanupSemantic(diff); + const result: diff_result = { + left: { + rev: "vault", + data: vaultText, + ctime: latest.information.storage.ctime ?? 0, + mtime: latest.information.storage.mtime ?? 0, + }, + right: { + rev: selectedRevision, + data: databaseText, + ctime: revision.metadata.ctime, + mtime: revision.metadata.mtime, + }, + diff, + }; + new ConflictResolveModal( + this.app, + path as FilePathWithPrefix, + result, + false, + undefined, + { + readOnly: true, + title: $msg("Vault and database revision"), + localName: $msg("Vault file"), + remoteName: $msg("Database revision"), + } + ).open(); + return true; + }; + const formatSigned = (value: number) => `${value >= 0 ? "+" : ""}${value}`; + const timestampRelationLabel = ( + relation: ReturnType["timestampRelation"] + ) => { + switch (relation) { + case "vault-newer": + return $msg("Vault file is newer"); + case "database-newer": + return $msg("Database revision is newer"); + case "same-window": + return $msg("Within the two-second comparison window"); + default: + return $msg("Timestamp comparison unavailable"); + } }; const addRepairResult = (inspection: FileRepairInspection) => { const { information, revisions } = inspection; @@ -188,40 +332,128 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, Logger(`Verification no longer reports a problem for ${path}`, LOG_LEVEL_NOTICE); } }; + const runMutation = async ( + description: string, + mutation: () => Promise + ) => { + try { + const succeeded = await mutation(); + if (!succeeded) { + Logger(`${description} failed: ${path}`, LOG_LEVEL_NOTICE); + } + } finally { + await refresh(); + } + }; + const discardLiveBranchAction = (revision: string): RepairMenuAction => ({ + title: $msg("Discard this branch"), + warning: true, + run: async () => { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.", + { + REVISION: revision, + FILE: path, + } + ), + { + title: $msg("Discard branch"), + defaultOption: "No", + } + )) === "yes"; + if (!confirmed) { + return; + } + const result = await discardLiveBranch(this.core, path, revision); + Logger( + `Discard database branch ${revision} of ${path}: ${result}`, + result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE + ); + await refresh(); + }, + }); - this.createEl(card, "h6", { text: path }); + const fileHeader = this.createEl(card, "div", { cls: "sls-repair-header" }); + this.createEl(fileHeader, "h6", { text: path }); + const fileMenuHost = this.createEl(fileHeader, "div"); if (information.storage.exists) { this.createEl(card, "div", { - text: $msg("Vault file: modified ${TIME}, size ${SIZE}", { + text: $msg("📁 Vault: ${SIZE} B · ${TIME}", { TIME: new Date(information.storage.mtime ?? 0).toLocaleString(), SIZE: `${information.storage.size ?? 0}`, }), + cls: "sls-repair-metric", }); } else { - this.createEl(card, "div", { text: $msg("Vault file: missing") }); + this.createEl(card, "div", { + text: $msg("📁 Vault: missing"), + cls: "sls-repair-metric", + }); } if (!information.database.exists) { - this.createEl(card, "div", { text: $msg("Local database document: missing") }); + this.createEl(card, "div", { + text: $msg("🗄️ Local DB: missing"), + cls: "sls-repair-metric", + }); + } + if (information.database.conflictCount > 0) { + const winner = revisions.find(({ role }) => role === "winner"); + const vaultMatchesWinner = + winner !== undefined && + (winner.metadata.deleted + ? !information.storage.exists + : information.storage.exists && + winner.contentMatchesStorage === true); + const status = this.createEl(card, "div", { cls: "sls-repair-status" }); + if (vaultMatchesWinner) { + this.createEl(status, "span", { + text: $msg("✅ Vault matches winner"), + cls: "sls-repair-status-ok", + }); + } + this.createEl(status, "span", { + text: $msg("⚠️ Conflicts: ${COUNT}", { + COUNT: `${information.database.conflictCount}`, + }), + cls: "sls-repair-status-warning", + }); } const addRevision = (revision: FileRepairRevision) => { const { metadata } = revision; const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" }); - this.createEl(revisionEl, "div", { + const revisionHeader = this.createEl(revisionEl, "div", { + cls: "sls-repair-header", + }); + this.createEl(revisionHeader, "div", { text: $msg("${ROLE}: ${REVISION}", { ROLE: revision.role === "winner" ? $msg("Winner revision") : $msg("Conflict revision"), REVISION: metadata.revision ?? $msg("Unknown revision"), }), cls: "sls-repair-revision-title", }); + const revisionMenuHost = this.createEl(revisionHeader, "div"); + const comparison = getFileRepairRevisionComparison(inspection, revision); if (metadata.deleted) { - this.createEl(revisionEl, "div", { text: $msg("Logical deletion") }); + this.createEl(revisionEl, "div", { + text: $msg("🗑️ Logical deletion"), + cls: "sls-repair-metric", + }); } else if (revision.contentReadable) { this.createEl(revisionEl, "div", { - text: $msg("Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", { - RECORDED: `${metadata.recordedSize}`, - ACTUAL: `${revision.loadedEntry === false ? 0 : readAsBlob(revision.loadedEntry).size}`, - }), + text: $msg( + "📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B", + { + RECORDED: `${comparison.recordedSize}`, + DECODED: `${comparison.decodedSize ?? 0}`, + DIFFERENCE: formatSigned( + comparison.recordedToDecodedSizeDifference ?? 0 + ), + } + ), + cls: "sls-repair-metric", }); } else { const missing = metadata.chunks.filter( @@ -229,10 +461,16 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, !embedded && localDatabaseState !== "available" ); this.createEl(revisionEl, "div", { - text: $msg("Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", { + text: $msg("🧩 Missing chunks: ${COUNT}", { COUNT: `${missing.length}`, }), - cls: "mod-warning", + cls: "sls-repair-metric mod-warning", + }); + this.createEl(revisionEl, "div", { + text: $msg("📦 DB: recorded ${RECORDED} B · decoded unavailable", { + RECORDED: `${comparison.recordedSize}`, + }), + cls: "sls-repair-metric", }); if (missing.length > 0) { this.createEl(revisionEl, "code", { @@ -243,28 +481,196 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, }); } } + if ( + comparison.vaultSize !== null && + comparison.databaseToVaultSizeDifference !== null + ) { + this.createEl(revisionEl, "div", { + text: $msg("📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B", { + VAULT: `${comparison.vaultSize}`, + DIFFERENCE: formatSigned( + comparison.databaseToVaultSizeDifference + ), + }), + cls: "sls-repair-metric", + }); + } + if ( + comparison.vaultMtime !== null && + comparison.timestampDifferenceMs !== null + ) { + this.createEl(revisionEl, "div", { + text: $msg( + "🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})", + { + DATABASE_TIME: new Date( + comparison.databaseMtime + ).toLocaleString(), + VAULT_TIME: new Date( + comparison.vaultMtime + ).toLocaleString(), + DIFFERENCE: formatSigned( + comparison.timestampDifferenceMs + ), + RELATION: timestampRelationLabel( + comparison.timestampRelation + ), + } + ), + cls: "sls-repair-metric", + }); + } if (revision.contentMatchesStorage === true) { - this.createEl(revisionEl, "div", { text: $msg("Matches the current Vault file") }); + this.createEl(revisionEl, "div", { + text: $msg("✅ Matches Vault"), + cls: "sls-repair-metric", + }); } else if (revision.contentMatchesStorage === false) { - this.createEl(revisionEl, "div", { text: $msg("Differs from the current Vault file") }); + this.createEl(revisionEl, "div", { + text: $msg("⚠️ Differs from Vault"), + cls: "sls-repair-metric mod-warning", + }); } - if (!metadata.deleted && !revision.contentReadable && metadata.revision) { - const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" }); - addActionButton(actions, $msg("Retry reading revision"), async () => { - const loaded = await retryReadFileDatabaseRevision(this.core, path, metadata.revision!); - Logger( - loaded - ? `Revision ${metadata.revision} of ${path} is readable after retry` - : `Revision ${metadata.revision} of ${path} remains unreadable`, - LOG_LEVEL_NOTICE - ); - await refresh(); + const policy = getFileRepairRevisionActions(inspection, revision); + const revisionActions: RepairMenuAction[] = []; + if (metadata.revision && policy.compareWithVault) { + revisionActions.push({ + title: $msg("Compare with Vault"), + run: async () => { + await openRevisionComparison(path, metadata.revision!); + }, }); - addActionButton( - actions, - $msg("Discard unreadable revision"), - async () => { + } + if (metadata.revision && policy.applyRevisionToVault) { + revisionActions.push({ + title: $msg("Apply this revision to Vault"), + run: async () => { + if (await this.core.storageAccess.isExistsIncludeHidden(path)) { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.", + { + REVISION: metadata.revision!, + FILE: path, + } + ), + { + title: $msg("Apply database revision to Vault"), + defaultOption: "No", + } + )) === "yes"; + if (!confirmed) { + return; + } + } + await runMutation( + `Apply database revision ${metadata.revision} to the Vault`, + () => + applyRevisionToStorage( + path, + metadata.revision!, + true + ) + ); + }, + }); + } + if (metadata.revision && policy.markAsVaultRevision) { + revisionActions.push({ + title: $msg("Mark this revision as the Vault version"), + run: async () => { + await runMutation( + `Mark database revision ${metadata.revision} as the Vault version`, + () => + storeStorageOnRevision( + path, + metadata.revision!, + false + ) + ); + }, + }); + } + if (metadata.revision && policy.storeVaultOnBranch) { + revisionActions.push({ + title: $msg("Store Vault file as a child of this revision"), + run: async () => { + await runMutation( + `Store the Vault file on database revision ${metadata.revision}`, + () => + storeStorageOnRevision( + path, + metadata.revision! + ) + ); + }, + }); + } + if (metadata.revision && policy.applyLogicalDeletionToVault) { + revisionActions.push({ + title: $msg("Apply logical deletion to Vault"), + warning: true, + run: async () => { + if (await this.core.storageAccess.isExistsIncludeHidden(path)) { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.", + { + REVISION: metadata.revision!, + FILE: path, + } + ), + { + title: $msg("Apply logical deletion to Vault"), + defaultOption: "No", + } + )) === "yes"; + if (!confirmed) { + return; + } + } + await runMutation( + `Apply logical deletion ${metadata.revision} to the Vault`, + () => + applyRevisionToStorage( + path, + metadata.revision!, + true + ) + ); + }, + }); + } + if (metadata.revision && policy.retryRevision) { + revisionActions.push({ + title: $msg("Retry reading revision"), + run: async () => { + const loaded = await retryReadFileDatabaseRevision( + this.core, + path, + metadata.revision! + ); + Logger( + loaded + ? `Revision ${metadata.revision} of ${path} is readable after retry` + : `Revision ${metadata.revision} of ${path} remains unreadable`, + LOG_LEVEL_NOTICE + ); + await refresh(); + }, + }); + } + if (metadata.revision && policy.discardBranch) { + revisionActions.push(discardLiveBranchAction(metadata.revision)); + } + if (metadata.revision && policy.discardRevision) { + revisionActions.push({ + title: $msg("Discard unreadable revision"), + warning: true, + run: async () => { const confirmed = (await this.core.confirm.askYesNoDialog( $msg( @@ -293,55 +699,54 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, ); await refresh(); }, - true - ); + }); } + addActionMenu( + revisionMenuHost, + $msg("More actions for revision ${REVISION}", { + REVISION: metadata.revision ?? $msg("Unknown revision"), + }), + revisionActions + ); }; revisions.forEach(addRevision); for (const revision of information.database.unavailableConflictRevisions) { const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" }); - this.createEl(revisionEl, "div", { + const revisionHeader = this.createEl(revisionEl, "div", { + cls: "sls-repair-header", + }); + this.createEl(revisionHeader, "div", { text: $msg("${ROLE}: ${REVISION}", { ROLE: $msg("Conflict revision"), REVISION: revision, }), cls: "sls-repair-revision-title", }); + const revisionMenuHost = this.createEl(revisionHeader, "div"); this.createEl(revisionEl, "div", { text: $msg("Revision metadata is unavailable on this device"), cls: "mod-warning", }); - const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" }); - addActionButton(actions, $msg("Retry reading revision"), async () => { - await retryReadFileDatabaseRevision(this.core, path, revision); - await refresh(); - }); - addActionButton( - actions, - $msg("Discard unreadable revision"), - async () => { - const confirmed = - (await this.core.confirm.askYesNoDialog( - $msg( - "Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.", - { - REVISION: revision, - FILE: path, - } - ), - { - title: $msg("Discard unreadable revision"), - defaultOption: "No", - } - )) === "yes"; - if (!confirmed) { - return; - } - await discardUnreadableLiveRevision(this.core, path, revision); - await refresh(); - }, - true + addActionMenu( + revisionMenuHost, + $msg("More actions for revision ${REVISION}", { + REVISION: revision, + }), + [ + { + title: $msg("Retry reading revision"), + run: async () => { + await retryReadFileDatabaseRevision( + this.core, + path, + revision + ); + await refresh(); + }, + }, + discardLiveBranchAction(revision), + ] ); } @@ -365,45 +770,41 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, } const winner = revisions.find(({ role }) => role === "winner"); - const actions = this.createEl(card, "div", { cls: "sls-repair-actions" }); - if (winner?.loadedEntry && information.storage.exists) { + const fileActions: RepairMenuAction[] = []; + if (winner?.loadedEntry) { const winnerEntry = winner.loadedEntry; - addActionButton(actions, $msg("Show revision history"), () => { - eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, { - file: path as FilePathWithPrefix, - fileOnDB: winnerEntry, - }); + fileActions.push({ + title: $msg("Show revision history"), + run: () => { + eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, { + file: path as FilePathWithPrefix, + fileOnDB: winnerEntry, + }); + }, }); } - if ( - information.storage.exists && - information.database.conflictCount === 0 && - (!winner || winner.contentReadable) - ) { - addActionButton(actions, $msg("Use Vault file in local database"), async () => { - if (!(await storeStorageInDatabase(path))) { - Logger(`Failed to store the Vault file in the local database: ${path}`, LOG_LEVEL_NOTICE); - return; - } - await refresh(); + if (information.storage.exists && !information.database.exists) { + fileActions.push({ + title: $msg("Store Vault file as a new local database document"), + run: async () => { + await runMutation( + "Store the Vault file as a new local database document", + () => storeStorageInDatabase(path) + ); + }, }); } - if ( - !information.storage.exists && - information.database.conflictCount === 0 && - winner?.loadedEntry - ) { - addActionButton(actions, $msg("Restore database winner to Vault"), async () => { - if (!(await applyWinnerToStorage(path, winner))) { - Logger(`Failed to restore the database winner to the Vault: ${path}`, LOG_LEVEL_NOTICE); - return; - } - await refresh(); - }); - } - addActionButton(actions, $msg("Copy database information"), async () => { - await copyFileDatabaseInfo(this.core, path); + fileActions.push({ + title: $msg("Copy database information"), + run: async () => { + await copyFileDatabaseInfo(this.core, path); + }, }); + addActionMenu( + fileMenuHost, + $msg("More actions for ${FILE}", { FILE: path }), + fileActions + ); }; new Setting(paneEl) diff --git a/src/serviceFeatures/fileRepair.ts b/src/serviceFeatures/fileRepair.ts index f5cef48b..64172843 100644 --- a/src/serviceFeatures/fileRepair.ts +++ b/src/serviceFeatures/fileRepair.ts @@ -35,6 +35,8 @@ export type DiscardUnreadableRevisionResult = | "no-longer-live" | "revision-is-readable"; +export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision"; + export async function inspectFileRepair(core: FileRepairCore, path: string): Promise { const information = await inspectFileDatabaseInfo(core, path); const storageContent = information.storage.exists @@ -62,12 +64,12 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro } const winner = revisions.find(({ role }) => role === "winner"); + const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted; const databaseAndStorageDiffer = - information.storage.exists !== information.database.exists || + information.storage.exists !== winnerRepresentsStoredFile || (information.storage.exists && - winner !== undefined && - (winner.metadata.deleted || winner.contentMatchesStorage === false)) || - (!information.storage.exists && winner !== undefined && !winner.metadata.deleted); + winnerRepresentsStoredFile && + winner.contentMatchesStorage === false); const unreadableLiveRevision = information.database.unavailableConflictRevisions.length > 0 || revisions.some(({ contentReadable }) => !contentReadable); @@ -107,3 +109,24 @@ export async function discardUnreadableLiveRevision( const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); return deleted ? "discarded" : "failed"; } + +export async function discardLiveBranch( + core: FileRepairCore, + path: string, + revision: string +): Promise { + const latest = await inspectFileDatabaseInfo(core, path); + const liveRevisions = [ + latest.database.currentRevision, + ...latest.database.conflictRevisions, + ].filter((candidate): candidate is string => candidate !== null); + if (!liveRevisions.includes(revision)) { + return "no-longer-live"; + } + if (liveRevisions.length < 2) { + return "only-live-revision"; + } + + const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision); + return deleted ? "discarded" : "failed"; +} diff --git a/src/serviceFeatures/fileRepair.unit.spec.ts b/src/serviceFeatures/fileRepair.unit.spec.ts index 9d066877..af12a34b 100644 --- a/src/serviceFeatures/fileRepair.unit.spec.ts +++ b/src/serviceFeatures/fileRepair.unit.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + discardLiveBranch, discardUnreadableLiveRevision, inspectFileRepair, } from "./fileRepair"; @@ -16,6 +17,7 @@ function createCore() { size: 7, type: "plain", children: ["h:current"], + deleted: false, eden: {}, }; const conflict = { @@ -118,6 +120,29 @@ describe("file repair inspection", () => { expect(inspection.requiresAttention).toBe(true); }); + it("omits a logical deletion which already matches an absent Vault file", async () => { + const { core, current } = createCore(); + current.deleted = true; + current._conflicts = []; + current.children = []; + core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false); + core.storageAccess.statHidden.mockResolvedValue(null as never); + + const inspection = await inspectFileRepair(core as never, "note.md"); + + expect(inspection.revisions).toEqual([ + expect.objectContaining({ + role: "winner", + contentReadable: true, + metadata: expect.objectContaining({ + deleted: true, + revision: "3-current", + }), + }), + ]); + expect(inspection.requiresAttention).toBe(false); + }); + it("rechecks liveness and readability before discarding an exact revision", async () => { const { core, deleteRevisionFromDB } = createCore(); @@ -169,4 +194,35 @@ describe("file repair inspection", () => { expect(deleteRevisionFromDB).not.toHaveBeenCalled(); }); + + it("discards an exact readable winner while another live branch remains", async () => { + const { core, deleteRevisionFromDB } = createCore(); + + await expect( + discardLiveBranch(core as never, "note.md", "3-current") + ).resolves.toBe("discarded"); + + expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current"); + }); + + it("refuses to discard the only live branch", async () => { + const { core, current, deleteRevisionFromDB } = createCore(); + current._conflicts = []; + + await expect( + discardLiveBranch(core as never, "note.md", "3-current") + ).resolves.toBe("only-live-revision"); + + expect(deleteRevisionFromDB).not.toHaveBeenCalled(); + }); + + it("refuses to discard a branch which is no longer live", async () => { + const { core, deleteRevisionFromDB } = createCore(); + + await expect( + discardLiveBranch(core as never, "note.md", "1-stale") + ).resolves.toBe("no-longer-live"); + + expect(deleteRevisionFromDB).not.toHaveBeenCalled(); + }); }); diff --git a/src/serviceFeatures/fileRepairPresentation.ts b/src/serviceFeatures/fileRepairPresentation.ts new file mode 100644 index 00000000..4906f4cb --- /dev/null +++ b/src/serviceFeatures/fileRepairPresentation.ts @@ -0,0 +1,144 @@ +import { + BASE_IS_NEW, + EVEN, + TARGET_IS_NEW, +} from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols"; +import { + compareMTime, + readAsBlob, +} from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import type { + FileRepairInspection, + FileRepairRevision, +} from "./fileRepair"; + +export type FileRepairRevisionActions = { + compareWithVault: boolean; + applyRevisionToVault: boolean; + markAsVaultRevision: boolean; + storeVaultOnBranch: boolean; + applyLogicalDeletionToVault: boolean; + retryRevision: boolean; + discardBranch: boolean; + discardRevision: boolean; +}; + +export type FileRepairTimestampRelation = + | "vault-newer" + | "database-newer" + | "same-window" + | "unavailable"; + +export type FileRepairRevisionComparison = { + recordedSize: number; + decodedSize: number | null; + recordedToDecodedSizeDifference: number | null; + vaultSize: number | null; + databaseToVaultSizeDifference: number | null; + databaseMtime: number; + vaultMtime: number | null; + timestampDifferenceMs: number | null; + timestampRelation: FileRepairTimestampRelation; +}; + +export function getFileRepairRevisionActions( + inspection: FileRepairInspection, + revision: FileRepairRevision +): FileRepairRevisionActions { + const storageExists = inspection.information.storage.exists; + const hasRevision = revision.metadata.revision !== null; + const readableFileRevision = + !revision.metadata.deleted && + revision.contentReadable && + revision.loadedEntry !== false; + const matchesVault = storageExists && revision.contentMatchesStorage === true; + const hasConflictBranches = inspection.information.database.conflictCount > 0; + + return { + compareWithVault: + readableFileRevision && + storageExists && + revision.contentMatchesStorage === false && + isPlainText(inspection.information.path), + applyRevisionToVault: + hasRevision && + readableFileRevision && + (!storageExists || revision.contentMatchesStorage !== true), + markAsVaultRevision: + hasRevision && + readableFileRevision && + matchesVault, + storeVaultOnBranch: + hasRevision && + storageExists && + revision.contentMatchesStorage !== true, + applyLogicalDeletionToVault: + hasRevision && + revision.metadata.deleted && + storageExists, + retryRevision: + hasRevision && + !revision.metadata.deleted && + !revision.contentReadable, + discardBranch: hasRevision && hasConflictBranches, + discardRevision: + hasRevision && + !hasConflictBranches && + !revision.metadata.deleted && + !revision.contentReadable, + }; +} + +export function getFileRepairRevisionComparison( + inspection: FileRepairInspection, + revision: FileRepairRevision +): FileRepairRevisionComparison { + const decodedSize = + revision.loadedEntry === false + ? null + : readAsBlob(revision.loadedEntry).size; + const vaultSize = + inspection.information.storage.exists + ? (inspection.information.storage.size ?? null) + : null; + const databaseMtime = revision.metadata.mtime; + const vaultMtime = + inspection.information.storage.exists + ? (inspection.information.storage.mtime ?? null) + : null; + const timestampDifferenceMs = + databaseMtime > 0 && vaultMtime !== null && vaultMtime > 0 + ? vaultMtime - databaseMtime + : null; + let timestampRelation: FileRepairTimestampRelation = "unavailable"; + if (timestampDifferenceMs !== null) { + const comparison = compareMTime(vaultMtime!, databaseMtime); + timestampRelation = + comparison === EVEN + ? "same-window" + : comparison === BASE_IS_NEW + ? "vault-newer" + : comparison === TARGET_IS_NEW + ? "database-newer" + : "unavailable"; + } + + return { + recordedSize: revision.metadata.recordedSize, + decodedSize, + recordedToDecodedSizeDifference: + decodedSize === null + ? null + : decodedSize - revision.metadata.recordedSize, + vaultSize, + databaseToVaultSizeDifference: + decodedSize === null || vaultSize === null + ? null + : vaultSize - decodedSize, + databaseMtime, + vaultMtime, + timestampDifferenceMs, + timestampRelation, + }; +} diff --git a/src/serviceFeatures/fileRepairPresentation.unit.spec.ts b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts new file mode 100644 index 00000000..1f5f968c --- /dev/null +++ b/src/serviceFeatures/fileRepairPresentation.unit.spec.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { FileRepairInspection, FileRepairRevision } from "./fileRepair"; +import { + getFileRepairRevisionActions, + getFileRepairRevisionComparison, +} from "./fileRepairPresentation"; + +function createInspection( + revision: Partial = {}, + storage: { exists: boolean; size?: number; mtime?: number } = { + exists: true, + size: 12, + mtime: 5_500, + } +): { inspection: FileRepairInspection; revision: FileRepairRevision } { + const completeRevision = { + role: "conflict", + metadata: { + documentId: "f:note", + revision: "2-conflict", + current: false, + deleted: false, + storageType: "plain", + storageLayout: "chunked", + ctime: 1, + mtime: 2_000, + recordedSize: 9, + revisionHistory: [], + chunkReferences: 0, + uniqueChunkReferences: 0, + embeddedChunkReferences: 0, + locallyStoredChunkReferences: 0, + contentAvailableLocally: true, + chunks: [], + }, + contentReadable: true, + contentMatchesStorage: false, + loadedEntry: { + _id: "f:note", + _rev: "2-conflict", + path: "note.md", + ctime: 1, + mtime: 2_000, + size: 9, + type: "plain", + datatype: "plain", + children: [], + eden: {}, + data: "content", + }, + ...revision, + } as FileRepairRevision; + const inspection = { + information: { + path: "note.md", + databasePath: "note.md" as FilePathWithPrefix, + storage, + database: { + source: "local database on this device", + remoteQueried: false, + exists: true, + currentRevision: "3-winner", + conflictCount: 1, + conflictRevisions: ["2-conflict"], + unavailableConflictRevisions: [], + revisions: [], + mergeBases: [], + }, + }, + revisions: [completeRevision], + requiresAttention: true, + } satisfies FileRepairInspection; + return { inspection, revision: completeRevision }; +} + +describe("file repair presentation", () => { + it("offers both reconciliation directions for a readable differing revision", () => { + const { inspection, revision } = createInspection(); + + expect(getFileRepairRevisionActions(inspection, revision)).toEqual({ + compareWithVault: true, + applyRevisionToVault: true, + markAsVaultRevision: false, + storeVaultOnBranch: true, + applyLogicalDeletionToVault: false, + retryRevision: false, + discardRevision: false, + discardBranch: true, + }); + }); + + it("marks an exact matching revision without creating another child", () => { + const { inspection, revision } = createInspection({ + contentMatchesStorage: true, + }); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + compareWithVault: false, + applyRevisionToVault: false, + markAsVaultRevision: true, + storeVaultOnBranch: false, + discardBranch: true, + }); + }); + + it("does not offer a text comparison for a binary file", () => { + const { inspection, revision } = createInspection(); + inspection.information.path = "image.png"; + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + compareWithVault: false, + applyRevisionToVault: true, + storeVaultOnBranch: true, + }); + }); + + it("offers explicit deletion or branch extension for a logical deletion", () => { + const { inspection, revision } = createInspection({ + metadata: { + ...createInspection().revision.metadata, + deleted: true, + }, + contentReadable: true, + contentMatchesStorage: null, + loadedEntry: false, + }); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + applyRevisionToVault: false, + storeVaultOnBranch: true, + applyLogicalDeletionToVault: true, + retryRevision: false, + discardRevision: false, + discardBranch: true, + }); + }); + + it("offers retry, discard, and branch extension for an unreadable live revision", () => { + const { inspection, revision } = createInspection({ + contentReadable: false, + contentMatchesStorage: null, + loadedEntry: false, + }); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + compareWithVault: false, + applyRevisionToVault: false, + markAsVaultRevision: false, + storeVaultOnBranch: true, + retryRevision: true, + discardRevision: false, + discardBranch: true, + }); + }); + + it("keeps the existing unreadable-leaf escape hatch when there is no conflict branch", () => { + const { inspection, revision } = createInspection({ + role: "winner", + contentReadable: false, + contentMatchesStorage: null, + loadedEntry: false, + }); + inspection.information.database.conflictCount = 0; + inspection.information.database.conflictRevisions = []; + inspection.information.database.currentRevision = revision.metadata.revision; + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + discardRevision: true, + discardBranch: false, + }); + }); + + it("does not offer a storage action for a matching absent logical deletion", () => { + const { inspection, revision } = createInspection( + { + metadata: { + ...createInspection().revision.metadata, + deleted: true, + }, + contentReadable: true, + contentMatchesStorage: null, + loadedEntry: false, + }, + { exists: false } + ); + + expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({ + applyLogicalDeletionToVault: false, + storeVaultOnBranch: false, + }); + }); + + it("reports recorded, decoded, Vault-size, and timestamp differences", () => { + const { inspection, revision } = createInspection(); + + expect(getFileRepairRevisionComparison(inspection, revision)).toEqual({ + recordedSize: 9, + decodedSize: 7, + recordedToDecodedSizeDifference: -2, + vaultSize: 12, + databaseToVaultSizeDifference: 5, + databaseMtime: 2_000, + vaultMtime: 5_500, + timestampDifferenceMs: 3_500, + timestampRelation: "vault-newer", + }); + }); + + it("uses the same two-second timestamp comparison window as synchronisation", () => { + const { inspection, revision } = createInspection( + { + metadata: { + ...createInspection().revision.metadata, + mtime: 3_001, + }, + }, + { + exists: true, + size: 12, + mtime: 3_999, + } + ); + + expect(getFileRepairRevisionComparison(inspection, revision)).toMatchObject({ + timestampDifferenceMs: 998, + timestampRelation: "same-window", + }); + }); +}); diff --git a/styles.css b/styles.css index ab7ca645..8c9ec551 100644 --- a/styles.css +++ b/styles.css @@ -612,6 +612,54 @@ body.is-mobile .livesync-compatibility-review-notice { background: var(--background-secondary); } +.sls-repair-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--size-4-2); + min-width: 0; +} + +.sls-repair-header > :first-child { + flex: 1 1 auto; + min-width: 0; +} + +.sls-repair-header h6 { + margin: 0; + overflow-wrap: anywhere; +} + +.sls-repair-status { + display: flex; + flex-wrap: wrap; + gap: var(--size-4-2); + margin-top: var(--size-4-1); + font-size: var(--font-ui-smaller); +} + +.sls-repair-status-ok { + color: var(--text-success); +} + +.sls-repair-status-warning { + color: var(--text-warning); +} + +.sls-repair-metric { + margin-top: var(--size-4-1); + font-size: var(--font-ui-smaller); + line-height: var(--line-height-tight); + overflow-wrap: anywhere; +} + +.sls-repair-action-menu { + min-width: var(--clickable-icon-size); + width: var(--clickable-icon-size); + height: var(--clickable-icon-size); + padding: 0; +} + .sls-repair-revision { margin-top: var(--size-4-2); padding: var(--size-4-2); @@ -636,13 +684,6 @@ body.is-mobile .livesync-compatibility-review-notice { color: var(--text-warning); } -.sls-repair-actions { - display: flex; - flex-wrap: wrap; - gap: var(--size-4-2); - margin-top: var(--size-4-2); -} - /* Diff navigation */ .diff-options-row { display: flex; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index f7a452f8..20717f69 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -161,7 +161,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. -`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. +`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose the appropriate `…` menu for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts index 405c20c9..7b5e642d 100644 --- a/test/e2e-obsidian/scripts/revision-repair.ts +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -8,8 +8,10 @@ import { import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; +import type { Locator, Page } from "playwright"; const path = "revision-repair.md"; +const healthyDeletedPath = "healthy-logical-deletion.md"; const baseContent = "Revision repair\n\nShared base.\n"; const branchContents = [ `Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`, @@ -28,6 +30,11 @@ type RevisionTree = { conflictRevisions: string[]; }; +type VaultWinnerState = { + matches: boolean; + winnerRevision: string; +}; + type ObsidianSettingsController = { open(): void; openTabById(tabId: string): void; @@ -56,6 +63,49 @@ async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): ); } +async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(healthyDeletedPath)};`, + `const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`, + "let file=app.vault.getAbstractFileByPath(path);", + "if(!file) file=await app.vault.create(path,content);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); + await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath); + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(healthyDeletedPath)};`, + `const timeoutMs=${JSON.stringify(uiTimeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);", + "await app.vault.delete(file);", + "const id=await core.services.path.path2id(path);", + "const deadline=Date.now()+timeoutMs;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()false);", + " if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){", + " return JSON.stringify(doc._rev);", + " }", + " await sleep(250);", + "}", + "throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);", + "})()", + ].join(""), + env + ); +} + async function createBrokenConflict( cliBinary: string, env: NodeJS.ProcessEnv, @@ -128,6 +178,93 @@ async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Prom ); } +async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error(`Vault file is missing: ${path}`);", + "const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);", + "if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);", + "const vaultContent=await app.vault.read(file);", + "const data=Array.isArray(entry.data)?entry.data:[entry.data];", + "const databaseContent=await new Blob(data).text();", + "return JSON.stringify({", + " matches:vaultContent===databaseContent,", + " winnerRevision:entry._rev,", + "});", + "})()", + ].join(""), + env + ); +} + +async function readFileReflectionProvenance( + cliBinary: string, + env: NodeJS.ProcessEnv, + targetPath = path +): Promise<{ revision: string; observedStorageMtime?: number } | null> { + return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(targetPath)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');", + "return JSON.stringify((await store.get(path))??null);", + "})()", + ].join(""), + env + ); +} + +function repairCard(settings: Locator): Locator { + return settings.locator(".sls-repair-result").filter({ hasText: path }); +} + +function revisionCard(settings: Locator, revision: string): Locator { + return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision }); +} + +async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise { + await revisionCard(settings, revision) + .getByRole("button", { + name: `More actions for revision ${revision}`, + exact: true, + }) + .click({ timeout: uiTimeoutMs }); + const menu = page.locator(".menu:visible").last(); + await menu.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const box = await menu.boundingBox(); + const viewport = await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + if ( + box === null || + box.y < 0 || + box.y + box.height > viewport.height - 4 + ) { + throw new Error( + `Revision action menu is outside the viewport: ${JSON.stringify({ + box, + viewport, + })}` + ); + } + return menu; +} + +async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise { + const menu = await openRevisionActionMenu(page, settings, revision); + const item = menu.getByText(action, { exact: true }); + await item.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await item.click({ timeout: uiTimeoutMs }); +} + async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise { await evalObsidianJson( cliBinary, @@ -187,7 +324,22 @@ async function main(): Promise { await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await createAndOpenBaseFile(cliBinary, session.cliEnv); const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv); const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev); + const healthyDeletionProvenance = await readFileReflectionProvenance( + cliBinary, + session.cliEnv, + healthyDeletedPath + ); + if (healthyDeletionProvenance !== null) { + throw new Error( + `A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({ + healthyDeletedPath, + healthyDeletionRevision, + healthyDeletionProvenance, + })}` + ); + } await requestConflictCheck(cliBinary, session.cliEnv); const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv); @@ -219,13 +371,17 @@ async function main(): Promise { await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({ timeout: uiTimeoutMs, }); - const card = settings.locator(".sls-repair-result").filter({ hasText: path }); + const card = repairCard(settings); await card.waitFor({ state: "visible", timeout: uiTimeoutMs }); - const brokenRevision = card - .locator(".sls-repair-revision") - .filter({ hasText: fixture.conflictRevision }); + if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) { + throw new Error( + `Verify and Repair reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).` + ); + } + const winnerRevision = revisionCard(settings, fixture.winnerRevision); + const brokenRevision = revisionCard(settings, fixture.conflictRevision); await brokenRevision - .getByText(/Unreadable on this device/u) + .getByText(/🧩 Missing chunks: 1/u) .waitFor({ state: "visible", timeout: uiTimeoutMs }); await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({ state: "visible", @@ -234,43 +390,276 @@ async function main(): Promise { if ((await card.locator(".sls-repair-revision").count()) !== 2) { throw new Error("Verify and Repair did not render the winner and conflict revision separately."); } - - await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({ + for (const label of [ + /📦 DB: recorded/u, + /📁 Vault:/u, + /Δsize vs DB/u, + /🕒 DB /u, + /Δtime /u, + /⚠️ Differs from Vault/u, + ]) { + await winnerRevision.getByText(label).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + await brokenRevision.getByText(/decoded unavailable/u).waitFor({ + state: "visible", timeout: uiTimeoutMs, }); - await settings - .locator(".sls-repair-result") - .filter({ hasText: path }) - .locator(".sls-repair-revision") - .filter({ hasText: fixture.conflictRevision }) - .getByText(/Unreadable on this device/u) - .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision); + for (const label of [ + "Compare with Vault", + "Apply this revision to Vault", + "Store Vault file as a child of this revision", + "Discard this branch", + ]) { + await winnerMenu.getByText(label, { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + if ( + (await winnerMenu + .getByText("Mark this revision as the Vault version", { + exact: true, + }) + .count()) !== 0 + ) { + throw new Error("A differing revision incorrectly offered to record an exact Vault match."); + } + await page.keyboard.press("Escape"); }); - const afterRetry = await readRevisionTree(cliBinary, session.cliEnv); - if (!afterRetry.conflictRevisions.includes(fixture.conflictRevision)) { - throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`); - } - - const screenshot = await captureObsidianElement( + const repairCardScreenshot = await captureObsidianElement( session.remoteDebuggingPort, "revision-repair-unreadable-conflict.png", (page) => page.locator(".sls-repair-result").filter({ hasText: path }) ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const card = page.locator(".sls-repair-result").filter({ hasText: path }); + await card.evaluate((element) => { + const htmlElement = element as HTMLElement; + htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? ""; + htmlElement.style.width = "360px"; + htmlElement.style.maxWidth = "100%"; + }); + const dimensions = await card.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + if (dimensions.scrollWidth > dimensions.clientWidth + 1) { + throw new Error( + `Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}` + ); + } + }); + const mobileWidthScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-mobile-width.png", + (page) => page.locator(".sls-repair-result").filter({ hasText: path }) + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const card = page.locator(".sls-repair-result").filter({ hasText: path }); + await card.evaluate((element) => { + const htmlElement = element as HTMLElement; + const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? ""; + if (originalStyle.length > 0) { + htmlElement.setAttribute("style", originalStyle); + } else { + htmlElement.removeAttribute("style"); + } + delete htmlElement.dataset.e2eOriginalStyle; + }); + }); await withObsidianPage(session.remoteDebuggingPort, async (page) => { const settings = page.locator(".sls-setting"); - const brokenRevision = () => - settings - .locator(".sls-repair-result") - .filter({ hasText: path }) - .locator(".sls-repair-revision") - .filter({ hasText: fixture.conflictRevision }); - await brokenRevision() - .getByRole("button", { name: "Discard unreadable revision", exact: true }) + await openRevisionActionMenu(page, settings, fixture.winnerRevision); + }); + const readableMenuScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-readable-actions.png", + (page) => page.locator(".menu:visible").last() + ); + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.keyboard.press("Escape"); + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault"); + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Vault and database revision", + }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByText(path, { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByText(/Vault file:/u).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByText(/Database revision:/u).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const actions = modal.locator(".conflict-action-container"); + await actions.getByRole("button", { name: "Close", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) { + if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) { + throw new Error(`Read-only comparison exposed the resolution action '${action}'.`); + } + } + }); + const comparisonScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-read-only-comparison.png", + (page) => + page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Vault and database revision", + }), + }) + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Vault and database revision", + }), + }); + await modal + .locator(".conflict-action-container") + .getByRole("button", { name: "Close", exact: true }) .click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const beforeApply = await readRevisionTree(cliBinary, session.cliEnv); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault"); const confirmation = page.locator(".modal-container").filter({ - has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }), + has: page.locator(".modal-title").filter({ + hasText: "Apply database revision to Vault", + }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + await revisionCard(settings, fixture.winnerRevision) + .getByText("✅ Matches Vault", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const status = repairCard(settings).locator(".sls-repair-status"); + await status + .getByText("✅ Vault matches winner", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await status + .getByText("⚠️ Conflicts: 1", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + const matchedWinnerWithConflictScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-winner-match-with-conflict.png", + (page) => page.locator(".sls-repair-result").filter({ hasText: path }) + ); + const afterApply = await readRevisionTree(cliBinary, session.cliEnv); + if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) { + throw new Error( + `Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({ + beforeApply, + afterApply, + })}` + ); + } + const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv); + const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv); + if ( + !vaultWinner.matches || + vaultWinner.winnerRevision !== fixture.winnerRevision || + appliedProvenance?.revision !== fixture.winnerRevision + ) { + throw new Error( + `Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({ + vaultWinner, + appliedProvenance, + fixture, + })}` + ); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision); + await menu + .getByText("Mark this revision as the Vault version", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await menu + .getByText("Discard this branch", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await menu + .getByText("Mark this revision as the Vault version", { exact: true }) + .click({ timeout: uiTimeoutMs }); + await revisionCard(settings, fixture.winnerRevision) + .getByText("✅ Matches Vault", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv); + const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv); + if ( + JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) || + markedProvenance?.revision !== fixture.winnerRevision + ) { + throw new Error( + `Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({ + beforeApply, + afterExactMark, + markedProvenance, + })}` + ); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision); + for (const label of [ + "Store Vault file as a child of this revision", + "Retry reading revision", + "Discard this branch", + ]) { + await menu.getByText(label, { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + }); + const unreadableMenuScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "revision-repair-unreadable-actions-context.png", + (page) => page.locator("body") + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.keyboard.press("Escape"); + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision"); + await revisionCard(settings, fixture.conflictRevision) + .getByText(/🧩 Missing chunks:/u) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + const afterRetry = await readRevisionTree(cliBinary, session.cliEnv); + if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) { + throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`); + } + + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const settings = page.locator(".sls-setting"); + await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch"); + const confirmation = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Discard branch" }), }); await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs }); @@ -278,36 +667,23 @@ async function main(): Promise { }); const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv); - if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) { + if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) { throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`); } await withObsidianPage(session.remoteDebuggingPort, async (page) => { const settings = page.locator(".sls-setting"); - const brokenRevision = settings - .locator(".sls-repair-result") - .filter({ hasText: path }) - .locator(".sls-repair-revision") - .filter({ hasText: fixture.conflictRevision }); - await brokenRevision - .getByRole("button", { name: "Discard unreadable revision", exact: true }) - .click({ timeout: uiTimeoutMs }); + await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch"); const confirmation = page.locator(".modal-container").filter({ - has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }), + has: page.locator(".modal-title").filter({ hasText: "Discard branch" }), }); await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); - await settings - .locator(".sls-repair-revision") - .filter({ hasText: fixture.conflictRevision }) - .waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs }); }); const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv); - if ( - afterDiscard.winnerRevision !== fixture.winnerRevision || - afterDiscard.conflictRevisions.length !== 0 - ) { + if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) { throw new Error( `Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({ fixture, @@ -315,11 +691,31 @@ async function main(): Promise { })}` ); } + const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv); + const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv); + if ( + !finalVaultWinner.matches || + finalVaultWinner.winnerRevision !== fixture.winnerRevision || + finalProvenance?.revision !== fixture.winnerRevision + ) { + throw new Error( + `Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({ + finalVaultWinner, + finalProvenance, + fixture, + })}` + ); + } console.log( - "Real Obsidian kept an unreadable conflict revision through automatic checking and retry, rendered every live revision separately, required confirmation, and discarded only the selected revision." + "Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation." ); - console.log(`Repair screenshot: ${screenshot}`); + console.log(`Repair card screenshot: ${repairCardScreenshot}`); + console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`); + console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`); + console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`); + console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`); + console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`); } finally { if (session) { await session.app.stop(); diff --git a/updates.md b/updates.md index 002bc6a8..048686cc 100644 --- a/updates.md +++ b/updates.md @@ -14,7 +14,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved -- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. +- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, unavailable shared ancestors, and file-information differences separately. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, a Vault which matches the winner, and conflict branches which still remain. Each live revision has an **…** menu for read-only text comparison, exact revision-to-Vault reflection, recording an exact match, storing the Vault file as a child of that branch, or explicitly discarding only that branch while another live branch remains. A logical deletion which already matches an absent Vault file is no longer reported. Retrying a revision does not change the tree, and discarding the sole unreadable live revision remains an explicitly confirmed recovery action. - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Text in setup and review dialogues can now be selected for copying or translation. - When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. From 127d460e18915533a1bd4685de0e5ee1d35dddb2 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sat, 25 Jul 2026 11:18:10 +0000 Subject: [PATCH 155/170] Improve conflict inspection and recovery workflow --- docs/recovery.md | 22 +++++++ docs/settings.md | 12 ++-- docs/specs_conflict_resolution.md | 4 +- docs/specs_garbage_collection.md | 2 +- docs/terms.md | 2 +- docs/troubleshooting.md | 2 +- .../messages/LiveSyncProvisionalMessages.ts | 9 +-- src/deps.ts | 1 + .../features/SettingDialogue/PaneHatch.ts | 66 +++++++++---------- styles.css | 8 +++ test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/scripts/dialog-mounts.ts | 27 +++++++- test/e2e-obsidian/scripts/revision-repair.ts | 23 ++++--- updates.md | 2 +- 14 files changed, 120 insertions(+), 62 deletions(-) diff --git a/docs/recovery.md b/docs/recovery.md index 1d98ce04..2a76b141 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -32,6 +32,28 @@ While suspended: The flag deliberately enables file logging, which may affect performance. Remove it after the emergency has been understood. +## Recover a conflicted or mismatched file + +Use this workflow when one file, or a small number of known files, has conflicts, missing chunks, or a difference between the current Vault file and the local LiveSync database. The inspection is device-local: it does not query a remote database or prove that another device has the same chunks. + +The `Hatch` recovery controls are ordered by escalation. Running **Recreate chunks for current Vault files** again with unchanged chunk settings and file contents produces the same chunks, and does not alter the revision tree. **Inspect conflicts and file/database differences** then provides actions for exact revisions. **Resolve All conflicted files by the newer one** is last because it applies a modification-time policy in bulk and logically deletes every other live version. + +1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version. +2. If another device or backup has the intended content, preserve that copy before changing any revision. +3. If the current Vault file is readable, select **Recreate current chunks**. This can restore only chunks derived from the current Vault contents; it cannot reconstruct unique bytes from an unavailable historical or conflict revision. +4. Select **Inspect conflicts and file/database differences** → **Scan all files**. +5. Review the database winner, every conflict revision, and any unavailable shared ancestor separately. Revision identifiers, `Δsize`, `Δtime`, and chunk availability are diagnostic evidence; they do not decide which content is correct. +6. Use the wrench menu on the exact revision: + - **Compare with Vault** opens a read-only comparison for readable text. + - **Apply this revision to Vault** replaces the Vault file with that readable database revision. + - **Mark this revision as the Vault version** records an exact byte-for-byte match without creating a child revision. + - **Store Vault file as a child of this revision** preserves the current Vault bytes on that selected branch. + - **Retry reading revision** retries configured chunk retrieval without changing the revision tree. + - **Apply logical deletion to Vault**, **Discard this branch**, and **Discard unreadable revision** are destructive decisions. Use them only after preserving every version which may still be needed. +7. Synchronise the healthy source if chunks were restored, scan again, and confirm that the expected conflict or difference has disappeared before resuming ordinary editing. + +An absent Vault file and a logical-deletion winner already agree and do not require a repair card unless another live branch remains. If the scan reports many unrelated files, or the local database itself is incomplete or corrupt, stop the per-file workflow and use [Reset synchronisation on this device](#reset-synchronisation-on-this-device) from a trusted remote. If the central remote must instead be reconstructed from an authoritative Vault, use [Overwrite server data with this device's files](#overwrite-server-data-with-this-devices-files). + ## Reset synchronisation on this device Use this when the remote copy is trusted but this device's local LiveSync database is incomplete, corrupt, or no longer aligned with it. diff --git a/docs/settings.md b/docs/settings.md index 08e545a4..0f0003de 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -731,15 +731,15 @@ Stop reflecting database changes to storage files. Recreate chunks from files currently present in the Vault. This can repair missing chunks for those exact current contents after they have been confirmed as authoritative. It cannot reconstruct unavailable historical or conflict content. -#### Resolve All conflicted files by the newer one - -After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable. - -#### Verify and repair all files +#### Inspect conflicts and file/database differences Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded. -`Retry reading revision` retries the configured chunk-retrieval path without changing the revision tree. `Discard unreadable revision` is offered only for an exact current live revision which remains unreadable; after confirmation, it creates a logical deletion for that revision. Prefer recovery from another replica or backup before discarding it. +Select **Scan all files** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. + +#### Resolve All conflicted files by the newer one + +After confirmation, resolve every conflict by modification time. This logically deletes every version except the newest one. It is a destructive policy choice and cannot recover content which is already unavailable. #### Check and convert non-path-obfuscated files diff --git a/docs/specs_conflict_resolution.md b/docs/specs_conflict_resolution.md index 25dfd012..e476f75c 100644 --- a/docs/specs_conflict_resolution.md +++ b/docs/specs_conflict_resolution.md @@ -50,9 +50,9 @@ The compatibility implementation currently selects the newer modification time f A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing. -**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved. +**Hatch** → **Inspect conflicts and file/database differences** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved. -Each reported live revision has a compact **…** menu. The available actions depend on the exact revision and current Vault state: +Each reported live revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state: - **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files. - **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation. diff --git a/docs/specs_garbage_collection.md b/docs/specs_garbage_collection.md index 8a75f975..67ca4a30 100644 --- a/docs/specs_garbage_collection.md +++ b/docs/specs_garbage_collection.md @@ -47,7 +47,7 @@ Garbage Collection deliberately trades historical recoverability for storage. A Writing the same bytes again produces the same content-derived chunk identifier. If that chunk was collected previously, the normal chunk-writing path creates a new live revision for it, and ordinary replication can transfer it again. This does not recover an older file revision automatically; it only makes the newly written content available. -Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Verify and repair all files**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required. +Garbage Collection does not reconstruct a chunk which is already missing, determine whether an unreadable revision is important, or repair a damaged local database. Use **Inspect conflicts and file/database differences**, another healthy replica, or a backup for those cases. Use **Overwrite Server Data with This Device's Files** only when a chosen Vault is authoritative and a deliberate remote rebuild is required. ## Verification diff --git a/docs/terms.md b/docs/terms.md index 721e2c44..98cdfcbe 100644 --- a/docs/terms.md +++ b/docs/terms.md @@ -27,7 +27,7 @@ All guidelines and conventions listed below are disclosed and maintained solely - Boot-up sequence (boot-sequence) - The initialisation process of the plug-in when Obsidian starts. It starts with the loading of the plug-in, setting up core services, loading saved settings, and opening the local database. Once the layout is ready, the plug-in checks for the presence of flag files, runs configuration diagnostics, connects to the remote database, and begins file watching. The sequence finishes once the plug-in is fully ready and operational. - Broken files (Size mismatch) - - A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be detected and resolved by running validation tools such as `Verify and repair all files` on the Hatch pane. + - A state where a file's metadata and the actual content stored in its chunks do not match, causing file retrieval or synchronisation failures. These mismatches can be inspected with `Inspect conflicts and file/database differences` on the Hatch pane, then handled one exact revision at a time. - Chunk / Chunks - Divided units of data stored in the database or object storage to facilitate efficient synchronisation. - Compaction diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 95434b6c..0b7c283e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -57,7 +57,7 @@ If the log reports missing chunks or a size mismatch: 2. restart Obsidian once to rule out an interrupted fetch; 3. synchronise a device or restore a backup which still has the correct content; 4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise; -5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; use each revision's **…** menu to compare readable text, apply that exact revision to the Vault, store the Vault file as its child, or record an exact byte-for-byte match; and +5. follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file); run `Inspect conflicts and file/database differences` from `Hatch`, then use each revision's wrench menu to review and act on that exact branch; and 6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf. The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted. diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index 6353e8d8..a9a652fc 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -140,10 +140,11 @@ export const liveSyncProvisionalEnglishMessages = { "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.": "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.", "Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version", - "Verify and repair all files": "Verify and repair all files", - "Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.": - "Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision.", - "Verify all": "Verify all", + "Inspect conflicts and file/database differences": + "Inspect conflicts and file/database differences", + "Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.": + "Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.", + "Scan all files": "Scan all files", "Connection settings": "Connection settings", "Saved connections": "Saved connections", } as const; diff --git a/src/deps.ts b/src/deps.ts index 92405d0c..0e4eb819 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -26,6 +26,7 @@ export { WorkspaceLeaf, Menu, request, + setIcon, getLanguage, requireApiVersion, ButtonComponent, diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 63b5c79f..628ae9f6 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -10,7 +10,7 @@ import { import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; -import { Menu, diff_match_patch } from "@/deps.ts"; +import { Menu, diff_match_patch, setIcon } from "@/deps.ts"; import { $msg } from "@/common/translation"; import { Semaphore } from "octagonal-wheels/concurrency/semaphore"; import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts"; @@ -142,7 +142,8 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, if (actions.length === 0) { return; } - this.createEl(parent, "button", { text: "…", cls: "sls-repair-action-menu" }, (button) => { + this.createEl(parent, "button", { cls: "sls-repair-action-menu" }, (button) => { + setIcon(button, "wrench"); button.setAttr("aria-label", label); button.setAttr("title", label); button.onClickEvent(() => { @@ -823,47 +824,20 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, }) ); new Setting(paneEl) - .setName("Resolve All conflicted files by the newer one") - .setDesc( - "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one." - ) - .addButton((button) => - button - .setButtonText("Resolve All") - .setCta() - .onClick(async () => { - const confirmed = - (await this.core.confirm.askYesNoDialog( - $msg( - "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable." - ), - { - title: $msg("Resolve all conflicts by the newest version"), - defaultOption: "No", - } - )) === "yes"; - if (!confirmed) { - return; - } - await this.services.conflict.resolveAllConflictedFilesByNewerOnes(); - }) - ); - - new Setting(paneEl) - .setName($msg("Verify and repair all files")) + .setName($msg("Inspect conflicts and file/database differences")) .setDesc( $msg( - "Compare each Vault file with every live local-database revision. Unreadable conflict versions remain visible until you retry or explicitly discard an exact revision." + "Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision." ) ) .addButton((button) => button - .setButtonText($msg("Verify all")) + .setButtonText($msg("Scan all files")) .setDisabled(false) .setCta() .onClick(async () => { resultArea.replaceChildren(); - Logger("Start verifying all files", LOG_LEVEL_NOTICE, "verify"); + Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify"); this.core.localDatabase.clearCaches(); const allPaths = await collectFileDatabaseInfoPaths(this.core); let i = 0; @@ -920,6 +894,32 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, // Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed"); }) ); + new Setting(paneEl) + .setName("Resolve All conflicted files by the newer one") + .setDesc( + "Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one." + ) + .addButton((button) => + button + .setButtonText("Resolve All") + .setCta() + .onClick(async () => { + const confirmed = + (await this.core.confirm.askYesNoDialog( + $msg( + "Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable." + ), + { + title: $msg("Resolve all conflicts by the newest version"), + defaultOption: "No", + } + )) === "yes"; + if (!confirmed) { + return; + } + await this.services.conflict.resolveAllConflictedFilesByNewerOnes(); + }) + ); new Setting(paneEl) .setName("Check and convert non-path-obfuscated files") .setDesc("") diff --git a/styles.css b/styles.css index 8c9ec551..7815af18 100644 --- a/styles.css +++ b/styles.css @@ -654,12 +654,20 @@ body.is-mobile .livesync-compatibility-review-notice { } .sls-repair-action-menu { + display: inline-flex; + align-items: center; + justify-content: center; min-width: var(--clickable-icon-size); width: var(--clickable-icon-size); height: var(--clickable-icon-size); padding: 0; } +.sls-repair-action-menu .svg-icon { + width: 18px; + height: 18px; +} + .sls-repair-revision { margin-top: var(--size-4-2); padding: var(--size-4-2); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 20717f69..2b43797b 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -161,7 +161,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms `test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them. -`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose the appropriate `…` menu for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. +`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Inspect conflicts and file/database differences** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose a wrench menu with the appropriate actions for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally. `test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives. diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts index 99d565de..e8caf9e2 100644 --- a/test/e2e-obsidian/scripts/dialog-mounts.ts +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -765,18 +765,39 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { for (const label of [ "Write logs into the file", "Recreate chunks for current Vault files", - "Verify and repair all files", + "Inspect conflicts and file/database differences", + "Resolve All conflicted files by the newer one", ]) { await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); } + const settingNames = await liveSyncSettings.locator(".setting-item-name").allTextContents(); + const recreateIndex = settingNames.findIndex((name) => + name.includes("Recreate chunks for current Vault files") + ); + const inspectIndex = settingNames.findIndex((name) => + name.includes("Inspect conflicts and file/database differences") + ); + const resolveIndex = settingNames.findIndex((name) => + name.includes("Resolve All conflicted files by the newer one") + ); + if ( + recreateIndex === -1 || + inspectIndex === -1 || + resolveIndex === -1 || + !(recreateIndex < inspectIndex && inspectIndex < resolveIndex) + ) { + throw new Error( + "Recovery actions are not ordered from chunk recreation through inspection to bulk conflict resolution" + ); + } await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); - await liveSyncSettings.getByRole("button", { name: "Verify all", exact: true }).waitFor({ + await liveSyncSettings.getByRole("button", { name: "Scan all files", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); @@ -897,7 +918,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { await page .locator(".sls-setting:visible") .last() - .getByRole("button", { name: "Verify all", exact: true }) + .getByRole("button", { name: "Scan all files", exact: true }) .click({ timeout: uiTimeoutMs, }); diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts index 7b5e642d..c6e851ca 100644 --- a/test/e2e-obsidian/scripts/revision-repair.ts +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -230,12 +230,15 @@ function revisionCard(settings: Locator, revision: string): Locator { } async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise { - await revisionCard(settings, revision) - .getByRole("button", { - name: `More actions for revision ${revision}`, - exact: true, - }) - .click({ timeout: uiTimeoutMs }); + const actionButton = revisionCard(settings, revision).getByRole("button", { + name: `More actions for revision ${revision}`, + exact: true, + }); + await actionButton.locator("svg.lucide-wrench").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await actionButton.click({ timeout: uiTimeoutMs }); const menu = page.locator(".menu:visible").last(); await menu.waitFor({ state: "visible", timeout: uiTimeoutMs }); const box = await menu.boundingBox(); @@ -366,16 +369,18 @@ async function main(): Promise { await settings.waitFor({ state: "visible", timeout: uiTimeoutMs }); await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs }); const verifySetting = settings.locator(".setting-item").filter({ - has: page.getByText("Verify and repair all files", { exact: true }), + has: page.getByText("Inspect conflicts and file/database differences", { + exact: true, + }), }); - await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({ + await verifySetting.getByRole("button", { name: "Scan all files", exact: true }).click({ timeout: uiTimeoutMs, }); const card = repairCard(settings); await card.waitFor({ state: "visible", timeout: uiTimeoutMs }); if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) { throw new Error( - `Verify and Repair reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).` + `File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).` ); } const winnerRevision = revisionCard(settings, fixture.winnerRevision); diff --git a/updates.md b/updates.md index 048686cc..54d99fb5 100644 --- a/updates.md +++ b/updates.md @@ -14,7 +14,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved -- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, unavailable shared ancestors, and file-information differences separately. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, a Vault which matches the winner, and conflict branches which still remain. Each live revision has an **…** menu for read-only text comparison, exact revision-to-Vault reflection, recording an exact match, storing the Vault file as a child of that branch, or explicitly discarding only that branch while another live branch remains. A logical deletion which already matches an absent Vault file is no longer reported. Retrying a revision does not change the tree, and discarding the sole unreadable live revision remains an explicitly confirmed recovery action. +- **Inspect conflicts and file/database differences** now reports the database winner, every conflict revision, missing chunks, unavailable shared ancestors, and file-information differences separately. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, a Vault which matches the winner, and conflict branches which still remain. Each live revision has a wrench menu for read-only text comparison, exact revision-to-Vault reflection, recording an exact match, storing the Vault file as a child of that branch, or explicitly discarding only that branch while another live branch remains. A logical deletion which already matches an absent Vault file is no longer reported. Retrying a revision does not change the tree, and discarding the sole unreadable live revision remains an explicitly confirmed recovery action. - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Text in setup and review dialogues can now be selected for copying or translation. - When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. From ad762d4ddfae966a49be040ebdcba6d733eec72b Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 08:05:39 +0000 Subject: [PATCH 156/170] Update Commonlib and Obsidian test-session packages --- package-lock.json | 24 ++++++++++++------------ package.json | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index e23e436a..f30c0c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.12", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.14", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", @@ -56,7 +56,7 @@ "@types/transform-pouch": "^1.0.6", "@typescript-eslint/parser": "8.56.1", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.2.5", + "@vrtmrz/obsidian-test-session": "0.2.6", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -4764,9 +4764,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.0-rc.12", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.12.tgz", - "integrity": "sha512-pMiOL4x5pDKCYOwtH3nG+9Ra1sLjowItBUrYoHpvcrweV4NSN0OkAEMk1vxBQlKMmKtnrAdy0WuQvmsdLOWHqA==", + "version": "0.1.0-rc.14", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.14.tgz", + "integrity": "sha512-5aEy0x/aGNJjNdQYpQQ3VK386qlXZNInxq8pcsjf+NTV/X8jBQ+Q+omiRjYqn6BpS30xVWWeAZ/vdTeaiLsDVw==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", @@ -4839,9 +4839,9 @@ } }, "node_modules/@vrtmrz/obsidian-test-session": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.5.tgz", - "integrity": "sha512-ZsI+Yx3z6IEFfh5Ey5mEUBNI0SUD6oDhP7D9LSZVEqPmdJz2JMiag7d8u/p1i6KpsoI2HbDvtw4RHpB+BQReAw==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.6.tgz", + "integrity": "sha512-7xDTmTW3igBxwQGgbbpoRZU0JvOBpGgdRT4qF2WVrKz03OtKMJdkfJILY04/HZ+n4tvn9Ze8phJBr0dx3wewcQ==", "dev": true, "license": "MIT", "engines": { @@ -5975,15 +5975,15 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { diff --git a/package.json b/package.json index 927ee1db..5de42ca4 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "@types/transform-pouch": "^1.0.6", "@typescript-eslint/parser": "8.56.1", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.2.5", + "@vrtmrz/obsidian-test-session": "0.2.6", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -165,7 +165,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.12", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.14", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", From 4194a0067ce7eb0e29bdde0086f9f45790310873 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 08:06:08 +0000 Subject: [PATCH 157/170] Stabilise configured Real Obsidian workflows --- .../runner/liveSyncWorkflow.test.ts | 16 ++ test/e2e-obsidian/runner/liveSyncWorkflow.ts | 11 +- test/e2e-obsidian/runner/securitySeed.ts | 11 ++ .../scripts/cli-to-obsidian-sync.ts | 35 ++-- test/e2e-obsidian/scripts/minio-upload.ts | 24 +++ test/e2e-obsidian/scripts/review-harness.ts | 158 +++++++++++++++++- .../scripts/security-seed-reconnect.ts | 23 +++ 7 files changed, 256 insertions(+), 22 deletions(-) diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts index c85d0163..85f64421 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -10,6 +10,7 @@ vi.mock("./cli.ts", () => ({ evalObsidianJson })); import { assertE2eCompatibilityMarker, createE2eCouchDbPluginData, + prepareRemote, waitForLiveSyncCoreReady, type CompatibilityMarkerState, } from "./liveSyncWorkflow.ts"; @@ -77,3 +78,18 @@ describe("Real Obsidian core readiness", () => { expect(evalObsidianJson).toHaveBeenCalledTimes(2); }); }); + +describe("remote fixture preparation", () => { + it("waits for the remote Security Seed after resolving a new remote", async () => { + evalObsidianJson.mockReset(); + evalObsidianJson.mockResolvedValueOnce({ status: "resolved", securitySeedReady: true }); + + await prepareRemote("obsidian-cli", {}); + + const evaluatedCode = String(evalObsidianJson.mock.calls[0]?.[1] ?? ""); + expect(evaluatedCode.indexOf("markRemoteResolved")).toBeLessThan( + evaluatedCode.indexOf("ensurePBKDF2Salt") + ); + expect(evaluatedCode).toContain("Timed out preparing the remote Security Seed"); + }); +}); diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts index ca10fd62..896fb058 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -462,6 +462,7 @@ export function assertObsidianServiceContextContract(result: ObsidianServiceCont } export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000); await evalObsidianJson( cliBinary, [ @@ -471,8 +472,16 @@ export async function prepareRemote(cliBinary: string, env: NodeJS.ProcessEnv): "const replicator=core.services.replicator.getActiveReplicator();", "await replicator.tryCreateRemoteDatabase(settings);", "await replicator.markRemoteResolved(settings);", + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "let securitySeedReady=false;", + "do{", + "securitySeedReady=await replicator.ensurePBKDF2Salt(settings,false,false);", + "if(securitySeedReady) break;", + "await new Promise((resolve)=>setTimeout(resolve,250));", + "}while(Date.now() { cliBinary: obsidianCli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData( + { + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }, + { + encrypt: true, + passphrase: e2eePassphrase, + usePathObfuscation: true, + E2EEAlgorithm: "v2", + } + ), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); - await configureCouchDb( - obsidianCli.binary, - session.cliEnv, - { - uri: couchDb.uri, - username: couchDb.username, - password: couchDb.password, - dbName, - }, - { - encrypt: true, - passphrase: e2eePassphrase, - usePathObfuscation: true, - E2EEAlgorithm: "v2", - } - ); - await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); await prepareRemote(obsidianCli.binary, session.cliEnv); await pushLocalChanges(obsidianCli.binary, session.cliEnv); diff --git a/test/e2e-obsidian/scripts/minio-upload.ts b/test/e2e-obsidian/scripts/minio-upload.ts index 6af8b42a..b0899f12 100644 --- a/test/e2e-obsidian/scripts/minio-upload.ts +++ b/test/e2e-obsidian/scripts/minio-upload.ts @@ -1,8 +1,27 @@ +/** + * Verifies one complete Object Storage upload from a real Obsidian Vault, + * through LiveSync's local database and Journal Sync, to an S3-compatible + * service observed independently through the AWS SDK. + * + * The isolated Vault starts with Object Storage settings and the device-local + * compatibility acknowledgement already in place. Unconfigured start-up is + * intentionally inert and belongs to the onboarding scenario; compatibility + * review and visible setup have their own dedicated workflows. Supplying those + * prerequisites here keeps this scenario focused on the upload boundary. + * + * Note creation, local-database observation, one-shot synchronisation, request + * accounting, remote-object inspection, and prefix cleanup remain in one + * scenario so that a pass proves the same payload crossed every boundary. + * Separate successes would not prove that those observations belonged to the + * same upload. + */ import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, configureObjectStorage, + createE2eObjectStoragePluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -100,6 +119,11 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eObjectStoragePluginData({ + ...objectStorage, + bucketPrefix, + }), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); diff --git a/test/e2e-obsidian/scripts/review-harness.ts b/test/e2e-obsidian/scripts/review-harness.ts index e080a45a..3c7fb96f 100644 --- a/test/e2e-obsidian/scripts/review-harness.ts +++ b/test/e2e-obsidian/scripts/review-harness.ts @@ -1,3 +1,5 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { assertLocatorHasMinimumTouchTarget, assertLocatorWithinSafeArea, @@ -6,11 +8,17 @@ import { import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; import { REVIEW_HARNESS_STATE_KEY } from "../../../src/features/ReviewHarness/reviewHarnessController.ts"; import { REVIEW_HARNESS_FIXTURE_ROOT } from "../../../src/features/ReviewHarness/reviewHarnessVaultFixture.ts"; +import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; -import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { + captureObsidianDialogue, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVIEW_HARNESS_TIMEOUT_MS ?? 15000); @@ -26,6 +34,135 @@ type ReviewHarnessTestGlobal = typeof globalThis & { reviewHarnessCopiedReport?: string; }; +type ReviewHarnessReadinessSnapshot = { + coreAvailable: boolean; + databaseReady?: boolean; + appReady?: boolean; + configured?: boolean; + remoteType?: string; + settingVersion?: number; + suspended?: boolean; + unresolvedMessages: string[]; +}; + +const sensitiveDiagnosticLine = + /security seed|passphrase|password|credential|secret|access.?key|jwt.?key|authori[sz]ation|obsidian:\/\/setuplivesync|sls\+/iu; +const interruptedStartupMessages = [ + "No replicator has been activated or has not been initialised yet.", + "Self-hosted LiveSync cannot be initialised, exiting loading.", +]; + +function redactDiagnosticLine(line: string): string { + if (sensitiveDiagnosticLine.test(line)) return "[REDACTED SENSITIVE LOG LINE]"; + return line.replace(/\bhttps?:\/\/[^/\s:@]+:[^@\s/]+@/giu, "https://[REDACTED]@"); +} + +async function assertNoInterruptedStartupNotice(stage: string): Promise { + const notices = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForTimeout(1500); + return await page.locator(".notice").allTextContents(); + }); + const interrupted = notices.filter((notice) => + interruptedStartupMessages.some((message) => notice.includes(message)) + ); + if (interrupted.length > 0) { + throw new Error(`LiveSync emitted an interrupted-startup Notice during ${stage}: ${interrupted.join(" | ")}`); + } + console.log(`No interrupted-startup Notice observed during ${stage}.`); +} + +async function captureReadinessFailure( + cliBinary: string, + session: ObsidianLiveSyncSession, + readinessError: unknown +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + + const captureErrors: string[] = []; + let screenshotPath: string | undefined; + try { + screenshotPath = await captureObsidianPage( + obsidianRemoteDebuggingPort(), + "review-harness-core-not-ready.png", + async () => undefined + ); + } catch (error) { + captureErrors.push(`screenshot: ${error instanceof Error ? error.message : String(error)}`); + } + + let readiness: ReviewHarnessReadinessSnapshot | undefined; + try { + readiness = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync']?.core;", + "if(!core)return JSON.stringify({coreAvailable:false,unresolvedMessages:[]});", + "const settings=core.services.setting.currentSettings();", + "let unresolvedMessages=[];", + "try{", + "unresolvedMessages=(await core.services.appLifecycle.getUnresolvedMessages()).flat()", + ".filter((message)=>message!==undefined&&message!==null)", + ".map((message)=>String(message)).slice(-50);", + "}catch(error){unresolvedMessages=[`Could not inspect unresolved messages: ${String(error)}`];}", + "return JSON.stringify({", + "coreAvailable:true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "configured:settings?.isConfigured===true,", + "remoteType:settings?.remoteType??'',", + "settingVersion:settings?.settingVersion,", + "suspended:core.services.appLifecycle.isSuspended(),", + "unresolvedMessages,", + "});", + "})()", + ].join(""), + session.cliEnv + ); + readiness.unresolvedMessages = readiness.unresolvedMessages.map(redactDiagnosticLine); + } catch (error) { + captureErrors.push(`readiness snapshot: ${error instanceof Error ? error.message : String(error)}`); + } + + let recentLog: string[] = []; + try { + recentLog = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const opened = await page.evaluate( + (commandId) => + (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:view-log" + ); + if (!opened) throw new Error("The Show log command was not registered."); + const logPane = page.locator(".logpane"); + await logPane.waitFor({ state: "visible", timeout: 5000 }); + return (await logPane.locator(".log pre").allTextContents()).slice(-80).map(redactDiagnosticLine); + }); + } catch (error) { + captureErrors.push(`recent log: ${error instanceof Error ? error.message : String(error)}`); + } + + const resultPath = join(outputDirectory, "review-harness-core-not-ready.json"); + await writeFile( + resultPath, + `${JSON.stringify( + { + capturedAt: new Date().toISOString(), + failure: readinessError instanceof Error ? readinessError.message : String(readinessError), + screenshotPath, + readiness, + recentLog, + captureErrors, + }, + null, + 2 + )}\n`, + "utf8" + ); + if (screenshotPath) console.error(`Review Harness core readiness screenshot: ${screenshotPath}`); + console.error(`Review Harness core readiness diagnostics: ${resultPath}`); +} + async function openHarness(): Promise { const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { return await page.evaluate( @@ -203,6 +340,8 @@ async function copyAndReadReport(): Promise { async function verifyMobileHarness(): Promise { await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + if (await harness.isVisible()) return; await page.evaluate(async (viewType) => { const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) { @@ -252,7 +391,7 @@ async function main(): Promise { vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData: { - doctorProcessedVersion: "0.25.27", + doctorProcessedVersion: "1.0.0", settingVersion: CURRENT_SETTING_VERSION, isConfigured: true, additionalSuffixOfDatabaseName: "", @@ -269,7 +408,20 @@ async function main(): Promise { periodicReplication: true, }, }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await assertNoInterruptedStartupNotice("plug-in session start"); + try { + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + } catch (error) { + await captureReadinessFailure(cli.binary, session, error).catch((diagnosticError: unknown) => { + console.error( + `Could not capture Review Harness readiness diagnostics: ${ + diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError) + }` + ); + }); + throw error; + } + await assertNoInterruptedStartupNotice("core readiness"); await keepCompatibilityPaused(); await openHarness(); await waitForHarness(); diff --git a/test/e2e-obsidian/scripts/security-seed-reconnect.ts b/test/e2e-obsidian/scripts/security-seed-reconnect.ts index e0027a23..d75ffc21 100644 --- a/test/e2e-obsidian/scripts/security-seed-reconnect.ts +++ b/test/e2e-obsidian/scripts/security-seed-reconnect.ts @@ -1,3 +1,26 @@ +/** + * Provides release evidence for the Security Seed refresh behaviour shared by + * supported platforms in real Obsidian. It verifies that an already-open + * device keeps its deliberately stale cached Seed until replication, refreshes + * from the managed CouchDB fixture before encrypting, and never restores the + * old Seed to the remote synchronisation-parameter document. + * + * The scenario uses isolated Vaults, profiles, and a random database because + * settings, the local database, the renderer process, and CouchDB must all + * participate in the result. Device A is restarted with the same Vault and + * profile, while device B is fresh. The devices run sequentially after the + * same-process stale-cache assertion because desktop Obsidian may enforce a + * single application instance; running them concurrently would test launcher + * behaviour rather than LiveSync's shared plug-in implementation. + * + * Seed replacement, A-to-B decryption, B-to-A return synchronisation, final + * remote-document comparison, error-log inspection, screenshots, and strict + * teardown remain one scenario. Together they prove that the same replacement + * Seed was used across the complete encrypted round trip and was not later + * rolled back. Independent passing checks would not establish that continuity. + * The result records fingerprints only and does not claim to cover an + * iPadOS-specific background or reconnect lifecycle. + */ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { access, mkdir, readFile, writeFile } from "node:fs/promises"; From 51a749099d9d5c0fb53bd1dc4447a725f9825314 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 08:06:48 +0000 Subject: [PATCH 158/170] Verify P2P pane in a fresh mobile session --- .../services/ObsidianAPIService.unit.spec.ts | 67 +++++ test/e2e-obsidian/README.md | 16 +- test/e2e-obsidian/runner/mobileUi.ts | 76 +++++- test/e2e-obsidian/runner/session.test.ts | 32 +++ test/e2e-obsidian/runner/session.ts | 11 +- test/e2e-obsidian/scripts/p2p-pane.ts | 254 +++++++++++++++--- 6 files changed, 404 insertions(+), 52 deletions(-) create mode 100644 src/modules/services/ObsidianAPIService.unit.spec.ts diff --git a/src/modules/services/ObsidianAPIService.unit.spec.ts b/src/modules/services/ObsidianAPIService.unit.spec.ts new file mode 100644 index 00000000..6523bfaf --- /dev/null +++ b/src/modules/services/ObsidianAPIService.unit.spec.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + platform: { + isMobile: false, + }, +})); + +vi.mock("@/deps.ts", () => ({ + Platform: mocks.platform, + requestUrl: vi.fn(), +})); + +vi.mock("@/deps", () => ({ + Platform: mocks.platform, + requestUrl: vi.fn(), +})); + +vi.mock("@/modules/essentialObsidian/APILib/ObsHttpHandler", () => ({ + ObsHttpHandler: class {}, +})); + +vi.mock("./ObsidianConfirm", () => ({ + ObsidianConfirm: class {}, +})); + +import { ObsidianAPIService } from "./ObsidianAPIService"; +import type { ObsidianServiceContext } from "./ObsidianServiceContext"; + +function createService(workspace: Record, isMobile = false): ObsidianAPIService { + return new ObsidianAPIService({ + app: { workspace, isMobile }, + } as unknown as ObsidianServiceContext); +} + +beforeEach(() => { + mocks.platform.isMobile = false; + vi.clearAllMocks(); +}); + +describe("ObsidianAPIService.showWindowOnRight", () => { + it("keeps the status view in the right leaf on mobile", async () => { + mocks.platform.isMobile = true; + const rightLeaf = { + setViewState: vi.fn().mockResolvedValue(undefined), + }; + const workspace = { + getLeavesOfType: vi.fn(() => []), + getLeaf: vi.fn(), + getRightLeaf: vi.fn(() => rightLeaf), + revealLeaf: vi.fn().mockResolvedValue(undefined), + }; + const service = createService(workspace, true); + + expect(service.isMobile()).toBe(true); + await service.showWindowOnRight("p2p-status"); + + expect(workspace.getLeavesOfType).toHaveBeenCalledWith("p2p-status"); + expect(workspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(workspace.getLeaf).not.toHaveBeenCalled(); + expect(rightLeaf.setViewState).toHaveBeenCalledWith({ + type: "p2p-status", + active: false, + }); + expect(workspace.revealLeaf).toHaveBeenCalledWith(rightLeaf); + }); +}); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 2b43797b..ea57edab 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -7,12 +7,12 @@ The generic application discovery, isolated-vault, plug-in installation, process The current smoke runner verifies the launch path and the loaded plug-in's Service Context composition: 1. create a temporary vault, -2. install the built Self-hosted LiveSync plug-in artifacts, +2. install the built Self-hosted LiveSync plug-in artefacts, 3. launch real Obsidian, 4. open the temporary vault through `obsidian-cli`, -5. enable Obsidian community plug-ins for the temporary app profile, -6. reload Self-hosted LiveSync through `obsidian-cli`, -7. verify through `obsidian-cli eval` that the plug-in is loaded, +5. prepare the isolated Vault trust state and handle any Obsidian trust prompt, +6. preserve natural plug-in loading, or complete requested pre-load work before loading the plug-in once in controlled start-up, +7. verify through the active renderer that the plug-in is loaded, 8. observe event and translation results from the actual `ObsidianServiceContext`, 9. verify that the Service Hub and every exposed service retain that exact Context, 10. optionally drive a real vault or CouchDB workflow through Obsidian's own API, and @@ -24,6 +24,8 @@ Obsidian 1.12 stores the global community plug-in switch outside `.obsidian/comm Future workflows should use `startObsidianLiveSyncSession()` from `runner/session.ts` rather than repeating the launch and plug-in readiness sequence. Add generic Obsidian bootstrap improvements to Fancy Kit; keep LiveSync behaviour and scenario helpers here. +When a LiveSync-owned scenario must establish application state before the plug-in's first load, pass an instance-scoped `lifecycle.beforePluginStart` callback through that wrapper. For example, the P2P pane scenario calls `setObsidianMobileTestModeBeforePluginStart()` there so LiveSync observes the mobile application state while registering its command and view. Mobile emulation reopens Obsidian's workspace layout; this helper waits for both the `is-mobile` body state and `workspace.layoutReady` before controlled loading continues. The shared package owns the controlled start-up order and guarantees that the plug-in loads once; the LiveSync scenario owns the resulting command, workspace placement, and visible UI assertions. Changing the state only after loading the plug-in is not evidence of its mobile start-up behaviour. + Each test vault uses an isolated Obsidian profile. The runner creates temporary directories for `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, and Electron `--user-data-dir`, writes the vault registry into those directories, pre-seeds the temporary Chromium local storage so community plug-ins are trusted for that generated vault ID, and passes the same environment to `obsidian-cli`. This is intended to keep real Obsidian E2E runs separate from a developer's daily Obsidian profile and vault registry. On macOS, `@vrtmrz/obsidian-test-session` keeps the generated Vault and profile below `/tmp` so Obsidian's Unix-domain CLI socket remains below the platform path limit. It also gives only the isolated Obsidian process Chromium's mock-keychain flag, preventing the empty test HOME from opening a blocking login-keychain dialogue. LiveSync's deterministic fixture selects the built-in default language so a host-language translation prompt cannot pause plug-in readiness. The case-only rename check enumerates the parent directory and compares exact spellings because an old-path lookup still resolves the renamed file on the default case-insensitive macOS filesystem. @@ -110,7 +112,7 @@ The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixe `test:e2e:obsidian:review-harness` exercises only the boundaries owned by the opt-in maintainer Harness. It retains a real compatibility pause, uses the fixed Harness restart action to persist a device-local continuation and reload Obsidian, and requires the Harness to delete that state before reopening. It also runs the bounded local observations, confirms the dedicated Vault fixture root is removed, captures the copied privacy-bounded Markdown report, and checks the Harness layout and touch targets in mobile test mode. Compatibility explanation and persistence details remain owned by `settings-ui`, real P2P transfer remains owned by the dedicated P2P suites, and general Vault reflection remains owned by `vault-reflection`; the Harness test does not duplicate those workflows. -`test:e2e:obsidian:p2p-pane` starts one unconfigured CouchDB-only session and one configured P2P session. It proves that the command remains registered while the retired command, automatic pane, and unconfigured ribbon entry are absent. For the configured profile, it verifies that the ribbon and current status command reach the pane without opening it at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. +`test:e2e:obsidian:p2p-pane` starts one configured CouchDB-only session with no P2P profile and separate configured P2P sessions for desktop and mobile. It proves that the command remains registered while the retired command, automatic pane, and ribbon entry without a P2P configuration are absent. For the configured P2P profiles, it verifies that the desktop ribbon is available, the current status command reaches the pane without it opening at start-up, checks its connection control and horizontal layout, and captures unobstructed desktop and mobile screenshots. The mobile session uses a fresh Vault, profile, and Obsidian process, enters `app.emulateMobile(true)` through `lifecycle.beforePluginStart`, and requires the P2P view to belong to the right drawer rather than inheriting desktop workspace state. It deliberately uses no relay or peer: replacement of the active replicator is covered by focused unit tests, the Deno and Compose CLI P2P lifecycle suite covers the headless transport, and `p2p-setup-uri-workflow` owns the visible transfer path between two real Obsidian sessions. `test:e2e:obsidian:local-suite` builds the plug-in and, unless `LIVESYNC_CLI_COMMAND` selects an external CLI, the local LiveSync CLI. It then runs discovery, smoke, the onboarding invitation, Svelte dialogue mounting, revision repair, settings UI, the Review Harness, the P2P status pane, Vault reflection, CouchDB upload and manual setup, CLI-to-Obsidian synchronisation, Object Storage upload and Setup URI round-trip, P2P Setup URI round-trip, startup scan, provisioned CouchDB Setup URI, two-vault synchronisation, Hidden File Sync, Customisation Sync, and setting Markdown export in sequence. Start the local CouchDB, MinIO, and P2P relay fixtures before running it, or use `test:e2e:obsidian:local-suite:services` to let the wrapper stop leftover fixtures, start fresh fixtures, and stop them again after the run. @@ -126,6 +128,8 @@ The two-Vault workflow performs the missing-marker review once for each isolated `test:e2e:obsidian:cli-to-obsidian-sync` is the cross-runtime compatibility check for the official LiveSync CLI and the real Obsidian plug-in. Build the plug-in first, and build the local CLI too when no external CLI command is selected. The script uses E2EE, Path Obfuscation, and the current preferred chunk settings to create and synchronise a note through the CLI, starts real Obsidian with an isolated Vault and profile, synchronises the same CouchDB database, and verifies that the plug-in materialises identical note content. This covers the boundary that CLI-only and plug-in-only round trips do not exercise. +The isolated Obsidian session starts with its CouchDB settings and device-local compatibility acknowledgement already in place. This keeps the scenario focused on cross-runtime data compatibility; unconfigured start-up and visible CouchDB onboarding are covered by their dedicated workflows. + By default, the compatibility check runs `node src/apps/cli/dist/index.cjs`. Set `LIVESYNC_CLI_COMMAND` to test another CLI build or distribution. The value may be a quoted command line or a JSON array of executable and prefix arguments; the scenario arguments are appended without going through a shell. For example, to test an executable on `PATH`: @@ -141,7 +145,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- npm run test:e2e:obsidian:cli-to-obsidian-sync ``` -`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service, configures Self-hosted LiveSync for Object Storage through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance. +`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance. `test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. diff --git a/test/e2e-obsidian/runner/mobileUi.ts b/test/e2e-obsidian/runner/mobileUi.ts index 530f008c..d492092d 100644 --- a/test/e2e-obsidian/runner/mobileUi.ts +++ b/test/e2e-obsidian/runner/mobileUi.ts @@ -1,3 +1,5 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; import { assertLocatorHasMinimumTouchTarget, assertLocatorWithinSafeArea, @@ -12,13 +14,20 @@ export const desktopViewport = { width: 1024, height: 768 } as const; export const iPhoneSafeArea = { top: 47, right: 0, bottom: 34, left: 0 } as const; type ObsidianTestApp = { + isMobile?: boolean; emulateMobile?: (mobile: boolean) => void; plugins?: { plugins: Record }; + workspace?: { layoutReady?: boolean }; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; -export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise { +async function applyObsidianMobileTestMode( + port: number, + enabled: boolean, + timeoutMs: number, + waitForLiveSync: boolean +): Promise { await withObsidianPage(port, async (page) => { await page.setViewportSize(enabled ? mobileViewport : desktopViewport); await page.evaluate((nextEnabled) => { @@ -28,17 +37,49 @@ export async function setObsidianMobileTestMode(port: number, enabled: boolean, } obsidianApp.emulateMobile(nextEnabled); }, enabled); - await page.waitForFunction( - (nextEnabled) => { + // Obsidian reopens its workspace layout when platform emulation + // changes. Loading a controlled plug-in before that transition has + // completed can leave the plug-in enabled but absent from the active + // renderer. + try { + await page.waitForFunction( + ({ nextEnabled, waitForLiveSync }) => { + const obsidianApp = (globalThis as ObsidianTestGlobal).app; + return ( + document.body.classList.contains("is-mobile") === nextEnabled && + obsidianApp?.workspace?.layoutReady === true && + (!waitForLiveSync || obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined) + ); + }, + { nextEnabled: enabled, waitForLiveSync }, + { timeout: timeoutMs } + ); + } catch (error) { + const state = await page.evaluate(() => { const obsidianApp = (globalThis as ObsidianTestGlobal).app; - return ( - document.body.classList.contains("is-mobile") === nextEnabled && - obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined - ); - }, - enabled, - { timeout: timeoutMs } - ); + return { + appIsMobile: obsidianApp?.isMobile ?? null, + bodyClasses: document.body.className, + documentReadyState: document.readyState, + liveSyncLoaded: obsidianApp?.plugins?.plugins["obsidian-livesync"] !== undefined, + viewport: { width: window.innerWidth, height: window.innerHeight }, + workspaceLayoutReady: obsidianApp?.workspace?.layoutReady ?? null, + }; + }); + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + const screenshotPath = join( + outputDirectory, + waitForLiveSync + ? "mobile-mode-transition.failure.png" + : "mobile-mode-before-plugin-start.failure.png" + ); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `Obsidian mobile-mode transition did not settle: ${JSON.stringify(state)}; screenshot=${screenshotPath}; cause=${detail}` + ); + } await page.evaluate( (safeArea) => { for (const edge of ["top", "right", "bottom", "left"] as const) { @@ -52,6 +93,19 @@ export async function setObsidianMobileTestMode(port: number, enabled: boolean, }); } +/** Enters mobile emulation before LiveSync's first load in a controlled session. */ +export async function setObsidianMobileTestModeBeforePluginStart( + port: number, + enabled: boolean, + timeoutMs: number +): Promise { + await applyObsidianMobileTestMode(port, enabled, timeoutMs, false); +} + +export async function setObsidianMobileTestMode(port: number, enabled: boolean, timeoutMs: number): Promise { + await applyObsidianMobileTestMode(port, enabled, timeoutMs, true); +} + export async function assertMobileDialogueLayout(page: Page, container: Locator, label: string): Promise { const dialogue = container.locator(".modal").last(); const closeButton = dialogue.locator(".modal-close-button"); diff --git a/test/e2e-obsidian/runner/session.test.ts b/test/e2e-obsidian/runner/session.test.ts index c959ffd3..8977cb96 100644 --- a/test/e2e-obsidian/runner/session.test.ts +++ b/test/e2e-obsidian/runner/session.test.ts @@ -52,4 +52,36 @@ describe("LiveSync real-Obsidian session", () => { }) ); }); + + it("forwards instance-scoped lifecycle hooks and the selected plug-in start mode", async () => { + const beforePluginStart = vi.fn(async () => undefined); + const vault = { + path: "/tmp/mobile-vault", + statePath: "/tmp/mobile-state", + name: "mobile-vault", + id: "mobile-vault-id", + homePath: "/tmp/mobile-state/home", + xdgConfigPath: "/tmp/mobile-state/xdg-config", + xdgCachePath: "/tmp/mobile-state/xdg-cache", + xdgDataPath: "/tmp/mobile-state/xdg-data", + userDataPath: "/tmp/mobile-state/user-data", + processMarker: "/tmp/mobile-state", + dispose: vi.fn(async () => undefined), + }; + + await startObsidianLiveSyncSession({ + binary: "/Applications/Obsidian", + cliBinary: "obsidian-cli", + vault, + pluginStartup: "controlled", + lifecycle: { beforePluginStart }, + }); + + expect(startObsidianPluginSession).toHaveBeenCalledWith( + expect.objectContaining({ + lifecycle: { beforePluginStart }, + pluginStartup: "controlled", + }) + ); + }); }); diff --git a/test/e2e-obsidian/runner/session.ts b/test/e2e-obsidian/runner/session.ts index 8f617587..8347f5e3 100644 --- a/test/e2e-obsidian/runner/session.ts +++ b/test/e2e-obsidian/runner/session.ts @@ -1,4 +1,9 @@ -import { startObsidianPluginSession, type ObsidianPluginSession } from "@vrtmrz/obsidian-test-session"; +import { + startObsidianPluginSession, + type ObsidianPluginSession, + type ObsidianPluginSessionLifecycle, + type ObsidianPluginStartupMode, +} from "@vrtmrz/obsidian-test-session"; import type { TemporaryVault } from "./vault.ts"; export type ObsidianLiveSyncSession = ObsidianPluginSession; @@ -11,6 +16,8 @@ export type StartObsidianLiveSyncSessionOptions = { startupGraceMs?: number; pluginData?: Record; localStorageEntries?: Readonly>; + pluginStartup?: ObsidianPluginStartupMode; + lifecycle?: ObsidianPluginSessionLifecycle; env?: NodeJS.ProcessEnv; }; @@ -26,6 +33,8 @@ export async function startObsidianLiveSyncSession( startupGraceMs: options.startupGraceMs, pluginData: options.pluginData, localStorageEntries: options.localStorageEntries, + pluginStartup: options.pluginStartup, + lifecycle: options.lifecycle, env: options.env, }); } diff --git a/test/e2e-obsidian/scripts/p2p-pane.ts b/test/e2e-obsidian/scripts/p2p-pane.ts index 8baf00e3..fc57b6c8 100644 --- a/test/e2e-obsidian/scripts/p2p-pane.ts +++ b/test/e2e-obsidian/scripts/p2p-pane.ts @@ -1,46 +1,181 @@ +/** + * Verifies the complete user-visible contract of the P2P status pane in real + * Obsidian: a configured CouchDB-only Vault with no P2P profile is not + * presented with P2P controls, while configured P2P devices can deliberately + * open the current pane in the appropriate workspace area. + * + * Desktop and mobile use separate Vaults, profiles, and Obsidian processes. + * Mobile mode is enabled before LiveSync's first load so that command and view + * registration observe the mobile application state, and no desktop workspace + * state can make a misplaced or restored pane appear correct. + * + * Command registration, automatic-opening policy, ribbon availability, + * workspace ownership, layout, and screenshots are kept in one scenario + * because together they describe one navigation path. Checking them in + * isolation could miss a pane which is registered correctly but opens in the + * wrong area, or one which is visible only because another session restored it. + */ import { assertLocatorWithinViewport, assertNoHorizontalOverflow } from "@vrtmrz/obsidian-test-session"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; -import type { Page } from "playwright"; +import type { ConsoleMessage, Page } from "playwright"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; -import { setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { setObsidianMobileTestModeBeforePluginStart } from "../runner/mobileUi.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { captureObsidianPage, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_PANE_TIMEOUT_MS ?? 10000); +type ObsidianTestLeaf = { + containerEl?: HTMLElement; + view?: { getViewType?: () => string }; +}; + +type ObsidianTestWorkspace = { + activeLeaf?: ObsidianTestLeaf; + getLeavesOfType?: (type: string) => ObsidianTestLeaf[]; + getRightLeaf?: (split: boolean) => ObsidianTestLeaf | null; + rightSplit?: { containerEl?: HTMLElement }; +}; + type ObsidianTestApp = { commands?: { commands?: Record; executeCommandById(commandId: string): boolean; }; + isMobile?: boolean; + plugins?: { + plugins?: Record< + string, + { + core?: { + services?: { + API?: { + isMobile?: () => boolean; + }; + }; + }; + } + >; + }; + workspace?: ObsidianTestWorkspace; }; type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; -async function openP2PStatusPane(): Promise { - const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { - return await page.evaluate( - (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, - "obsidian-livesync:open-p2p-server-status" - ); +async function openP2PStatusPane(page: Page) { + return await page.evaluate((commandId) => { + const app = (globalThis as ObsidianTestGlobal).app; + const plugin = app?.plugins?.plugins?.["obsidian-livesync"]; + return { + opened: app?.commands?.executeCommandById(commandId) === true, + appIsMobile: app?.isMobile ?? null, + apiIsMobile: plugin?.core?.services?.API?.isMobile?.() ?? null, + bodyIsMobile: document.body.classList.contains("is-mobile"), + }; + }, "obsidian-livesync:open-p2p-server-status"); +} + +async function collectP2PWorkspaceState(page: Page) { + return await page.evaluate(() => { + const workspace = (globalThis as ObsidianTestGlobal).app?.workspace; + const activeLeaf = workspace?.activeLeaf; + const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? []; + const rightLeaf = workspace?.getRightLeaf?.(false); + return { + bodyClasses: document.body.className, + activeLeaf: { + type: activeLeaf?.view?.getViewType?.() ?? null, + visible: activeLeaf?.containerEl?.checkVisibility?.() ?? null, + classes: activeLeaf?.containerEl?.className ?? null, + }, + p2pLeaves: p2pLeaves.map((leaf) => ({ + type: leaf.view?.getViewType?.() ?? null, + visible: leaf.containerEl?.checkVisibility?.() ?? null, + classes: leaf.containerEl?.className ?? null, + })), + rightLeaf: { + type: rightLeaf?.view?.getViewType?.() ?? null, + visible: rightLeaf?.containerEl?.checkVisibility?.() ?? null, + classes: rightLeaf?.containerEl?.className ?? null, + }, + visibleP2PContents: document.querySelectorAll( + ".workspace-leaf-content[data-type='p2p-server-status']:not(.is-hidden)" + ).length, + }; }); - if (!opened) { - throw new Error("The P2P status command was not registered or could not be executed."); +} + +async function assertMobileP2PPlacement(page: Page): Promise { + const placement = await page.evaluate(() => { + const workspace = (globalThis as ObsidianTestGlobal).app?.workspace; + const p2pLeaves = workspace?.getLeavesOfType?.("p2p-server-status") ?? []; + const rightSplit = workspace?.rightSplit?.containerEl; + const rightLeaf = workspace?.getRightLeaf?.(false); + return { + p2pLeafCount: p2pLeaves.length, + inRightSplit: p2pLeaves.some( + (leaf) => + (rightSplit?.contains(leaf.containerEl ?? null) ?? false) || + (leaf.containerEl?.closest(".mod-right-split, .workspace-drawer.mod-right") ?? null) !== null + ), + rightLeafType: rightLeaf?.view?.getViewType?.() ?? null, + p2pLeafClasses: p2pLeaves.map((leaf) => leaf.containerEl?.className ?? null), + rightSplitClasses: rightSplit?.className ?? null, + }; + }); + if (!placement.inRightSplit) { + throw new Error(`The mobile P2P status view was not opened in the right leaf: ${JSON.stringify(placement)}`); } } async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise { - await openP2PStatusPane(); return await captureObsidianPage(obsidianRemoteDebuggingPort(), filename, async (page) => { + const runtimeErrors: string[] = []; + const onPageError = (error: Error) => runtimeErrors.push(`pageerror: ${error.message}`); + const onConsole = (message: ConsoleMessage) => { + if (message.type() === "error") runtimeErrors.push(`console: ${message.text()}`); + }; + page.on("pageerror", onPageError); + page.on("console", onConsole); + let dispatchState: Awaited> | undefined; const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); - await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + try { + dispatchState = await openP2PStatusPane(page); + if (!dispatchState.opened) { + throw new Error("The P2P status command was not registered or could not be executed."); + } + if ( + mobile && + (dispatchState.appIsMobile !== true || + dispatchState.apiIsMobile !== true || + dispatchState.bodyIsMobile !== true) + ) { + throw new Error( + `The mobile P2P command did not observe a fully mobile application state: ${JSON.stringify(dispatchState)}` + ); + } + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + } catch (error) { + const workspaceState = await collectP2PWorkspaceState(page); + console.error( + `P2P command state after failed open: ${JSON.stringify({ dispatchState, runtimeErrors })}` + ); + console.error(`P2P workspace state after failed open: ${JSON.stringify(workspaceState)}`); + throw error; + } finally { + page.off("pageerror", onPageError); + page.off("console", onConsole); + } + if (mobile) { + await assertMobileP2PPlacement(page); + } const pane = heading.locator( "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" ); @@ -51,7 +186,14 @@ async function verifyP2PStatusPane(filename: string, mobile: boolean): Promise { throw new Error("The retired P2P pane command is still exposed."); } if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) { - throw new Error("The P2P status pane opened automatically for an unconfigured CouchDB user."); + throw new Error("The P2P status pane opened automatically for a CouchDB user without P2P configured."); } if ((await page.locator(".livesync-ribbon-p2p-server-status").count()) !== 0) { throw new Error("The P2P ribbon icon was shown without a P2P configuration."); @@ -104,12 +246,43 @@ async function assertConfiguredP2PUIIsAvailable(): Promise { }); } +async function assertConfiguredP2PCommandIsAvailable(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const state = await page.evaluate(() => { + const app = (globalThis as ObsidianTestGlobal).app; + const commands = app?.commands?.commands ?? {}; + return { + commandRegistered: commands["obsidian-livesync:open-p2p-server-status"] !== undefined, + openPaneCount: app?.workspace?.getLeavesOfType?.("p2p-server-status").length ?? 0, + }; + }); + if (!state.commandRegistered) { + throw new Error("The configured P2P status command was not registered in mobile mode."); + } + if (state.openPaneCount !== 0) { + throw new Error("The configured P2P status pane opened before the mobile user requested it."); + } + }); +} + async function dismissOpenNotices(page: Page): Promise { const deadline = Date.now() + uiTimeoutMs; let quietSince = Date.now(); while (Date.now() < deadline) { - const notices = page.locator(".notice:visible"); - if ((await notices.count()) === 0) { + const dismissed = await page.evaluate(() => { + const notices = (Array.from(document.querySelectorAll(".notice")) as HTMLElement[]).filter( + (notice) => notice.checkVisibility?.() ?? notice.offsetParent !== null + ); + for (const notice of notices) { + const closeButton = notice.querySelector(".notice-close-button") as HTMLElement | null; + // Obsidian 1.12 does not render a separate close control for + // every Notice; clicking the Notice itself is its standard + // dismiss action. + (closeButton ?? notice).click(); + } + return notices.length; + }); + if (dismissed === 0) { if (Date.now() - quietSince >= 500) { return; } @@ -117,14 +290,7 @@ async function dismissOpenNotices(page: Page): Promise { continue; } quietSince = Date.now(); - const closeButton = notices.first().locator(".notice-close-button"); - if ((await closeButton.count()) > 0) { - await closeButton.click({ force: true, timeout: uiTimeoutMs }); - } else { - // Obsidian 1.12 does not render a separate close control for every - // Notice; clicking the Notice itself is its standard dismiss action. - await notices.first().click({ force: true, position: { x: 2, y: 2 }, timeout: uiTimeoutMs }); - } + await page.waitForTimeout(50); } throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot."); } @@ -169,7 +335,8 @@ async function withP2PSession( binary: string, cliBinary: string, pluginData: Record, - verify: () => Promise + verify: () => Promise, + options: { mobileBeforePluginStart?: boolean } = {} ): Promise { const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; @@ -181,6 +348,17 @@ async function withP2PSession( startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), pluginData, localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), + lifecycle: options.mobileBeforePluginStart + ? { + beforePluginStart: async ({ remoteDebuggingPort }) => { + await setObsidianMobileTestModeBeforePluginStart( + remoteDebuggingPort, + true, + uiTimeoutMs + ); + }, + } + : undefined, }); await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); await verify(); @@ -210,17 +388,25 @@ async function main(): Promise { async () => { await assertConfiguredP2PUIIsAvailable(); const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false); - await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), true, uiTimeoutMs); - try { - const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true); - console.log( - `Configured P2P status UI remained opt-in and was reachable on desktop and mobile. Screenshots: ${desktopScreenshot}, ${mobileScreenshot}` - ); - } finally { - await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs); - } + console.log( + `Configured P2P status UI remained opt-in and was reachable on desktop. Screenshot: ${desktopScreenshot}` + ); } ); + + await withP2PSession( + binary, + cli.binary, + createConfiguredP2PPluginData(), + async () => { + await assertConfiguredP2PCommandIsAvailable(); + const mobileScreenshot = await verifyP2PStatusPane("p2p-status-pane-mobile.png", true); + console.log( + `Configured P2P status UI remained opt-in and was reachable on mobile. Screenshot: ${mobileScreenshot}` + ); + }, + { mobileBeforePluginStart: true } + ); } main().catch((error: unknown) => { From 12061b7bf3d3a8c3cc723eb822dc3f12b21c61be Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 08:28:44 +0000 Subject: [PATCH 159/170] Reconcile beta.4 metadata for the next preview --- updates.md | 18 +++++++++++++++++- versions.json | 3 ++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/updates.md b/updates.md index 54d99fb5..2ef4928e 100644 --- a/updates.md +++ b/updates.md @@ -14,7 +14,23 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved -- **Inspect conflicts and file/database differences** now reports the database winner, every conflict revision, missing chunks, unavailable shared ancestors, and file-information differences separately. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, a Vault which matches the winner, and conflict branches which still remain. Each live revision has a wrench menu for read-only text comparison, exact revision-to-Vault reflection, recording an exact match, storing the Vault file as a child of that branch, or explicitly discarding only that branch while another live branch remains. A logical deletion which already matches an absent Vault file is no longer reported. Retrying a revision does not change the tree, and discarding the sole unreadable live revision remains an explicitly confirmed recovery action. +- **Inspect conflicts and file/database differences** now compares the current Vault file with every live database revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. Each revision's wrench menu can compare readable text, reflect that exact revision to the Vault, record an exact byte match, store the Vault content as a child of that branch, or discard only that branch. + +### Fixed + +- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. + +### Testing + +- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions. + +## 1.0.0-beta.4 + +25th July, 2026 + +### Improved + +- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. - Text in setup and review dialogues can now be selected for copying or translation. - When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. diff --git a/versions.json b/versions.json index a4585f26..5f4652c9 100644 --- a/versions.json +++ b/versions.json @@ -9,5 +9,6 @@ "1.0.0-beta.0": "1.7.2", "1.0.0-beta.1": "1.7.2", "1.0.0-beta.2": "1.7.2", - "1.0.0-beta.3": "1.7.2" + "1.0.0-beta.3": "1.7.2", + "1.0.0-beta.4": "1.7.2" } From da4b188b38bbf8731da172cb53b3f343f7493414 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 12:44:36 +0000 Subject: [PATCH 160/170] Clarify the file inspection action --- docs/recovery.md | 2 +- docs/settings.md | 2 +- src/common/messages/LiveSyncProvisionalMessages.ts | 2 +- src/modules/features/SettingDialogue/PaneHatch.ts | 2 +- test/e2e-obsidian/scripts/dialog-mounts.ts | 4 ++-- test/e2e-obsidian/scripts/revision-repair.ts | 2 +- updates.md | 3 +++ 7 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/recovery.md b/docs/recovery.md index 2a76b141..cb23d6fe 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -41,7 +41,7 @@ The `Hatch` recovery controls are ordered by escalation. Running **Recreate chun 1. Stop editing the affected file, pause replication on the participating devices, and keep a separate copy of every readable version. 2. If another device or backup has the intended content, preserve that copy before changing any revision. 3. If the current Vault file is readable, select **Recreate current chunks**. This can restore only chunks derived from the current Vault contents; it cannot reconstruct unique bytes from an unavailable historical or conflict revision. -4. Select **Inspect conflicts and file/database differences** → **Scan all files**. +4. Select **Inspect conflicts and file/database differences** → **Begin inspection**. 5. Review the database winner, every conflict revision, and any unavailable shared ancestor separately. Revision identifiers, `Δsize`, `Δtime`, and chunk availability are diagnostic evidence; they do not decide which content is correct. 6. Use the wrench menu on the exact revision: - **Compare with Vault** opens a read-only comparison for readable text. diff --git a/docs/settings.md b/docs/settings.md index 0f0003de..1ac07517 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -735,7 +735,7 @@ Recreate chunks from files currently present in the Vault. This can repair missi Compare each Vault file with every current live revision in the local database. Each winner and conflict revision is shown separately with its exact revision identifier, local chunk availability, and relationship to the current Vault file. Unavailable shared ancestors are reported separately because they prevent conservative three-way merging but are not live revisions which can be discarded. -Select **Scan all files** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. +Select **Begin inspection** to run the inspection. Each reported file and live revision has a wrench menu for read-only comparison, applying an exact database revision to the Vault, recording an exact byte match, preserving the Vault file as a child of a selected branch, retrying chunk retrieval, or explicitly discarding a branch. Destructive actions require confirmation. Follow [Recover a conflicted or mismatched file](recovery.md#recover-a-conflicted-or-mismatched-file) before changing revision history. #### Resolve All conflicted files by the newer one diff --git a/src/common/messages/LiveSyncProvisionalMessages.ts b/src/common/messages/LiveSyncProvisionalMessages.ts index a9a652fc..d9a4f5b2 100644 --- a/src/common/messages/LiveSyncProvisionalMessages.ts +++ b/src/common/messages/LiveSyncProvisionalMessages.ts @@ -144,7 +144,7 @@ export const liveSyncProvisionalEnglishMessages = { "Inspect conflicts and file/database differences", "Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.": "Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.", - "Scan all files": "Scan all files", + "Begin inspection": "Begin inspection", "Connection settings": "Connection settings", "Saved connections": "Saved connections", } as const; diff --git a/src/modules/features/SettingDialogue/PaneHatch.ts b/src/modules/features/SettingDialogue/PaneHatch.ts index 628ae9f6..45c51286 100644 --- a/src/modules/features/SettingDialogue/PaneHatch.ts +++ b/src/modules/features/SettingDialogue/PaneHatch.ts @@ -832,7 +832,7 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, ) .addButton((button) => button - .setButtonText($msg("Scan all files")) + .setButtonText($msg("Begin inspection")) .setDisabled(false) .setCta() .onClick(async () => { diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts index e8caf9e2..86af117a 100644 --- a/test/e2e-obsidian/scripts/dialog-mounts.ts +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -797,7 +797,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { state: "visible", timeout: uiTimeoutMs, }); - await liveSyncSettings.getByRole("button", { name: "Scan all files", exact: true }).waitFor({ + await liveSyncSettings.getByRole("button", { name: "Begin inspection", exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs, }); @@ -918,7 +918,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise { await page .locator(".sls-setting:visible") .last() - .getByRole("button", { name: "Scan all files", exact: true }) + .getByRole("button", { name: "Begin inspection", exact: true }) .click({ timeout: uiTimeoutMs, }); diff --git a/test/e2e-obsidian/scripts/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts index c6e851ca..8dbab659 100644 --- a/test/e2e-obsidian/scripts/revision-repair.ts +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -373,7 +373,7 @@ async function main(): Promise { exact: true, }), }); - await verifySetting.getByRole("button", { name: "Scan all files", exact: true }).click({ + await verifySetting.getByRole("button", { name: "Begin inspection", exact: true }).click({ timeout: uiTimeoutMs, }); const card = repairCard(settings); diff --git a/updates.md b/updates.md index 2ef4928e..57ab3654 100644 --- a/updates.md +++ b/updates.md @@ -14,14 +14,17 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved +- The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the scope already stated by the setting. - **Inspect conflicts and file/database differences** now compares the current Vault file with every live database revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. Each revision's wrench menu can compare readable text, reflect that exact revision to the Vault, record an exact byte match, store the Vault content as a child of that branch, or discard only that branch. ### Fixed +- Start-up file scans now omit legacy LiveSync log files and flag files before comparing Vault and local-database state. Existing ignored database records remain untouched and no longer produce misleading restore or deletion warnings. - A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. ### Testing +- Added Commonlib collection regressions for built-in ignored files, and updated the Real Obsidian inspection and revision-repair scenarios for the clearer action label. - Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions. ## 1.0.0-beta.4 From 18e4d16c6fb0a22cbc88ac6a84ac0e3c55f154c8 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 16:15:46 +0000 Subject: [PATCH 161/170] Use the stable Commonlib package --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index f30c0c01..e6efdabf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.14", + "@vrtmrz/livesync-commonlib": "0.1.0", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", @@ -4764,9 +4764,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.0-rc.14", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.14.tgz", - "integrity": "sha512-5aEy0x/aGNJjNdQYpQQ3VK386qlXZNInxq8pcsjf+NTV/X8jBQ+Q+omiRjYqn6BpS30xVWWeAZ/vdTeaiLsDVw==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0.tgz", + "integrity": "sha512-rdzEubzLStioanE67pps2XSGZ2UGyMIyeEoKsdPztQLLW8wM05PWApOPbJujIydHcDr2hFanbDFe89Lk7Mn4XQ==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index 5de42ca4..a231db66 100644 --- a/package.json +++ b/package.json @@ -165,7 +165,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.14", + "@vrtmrz/livesync-commonlib": "0.1.0", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", From 4f01e7f687d4059690cdc93f5aecfd6c8e5dcbed Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 16:16:26 +0000 Subject: [PATCH 162/170] Keep startup scan on a configured Vault --- test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/scripts/startup-scan.ts | 34 ++++++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index ea57edab..e8a749b8 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -151,7 +151,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- `test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. -`test:e2e:obsidian:startup-scan` configures a temporary CouchDB database, stops Obsidian, writes a note directly into the vault, restarts Obsidian, and verifies from CouchDB that the boot-time scan picked up the offline file. +`test:e2e:obsidian:startup-scan` starts from a CouchDB fixture using current settings with its device-local compatibility marker already acknowledged, stops Obsidian, writes a note directly into the Vault, restarts the same isolated Vault and profile without rewriting its plug-in data, and verifies from CouchDB that the start-up scan picked up the offline file. Onboarding remains covered by `onboarding-invitation`; this scenario owns the ordinary configured restart and start-up scan. `test:e2e:obsidian:setup-uri-workflow` runs the repository's public Commonlib-backed CouchDB provisioning and Setup URI tools against the local CouchDB fixture. It configures a new, empty Vault in the first real Obsidian session through the visible onboarding wizard and uses Rebuild. After that device is working, it generates a new Setup URI through the registered command; the second real Obsidian Vault uses that URI for Fetch instead of reusing the initial Setup URI produced by the provisioning tool. The workflow verifies ordinary notes from the first device to the second and back again, independently enables Hidden File Sync on each device, and verifies a snippet. The retained Setup URI screenshots show only encrypted URIs and visually masked Setup URI passphrases; plaintext credentials are not captured. Files prefixed with `guide-` capture the relevant dialogue, settings panel, or workspace leaf without transient Notices. Public documentation copies selected images only after visual inspection; the E2E run does not overwrite repository documentation assets. diff --git a/test/e2e-obsidian/scripts/startup-scan.ts b/test/e2e-obsidian/scripts/startup-scan.ts index 9aaa4de0..432145a3 100644 --- a/test/e2e-obsidian/scripts/startup-scan.ts +++ b/test/e2e-obsidian/scripts/startup-scan.ts @@ -1,3 +1,13 @@ +/** + * Proves that a configured LiveSync Vault scans files created while Obsidian + * was stopped. The first launch receives a CouchDB profile using current + * settings and its acknowledged device-local compatibility marker before the + * plug-in loads. + * + * The second launch reuses the same Vault, profile, local database, and + * settings without rewriting plug-in data, so the assertion covers an + * ordinary configured restart rather than the separate onboarding flow. + */ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { @@ -11,7 +21,8 @@ import { import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, - configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -47,6 +58,12 @@ async function main(): Promise { const couchDb = await loadCouchDbConfig(); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "startup-scan"); + const couchDbSettings = { + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }; const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; @@ -63,15 +80,11 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData(couchDbSettings), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); - const configured = await configureCouchDb(cli.binary, session.cliEnv, { - uri: couchDb.uri, - username: couchDb.username, - password: couchDb.password, - dbName, - }); - assertEqual(configured.isConfigured, true, "Self-hosted LiveSync was not configured."); + const initialReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + assertEqual(initialReadiness.configured, true, "Self-hosted LiveSync did not start configured."); await prepareRemote(cli.binary, session.cliEnv); await session.app.stop(); session = undefined; @@ -84,7 +97,8 @@ async function main(): Promise { vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), }); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + const restartedReadiness = await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + assertEqual(restartedReadiness.configured, true, "Self-hosted LiveSync lost its configuration on restart."); const localEntry = await waitForLocalDatabaseEntry(cli.binary, session.cliEnv, notePath); await pushLocalChanges(cli.binary, session.cliEnv); From 866575add55441f5cd17ecaa7bf9835f589b7a5a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Sun, 26 Jul 2026 23:34:52 +0000 Subject: [PATCH 163/170] Correct staged release and merge gates --- .github/workflows/finalise-release.yml | 14 ++++- devs.md | 29 +++++----- utils/release-pr-body.mjs | 34 ++++++++---- utils/release-process.unit.spec.ts | 73 ++++++++++++++++---------- 4 files changed, 100 insertions(+), 50 deletions(-) diff --git a/.github/workflows/finalise-release.yml b/.github/workflows/finalise-release.yml index 2991d511..620c0915 100644 --- a/.github/workflows/finalise-release.yml +++ b/.github/workflows/finalise-release.yml @@ -62,6 +62,7 @@ jobs: VERSION: ${{ inputs.version }} EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} PRERELEASE: ${{ inputs.prerelease }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | set -euo pipefail ACTUAL_HEAD_SHA="$(git rev-parse HEAD)" @@ -73,6 +74,10 @@ jobs: echo "Version ${VERSION} is a pre-release version, but prerelease was not enabled." >&2 exit 1 fi + if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then + echo "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." >&2 + exit 1 + fi node utils/release-notes.mjs validate "${VERSION}" - name: Ensure and push release tags @@ -120,8 +125,13 @@ jobs: echo "" echo "Dispatched the plug-in release workflow for \`${VERSION}\`. After approval for the release environment, it creates a draft GitHub Release." echo "" - if [[ "${PRERELEASE}" == "true" ]]; then - echo "Publish the draft as a pre-release, keep the release pull request in draft, and merge only after BRAT validation succeeds." + if [[ "${VERSION}" == *-* ]]; then + echo "Publish the draft as a pre-release without replacing the latest stable release." + echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." + elif [[ "${PRERELEASE}" == "true" ]]; then + echo "Publish the draft initially as a pre-release without replacing the latest stable release." + echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request." + echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate." else echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds." fi diff --git a/devs.md b/devs.md index a27bebc1..2951e8e5 100644 --- a/devs.md +++ b/devs.md @@ -244,7 +244,9 @@ export class ModuleExample extends AbstractObsidianModule { - Use SemVer beta identifiers such as `1.0.0-beta.0` for immutable integration previews. Increment the beta number when a published preview needs a correction. Reserve `1.0.0-rc.0` for the first feature- and contract-frozen release candidate. Historical `-patchedN` releases remain unchanged in the release history. - Publish a pre-release from an immutable reviewed tag, mark its GitHub Release as a pre-release, and do not replace the latest stable release. - A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag. -- Keep the release pull request in draft until the exact published plug-in has passed BRAT validation. If validation fails, prepare the next pre-release version rather than moving the existing tag. +- Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action. +- Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation. +- If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version. ## Release Notes @@ -264,13 +266,15 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`. - Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and dispatches the plug-in release workflow. A CLI tag starts its own container workflow. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere. - The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. The CLI Docker workflow retains its documented branch, tag, and manual triggers. -- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. For a selected CLI publication, confirm the image tags appropriate to a stable or pre-release version. -- Publish a stable draft as the latest release, or publish a pre-release draft without replacing the latest stable release. In either case, keep the release PR in draft and leave its base branch unchanged until BRAT validation succeeds. Record that state in the PR. +- For a hyphenated pre-release, run finalisation with `prerelease=true`; CLI publication remains optional. For a stable version awaiting BRAT validation, use `prerelease=true` and `publish_cli=false`. +- Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. When a hyphenated pre-release includes the CLI, confirm that it received only its immutable version and SHA-qualified image tags. +- Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request. - Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release. -- After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. This keeps the tagged release commit in that branch's history. +- After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action. +- After a stable version passes, remove its GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. Only then mark the stable release pull request ready and merge it into the selected base branch with a merge commit. - If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show :updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history. - Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action. -- For a pre-release, set `prerelease=true` in `Finalise Release Tags`. A hyphenated version is rejected unless that input is enabled. +- A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`. ### Release Cheat Sheet @@ -290,15 +294,16 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - `version`: the same target version. - `release_branch`: leave blank unless the release branch used a custom name. - `expected_head_sha`: the full head commit SHA reviewed in the release PR. - - `prerelease`: enable for a version such as `1.0.0-rc.0`. - - `publish_cli`: disable when the reviewed release is plug-in-only. + - `prerelease`: enable for a version such as `1.0.0-rc.0`, and also when staging a stable version for BRAT. + - `publish_cli`: optional for a hyphenated pre-release, but disable it when staging a stable version. 5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release. -6. If CLI publication was selected, confirm that the CLI tag event published the expected image tags. -7. Publish the draft as a stable release or pre-release as selected, but keep the release PR in draft and leave its base branch unchanged. -8. Update the PR state message to describe the published release and state that merging remains on hold until BRAT validation is complete. +6. If a hyphenated pre-release includes the CLI, confirm that the CLI tag event published only immutable version and SHA-qualified image tags. +7. Publish the draft as a GitHub pre-release without replacing the latest stable release, but keep the release PR in draft and leave its base branch unchanged. +8. Update the PR state message to describe the published pre-release and state that merging remains on hold. 9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario. -10. After BRAT validation succeeds, mark the release PR ready and merge it into the selected base branch with a merge commit. -11. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries. +10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action. +11. After a stable version passes, remove its pre-release designation, make the exact release the latest stable release, publish the stable CLI tags through a separate maintainer gate if selected, then mark the release PR ready and merge it into the selected base branch. +12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries. ## Contribution Guidelines diff --git a/utils/release-pr-body.mjs b/utils/release-pr-body.mjs index 71a929d3..f48f159f 100644 --- a/utils/release-pr-body.mjs +++ b/utils/release-pr-body.mjs @@ -20,9 +20,10 @@ function inlineCode(value) { /** * Render the reader-facing checklist for a draft release pull request. * - * The version decides whether publication is stable or pre-release. The base - * branch is included explicitly because integration previews can target a - * reviewed integration branch rather than `main`. + * The version decides whether the release commit is an immutable SemVer + * pre-release or a stable version staged through a GitHub pre-release. The + * base branch is included explicitly because integration previews can target + * a reviewed integration branch rather than `main`. * * @param {string} version * @param {string} baseBranch @@ -39,13 +40,28 @@ export function renderReleasePrBody(version, baseBranch) { const isPrerelease = selectedVersion.includes("-"); const purpose = isPrerelease ? `an immutable pre-release for BRAT validation without replacing the latest stable release` - : `the next stable release`; + : `a stable version which will first be staged as a GitHub pre-release for BRAT validation`; const finaliseInstruction = isPrerelease ? "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=true`" - : "Run the finalise release workflow with this PR's fixed head SHA and `prerelease=false`"; + : "Run the finalise release workflow with this PR's fixed head SHA, `prerelease=true`, and `publish_cli=false`"; const publicationInstruction = isPrerelease ? "Publish the GitHub Release as a pre-release without replacing the latest stable release, while keeping this pull request in draft" - : "Publish the GitHub Release as the latest stable release while keeping this pull request in draft"; + : "Publish the GitHub Release initially as a pre-release without replacing the latest stable release, while keeping this pull request in draft"; + const assetInstruction = isPrerelease + ? "Confirm the draft GitHub Release assets and the published CLI image, if selected" + : "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes"; + const holdInstruction = isPrerelease + ? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.` + : `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation and has been promoted to the latest stable release.`; + const completionInstructions = isPrerelease + ? [ + "- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action", + ] + : [ + "- [ ] Remove the pre-release designation and make this exact release the latest stable release", + "- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate", + `- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, + ]; return [ `This release pull request prepares Self-hosted LiveSync ${versionCode} from ${baseBranchCode} as ${purpose}.`, @@ -53,7 +69,7 @@ export function renderReleasePrBody(version, baseBranch) { "> [!IMPORTANT]", "> **Merge intentionally on hold**", ">", - `> Publishing the GitHub Release does not unblock this pull request. Keep this pull request in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation.`, + `> ${holdInstruction}`, "", "## Release checklist", "", @@ -62,10 +78,10 @@ export function renderReleasePrBody(version, baseBranch) { "- [ ] Confirm `manifest.json`, `versions.json`, workspace package versions, and the locked Commonlib package version", "- [ ] Confirm CI has passed", `- [ ] ${finaliseInstruction}`, - "- [ ] Confirm the draft GitHub Release assets and the published CLI image, if selected", + `- [ ] ${assetInstruction}`, `- [ ] ${publicationInstruction}`, "- [ ] Validate the exact published release with BRAT", - `- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, + ...completionInstructions, "", ].join("\n"); } diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index abdb7a05..62338eb0 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -4,10 +4,10 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { renderReleasePrBody } from "./release-pr-body.mjs"; import { ensureTags } from "./release-tags.mjs"; const releaseNotesScript = fileURLToPath(new URL("./release-notes.mjs", import.meta.url)); -const releasePrBodyScript = fileURLToPath(new URL("./release-pr-body.mjs", import.meta.url)); const versionBumpScript = process.env.VERSION_BUMP_SCRIPT || fileURLToPath(new URL("../version-bump.mjs", import.meta.url)); const workspaceUpdateScript = fileURLToPath(new URL("../update-workspaces.mjs", import.meta.url)); @@ -160,13 +160,12 @@ describe("release notes", () => { describe("release workflow", () => { it("uses the locked Commonlib package instead of generated fallback declarations", () => { const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); - const body = runNode(releasePrBodyScript, ["1.0.0-beta.0", "integration"], makeTemporaryDirectory()); + const body = renderReleasePrBody("1.0.0-beta.0", "integration"); expect(workflow).not.toContain("npm run build:lib:types"); expect(workflow).not.toMatch(/git add[^\n]*_types/); expect(workflow).toMatch(/git add[^\n]*package-lock\.json/); - expect(body.status, body.stderr).toBe(0); - expect(body.stdout).toContain("locked Commonlib package version"); + expect(body).toContain("locked Commonlib package version"); }); it("reruns the version lifecycle when the integration branch already selects the release version", () => { @@ -183,36 +182,56 @@ describe("release workflow", () => { expect(workflow).not.toContain("latest stable release"); }); - it("keeps the release PR in draft until BRAT validation", () => { - const prerelease = runNode( - releasePrBodyScript, - ["1.0.0-beta.0", "common-library-package-boundary"], - makeTemporaryDirectory() - ); + it("keeps an immutable pre-release out of its base branch after BRAT validation", () => { + const prerelease = renderReleasePrBody("1.0.0-rc.0", "common-library-package-boundary"); - expect(prerelease.status, prerelease.stderr).toBe(0); - expect(prerelease.stdout).toContain("Merge intentionally on hold"); - expect(prerelease.stdout).toContain("Self-hosted LiveSync `1.0.0-beta.0`"); - expect(prerelease.stdout).toContain("leave `common-library-package-boundary` unchanged"); - expect(prerelease.stdout).toContain("prerelease=true"); - expect(prerelease.stdout).toContain( + expect(prerelease).toContain("Merge intentionally on hold"); + expect(prerelease).toContain("Self-hosted LiveSync `1.0.0-rc.0`"); + expect(prerelease).toContain("leave `common-library-package-boundary` unchanged"); + expect(prerelease).toContain("prerelease=true"); + expect(prerelease).toContain( "Publish the GitHub Release as a pre-release without replacing the latest stable release" ); - expect(prerelease.stdout).toContain("Validate the exact published release with BRAT"); - expect(prerelease.stdout).toContain( - "Mark this pull request ready and merge it into `common-library-package-boundary` with a merge commit" - ); + expect(prerelease).toContain("Validate the exact published release with BRAT"); + expect(prerelease).toContain("Keep this pre-release pull request unmerged"); + expect(prerelease).toContain("close it only through a separate maintainer action"); + expect(prerelease).not.toContain("Mark this pull request ready and merge it"); }); - it("keeps stable release instructions distinct from pre-release instructions", () => { - const stable = runNode(releasePrBodyScript, ["1.0.0", "main"], makeTemporaryDirectory()); + it("publishes a stable version initially as a GitHub pre-release for BRAT validation", () => { + const stable = renderReleasePrBody("1.0.0", "main"); - expect(stable.status, stable.stderr).toBe(0); - expect(stable.stdout).toContain("prerelease=false"); - expect(stable.stdout).toContain( - "Publish the GitHub Release as the latest stable release while keeping this pull request in draft" + expect(stable).toContain("prerelease=true"); + expect(stable).toContain("publish_cli=false"); + expect(stable).toContain( + "Publish the GitHub Release initially as a pre-release without replacing the latest stable release" + ); + expect(stable).toContain( + "Remove the pre-release designation and make this exact release the latest stable release" + ); + expect(stable).toContain( + "Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate" + ); + expect(stable).toContain("Mark this pull request ready and merge it into `main` with a merge commit"); + expect(stable).not.toContain("prerelease=false"); + }); + + it("summarises immutable pre-releases separately from stable versions awaiting promotion", () => { + const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); + + expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then'); + expect(workflow).toContain( + "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." + ); + expect(workflow).toContain( + "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request." + ); + expect(workflow).toContain( + 'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then' + ); + expect(workflow).toContain( + "A stable version staged as a pre-release must use publish_cli=false so that the CLI latest and major-minor image tags do not advance before BRAT validation." ); - expect(stable.stdout).not.toContain("as a pre-release without replacing"); }); it("dispatches the plug-in workflow and lets the CLI tag trigger its own workflow", () => { From e35583a7520a4de32f87a285281b0a85e3084c74 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 27 Jul 2026 00:52:15 +0000 Subject: [PATCH 164/170] Reconcile beta.5 metadata for release candidate --- updates.md | 18 ++++++++++++++++-- versions.json | 3 ++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/updates.md b/updates.md index 57ab3654..5384a33f 100644 --- a/updates.md +++ b/updates.md @@ -15,16 +15,30 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ### Improved - The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the scope already stated by the setting. -- **Inspect conflicts and file/database differences** now compares the current Vault file with every live database revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. Each revision's wrench menu can compare readable text, reflect that exact revision to the Vault, record an exact byte match, store the Vault content as a child of that branch, or discard only that branch. ### Fixed - Start-up file scans now omit legacy LiveSync log files and flag files before comparing Vault and local-database state. Existing ignored database records remain untouched and no longer produce misleading restore or deletion warnings. -- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. ### Testing - Added Commonlib collection regressions for built-in ignored files, and updated the Real Obsidian inspection and revision-repair scenarios for the clearer action label. + +## 1.0.0-beta.5 + +26th July, 2026 + +### Improved + +- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. +- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation. + +### Fixed + +- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. + +### Testing + - Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions. ## 1.0.0-beta.4 diff --git a/versions.json b/versions.json index 5f4652c9..f97f3468 100644 --- a/versions.json +++ b/versions.json @@ -10,5 +10,6 @@ "1.0.0-beta.1": "1.7.2", "1.0.0-beta.2": "1.7.2", "1.0.0-beta.3": "1.7.2", - "1.0.0-beta.4": "1.7.2" + "1.0.0-beta.4": "1.7.2", + "1.0.0-beta.5": "1.7.2" } From 8c6824b7f610f8fa77107d111cf28b128bdb3ebc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:36:33 +0000 Subject: [PATCH 165/170] Releasing 1.0.0-rc.0 --- manifest.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- src/apps/cli/package.json | 2 +- src/apps/webapp/package.json | 2 +- src/apps/webpeer/package.json | 2 +- updates.md | 4 ++++ versions.json | 3 ++- 8 files changed, 16 insertions(+), 11 deletions(-) diff --git a/manifest.json b/manifest.json index 327fbad3..39908b15 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-livesync", "name": "Self-hosted LiveSync", - "version": "1.0.0-beta.0", + "version": "1.0.0-rc.0", "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.", "author": "vorotamoroz", diff --git a/package-lock.json b/package-lock.json index e6efdabf..aadd050b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-livesync", - "version": "1.0.0-beta.0", + "version": "1.0.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-livesync", - "version": "1.0.0-beta.0", + "version": "1.0.0-rc.0", "license": "MIT", "workspaces": [ "src/apps/cli", @@ -15913,7 +15913,7 @@ }, "src/apps/cli": { "name": "self-hosted-livesync-cli", - "version": "1.0.0-beta.0-cli", + "version": "1.0.0-rc.0-cli", "dependencies": { "chokidar": "^4.0.0", "minimatch": "^10.2.5", @@ -15938,7 +15938,7 @@ }, "src/apps/webapp": { "name": "livesync-webapp", - "version": "1.0.0-beta.0-webapp", + "version": "1.0.0-rc.0-webapp", "dependencies": { "octagonal-wheels": "^0.1.51" }, @@ -15950,7 +15950,7 @@ } }, "src/apps/webpeer": { - "version": "1.0.0-beta.0-webpeer", + "version": "1.0.0-rc.0-webpeer", "dependencies": { "octagonal-wheels": "^0.1.51" }, diff --git a/package.json b/package.json index a231db66..9e9da536 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-livesync", - "version": "1.0.0-beta.0", + "version": "1.0.0-rc.0", "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", "type": "module", diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 51e94d64..3be2ca45 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "self-hosted-livesync-cli", "private": true, - "version": "1.0.0-beta.0-cli", + "version": "1.0.0-rc.0-cli", "main": "dist/index.cjs", "type": "module", "scripts": { diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json index 3f64c34a..eac857df 100644 --- a/src/apps/webapp/package.json +++ b/src/apps/webapp/package.json @@ -1,7 +1,7 @@ { "name": "livesync-webapp", "private": true, - "version": "1.0.0-beta.0-webapp", + "version": "1.0.0-rc.0-webapp", "type": "module", "description": "Browser-based Self-hosted LiveSync using FileSystem API", "scripts": { diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json index 16f9c47c..f49a942b 100644 --- a/src/apps/webpeer/package.json +++ b/src/apps/webpeer/package.json @@ -1,7 +1,7 @@ { "name": "webpeer", "private": true, - "version": "1.0.0-beta.0-webpeer", + "version": "1.0.0-rc.0-webpeer", "type": "module", "scripts": { "dev": "vite", diff --git a/updates.md b/updates.md index 5384a33f..d779029e 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,10 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +## 1.0.0-rc.0 + +27th July, 2026 + ### Improved - The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the scope already stated by the setting. diff --git a/versions.json b/versions.json index f97f3468..d176d6b5 100644 --- a/versions.json +++ b/versions.json @@ -11,5 +11,6 @@ "1.0.0-beta.2": "1.7.2", "1.0.0-beta.3": "1.7.2", "1.0.0-beta.4": "1.7.2", - "1.0.0-beta.5": "1.7.2" + "1.0.0-beta.5": "1.7.2", + "1.0.0-rc.0": "1.7.2" } From 3ce6ccf7a6f9b1231508c90ce13a23351fa8748a Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 27 Jul 2026 01:57:25 +0000 Subject: [PATCH 166/170] Polish 1.0.0-rc.0 release notes --- updates.md | 60 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/updates.md b/updates.md index d779029e..a4b5de0c 100644 --- a/updates.md +++ b/updates.md @@ -16,17 +16,67 @@ Earlier releases remain available in the 0.25 release history and the legacy rel 27th July, 2026 -### Improved +The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). -- The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the scope already stated by the setting. +### Important -### Fixed +- This is the first 1.0 release candidate. It remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault. +- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. Existing automatic synchronisation choices are preserved and resume only after the decision has been saved. -- Start-up file scans now omit legacy LiveSync log files and flag files before comparing Vault and local-database state. Existing ignored database records remain untouched and no longer produce misleading restore or deletion warnings. +### Changes consolidated from beta.0 through beta.5 + +#### Setup and compatibility + +- An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings** → **Setup**. +- Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins. +- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting. +- Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately. +- Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review. + +#### Conflict handling and recovery + +- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch. +- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review. +- **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict. +- **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain. +- Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch. +- Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention. +- Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message. + +#### P2P and optional synchronisation features + +- P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default. +- P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions. +- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild. +- P2P setup and guidance now distinguish the required signalling relay from optional TURN, describe the replaceable public relay's privacy and availability limits, and reliably close and recreate relay connections across settings changes and database resets. +- Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages. +- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update. + +#### Interface and operations + +- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work. +- Setup and review dialogue text can be selected for copying or translation. Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand. +- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls. +- Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer. +- Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count. + +#### Other fixes and security + +- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. +- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links, and the CLI rejects detected path traversal and symbolic-link components before Vault operations. +- Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository. + +### Changes since beta.5 + +- The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the setting name. +- Start-up and full-inspection scans now omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged. ### Testing -- Added Commonlib collection regressions for built-in ignored files, and updated the Real Obsidian inspection and revision-repair scenarios for the clearer action label. +- Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up. +- Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks. +- An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip. +- The beta series was exercised through BRAT on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations. The exact RC artefact will be validated separately after publication. ## 1.0.0-beta.5 From d5a40a7e3d2bafd1da0c5df28d5eac0cb84f63b7 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 27 Jul 2026 03:37:46 +0000 Subject: [PATCH 167/170] release: prepare 1.0.0-rc.1 --- .github/workflows/finalise-release.yml | 9 ++++++- devs.md | 6 ++--- manifest.json | 2 +- package-lock.json | 10 ++++---- package.json | 2 +- src/apps/cli/Dockerfile | 6 ++--- src/apps/cli/docker-image.unit.spec.ts | 11 +++++++++ src/apps/cli/package.json | 2 +- .../cli/setup-uri-e2e-helper.unit.spec.ts | 11 +++++++++ src/apps/cli/test/test-setup-put-cat-linux.sh | 2 +- src/apps/webapp/package.json | 2 +- src/apps/webpeer/package.json | 2 +- updates.md | 24 +++++++++++++++++++ utils/release-process.unit.spec.ts | 11 ++++++--- versions.json | 3 ++- 15 files changed, 81 insertions(+), 22 deletions(-) create mode 100644 src/apps/cli/docker-image.unit.spec.ts create mode 100644 src/apps/cli/setup-uri-e2e-helper.unit.spec.ts diff --git a/.github/workflows/finalise-release.yml b/.github/workflows/finalise-release.yml index 620c0915..9451fc7e 100644 --- a/.github/workflows/finalise-release.yml +++ b/.github/workflows/finalise-release.yml @@ -101,8 +101,15 @@ jobs: GH_TOKEN: ${{ github.token }} VERSION: ${{ inputs.version }} PRERELEASE: ${{ inputs.prerelease }} + PUBLISH_CLI: ${{ inputs.publish_cli }} run: | set -euo pipefail + if [[ "${PUBLISH_CLI}" == "true" ]]; then + gh workflow run cli-docker.yml \ + --ref "${VERSION}-cli" \ + --field dry_run=false \ + --field force=false + fi gh workflow run release.yml \ --ref "${VERSION}" \ --field tag="${VERSION}" \ @@ -118,7 +125,7 @@ jobs: { echo "Ensured the plug-in tag \`${VERSION}\` points to the reviewed release commit." if [[ "${PUBLISH_CLI}" == "true" ]]; then - echo "The CLI tag \`${VERSION}-cli\` was also created; its tag event starts the container workflow." + echo "The CLI tag \`${VERSION}-cli\` was also created, and finalisation explicitly dispatched the CLI container workflow." else echo "CLI publication was omitted." fi diff --git a/devs.md b/devs.md index 2951e8e5..d80a0b1c 100644 --- a/devs.md +++ b/devs.md @@ -264,8 +264,8 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - Run the `Prepare Release PR` workflow with the target version and selected base branch. It creates the release branch, updates versions, confirms that Commonlib is locked to an immutable package version, moves the `## Unreleased` notes to the target version, commits the release preparation, pushes the branch, and opens a draft release PR. The base branch may already select the target development version; the workflow still runs the version lifecycle so that release-only metadata such as `versions.json` is recorded in the release commit. - Do not tag the release branch when the PR is first created. Polish the release PR first, especially `updates.md`. -- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and dispatches the plug-in release workflow. A CLI tag starts its own container workflow. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere. -- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. The CLI Docker workflow retains its documented branch, tag, and manual triggers. +- Once the release PR head is fixed, run the `Finalise Release Tags` workflow with its full head commit SHA. It validates the release branch, ensures that the plug-in tag points to that commit, optionally creates the corresponding CLI tag, and explicitly dispatches the selected plug-in and CLI release workflows. The finalisation workflow can be retried when existing tags already point to the reviewed commit, but stops if a selected tag points elsewhere. +- The plug-in publishing workflow is intentionally dispatch-only. Pushing a plug-in tag directly does not publish a GitHub Release; use `Finalise Release Tags`, or dispatch `Release Obsidian Plugin` explicitly for recovery or a pre-release. When CLI publication is selected, finalisation dispatches the CLI Docker workflow against the reviewed CLI tag instead of relying on a tag created by `GITHUB_TOKEN` to start another workflow. - For a hyphenated pre-release, run finalisation with `prerelease=true`; CLI publication remains optional. For a stable version awaiting BRAT validation, use `prerelease=true` and `publish_cli=false`. - Approve the `Release Obsidian Plugin` workflow for the `release` environment, then inspect the generated draft GitHub Release. When a hyphenated pre-release includes the CLI, confirm that it received only its immutable version and SHA-qualified image tags. - Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request. @@ -297,7 +297,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - `prerelease`: enable for a version such as `1.0.0-rc.0`, and also when staging a stable version for BRAT. - `publish_cli`: optional for a hyphenated pre-release, but disable it when staging a stable version. 5. Approve the `Release Obsidian Plugin` workflow for the `release` environment, then check the generated draft GitHub Release. -6. If a hyphenated pre-release includes the CLI, confirm that the CLI tag event published only immutable version and SHA-qualified image tags. +6. If a hyphenated pre-release includes the CLI, confirm that the explicitly dispatched CLI workflow published only immutable version and SHA-qualified image tags. 7. Publish the draft as a GitHub pre-release without replacing the latest stable release, but keep the release PR in draft and leave its base branch unchanged. 8. Update the PR state message to describe the published pre-release and state that merging remains on hold. 9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario. diff --git a/manifest.json b/manifest.json index 39908b15..def3b652 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-livesync", "name": "Self-hosted LiveSync", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "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.", "author": "vorotamoroz", diff --git a/package-lock.json b/package-lock.json index aadd050b..bcfc013d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-livesync", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-livesync", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "license": "MIT", "workspaces": [ "src/apps/cli", @@ -15913,7 +15913,7 @@ }, "src/apps/cli": { "name": "self-hosted-livesync-cli", - "version": "1.0.0-rc.0-cli", + "version": "1.0.0-rc.1-cli", "dependencies": { "chokidar": "^4.0.0", "minimatch": "^10.2.5", @@ -15938,7 +15938,7 @@ }, "src/apps/webapp": { "name": "livesync-webapp", - "version": "1.0.0-rc.0-webapp", + "version": "1.0.0-rc.1-webapp", "dependencies": { "octagonal-wheels": "^0.1.51" }, @@ -15950,7 +15950,7 @@ } }, "src/apps/webpeer": { - "version": "1.0.0-rc.0-webpeer", + "version": "1.0.0-rc.1-webpeer", "dependencies": { "octagonal-wheels": "^0.1.51" }, diff --git a/package.json b/package.json index 9e9da536..d391fd91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-livesync", - "version": "1.0.0-rc.0", + "version": "1.0.0-rc.1", "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", "type": "module", diff --git a/src/apps/cli/Dockerfile b/src/apps/cli/Dockerfile index bf6b9bf7..152710ae 100644 --- a/src/apps/cli/Dockerfile +++ b/src/apps/cli/Dockerfile @@ -101,9 +101,9 @@ COPY --from=runtime-deps /deps/node_modules ./node_modules # Copy the built CLI bundle from builder stage COPY --from=builder /build/src/apps/cli/dist ./dist -# Install entrypoint wrapper -COPY src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli -RUN chmod +x /usr/local/bin/livesync-cli +# Install the entrypoint wrapper with a deterministic mode, regardless of +# source checkout permissions. +COPY --chmod=755 src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli # Mount your vault / local database directory here VOLUME ["/data"] diff --git a/src/apps/cli/docker-image.unit.spec.ts b/src/apps/cli/docker-image.unit.spec.ts new file mode 100644 index 00000000..8834ebd6 --- /dev/null +++ b/src/apps/cli/docker-image.unit.spec.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dockerfile = readFileSync(new URL("./Dockerfile", import.meta.url), "utf8"); + +describe("CLI Docker image", () => { + it("sets a deterministic readable and executable entrypoint mode", () => { + expect(dockerfile).toContain("COPY --chmod=755 src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli"); + expect(dockerfile).not.toContain("RUN chmod +x /usr/local/bin/livesync-cli"); + }); +}); diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 3be2ca45..481ae59a 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "self-hosted-livesync-cli", "private": true, - "version": "1.0.0-rc.0-cli", + "version": "1.0.0-rc.1-cli", "main": "dist/index.cjs", "type": "module", "scripts": { diff --git a/src/apps/cli/setup-uri-e2e-helper.unit.spec.ts b/src/apps/cli/setup-uri-e2e-helper.unit.spec.ts new file mode 100644 index 00000000..fc7d38b4 --- /dev/null +++ b/src/apps/cli/setup-uri-e2e-helper.unit.spec.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const setupPutCatHelper = readFileSync(new URL("./test/test-setup-put-cat-linux.sh", import.meta.url), "utf8"); + +describe("CLI setup URI E2E helper", () => { + it("evaluates Commonlib package imports as ESM", () => { + expect(setupPutCatHelper).toContain("node --input-type=module -e"); + expect(setupPutCatHelper).not.toContain("npx tsx -e"); + }); +}); diff --git a/src/apps/cli/test/test-setup-put-cat-linux.sh b/src/apps/cli/test/test-setup-put-cat-linux.sh index 12c781a4..773833d9 100644 --- a/src/apps/cli/test/test-setup-put-cat-linux.sh +++ b/src/apps/cli/test/test-setup-put-cat-linux.sh @@ -27,7 +27,7 @@ cli_test_init_settings_file "$SETTINGS_FILE" echo "[INFO] creating setup URI from settings" SETUP_URI="$( - SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" npx tsx -e ' + SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" node --input-type=module -e ' import { fs } from "@vrtmrz/livesync-commonlib/node"; import { encodeSettingsToSetupURI } from "@vrtmrz/livesync-commonlib/compat/API/processSetting"; (async () => { diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json index eac857df..bd5cbcb1 100644 --- a/src/apps/webapp/package.json +++ b/src/apps/webapp/package.json @@ -1,7 +1,7 @@ { "name": "livesync-webapp", "private": true, - "version": "1.0.0-rc.0-webapp", + "version": "1.0.0-rc.1-webapp", "type": "module", "description": "Browser-based Self-hosted LiveSync using FileSystem API", "scripts": { diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json index f49a942b..28a08597 100644 --- a/src/apps/webpeer/package.json +++ b/src/apps/webpeer/package.json @@ -1,7 +1,7 @@ { "name": "webpeer", "private": true, - "version": "1.0.0-rc.0-webpeer", + "version": "1.0.0-rc.1-webpeer", "type": "module", "scripts": { "dev": "vite", diff --git a/updates.md b/updates.md index a4b5de0c..0a7c5f95 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,30 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +## 1.0.0-rc.1 + +27th July, 2026 + +The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). + +### Important + +- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published. +- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication. + +### CLI and release validation + +- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation. +- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout. +- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow. +- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs. + +### Testing + +- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency. +- The same scenario completed through the rebuilt non-root Docker image. +- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation. + ## 1.0.0-rc.0 27th July, 2026 diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index 62338eb0..c9595e1b 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -234,16 +234,21 @@ describe("release workflow", () => { ); }); - it("dispatches the plug-in workflow and lets the CLI tag trigger its own workflow", () => { + it("dispatches the selected plug-in and CLI workflows explicitly", () => { const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); expect(workflow).toContain("actions: write"); expect(workflow).toContain('node utils/release-tags.mjs ensure "${VERSION}" "${EXPECTED_HEAD_SHA}"'); expect(workflow).toContain('git push --atomic origin "refs/tags/${VERSION}" "refs/tags/${VERSION}-cli"'); expect(workflow).not.toContain("Tag already exists"); + expect(workflow).toContain("PUBLISH_CLI: ${{ inputs.publish_cli }}"); + expect(workflow).toContain('if [[ "${PUBLISH_CLI}" == "true" ]]; then'); + expect(workflow).toContain("gh workflow run cli-docker.yml"); + expect(workflow).toContain('--ref "${VERSION}-cli"'); + expect(workflow).toContain("--field dry_run=false"); + expect(workflow).toContain("--field force=false"); expect(workflow).toContain("gh workflow run release.yml"); - expect(workflow).not.toContain("gh workflow run cli-docker.yml"); - expect(workflow).toContain("its tag event starts the container workflow"); + expect(workflow).toContain("explicitly dispatched the CLI container workflow"); }); it("publishes only by explicit dispatch and validates the selected release", () => { diff --git a/versions.json b/versions.json index d176d6b5..ac318380 100644 --- a/versions.json +++ b/versions.json @@ -12,5 +12,6 @@ "1.0.0-beta.3": "1.7.2", "1.0.0-beta.4": "1.7.2", "1.0.0-beta.5": "1.7.2", - "1.0.0-rc.0": "1.7.2" + "1.0.0-rc.0": "1.7.2", + "1.0.0-rc.1": "1.7.2" } From c3f2163204d6c3bac48e68a03d5c23ccc7db1130 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 27 Jul 2026 08:12:33 +0000 Subject: [PATCH 168/170] Release 1.0.0 --- docs/releases/1.0-previews.md | 161 ++++++++++++++++++++++ manifest.json | 2 +- package-lock.json | 10 +- package.json | 2 +- src/apps/cli/package.json | 2 +- src/apps/webapp/package.json | 2 +- src/apps/webpeer/package.json | 2 +- updates.md | 245 +++++++++------------------------- updates_old.md | 1 + versions.json | 5 +- 10 files changed, 234 insertions(+), 198 deletions(-) create mode 100644 docs/releases/1.0-previews.md diff --git a/docs/releases/1.0-previews.md b/docs/releases/1.0-previews.md new file mode 100644 index 00000000..d4940dc2 --- /dev/null +++ b/docs/releases/1.0-previews.md @@ -0,0 +1,161 @@ +# 1.0 preview release history + +This document records the opt-in beta and release-candidate builds published before 1.0.0. Most users upgrading from 0.25.83 only need the consolidated [1.0.0 release notes](../../updates.md). + +The prepared `1.0.0-rc.0` tag was not published as a plug-in release and is therefore omitted. + +## 1.0.0-rc.1 + +27th July, 2026 + +The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). + +### Important + +- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published. +- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication. + +### CLI and release validation + +- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation. +- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout. +- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow. +- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs. + +### Testing + +- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency. +- The same scenario completed through the rebuilt non-root Docker image. +- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation. + +## 1.0.0-beta.5 + +26th July, 2026 + +### Improved + +- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. +- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation. + +### Fixed + +- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. + +### Testing + +- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions. + +## 1.0.0-beta.4 + +25th July, 2026 + +### Improved + +- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. +- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. +- Text in setup and review dialogues can now be selected for copying or translation. +- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. + +### Fixed + +- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. +- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message. + +### Testing + +- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts. +- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation. +- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip. + +## 1.0.0-beta.3 + +24th July, 2026 + +### Improved + +- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. +- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. +- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. +- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. +- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. + +### Fixed + +- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. +- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. + +### Testing + +- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. + +## 1.0.0-beta.2 + +23rd July, 2026 + +### Improved + +- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved. + +### Fixed + +- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred. + +### Testing + +- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages. + +## 1.0.0-beta.1 + +22nd July, 2026 + +### Important + +- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault. + +### Fixed + +- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers. +- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner. + +### Testing + +- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active. + +## 1.0.0-beta.0 + +22nd July, 2026 + +### Important + +- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation. +- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully. + +### Improved + +- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically. +- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins. +- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow. +- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification. +- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive. +- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room. +- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555. +- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer. +- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions. + +### Fixed + +- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. +- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update. +- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected. + +### Security + +- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations. + +### Miscellaneous + +- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository. + +### Testing + +- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures. diff --git a/manifest.json b/manifest.json index def3b652..cf0ec3d1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-livesync", "name": "Self-hosted LiveSync", - "version": "1.0.0-rc.1", + "version": "1.0.0", "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.", "author": "vorotamoroz", diff --git a/package-lock.json b/package-lock.json index bcfc013d..98b450ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-livesync", - "version": "1.0.0-rc.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-livesync", - "version": "1.0.0-rc.1", + "version": "1.0.0", "license": "MIT", "workspaces": [ "src/apps/cli", @@ -15913,7 +15913,7 @@ }, "src/apps/cli": { "name": "self-hosted-livesync-cli", - "version": "1.0.0-rc.1-cli", + "version": "1.0.0-cli", "dependencies": { "chokidar": "^4.0.0", "minimatch": "^10.2.5", @@ -15938,7 +15938,7 @@ }, "src/apps/webapp": { "name": "livesync-webapp", - "version": "1.0.0-rc.1-webapp", + "version": "1.0.0-webapp", "dependencies": { "octagonal-wheels": "^0.1.51" }, @@ -15950,7 +15950,7 @@ } }, "src/apps/webpeer": { - "version": "1.0.0-rc.1-webpeer", + "version": "1.0.0-webpeer", "dependencies": { "octagonal-wheels": "^0.1.51" }, diff --git a/package.json b/package.json index d391fd91..d2ab993c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-livesync", - "version": "1.0.0-rc.1", + "version": "1.0.0", "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", "type": "module", diff --git a/src/apps/cli/package.json b/src/apps/cli/package.json index 481ae59a..44e66a6b 100644 --- a/src/apps/cli/package.json +++ b/src/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "self-hosted-livesync-cli", "private": true, - "version": "1.0.0-rc.1-cli", + "version": "1.0.0-cli", "main": "dist/index.cjs", "type": "module", "scripts": { diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json index bd5cbcb1..dd98602f 100644 --- a/src/apps/webapp/package.json +++ b/src/apps/webapp/package.json @@ -1,7 +1,7 @@ { "name": "livesync-webapp", "private": true, - "version": "1.0.0-rc.1-webapp", + "version": "1.0.0-webapp", "type": "module", "description": "Browser-based Self-hosted LiveSync using FileSystem API", "scripts": { diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json index 28a08597..7474c1cd 100644 --- a/src/apps/webpeer/package.json +++ b/src/apps/webpeer/package.json @@ -1,7 +1,7 @@ { "name": "webpeer", "private": true, - "version": "1.0.0-rc.1-webpeer", + "version": "1.0.0-webpeer", "type": "module", "scripts": { "dev": "vite", diff --git a/updates.md b/updates.md index 0a7c5f95..078e03fc 100644 --- a/updates.md +++ b/updates.md @@ -12,224 +12,99 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased -## 1.0.0-rc.1 +## 1.0.0 27th July, 2026 The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). -### Important +### Setup and compatibility -- This candidate retains the plug-in behaviour prepared for rc.0. The version was advanced because release tags are immutable; rc.0 was stopped during CLI validation before a plug-in release was published. -- This remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. The exact rc.1 plug-in and CLI artefacts will be validated separately after publication. - -### CLI and release validation - -- CLI release validation now generates Setup URIs through the supported ESM package interface, allowing the Docker test to reach the CLI container instead of stopping during test preparation. -- The CLI Docker image now assigns its entrypoint permissions explicitly, so non-root execution does not depend on permissions inherited from the source checkout. -- Release finalisation now explicitly dispatches the CLI container workflow when CLI publication is selected, rather than relying on a workflow-created tag to start another workflow. -- Focused regression tests guard the ESM execution mode and deterministic container entrypoint permissions, while the existing release-workflow tests now require explicit CLI dispatch with non-dry-run, immutable-tag inputs. - -### Testing - -- The native CLI setup, put, cat, list, information, deletion, conflict-resolution, and revision-retrieval scenario completed with the packaged Commonlib dependency. -- The same scenario completed through the rebuilt non-root Docker image. -- The focused CLI and release-workflow unit tests passed after first demonstrating all three regressions against the unmodified implementation. - -## 1.0.0-rc.0 - -27th July, 2026 - -The work towards 1.0 has become so substantial that I have written [an article about it](https://fancy-syncing.vrtmrz.net/blog/0036-livesync-1_0_0-en.html) (linked again here). - -### Important - -- This is the first 1.0 release candidate. It remains an opt-in pre-release for BRAT validation and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault. -- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. Existing automatic synchronisation choices are preserved and resume only after the decision has been saved. - -### Changes consolidated from beta.0 through beta.5 - -#### Setup and compatibility +#### Improved - An unconfigured Vault now waits for the user to start setup. Onboarding is offered through a persistent Notice and remains available from **Self-hosted LiveSync settings** → **Setup**. - Setup now creates named CouchDB, Object Storage, and P2P connections. Setup URIs preserve their connection names and selections, and reserve Fetch or Rebuild before the ordinary start-up scan begins. -- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting. - Manual CouchDB setup distinguishes creating the first database from connecting another device. Onboarding requires a successful connection, while Settings can explicitly save an unverified connection and offers each server-setting correction separately. - Compatible differences limited to the chunk hash algorithm, chunk size, or splitter version are aligned automatically by default. Existing chunks remain readable, an explicit opt-out remains available, and differences involving incompatible settings still require review. -#### Conflict handling and recovery +#### Fixed + +- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting. + +#### Security + +- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness. +- Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links. + +### Conflict handling and recovery + +#### Improved -- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch. -- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review. - **Not now** postpones repeated automatic merge dialogues while retaining the unresolved-conflict warning. Three or more live revisions are reviewed one reproducible pair at a time, completed pairs remain resolved across restart, and explicit commands can reopen a postponed conflict. - **Inspect conflicts and file/database differences** compares the Vault with the database winner and every live conflict revision. Compact indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflicts remain. - Each reported file and live revision has a compact wrench menu for comparison, applying an exact readable revision, recording an exact byte match, storing the Vault content as a child of a selected branch, retrying missing chunks without changing the tree, or explicitly discarding one selected live branch. + +#### Fixed + +- Automatic text and structured-data merge now uses the nearest revision actually shared by both branches. A resolution received from another device no longer recreates the same conflict merely because the Vault still contains the exact content of the removed branch. +- Edits, logical deletions, and renames made while a file remains conflicted extend the revision displayed on that device. When the relationship cannot be proved, LiveSync preserves the branches for review. - Unreadable live revisions are preserved during automatic handling. An absent Vault file and a winning logical deletion are treated as agreement unless another live branch still requires attention. - Garbage Collection V3 is limited to CouchDB and now protects every live conflict branch, required shared ancestry, and shared chunks. It stops when device progress cannot be verified and reports compaction failure without a contradictory success message. -#### P2P and optional synchronisation features +### P2P and optional synchronisation features + +#### Improved - P2P and Hidden File Sync remain supported opt-in features. Customisation Sync remains a supported Advanced workflow, while Data Compression remains available but disabled by default. - P2P controls remain outside the ordinary CouchDB experience until P2P is configured. The current status pane distinguishes announcing changes, following a peer, and persistent per-device actions. -- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild. -- P2P setup and guidance now distinguish the required signalling relay from optional TURN, describe the replaceable public relay's privacy and availability limits, and reliably close and recreate relay connections across settings changes and database resets. +- P2P setup and guidance now distinguish the required signalling relay from optional TURN and describe the replaceable public relay's privacy and availability limits. - Enabling Hidden File Sync opens one progress Notice before saving the setting and reuses it until the initial scan has finished instead of stacking phase, reload, and restart messages. -- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update. -#### Interface and operations +#### Fixed + +- First-device P2P setup can complete its signalling test without another peer online. Fetch on an additional device still requires an available source peer and a completed P2P Rebuild. +- P2P relay connections now close and are recreated reliably after settings changes and database resets. + +### Interface, translation, and operations + +#### Improved - Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands retain their identifiers so that existing hotkeys continue to work. -- Setup and review dialogue text can be selected for copying or translation. Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand. -- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls. +- Setup and review dialogue text can be selected for copying or translation. - Remote-size warnings use persistent clickable Notices. Initial uploads and Rebuild no longer ask to send every chunk in advance; ordinary replication completes the transfer. - Obsolete controls for the plug-in trash setting and fixed chunk revisions were removed. The Change Log remains available but no longer opens automatically or tracks an unread count. - -#### Other fixes and security - -- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. -- Fly.io setup generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates address excessive CPU use from crafted path patterns and `mailto:` links, and the CLI rejects detected path traversal and symbolic-link components before Vault operations. - Self-hosted LiveSync now owns its translation catalogue. Commonlib supplies canonical English to other consumers, while translation contributions can be made in the main Self-hosted LiveSync repository. -### Changes since beta.5 +#### Fixed -- The Hatch action for **Inspect conflicts and file/database differences** is now labelled **Begin inspection** so that its purpose is clear without repeating the setting name. -- Start-up and full-inspection scans now omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged. +- Applying an available interface translation no longer holds start-up behind an unsolicited dialogue; a persistent Notice opens the existing details on demand. +- Action buttons are arranged for narrow mobile screens, long dialogues keep their controls reachable, and persistent Notices no longer cover close controls. -### Testing +### Storage and file selection + +#### Fixed + +- The optional Custom HTTP Handler used by Object Storage sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. +- Broadening selectors, ignore rules, size or modification-time limits, or file-name case handling now rechecks previously received files without requiring another remote update. +- Start-up and full-inspection scans omit built-in legacy LiveSync log files and recovery flag files before comparing Vault and local-database state. Existing ignored database records remain untouched, and user-configured ignore behaviour is unchanged. + +### Command-line tool + +#### Fixed + +- CLI Setup URI validation now uses the supported Commonlib ESM package interface. +- The non-root Docker image no longer depends on permissions inherited from the source checkout. + +#### Security + +- The CLI rejects detected path traversal and symbolic-link components before Vault operations. + +### Validation + +#### Testing - Expanded automated Real Obsidian coverage for upgrades, two-device synchronisation, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, conflict and revision recovery, failure diagnostics, and strict clean-up. - Real CouchDB integration coverage verifies logical deletion, shared and conflict chunk retention, compaction, downstream replication, and recreation of content-addressed chunks. - An encrypted Real Obsidian reconnect scenario replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation adopts the replacement without restoring the old value, and proves a bidirectional encrypted round-trip. -- The beta series was exercised through BRAT on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations. The exact RC artefact will be validated separately after publication. - -## 1.0.0-beta.5 - -26th July, 2026 - -### Improved - -- **Inspect conflicts and file/database differences** now compares the current Vault file with the database winner and every live conflict revision. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, whether the Vault matches the winner, and whether conflict branches remain. -- Each reported file and live revision now has a compact wrench menu. Its available actions can compare readable text, apply the selected revision to the Vault, record an exact byte match, store the Vault content as a child of the selected branch, retry retrieving missing chunks without changing the revision tree, or discard only the selected live branch after confirmation. - -### Fixed - -- A winning logical deletion is no longer reported as a missing Vault file when the file is already absent. - -### Testing - -- Strengthened Real Obsidian coverage for start-up with an existing configuration, Security Seed readiness, failure diagnostics, and P2P status pane placement in separate desktop and mobile sessions. - -## 1.0.0-beta.4 - -25th July, 2026 - -### Improved - -- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation. -- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**. -- Text in setup and review dialogues can now be selected for copying or translation. -- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue. - -### Fixed - -- An unreadable conflict revision is no longer deleted automatically merely because its chunks are unavailable on the current device. -- Garbage Collection V3 now protects chunks required by every live conflict branch and the available revision ancestry needed to review and merge conflicts, instead of considering only the database winner. The action is offered only for CouchDB because P2P has no central database to compact and does not provide the device inventory required by the workflow. Collection now stops when device progress cannot be verified, and a compaction timeout is no longer followed by a contradictory success message. - -### Testing - -- Added regressions for revision repair, command availability, selectable dialogues, conflict-aware chunk reachability, device-progress safeguards, and compaction timeouts. -- Added a real CouchDB integration test for logical chunk deletion, shared and conflict chunk retention, compaction completion, downstream replication, and content-addressed chunk recreation. -- Added a real Obsidian encrypted reconnect scenario which replaces the remote Security Seed while one client retains the previous value, verifies that synchronisation refreshes it without restoring the old value, and proves a bidirectional encrypted round-trip. - -## 1.0.0-beta.3 - -24th July, 2026 - -### Improved - -- Enabling Hidden File Sync now opens one progress Notice before its setting is saved and reuses that Notice throughout the initial file scan, instead of stacking separate phase and restart Notices. -- P2P is now presented only after it has been configured: its status pane no longer opens at start-up, its ribbon icon remains hidden for CouchDB-only Vaults, and the retired P2P pane command has been removed. The current pane distinguishes announcing changes, following a peer, and persistent per-device actions. Setup and guidance now distinguish the required signalling relay from optional TURN, and describe the public signalling relay's privacy and availability limits. -- First-device P2P setup now accepts a successfully opened signalling room without requiring another peer to be online. Additional-device Fetch still requires selecting a source peer and completing `P2P Rebuild`. -- Manual CouchDB setup now distinguishes creating a first database from connecting an additional device to an existing one. Settings mode can save an unverified profile explicitly, while onboarding requires a successful connection, and each proposed server-configuration fix requires separate confirmation. -- Differences limited to the chunk hash algorithm, chunk size, or splitter version are now aligned automatically by default. Existing content remains readable, while an explicit opt-out and any difference which also involves an incompatible setting retain manual review. - -### Fixed - -- Choosing **Apply settings to this device, and fetch again** for a compatible configuration mismatch now applies the remote settings before Fetch, instead of updating the remote database with this device's settings. -- Accepted settings which control how new chunks are created now take effect before synchronisation is retried, rather than leaving the previous hash or splitter active until restart. - -### Testing - -- Added regressions for P2P configuration, the distinction between setting up the first device and using Fetch on an additional device, the P2P status pane, CouchDB setup policy, and mobile dialogues. - -## 1.0.0-beta.2 - -23rd July, 2026 - -### Improved - -- Choosing **Not now** on a merge conflict now postpones repeated dialogues for that conflict while the active file retains an unresolved-conflict warning. Three or more live versions show their current count and are reviewed one deterministic pair at a time; completed pairs remain resolved across restart. The existing conflict commands can reopen a postponed conflict explicitly, and a later conflict prompts again after the current one has been resolved. - -### Fixed - -- Answering or externally closing a merge dialogue immediately no longer leaves conflict processing waiting for a response which has already occurred. - -### Testing - -- Added revision-tree regressions and focused real-Obsidian scenarios for multiple-version review and restart between resolution stages. - -## 1.0.0-beta.1 - -22nd July, 2026 - -### Important - -- This corrected opt-in integration preview follows `1.0.0-beta.0` and does not replace the latest stable release. Update every participating device before resuming synchronisation, and continue to use a current backup while testing with an existing Vault. - -### Fixed - -- Conflict resolutions made on another device no longer recreate the same conflict when the receiving Vault still contains the exact content of the deleted losing revision. Automatic text and structured-data merge now uses the nearest revision actually shared by both branches instead of inferring ancestry from revision generation numbers. -- Edits, deletions, and renames made while a file is conflicted now extend the exact revision displayed on that device. If LiveSync cannot prove the displayed branch, it preserves the affected branches for review instead of silently applying the operation to the database winner. - -### Testing - -- Added revision-tree regressions and focused real-Obsidian scenarios for propagated resolutions and file operations performed while a conflict remains active. - -## 1.0.0-beta.0 - -22nd July, 2026 - -### Important - -- This is an opt-in 1.0 integration preview for BRAT and testing with existing Vaults. It does not replace the latest stable release. Use it with a current backup, and update every participating device before resuming synchronisation. -- An upgraded, copied, or restored Vault may pause replication for an explicit compatibility review. The review preserves the existing automatic synchronisation choices and resumes them only after the decision has been saved successfully. - -### Improved - -- An unconfigured installation now waits for you to start setup. A long-lived Notice offers the setup action, and **Open onboarding wizard** remains available from the command palette instead of the dialogue opening automatically. -- The setup wizard now creates named remote profiles for CouchDB, Object Storage, and P2P. Current Setup URIs preserve their profile names and selections, and the wizard reserves Rebuild or Fetch before the ordinary start-up scan begins. -- Peer-to-Peer Synchronisation (P2P) and Hidden File Sync are supported opt-in features. JWT authentication, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews. Customisation Sync remains a supported advanced workflow. -- Data Compression remains available after measurement showed a modest, workload-dependent reduction in stored and transferred chunk data. Its benefits, costs, and reason for remaining disabled by default in 1.0 are described in the Data Compression specification. -- Compatibility review now runs before Config Doctor without overlapping it. Existing Vaults retain their automatic synchronisation choices and explicit file-name case setting. For installations created by earlier releases, LiveSync preserves whether setup had been completed and saves a missing legacy case setting as case-insensitive. -- P2P connections now restart reliably after settings are reapplied or the local database is reset. Setup on an additional device asks you to select the source device once. Disconnecting leaves the LiveSync room and closes its signalling relay connections so that reconnecting can establish a new room. -- Action buttons are stacked vertically, long setup dialogues keep their controls reachable on mobile screens, and persistent Notices no longer cover close controls. Hidden File Sync reload and restart requests are grouped into one message, including the case reported in issue #555. -- Warnings about estimated remote storage size now appear as long-lived clickable Notices instead of timed dialogues. Initial uploads and Rebuild operations no longer prompt to send every chunk in advance; ordinary replication completes the transfer. -- Removed the obsolete **Use the trash bin** control and the setting for fixed chunk revisions. Remote deletion still follows Obsidian's preference, and chunk revisions remain content-derived. The Change Log remains available but no longer opens automatically or tracks unread versions. - -### Fixed - -- The optional Custom HTTP Handler used by Object Storage now sends the correct byte range from binary request bodies and reports unsupported body types instead of silently sending an empty request. -- When selectors, ignore files, size limits, modification-time limits, or file-name case settings are broadened, LiveSync now rechecks previously received files without requiring another remote update. -- P2P setup on the first device no longer displays reset or upload steps for a central database, and Config Doctor now offers its chunk size recommendation for CouchDB only when a CouchDB remote profile is selected. - -### Security - -- Fly.io setup now generates CouchDB and Vault encryption secrets with cryptographically secure randomness. Dependency updates prevent excessive CPU use from specially crafted path patterns and `mailto:` links. The CLI rejects path traversal and symbolic-link components detected before Vault operations. - -### Miscellaneous - -- Self-hosted LiveSync now owns its translation catalogue. Commonlib provides English messages to other applications, and translation contributions can be made directly to the Self-hosted LiveSync repository. - -### Testing - -- Expanded automated testing in Obsidian for upgrades, synchronisation between two devices, CouchDB, Object Storage, P2P, Hidden File Sync, mobile dialogues, and clean-up after failures. +- The plug-in code in this release was installed through BRAT and validated on macOS, iOS, and Android, including upgrade from 0.25.83, bidirectional synchronisation, P2P setup, conflict handling, recovery controls, mobile layouts, and start-up with existing configurations. +- Native and non-root Docker CLI scenarios cover setup, write, read, list, information, deletion, conflict resolution, and revision retrieval with the packaged Commonlib dependency. diff --git a/updates_old.md b/updates_old.md index de9a2896..6d6e4ee6 100644 --- a/updates_old.md +++ b/updates_old.md @@ -3,6 +3,7 @@ The release history is now kept as one chronological sequence across smaller files: - [Current 1.x releases](updates.md) +- [1.0 beta and release-candidate history](docs/releases/1.0-previews.md) - [0.25 releases](docs/releases/0.25.md) - [Releases before 0.25](docs/releases/legacy.md) diff --git a/versions.json b/versions.json index ac318380..6ffd7703 100644 --- a/versions.json +++ b/versions.json @@ -1,8 +1,6 @@ { "0.25.61": "1.7.2", "0.25.60": "1.7.2", - "1.0.1": "0.9.12", - "1.0.0": "0.9.7", "0.25.81": "1.7.2", "0.25.82": "1.7.2", "0.25.83": "1.7.2", @@ -13,5 +11,6 @@ "1.0.0-beta.4": "1.7.2", "1.0.0-beta.5": "1.7.2", "1.0.0-rc.0": "1.7.2", - "1.0.0-rc.1": "1.7.2" + "1.0.0-rc.1": "1.7.2", + "1.0.0": "1.7.2" } From 2b95766d4f82dae9ae87b8224affd9c9be3f8025 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 27 Jul 2026 08:28:16 +0000 Subject: [PATCH 169/170] Correct stable release promotion order --- .github/workflows/finalise-release.yml | 3 ++- devs.md | 5 +++-- utils/release-pr-body.mjs | 7 ++++--- utils/release-process.unit.spec.ts | 23 ++++++++++++++++++++--- 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/finalise-release.yml b/.github/workflows/finalise-release.yml index 9451fc7e..d472012d 100644 --- a/.github/workflows/finalise-release.yml +++ b/.github/workflows/finalise-release.yml @@ -137,7 +137,8 @@ jobs: echo "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." elif [[ "${PRERELEASE}" == "true" ]]; then echo "Publish the draft initially as a pre-release without replacing the latest stable release." - echo "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request." + echo "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch." + echo "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release." echo "Create the stable CLI tag and publish its latest and major-minor image tags through a separate maintainer gate." else echo "Publish the draft as the latest stable release, keep the release pull request in draft, and merge only after BRAT validation succeeds." diff --git a/devs.md b/devs.md index d80a0b1c..a6acd9c6 100644 --- a/devs.md +++ b/devs.md @@ -246,6 +246,7 @@ export class ModuleExample extends AbstractObsidianModule { - A plug-in review release may omit the CLI image when the CLI artefact is not part of the required validation. When a pre-release CLI image is published, it receives immutable version and SHA-qualified tags only; it must not advance `latest` or a stable major-minor tag. - Keep a hyphenated pre-release's release pull request in draft and unmerged after BRAT validation. Reconcile the published version's metadata into its base branch through a reviewed metadata-only commit, then close the release pull request only through a separate maintainer action. - Stage a stable version for BRAT by publishing its exact `x.y.z` tag initially as a GitHub pre-release with `prerelease=true` and `publish_cli=false`. The stable manifest version would otherwise make the CLI workflow advance `latest` and the major-minor image tag before validation. +- After a staged stable version passes BRAT validation, merge its exact release commit into the reviewed base branch and integrate it through the reviewed branch chain into the repository's default branch. Promote the GitHub Release only after the default branch contains the exact stable metadata; publish stable CLI tags through a separate maintainer gate. - If validation fails, leave every published tag unchanged and prepare the next pre-release or patch version. ## Release Notes @@ -271,7 +272,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel - Publish the draft as a GitHub pre-release without replacing the latest stable release. Keep its release pull request in draft and leave its base branch unchanged throughout BRAT validation. Record that state in the pull request. - Validate the published release through BRAT. Confirm start-up, ordinary bidirectional synchronisation, and any regression scenario relevant to the release. - After a hyphenated pre-release passes, keep its release pull request unmerged. Add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`, then close the release pull request only through a separate maintainer action. -- After a stable version passes, remove its GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. Only then mark the stable release pull request ready and merge it into the selected base branch with a merge commit. +- After a stable version passes, mark its release pull request ready and merge the exact release commit into the selected base branch with a merge commit. Integrate that exact commit through the reviewed branch chain into the repository's default branch. Only after the default branch contains the matching stable metadata, remove the GitHub pre-release designation and make that exact release the latest stable release. Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate. - If BRAT validation fails, keep the release PR in draft and do not move published tags. Before preparing the next version, add a reviewed metadata-only commit to the selected base branch which records the published version in `versions.json` and moves its exact tagged release notes out of `## Unreleased`. Keep only changes made after that tag under `## Unreleased`. Compare the historical section with `git show :updates.md`; do not merge the failed release PR or describe it as validated. The next release PR can then rotate only the correction notes while preserving the immutable release history. - Prepare and publish the next patch or pre-release version from that reconciled base. Leave the failed release PR draft until it is deliberately closed as superseded under a separate maintainer action. - A hyphenated version is rejected unless `prerelease=true`. A stable version staged with `prerelease=true` is rejected unless `publish_cli=false`. @@ -302,7 +303,7 @@ The `Finalise Release Tags` and `Release Obsidian Plugin` workflows use the `rel 8. Update the PR state message to describe the published pre-release and state that merging remains on hold. 9. Validate the published release through BRAT, including start-up, ordinary bidirectional synchronisation, and any release-specific regression scenario. 10. After a hyphenated pre-release passes, keep its release PR unmerged, reconcile its `versions.json` entry and exact release-note section into the selected base branch as metadata only, then close the PR through a separate maintainer action. -11. After a stable version passes, remove its pre-release designation, make the exact release the latest stable release, publish the stable CLI tags through a separate maintainer gate if selected, then mark the release PR ready and merge it into the selected base branch. +11. After a stable version passes, mark the release PR ready and merge the exact release commit into the selected base branch. Integrate that commit through the reviewed branch chain into the repository's default branch. Once the default branch contains the matching stable metadata, remove the pre-release designation, make the exact release the latest stable release, and publish stable CLI tags through a separate maintainer gate if selected. 12. If validation fails, leave the PR in draft and do not move the published tags. Reconcile the published version's `updates.md` section and `versions.json` entry into the base branch as metadata only, then prepare the next patch or pre-release version from the remaining `## Unreleased` entries. ## Contribution Guidelines diff --git a/utils/release-pr-body.mjs b/utils/release-pr-body.mjs index f48f159f..18baa602 100644 --- a/utils/release-pr-body.mjs +++ b/utils/release-pr-body.mjs @@ -52,15 +52,16 @@ export function renderReleasePrBody(version, baseBranch) { : "Confirm the draft GitHub Release assets; keep stable CLI publication deferred until BRAT validation passes"; const holdInstruction = isPrerelease ? `Publishing and validating this pre-release does not unblock this pull request. Keep it in draft and unmerged, and leave ${baseBranchCode} unchanged.` - : `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation and has been promoted to the latest stable release.`; + : `Publishing the GitHub pre-release does not unblock this pull request. Keep it in draft, and leave ${baseBranchCode} unchanged, until the exact published build has passed BRAT validation. Promotion remains on hold until the exact release commit has been integrated into the repository's default branch.`; const completionInstructions = isPrerelease ? [ "- [ ] Keep this pre-release pull request unmerged; close it only through a separate maintainer action", ] : [ - "- [ ] Remove the pre-release designation and make this exact release the latest stable release", + `- [ ] After BRAT validation passes, mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, + "- [ ] Integrate the exact release commit through the reviewed branch chain into the repository's default branch", + "- [ ] Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release", "- [ ] Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate", - `- [ ] Mark this pull request ready and merge it into ${baseBranchCode} with a merge commit`, ]; return [ diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts index c9595e1b..8f6edf90 100644 --- a/utils/release-process.unit.spec.ts +++ b/utils/release-process.unit.spec.ts @@ -207,12 +207,26 @@ describe("release workflow", () => { "Publish the GitHub Release initially as a pre-release without replacing the latest stable release" ); expect(stable).toContain( - "Remove the pre-release designation and make this exact release the latest stable release" + "After BRAT validation passes, mark this pull request ready and merge it into `main` with a merge commit" + ); + expect(stable).toContain( + "Integrate the exact release commit through the reviewed branch chain into the repository's default branch" + ); + expect(stable).toContain( + "Confirm the default branch contains the exact release metadata, then remove the pre-release designation and make this exact release the latest stable release" ); expect(stable).toContain( "Create the stable CLI tag and publish its `latest` and major-minor image tags, if selected, through a separate maintainer gate" ); - expect(stable).toContain("Mark this pull request ready and merge it into `main` with a merge commit"); + expect(stable.indexOf("After BRAT validation passes")).toBeLessThan( + stable.indexOf("Integrate the exact release commit") + ); + expect(stable.indexOf("Integrate the exact release commit")).toBeLessThan( + stable.indexOf("Confirm the default branch contains the exact release metadata") + ); + expect(stable.indexOf("Confirm the default branch contains the exact release metadata")).toBeLessThan( + stable.indexOf("Create the stable CLI tag") + ); expect(stable).not.toContain("prerelease=false"); }); @@ -224,7 +238,10 @@ describe("release workflow", () => { "Keep the release pull request in draft and unmerged after BRAT validation; close it only through a separate maintainer action." ); expect(workflow).toContain( - "After BRAT validation, remove the pre-release designation and make this exact release the latest stable release before merging the release pull request." + "After BRAT validation, merge the release pull request into its reviewed base branch and integrate the exact release commit into the default branch." + ); + expect(workflow).toContain( + "Only after the default branch contains the exact release metadata, remove the pre-release designation and make this exact release the latest stable release." ); expect(workflow).toContain( 'if [[ "${VERSION}" != *-* && "${PRERELEASE}" == "true" && "${PUBLISH_CLI}" == "true" ]]; then' From f1e382c6ed45a8b9111f2fb9cab36ce9bf9bdcf5 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 27 Jul 2026 09:07:20 +0000 Subject: [PATCH 170/170] Check repository tools with community lint --- _tools/bakei18n.ts | 7 ++- _tools/checkI18nCoverage.ts | 21 ++++--- _tools/decompileRosetta.ts | 5 +- _tools/decompileRosettaToJson.ts | 19 ++---- _tools/inspect-troubleshooting-docs.ts | 25 ++++---- _tools/json2yaml.ts | 24 ++++---- _tools/messagelib.ts | 81 +++++++++++++++----------- _tools/messagelib.unit.spec.ts | 41 +++++++++++++ _tools/yaml2json.ts | 21 +++---- package.json | 3 +- 10 files changed, 150 insertions(+), 97 deletions(-) create mode 100644 _tools/messagelib.unit.spec.ts diff --git a/_tools/bakei18n.ts b/_tools/bakei18n.ts index 9ded7145..b86cc390 100644 --- a/_tools/bakei18n.ts +++ b/_tools/bakei18n.ts @@ -1,11 +1,12 @@ -import { writeFileSync } from "fs"; import { allMessages } from "../src/common/messages/combinedMessages.dev.ts"; -import path from "path"; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; const currentPath = __dirname; const outDir = path.resolve(currentPath, "../src/common/messages/combinedMessages.prod.ts"); -console.log(`Writing to ${outDir}`); +process.stdout.write(`Writing to ${outDir}\n`); writeFileSync( outDir, `export const allMessages: Readonly>>> = ${JSON.stringify(allMessages, null, 4)};` diff --git a/_tools/checkI18nCoverage.ts b/_tools/checkI18nCoverage.ts index 6b51f33b..f8c6d744 100644 --- a/_tools/checkI18nCoverage.ts +++ b/_tools/checkI18nCoverage.ts @@ -1,14 +1,14 @@ -import { readFile } from "fs/promises"; -import { join, resolve } from "path"; import { glob } from "tinyglobby"; import { parse } from "yaml"; import { objectToDotted } from "./messagelib.ts"; +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; -const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/")); +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/")); const files = (await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir })).sort(); -function flattenMessages(src: Record) { +function flattenMessages(src: unknown) { return Object.fromEntries( Object.entries(objectToDotted(src)) .map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value] as const) @@ -20,9 +20,14 @@ function flattenMessages(src: Record) { const localeData = new Map>(); for (const file of files) { const segments = file.split(/[/\\]/); - const locale = segments[segments.length - 1]!.replace(/\.yaml$/, ""); - const content = await readFile(file, "utf-8"); - localeData.set(locale, flattenMessages(parse(content) ?? {})); + const localeFilename = segments[segments.length - 1]; + if (localeFilename === undefined) { + throw new Error(`Could not determine the locale name for ${file}`); + } + const locale = localeFilename.replace(/\.yaml$/, ""); + const content = await fsPromises.readFile(file, "utf-8"); + const parsed: unknown = parse(content); + localeData.set(locale, flattenMessages(parsed ?? {})); } const baseLocale = "en"; @@ -55,4 +60,4 @@ const report = Object.fromEntries( }) ); -console.log(JSON.stringify(report, null, 2)); +process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); diff --git a/_tools/decompileRosetta.ts b/_tools/decompileRosetta.ts index 9c8b2600..c2716382 100644 --- a/_tools/decompileRosetta.ts +++ b/_tools/decompileRosetta.ts @@ -1,9 +1,8 @@ -import { writeFileSync } from "fs"; - import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "../src/common/rosetta"; import { allMessages } from "../src/common/messages/combinedMessages.dev.ts"; -import path from "path"; +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); const thisFileDir = __dirname; const outDir = path.join(thisFileDir, "i18n"); diff --git a/_tools/decompileRosettaToJson.ts b/_tools/decompileRosettaToJson.ts index 5eb8a65d..030c067e 100644 --- a/_tools/decompileRosettaToJson.ts +++ b/_tools/decompileRosettaToJson.ts @@ -1,26 +1,17 @@ -import { writeFileSync } from "fs"; - import { allMessages } from "../src/common/messages/combinedMessages.prod.ts"; const __dirname = import.meta.dirname; -import path from "path"; + +const { writeFileSync } = process.getBuiltinModule("node:fs"); +const path = process.getBuiltinModule("node:path"); const thisFileDir = __dirname; const outDir = path.resolve(thisFileDir, "../src/common/messagesJson"); const out = {} as Record; for (const [key, value] of Object.entries(allMessages)) { - //@ts-ignore - for (const [lang, langValue] of Object.entries(allMessages[key])) { + for (const [lang, langValue] of Object.entries(value)) { if (!out[lang]) out[lang] = {}; - if (lang in value) { - out[lang][key] = langValue as string; - } else { - if (lang === "def") { - out[lang][key] = key; - } else { - out[lang][key] = undefined; - } - } + out[lang][key] = langValue; } } diff --git a/_tools/inspect-troubleshooting-docs.ts b/_tools/inspect-troubleshooting-docs.ts index e7de1545..245bf3da 100644 --- a/_tools/inspect-troubleshooting-docs.ts +++ b/_tools/inspect-troubleshooting-docs.ts @@ -1,6 +1,6 @@ -import { access, readFile } from "node:fs/promises"; -import { dirname, relative, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); +const url = process.getBuiltinModule("node:url"); type InspectionError = { check: "current-label" | "local-reference" | "retired-label"; @@ -20,7 +20,7 @@ const messageCataloguePath = "src/common/messagesJson/en.json"; const markdownLinkPattern = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+["'][^)]*["'])?\)/gu; function repositoryRootFromThisFile(): string { - return resolve(dirname(fileURLToPath(import.meta.url)), ".."); + return path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), ".."); } function normaliseReferenceTarget(rawTarget: string): string { @@ -48,14 +48,14 @@ async function inspectLocalReferences( const [pathPart] = target.split("#", 1); if (!pathPart) continue; checked++; - const referencedPath = resolve(repositoryRoot, dirname(documentPath), pathPart); + const referencedPath = path.resolve(repositoryRoot, path.dirname(documentPath), pathPart); try { - await access(referencedPath); + await fsPromises.access(referencedPath); } catch { errors.push({ check: "local-reference", file: documentPath, - detail: `Missing local reference: ${relative(repositoryRoot, referencedPath)}`, + detail: `Missing local reference: ${path.relative(repositoryRoot, referencedPath)}`, }); } } @@ -68,14 +68,13 @@ export async function inspectTroubleshootingDocs( const errors: InspectionError[] = []; const documents = new Map(); for (const guidePath of guidePaths) { - documents.set(guidePath, await readFile(resolve(repositoryRoot, guidePath), "utf8")); + documents.set(guidePath, await fsPromises.readFile(path.resolve(repositoryRoot, guidePath), "utf8")); } const troubleshooting = documents.get("docs/troubleshooting.md")!; - const catalogue = JSON.parse(await readFile(resolve(repositoryRoot, messageCataloguePath), "utf8")) as Record< - string, - string - >; + const catalogue = JSON.parse( + await fsPromises.readFile(path.resolve(repositoryRoot, messageCataloguePath), "utf8") + ) as Record; const requiredMessageKeys = [ "TweakMismatchResolve.Action.UseConfigured", "TweakMismatchResolve.Action.UseMine", @@ -132,7 +131,7 @@ async function runCli(): Promise { if (!result.ok) process.exitCode = 1; } -const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : undefined; +const invokedPath = process.argv[1] ? url.pathToFileURL(path.resolve(process.argv[1])).href : undefined; if (invokedPath === import.meta.url) { await runCli(); } diff --git a/_tools/json2yaml.ts b/_tools/json2yaml.ts index de8cae87..299b5d6b 100644 --- a/_tools/json2yaml.ts +++ b/_tools/json2yaml.ts @@ -1,27 +1,31 @@ // Convert Application convenient Message Resources (JSON) to Human-Editable format (YAML) -import { readFile, writeFile } from "fs/promises"; -import { join, resolve } from "path"; import { stringify } from "yaml"; import { glob } from "tinyglobby"; import { dottedToObject } from "./messagelib"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; -const targetDir = resolve(join(__dirname, "../src/common/messagesJson/")); -console.log(`Target directory: ${targetDir}`); +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesJson/")); +process.stdout.write(`Target directory: ${targetDir}\n`); const files = await glob(`*.json`, { expandDirectories: false, absolute: true, cwd: targetDir }); for (const file of files) { - const filePath = resolve(file); - console.log(`Processing file: ${filePath}`); - const content = await readFile(filePath, "utf-8"); - const jsonDataSrc = JSON.parse(content); + const filePath = path.resolve(file); + process.stdout.write(`Processing file: ${filePath}\n`); + const content = await fsPromises.readFile(filePath, "utf-8"); + const jsonDataSrc: unknown = JSON.parse(content); + if (typeof jsonDataSrc !== "object" || jsonDataSrc === null || Array.isArray(jsonDataSrc)) { + throw new TypeError(`Expected ${filePath} to contain a JSON object`); + } const jsonDataD2 = Object.fromEntries( Object.entries(jsonDataSrc).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) ); const jsonData = dottedToObject(jsonDataD2); const yamlData = stringify(jsonData, { indent: 2 }); const yamlFilePath = filePath.replace(/\.json$/, ".yaml").replace("Json", "YAML"); - await writeFile(yamlFilePath, yamlData, "utf-8"); - console.log(`Converted ${filePath} to ${yamlFilePath}`); + await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8"); + process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`); } // console.dir(files, { depth: 0 }); diff --git a/_tools/messagelib.ts b/_tools/messagelib.ts index 6243ffad..95a4c664 100644 --- a/_tools/messagelib.ts +++ b/_tools/messagelib.ts @@ -1,38 +1,49 @@ -export function objectToDotted(obj: any, prefix = ""): Record { - return Object.entries(obj).reduce( - (acc, [key, value]) => { - const newKey = prefix ? `${prefix}.${key}` : key; - if (typeof value === "object" && value !== null && !Array.isArray(value)) { - Object.assign(acc, objectToDotted(value, newKey)); - } else { - acc[newKey] = value; - } - return acc; - }, - {} as Record - ); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -export function dottedToObject(obj: Record): Record { - return Object.entries(obj).reduce( - (acc, [key, value]) => { - if (key.includes(" ")) { - // Return as is. - return { ...acc, [key]: value }; // Skip keys with spaces + +export function objectToDotted(obj: unknown, prefix = ""): Record { + if (!isRecord(obj)) { + throw new TypeError("Expected a message catalogue object"); + } + const flattened: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const newKey = prefix ? `${prefix}.${key}` : key; + if (isRecord(value)) { + Object.assign(flattened, objectToDotted(value, newKey)); + } else { + flattened[newKey] = value; + } + } + return flattened; +} + +export function dottedToObject(obj: unknown): Record { + if (!isRecord(obj)) { + throw new TypeError("Expected a dotted message catalogue object"); + } + const nestedResult: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key.includes(" ")) { + nestedResult[key] = value; + continue; + } + const keys = key.split("."); + let nested = nestedResult; + for (const [index, currentKey] of keys.entries()) { + if (index === keys.length - 1) { + nested[currentKey] = value; + continue; } - const keys = key.split("."); - keys.reduce((nestedAcc, currKey, index) => { - if (currKey in nestedAcc && typeof nestedAcc[currKey] !== "object") { - nestedAcc[currKey] = { _value: nestedAcc[currKey] }; // Convert to object if not already - } - if (index === keys.length - 1) { - nestedAcc[currKey] = value; - } else { - nestedAcc[currKey] = nestedAcc[currKey] || {}; - } - return nestedAcc[currKey]; - }, acc); - return acc; - }, - {} as Record - ); + const currentValue = nested[currentKey]; + if (isRecord(currentValue)) { + nested = currentValue; + continue; + } + const replacement = currentValue === undefined || currentValue === null ? {} : { _value: currentValue }; + nested[currentKey] = replacement; + nested = replacement; + } + } + return nestedResult; } diff --git a/_tools/messagelib.unit.spec.ts b/_tools/messagelib.unit.spec.ts new file mode 100644 index 00000000..ae8f612a --- /dev/null +++ b/_tools/messagelib.unit.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { dottedToObject, objectToDotted } from "./messagelib"; + +describe("message catalogue conversion", () => { + it("flattens nested objects while preserving leaf values", () => { + expect( + objectToDotted({ + dialogue: { + title: "Title", + options: ["first", "second"], + }, + "literal key": "Literal", + }) + ).toEqual({ + "dialogue.title": "Title", + "dialogue.options": ["first", "second"], + "literal key": "Literal", + }); + }); + + it("preserves an existing leaf under _value when a dotted child follows it", () => { + expect( + dottedToObject({ + section: "Base value", + "section.child": "Child value", + "literal key": "Literal", + }) + ).toEqual({ + section: { + _value: "Base value", + child: "Child value", + }, + "literal key": "Literal", + }); + }); + + it("rejects non-object catalogue roots", () => { + expect(() => objectToDotted("not an object")).toThrow("Expected a message catalogue object"); + expect(() => dottedToObject(["not", "an", "object"])).toThrow("Expected a dotted message catalogue object"); + }); +}); diff --git a/_tools/yaml2json.ts b/_tools/yaml2json.ts index 484ef2a5..d35a39d3 100644 --- a/_tools/yaml2json.ts +++ b/_tools/yaml2json.ts @@ -1,29 +1,30 @@ // Convert Human-Editable format (YAML) to Application convenient Message Resources (JSON) -import { readFile, writeFile } from "fs/promises"; -import { join, resolve } from "path"; import { parse } from "yaml"; import { glob } from "tinyglobby"; import { objectToDotted } from "./messagelib"; + +const fsPromises = process.getBuiltinModule("node:fs/promises"); +const path = process.getBuiltinModule("node:path"); const __dirname = import.meta.dirname; -const targetDir = resolve(join(__dirname, "../src/common/messagesYAML/")); -console.log(`Target directory: ${targetDir}`); +const targetDir = path.resolve(path.join(__dirname, "../src/common/messagesYAML/")); +process.stdout.write(`Target directory: ${targetDir}\n`); const files = await glob(`*.yaml`, { expandDirectories: false, absolute: true, cwd: targetDir }); for (const file of files) { - const filePath = resolve(file); - const content = await readFile(filePath, "utf-8"); - const jsonDataSrc = parse(content); + const filePath = path.resolve(file); + const content = await fsPromises.readFile(filePath, "utf-8"); + const jsonDataSrc: unknown = parse(content); const jsonDataD2 = objectToDotted(jsonDataSrc); const jsonData = Object.fromEntries( Object.entries(jsonDataD2) - .map(([key, value]) => [key.endsWith("._value") ? key.slice(0, -7) : key, value]) + .map(([key, value]): [string, unknown] => [key.endsWith("._value") ? key.slice(0, -7) : key, value]) .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) ); const yamlData = JSON.stringify(jsonData, null, 4) + "\n"; const yamlFilePath = filePath.replace(/\.yaml$/, ".json").replace("YAML", "Json"); - await writeFile(yamlFilePath, yamlData, "utf-8"); - console.log(`Converted ${filePath} to ${yamlFilePath}`); + await fsPromises.writeFile(yamlFilePath, yamlData, "utf-8"); + process.stdout.write(`Converted ${filePath} to ${yamlFilePath}\n`); } // console.dir(files, { depth: 0 }); diff --git a/package.json b/package.json index d2ab993c..3d66a4b5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "buildDev": "node esbuild.config.mjs dev", "lint": "eslint --cache --cache-strategy content --concurrency off src", "lint:community": "eslint --config eslint.community.config.mjs --concurrency off src", + "lint:community:tools": "eslint --config eslint.community.config.mjs --concurrency off --max-warnings 0 _tools", "svelte-check": "svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "tsc-check": "tsc --noEmit", "tsc-check:apps": "tsc --noEmit -p src/apps/browser/tsconfig.json && tsc --noEmit -p src/apps/cli/tsconfig.json && tsc --noEmit -p src/apps/webapp/tsconfig.json && tsc --noEmit -p src/apps/webpeer/tsconfig.app.json && tsc --noEmit -p src/apps/webpeer/tsconfig.node.json", @@ -22,7 +23,7 @@ "prettyNoWrite": "prettier --config ./.prettierrc.mjs \"**/*.js\" \"**/*.ts\" \"**/*.json\" ", "precheck:compatibility": "npm run build", "check:compatibility": "node utils/check-compatibility.js --file main.js --ios 15", - "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run svelte-check && npm run check:compatibility", + "check": "npm run tsc-check && npm run tsc-check:apps && npm run lint && npm run lint:community -- --quiet && npm run lint:community:tools && npm run svelte-check && npm run check:compatibility", "i18n:bake": "npm run i18n:yaml2json && npm run i18n:bakejson && npm run i18n:format", "i18n:bakejson": "tsx _tools/bakei18n.ts", "i18n:format": "prettier --config .prettierrc.mjs --write --log-level error 'src/common/messagesJson/*.json' 'src/common/messages/*.ts'",

    a