diff --git a/.dockerignore b/.dockerignore index 76fcffa9..a3ee67b0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,8 +15,11 @@ main_org.js pouchdb-browser.js production/ -# Test coverage and reports -coverage/ +# Test coverage and reports +coverage/ +_testdata/ +test/bench-network/bench-results/ +src/apps/cli/testdeno/bench-results/ # Local environment / secrets .env diff --git a/.eslintrc b/.eslintrc index f55fe989..7f98b4a6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -20,8 +20,6 @@ "ignorePatterns": [ "**/node_modules/*", "**/jest.config.js", - "src/lib/coverage", - "src/lib/browsertest", "**/test.ts", "**/tests.ts", "**/**test.ts", @@ -56,4 +54,4 @@ } ] } -} \ No newline at end of file +} diff --git a/.github/workflows/cli-deno-tests.yml b/.github/workflows/cli-deno-tests.yml index eff411c0..d249fa00 100644 --- a/.github/workflows/cli-deno-tests.yml +++ b/.github/workflows/cli-deno-tests.yml @@ -8,14 +8,14 @@ on: paths: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - - 'src/lib/src/API/processSetting.ts' + - 'test/bench-network/**' - 'package.json' - 'package-lock.json' pull_request: paths: - '.github/workflows/cli-deno-tests.yml' - 'src/apps/cli/**' - - 'src/lib/src/API/processSetting.ts' + - 'test/bench-network/**' - 'package.json' - 'package-lock.json' workflow_dispatch: @@ -25,8 +25,6 @@ on: type: choice options: - test:ci - - test:p2p - - test:all - test:local - test:e2e-matrix default: test:ci @@ -60,12 +58,6 @@ jobs: test:ci) TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]' ;; - test:p2p) - TASK_MATRIX='["test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download"]' - ;; - test:all) - TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:p2p-host","test:p2p-peers","test:p2p-sync","test:p2p-three-nodes","test:p2p-upload-download","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]' - ;; test:local) TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]' ;; @@ -93,8 +85,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -155,3 +145,38 @@ 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 + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Run Compose CLI P2P E2E + env: + CLI_E2E_TASK: test:p2p:ci + 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 run-livesync-cli-e2e + + - 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-docker.yml b/.github/workflows/cli-docker.yml index b5405c48..54eaa5fd 100644 --- a/.github/workflows/cli-docker.yml +++ b/.github/workflows/cli-docker.yml @@ -2,7 +2,8 @@ # Image tag format: --cli # Example: 0.25.56-1743500000-cli # -# The image is also tagged 'latest' for convenience. +# Stable releases are also tagged with their major-minor version and 'latest'. +# Pre-releases receive immutable version and SHA-qualified tags only. # Image name: ghcr.io//livesync-cli name: Build and Push CLI Docker Image @@ -22,7 +23,6 @@ on: - "src/apps/webpeer/**" - ".github/workflows/release.yml" - ".github/workflows/unit-ci.yml" - - ".github/workflows/harness-ci.yml" workflow_dispatch: inputs: dry_run: @@ -47,8 +47,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Derive image tag id: meta @@ -57,12 +55,17 @@ jobs: MAJOR_MINOR=$(echo "${VERSION}" | cut -d. -f1,2) SHORT_SHA=$(git rev-parse --short HEAD) IMAGE="ghcr.io/${{ github.repository_owner }}/livesync-cli" - + # Build tag list based on the event and git ref TAGS="" if [[ "${{ github.ref }}" == refs/tags/* ]]; then - # Stable release builds - TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli" + if [[ "${VERSION}" == *-* ]]; then + # Pre-release builds must not advance stable moving tags. + TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli" + else + # Stable release builds + TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli" + fi elif [[ "${{ github.ref }}" == refs/heads/main ]]; then # Bleeding-edge / nightly builds TAGS="${IMAGE}:edge" @@ -70,7 +73,7 @@ jobs: # Other branches / manual run fallback TAGS="${IMAGE}:${VERSION}-dev-sha-${SHORT_SHA}-cli" fi - + # Determine if the image should be pushed PUSH="true" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then @@ -78,7 +81,7 @@ jobs: PUSH="false" fi fi - + echo "tags=${TAGS}" >> $GITHUB_OUTPUT echo "push=${PUSH}" >> $GITHUB_OUTPUT @@ -89,6 +92,11 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -126,5 +134,6 @@ jobs: file: src/apps/cli/Dockerfile push: ${{ steps.meta.outputs.push }} tags: ${{ steps.meta.outputs.tags }} + platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/cli-e2e.yml b/.github/workflows/cli-e2e.yml index 17388606..6b3fcc4b 100644 --- a/.github/workflows/cli-e2e.yml +++ b/.github/workflows/cli-e2e.yml @@ -23,8 +23,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - with: - submodules: recursive - name: Setup Node.js uses: actions/setup-node@v4 @@ -64,4 +62,4 @@ jobs: working-directory: src/apps/cli run: | bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true - bash ./util/minio-stop.sh >/dev/null 2>&1 || true \ No newline at end of file + bash ./util/minio-stop.sh >/dev/null 2>&1 || true diff --git a/.github/workflows/cli-p2p-compose-smoke.yml b/.github/workflows/cli-p2p-compose-smoke.yml new file mode 100644 index 00000000..4b62bbd0 --- /dev/null +++ b/.github/workflows/cli-p2p-compose-smoke.yml @@ -0,0 +1,94 @@ +# Run the Compose-packaged CLI P2P smoke benchmark. +# +# 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: + 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 + 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 + + - name: Show Docker versions + run: | + docker --version + docker compose version + + - name: Run Compose P2P smoke benchmark + env: + 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 || '1' }} + BENCH_BIN_SIZE_BYTES: '512' + BENCH_COUCHDB_RTT_MS: ${{ inputs.couchdb_rtt_ms || '20' }} + BENCH_SYNC_TIMEOUT: '180' + 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 --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 --profile signalling-shim logs --no-color couchdb nostr-relay p2p-signalling-shim || true + + - 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/.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..a254a8d9 --- /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..d2dc352f --- /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..64fd066b --- /dev/null +++ b/src/apps/browser/ui/MessageBox.svelte @@ -0,0 +1,137 @@ + + + +
    {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..8066da4b --- /dev/null +++ b/src/apps/browser/ui/TextInputBox.svelte @@ -0,0 +1,126 @@ + + + +
    {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/apps/webapp/test/e2e.spec.ts b/src/apps/webapp/test/e2e.spec.ts deleted file mode 100644 index 72c8343d..00000000 --- a/src/apps/webapp/test/e2e.spec.ts +++ /dev/null @@ -1,294 +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 { mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -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!); - if (!cov) return; - if (typeof cov === "object" && Object.keys(cov as Record).length === 0) { - return; - } - - const outDir = path.resolve(__dirname, "../.nyc_output"); - mkdirSync(outDir, { recursive: true }); - const name = `${testInfo.testId.replace(/[^a-zA-Z0-9_-]/g, "_")}-${label}.json`; - 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/tsconfig.json b/src/apps/webapp/tsconfig.json index e2198719..ab83008a 100644 --- a/src/apps/webapp/tsconfig.json +++ b/src/apps/webapp/tsconfig.json @@ -23,8 +23,7 @@ /* Path mapping */ // "baseUrl": ".", "paths": { - "@/*": ["../../*"], - "@lib/*": ["../../lib/src/*", "../../../_types/src/lib/src/*"] + "@/*": ["../../*"] } }, "include": ["*.ts", "**/*.ts", "**/*.tsx", "**/*.svelte"], diff --git a/src/apps/webapp/vaultSelector.ts b/src/apps/webapp/vaultSelector.ts index 35fc4f4f..7c2d2321 100644 --- a/src/apps/webapp/vaultSelector.ts +++ b/src/apps/webapp/vaultSelector.ts @@ -1,4 +1,4 @@ -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; const HANDLE_DB_NAME = "livesync-webapp-handles"; const HANDLE_STORE_NAME = "handles"; @@ -15,6 +15,18 @@ export type VaultHistoryItem = { type VaultHistoryValue = VaultHistoryItem; +function isVaultHistoryValue(value: unknown): value is VaultHistoryValue { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.name === "string" && + typeof candidate.handle === "object" && + candidate.handle !== null && + typeof candidate.lastUsedAt === "number" + ); +} + function makeVaultKey(id: string): string { return `${VAULT_KEY_PREFIX}${id}`; } @@ -87,7 +99,7 @@ export class VaultHistoryStore { async getLastUsedVaultId(): Promise { return this.withStore("readonly", async (store) => { - const value = await this.requestAsPromise(store.get(LAST_USED_KEY)); + const value: unknown = await this.requestAsPromise(store.get(LAST_USED_KEY)); return typeof value === "string" ? value : null; }); } @@ -95,20 +107,21 @@ export class VaultHistoryStore { async getVaultHistory(): Promise { return this.withStore("readonly", async (store) => { const keys = await this.requestAsPromise(store.getAllKeys()); - const values = (await this.requestAsPromise(store.getAll())) as unknown[]; + const values = await this.requestAsPromise(store.getAll() as IDBRequest); const items: VaultHistoryItem[] = []; for (let i = 0; i < keys.length; i++) { - const key = String(keys[i]); + const key = keys[i]; + if (typeof key !== "string") continue; const id = parseVaultId(key); - const value = values[i] as Partial | undefined; - if (!id || !value || !value.handle || !value.name) { + const value = values[i]; + if (!id || !isVaultHistoryValue(value)) { continue; } items.push({ id, - name: String(value.name), + name: value.name, handle: value.handle, - lastUsedAt: Number(value.lastUsedAt || 0), + lastUsedAt: value.lastUsedAt, }); } items.sort((a, b) => b.lastUsedAt - a.lastUsedAt); diff --git a/src/apps/webapp/vite.config.ts b/src/apps/webapp/vite.config.ts index a82a1484..8857acf9 100644 --- a/src/apps/webapp/vite.config.ts +++ b/src/apps/webapp/vite.config.ts @@ -1,47 +1,26 @@ import { defineConfig } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; -import istanbul from "vite-plugin-istanbul"; -import path from "node:path"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, fs, path } from "@vrtmrz/livesync-commonlib/node"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "../../.."); -const packageJson = JSON.parse(readFileSync(path.resolve(repoRoot, "package.json"), "utf-8")); -const manifestJson = JSON.parse(readFileSync(path.resolve(repoRoot, "manifest.json"), "utf-8")); -const enableCoverage = process.env.PW_COVERAGE === "1"; + +function readVersion(filePath: string): string | undefined { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf-8")); + if (typeof parsed !== "object" || parsed === null || !("version" in parsed)) { + return undefined; + } + return typeof parsed.version === "string" ? parsed.version : undefined; +} + +const packageVersion = readVersion(path.resolve(repoRoot, "package.json")); +const manifestVersion = readVersion(path.resolve(repoRoot, "manifest.json")); // 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, "../../"), - "@lib": path.resolve(__dirname, "../../lib/src"), - obsidian: path.resolve(__dirname, "../../../test/harness/obsidian-mock.ts"), + obsidian: path.resolve(__dirname, "./obsidianMock.ts"), }, }, base: "./", @@ -49,19 +28,16 @@ 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"], }, }, define: { - MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestJson.version || "0.0.0"), - PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageJson.version || "0.0.0"), + MANIFEST_VERSION: JSON.stringify(process.env.MANIFEST_VERSION || manifestVersion || "0.0.0"), + PACKAGE_VERSION: JSON.stringify(process.env.PACKAGE_VERSION || packageVersion || "0.0.0"), global: "globalThis", hostPlatform: JSON.stringify(process.platform || "linux"), }, diff --git a/src/apps/webpeer/package.json b/src/apps/webpeer/package.json index 67d00a26..7474c1cd 100644 --- a/src/apps/webpeer/package.json +++ b/src/apps/webpeer/package.json @@ -1,7 +1,7 @@ { "name": "webpeer", "private": true, - "version": "0.25.80-webpeer", + "version": "1.0.0-webpeer", "type": "module", "scripts": { "dev": "vite", @@ -12,7 +12,7 @@ "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" }, "dependencies": { - "octagonal-wheels": "^0.1.47" + "octagonal-wheels": "^0.1.51" }, "devDependencies": { "eslint-plugin-svelte": "^3.19.0", @@ -22,9 +22,5 @@ "svelte-check": "^4.6.0", "typescript": "5.9.3", "vite": "^8.0.16" - }, - "imports": { - "../../src/worker/bgWorker.ts": "../../src/worker/bgWorker.mock.ts", - "@lib/worker/bgWorker.ts": "@lib/worker/bgWorker.mock.ts" } } diff --git a/src/apps/webpeer/src/CommandsShim.ts b/src/apps/webpeer/src/CommandsShim.ts index 0f53cd1c..31a9a352 100644 --- a/src/apps/webpeer/src/CommandsShim.ts +++ b/src/apps/webpeer/src/CommandsShim.ts @@ -1,6 +1,6 @@ -import { LOG_LEVEL_VERBOSE } from "@lib/common/types"; +import { LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types"; -import { defaultLoggerEnv, setGlobalLogFunction } from "@lib/common/logger"; +import { defaultLoggerEnv, setGlobalLogFunction } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { writable } from "svelte/store"; export const logs = writable([] as string[]); @@ -9,7 +9,6 @@ let _logs = [] as string[]; const maxLines = 10000; setGlobalLogFunction((msg, level) => { - console.log(msg); const msgstr = typeof msg === "string" ? msg : JSON.stringify(msg); const strLog = `${new Date().toISOString()}\u2001${msgstr}`; _logs.push(strLog); diff --git a/src/apps/webpeer/src/P2PReplicatorShim.ts b/src/apps/webpeer/src/P2PReplicatorShim.ts index a4d8d625..9fbfae5e 100644 --- a/src/apps/webpeer/src/P2PReplicatorShim.ts +++ b/src/apps/webpeer/src/P2PReplicatorShim.ts @@ -1,4 +1,4 @@ -import { PouchDB } from "@lib/pouchdb/pouchdb-browser"; +import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser"; import { type EntryDoc, type ObsidianLiveSyncSettings, @@ -6,33 +6,32 @@ import { LOG_LEVEL_VERBOSE, P2P_DEFAULT_SETTINGS, REMOTE_P2P, -} from "@lib/common/types"; -import { eventHub } from "@lib/hub/hub"; +} from "@vrtmrz/livesync-commonlib/compat/common/types"; -import type { Confirm } from "@lib/interfaces/Confirm"; -import { LOG_LEVEL_NOTICE, Logger, type LOG_LEVEL } from "@lib/common/logger"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import { LOG_LEVEL_NOTICE, Logger, type LOG_LEVEL } from "@vrtmrz/livesync-commonlib/compat/common/logger"; import { EVENT_P2P_PEER_SHOW_EXTRA_MENU, type PeerStatus, type PluginShim, -} from "@lib/replication/trystero/P2PReplicatorPaneCommon"; -import { useP2PReplicator } from "@lib/replication/trystero/P2PReplicatorCore"; -import { P2PLogCollector } from "@lib/replication/trystero/P2PLogCollector"; -import type { P2PReplicatorBase } from "@lib/replication/trystero/P2PReplicatorBase.ts"; +} from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorPaneCommon"; +import { useP2PReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorCore"; +import { P2PLogCollector } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PLogCollector"; +import type { P2PReplicatorBase } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/P2PReplicatorBase"; import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase"; import { reactiveSource } from "octagonal-wheels/dataobject/reactive_v2"; -import { EVENT_SETTING_SAVED } from "@lib/events/coreEvents"; +import { EVENT_SETTING_SAVED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import { unique } from "octagonal-wheels/collection"; -import { BrowserServiceHub } from "@lib/services/BrowserServices"; -import { SETTING_KEY_P2P_DEVICE_NAME } from "@lib/common/types"; -import { ServiceContext } from "@lib/services/base/ServiceBase"; -import type { InjectableServiceHub } from "@lib/services/InjectableServices"; -import { Menu } from "@lib/services/implements/browser/Menu"; +import { SETTING_KEY_P2P_DEVICE_NAME } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { ServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub"; +import { Menu } from "@/apps/browser/BrowserMenu"; import { SimpleStoreIDBv2 } from "octagonal-wheels/databases/SimpleStoreIDBv2"; -import type { BrowserAPIService } from "@lib/services/implements/browser/BrowserAPIService"; -import type { InjectableSettingService } from "@lib/services/implements/injectable/InjectableSettingService"; -import { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; -import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; +import type { BrowserAPIService } from "@vrtmrz/livesync-commonlib/compat/services/implements/browser/BrowserAPIService"; +import type { InjectableSettingService } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableSettingService"; +import { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; +import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; +import { createLiveSyncBrowserServiceHub } from "@/apps/browser/createLiveSyncBrowserServiceHub"; function addToList(item: string, list: string) { return unique( @@ -91,7 +90,7 @@ export class P2PReplicatorShim implements P2PReplicatorBase { } constructor() { - const browserServiceHub = new BrowserServiceHub(); + const browserServiceHub = createLiveSyncBrowserServiceHub(); this.services = browserServiceHub; (this.services.API as BrowserAPIService).getSystemVaultName.setHandler( @@ -103,7 +102,7 @@ export class P2PReplicatorShim implements P2PReplicatorBase { this.services.setting.settings = _settings; (this.services.setting as InjectableSettingService).saveData.setHandler(async (data) => { await repStore.set("settings", data); - eventHub.emitEvent(EVENT_SETTING_SAVED, data); + this.services.context.events.emitEvent(EVENT_SETTING_SAVED, data); }); (this.services.setting as InjectableSettingService).loadData.setHandler(async () => { const settings = { ..._settings, ...((await repStore.get("settings")) as ObsidianLiveSyncSettings) }; @@ -180,7 +179,7 @@ export class P2PReplicatorShim implements P2PReplicatorBase { m?: Menu; afterConstructor(): void { - eventHub.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => { + this.services.context.events.onEvent(EVENT_P2P_PEER_SHOW_EXTRA_MENU, ({ peer, event }) => { if (this.m) { this.m.hide(); } @@ -231,6 +230,34 @@ export class P2PReplicatorShim implements P2PReplicatorBase { return this._liveSyncReplicator?.disableBroadcastChanges(); } + enableBroadcastChanges() { + return this._liveSyncReplicator?.enableBroadcastChanges(); + } + + disableBroadcastChanges() { + return this._liveSyncReplicator?.disableBroadcastChanges(); + } + + async makeDecision(decision: Parameters[0]): Promise { + await this._liveSyncReplicator?.makeDecision(decision); + } + + async revokeDecision(decision: Parameters[0]): Promise { + await this._liveSyncReplicator?.revokeDecision(decision); + } + + watchPeer(peerId: string): void { + this._liveSyncReplicator?.watchPeer(peerId); + } + + unwatchPeer(peerId: string): void { + this._liveSyncReplicator?.unwatchPeer(peerId); + } + + async sync(peerId: string, showNotice?: boolean): Promise { + return await this._liveSyncReplicator?.sync(peerId, showNotice); + } + get replicator() { return this._liveSyncReplicator; } diff --git a/src/apps/webpeer/src/SyncMain.svelte b/src/apps/webpeer/src/SyncMain.svelte index 538fff34..1cb9209d 100644 --- a/src/apps/webpeer/src/SyncMain.svelte +++ b/src/apps/webpeer/src/SyncMain.svelte @@ -3,13 +3,12 @@ import P2PReplicatorPane from "@/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte"; import { onMount, tick } from "svelte"; import { cmdSyncShim } from "./P2PReplicatorShim"; - import { eventHub } from "@lib/hub/hub"; - import { EVENT_LAYOUT_READY } from "@lib/events/coreEvents"; + import { EVENT_LAYOUT_READY } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; let synchronised = $state(cmdSyncShim.init()); onMount(() => { - eventHub.emitEvent(EVENT_LAYOUT_READY); + void synchronised.then((shim) => shim.services.context.events.emitEvent(EVENT_LAYOUT_READY)); return () => { synchronised.then((e) => e.close()); }; @@ -27,7 +26,7 @@
    {#await synchronised then cmdSync} - + {:catch error}

    {error.message}

    {/await} diff --git a/src/apps/webpeer/src/UITest.svelte b/src/apps/webpeer/src/UITest.svelte index 0e6af79b..459006cc 100644 --- a/src/apps/webpeer/src/UITest.svelte +++ b/src/apps/webpeer/src/UITest.svelte @@ -1,6 +1,6 @@
    - +

    Available Peers

    @@ -142,7 +131,7 @@ diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts index ae5f02f6..39cf4a62 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.ts @@ -1,7 +1,7 @@ -import { App } from "@/deps.ts"; -import { Logger } from "@lib/common/logger"; -import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@lib/common/types"; -import type { LiveSyncTrysteroReplicator } from "@lib/replication/trystero/LiveSyncTrysteroReplicator"; +import type { App } from "@/deps.ts"; +import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger"; +import { LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LiveSyncTrysteroReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/LiveSyncTrysteroReplicator"; import { P2POpenReplicationModal } from "./P2POpenReplicationModal"; /** @@ -20,52 +20,54 @@ export function createOpenReplicationUI( (showResult: boolean): Promise => { const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; return new Promise((resolve) => { + let resolved = false; + let sessionResult = false; + let activeSynchronisations = 0; + let closed = false; + const safeResolve = () => { + if (resolved) return; + resolved = true; + resolve(sessionResult); + }; + const settleClosedSession = () => { + if (closed && activeSynchronisations === 0) safeResolve(); + }; + const synchronise = async (peerId: string, closeConnection: boolean) => { + activeSynchronisations++; + try { + // Pull first, then push only when the pull succeeds. + const pullResult = await replicator.replicateFrom(peerId, showResult); + if (!pullResult?.ok) { + sessionResult = false; + return; + } + const pushResult = await replicator.requestSynchroniseToPeer(peerId); + sessionResult = pushResult?.ok ?? true; + if (sessionResult && closeConnection) await replicator.close(); + } catch (e) { + Logger( + `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, + logLevel + ); + sessionResult = false; + } finally { + activeSynchronisations--; + settleClosedSession(); + } + }; const modal = new P2POpenReplicationModal( app, replicator, { - onSync: async (peerId: string) => { - try { - // pull (replicateFrom) first; push only on success - const pullResult = await replicator.replicateFrom(peerId, showResult); - if (pullResult?.ok) { - const pushResult = await replicator.requestSynchroniseToPeer(peerId); - resolve(pushResult?.ok ?? true); - } else { - resolve(false); - } - } catch (e) { - Logger( - `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, - logLevel - ); - resolve(false); - } - }, - onSyncAndClose: async (peerId: string) => { - try { - const pullResult = await replicator.replicateFrom(peerId, showResult); - if (pullResult?.ok) { - const pushResult = await replicator.requestSynchroniseToPeer(peerId); - if (pushResult?.ok ?? true) { - await replicator.close(); - resolve(true); - } else { - resolve(false); - } - } else { - resolve(false); - } - } catch (e) { - Logger( - `Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`, - logLevel - ); - resolve(false); - } - }, + onSync: (peerId: string) => synchronise(peerId, false), + onSyncAndClose: (peerId: string) => synchronise(peerId, true), }, - showResult + showResult, + "P2P Replication", + () => { + closed = true; + settleClosedSession(); + } ); modal.open(); }); @@ -89,27 +91,42 @@ export function createOpenRebuildUI( const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO; return new Promise((resolve) => { let resolved = false; + let activeSynchronisations = 0; + let closed = false; + let operationCompleted = false; + let sessionResult = false; const safeResolve = (val: boolean) => { if (!resolved) { resolved = true; resolve(val); } }; + const settleSession = () => { + if (activeSynchronisations !== 0) return; + if (closed || operationCompleted) safeResolve(sessionResult); + }; const doRebuild = async (peerId: string) => { - replicator.setOnSetup(); + activeSynchronisations++; try { + replicator.setOnSetup(); Logger(`Rebuilding from peer ${peerId}`, logLevel); - const result = await replicator.replicateFrom(peerId, showResult); - safeResolve(result?.ok ?? false); + const result = await replicator.replicateFrom(peerId, showResult, true); + sessionResult = result?.ok ?? false; } catch (e) { Logger( `Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`, logLevel ); - safeResolve(false); + sessionResult = false; } finally { - replicator.clearOnSetup(); + try { + replicator.clearOnSetup(); + } finally { + operationCompleted = true; + activeSynchronisations--; + settleSession(); + } } }; @@ -122,7 +139,10 @@ export function createOpenRebuildUI( }, showResult, "P2P Rebuild", - () => safeResolve(false), + () => { + closed = true; + settleSession(); + }, true ); modal.open(); diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts new file mode 100644 index 00000000..86ad37e0 --- /dev/null +++ b/src/features/P2PSync/P2PReplicator/P2PReplicationUI.unit.spec.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const modalState = vi.hoisted(() => ({ + instances: [] as Array<{ + callback: { + onSync: (peerId: string) => Promise; + onSyncAndClose: (peerId: string) => Promise; + }; + onClosed?: () => void; + open: ReturnType; + }>, +})); + +vi.mock("@/deps.ts", () => ({ App: class {} })); + +vi.mock("./P2POpenReplicationModal", () => ({ + P2POpenReplicationModal: class { + callback; + onClosed; + open = vi.fn(); + + constructor( + _app: unknown, + _replicator: unknown, + callback: (typeof modalState.instances)[number]["callback"], + _showResult: boolean, + _title?: string, + onClosed?: () => void + ) { + this.callback = callback; + this.onClosed = onClosed; + modalState.instances.push(this); + } + }, +})); + +import { createOpenRebuildUI, createOpenReplicationUI } from "./P2PReplicationUI"; + +function createReplicator() { + return { + replicateFrom: vi.fn(async () => ({ ok: true })), + requestSynchroniseToPeer: vi.fn(async () => ({ ok: true })), + close: vi.fn(async () => undefined), + setOnSetup: vi.fn(), + clearOnSetup: vi.fn(), + } as any; +} + +describe("createOpenReplicationUI", () => { + beforeEach(() => { + modalState.instances.length = 0; + }); + + it("settles a cancelled peer-selection session when the modal closes", async () => { + const session = createOpenReplicationUI({} as any)(createReplicator())(true); + const modal = modalState.instances[0]; + + expect(modal.onClosed).toBeTypeOf("function"); + modal.onClosed?.(); + + await expect(session).resolves.toBe(false); + }); + + it("keeps repeated synchronisation inside the session boundary until the modal closes", async () => { + const replicator = createReplicator(); + const session = createOpenReplicationUI({} as any)(replicator)(true); + const modal = modalState.instances[0]; + let settled = false; + void session.finally(() => { + settled = true; + }); + + await modal.callback.onSync("peer-a"); + await Promise.resolve(); + + expect(settled).toBe(false); + await modal.callback.onSync("peer-b"); + expect(replicator.replicateFrom).toHaveBeenCalledTimes(2); + expect(replicator.requestSynchroniseToPeer).toHaveBeenCalledTimes(2); + + modal.onClosed?.(); + await expect(session).resolves.toBe(true); + }); + + it("waits for an in-flight synchronisation when the modal closes", async () => { + let finishPull!: (value: { ok: boolean }) => void; + const replicator = createReplicator(); + replicator.replicateFrom.mockImplementation( + async () => + await new Promise<{ ok: boolean }>((resolve) => { + finishPull = resolve; + }) + ); + const session = createOpenReplicationUI({} as any)(replicator)(true); + const modal = modalState.instances[0]; + let settled = false; + void session.finally(() => { + settled = true; + }); + + const synchronisation = modal.callback.onSync("peer-a"); + modal.onClosed?.(); + await Promise.resolve(); + + expect(settled).toBe(false); + + finishPull({ ok: true }); + await synchronisation; + await expect(session).resolves.toBe(true); + }); + + it("closes the P2P connection after a successful sync-and-close action", async () => { + const replicator = createReplicator(); + const session = createOpenReplicationUI({} as any)(replicator)(true); + const modal = modalState.instances[0]; + + await modal.callback.onSyncAndClose("peer-a"); + + expect(replicator.close).toHaveBeenCalledOnce(); + let settled = false; + void session.finally(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + modal.onClosed?.(); + await expect(session).resolves.toBe(true); + }); +}); + +describe("createOpenRebuildUI", () => { + beforeEach(() => { + modalState.instances.length = 0; + }); + + it("waits for an in-flight rebuild when the modal closes", async () => { + let finishPull!: (value: { ok: boolean }) => void; + const replicator = createReplicator(); + replicator.replicateFrom.mockImplementation( + async () => + await new Promise<{ ok: boolean }>((resolve) => { + finishPull = resolve; + }) + ); + const session = createOpenRebuildUI({} as any)(replicator)(true); + const modal = modalState.instances[0]; + let settled = false; + void session.finally(() => { + settled = true; + }); + + const rebuild = modal.callback.onSyncAndClose("peer-a"); + modal.onClosed?.(); + await Promise.resolve(); + + expect(settled).toBe(false); + + finishPull({ ok: true }); + await rebuild; + await expect(session).resolves.toBe(true); + expect(replicator.setOnSetup).toHaveBeenCalledOnce(); + expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true); + expect(replicator.clearOnSetup).toHaveBeenCalledOnce(); + }); + + it("does not complete Fetch when the rebuild dialogue closes without selecting a peer", async () => { + const replicator = createReplicator(); + const session = createOpenRebuildUI({} as any)(replicator)(true); + const modal = modalState.instances[0]; + + modal.onClosed?.(); + + await expect(session).resolves.toBe(false); + expect(replicator.replicateFrom).not.toHaveBeenCalled(); + expect(replicator.setOnSetup).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte index 5987746d..a89eded8 100644 --- a/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte +++ b/src/features/P2PSync/P2PReplicator/P2PReplicatorPane.svelte @@ -1,12 +1,12 @@ @@ -435,7 +453,7 @@

    Please select an active P2P remote configuration to change P2P sync targets.

    {/if} - +
    @@ -481,35 +499,34 @@ > Revoke +
    - WATCH + {translateMessage("Follow changes")}
    -
    - SYNC - -
    {:else}
    @@ -800,7 +817,7 @@ } .accepted-row { - grid-template-columns: 1fr auto auto; + grid-template-columns: 1fr auto auto auto; } .decision-label { diff --git a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts index e51cd7df..e10cfad9 100644 --- a/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts +++ b/src/features/P2PSync/P2PReplicator/P2PServerStatusPaneView.ts @@ -2,7 +2,7 @@ import { WorkspaceLeaf } from "@/deps.ts"; import { mount } from "svelte"; import { SvelteItemView } from "@/common/SvelteItemView.ts"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; -import type { P2PPaneParams } from "@lib/replication/trystero/UseP2PReplicatorResult"; +import type { P2PPaneParams } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; import P2PServerStatusPane from "./P2PServerStatusPane.svelte"; export const VIEW_TYPE_P2P_SERVER_STATUS = "p2p-server-status"; @@ -35,7 +35,7 @@ export class P2PServerStatusPaneView extends SvelteItemView { return mount(P2PServerStatusPane, { target, props: { - liveSyncReplicator: this._p2pResult.replicator, + getLiveSyncReplicator: () => this._p2pResult.replicator, core: this.core, }, }); diff --git a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte index 476fa270..b9b92a99 100644 --- a/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte +++ b/src/features/P2PSync/P2PReplicator/PeerStatusRow.svelte @@ -1,9 +1,9 @@ - - -

    - The connection to the server has been configured successfully. As the next step, the latest synchronisation data will be downloaded from the server to this device. -

    -

    - PLEASE NOTE -
    - After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised - files in this vault, conflicts may occur with the server data. -

    -
    - - Please select the button below to restart and proceed to the data fetching confirmation. - - - setResult(TYPE_APPLY)} /> - setResult(TYPE_CANCELLED)} /> - +{#if isP2P} + + +

    + {translateMessage( + "The P2P connection has been configured successfully. The initial synchronisation data must now be fetched from an online source device." + )} +

    +

    + PLEASE NOTE +
    + {translateMessage( + "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." + )} +

    +
    + + + {translateMessage("Restart this device, then choose the source device when P2P Rebuild opens.")} + + + + setResult(TYPE_APPLY)} + /> + setResult(TYPE_CANCELLED)} /> + +{:else} + + +

    + The connection to the server has been configured successfully. As the next step, the latest synchronisation data will be downloaded from the server to this device. +

    +

    + PLEASE NOTE +
    + After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised + files in this vault, conflicts may occur with the server data. +

    +
    + + Please select the button below to restart and proceed to the data fetching confirmation. + + + setResult(TYPE_APPLY)} /> + setResult(TYPE_CANCELLED)} /> + +{/if} diff --git a/src/modules/features/SetupWizard/dialogs/OutroNewUser.svelte b/src/modules/features/SetupWizard/dialogs/OutroNewUser.svelte index 031ae858..6df6f571 100644 --- a/src/modules/features/SetupWizard/dialogs/OutroNewUser.svelte +++ b/src/modules/features/SetupWizard/dialogs/OutroNewUser.svelte @@ -1,37 +1,63 @@ - - -

    - The connection to the server has been configured successfully. As the next step, the synchronisation data on the server will be built based on the current data on this device. -

    -

    - IMPORTANT -
    - After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware that - any unintended data currently on the server will be completely overwritten. -

    -
    - - Please select the button below to restart and proceed to the final confirmation. - - - setResult(TYPE_APPLY)} /> - setResult(TYPE_CANCELLED)} /> - +{#if isP2P} + + +

    {msg("Ui.SetupWizard.OutroNewP2PUser.GuidancePrimary")}

    +

    + {msg("Ui.SetupWizard.OutroNewP2PUser.Important")} +
    + {msg("Ui.SetupWizard.OutroNewP2PUser.GuidanceNotice")} +

    +
    + + {msg("Ui.SetupWizard.OutroNewP2PUser.Question")} + + + setResult(TYPE_APPLY)} + /> + setResult(TYPE_CANCELLED)} /> + +{:else} + + +

    + The connection to the server has been configured successfully. As the next step, the synchronisation data on the server will be built based on the current data on this device. +

    +

    + IMPORTANT +
    + After restarting, the data on this device will be uploaded to the server as the 'master copy'. Please be aware + that any unintended data currently on the server will be completely overwritten. +

    +
    + + Please select the button below to restart and proceed to the final confirmation. + + + setResult(TYPE_APPLY)} /> + setResult(TYPE_CANCELLED)} /> + +{/if} diff --git a/src/modules/features/SetupWizard/dialogs/PanelCouchDBCheck.svelte b/src/modules/features/SetupWizard/dialogs/PanelCouchDBCheck.svelte index 0206e9c0..39d825f6 100644 --- a/src/modules/features/SetupWizard/dialogs/PanelCouchDBCheck.svelte +++ b/src/modules/features/SetupWizard/dialogs/PanelCouchDBCheck.svelte @@ -2,23 +2,27 @@ /** * Panel to check and fix CouchDB configuration issues */ - import type { ObsidianLiveSyncSettings } from "@lib/common/types"; - import Decision from "@lib/UI/components/Decision.svelte"; - import UserDecisions from "@lib/UI/components/UserDecisions.svelte"; + import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; + import Decision from "@/modules/services/LiveSyncUI/components/Decision.svelte"; + import UserDecisions from "@/modules/services/LiveSyncUI/components/UserDecisions.svelte"; import { checkConfig, type ConfigCheckResult, type ResultError, type ResultErrorMessage } from "./utilCheckCouchDB"; + import { LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger"; + import { $msg as translateMessage } from "@/common/translation"; + import { getDialogContext } from "@/modules/services/LiveSyncUI/svelteDialog"; + import { getCouchDBServerFixConfirmation } from "./couchDBServerFixConfirmation"; type Props = { trialRemoteSetting: ObsidianLiveSyncSettings; }; const { trialRemoteSetting }: Props = $props(); + const context = getDialogContext(); let detectedIssues = $state([]); async function testAndFixSettings() { detectedIssues = []; try { const fixResults = await checkConfig(trialRemoteSetting); - console.dir(fixResults); detectedIssues = fixResults; } catch (e) { - console.error("Error during testAndFixSettings:", e); + Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-check"); detectedIssues.push({ message: `Error during testAndFixSettings: ${e}`, result: "error", classes: [] }); } } @@ -33,14 +37,23 @@ } let processing = $state(false); async function fixIssue(issue: ResultError) { + const confirmation = getCouchDBServerFixConfirmation(issue.settingKey, issue.expectedValue); + const confirmed = await context.services.confirm.askYesNoDialog(confirmation.message, { + title: confirmation.title, + defaultOption: "No", + }); + if (confirmed !== "yes") { + return; + } try { processing = true; await issue.fix(); } catch (e) { - console.error("Error during fixIssue:", e); + Logger(e, LOG_LEVEL_VERBOSE, "setup-couchdb-fix"); + } finally { + await testAndFixSettings(); + processing = false; } - await testAndFixSettings(); - processing = false; } const errorIssueCount = $derived.by(() => { return detectedIssues.filter((issue) => isErrorResult(issue)).length; @@ -64,7 +77,7 @@
    {/snippet} - +
    diff --git a/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte b/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte index 8db36e47..be4c3e78 100644 --- a/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte +++ b/src/modules/features/SetupWizard/dialogs/RebuildEverything.svelte @@ -1,15 +1,16 @@ - -This procedure will first delete all existing synchronisation data from the server. Following this, the server data - will be completely rebuilt, using the current state of your Vault on this device (including its local database) as - the single, authoritative master copy. - - You should perform this operation only in exceptional circumstances, such as when the server data is completely - corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually - large in comparison to the Vault size. - - - + {msg("Ui.SetupWizard.RebuildEverythingP2P.Guidance")} + {msg("Ui.SetupWizard.RebuildEverythingP2P.Note")} + + + {msg("Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalResetNote")} + + +{:else} + + This procedure will first delete all existing synchronisation data from the server. Following this, the server + data will be completely rebuilt, using the current state of your Vault on this device (including its local + database) as the single, authoritative master copy. - There is a way to resolve this on other devices. - Of course, we can back up the data before proceeding. - - - by resetting the remote, you will be informed on other devices. - - - + + You should perform this operation only in exceptional circumstances, such as when the server data is completely + corrupted, when changes on all other devices are no longer needed, or when the database size has become unusually + large in comparison to the Vault size. + + + + There is a way to resolve this on other devices. + Of course, we can back up the data before proceeding. + + + by resetting the remote, you will be informed on other devices. + + + +{/if}
    Have you created a backup before proceeding? @@ -103,12 +114,19 @@ - - - - - +{#if !isP2P} + + + + + +{/if} - commit()} /> + commit()} + /> setResult(TYPE_CANCEL)} /> diff --git a/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte b/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte index fcef3134..5c582c8b 100644 --- a/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte +++ b/src/modules/features/SetupWizard/dialogs/ScanQRCode.svelte @@ -1,9 +1,9 @@ -We will now proceed with the server configuration. +{translateMessage("Ui.SetupWizard.SelectNew.Guidance")} - How would you like to configure the connection to your server? + {translateMessage("Ui.SetupWizard.SelectNew.Question")} diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte index 01a299e6..e019bb79 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemote.svelte @@ -1,11 +1,12 @@ - + - Please select the type of server to which you are connecting. + {translateMessage("Ui.SetupWizard.SetupRemote.Guidance")} - - diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte index 2ee81af1..26b7ee5e 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteBucket.svelte @@ -1,23 +1,23 @@ @@ -150,6 +175,7 @@ /> We can use only Secure (HTTPS) connections on Obsidian Mobile. +{translateMessage("Enter a complete HTTP or HTTPS URL.")} - You cannot use capital letters, spaces, or special characters in the database name. And not allowed to start with an - underscore (_). + {translateMessage("CouchDB validates the database name when you connect. The name must not be empty.")} @@ -270,6 +294,11 @@ + + {translateMessage( + "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server. Use it only with a server you trust; administrator access may be required." + )} +
    @@ -281,8 +310,19 @@ Checking connection... Please wait. {:else} - checkAndCommit()} /> - commit()} /> + checkAndCommit()} /> + {#if setupMode === "settings"} + + {translateMessage( + "Saving without a successful connection test keeps this profile, but automatic synchronisation may fail until the connection is corrected." + )} + + commit()} + /> + {/if} cancel()} /> {/if} diff --git a/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte b/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte index 09a61766..4c6eeff2 100644 --- a/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte +++ b/src/modules/features/SetupWizard/dialogs/SetupRemoteE2EE.svelte @@ -1,21 +1,21 @@ + +
    + +
    + + diff --git a/src/modules/services/LiveSyncUI/components/Check.svelte b/src/modules/services/LiveSyncUI/components/Check.svelte new file mode 100644 index 00000000..5b33a9f1 --- /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..3a90725c --- /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..5fc71d93 --- /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..71227fea --- /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..cb6af576 --- /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..117e1760 --- /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..75eed247 --- /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..50248699 --- /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..5ebf3884 --- /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/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/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..f9197e02 100644 --- a/src/modules/services/ObsidianConfirm.ts +++ b/src/modules/services/ObsidianConfirm.ts @@ -1,18 +1,16 @@ 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 { - askYesNo, - askString, - confirmWithMessageWithWideButton, - askSelectString, - confirmWithMessage, -} from "@/modules/coreObsidian/UILib/dialogs"; +import { EVENT_PLUGIN_UNLOADED } from "@/common/events"; +import { $msg } from "@/common/translation"; +import type { Confirm, ConfirmActionLayout } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import { confirmAction, pickOne, promptPassword, promptText } from "@vrtmrz/obsidian-plugin-kit"; +import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext"; +import { confirmWithMessageWithWideButton } from "@/modules/coreObsidian/UILib/dialogs"; export class ObsidianConfirm implements Confirm { private _context: T; + private readonly dialogueController = new AbortController(); + private readonly popupKeys = new Set(); get _app(): App { return this._context.app; } @@ -21,12 +19,59 @@ export class ObsidianConfirm { + this.dialogueController.abort(); + for (const popupKey of this.popupKeys) { + this.closePopup(popupKey); + } + }); } - askYesNo(message: string): Promise<"yes" | "no"> { - return askYesNo(this._app, message); + + private get dialogueLifecycle() { + return { signal: this.dialogueController.signal }; } - askString(title: string, key: string, placeholder: string, isPassword: boolean = false): Promise { - return askString(this._app, title, key, placeholder, isPassword); + + private hasCountdown(timeout: number | undefined): timeout is number { + return timeout !== undefined && timeout > 0; + } + + async askYesNo(message: string): Promise<"yes" | "no"> { + const result = await confirmAction( + this._app, + { + title: $msg("moduleInputUIObsidian.defaultTitleConfirmation"), + message, + actions: ["yes", "no"] as const, + labels: { + yes: $msg("moduleInputUIObsidian.optionYes"), + no: $msg("moduleInputUIObsidian.optionNo"), + }, + actionLayout: "vertical", + defaultAction: "no", + sourcePath: "/", + }, + this.dialogueLifecycle + ); + return result === "yes" ? "yes" : "no"; + } + + async askString( + title: string, + key: string, + placeholder: string, + isPassword: boolean = false + ): Promise { + const prompt = isPassword ? promptPassword : promptText; + const result = await prompt( + this._app, + { + title, + label: key, + placeholder, + }, + this.dialogueLifecycle + ); + return result ?? false; } async askYesNoDialog( @@ -37,6 +82,22 @@ export class ObsidianConfirm { - return askSelectString(this._app, message, items); + async askSelectString(message: string, items: string[]): Promise { + const result = await pickOne( + this._app, + { + items, + getText: (item) => item, + placeholder: message, + }, + this.dialogueLifecycle + ); + return result ?? ""; } - askSelectStringDialogue( + async askSelectStringDialogue( message: string, buttons: T, opt: { title?: string; defaultAction: T[number]; timeout?: number } ): Promise { const defaultTitle = $msg("moduleInputUIObsidian.defaultTitleSelect"); + // Commonlib owns the transport decision, while LiveSync owns the + // concrete Obsidian view which lets users revise that decision. + const presentedMessage = + opt.title === "P2P Connection Request" + ? message.replace("Peer-to-Peer Replicator Pane", $msg("P2P Status pane")) + : message; + if (!this.hasCountdown(opt.timeout)) { + const result = await confirmAction( + this._app, + { + title: opt.title || defaultTitle, + message: presentedMessage, + actions: buttons, + actionLayout: "vertical", + defaultAction: opt.defaultAction, + sourcePath: "/", + }, + this.dialogueLifecycle + ); + return result ?? false; + } return confirmWithMessageWithWideButton( this._plugin, opt.title || defaultTitle, - message, + presentedMessage, buttons, opt.defaultAction, opt.timeout ); } - askInPopup(key: string, dialogText: string, anchorCallback: (anchor: HTMLAnchorElement) => void) { + askInPopup( + key: string, + dialogText: string, + anchorCallback: (anchor: HTMLAnchorElement) => void, + durationMs: number = 20000 + ) { + const popupKey = "popup-" + key; + this.popupKeys.add(popupKey); const fragment = createFragment((doc) => { const [beforeText, afterText] = dialogText.split("{HERE}", 2); - doc.createEl("span", undefined, (a) => { + doc.createSpan(undefined, (a) => { a.appendText(beforeText); a.appendChild( a.createEl("a", undefined, (anchor) => { anchorCallback(anchor); + anchor.addEventListener("click", () => this.closePopup(popupKey)); }) ); a.appendText(afterText); }); }); - const popupKey = "popup-" + key; scheduleTask(popupKey, 1000, async () => { + if (this.dialogueController.signal.aborted) { + this.popupKeys.delete(popupKey); + return; + } const popup = await memoIfNotExist(popupKey, () => new Notice(fragment, 0)); const isShown = popup?.noticeEl?.isShown(); if (!isShown) { memoObject(popupKey, new Notice(fragment, 0)); } - scheduleTask(popupKey + "-close", 20000, () => { - const popup = retrieveMemoObject(popupKey); - if (!popup) return; - if (popup?.noticeEl?.isShown()) { - popup.hide(); - } - disposeMemoObject(popupKey); - }); + scheduleTask(popupKey + "-close", durationMs, () => this.closePopup(popupKey)); }); } - confirmWithMessage( + private closePopup(popupKey: string) { + const popup = retrieveMemoObject(popupKey); + if (!popup) { + this.popupKeys.delete(popupKey); + return; + } + if (popup.noticeEl?.isShown()) { + popup.hide(); + } + disposeMemoObject(popupKey); + this.popupKeys.delete(popupKey); + } + + async confirmWithMessage( title: string, contentMd: string, buttons: string[], defaultAction: (typeof buttons)[number], - timeout?: number + timeout?: number, + actionLayout?: ConfirmActionLayout ): Promise<(typeof buttons)[number] | false> { - return confirmWithMessage(this._plugin, title, contentMd, buttons, defaultAction, timeout); + if (this.hasCountdown(timeout)) { + return confirmWithMessageWithWideButton(this._plugin, title, contentMd, buttons, defaultAction, timeout); + } + const result = await confirmAction( + this._app, + { + title, + message: contentMd, + actions: buttons, + defaultAction, + sourcePath: "/", + actionLayout: actionLayout ?? "vertical", + }, + this.dialogueLifecycle + ); + return result ?? false; } } diff --git a/src/modules/services/ObsidianConfirm.unit.spec.ts b/src/modules/services/ObsidianConfirm.unit.spec.ts new file mode 100644 index 00000000..afb7e3a0 --- /dev/null +++ b/src/modules/services/ObsidianConfirm.unit.spec.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + confirmAction: vi.fn(), + pickOne: vi.fn(), + promptPassword: vi.fn(), + promptText: vi.fn(), + legacyAskSelectString: vi.fn(), + legacyAskString: vi.fn(), + legacyAskYesNo: vi.fn(), + legacyConfirm: vi.fn(), + legacyWideConfirm: vi.fn(), +})); + +vi.mock("@vrtmrz/obsidian-plugin-kit", () => ({ + confirmAction: mocks.confirmAction, + pickOne: mocks.pickOne, + promptPassword: mocks.promptPassword, + promptText: mocks.promptText, +})); + +vi.mock("@/modules/coreObsidian/UILib/dialogs", () => ({ + askSelectString: mocks.legacyAskSelectString, + askString: mocks.legacyAskString, + askYesNo: mocks.legacyAskYesNo, + confirmWithMessage: mocks.legacyConfirm, + confirmWithMessageWithWideButton: mocks.legacyWideConfirm, +})); + +vi.mock("@/deps", () => ({ + Notice: class {}, +})); + +import { EVENT_PLUGIN_UNLOADED } from "@/common/events"; +import { memoObject, retrieveMemoObject } from "@/common/utils"; +import { createLiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; +import { ObsidianConfirm } from "./ObsidianConfirm"; +import type { ObsidianServiceContext } from "./ObsidianServiceContext"; + +function createConfirm() { + const app = { id: "app" }; + const plugin = { app }; + const events = createLiveSyncEventHub(); + const context = { app, plugin, events } as unknown as ObsidianServiceContext; + return { confirm: new ObsidianConfirm(context), events, app, plugin }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ObsidianConfirm Fancy Kit adapter", () => { + it("uses owner-bound Kit prompts and preserves cancellation and empty input", async () => { + const { confirm, app } = createConfirm(); + mocks.promptText.mockResolvedValueOnce(null); + mocks.promptPassword.mockResolvedValueOnce(""); + + await expect(confirm.askString("Name", "Device name", "New Remote")).resolves.toBe(false); + await expect(confirm.askString("Secret", "Passphrase", "Enter it", true)).resolves.toBe(""); + + expect(mocks.promptText).toHaveBeenCalledWith( + app, + { + title: "Name", + label: "Device name", + placeholder: "New Remote", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.promptPassword).toHaveBeenCalledWith( + app, + { + title: "Secret", + label: "Passphrase", + placeholder: "Enter it", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyAskString).not.toHaveBeenCalled(); + }); + + it("uses Kit for untimed yes/no and typed selection while preserving dismissed results", async () => { + const { confirm, app } = createConfirm(); + mocks.confirmAction.mockResolvedValueOnce("yes"); + mocks.pickOne.mockResolvedValueOnce("Beta").mockResolvedValueOnce(null); + + await expect(confirm.askYesNo("Continue?")).resolves.toBe("yes"); + await expect(confirm.askSelectString("Target", ["Alpha", "Beta"])).resolves.toBe("Beta"); + await expect(confirm.askSelectString("Target", ["Alpha"])).resolves.toBe(""); + + expect(mocks.confirmAction).toHaveBeenCalledWith( + app, + expect.objectContaining({ + message: "Continue?", + actions: ["yes", "no"], + actionLayout: "vertical", + defaultAction: "no", + }), + { signal: expect.any(AbortSignal) } + ); + expect(mocks.pickOne).toHaveBeenCalledWith( + app, + expect.objectContaining({ + items: ["Alpha", "Beta"], + getText: expect.any(Function), + }), + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyAskYesNo).not.toHaveBeenCalled(); + expect(mocks.legacyAskSelectString).not.toHaveBeenCalled(); + }); + + it("keeps untimed and countdown action dialogues vertically stacked", async () => { + const { confirm, app, plugin } = createConfirm(); + mocks.confirmAction.mockResolvedValueOnce("Apply").mockResolvedValueOnce("Yes"); + mocks.legacyWideConfirm.mockResolvedValueOnce("Cancel").mockResolvedValueOnce("No"); + + await expect(confirm.confirmWithMessage("Review", "**Apply?**", ["Apply", "Cancel"], "Cancel")).resolves.toBe( + "Apply" + ); + await expect(confirm.askYesNoDialog("Continue?", { title: "Question", defaultOption: "Yes" })).resolves.toBe( + "yes" + ); + await expect(confirm.confirmWithMessage("Timed", "Wait", ["Apply", "Cancel"], "Cancel", 30)).resolves.toBe( + "Cancel" + ); + await expect(confirm.askYesNoDialog("Timed?", { defaultOption: "No", timeout: 10 })).resolves.toBe("no"); + + expect(mocks.confirmAction).toHaveBeenNthCalledWith( + 1, + app, + { + title: "Review", + message: "**Apply?**", + actions: ["Apply", "Cancel"], + actionLayout: "vertical", + defaultAction: "Cancel", + sourcePath: "/", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.confirmAction).toHaveBeenNthCalledWith( + 2, + app, + expect.objectContaining({ + title: "Question", + message: "Continue?", + actions: ["Yes", "No"], + actionLayout: "vertical", + defaultAction: "Yes", + }), + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith( + 1, + plugin, + "Timed", + "Wait", + ["Apply", "Cancel"], + "Cancel", + 30 + ); + expect(mocks.legacyWideConfirm).toHaveBeenNthCalledWith( + 2, + plugin, + expect.any(String), + "Timed?", + expect.any(Array), + expect.any(String), + 10 + ); + expect(mocks.legacyConfirm).not.toHaveBeenCalled(); + }); + + it("uses Kit for untimed multi-action selection and keeps timed wide actions on the countdown dialogue", async () => { + const { confirm, app, plugin } = createConfirm(); + const actions = ["Apply now", "Review later"] as const; + mocks.confirmAction.mockResolvedValueOnce("Review later"); + mocks.legacyWideConfirm.mockResolvedValueOnce("Apply now"); + + await expect( + confirm.askSelectStringDialogue("Choose the next step", actions, { + title: "Next step", + defaultAction: "Review later", + }) + ).resolves.toBe("Review later"); + await expect( + confirm.askSelectStringDialogue("Choose before the timer expires", actions, { + title: "Timed step", + defaultAction: "Apply now", + timeout: 15, + }) + ).resolves.toBe("Apply now"); + + expect(mocks.confirmAction).toHaveBeenCalledWith( + app, + { + title: "Next step", + message: "Choose the next step", + actions, + actionLayout: "vertical", + defaultAction: "Review later", + sourcePath: "/", + }, + { signal: expect.any(AbortSignal) } + ); + expect(mocks.legacyWideConfirm).toHaveBeenCalledWith( + plugin, + "Timed step", + "Choose before the timer expires", + actions, + "Apply now", + 15 + ); + }); + + it("refers P2P connection approvals to the current P2P Status pane", async () => { + const { confirm, plugin } = createConfirm(); + const actions = ["Accept", "Ignore"] as const; + mocks.legacyWideConfirm.mockResolvedValueOnce("Ignore"); + + await confirm.askSelectStringDialogue( + "You can revoke your decision from the Peer-to-Peer Replicator Pane.", + actions, + { + title: "P2P Connection Request", + defaultAction: "Ignore", + timeout: 30, + } + ); + + expect(mocks.legacyWideConfirm).toHaveBeenCalledWith( + plugin, + "P2P Connection Request", + "You can revoke your decision from the P2P Status pane.", + actions, + "Ignore", + 30 + ); + }); + + it("dismisses an open Kit dialogue when the plug-in unload event is emitted", async () => { + const { confirm, events } = createConfirm(); + let observedSignal: AbortSignal | undefined; + mocks.confirmAction.mockImplementation( + (_app, _options, lifecycle: { signal: AbortSignal }) => + new Promise((resolve) => { + observedSignal = lifecycle.signal; + lifecycle.signal.addEventListener("abort", () => resolve(null), { once: true }); + }) + ); + + const result = confirm.confirmWithMessage("Review", "Message", ["OK"], "OK"); + expect(observedSignal?.aborted).toBe(false); + + events.emitEvent(EVENT_PLUGIN_UNLOADED); + + await expect(result).resolves.toBe(false); + expect(observedSignal?.aborted).toBe(true); + }); + + it("closes an active Notice when the plug-in unload event is emitted", () => { + const { confirm, events } = createConfirm(); + const popupKey = "popup-remote-size-exceeded"; + const popup = { + hide: vi.fn(), + noticeEl: { isShown: vi.fn(() => true) }, + }; + memoObject(popupKey, popup); + (confirm as unknown as { popupKeys: Set }).popupKeys.add(popupKey); + + events.emitEvent(EVENT_PLUGIN_UNLOADED); + + expect(popup.hide).toHaveBeenCalledOnce(); + expect(retrieveMemoObject(popupKey)).toBe(false); + }); +}); 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/ObsidianNoticeGroups.ts b/src/modules/services/ObsidianNoticeGroups.ts new file mode 100644 index 00000000..a59105ec --- /dev/null +++ b/src/modules/services/ObsidianNoticeGroups.ts @@ -0,0 +1,51 @@ +import { KeyedNoticeGroupManager } from "@vrtmrz/obsidian-plugin-kit/notice"; + +export interface ObsidianNoticeGroupItem { + message: string; + action?: { + label: string; + onSelect: () => void; + }; +} + +interface KeyedNoticeGroupDriver { + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): unknown; + finish(groupKey: string, options?: { durationMs?: number | false }): boolean; + removeItem(groupKey: string, itemKey: string): boolean; + hide(groupKey: string): boolean; + dispose(): void; +} + +/** Obsidian-owned interactive Notice capability exposed through the application Context. */ +export interface ObsidianNoticeGroups { + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void; + finish(groupKey: string, options?: { durationMs?: number | false }): boolean; + removeItem(groupKey: string, itemKey: string): boolean; + hide(groupKey: string): boolean; + dispose(): void; +} + +/** Adapts Fancy Kit grouped Notices without exposing Obsidian Notice instances to features. */ +export class ObsidianNoticeGroupManager implements ObsidianNoticeGroups { + constructor(private readonly manager: KeyedNoticeGroupDriver = new KeyedNoticeGroupManager()) {} + + setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void { + this.manager.setItem(groupKey, itemKey, item); + } + + finish(groupKey: string, options?: { durationMs?: number | false }): boolean { + return this.manager.finish(groupKey, options); + } + + removeItem(groupKey: string, itemKey: string): boolean { + return this.manager.removeItem(groupKey, itemKey); + } + + hide(groupKey: string): boolean { + return this.manager.hide(groupKey); + } + + dispose(): void { + this.manager.dispose(); + } +} diff --git a/src/modules/services/ObsidianNoticeGroups.unit.spec.ts b/src/modules/services/ObsidianNoticeGroups.unit.spec.ts new file mode 100644 index 00000000..3af2893d --- /dev/null +++ b/src/modules/services/ObsidianNoticeGroups.unit.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@vrtmrz/obsidian-plugin-kit/notice", () => ({ + KeyedNoticeGroupManager: class KeyedNoticeGroupManager {}, +})); + +import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups"; + +describe("ObsidianNoticeGroupManager", () => { + it("keeps Fancy Kit rendering behind the Context-owned capability", () => { + const driver = { + setItem: vi.fn(), + finish: vi.fn(() => true), + removeItem: vi.fn(() => true), + hide: vi.fn(() => true), + dispose: vi.fn(), + }; + const groups = new ObsidianNoticeGroupManager(driver); + const item = { + message: "Complete", + action: { label: "Review", onSelect: vi.fn() }, + }; + + groups.setItem("integrity", "result", item); + expect(driver.setItem).toHaveBeenCalledWith("integrity", "result", item); + expect(groups.finish("integrity", { durationMs: 1_000 })).toBe(true); + expect(groups.removeItem("integrity", "result")).toBe(true); + expect(groups.hide("integrity")).toBe(true); + groups.dispose(); + expect(driver.dispose).toHaveBeenCalledOnce(); + }); +}); 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..694b336e --- /dev/null +++ b/src/modules/services/ObsidianServiceContext.ts @@ -0,0 +1,22 @@ +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"; +import type { ObsidianNoticeGroups } from "./ObsidianNoticeGroups"; + +/** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */ +export class ObsidianServiceContext extends ServiceContext { + app: App; + plugin: Plugin; + liveSyncPlugin: ObsidianLiveSyncPlugin; + readonly noticeGroups: ObsidianNoticeGroups; + + constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin, noticeGroups: ObsidianNoticeGroups) { + super({ events: eventHub, translate: translateLiveSyncMessage }); + this.app = app; + this.plugin = plugin; + this.liveSyncPlugin = liveSyncPlugin; + this.noticeGroups = noticeGroups; + } +} diff --git a/src/modules/services/ObsidianServiceContext.unit.spec.ts b/src/modules/services/ObsidianServiceContext.unit.spec.ts new file mode 100644 index 00000000..d95f9306 --- /dev/null +++ b/src/modules/services/ObsidianServiceContext.unit.spec.ts @@ -0,0 +1,29 @@ +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 noticeGroups = {} as Parameters[3]; + const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin, noticeGroups); + + 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); + expect(context.noticeGroups).toBe(noticeGroups); + }); +}); diff --git a/src/modules/services/ObsidianServiceHub.ts b/src/modules/services/ObsidianServiceHub.ts index 9a2a85ce..5cfd7fde 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, @@ -22,12 +22,18 @@ import { ObsidianAppLifecycleService } from "./ObsidianAppLifecycleService"; 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"; +import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups"; +import { setLang } from "@/common/translation"; // InjectableServiceHub export class ObsidianServiceHub extends InjectableServiceHub { constructor(plugin: ObsidianLiveSyncPlugin) { - const context = new ObsidianServiceContext(plugin.app, plugin, plugin); + const noticeGroups = new ObsidianNoticeGroupManager(); + const context = new ObsidianServiceContext(plugin.app, plugin, plugin, noticeGroups); const API = new ObsidianAPIService(context); const conflict = new ObsidianConflictService(context); @@ -37,11 +43,13 @@ export class ObsidianServiceHub extends InjectableServiceHub { + await screenWakeLock.dispose(); + noticeGroups.dispose(); + return true; + }); const database = new ObsidianDatabaseService(context, { + pouchDB: PouchDB, path: path, vault: vault, setting: setting, API: API, }); const keyValueDB = new ObsidianKeyValueDBService(context, { + openKeyValueDatabase: OpenKeyValueDatabase, appLifecycle: appLifecycle, databaseEvents: databaseEvents, vault: vault, @@ -74,6 +90,7 @@ export class ObsidianServiceHub extends InjectableServiceHub {} diff --git a/src/modules/services/ObsidianSettingService.ts b/src/modules/services/ObsidianSettingService.ts index f3a32eef..a934ebd1 100644 --- a/src/modules/services/ObsidianSettingService.ts +++ b/src/modules/services/ObsidianSettingService.ts @@ -1,19 +1,29 @@ -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 function normaliseObsidianSettingsData(data: unknown): ObsidianLiveSyncSettings | undefined { + if (typeof data !== "object" || data === null || Array.isArray(data)) return undefined; + return data as ObsidianLiveSyncSettings; +} 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); }); } @@ -34,6 +44,6 @@ export class ObsidianSettingService extends Se return await this.context.liveSyncPlugin.saveData(data); } protected override async loadData(): Promise { - return await this.context.liveSyncPlugin.loadData(); + return normaliseObsidianSettingsData(await this.context.liveSyncPlugin.loadData()); } } diff --git a/src/modules/services/ObsidianSettingService.unit.spec.ts b/src/modules/services/ObsidianSettingService.unit.spec.ts new file mode 100644 index 00000000..9175c931 --- /dev/null +++ b/src/modules/services/ObsidianSettingService.unit.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { normaliseObsidianSettingsData } from "./ObsidianSettingService.ts"; + +describe("normaliseObsidianSettingsData", () => { + it("maps Obsidian's missing data value to Commonlib's new-Vault input", () => { + expect(normaliseObsidianSettingsData(null)).toBeUndefined(); + }); + + it("preserves stored settings", () => { + const settings = { isConfigured: false } as ObsidianLiveSyncSettings; + + expect(normaliseObsidianSettingsData(settings)).toBe(settings); + }); +}); 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..63e353a1 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, @@ -23,6 +23,11 @@ export class SvelteDialogObsidian< super(context.app); this.initDialog(context, dependents, component, initialData); } + + override onOpen(): void { + super.onOpen(); + this.contentEl.closest(".modal-container")?.classList.add("livesync-svelte-dialog-container"); + } } export class ObsidianSvelteDialogManager extends SvelteDialogManagerBase { diff --git a/src/rabinKarpBom.unit.spec.ts b/src/rabinKarpBom.unit.spec.ts new file mode 100644 index 00000000..67e4781f --- /dev/null +++ b/src/rabinKarpBom.unit.spec.ts @@ -0,0 +1,18 @@ +import { splitPiecesRabinKarp } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/chunks"; +import { describe, expect, it } from "vitest"; + +describe("Rabin-Karp text splitting", () => { + it("preserves U+FEFF at the beginning of an internal chunk", async () => { + const content = `${"a".repeat(1024)}\uFEFF${"b".repeat(1024)}`; + const createChunks = await splitPiecesRabinKarp(new Blob([content], { type: "text/plain" }), 1024, true, 1024); + const chunks: string[] = []; + + for await (const chunk of createChunks()) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(3); + expect(chunks[1].startsWith("\uFEFF")).toBe(true); + expect(chunks.join("")).toBe(content); + }); +}); diff --git a/src/serviceFeatures/compatibilityReview.ts b/src/serviceFeatures/compatibilityReview.ts new file mode 100644 index 00000000..8fc85f3e --- /dev/null +++ b/src/serviceFeatures/compatibilityReview.ts @@ -0,0 +1,186 @@ +import { fireAndForget } from "octagonal-wheels/promises"; +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { LiveSyncCore } from "@/main.ts"; +import { + COMPATIBILITY_PAUSE_SETTING_MESSAGE, + DATABASE_COMPATIBILITY_VERSION_KEY, + evaluateCompatibilityPause, + legacyDatabaseCompatibilityVersionKey, + type CompatibilityPause, +} from "@/common/databaseCompatibility.ts"; + +export type CompatibilityReviewSummaryAction = "details" | "resume" | "keep-paused" | false; +export type CompatibilityReviewDetailsAction = "back" | false; + +// Explicit flag-file recovery runs at priorities 5, 10, and 20. Present the +// compatibility review only after those operations have completed; a recovery +// handler which stops start-up also prevents this dialogue from competing with it. +export const COMPATIBILITY_REVIEW_LAYOUT_PRIORITY = 30; + +export interface CompatibilityReviewUi { + showSummary(pause: CompatibilityPause): Promise; + showDetails(pause: CompatibilityPause): Promise; + showReminder(openReview: () => void): void; + clearReminder(): void; +} + +export class CompatibilityReviewController { + private pause: CompatibilityPause | undefined; + private activeReview: Promise | undefined; + private _initialised = false; + private disposed = false; + + constructor( + private readonly core: LiveSyncCore, + private readonly ui: CompatibilityReviewUi, + private readonly currentVersion: number = VER + ) {} + + get pendingPause(): CompatibilityPause | undefined { + return this.pause; + } + + get initialised(): boolean { + return this._initialised; + } + + private readAcknowledgedVersion(): string | null { + const setting = this.core.services.setting; + const currentMarker = setting.getSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY); + if (currentMarker) return currentMarker; + + const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); + const legacyMarker = setting.getDeviceLocalConfig(legacyKey); + if (!legacyMarker) return null; + + setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, legacyMarker); + setting.deleteDeviceLocalConfig(legacyKey); + return legacyMarker; + } + + async initialise(): Promise { + if (this.disposed) return true; + const setting = this.core.services.setting; + const settings = setting.currentSettings(); + const migrationState = setting.getSettingsMigrationState(); + + // An existing unconfigured Vault cannot replicate, so a database + // compatibility pause would only compete with onboarding and persist + // a misleading sync warning. Do not acknowledge the missing marker: + // activation on a later start must evaluate the same state again. + // Genuinely new Vaults still initialise their marker below. + if (settings.isConfigured !== true && migrationState?.isNewVault !== true) { + this.pause = undefined; + this.ui.clearReminder(); + this._initialised = true; + return true; + } + + const acknowledgedVersion = this.readAcknowledgedVersion(); + const evaluation = evaluateCompatibilityPause({ + acknowledgedVersion, + currentVersion: this.currentVersion, + migrationState, + legacyReviewMessage: settings.versionUpFlash, + }); + + this.pause = evaluation.pause; + if (evaluation.initialiseAcknowledgedVersion) { + setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); + this._initialised = true; + return true; + } + if (!this.pause) { + this._initialised = true; + return true; + } + + if (settings.versionUpFlash === "") { + settings.versionUpFlash = COMPATIBILITY_PAUSE_SETTING_MESSAGE; + await setting.saveSettingData(); + } + this._initialised = true; + return true; + } + + private async acknowledge(): Promise { + if (!this.pause?.resumable) return; + const setting = this.core.services.setting; + const settings = setting.currentSettings(); + const previousMessage = settings.versionUpFlash; + settings.versionUpFlash = ""; + try { + await setting.saveSettingData(); + } catch (error) { + settings.versionUpFlash = previousMessage || COMPATIBILITY_PAUSE_SETTING_MESSAGE; + throw error; + } + + setting.setSmallConfig(DATABASE_COMPATIBILITY_VERSION_KEY, `${this.currentVersion}`); + const legacyKey = legacyDatabaseCompatibilityVersionKey(this.core.services.vault.getVaultName()); + setting.deleteDeviceLocalConfig(legacyKey); + this.pause = undefined; + this.ui.clearReminder(); + await this.core.services.control.applySettings(); + } + + private async runReview(): Promise { + while (this.pause) { + const action = await this.ui.showSummary(this.pause); + if (action === "details") { + const detailsAction = await this.ui.showDetails(this.pause); + if (detailsAction === "back") continue; + break; + } + if (action === "resume" && this.pause.resumable) { + await this.acknowledge(); + return; + } + break; + } + if (this.pause && !this.disposed) { + this.ui.showReminder(() => { + fireAndForget(() => this.openReview()); + }); + } + } + + openReview(): Promise { + if (this.disposed || !this.pause) return Promise.resolve(); + if (this.activeReview) return this.activeReview; + this.ui.clearReminder(); + this.activeReview = this.runReview().finally(() => { + this.activeReview = undefined; + }); + return this.activeReview; + } + + dispose(): void { + this.disposed = true; + this.pause = undefined; + this.ui.clearReminder(); + } +} + +export function useCompatibilityReview(core: LiveSyncCore, ui: CompatibilityReviewUi): CompatibilityReviewController { + const controller = new CompatibilityReviewController(core, ui); + core.services.appLifecycle.onSettingLoaded.addHandler(() => controller.initialise()); + core.services.appLifecycle.onLayoutReady.addHandler(() => { + fireAndForget(() => controller.openReview()); + return Promise.resolve(true); + }, COMPATIBILITY_REVIEW_LAYOUT_PRIORITY); + core.services.appLifecycle.onUnload.addHandler(() => { + controller.dispose(); + return Promise.resolve(true); + }); + core.services.API.addCommand({ + id: "livesync-review-compatibility-pause", + name: "Review why synchronisation is paused", + checkCallback: (checking) => { + if (!controller.pendingPause) return false; + if (!checking) fireAndForget(() => controller.openReview()); + return true; + }, + }); + return controller; +} diff --git a/src/serviceFeatures/compatibilityReview.unit.spec.ts b/src/serviceFeatures/compatibilityReview.unit.spec.ts new file mode 100644 index 00000000..3155477f --- /dev/null +++ b/src/serviceFeatures/compatibilityReview.unit.spec.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + COMPATIBILITY_PAUSE_SETTING_MESSAGE, + DATABASE_COMPATIBILITY_VERSION_KEY, + legacyDatabaseCompatibilityVersionKey, +} from "@/common/databaseCompatibility.ts"; +import { + CompatibilityReviewController, + type CompatibilityReviewUi, + useCompatibilityReview, +} from "./compatibilityReview.ts"; + +function migrationState(overrides: Record = {}) { + return { + sourceVersion: 2, + targetVersion: 2, + isNewVault: false, + isFromFutureSchema: false, + changed: false, + requiresSyncReview: false, + reviewReasons: [], + ...overrides, + }; +} + +function createFixture( + options: { + marker?: string | null; + legacyMarker?: string | null; + versionUpFlash?: string; + isConfigured?: boolean; + migration?: Record; + } = {} +) { + const local = new Map(); + if (options.marker !== undefined && options.marker !== null) { + local.set(DATABASE_COMPATIBILITY_VERSION_KEY, options.marker); + } + const legacyKey = legacyDatabaseCompatibilityVersionKey("Test Vault"); + if (options.legacyMarker !== undefined && options.legacyMarker !== null) { + local.set(legacyKey, options.legacyMarker); + } + const settings = { + versionUpFlash: options.versionUpFlash ?? "", + isConfigured: options.isConfigured ?? true, + }; + const saveSettingData = vi.fn().mockResolvedValue(undefined); + const applySettings = vi.fn().mockResolvedValue(true); + const setting = { + currentSettings: () => settings, + getSettingsMigrationState: () => migrationState(options.migration), + getSmallConfig: (key: string) => local.get(key) ?? "", + setSmallConfig: (key: string, value: string) => local.set(key, value), + getDeviceLocalConfig: (key: string) => local.get(key) ?? null, + deleteDeviceLocalConfig: (key: string) => local.delete(key), + saveSettingData, + }; + const core = { + services: { + setting, + vault: { getVaultName: () => "Test Vault" }, + control: { applySettings }, + }, + } as never; + const ui: CompatibilityReviewUi = { + showSummary: vi.fn().mockResolvedValue("keep-paused"), + showDetails: vi.fn().mockResolvedValue(false), + showReminder: vi.fn(), + clearReminder: vi.fn(), + }; + const controller = new CompatibilityReviewController(core, ui, 12); + return { controller, ui, local, legacyKey, settings, saveSettingData, applySettings }; +} + +describe("compatibility review controller", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("initialises the acknowledged version for a new Vault without showing a pause", async () => { + const fixture = createFixture({ marker: null, isConfigured: false, migration: { isNewVault: true } }); + + expect(fixture.controller.initialised).toBe(false); + + await expect(fixture.controller.initialise()).resolves.toBe(true); + + expect(fixture.controller.initialised).toBe(true); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12"); + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.saveSettingData).not.toHaveBeenCalled(); + }); + + it("defers a missing database marker while the Vault remains unconfigured", async () => { + const fixture = createFixture({ marker: null, isConfigured: false }); + + await expect(fixture.controller.initialise()).resolves.toBe(true); + + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.settings.versionUpFlash).toBe(""); + expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false); + expect(fixture.saveSettingData).not.toHaveBeenCalled(); + + fixture.settings.isConfigured = true; + await expect(fixture.controller.initialise()).resolves.toBe(true); + + expect(fixture.controller.pendingPause?.reasons).toContainEqual({ + source: "database-version", + state: "missing", + currentVersion: 12, + resumable: true, + }); + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.has(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe(false); + expect(fixture.saveSettingData).toHaveBeenCalledOnce(); + }); + + it("preserves preferences and advances the marker only after an upgrade review is resumed", async () => { + const fixture = createFixture({ marker: "11" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); + + await fixture.controller.initialise(); + + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + expect(fixture.saveSettingData).toHaveBeenCalledTimes(1); + + await fixture.controller.openReview(); + + expect(fixture.settings.versionUpFlash).toBe(""); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("12"); + expect(fixture.saveSettingData).toHaveBeenCalledTimes(2); + expect(fixture.applySettings).toHaveBeenCalledOnce(); + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.ui.clearReminder).toHaveBeenCalled(); + }); + + it("does not allow a downgrade pause to be resumed", async () => { + const fixture = createFixture({ marker: "13" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); + + await fixture.controller.initialise(); + await fixture.controller.openReview(); + + expect(fixture.controller.pendingPause?.resumable).toBe(false); + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("13"); + expect(fixture.applySettings).not.toHaveBeenCalled(); + expect(fixture.ui.showReminder).toHaveBeenCalledOnce(); + }); + + it("returns from details to the reason dialogue and leaves a persistent reminder", async () => { + const fixture = createFixture({ marker: "11" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValueOnce("details").mockResolvedValueOnce("keep-paused"); + vi.mocked(fixture.ui.showDetails).mockResolvedValue("back"); + + await fixture.controller.initialise(); + await fixture.controller.openReview(); + + expect(fixture.ui.showSummary).toHaveBeenCalledTimes(2); + expect(fixture.ui.showDetails).toHaveBeenCalledOnce(); + expect(fixture.ui.showReminder).toHaveBeenCalledOnce(); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + }); + + it("migrates the old Vault-scoped marker to Commonlib device-local storage", async () => { + const fixture = createFixture({ legacyMarker: "11" }); + + await fixture.controller.initialise(); + + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + expect(fixture.local.has(fixture.legacyKey)).toBe(false); + expect(fixture.controller.pendingPause).toBeDefined(); + }); + + it("restores the runtime gate if persisting an acknowledgement fails", async () => { + const fixture = createFixture({ marker: "11" }); + vi.mocked(fixture.ui.showSummary).mockResolvedValue("resume"); + await fixture.controller.initialise(); + fixture.saveSettingData.mockRejectedValueOnce(new Error("save failed")); + + await expect(fixture.controller.openReview()).rejects.toThrow("save failed"); + + expect(fixture.settings.versionUpFlash).toBe(COMPATIBILITY_PAUSE_SETTING_MESSAGE); + expect(fixture.local.get(DATABASE_COMPATIBILITY_VERSION_KEY)).toBe("11"); + expect(fixture.applySettings).not.toHaveBeenCalled(); + }); + + it("does not open a delayed review after the controller has been disposed", async () => { + const fixture = createFixture({ marker: "11" }); + await fixture.controller.initialise(); + + fixture.controller.dispose(); + await fixture.controller.openReview(); + + expect(fixture.controller.pendingPause).toBeUndefined(); + expect(fixture.ui.showSummary).not.toHaveBeenCalled(); + expect(fixture.ui.clearReminder).toHaveBeenCalledOnce(); + }); + + it("runs the review after the ordered red flag recovery handlers", () => { + const onSettingLoaded = { addHandler: vi.fn() }; + const onLayoutReady = { addHandler: vi.fn() }; + const onUnload = { addHandler: vi.fn() }; + const core = { + services: { + appLifecycle: { onSettingLoaded, onLayoutReady, onUnload }, + API: { addCommand: vi.fn() }, + }, + } as never; + const ui: CompatibilityReviewUi = { + showSummary: vi.fn(), + showDetails: vi.fn(), + showReminder: vi.fn(), + clearReminder: vi.fn(), + }; + + useCompatibilityReview(core, ui); + + expect(onLayoutReady.addHandler).toHaveBeenCalledWith(expect.any(Function), 30); + }); +}); diff --git a/src/serviceFeatures/compatibilityReviewMarkdown.ts b/src/serviceFeatures/compatibilityReviewMarkdown.ts new file mode 100644 index 00000000..c4730b3b --- /dev/null +++ b/src/serviceFeatures/compatibilityReviewMarkdown.ts @@ -0,0 +1,54 @@ +import type { CompatibilityPause, CompatibilityPauseReason } from "@/common/databaseCompatibility.ts"; + +export function compatibilityReviewSummaryMarkdown(pause: CompatibilityPause): string { + const action = !pause.resumable + ? "This installation cannot safely acknowledge the detected state. Update Self-hosted LiveSync before attempting to synchronise again." + : "Before resuming, review the compatibility details and update Self-hosted LiveSync on every device which uses this remote database."; + return `Remote synchronisation is paused on this device because its compatibility state requires attention. + +${action} + +Your automatic synchronisation preferences have not been changed. Closing this dialogue keeps synchronisation paused.`; +} + +function reasonMarkdown(reason: CompatibilityPauseReason): string { + if (reason.source === "database-version") { + if (reason.state === "upgrade") { + return `- The last acknowledged internal database version was **${reason.acknowledgedVersion}** and this installation uses **${reason.currentVersion}**.`; + } + if (reason.state === "downgrade") { + return `- This installation uses internal database version **${reason.currentVersion}**, but this device previously acknowledged newer version **${reason.acknowledgedVersion}**. An older installation must not resume synchronisation.`; + } + if (reason.state === "missing") { + return `- No previously acknowledged internal database version was found for this existing Vault. This can happen when a Vault is copied or restored, or when it is opened with a new Obsidian profile. This installation uses version **${reason.currentVersion}**. An empty local database does not mean that it is safe to resume automatically.`; + } + return `- The saved internal database version marker is invalid. This installation uses version **${reason.currentVersion}**.`; + } + if (reason.source === "settings-schema") { + if (reason.isFromFutureSchema) { + return `- The saved settings use schema **${reason.sourceVersion}**, which is newer than schema **${reason.currentVersion}** supported by this installation.`; + } + return `- The settings were migrated from schema **${reason.sourceVersion}** to **${reason.currentVersion}** and require review before synchronisation resumes.`; + } + const escapedMessage = reason.message.replace(/[\\`*_{}[\]()<>#+.!|-]/gu, "\\$&"); + return `- An earlier compatibility review remains pending: ${escapedMessage}`; +} + +export function compatibilityReviewDetailsMarkdown(pause: CompatibilityPause): string { + const resolution = !pause.resumable + ? "Install a compatible current version of Self-hosted LiveSync. This pause cannot be dismissed by the current installation." + : "After all devices have been updated, return to the compatibility review summary and explicitly resume synchronisation. The current internal version will only then be recorded as acknowledged."; + return `## Why synchronisation is paused + +${pause.reasons.map(reasonMarkdown).join("\n")} + +## What the pause changes + +- Remote replication is blocked before work begins. +- Your saved automatic synchronisation preferences remain unchanged. +- Closing either dialogue leaves the safety gate active. + +## What to do next + +${resolution}`; +} diff --git a/src/serviceFeatures/compatibilityReviewObsidian.ts b/src/serviceFeatures/compatibilityReviewObsidian.ts new file mode 100644 index 00000000..623b111d --- /dev/null +++ b/src/serviceFeatures/compatibilityReviewObsidian.ts @@ -0,0 +1,82 @@ +import { Notice } from "@/deps.ts"; +import type { Confirm } from "@vrtmrz/livesync-commonlib/compat/interfaces/Confirm"; +import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; +import type { + CompatibilityReviewDetailsAction, + CompatibilityReviewSummaryAction, + CompatibilityReviewUi, +} from "./compatibilityReview.ts"; +import { + compatibilityReviewDetailsMarkdown, + compatibilityReviewSummaryMarkdown, +} from "./compatibilityReviewMarkdown.ts"; + +const REVIEW_DETAILS = "Review compatibility details"; +const KEEP_PAUSED = "Keep synchronisation paused"; +const RESUME = "Resume synchronisation"; +const BACK = "Back to compatibility review"; + +export class ObsidianCompatibilityReviewUi implements CompatibilityReviewUi { + private reminder: Notice | undefined; + + constructor(private readonly confirm: Confirm) {} + + async showSummary(pause: CompatibilityPause): Promise { + const buttons = !pause.resumable + ? ([REVIEW_DETAILS, KEEP_PAUSED] as const) + : ([REVIEW_DETAILS, RESUME, KEEP_PAUSED] as const); + const result = await this.confirm.confirmWithMessage( + "Synchronisation paused for compatibility review", + compatibilityReviewSummaryMarkdown(pause), + [...buttons], + KEEP_PAUSED, + undefined, + "vertical" + ); + if (result === REVIEW_DETAILS) return "details"; + if (result === RESUME) return "resume"; + if (result === KEEP_PAUSED) return "keep-paused"; + return false; + } + + async showDetails(pause: CompatibilityPause): Promise { + const result = await this.confirm.confirmWithMessage( + "Compatibility review details", + compatibilityReviewDetailsMarkdown(pause), + [BACK], + BACK, + undefined, + "vertical" + ); + if (result === BACK) return "back"; + return false; + } + + showReminder(openReview: () => void): void { + this.clearReminder(); + let reminderAnchor: HTMLAnchorElement | undefined; + const fragment = createFragment((documentFragment) => { + documentFragment.createSpan({ + text: "Self-hosted LiveSync has paused remote synchronisation for compatibility review. ", + }); + documentFragment.createEl("a", { text: "Review why" }, (anchor) => { + reminderAnchor = anchor; + anchor.addEventListener("click", (event) => { + event.preventDefault(); + openReview(); + }); + }); + }); + this.reminder = new Notice(fragment, 0); + reminderAnchor?.closest(".notice")?.classList.add("livesync-compatibility-review-notice"); + } + + clearReminder(): void { + this.reminder?.hide(); + this.reminder = undefined; + } +} + +export function createObsidianCompatibilityReviewUi(confirm: Confirm): CompatibilityReviewUi { + return new ObsidianCompatibilityReviewUi(confirm); +} diff --git a/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts b/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts new file mode 100644 index 00000000..17a961a0 --- /dev/null +++ b/src/serviceFeatures/compatibilityReviewObsidian.unit.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; +import { compatibilityReviewDetailsMarkdown } from "./compatibilityReviewMarkdown.ts"; +import { ObsidianCompatibilityReviewUi } from "./compatibilityReviewObsidian.ts"; + +vi.mock("@/deps.ts", () => ({ + Notice: class { + hide() {} + }, +})); + +const resumablePause: CompatibilityPause = { + resumable: true, + reasons: [ + { + source: "database-version", + state: "upgrade", + acknowledgedVersion: 11, + currentVersion: 12, + resumable: true, + }, + ], +}; + +describe("Obsidian compatibility review", () => { + it("explains why a configured Vault can be missing its device-local acknowledgement", async () => { + const pause: CompatibilityPause = { + resumable: true, + reasons: [ + { + source: "database-version", + state: "missing", + currentVersion: 12, + resumable: true, + }, + ], + }; + + const details = compatibilityReviewDetailsMarkdown(pause); + expect(details).toContain("copied or restored"); + expect(details).toContain("new Obsidian profile"); + expect(details).toContain("does not mean that it is safe to resume automatically"); + }); + + it("offers the generic resume action in a vertical action dialogue", async () => { + const confirmWithMessage = vi.fn().mockResolvedValue("Resume synchronisation"); + const ui = new ObsidianCompatibilityReviewUi({ confirmWithMessage } as never); + + await expect(ui.showSummary(resumablePause)).resolves.toBe("resume"); + expect(confirmWithMessage).toHaveBeenCalledWith( + "Synchronisation paused for compatibility review", + expect.any(String), + ["Review compatibility details", "Resume synchronisation", "Keep synchronisation paused"], + "Keep synchronisation paused", + undefined, + "vertical" + ); + }); +}); diff --git a/src/serviceFeatures/configuredStartupLifecycle.ts b/src/serviceFeatures/configuredStartupLifecycle.ts new file mode 100644 index 00000000..fa179e69 --- /dev/null +++ b/src/serviceFeatures/configuredStartupLifecycle.ts @@ -0,0 +1,41 @@ +export interface ConfiguredStartupLifecycleRuntime { + databaseReady: boolean; + reportDatabaseNotReady(): void; + hasCompromisedChunks(): Promise; + hasIncompleteDocuments(): Promise; + waitForCompatibilityReview(): Promise; + runDoctor(): Promise; + migrateBulkSend(): Promise; +} + +export interface StartupEntryLifecycleRuntime { + configured: boolean; + inviteToOnboarding(): void; +} + +/** + * Keeps an unconfigured Vault outside database initialisation and all + * configured-only start-up work while offering an explicit setup action. + */ +export function runStartupEntryLifecycle(runtime: StartupEntryLifecycleRuntime): boolean { + if (runtime.configured) return true; + runtime.inviteToOnboarding(); + return false; +} + +/** + * Separates the inert, unconfigured startup path from checks which must run + * before an already configured device is allowed to synchronise. + */ +export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLifecycleRuntime): Promise { + if (!runtime.databaseReady) { + runtime.reportDatabaseNotReady(); + return false; + } + if (!(await runtime.hasCompromisedChunks())) return false; + if (!(await runtime.hasIncompleteDocuments())) return false; + await runtime.waitForCompatibilityReview(); + if (!(await runtime.runDoctor())) return false; + await runtime.migrateBulkSend(); + return true; +} diff --git a/src/serviceFeatures/configuredStartupLifecycle.unit.spec.ts b/src/serviceFeatures/configuredStartupLifecycle.unit.spec.ts new file mode 100644 index 00000000..9bfaff6e --- /dev/null +++ b/src/serviceFeatures/configuredStartupLifecycle.unit.spec.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { + runConfiguredStartupLifecycle, + runStartupEntryLifecycle, + type ConfiguredStartupLifecycleRuntime, +} from "./configuredStartupLifecycle"; + +function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[] } { + const events: string[] = []; + return { + events, + databaseReady: true, + reportDatabaseNotReady: vi.fn(() => events.push("database-not-ready")), + hasCompromisedChunks: vi.fn(async () => { + events.push("compromised-chunks"); + return true; + }), + hasIncompleteDocuments: vi.fn(async () => { + events.push("incomplete-documents"); + return true; + }), + waitForCompatibilityReview: vi.fn(async () => {}), + runDoctor: vi.fn(async () => { + events.push("doctor"); + return true; + }), + migrateBulkSend: vi.fn(async () => { + events.push("bulk-send"); + }), + }; +} + +describe("runConfiguredStartupLifecycle", () => { + it("runs configured checks in order before allowing initialisation", async () => { + const runtime = createRuntime(); + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true); + + expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]); + }); + + it("keeps Config Doctor behind the initial compatibility review", async () => { + const runtime = createRuntime(); + Object.assign(runtime, { + waitForCompatibilityReview: vi.fn(async () => { + runtime.events.push("compatibility-review"); + }), + }); + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true); + + expect(runtime.events).toEqual([ + "compromised-chunks", + "incomplete-documents", + "compatibility-review", + "doctor", + "bulk-send", + ]); + }); + + it("stops before onboarding or checks when the database is unavailable", async () => { + const runtime = createRuntime(); + runtime.databaseReady = false; + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false); + + expect(runtime.events).toEqual(["database-not-ready"]); + }); + + it("stops the configured sequence at the first failed check", async () => { + const runtime = createRuntime(); + vi.mocked(runtime.hasIncompleteDocuments).mockImplementation(async () => { + runtime.events.push("incomplete-documents"); + return false; + }); + + await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(false); + + expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents"]); + }); +}); + +describe("runStartupEntryLifecycle", () => { + it("offers onboarding and stops before database initialisation on an unconfigured Vault", () => { + const inviteToOnboarding = vi.fn(); + + expect( + runStartupEntryLifecycle({ + configured: false, + inviteToOnboarding, + }) + ).toBe(false); + + expect(inviteToOnboarding).toHaveBeenCalledOnce(); + }); + + it("allows a configured Vault to continue to database initialisation", () => { + const inviteToOnboarding = vi.fn(); + + expect( + runStartupEntryLifecycle({ + configured: true, + inviteToOnboarding, + }) + ).toBe(true); + + expect(inviteToOnboarding).not.toHaveBeenCalled(); + }); +}); 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..64172843 --- /dev/null +++ b/src/serviceFeatures/fileRepair.ts @@ -0,0 +1,132 @@ +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 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 + ? 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 winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted; + const databaseAndStorageDiffer = + information.storage.exists !== winnerRepresentsStoredFile || + (information.storage.exists && + winnerRepresentsStoredFile && + winner.contentMatchesStorage === false); + 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"; +} + +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 new file mode 100644 index 00000000..af12a34b --- /dev/null +++ b/src/serviceFeatures/fileRepair.unit.spec.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, vi } from "vitest"; +import { + discardLiveBranch, + 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"], + deleted: false, + 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("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(); + + 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(); + }); + + 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/src/serviceFeatures/onLayoutReady/enablei18n.ts b/src/serviceFeatures/onLayoutReady/enablei18n.ts index fe8e98a8..5a83eafb 100644 --- a/src/serviceFeatures/onLayoutReady/enablei18n.ts +++ b/src/serviceFeatures/onLayoutReady/enablei18n.ts @@ -1,26 +1,66 @@ -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 { 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"; +import { LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; -function tryGetLanguage() { - try { - // Note: 1.8.7+ is required. but it is 18, Feb., 2025. we want to fallback on earlier versions, so we catch the error here. - // eslint-disable-next-line obsidianmd/no-unsupported-api - return getLanguage(); - } catch (e) { - console.error("Failed to get Obsidian language, defaulting to 'def'", e); - return "en"; +function tryGetLanguage(onError: (error: unknown) => void) { + if (requireApiVersion("1.8.7")) { + try { + return getLanguage(); + } catch (e) { + onError(e); + } + } + return "en"; +} + +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 } }) => { +export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API, appLifecycle } }) => { // Clear missing translation handler to avoid unnecessary warnings. __onMissingTranslation(() => {}); let isChanged = false; const settings = setting.currentSettings(); if (settings.displayLanguage == "") { - const obsidianLanguage = tryGetLanguage(); + const obsidianLanguage = tryGetLanguage((error) => { + API.addLog( + `Failed to get Obsidian language; defaulting to 'en': ${String(error)}`, + LOG_LEVEL_VERBOSE, + "i18n-language" + ); + }); if ( SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting @@ -29,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/src/serviceFeatures/redFlag.simpleFetch.ts b/src/serviceFeatures/redFlag.simpleFetch.ts index 981556d6..214cad2e 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,12 +9,12 @@ 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"; 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)) { @@ -93,14 +93,14 @@ export async function askSimpleFetchMode( const msg = `We are about to retrieve the remote data. -Firstly, how shall we handle the data retrieved from this remote server? +Firstly, how shall we handle the data retrieved from this remote source? - **${SIMPLE_FETCH_STAGE1_NEWER_WINS}**: Compares the modified time of files and takes the newer one. If you have been using Self-hosted LiveSync and have made changes on multiple devices, this option may be suitable for you as it tries to merge changes based on modified time. - **${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 server? [ 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 server? 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 () => { @@ -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..67399057 100644 --- a/src/serviceFeatures/redFlag.ts +++ b/src/serviceFeatures/redFlag.ts @@ -1,20 +1,24 @@ 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/settings"; +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/remote-configurations"; +import { isP2PMainRemote } from "@/common/remoteConfiguration"; /** * Flag file handler interface, similar to target filter pattern. @@ -382,8 +386,11 @@ export function createRebuildFlagHandler( // Handle the rebuild everything scheduled operation const onScheduled = async () => { - const method = - await host.services.UI.dialogManager.openWithExplicitCancel(RebuildEverything); + const settings = host.services.setting.currentSettings(); + const method = await host.services.UI.dialogManager.openWithExplicitCancel< + RebuildEverythingResult, + { isP2P: boolean } + >(RebuildEverything, { isP2P: isP2PMainRemote(settings) }); if (method === "cancelled") { log("Rebuild everything cancelled by user.", LOG_LEVEL_NOTICE); await cleanupFlag(); @@ -391,7 +398,6 @@ export function createRebuildFlagHandler( return false; } const { extra } = method; - const settings = host.services.setting.currentSettings(); await adjustSettingToRemoteIfNeeded(host, log, extra, settings); return await processVaultInitialisation(host, log, async () => { await host.serviceModules.rebuilder.$rebuildEverything(); diff --git a/src/serviceFeatures/redFlag.unit.spec.ts b/src/serviceFeatures/redFlag.unit.spec.ts index f94103e0..de8d998c 100644 --- a/src/serviceFeatures/redFlag.unit.spec.ts +++ b/src/serviceFeatures/redFlag.unit.spec.ts @@ -1,7 +1,11 @@ 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, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; import { createFetchAllFlagHandler, createRebuildFlagHandler, @@ -18,14 +22,14 @@ 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_DETAILED, SIMPLE_FETCH_STAGE1_NEWER_WINS, SIMPLE_FETCH_STAGE1_REMOTE_WINS, SIMPLE_FETCH_STAGE2_NEWER_CLEANUP, @@ -36,9 +40,9 @@ import { askAndPerformFastSetupOnScheduledFetchAll, askSimpleFetchMode, } from "./redFlag.simpleFetch"; -import { activateRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig"; +import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations"; //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 +50,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 +163,7 @@ const createHostMock = () => { return { services: { + context: createServiceContext(), setting: settingMock, appLifecycle: appLifecycleMock, UI: uiMock, @@ -464,16 +469,19 @@ describe("Red Flag Feature", () => { expect(result).toBe(true); expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled(); expect(synchroniseAllFilesBetweenDBandStorage).toHaveBeenCalled(); + const firstPrompt = host.mocks.ui.confirm.confirmWithMessage.mock.calls[0]?.[1]; + expect(firstPrompt).toContain("data retrieved from this remote source"); + expect(firstPrompt).not.toContain("remote server"); // We can't easily check performFullScan call here because it's imported, // 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", @@ -657,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 () => { @@ -810,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); @@ -866,6 +874,20 @@ describe("Red Flag Feature", () => { }); describe("Rebuild All Flag Handler", () => { + it("identifies P2P when opening the scheduled rebuild confirmation", async () => { + const host = createHostMock(); + const log = createLoggerMock(); + host.mocks.setting.settings.remoteType = REMOTE_P2P; + host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL); + host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled"); + + await createRebuildFlagHandler(host as any, log).handle(); + + expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), { + isP2P: true, + }); + }); + it("should detect rebuild all flag using original filename", async () => { const host = createHostMock(); const log = createLoggerMock(); @@ -1455,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); @@ -1537,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/setupObsidian/qrCode.ts b/src/serviceFeatures/setupObsidian/qrCode.ts new file mode 100644 index 00000000..844719c2 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/qrCode.ts @@ -0,0 +1,79 @@ +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", + 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)) + ); + 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..61244f7c --- /dev/null +++ b/src/serviceFeatures/setupObsidian/qrCode.unit.spec.ts @@ -0,0 +1,157 @@ +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/context"; +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)); + }); + + 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/settingsReset.ts b/src/serviceFeatures/setupObsidian/settingsReset.ts new file mode 100644 index 00000000..10862195 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/settingsReset.ts @@ -0,0 +1,10 @@ +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; + +export function createEditingSettingsAfterFullReset(editingSettings: T): T { + return { ...editingSettings, ...createNewVaultSettings(), isConfigured: false }; +} + +export function createCoreSettingsAfterFullReset(): ObsidianLiveSyncSettings { + return { ...createNewVaultSettings(), isConfigured: false }; +} diff --git a/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts b/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts new file mode 100644 index 00000000..4d0a5cf6 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/settingsReset.unit.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings"; +import { createCoreSettingsAfterFullReset, createEditingSettingsAfterFullReset } from "./settingsReset.ts"; + +describe("full settings reset", () => { + it("resets the persisted settings to the recommended new-Vault values", () => { + const settings = createCoreSettingsAfterFullReset(); + expect(settings).toEqual({ + ...createNewVaultSettings(), + isConfigured: false, + }); + }); + + it("preserves settings-dialog fields while applying the new-Vault values", () => { + const editing = { + ...DEFAULT_SETTINGS, + configPassphrase: "dialog-only", + } as ObsidianLiveSyncSettings & { configPassphrase: string }; + + expect(createEditingSettingsAfterFullReset(editing)).toEqual({ + ...editing, + ...createNewVaultSettings(), + isConfigured: false, + }); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts new file mode 100644 index 00000000..3b4f2a25 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.ts @@ -0,0 +1,35 @@ +export type SetupInitialisationMode = "fetch" | "rebuild"; + +export interface SetupInitialisationScheduler { + scheduleFetch(prepareBeforeRestart?: () => Promise): Promise; + scheduleRebuild(prepareBeforeRestart?: () => Promise): Promise; +} + +/** + * Reserves the next-start initialisation operation before enabling settings. + * The scheduler owns suspension, rollback, and restart ordering. + */ +export function applySettingsWithScheduledInitialisation( + scheduler: SetupInitialisationScheduler, + mode: SetupInitialisationMode, + applySettings: () => Promise +): Promise { + return mode === "fetch" ? scheduler.scheduleFetch(applySettings) : scheduler.scheduleRebuild(applySettings); +} + +/** + * Uses Fetch only for the transition from an unconfigured device to an + * explicitly configured existing device. Ordinary edits apply immediately. + */ +export async function applySettingsAndFetchOnActivation( + scheduler: SetupInitialisationScheduler, + wasConfigured: boolean | undefined, + willBeConfigured: boolean | undefined, + applySettings: () => Promise +): Promise { + if (!wasConfigured && willBeConfigured) { + return await applySettingsWithScheduledInitialisation(scheduler, "fetch", applySettings); + } + await applySettings(); + return true; +} diff --git a/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts new file mode 100644 index 00000000..89b1bc74 --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupActivationLifecycle.unit.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applySettingsAndFetchOnActivation, + applySettingsWithScheduledInitialisation, + type SetupInitialisationScheduler, +} from "./setupActivationLifecycle"; + +function createScheduler() { + const events: string[] = []; + const scheduler: SetupInitialisationScheduler = { + scheduleFetch: vi.fn(async (prepare) => { + events.push("fetch-reserved"); + await prepare?.(); + return true; + }), + scheduleRebuild: vi.fn(async (prepare) => { + events.push("rebuild-reserved"); + await prepare?.(); + return true; + }), + }; + const applySettings = vi.fn(async () => { + events.push("settings-applied"); + }); + return { applySettings, events, scheduler }; +} + +describe("setup activation lifecycle", () => { + it.each([ + ["fetch", "fetch-reserved"], + ["rebuild", "rebuild-reserved"], + ] as const)("reserves %s before applying settings", async (mode, reservedEvent) => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsWithScheduledInitialisation(scheduler, mode, applySettings)).resolves.toBe(true); + + expect(events).toEqual([reservedEvent, "settings-applied"]); + }); + + it("reserves Fetch when existing settings activate an unconfigured device", async () => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsAndFetchOnActivation(scheduler, false, true, applySettings)).resolves.toBe(true); + + expect(events).toEqual(["fetch-reserved", "settings-applied"]); + }); + + it("applies an ordinary configured-device edit without scheduling initialisation", async () => { + const { applySettings, events, scheduler } = createScheduler(); + + await expect(applySettingsAndFetchOnActivation(scheduler, true, true, applySettings)).resolves.toBe(true); + + expect(events).toEqual(["settings-applied"]); + expect(scheduler.scheduleFetch).not.toHaveBeenCalled(); + expect(scheduler.scheduleRebuild).not.toHaveBeenCalled(); + }); + + it("does not apply settings when the scheduler cannot reserve its flag", async () => { + const { applySettings, scheduler } = createScheduler(); + vi.mocked(scheduler.scheduleFetch).mockResolvedValueOnce(false); + + await expect(applySettingsWithScheduledInitialisation(scheduler, "fetch", applySettings)).resolves.toBe(false); + + expect(applySettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts index 4eb9e425..d745a563 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.ts @@ -1,9 +1,38 @@ 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"; +import { $msg } from "@/common/translation"; + +const ONBOARDING_NOTICE_DURATION_MS = 60_000; + +export async function openOnboarding(setupManager: SetupManager) { + return await setupManager.startOnBoarding(); +} + +export function showOnboardingInvitation(host: NecessaryServices<"UI", never>, setupManager: SetupManager): void { + const message = `${$msg("Welcome to Self-hosted LiveSync")} ${$msg( + "We will now guide you through a few questions to simplify the synchronisation setup." + )} {HERE}`; + host.services.UI.confirm.askInPopup( + "initial-onboarding", + message, + (anchor) => { + anchor.href = "#"; + anchor.classList.add("sls-onboarding-invitation-action"); + anchor.textContent = $msg("Ui.SetupWizard.Invitation.Start"); + anchor.addEventListener("click", (event) => { + event.preventDefault(); + fireAndForget(() => openOnboarding(setupManager)); + }); + }, + ONBOARDING_NOTICE_DURATION_MS + ); +} export async function openSetupURI(setupManager: SetupManager) { await setupManager.onUseSetupURI(UserMode.Unknown); @@ -24,8 +53,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..bdae06ca 100644 --- a/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts +++ b/src/serviceFeatures/setupObsidian/setupManagerHandlers.unit.spec.ts @@ -1,7 +1,15 @@ 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 { openP2PSettings, openSetupURI, useSetupManagerHandlersFeature } from "./setupManagerHandlers"; +import { + EVENT_REQUEST_OPEN_P2P_SETTINGS, + EVENT_REQUEST_OPEN_SETUP_URI, +} from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; +import { + openOnboarding, + openP2PSettings, + openSetupURI, + showOnboardingInvitation, + useSetupManagerHandlersFeature, +} from "./setupManagerHandlers"; vi.mock("@/modules/features/SetupManager", () => { return { @@ -44,16 +52,79 @@ describe("setupObsidian/setupManagerHandlers", () => { expect(setupManager.onP2PManualSetup).toHaveBeenCalledWith("unknown", settings, false); }); - it("useSetupManagerHandlersFeature should register onLoaded handler that wires command and events", async () => { + it("openOnboarding should delegate to SetupManager.startOnBoarding", async () => { + const setupManager = { + startOnBoarding: vi.fn(async () => await Promise.resolve(false)), + } as any; + + await openOnboarding(setupManager); + + expect(setupManager.startOnBoarding).toHaveBeenCalledOnce(); + }); + + it("showOnboardingInvitation should wait for its fixed action link before opening onboarding", async () => { + let configureAnchor: ((anchor: HTMLAnchorElement) => void) | undefined; + const askInPopup = vi.fn( + (_key: string, _text: string, callback: (anchor: HTMLAnchorElement) => void, _durationMs?: number) => { + configureAnchor = callback; + } + ); + const host = { + services: { + UI: { confirm: { askInPopup } }, + }, + } as any; + const setupManager = { + startOnBoarding: vi.fn(async () => await Promise.resolve(false)), + } as any; + + showOnboardingInvitation(host, setupManager); + + expect(setupManager.startOnBoarding).not.toHaveBeenCalled(); + expect(askInPopup).toHaveBeenCalledWith( + "initial-onboarding", + expect.stringContaining("{HERE}"), + expect.any(Function), + 60_000 + ); + + let click: ((event: { preventDefault(): void }) => void) | undefined; + const addClass = vi.fn(); + const anchor = { + href: "", + textContent: "", + classList: { add: addClass }, + addEventListener: vi.fn((_name: string, listener: typeof click) => { + click = listener; + }), + } as unknown as HTMLAnchorElement; + configureAnchor!(anchor); + + expect(anchor.href).toBe("#"); + expect(anchor.textContent).toBe("Start setup"); + expect(addClass).toHaveBeenCalledWith("sls-onboarding-invitation-action"); + const preventDefault = vi.fn(); + click!({ preventDefault }); + await vi.waitFor(() => expect(setupManager.startOnBoarding).toHaveBeenCalledOnce()); + expect(preventDefault).toHaveBeenCalledOnce(); + }); + + 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 onEventSpy = vi.spyOn(eventHub, "onEvent"); + const events = { onEvent: vi.fn() }; const host = { services: { + context: { events }, API: { addCommand, }, + UI: { + confirm: { + askInPopup: vi.fn(), + }, + }, appLifecycle: { onLoaded: { addHandler, @@ -65,6 +136,7 @@ describe("setupObsidian/setupManagerHandlers", () => { }, } as any; const setupManager = { + startOnBoarding: vi.fn(async () => await Promise.resolve(false)), onUseSetupURI: vi.fn(async () => await Promise.resolve(true)), onP2PManualSetup: vi.fn(async () => await Promise.resolve(true)), } as any; @@ -75,13 +147,18 @@ describe("setupObsidian/setupManagerHandlers", () => { const loadedHandler = addHandler.mock.calls[0][0] as () => Promise; await loadedHandler(); + expect(addCommand).not.toHaveBeenCalledWith( + expect.objectContaining({ + id: "livesync-open-onboarding", + }) + ); expect(addCommand).toHaveBeenCalledWith( expect.objectContaining({ id: "livesync-opensetupuri", 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..a4fe4aec --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupUri.ts @@ -0,0 +1,87 @@ +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", + 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)", + 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)", + 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, () => + 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..27ec04ef --- /dev/null +++ b/src/serviceFeatures/setupObsidian/setupUri.unit.spec.ts @@ -0,0 +1,212 @@ +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/context"; +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)); + }); + + 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/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 da064924..a30f6182 100644 --- a/src/serviceFeatures/useP2PReplicatorUI.ts +++ b/src/serviceFeatures/useP2PReplicatorUI.ts @@ -1,24 +1,48 @@ 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 { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView"; +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 { P2PServerStatusPaneView, VIEW_TYPE_P2P_SERVER_STATUS, } 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"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.type"; +import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString"; + +export const LEGACY_VIEW_TYPE_P2P = "p2p-replicator"; + +class LegacyP2PStatusPaneView extends P2PServerStatusPaneView { + override getViewType() { + return LEGACY_VIEW_TYPE_P2P; + } +} + +export function hasP2PConfiguration(settings: Partial): boolean { + if ( + settings.remoteType === REMOTE_P2P || + settings.P2P_Enabled === true || + (settings.P2P_roomID ?? "").trim() !== "" || + (settings.P2P_passphrase ?? "").trim() !== "" + ) { + return true; + } + return Object.values(settings.remoteConfigurations ?? {}).some((configuration) => { + try { + return ConnectionStringParser.parse(configuration.uri).type === "p2p"; + } catch { + return false; + } + }); +} /** - * ServiceFeature: P2P Replicator lifecycle management. - * Binds a LiveSyncTrysteroReplicator to the host's lifecycle events, - * following the same middleware style as useOfflineScanner. - * - * @param viewTypeAndFactory Optional [viewType, factory] pair for registering the P2P pane view. - * When provided, also registers commands and ribbon icon via services.API. + * Obsidian-specific P2P views, commands, status collection, and ribbon wiring. + * Replicator ownership and lifecycle remain in Commonlib's + * `useP2PReplicatorFeature`; this feature only consumes its current result. */ export function useP2PReplicatorUI( @@ -43,67 +67,96 @@ 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, callback: () => void - ) => { addClass?: (name: string) => unknown } | undefined; - getPlatform: () => string; + ) => { addClass?: (name: string) => unknown; remove?: () => void } | undefined; }; // 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; }); + const p2pParams = { + get replicator() { + return getReplicator(); + }, + p2pLogCollector, + storeP2PStatusLine, + }; - // Register view, commands and ribbon if a view factory is provided - const viewType = VIEW_TYPE_P2P; - const factory = (leaf: WorkspaceLeaf) => { - return new P2PReplicatorPaneView(leaf, core, { - replicator: getReplicator(), - p2pLogCollector, - storeP2PStatusLine, - }); - }; const statusFactory = (leaf: WorkspaceLeaf) => { - return new P2PServerStatusPaneView(leaf, core, { - replicator: getReplicator(), - p2pLogCollector, - storeP2PStatusLine, - }); + return new P2PServerStatusPaneView(leaf, core, p2pParams); + }; + const legacyStatusFactory = (leaf: WorkspaceLeaf) => { + return new LegacyP2PStatusPaneView(leaf, core, p2pParams); }; - const openPane = () => api.showWindow(viewType); const openStatusPane = () => { if (api.showWindowOnRight) { return api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); } return api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS); }; - api.registerWindow(viewType, factory); + const runOpenReplication = () => { + const activeReplicator = replicator.replicator; + if (!activeReplicator) return; + const settings = host.services.setting.currentSettings(); + void host.services.replicator.runFiniteReplicationActivity( + () => activeReplicator.openReplication(settings, false, true, false), + { label: "replication" } + ); + }; + // Keep the retired view type registered only long enough to restore an + // existing workspace leaf with the current status UI. Layout-ready + // migration below rewrites it to the current type without opening a leaf. + api.registerWindow(LEGACY_VIEW_TYPE_P2P, legacyStatusFactory); api.registerWindow(VIEW_TYPE_P2P_SERVER_STATUS, statusFactory); + let ribbonElement: { addClass?: (name: string) => unknown; remove?: () => void } | undefined; + const updateRibbon = (settings: Partial) => { + if (hasP2PConfiguration(settings)) { + if (ribbonElement) return; + ribbonElement = api.addRibbonIcon("waypoints", "P2P Status", () => { + void openStatusPane(); + }); + ribbonElement?.addClass?.("livesync-ribbon-p2p-server-status"); + return; + } + ribbonElement?.remove?.(); + ribbonElement = undefined; + }; + + // Settings are loaded after onInitialise. Reading them from the earlier + // phase aborts the plug-in lifecycle before the local database can open. + host.services.appLifecycle.onSettingLoaded.addHandler(() => { + updateRibbon(host.services.setting.currentSettings()); + return Promise.resolve(true); + }); + host.services.appLifecycle.onInitialise.addHandler(() => { eventHub.onEvent(EVENT_REQUEST_OPEN_P2P, () => { - void openPane(); - }); - - api.addCommand({ - id: "open-p2p-replicator", - name: "P2P Sync : Open P2P Replicator (Old UI)", - callback: () => { - void openPane(); - }, + void openStatusPane(); }); 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({ @@ -111,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(); } - void replicator.replicator?.openReplication(settings, false, true, false); + return true; }, }); host.services.API.addCommand({ @@ -123,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(); } - void replicator.replicator?.openReplication(settings, false, true, false); + return true; }, }); @@ -135,34 +196,48 @@ 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; }, }); - // api.addRibbonIcon("waypoints", "P2P Replicator", () => { - // void openPane(); - // })?.addClass?.("livesync-ribbon-replicate-p2p"); - - api.addRibbonIcon("waypoints", "P2P Status", () => { - void openStatusPane(); - })?.addClass?.("livesync-ribbon-p2p-server-status"); - - return Promise.resolve(true); - }); - - host.services.appLifecycle.onLayoutReady.addHandler(() => { - if (api.getPlatform() !== "obsidian") { + host.services.setting.onSettingSaved?.addHandler((settings) => { + updateRibbon(settings); return Promise.resolve(true); - } - if (api.showWindowOnRight) { - void api.showWindowOnRight(VIEW_TYPE_P2P_SERVER_STATUS); - } else { - void api.showWindow(VIEW_TYPE_P2P_SERVER_STATUS); - } + }); + return Promise.resolve(true); }); - return { replicator: getReplicator(), p2pLogCollector, storeP2PStatusLine }; + + host.services.appLifecycle.onLayoutReady.addHandler(async () => { + const workspace = ( + host.services.context as { + app?: { + workspace?: { + getLeavesOfType(type: string): WorkspaceLeaf[]; + }; + }; + } + ).app?.workspace; + if (!workspace) { + return true; + } + const legacyLeaves = workspace.getLeavesOfType(LEGACY_VIEW_TYPE_P2P); + await Promise.all( + legacyLeaves.map((leaf) => + leaf.setViewState({ + type: VIEW_TYPE_P2P_SERVER_STATUS, + active: false, + }) + ) + ); + return true; + }); + return p2pParams; } diff --git a/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts new file mode 100644 index 00000000..b37e3944 --- /dev/null +++ b/src/serviceFeatures/useP2PReplicatorUI.unit.spec.ts @@ -0,0 +1,453 @@ +import { describe, expect, it, vi } from "vitest"; +import { createServiceContext } from "@vrtmrz/livesync-commonlib/context"; +import { eventHub, EVENT_REQUEST_OPEN_P2P } from "@/common/events"; + +vi.mock("@/features/P2PSync/P2PReplicator/P2PServerStatusPaneView", () => ({ + P2PServerStatusPaneView: class { + getViewType() { + return "p2p-status"; + } + }, + VIEW_TYPE_P2P_SERVER_STATUS: "p2p-status", +})); + +import { useP2PReplicatorUI } from "./useP2PReplicatorUI"; + +describe("useP2PReplicatorUI commands", () => { + it("waits for settings to load before deciding whether to show the P2P ribbon", async () => { + let initialise: (() => Promise) | undefined; + let settingLoaded: (() => Promise) | undefined; + let settings: Record | undefined; + const currentSettings = vi.fn(() => settings); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { + addHandler: vi.fn((handler) => { + settingLoaded = handler; + }), + }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings, + onSettingSaved: { addHandler: vi.fn() }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + + await expect(initialise?.()).resolves.toBe(true); + expect(currentSettings).not.toHaveBeenCalled(); + settings = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + }; + await expect(settingLoaded?.()).resolves.toBe(true); + expect(currentSettings).toHaveBeenCalledOnce(); + }); + + it("exposes a direct modal P2P replication command as finite replication activity", async () => { + const commands: Array<{ id: string; checkCallback?: (isChecking: boolean) => unknown }> = []; + let initialise: (() => Promise) | undefined; + const openReplication = vi.fn(async () => true); + const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task()); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn((command) => commands.push(command)), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "obsidian"), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + P2P_Enabled: true, + })), + }, + replicator: { runFiniteReplicationActivity }, + }, + } as any; + const p2p = { + replicator: { + server: { isServing: true }, + openReplication, + replicateFromCommand: vi.fn(), + }, + } as any; + + useP2PReplicatorUI(host, {} as any, p2p); + await initialise?.(); + commands.find((command) => command.id === "replicate-now-by-p2p")?.checkCallback?.(false); + + await vi.waitFor(() => expect(openReplication).toHaveBeenCalledOnce()); + expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), { + label: "replication", + }); + }); + + it("keeps the current replicator in the pane parameters after replacement", () => { + const first = { id: "first" }; + const second = { id: "second" }; + let current = first; + const p2p = { + get replicator() { + return current; + }, + } as any; + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "obsidian"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { currentSettings: vi.fn(() => ({ remoteType: "COUCHDB" })) }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + const paneParams = useP2PReplicatorUI(host, {} as any, p2p); + current = second; + + expect(paneParams.replicator).toBe(second); + }); + + it("retains only the current P2P status command and routes existing open requests to it", async () => { + 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); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow, + showWindowOnRight, + registerWindow: vi.fn(), + addCommand: vi.fn((command) => commands.push(command)), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "desktop"), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + remoteConfigurations: {}, + })), + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + const p2p = { replicator: undefined } as any; + + useP2PReplicatorUI(host, {} as any, p2p); + await initialise?.(); + + 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); + const showWindowOnRight = vi.fn(async () => undefined); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow, + showWindowOnRight, + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "obsidian"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { + addHandler: vi.fn((handler) => { + layoutReady = handler; + }), + }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + remoteConfigurations: {}, + })), + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + await layoutReady?.(); + + expect(showWindow).not.toHaveBeenCalled(); + expect(showWindowOnRight).not.toHaveBeenCalled(); + }); + + it("shows the ribbon only whilst a P2P configuration exists", async () => { + let initialise: (() => Promise) | undefined; + let settingLoaded: (() => Promise) | undefined; + let onSettingSaved: ((settings: unknown) => Promise) | undefined; + let currentSettings: any = { + remoteType: "COUCHDB", + remoteConfigurations: {}, + P2P_Enabled: false, + P2P_roomID: "", + P2P_passphrase: "", + }; + const ribbon = { addClass: vi.fn(), remove: vi.fn() }; + const addRibbonIcon = vi.fn(() => ribbon); + const host = { + services: { + context: createServiceContext(), + API: { + showWindow: vi.fn(async () => undefined), + showWindowOnRight: vi.fn(async () => undefined), + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon, + getPlatform: vi.fn(() => "desktop"), + }, + appLifecycle: { + onInitialise: { + addHandler: vi.fn((handler) => { + initialise = handler; + }), + }, + onSettingLoaded: { + addHandler: vi.fn((handler) => { + settingLoaded = handler; + }), + }, + onLayoutReady: { addHandler: vi.fn() }, + }, + setting: { + currentSettings: vi.fn(() => currentSettings), + onSettingSaved: { + addHandler: vi.fn((handler) => { + onSettingSaved = handler; + }), + }, + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + await initialise?.(); + await settingLoaded?.(); + expect(addRibbonIcon).not.toHaveBeenCalled(); + + currentSettings = { + ...currentSettings, + remoteConfigurations: { + peer: { + id: "peer", + name: "Peer", + uri: "sls+p2p://room?passphrase=secret", + isEncrypted: false, + }, + }, + }; + await onSettingSaved?.(currentSettings); + expect(addRibbonIcon).toHaveBeenCalledOnce(); + + await onSettingSaved?.(currentSettings); + expect(addRibbonIcon).toHaveBeenCalledOnce(); + + currentSettings = { + ...currentSettings, + remoteConfigurations: {}, + }; + await onSettingSaved?.(currentSettings); + expect(ribbon.remove).toHaveBeenCalledOnce(); + }); + + 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), + }; + const workspace = { + getLeavesOfType: vi.fn((type: string) => (type === "p2p-replicator" ? [legacyLeaf] : [])), + }; + const context = createServiceContext() as ReturnType & { + app: { workspace: typeof workspace }; + }; + context.app = { workspace }; + const showWindow = vi.fn(async () => undefined); + const showWindowOnRight = vi.fn(async () => undefined); + const host = { + services: { + context, + API: { + showWindow, + showWindowOnRight, + registerWindow: vi.fn(), + addCommand: vi.fn(), + addRibbonIcon: vi.fn(), + getPlatform: vi.fn(() => "desktop"), + }, + appLifecycle: { + onInitialise: { addHandler: vi.fn() }, + onSettingLoaded: { addHandler: vi.fn() }, + onLayoutReady: { + addHandler: vi.fn((handler) => { + layoutReady = handler; + }), + }, + }, + setting: { + currentSettings: vi.fn(() => ({ + remoteType: "COUCHDB", + remoteConfigurations: {}, + })), + }, + replicator: { runFiniteReplicationActivity: vi.fn() }, + }, + } as any; + + useP2PReplicatorUI(host, {} as any, { replicator: undefined } as any); + await layoutReady?.(); + + expect(legacyLeaf.setViewState).toHaveBeenCalledWith({ + type: "p2p-status", + active: false, + }); + expect(showWindow).not.toHaveBeenCalled(); + expect(showWindowOnRight).not.toHaveBeenCalled(); + }); +}); diff --git a/src/serviceFeatures/useReviewHarness.ts b/src/serviceFeatures/useReviewHarness.ts new file mode 100644 index 00000000..04d42755 --- /dev/null +++ b/src/serviceFeatures/useReviewHarness.ts @@ -0,0 +1,125 @@ +import { NEW_VAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings"; +import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; +import type { UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/UseP2PReplicatorResult"; +import type ObsidianLiveSyncPlugin from "@/main"; +import type { LiveSyncCore } from "@/main"; +import type { WorkspaceLeaf } from "@/deps"; +import { + ReviewHarnessController, + REVIEW_HARNESS_STATE_KEY, + type ReviewHarnessRuntime, +} from "@/features/ReviewHarness/reviewHarnessController"; +import { ReviewHarnessView, VIEW_TYPE_REVIEW_HARNESS } from "@/features/ReviewHarness/ReviewHarnessView"; +import type { ReviewHarnessScenarioResult } from "@/features/ReviewHarness/reviewHarnessContract"; +import { + REVIEW_HARNESS_FIXTURE_ROOT, + runReviewHarnessVaultRoundTrip, +} from "@/features/ReviewHarness/reviewHarnessVaultFixture"; +import type { CompatibilityReviewController } from "./compatibilityReview"; + +async function runVaultRoundTrip(plugin: ObsidianLiveSyncPlugin): Promise { + const vault = plugin.app.vault; + return runReviewHarnessVaultRoundTrip({ + confirmFixtureAccess: async () => + (await plugin.core.services.UI.confirm.askYesNoDialog( + "This scenario creates, reads, modifies, renames, and removes one owned fixture tree. Use a dedicated test Vault. Continue?", + { + title: "Review Harness: Vault fixture access", + defaultOption: "No", + } + )) === "yes", + fixtureRootExists: () => vault.getAbstractFileByPath(REVIEW_HARNESS_FIXTURE_ROOT) !== null, + createFixtureRoot: async () => { + await vault.createFolder(REVIEW_HARNESS_FIXTURE_ROOT); + }, + createFile: (path, content) => vault.create(path, content), + readFile: (file) => vault.read(file), + modifyFile: (file, content) => vault.modify(file, content), + renameFile: (file, path) => vault.rename(file, path), + filePath: (file) => file.path, + removeFixtureRoot: async () => { + const fixtureRoot = vault.getAbstractFileByPath(REVIEW_HARNESS_FIXTURE_ROOT); + if (fixtureRoot) await plugin.app.fileManager.trashFile(fixtureRoot); + }, + }); +} + +export function useReviewHarness( + core: LiveSyncCore, + plugin: ObsidianLiveSyncPlugin, + p2p: UseP2PReplicatorResult, + compatibilityReview: CompatibilityReviewController +): ReviewHarnessController { + const services = core.services; + const runtime: ReviewHarnessRuntime = { + now: () => new Date(), + getSettings: () => services.setting.currentSettings(), + getNewVaultSettings: () => NEW_VAULT_SETTINGS, + getSettingsMigrationState: () => services.setting.getSettingsMigrationState(), + isCompatibilityReviewInitialised: () => compatibilityReview.initialised, + getCompatibilityPause: () => compatibilityReview.pendingPause, + openCompatibilityReview: () => compatibilityReview.openReview(), + getP2PComposition: () => ({ + first: p2p.replicator, + second: p2p.replicator, + expectedServices: services, + }), + runVaultRoundTrip: () => runVaultRoundTrip(plugin), + readContinuation: () => services.setting.getSmallConfig(REVIEW_HARNESS_STATE_KEY), + writeContinuation: (value) => services.setting.setSmallConfig(REVIEW_HARNESS_STATE_KEY, value), + deleteContinuation: () => services.setting.deleteSmallConfig(REVIEW_HARNESS_STATE_KEY), + restart: () => services.appLifecycle.performRestart(), + reportError: (error) => services.API.addLog(error, LOG_LEVEL_NOTICE), + copyText: async (value) => { + if (!activeWindow.navigator.clipboard) throw new Error("Clipboard access is unavailable on this device."); + await activeWindow.navigator.clipboard.writeText(value); + }, + getEnvironment: () => ({ + pluginVersion: services.API.getPluginVersion(), + obsidianVersion: services.API.getAppVersion(), + platform: services.API.getPlatform(), + userAgent: activeWindow.navigator.userAgent || "unavailable", + viewport: + typeof activeWindow.innerWidth === "number" && typeof activeWindow.innerHeight === "number" + ? `${activeWindow.innerWidth}x${activeWindow.innerHeight}` + : "unavailable", + }), + }; + const controller = new ReviewHarnessController(runtime); + let continuationConsumed = false; + let registered = false; + let openAfterLayout = false; + + services.appLifecycle.onSettingLoaded.addHandler(() => { + if (!continuationConsumed) { + continuationConsumed = true; + controller.consumeContinuation(); + } + const snapshot = controller.snapshot(); + openAfterLayout = snapshot.resumedRequestId !== null || snapshot.continuationError !== null; + if (!services.setting.currentSettings().enableDebugTools || registered) return Promise.resolve(true); + + registered = true; + services.API.registerWindow( + VIEW_TYPE_REVIEW_HARNESS, + (leaf: WorkspaceLeaf) => new ReviewHarnessView(leaf, controller) + ); + services.API.addCommand({ + id: "open-review-harness", + name: "Open review harness", + callback: () => { + void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS); + }, + }); + return Promise.resolve(true); + }); + + services.appLifecycle.onLayoutReady.addHandler(() => { + if (openAfterLayout && services.setting.currentSettings().enableDebugTools) { + void services.API.showWindow(VIEW_TYPE_REVIEW_HARNESS); + } + return Promise.resolve(true); + }); + + return controller; +} diff --git a/src/serviceFeatures/useReviewHarness.unit.spec.ts b/src/serviceFeatures/useReviewHarness.unit.spec.ts new file mode 100644 index 00000000..1b3f48e4 --- /dev/null +++ b/src/serviceFeatures/useReviewHarness.unit.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CompatibilityPause } from "@/common/databaseCompatibility.ts"; +import { REVIEW_HARNESS_STATE_KEY } from "@/features/ReviewHarness/reviewHarnessController.ts"; +import { useReviewHarness } from "./useReviewHarness.ts"; + +const VIEW_TYPE_REVIEW_HARNESS = "self-hosted-livesync-review-harness"; + +vi.mock("@/features/ReviewHarness/ReviewHarnessView", () => ({ + VIEW_TYPE_REVIEW_HARNESS: "self-hosted-livesync-review-harness", + ReviewHarnessView: class {}, +})); + +function compatibilityPause(): CompatibilityPause { + return { + resumable: true, + reasons: [ + { + source: "settings-schema", + sourceVersion: 9, + currentVersion: 10, + isFromFutureSchema: false, + resumable: true, + reviewReasons: [], + }, + ], + }; +} + +function createFixture(options: { enableDebugTools?: boolean; continuation?: string } = {}) { + const settingLoadedHandlers: Array<() => Promise> = []; + const layoutReadyHandlers: Array<() => Promise> = []; + const local = new Map(); + if (options.continuation) local.set(REVIEW_HARNESS_STATE_KEY, options.continuation); + const settings = { + enableDebugTools: options.enableDebugTools ?? true, + liveSync: false, + syncOnSave: false, + syncOnEditorSave: true, + syncOnStart: false, + syncOnFileOpen: true, + syncAfterMerge: false, + periodicReplication: true, + }; + const services = {} as Record; + const replicator = { env: { services } }; + const api = { + registerWindow: vi.fn(), + addCommand: vi.fn(), + showWindow: vi.fn().mockResolvedValue(undefined), + addLog: vi.fn(), + getPluginVersion: () => "1.0.0-rc.0", + getAppVersion: () => "1.12.7", + getPlatform: () => "desktop", + }; + Object.assign(services, { + setting: { + currentSettings: () => settings, + getSettingsMigrationState: () => ({ + sourceVersion: 9, + targetVersion: 10, + isNewVault: false, + isFromFutureSchema: false, + changed: true, + requiresSyncReview: true, + reviewReasons: [], + }), + getSmallConfig: (key: string) => local.get(key) ?? "", + setSmallConfig: (key: string, value: string) => local.set(key, value), + deleteSmallConfig: (key: string) => local.delete(key), + }, + appLifecycle: { + onSettingLoaded: { + addHandler: vi.fn((handler: () => Promise) => settingLoadedHandlers.push(handler)), + }, + onLayoutReady: { + addHandler: vi.fn((handler: () => Promise) => layoutReadyHandlers.push(handler)), + }, + performRestart: vi.fn(), + }, + API: api, + UI: { + confirm: { askYesNoDialog: vi.fn().mockResolvedValue("no") }, + }, + }); + let pause: CompatibilityPause | undefined = compatibilityPause(); + const compatibilityReview = { + initialised: true, + get pendingPause() { + return pause; + }, + openReview: vi.fn(async () => { + pause = undefined; + }), + }; + const core = { services }; + const plugin = { + core, + app: { + vault: {}, + fileManager: {}, + }, + }; + + const controller = useReviewHarness(core as never, plugin as never, { replicator } as never, compatibilityReview as never); + return { + controller, + api, + settings, + local, + settingLoadedHandlers, + layoutReadyHandlers, + compatibilityReview, + }; +} + +describe("Review Harness composition", () => { + it("does not register the command or view when developer debug tools are disabled", async () => { + const fixture = createFixture({ enableDebugTools: false }); + + await fixture.settingLoadedHandlers[0](); + + expect(fixture.api.registerWindow).not.toHaveBeenCalled(); + expect(fixture.api.addCommand).not.toHaveBeenCalled(); + }); + + it("removes a one-shot continuation before reopening the Harness after layout", async () => { + const requestedAt = "2026-07-18T11:59:00.000Z"; + const continuation = JSON.stringify({ + formatVersion: 1, + requestId: `compatibility-review-${requestedAt}`, + scenarioId: "compatibility-review", + stage: "awaiting-restart", + requestedAt, + }); + const fixture = createFixture({ continuation }); + + await fixture.settingLoadedHandlers[0](); + + expect(fixture.local.has(REVIEW_HARNESS_STATE_KEY)).toBe(false); + expect(fixture.controller.snapshot().resumedRequestId).toBe(`compatibility-review-${requestedAt}`); + expect(fixture.api.registerWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS, expect.any(Function)); + + await fixture.layoutReadyHandlers[0](); + + expect(fixture.api.showWindow).toHaveBeenCalledWith(VIEW_TYPE_REVIEW_HARNESS); + }); + + it("uses the actual compatibility controller as the guided review boundary", async () => { + const fixture = createFixture(); + + await fixture.controller.runScenario("compatibility-review"); + expect(fixture.controller.snapshot().results["compatibility-review"].status).toBe("waiting-for-user"); + + await fixture.controller.openCompatibilityReview(); + + expect(fixture.compatibilityReview.openReview).toHaveBeenCalledOnce(); + expect(fixture.controller.snapshot().results["compatibility-review"].status).toBe("passed"); + }); +}); 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/FileReflectionProvenance.ts b/src/serviceModules/FileReflectionProvenance.ts new file mode 100644 index 00000000..f07ef4b6 --- /dev/null +++ b/src/serviceModules/FileReflectionProvenance.ts @@ -0,0 +1,27 @@ +import { + StoredFileReflectionProvenance, + type FileReflectionProvenanceRecord, +} from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance"; +import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils"; + +export const FILE_REFLECTION_PROVENANCE_STORE = "file-reflection-provenance-v1"; + +export type FileReflectionProvenanceStoreFactory = { + openSimpleStore(kind: string): SimpleStore; +}; + +/** + * Create the device-local record which links a Vault file to the exact + * database revision most recently reflected in that Vault. + * + * This runs during service composition, before KeyValueDB is opened. The + * returned namespaced handle is inert until its first operation; normal hosts + * complete the sequential onSettingLoaded lifecycle before Vault scanning, + * watching, or replication can invoke it. Operations are never held waiting for + * readiness; they fail on a lifecycle violation and may fail during reset. + */ +export function createFileReflectionProvenance(keyValueDB: FileReflectionProvenanceStoreFactory) { + return new StoredFileReflectionProvenance( + keyValueDB.openSimpleStore(FILE_REFLECTION_PROVENANCE_STORE) + ); +} diff --git a/src/serviceModules/FileReflectionProvenance.unit.spec.ts b/src/serviceModules/FileReflectionProvenance.unit.spec.ts new file mode 100644 index 00000000..6eadf3dc --- /dev/null +++ b/src/serviceModules/FileReflectionProvenance.unit.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SimpleStore } from "@vrtmrz/livesync-commonlib/compat/common/utils"; +import type { FileReflectionProvenanceRecord } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileReflectionProvenance"; +import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { + createFileReflectionProvenance, + FILE_REFLECTION_PROVENANCE_STORE, +} from "./FileReflectionProvenance"; + +describe("createFileReflectionProvenance", () => { + it("uses one reset-scoped host store for exact reflected revisions", async () => { + const values = new Map(); + const store = { + get: vi.fn(async (key: string) => values.get(key)), + set: vi.fn(async (key: string, value: FileReflectionProvenanceRecord) => { + values.set(key, value); + }), + delete: vi.fn(async (key: string) => { + values.delete(key); + }), + keys: vi.fn(async () => [...values.keys()]), + db: undefined, + } as unknown as SimpleStore; + const openSimpleStore = vi.fn().mockReturnValue(store); + const path = "note.md" as FilePathWithPrefix; + + const provenance = createFileReflectionProvenance({ openSimpleStore }); + expect(openSimpleStore).toHaveBeenCalledWith(FILE_REFLECTION_PROVENANCE_STORE); + await provenance.set(path, { revision: "3-displayed", observedStorageMtime: 123.456 }); + + expect(openSimpleStore).toHaveBeenCalledTimes(1); + await expect(provenance.get(path)).resolves.toEqual({ + revision: "3-displayed", + observedStorageMtime: 123.456, + }); + }); +}); 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 be527df5..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"; @@ -54,6 +54,11 @@ export class ObsidianFileSystemAdapter implements IFileSystemAdapter { + await this.vault.rename(file, newPath); + return file; + } + statFromNative(file: TFile): Promise { return Promise.resolve({ ...file.stat, type: "file" }); } 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 42ab566c..fb5fff32 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"; /** @@ -37,22 +37,16 @@ export class ObsidianVaultAdapter implements IVaultAdapter { return await this.app.vault.createBinary(path, toArrayBuffer(data), options); } - async delete(file: TFile | TFolder, force = false): Promise { - if ("trashFile" in this.app.fileManager) { - // eslint-disable-next-line obsidianmd/no-unsupported-api - return await this.app.fileManager.trashFile(file); - } - // eslint-disable-next-line obsidianmd/prefer-file-manager-trash-file -- Fallback for older versions of Obsidian without trashFile support - return await this.app.vault.delete(file, force); + async rename(file: TFile, newPath: string): Promise { + return await this.app.vault.rename(file, newPath); } - async trash(file: TFile | TFolder, force = false): Promise { - if ("trashFile" in this.app.fileManager) { - // eslint-disable-next-line obsidianmd/no-unsupported-api - return await this.app.fileManager.trashFile(file); - } - // eslint-disable-next-line obsidianmd/prefer-file-manager-trash-file -- Fallback for older versions of Obsidian without trashFile support - return await this.app.vault.trash(file, force); + async delete(file: TFile | TFolder): Promise { + return await this.app.fileManager.trashFile(file); + } + + async trash(file: TFile | TFolder): Promise { + return await this.app.fileManager.trashFile(file); } trigger(name: string, ...data: unknown[]): void { 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/styles.css b/styles.css index 84b222c2..ae2d7427 100644 --- a/styles.css +++ b/styles.css @@ -12,13 +12,26 @@ 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; } +.conflict-action-container { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + margin-top: var(--size-4-2); +} + .conflict-action-button { - margin-right: 4px; + width: 100%; + margin: 0; } .op-scrollable { @@ -138,8 +151,8 @@ div.sls-setting-menu-btn { /* width: 100%; */ } -.sls-setting-tab:hover~div.sls-setting-menu-btn, -.sls-setting-label.selected .sls-setting-tab:checked~div.sls-setting-menu-btn { +.sls-setting-tab:hover ~ div.sls-setting-menu-btn, +.sls-setting-label.selected .sls-setting-tab:checked ~ div.sls-setting-menu-btn { background-color: var(--interactive-accent); color: var(--text-on-accent); } @@ -174,9 +187,16 @@ body { /* padding: 2px; */ margin: 1px; border-radius: 4px; - background-image: linear-gradient(-45deg, - var(--sls-col-warn-stripe1) 25%, var(--sls-col-warn-stripe2) 25%, var(--sls-col-warn-stripe2) 50%, - var(--sls-col-warn-stripe1) 50%, var(--sls-col-warn-stripe1) 75%, var(--sls-col-warn-stripe2) 75%, var(--sls-col-warn-stripe2)); + background-image: linear-gradient( + -45deg, + var(--sls-col-warn-stripe1) 25%, + var(--sls-col-warn-stripe2) 25%, + var(--sls-col-warn-stripe2) 50%, + var(--sls-col-warn-stripe1) 50%, + var(--sls-col-warn-stripe1) 75%, + var(--sls-col-warn-stripe2) 75%, + var(--sls-col-warn-stripe2) + ); background-size: 30px 30px; display: flex; flex-direction: row; @@ -196,7 +216,6 @@ body { 100% { background-position: 30px 0; } - } .sls-setting-menu-buttons label { @@ -301,33 +320,99 @@ body { background-color: rgba(var(--background-modifier-error-rgb), 0.3); } -.sls-setting-disabled input[type=text], -.sls-setting-disabled input[type=number], -.sls-setting-disabled input[type=password] { +.sls-setting-disabled input[type="text"], +.sls-setting-disabled input[type="number"], +.sls-setting-disabled input[type="password"] { filter: brightness(80%); color: var(--text-muted); - } .sls-setting-hidden { display: none; } - - .sls-setting-obsolete { /* background-image: linear-gradient(-45deg, var(--sls-col-warn-stripe1) 25%, var(--sls-col-warn-stripe2) 25%, var(--sls-col-warn-stripe2) 50%, var(--sls-col-warn-stripe1) 50%, var(--sls-col-warn-stripe1) 75%, var(--sls-col-warn-stripe2) 75%, var(--sls-col-warn-stripe2)); */ - background-image: linear-gradient(-45deg, - transparent 25%, rgba(var(--background-secondary), 0.1) 25%, rgba(var(--background-secondary), 0.1) 50%, transparent 50%, transparent 75%, rgba(var(--background-secondary), 0.1) 75%, rgba(var(--background-secondary), 0.1)); + background-image: linear-gradient( + -45deg, + transparent 25%, + rgba(var(--background-secondary), 0.1) 25%, + rgba(var(--background-secondary), 0.1) 50%, + transparent 50%, + transparent 75%, + rgba(var(--background-secondary), 0.1) 75%, + rgba(var(--background-secondary), 0.1) + ); background-size: 60px 60px; } -.password-input>.setting-item-control>input { +.password-input > .setting-item-control > input { -webkit-text-security: disc; } +.sls-onboarding-invitation-action { + display: inline-flex; + min-width: 44px; + min-height: 44px; + align-items: center; + 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%; + overflow-x: clip; + padding-bottom: max(1rem, env(safe-area-inset-bottom)); +} + +.sls-review-harness .setting-item, +.sls-review-harness .setting-item-control { + flex-wrap: wrap; + max-width: 100%; +} + +.sls-review-harness .setting-item-info { + min-width: min(16rem, 100%); +} + +.sls-review-harness .setting-item-control { + gap: 0.5rem; +} + +.sls-review-harness button { + min-height: 44px; + max-width: 100%; + white-space: normal; +} + +.sls-review-harness__result, +.sls-review-harness__warning, +.sls-review-harness__privacy, +.sls-review-harness__error, +.sls-review-harness__resumed { + max-width: 100%; + overflow-wrap: anywhere; +} + +.sls-review-harness__result { + margin: 0 0 1rem; +} + +.sls-review-harness__warning, +.sls-review-harness__error { + color: var(--text-error); +} + +.sls-review-harness__resumed { + color: var(--text-success); +} + span.ls-mark-cr::after { user-select: none; content: "↲"; @@ -378,7 +463,6 @@ span.ls-mark-cr::after { } } - .livesync-status { user-select: none; pointer-events: none; @@ -401,13 +485,13 @@ span.ls-mark-cr::after { font-size: 80%; } -div.workspace-leaf-content[data-type=bases] .livesync-status { +div.workspace-leaf-content[data-type="bases"] .livesync-status { top: calc(var(--bases-header-height) + var(--header-height)); padding: 5px; padding-right: 18px; } -.is-mobile div.workspace-leaf-content[data-type=bases] .livesync-status { +.is-mobile div.workspace-leaf-content[data-type="bases"] .livesync-status { top: calc(var(--bases-header-height) + var(--view-header-height)); padding: 6px; padding-right: 18px; @@ -422,7 +506,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { .livesync-status .livesync-status-loghistory { text-align: left; opacity: 0.4; - } .livesync-status div.livesync-status-messagearea:empty { @@ -440,7 +523,6 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { margin-left: auto; } - .menu-setting-poweruser-disabled .sls-setting-poweruser { display: none; } @@ -459,7 +541,7 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { top: 2.5em; background-color: var(--background-secondary-alt); border-radius: 10px; - padding: 0.5em 1.0em; + padding: 0.5em 1em; } .active-pane .sls-setting-panel-title { @@ -476,6 +558,34 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { font-size: 0.8em; } +body.is-mobile .livesync-message-box-container { + box-sizing: border-box; + padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); + padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); + padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)); + padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); +} + +body.is-mobile .livesync-message-box-container .modal { + max-height: 100%; +} + +body.is-mobile .livesync-message-box-container button { + height: auto; + min-height: var(--input-height); + white-space: normal; +} + +body.is-mobile .livesync-compatibility-review-notice { + box-sizing: border-box; + margin-right: calc(64px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px))); + min-width: 0; + width: calc( + 100vw - 88px - var(--safe-area-inset-left, env(safe-area-inset-left, 0px)) - + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)) + ); +} + .sls-qr { display: flex; justify-content: center; @@ -488,7 +598,98 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { overflow-x: auto; white-space: pre-wrap; 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-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 { + 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); + 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); } /* Diff navigation */ @@ -615,4 +816,3 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { outline-offset: 1px; border-radius: 2px; } - 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.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/Dockerfile.runner b/test/bench-network/Dockerfile.runner new file mode 100644 index 00000000..c04677bb --- /dev/null +++ b/test/bench-network/Dockerfile.runner @@ -0,0 +1,40 @@ +# 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++ iproute2 libjpeg-turbo-progs time \ + && 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 LIVESYNC_CLI_TEST_SUPPORT=1 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-split-node.ts \ + bench-p2p.ts \ + bench-couchdb.ts \ + bench-compression.ts \ + test-p2p-sync.ts \ + test-p2p-replicator-replacement.ts \ + test-p2p-relay-disconnect.ts + +COPY test/bench-network/run-bench.sh /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/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 new file mode 100644 index 00000000..537f9b18 --- /dev/null +++ b/test/bench-network/README.md @@ -0,0 +1,315 @@ +# 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` +- `couchdb-netem-home-wifi` +- `couchdb-netem-tethering-vpn` +- `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 +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 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 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 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 + +```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 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 +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: + +```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 \ +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. + +## Data Compression benchmark + +The [Data Compression specification](../../docs/specs_data_compression.md) records the current storage contract, 1.0 decision, measured result, and follow-up optimisation candidates. This section is the benchmark runbook. + +The compression benchmark compares the exact CLI and Commonlib CouchDB path in +four configurations: E2EE off or on, each with Data Compression off or on. It +uses the normal CLI `mirror` and `sync` commands, so the recorded CouchDB +documents have passed through the current Rabin–Karp chunk splitter, optional +fflate compression, and E2EE V2 rather than a synthetic document transform. + +```bash +BENCH_COMMAND=compression \ +BENCH_COMPRESSION_REPEAT_COUNT=3 \ +BENCH_COUCHDB_RTT_MS=1 \ +docker compose -f test/bench-network/compose.yml run --build --rm bench-runner +``` + +The fixture contains current repository Markdown, PNG, JSON, and TypeScript +files; two deterministic JPEGs generated with `cjpeg`; a gzip-compressed +Markdown file; and deterministic high-entropy binary data. Every run verifies +all files after the second client has synchronised them. The JSON result under +`test/bench-network/bench-results/` records: + +- source bytes and stored chunk bytes by file kind; +- raw CouchDB external, active, and file sizes; +- request and response body bytes observed by the local HTTP proxy, including + the combined initial sync and full materialisation download; +- upload and download wall time, user and system CPU time, and maximum resident + memory for each CLI process; and +- percentage changes caused by enabling compression with E2EE both off and on. + +Use at least three repeats when making a default-setting decision. A `1 ms` +requested RTT keeps the local run focused on transform and storage costs. Run a +separate representative RTT when evaluating whether reduced request bodies +outweigh compression CPU on the intended network. HTTP byte counters cover +decoded bodies and exclude headers, while process timings include CLI start-up. +Full materialisation starts one CLI process per file and can repeat lazy chunk +fetches, so treat that phase as a CLI workflow measurement rather than a raw +download lower bound. Stored chunk size and upload request size are the more +direct transform comparisons. +The generated JPEGs are deterministic image-like fixtures, not a photographic +corpus, so broader media conclusions require a separately reviewed corpus. + +## 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 +``` + +## 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 +``` + +## 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 +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/`. +This shim currently measures the CouchDB path only. It does not shape or verify +the WebRTC P2P data path. diff --git a/test/bench-network/compose.yml b/test/bench-network/compose.yml new file mode 100644 index 00000000..2c2b2407 --- /dev/null +++ b/test/bench-network/compose.yml @@ -0,0 +1,343 @@ +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 = 65536 + + 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,mode=1777 + healthcheck: + test: ["CMD-SHELL", "nc -z 127.0.0.1 7777"] + interval: 2s + timeout: 5s + retries: 30 + + 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_healthy + 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_COMPRESSION_RESULT_ROOT: /workspace/src/apps/cli/testdeno/bench-results + BENCH_COMPRESSION_REPEAT_COUNT: ${BENCH_COMPRESSION_REPEAT_COUNT:-1} + 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_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} + 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} + LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + LIVESYNC_CLI_DEBUG: ${LIVESYNC_CLI_DEBUG:-0} + LIVESYNC_CLI_VERBOSE: ${LIVESYNC_CLI_VERBOSE:-0} + CLI_E2E_TASK: ${CLI_E2E_TASK:-test:p2p:ci} + 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 + + 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_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" + 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 + + 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_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} + BENCH_LIVESYNC_TEST_TEE: ${BENCH_LIVESYNC_TEST_TEE:-0} + 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: ../.. + 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 + +volumes: + p2p-split-work: 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" </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" <&2 + echo "Expected one of: cases, latency-sweep, compression, p2p-split-node" >&2 + exit 2 + ;; +esac diff --git a/test/contracts/serviceContext.ts b/test/contracts/serviceContext.ts new file mode 100644 index 00000000..ddfb6ce4 --- /dev/null +++ b/test/contracts/serviceContext.ts @@ -0,0 +1,78 @@ +import type { CommonlibMessageKey, 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: CommonlibMessageKey +): 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 ca321892..e8a749b8 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -1,30 +1,58 @@ # Real Obsidian E2E Runner -This directory contains the experimental real Obsidian end-to-end runner. +This directory contains the maintained real Obsidian end-to-end runner. -The current smoke runner verifies only the launch path: +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 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, -8. optionally drive a real vault or CouchDB workflow through Obsidian's own API, -9. terminate Obsidian and remove the temporary vault. +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 +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. 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. + +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. + +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_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. For an AppImage on Linux without FUSE, use the helper script: @@ -42,44 +70,133 @@ These tests are intended for local verification, not the default CI gate. Reuse ## Commands +After changing plug-in source, use the focused wrapper rather than invoking a scenario directly. It always rebuilds `main.js` before launching real Obsidian, and it builds the local CLI too when the CLI-to-Obsidian scenario needs it: + ```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. + +The principal entry points are: + +```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:runner npm run test:e2e:obsidian:install-appimage npm run test:e2e:obsidian:discover npm run test:e2e:obsidian:cli-help -- vaults verbose -npm run test:e2e:obsidian:smoke -npm run test:e2e:obsidian:vault-reflection -npm run test:e2e:obsidian:couchdb-upload -npm run test:e2e:obsidian:minio-upload -npm run test:e2e:obsidian:startup-scan -npm run test:e2e:obsidian:two-vault-sync -npm run test:e2e:obsidian:hidden-file-snippet-sync -npm run test:e2e:obsidian:customisation-sync -npm run test:e2e:obsidian:setting-markdown-export +npm run test:e2e:obsidian:upgrade-from-stable -- --transport all npm run test:e2e:obsidian:local-suite npm run test:e2e:obsidian:local-suite:services ``` -`test:e2e:obsidian:local-suite` runs `npm run build`, discovery, smoke, vault reflection, CouchDB upload, 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. +The underlying `test:e2e:obsidian:` scripts remain available for an immediate rerun against an already built, unchanged bundle. They do not build `main.js`; do not use them as the first verification after a source change. The complete local suite performs its own build. -`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. +`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: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. +`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: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: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:two-vault-sync` runs a two-vault note synchronisation workflow. It verifies note creation, update, rename, deletion, per-device target filters where one vault ignores a note that the other vault synchronises, and a separate encrypted round-trip with Path Obfuscation enabled. The optional Markdown conflict automatic merge check can be enabled with `E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true`, but it is not part of the default local suite. +`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: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. +`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. + +The mobile pass uses Obsidian's `app.emulateMobile(true)`, a 390 by 844 CSS-pixel viewport, and explicit iPhone-style safe-area insets of 47 pixels at the top and 34 pixels at the bottom. The public `@vrtmrz/obsidian-test-session` layout assertions require each modal to remain within the viewport and safe area without horizontal overflow. They also require the Obsidian Close control to remain within the safe area and provide at least a 44 by 44 CSS-pixel touch target. The runner clicks that control to verify actionability, then completes the explicit cancellation path. These simulated checks cover deterministic layout and interaction boundaries; they do not claim to reproduce a native operating-system overlay. + +`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 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. + +`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. + +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 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, 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. + +`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`: + +```bash +LIVESYNC_CLI_COMMAND='livesync-cli' npm run test:e2e:obsidian:cli-to-obsidian-sync +``` + +On Linux, a multi-architecture published Docker image can run against the local CouchDB fixture by sharing the temporary directory, using host networking, preserving the host user's file ownership, and overriding the image entrypoint so that the runner can supply its explicit database path. Images published before ARM64 support remain AMD64-only and require configured Docker emulation on an ARM host. + +```bash +LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --volume /tmp:/tmp --entrypoint node ghcr.io/vrtmrz/livesync-cli:edge /app/dist/index.cjs" \ + 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 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. + +`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` 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. + +`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 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. `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. `test:e2e:obsidian:setting-markdown-export` enables setting Markdown export, waits for the generated Markdown file in the vault, and verifies that credentials are omitted when `writeCredentialsForSettingSync=false`. +`test:e2e:obsidian:upgrade-from-stable` is the release-acceptance upgrade workflow. It installs the exact published 0.25.83 artefacts into an isolated Vault, verifies their pinned SHA-256 values, and then replaces only the plug-in artefacts with the current target while retaining the same Vault and isolated Obsidian profile. The first run downloads the old release into the ignored `_testdata/releases` cache; every later run verifies the cached bytes before use. + +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 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. + +Run the focused wrapper after source changes so that the target plug-in is rebuilt first: + +```bash +npm run test:e2e:obsidian:focused -- upgrade-from-stable --transport all --manage-services +``` + +Use `--transport couchdb` or `--transport object-storage` for a focused rerun. `--manage-services` starts and stops the required local fixture or fixtures; add `--keep-services` only when they should remain available for inspection. Set `E2E_LIVESYNC_TARGET_ARTIFACT_ROOT` to validate another already-built target directory, or `E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT` to use an explicit cache directory whose files still match the pinned release hashes. + +This workflow is deliberately excluded from `local-suite`. It downloads a published historical artefact, reuses one profile across multiple application versions, and is an expensive release-acceptance gate rather than a routine current-version scenario. P2P is also excluded because cross-version P2P interoperability is a separate physical validation boundary. + Start the local fixtures first when they are not already running: ```bash npm run test:docker-couchdb:start npm run test:docker-s3:start +npm run test:docker-p2p:start npm run test:e2e:obsidian:local-suite ``` @@ -92,6 +209,7 @@ npm run test:e2e:obsidian:local-suite:services Useful environment variables: - `OBSIDIAN_BINARY`: explicit Obsidian executable path. +- `OBSIDIAN_CLI`: explicit companion `obsidian-cli` executable path. - `E2E_OBSIDIAN_VERSION`: Obsidian AppImage version for `test:e2e:obsidian:install-appimage`; default is `1.12.7`. - `E2E_OBSIDIAN_APPIMAGE_ARCH`: AppImage architecture override, such as `arm64` or `x86_64`. - `E2E_OBSIDIAN_APPIMAGE_URL`: explicit AppImage URL override. @@ -99,13 +217,30 @@ Useful environment variables: - `E2E_OBSIDIAN_FORCE_DOWNLOAD=true`: re-download the AppImage even when it exists. - `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. +- `E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS`: timeout for each visible P2P Setup URI, peer-discovery, approval, and replication control; default is 60 seconds. +- `E2E_P2P_RELAY_URL`: signalling relay used by the real-Obsidian P2P workflow; default is the local relay at `ws://127.0.0.1:4010/`. +- `E2E_P2P_RELAY_PORT`: host port for the local P2P relay fixture; default is `4010`. +- `E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT`: CDP port for the second concurrent real Obsidian session; default is one greater than the primary port. - `E2E_OBSIDIAN_READY_TIMEOUT_MS`: plug-in readiness timeout in milliseconds. - `E2E_OBSIDIAN_CLI_READY_TIMEOUT_MS`: timeout for waiting until the vault-side Obsidian CLI exposes the plug-in catalogue. - `E2E_OBSIDIAN_CLI_TIMEOUT_MS`: timeout for each `obsidian-cli` invocation. +- `E2E_LIVESYNC_CLI_TIMEOUT_MS`: timeout for each official LiveSync CLI invocation in the CLI-to-Obsidian compatibility check; default is 60 seconds. +- `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, 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/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/couchdb.ts b/test/e2e-obsidian/runner/couchdb.ts index 753d4220..1ed61f88 100644 --- a/test/e2e-obsidian/runner/couchdb.ts +++ b/test/e2e-obsidian/runner/couchdb.ts @@ -26,6 +26,28 @@ export type CouchDbAllDocsResponse = { }>; }; +export type CouchDbLocalDocsResponse = { + rows: Array<{ + id: string; + key: string; + value: { rev: string }; + doc?: CouchDbDocument; + }>; +}; + +export type CouchDbDatabaseInfo = { + db_name: string; + doc_count: number; + doc_del_count: number; + 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) @@ -63,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, @@ -126,6 +156,43 @@ export async function createCouchDbDatabase(config: CouchDbConfig, dbName: strin } } +export async function putCouchDbDocument( + config: CouchDbConfig, + dbName: string, + document: CouchDbDocument +): Promise { + const response = await fetch(databaseUrl(config, dbName, documentSuffix(document._id)), { + method: "PUT", + headers: { + authorization: authHeader(config), + "content-type": "application/json", + }, + body: JSON.stringify(document), + }); + if (!response.ok) { + throw new Error( + `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 { const response = await fetch(databaseUrl(config, dbName), { method: "DELETE", @@ -138,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) }, @@ -150,6 +230,28 @@ export async function fetchAllCouchDbDocs(config: CouchDbConfig, dbName: string) return (await response.json()) as CouchDbAllDocsResponse; } +export async function fetchCouchDbLocalDocs(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName, "/_local_docs?include_docs=true"), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error( + `Failed to read CouchDB local documents from ${dbName}. HTTP ${response.status}: ${await response.text()}` + ); + } + return (await response.json()) as CouchDbLocalDocsResponse; +} + +export async function fetchCouchDbDatabaseInfo(config: CouchDbConfig, dbName: string): Promise { + const response = await fetch(databaseUrl(config, dbName), { + headers: { authorization: authHeader(config) }, + }); + if (!response.ok) { + throw new Error(`Failed to inspect CouchDB ${dbName}. HTTP ${response.status}: ${await response.text()}`); + } + return (await response.json()) as CouchDbDatabaseInfo; +} + export async function waitForCouchDbDocs( config: CouchDbConfig, dbName: string, 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/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts new file mode 100644 index 00000000..85f64421 --- /dev/null +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -0,0 +1,95 @@ +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { describe, expect, it, vi } from "vitest"; + +const { evalObsidianJson } = vi.hoisted(() => ({ + evalObsidianJson: vi.fn(), +})); + +vi.mock("./cli.ts", () => ({ evalObsidianJson })); + +import { + assertE2eCompatibilityMarker, + createE2eCouchDbPluginData, + prepareRemote, + waitForLiveSyncCoreReady, + type CompatibilityMarkerState, +} from "./liveSyncWorkflow.ts"; + +describe("compatibility marker persistence", () => { + it("waits for an accepted review to reach device-local storage", async () => { + const pending: CompatibilityMarkerState = { + vaultName: "fixture", + additionalSuffix: "-", + expectedStorageKey: "fixture--database-compatibility-version", + rawStorageValue: null, + serviceValue: "", + versionUpFlash: "", + }; + const persisted: CompatibilityMarkerState = { + ...pending, + rawStorageValue: `${VER}`, + serviceValue: `${VER}`, + }; + evalObsidianJson.mockResolvedValueOnce(pending).mockResolvedValueOnce(persisted); + + await expect( + assertE2eCompatibilityMarker("obsidian-cli", {}, { timeoutMs: 100, intervalMs: 0 }) + ).resolves.toEqual(persisted); + expect(evalObsidianJson).toHaveBeenCalledTimes(2); + }); +}); + +describe("configured CouchDB fixture", () => { + it("uses a current remote profile for ordinary configured fixtures", () => { + const pluginData = createE2eCouchDbPluginData({ + uri: "https://couch.example", + username: "alice", + password: "secret", + dbName: "notes", + }); + const remoteConfigurations = pluginData.remoteConfigurations as + | Record + | undefined; + + expect(remoteConfigurations).toBeDefined(); + expect(Object.keys(remoteConfigurations ?? {})).toHaveLength(1); + 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); + }); +}); + +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 ef842c30..896fb058 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -1,6 +1,12 @@ import { evalObsidianJson } from "./cli.ts"; +import { SERVICE_CONTEXT_MEMBERS } from "../../contracts/serviceContext.ts"; +import { DATABASE_COMPATIBILITY_VERSION_KEY } from "../../../src/common/databaseCompatibility.ts"; +import { CURRENT_SETTING_VERSION } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const"; +import { type ObsidianLiveSyncSettings, VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations"; import type { CouchDbConfig } from "./couchdb.ts"; import type { ObjectStorageConfig } from "./objectStorage.ts"; +import { captureObsidianDialogue, withObsidianPage } from "./ui.ts"; export type ConfiguredSettings = { isConfigured: boolean; @@ -18,6 +24,48 @@ export type ConfiguredSettings = { export type CoreReadiness = { databaseReady: boolean; appReady: boolean; + configured?: boolean; + remoteType?: string; + settingVersion?: number; + suspended?: boolean; +}; + +export type ReplicationAttempt = CoreReadiness & { + succeeded: boolean; + isOnline: boolean; + activeReplicator: string; + versionUpFlash: string; + unresolvedMessages: unknown[]; +}; + +export type CompatibilityMarkerState = { + vaultName: string; + additionalSuffix: string; + expectedStorageKey: string; + rawStorageValue: string | null; + serviceValue: string; + versionUpFlash: string; +}; + +export type CompatibilityMarkerWaitOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + +export type ResumeCompatibilityReviewOptions = { + verifyMissingDeviceMarkerExplanation?: boolean; + screenshotPrefix?: string; +}; + +export type ObsidianServiceContextContractResult = { + contextType: string; + eventResult: string[]; + translationResult: string; + hubUsesContext: boolean; + serviceContextMismatches: string[]; + appCapabilityMatches: boolean; + pluginCapabilityMatches: boolean; + liveSyncPluginCapabilityMatches: boolean; }; export type LocalDatabaseEntry = { @@ -28,28 +76,182 @@ export type LocalDatabaseEntry = { children: string[]; }; -function e2ePreferredSettingsSource(): string[] { - return [ - "liveSync:false,", - "syncOnStart:false,", - "syncOnSave:false,", - "usePluginSync:false,", - "usePluginSyncV2:true,", - "useEden:false,", - "customChunkSize:60,", - "sendChunksBulk:false,", - "sendChunksBulkMaxSize:1,", - "chunkSplitterVersion:'v3-rabin-karp',", - "readChunksOnline:true,", - "disableCheckingConfigMismatch:false,", - "enableCompression:false,", - "hashAlg:'xxhash64',", - "handleFilenameCaseSensitive:false,", - "doNotUseFixedRevisionForChunks:true,", - "E2EEAlgorithm:'v2',", - "doctorProcessedVersion:'0.25.27',", - "isConfigured:true,", - ]; +const E2E_PREFERRED_SETTINGS = { + displayLanguage: "def", + liveSync: false, + syncOnStart: false, + syncOnSave: false, + usePluginSync: false, + usePluginSyncV2: true, + useEden: false, + customChunkSize: 60, + sendChunksBulk: false, + sendChunksBulkMaxSize: 1, + chunkSplitterVersion: "v3-rabin-karp", + readChunksOnline: true, + disableCheckingConfigMismatch: false, + enableCompression: false, + hashAlg: "xxhash64", + handleFilenameCaseSensitive: false, + doNotUseFixedRevisionForChunks: true, + E2EEAlgorithm: "v2", + doctorProcessedVersion: "0.25.27", + settingVersion: CURRENT_SETTING_VERSION, + isConfigured: true, +} as const; + +export function createE2eObsidianDeviceLocalState( + vaultName: string, + additionalSuffixOfDatabaseName = "" +): Readonly> { + return { + [`${vaultName}-${additionalSuffixOfDatabaseName}-${DATABASE_COMPATIBILITY_VERSION_KEY}`]: `${VER}`, + }; +} + +export async function readE2eCompatibilityMarker( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const setting=core.services.setting;", + "const settings=setting.currentSettings();", + "const vaultName=core.services.API.getSystemVaultName();", + `const markerKey=${JSON.stringify(DATABASE_COMPATIBILITY_VERSION_KEY)};`, + "const additionalSuffix=`-${settings.additionalSuffixOfDatabaseName??''}`;", + "const expectedStorageKey=`${vaultName}${additionalSuffix}-${markerKey}`;", + "return JSON.stringify({", + "vaultName,additionalSuffix,expectedStorageKey,", + "rawStorageValue:localStorage.getItem(expectedStorageKey),", + "serviceValue:setting.getSmallConfig(markerKey),", + "versionUpFlash:settings.versionUpFlash,", + "});", + "})()", + ].join(""), + env + ); +} + +export async function assertE2eCompatibilityMarker( + cliBinary: string, + env: NodeJS.ProcessEnv, + options: CompatibilityMarkerWaitOptions = {} +): Promise { + const timeoutMs = options.timeoutMs ?? Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + const intervalMs = options.intervalMs ?? 100; + const deadline = Date.now() + timeoutMs; + let state = await readE2eCompatibilityMarker(cliBinary, env); + while (state.serviceValue !== `${VER}` && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + state = await readE2eCompatibilityMarker(cliBinary, env); + } + if (state.serviceValue !== `${VER}`) + throw new Error(`The E2E compatibility marker was not persisted before timeout: ${JSON.stringify(state)}`); + return state; +} + +export async function assertE2eCompatibilityReviewPending( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + const state = await readE2eCompatibilityMarker(cliBinary, env); + if (state.serviceValue !== "" || state.rawStorageValue !== null || state.versionUpFlash === "") { + throw new Error(`The copied-Vault compatibility review was not pending: ${JSON.stringify(state)}`); + } + return state; +} + +export async function resumeCompatibilityReview( + port: number, + options: ResumeCompatibilityReviewOptions = {} +): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + const title = "Synchronisation paused for compatibility review"; + const summaryLocator = (page: Parameters[1]>[0]) => + page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); + + if (options.screenshotPrefix) { + const summaryScreenshot = await captureObsidianDialogue( + port, + `${options.screenshotPrefix}-summary.png`, + async (page) => { + await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs }); + } + ); + console.log(`Compatibility review summary screenshot: ${summaryScreenshot}`); + } + + if (options.verifyMissingDeviceMarkerExplanation === true) { + await withObsidianPage(port, async (page) => { + const summary = summaryLocator(page); + await summary.waitFor({ state: "visible", timeout: timeoutMs }); + await summary.getByRole("button", { name: "Review compatibility details" }).click(); + }); + const detailsScreenshot = options.screenshotPrefix + ? await captureObsidianDialogue(port, `${options.screenshotPrefix}-details.png`, async (page) => { + const details = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await details.waitFor({ state: "visible", timeout: timeoutMs }); + await details.getByText("copied or restored", { exact: false }).waitFor({ + state: "visible", + timeout: timeoutMs, + }); + await details.getByText("new Obsidian profile", { exact: false }).waitFor({ + state: "visible", + timeout: timeoutMs, + }); + await details + .getByText("does not mean that it is safe to resume automatically", { exact: false }) + .waitFor({ + state: "visible", + timeout: timeoutMs, + }); + }) + : undefined; + if (detailsScreenshot) console.log(`Compatibility review details screenshot: ${detailsScreenshot}`); + await withObsidianPage(port, async (page) => { + const details = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await details.getByRole("button", { name: "Back to compatibility review" }).click(); + await summaryLocator(page).waitFor({ state: "visible", timeout: timeoutMs }); + }); + } + + await withObsidianPage(port, async (page) => { + const summary = summaryLocator(page); + await summary.waitFor({ state: "visible", timeout: timeoutMs }); + await summary.getByRole("button", { name: "Resume synchronisation" }).click(); + await summary.waitFor({ state: "hidden", timeout: timeoutMs }); + }); +} + +export function createE2eCouchDbPluginData( + settings: Pick & { dbName: string }, + overrides: Record = {} +): Record { + const pluginData = { + couchDB_URI: settings.uri, + couchDB_USER: settings.username, + couchDB_PASSWORD: settings.password, + couchDB_DBNAME: settings.dbName, + remoteType: "", + ...E2E_PREFERRED_SETTINGS, + ...overrides, + }; + upsertRemoteConfigurationInPlace(pluginData as ObsidianLiveSyncSettings, "couchdb", { + id: "e2e-couchdb", + name: "E2E CouchDB", + activate: true, + }); + return pluginData; } export function assertEqual(actual: unknown, expected: unknown, message: string): void { @@ -64,21 +266,14 @@ export async function configureCouchDb( settings: Pick & { dbName: string }, overrides: Record = {} ): Promise { + const nextSettings = createE2eCouchDbPluginData(settings, overrides); return await evalObsidianJson( cliBinary, [ "(async()=>{", "const plugin=app.plugins.plugins['obsidian-livesync'];", "const core=plugin.core;", - "const nextSettings={", - `couchDB_URI:${JSON.stringify(settings.uri)},`, - `couchDB_USER:${JSON.stringify(settings.username)},`, - `couchDB_PASSWORD:${JSON.stringify(settings.password)},`, - `couchDB_DBNAME:${JSON.stringify(settings.dbName)},`, - "remoteType:'',", - ...e2ePreferredSettingsSource(), - ...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`), - "};", + `const nextSettings=${JSON.stringify(nextSettings)};`, "await core.services.setting.applyExternalSettings(nextSettings,true);", "await core.services.control.applySettings();", "const current=core.services.setting.currentSettings();", @@ -103,25 +298,14 @@ export async function configureObjectStorage( settings: ObjectStorageConfig & { bucketPrefix: string }, overrides: Record = {} ): Promise { + const nextSettings = createE2eObjectStoragePluginData(settings, overrides); return await evalObsidianJson( cliBinary, [ "(async()=>{", "const plugin=app.plugins.plugins['obsidian-livesync'];", "const core=plugin.core;", - "const nextSettings={", - "remoteType:'MINIO',", - `endpoint:${JSON.stringify(settings.endpoint)},`, - `accessKey:${JSON.stringify(settings.accessKey)},`, - `secretKey:${JSON.stringify(settings.secretKey)},`, - `bucket:${JSON.stringify(settings.bucket)},`, - `region:${JSON.stringify(settings.region)},`, - `forcePathStyle:${JSON.stringify(settings.forcePathStyle)},`, - `bucketPrefix:${JSON.stringify(settings.bucketPrefix)},`, - "bucketCustomHeaders:'',", - ...e2ePreferredSettingsSource(), - ...Object.entries(overrides).map(([key, value]) => `${JSON.stringify(key)}:${JSON.stringify(value)},`), - "};", + `const nextSettings=${JSON.stringify(nextSettings)};`, "await core.services.setting.applyExternalSettings(nextSettings,true);", "await core.services.control.applySettings();", "const current=core.services.setting.currentSettings();", @@ -143,6 +327,25 @@ export async function configureObjectStorage( ); } +export function createE2eObjectStoragePluginData( + settings: ObjectStorageConfig & { bucketPrefix: string }, + overrides: Record = {} +): Record { + return { + remoteType: "MINIO", + endpoint: settings.endpoint, + accessKey: settings.accessKey, + secretKey: settings.secretKey, + bucket: settings.bucket, + region: settings.region, + forcePathStyle: settings.forcePathStyle, + bucketPrefix: settings.bucketPrefix, + bucketCustomHeaders: "", + ...E2E_PREFERRED_SETTINGS, + ...overrides, + }; +} + export async function waitForLiveSyncCoreReady( cliBinary: string, env: NodeJS.ProcessEnv, @@ -150,29 +353,116 @@ 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;", - "return JSON.stringify({", - "databaseReady:core.services.database.isDatabaseReady(),", - "appReady:core.services.appLifecycle.isReady(),", - "});", - "})()", - ].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}` + ); +} + +/** + * 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 { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_PREPARE_TIMEOUT_MS ?? 20000); await evalObsidianJson( cliBinary, [ @@ -182,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() { - await evalObsidianJson( + const attempt = await evalObsidianJson( cliBinary, [ "(async()=>{", "const core=app.plugins.plugins['obsidian-livesync'].core;", "await core.services.fileProcessing.commitPendingFileEvents();", "const result=await core.services.replication.replicate(true);", - "return JSON.stringify({result:!!result});", + "const settings=core.services.setting.currentSettings();", + "const activeReplicator=core.services.replicator.getActiveReplicator();", + "return JSON.stringify({", + "succeeded:!!result,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "isOnline:core.services.API.isOnline,", + "activeReplicator:activeReplicator?.constructor?.name??'(none)',", + "versionUpFlash:settings.versionUpFlash,", + "unresolvedMessages:(await core.services.appLifecycle.getUnresolvedMessages()).flat(),", + "});", "})()", ].join(""), env ); + if (!attempt.succeeded) { + throw new Error(`Finite replication did not start or complete: ${JSON.stringify(attempt)}`); + } } export async function waitForLocalDatabaseEntry( diff --git a/test/e2e-obsidian/runner/mobileUi.ts b/test/e2e-obsidian/runner/mobileUi.ts new file mode 100644 index 00000000..d492092d --- /dev/null +++ b/test/e2e-obsidian/runner/mobileUi.ts @@ -0,0 +1,163 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertLocatorWithinViewport, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; +import type { Locator, Page } from "playwright"; +import { withObsidianPage } from "./ui.ts"; + +export const mobileViewport = { width: 390, height: 844 } as const; +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 }; + +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) => { + const obsidianApp = (globalThis as ObsidianTestGlobal).app; + if (typeof obsidianApp?.emulateMobile !== "function") { + throw new Error("app.emulateMobile is unavailable"); + } + obsidianApp.emulateMobile(nextEnabled); + }, enabled); + // 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 { + 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) { + const property = `--safe-area-inset-${edge}`; + if (safeArea === null) document.body.style.removeProperty(property); + else document.body.style.setProperty(property, `${safeArea[edge]}px`); + } + }, + enabled ? iPhoneSafeArea : null + ); + }); +} + +/** 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"); + await assertLocatorWithinViewport(page, dialogue, { label }); + await assertNoHorizontalOverflow(page, dialogue, { label }); + await assertLocatorWithinSafeArea(page, dialogue, { + label, + safeAreaInsets: iPhoneSafeArea, + }); + await assertLocatorWithinSafeArea(page, closeButton, { + label: `${label} close button`, + safeAreaInsets: iPhoneSafeArea, + }); + await assertLocatorHasMinimumTouchTarget(page, closeButton, { + label: `${label} close button`, + }); + + const visibleButtons = dialogue.locator("button:visible"); + for (let index = 0; index < (await visibleButtons.count()); index++) { + const button = visibleButtons.nth(index); + const buttonLabel = (await button.innerText()).trim() || `button ${index + 1}`; + await assertLocatorWithinViewport(page, button, { label: `${label}: ${buttonLabel}` }); + await assertLocatorWithinSafeArea(page, button, { + label: `${label}: ${buttonLabel}`, + safeAreaInsets: iPhoneSafeArea, + }); + await assertNoHorizontalOverflow(page, button, { label: `${label}: ${buttonLabel}` }); + await assertLocatorHasMinimumTouchTarget(page, button, { label: `${label}: ${buttonLabel}` }); + } +} + +export async function assertMobileNoticeLayout( + page: Page, + notice: Locator, + label: string, + reservedRightPx = 56 +): Promise { + await assertLocatorWithinViewport(page, notice, { label }); + await assertNoHorizontalOverflow(page, notice, { label }); + await assertLocatorWithinSafeArea(page, notice, { + label, + safeAreaInsets: iPhoneSafeArea, + }); + const box = await notice.boundingBox(); + if (box === null) { + throw new Error(`${label} did not expose a measurable viewport rectangle.`); + } + const viewportWidth = await page.evaluate(() => window.innerWidth); + const rightEdge = box.x + box.width; + if (rightEdge > viewportWidth - reservedRightPx) { + throw new Error( + `${label} overlaps the reserved close-control column: right edge ${rightEdge}, limit ${viewportWidth - reservedRightPx}.` + ); + } +} diff --git a/test/e2e-obsidian/runner/objectStorage.ts b/test/e2e-obsidian/runner/objectStorage.ts index f0ea8455..01fee5cf 100644 --- a/test/e2e-obsidian/runner/objectStorage.ts +++ b/test/e2e-obsidian/runner/objectStorage.ts @@ -1,6 +1,7 @@ import { CreateBucketCommand, DeleteObjectsCommand, + GetObjectCommand, ListObjectsV2Command, S3Client, type _Object, @@ -120,6 +121,22 @@ export async function listObjectStorageObjects(config: ObjectStorageConfig, pref } } +export async function readObjectStorageObject(config: ObjectStorageConfig, key: string): Promise { + const client = createObjectStorageClient(config); + try { + const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key })); + if (!response.Body) throw new Error(`Object Storage returned an empty body for ${key}.`); + return await response.Body.transformToByteArray(); + } finally { + client.destroy(); + } +} + +export async function readObjectStorageJson(config: ObjectStorageConfig, key: string): Promise { + const bytes = await readObjectStorageObject(config, key); + return JSON.parse(new TextDecoder().decode(bytes)) as T; +} + export async function deleteObjectStoragePrefix(config: ObjectStorageConfig, prefix: string): Promise { const client = createObjectStorageClient(config); try { diff --git a/test/e2e-obsidian/runner/pathAssertions.test.ts b/test/e2e-obsidian/runner/pathAssertions.test.ts new file mode 100644 index 00000000..c7306737 --- /dev/null +++ b/test/e2e-obsidian/runner/pathAssertions.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { hasExactCaseOnlyRename } from "./pathEntries.ts"; + +describe("case-only rename assertions", () => { + it("accepts only the exact new spelling", () => { + expect(hasExactCaseOnlyRename(["case-rename.md"], "Case-Rename.md", "case-rename.md")).toBe(true); + }); + + it("rejects the old spelling even when a case-insensitive lookup would resolve it", () => { + expect(hasExactCaseOnlyRename(["Case-Rename.md"], "Case-Rename.md", "case-rename.md")).toBe(false); + }); + + it("rejects an ambiguous directory containing both spellings", () => { + expect(hasExactCaseOnlyRename(["Case-Rename.md", "case-rename.md"], "Case-Rename.md", "case-rename.md")).toBe( + false + ); + }); +}); diff --git a/test/e2e-obsidian/runner/pathAssertions.ts b/test/e2e-obsidian/runner/pathAssertions.ts new file mode 100644 index 00000000..4d9730d9 --- /dev/null +++ b/test/e2e-obsidian/runner/pathAssertions.ts @@ -0,0 +1,30 @@ +import { readdir } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import { hasExactCaseOnlyRename } from "./pathEntries.ts"; + +export async function waitForExactCaseOnlyRename( + vaultPath: string, + oldPath: string, + newPath: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000) +): Promise { + const oldDirectory = dirname(oldPath); + const newDirectory = dirname(newPath); + if (oldDirectory !== newDirectory) { + throw new Error(`Case-only rename paths must share one parent directory: ${oldPath} -> ${newPath}`); + } + + const oldName = basename(oldPath); + const newName = basename(newPath); + const directoryPath = join(vaultPath, newDirectory); + const deadline = Date.now() + timeoutMs; + let lastEntries: string[] = []; + while (Date.now() < deadline) { + lastEntries = await readdir(directoryPath); + if (hasExactCaseOnlyRename(lastEntries, oldName, newName)) return; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error( + `Timed out waiting for exact case-only rename: ${oldPath} -> ${newPath}. Directory entries: ${JSON.stringify(lastEntries)}` + ); +} diff --git a/test/e2e-obsidian/runner/pathEntries.ts b/test/e2e-obsidian/runner/pathEntries.ts new file mode 100644 index 00000000..241061c7 --- /dev/null +++ b/test/e2e-obsidian/runner/pathEntries.ts @@ -0,0 +1,3 @@ +export function hasExactCaseOnlyRename(entries: readonly string[], oldName: string, newName: string): boolean { + return entries.includes(newName) && !entries.includes(oldName); +} 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/releaseArtifact.test.ts b/test/e2e-obsidian/runner/releaseArtifact.test.ts new file mode 100644 index 00000000..ef76b7ce --- /dev/null +++ b/test/e2e-obsidian/runner/releaseArtifact.test.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ensurePinnedReleaseArtifact, + type PinnedPluginRelease, +} from "./releaseArtifact.ts"; + +const temporaryDirectories: string[] = []; + +function sha256(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +function fixtureRelease(contents: Record<"main.js" | "manifest.json" | "styles.css", string>): PinnedPluginRelease { + return { + pluginId: "fixture-plugin", + version: "1.2.3", + files: (Object.keys(contents) as Array).map((name) => ({ + name, + url: `https://example.invalid/${name}`, + sha256: sha256(contents[name]), + })), + }; +} + +afterEach(async () => { + for (const path of temporaryDirectories.splice(0)) { + await rm(path, { recursive: true, force: true }); + } +}); + +describe("pinned plug-in release artefacts", () => { + it("downloads, verifies, and reuses an immutable release cache", async () => { + const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-")); + temporaryDirectories.push(root); + const contents = { + "main.js": "console.log('fixture');\n", + "manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n', + "styles.css": ".fixture {}\n", + }; + const release = fixtureRelease(contents); + const fetchImplementation = vi.fn(async (input: string | URL | Request) => { + const name = new URL(String(input)).pathname.split("/").pop() as keyof typeof contents; + return new Response(contents[name], { status: 200 }); + }) as unknown as typeof fetch; + + await expect( + ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation }) + ).resolves.toBe(root); + await expect(readFile(join(root, "main.js"), "utf8")).resolves.toBe(contents["main.js"]); + expect(fetchImplementation).toHaveBeenCalledTimes(3); + + await ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation }); + expect(fetchImplementation).toHaveBeenCalledTimes(3); + }); + + it("rejects a downloaded file before it enters the release cache when its checksum differs", async () => { + const root = await mkdtemp(join(tmpdir(), "livesync-release-artifact-")); + temporaryDirectories.push(root); + const contents = { + "main.js": "expected\n", + "manifest.json": '{"id":"fixture-plugin","version":"1.2.3"}\n', + "styles.css": ".fixture {}\n", + }; + const release = fixtureRelease(contents); + const fetchImplementation = vi.fn(async () => new Response("tampered\n", { status: 200 })) as unknown as typeof fetch; + + await expect( + ensurePinnedReleaseArtifact(release, { artifactRoot: root, fetchImplementation }) + ).rejects.toThrow("checksum mismatch"); + await expect(readFile(join(root, "main.js"))).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/test/e2e-obsidian/runner/releaseArtifact.ts b/test/e2e-obsidian/runner/releaseArtifact.ts new file mode 100644 index 00000000..2c0e62e1 --- /dev/null +++ b/test/e2e-obsidian/runner/releaseArtifact.ts @@ -0,0 +1,128 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export type PinnedReleaseArtifactFile = { + name: "main.js" | "manifest.json" | "styles.css"; + url: string; + sha256: string; +}; + +export type PinnedPluginRelease = { + pluginId: string; + version: string; + files: readonly PinnedReleaseArtifactFile[]; +}; + +export type EnsurePinnedReleaseArtifactOptions = { + artifactRoot?: string; + fetchImplementation?: typeof fetch; +}; + +export const UPGRADE_SOURCE_RELEASE: PinnedPluginRelease = { + pluginId: "obsidian-livesync", + version: "0.25.83", + files: [ + { + name: "main.js", + url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/main.js", + sha256: "5e57f990635ab0cf2ff3879f3c6cb91ddfdbc146958d33d1e5d21f1869dff6a4", + }, + { + name: "manifest.json", + url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/manifest.json", + sha256: "4944f5665c94bcbb58db0e3708ec2bd8ee36118791271c01d085668876dc8ba6", + }, + { + name: "styles.css", + url: "https://github.com/vrtmrz/obsidian-livesync/releases/download/0.25.83/styles.css", + sha256: "37d31798186d7e97ea979e6d2aae8021ea1ac1df2c3b9d2b03dce269959c27f3", + }, + ], +}; + +function digest(content: Uint8Array): string { + return createHash("sha256").update(content).digest("hex"); +} + +function assertDigest(file: PinnedReleaseArtifactFile, content: Uint8Array): void { + const actual = digest(content); + if (actual !== file.sha256) { + throw new Error( + `Release artefact checksum mismatch for ${file.name}. Expected ${file.sha256}, received ${actual}.` + ); + } +} + +async function readCachedFile( + path: string, + file: PinnedReleaseArtifactFile +): Promise | undefined> { + try { + const content = new Uint8Array(await readFile(path)); + assertDigest(file, content); + return content; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +async function downloadVerifiedFile( + root: string, + file: PinnedReleaseArtifactFile, + fetchImplementation: typeof fetch +): Promise> { + const path = join(root, file.name); + const cached = await readCachedFile(path, file); + if (cached) return cached; + + const response = await fetchImplementation(file.url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`Could not download ${file.url}. HTTP ${response.status}: ${await response.text()}`); + } + const content = new Uint8Array(await response.arrayBuffer()); + assertDigest(file, content); + + const temporaryPath = `${path}.download-${process.pid}-${Date.now()}`; + try { + await writeFile(temporaryPath, content, { flag: "wx" }); + await rename(temporaryPath, path); + } finally { + await rm(temporaryPath, { force: true }); + } + return content; +} + +/** + * Materialise one immutable published plug-in release in the ignored E2E cache. + * + * Existing files are always verified before use. A mismatched cache is left in + * place for inspection and must be removed explicitly by the operator. + */ +export async function ensurePinnedReleaseArtifact( + release: PinnedPluginRelease = UPGRADE_SOURCE_RELEASE, + options: EnsurePinnedReleaseArtifactOptions = {} +): Promise { + const root = resolve( + options.artifactRoot ?? + process.env.E2E_LIVESYNC_SOURCE_ARTIFACT_ROOT?.trim() ?? + join("_testdata", "releases", release.pluginId, release.version) + ); + await mkdir(root, { recursive: true }); + + const fetched = new Map>(); + for (const file of release.files) { + fetched.set(file.name, await downloadVerifiedFile(root, file, options.fetchImplementation ?? fetch)); + } + + const manifestBytes = fetched.get("manifest.json"); + if (!manifestBytes) throw new Error("The pinned release does not define manifest.json."); + const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as { id?: unknown; version?: unknown }; + if (manifest.id !== release.pluginId || manifest.version !== release.version) { + throw new Error( + `Release manifest identity mismatch. Expected ${release.pluginId}@${release.version}, received ${String(manifest.id)}@${String(manifest.version)}.` + ); + } + return root; +} diff --git a/test/e2e-obsidian/runner/remoteActivity.ts b/test/e2e-obsidian/runner/remoteActivity.ts new file mode 100644 index 00000000..355d7fa7 --- /dev/null +++ b/test/e2e-obsidian/runner/remoteActivity.ts @@ -0,0 +1,237 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Page } from "playwright"; +import { withObsidianPage } from "./ui.ts"; +import { + REMOTE_OPERATION_ACTIVITY_ICON, + REMOTE_REQUEST_ACTIVITY_ICON, +} from "../../../src/modules/features/RemoteActivityStatus.ts"; + +export const REMOTE_ACTIVITY_E2E_STATE_KEY = "__livesyncE2ERemoteActivity"; +export const REMOTE_ACTIVITY_GATE_KIND = { + chunkFetch: "chunk-fetch", + oneShot: "one-shot", + trackedRequest: "tracked-request", +} as const; +export const REMOTE_ACTIVITY_EXPECTED_STATE = { + chunkFetchActive: "chunk-fetch-active", + finiteReplicationActive: "finite-replication-active", + idle: "idle", + trackedRequestActive: "tracked-request-active", +} as const; +export type RemoteActivityGateKind = (typeof REMOTE_ACTIVITY_GATE_KIND)[keyof typeof REMOTE_ACTIVITY_GATE_KIND]; + +export type RemoteActivitySnapshot = { + boundedRemoteActivityCount: number; + finiteReplicationActivityCount: number; + gateDone?: boolean; + gateEntered?: boolean; + gateError?: string; + gateKind?: RemoteActivityGateKind; + requestCount: number; + remoteOperationIndicatorVisible: boolean; + remoteRequestIndicatorVisible: boolean; + responseCount: number; + statusBarFound: boolean; + statusBarText: string; +}; + +export type ExpectedRemoteActivityState = + (typeof REMOTE_ACTIVITY_EXPECTED_STATE)[keyof typeof REMOTE_ACTIVITY_EXPECTED_STATE]; + +type RuntimeCounter = { value?: number }; + +type RuntimeCore = { + services?: { + API?: { + requestCount?: RuntimeCounter; + responseCount?: RuntimeCounter; + }; + replicator?: { + boundedRemoteActivityCount?: RuntimeCounter; + finiteReplicationActivityCount?: RuntimeCounter; + }; + }; +}; + +type RuntimeGate = { + done?: boolean; + entered?: boolean; + error?: string; + kind?: RemoteActivityGateKind; +}; + +type RendererGlobals = typeof globalThis & { + app?: { + plugins?: { + plugins?: Record; + }; + }; +}; + +async function readRemoteActivitySnapshotFromPage(page: Page): Promise { + return await page.evaluate( + ({ operationIcon, pluginId, requestIcon, stateKey }) => { + const globals = globalThis as RendererGlobals; + const core = globals.app?.plugins?.plugins?.[pluginId]?.core; + if (!core) throw new Error(`Obsidian plug-in is not loaded: ${pluginId}`); + const gate = (globalThis as unknown as Record)[stateKey]; + const statusBars = Array.from(document.querySelectorAll(".syncstatusbar")); + return { + boundedRemoteActivityCount: Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1), + finiteReplicationActivityCount: Number( + core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1 + ), + gateDone: gate?.done, + gateEntered: gate?.entered, + gateError: gate?.error, + gateKind: gate?.kind, + requestCount: Number(core.services?.API?.requestCount?.value ?? -1), + remoteOperationIndicatorVisible: statusBars.some((element) => + (element.textContent ?? "").includes(operationIcon) + ), + remoteRequestIndicatorVisible: statusBars.some((element) => + (element.textContent ?? "").includes(requestIcon) + ), + responseCount: Number(core.services?.API?.responseCount?.value ?? -1), + statusBarFound: statusBars.length > 0, + statusBarText: statusBars.map((element) => element.textContent ?? "").join("\n"), + } satisfies RemoteActivitySnapshot; + }, + { + operationIcon: REMOTE_OPERATION_ACTIVITY_ICON, + pluginId: "obsidian-livesync", + requestIcon: REMOTE_REQUEST_ACTIVITY_ICON, + stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY, + } + ); +} + +export async function readRemoteActivitySnapshot(port: number): Promise { + return await withObsidianPage(port, async (page) => await readRemoteActivitySnapshotFromPage(page)); +} + +function formatWaitFailure( + expected: ExpectedRemoteActivityState, + snapshot: RemoteActivitySnapshot | undefined, + error: unknown +): Error { + return new Error( + [ + `Timed out waiting for remote activity state: ${expected}`, + snapshot ? `Last snapshot: ${JSON.stringify(snapshot)}` : undefined, + error instanceof Error ? error.message : String(error), + ] + .filter((line): line is string => line !== undefined) + .join("\n") + ); +} + +export async function waitForRemoteActivityState( + port: number, + expected: ExpectedRemoteActivityState, + timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000) +): Promise { + try { + return await withObsidianPage(port, async (page) => { + await page.waitForFunction( + ({ expectedState, expectedStates, gateKinds, operationIcon, pluginId, requestIcon, stateKey }) => { + const globals = globalThis as RendererGlobals; + const core = globals.app?.plugins?.plugins?.[pluginId]?.core; + if (!core) return false; + const gate = (globalThis as unknown as Record)[stateKey]; + const statusBarText = Array.from(document.querySelectorAll(".syncstatusbar")) + .map((element) => element.textContent ?? "") + .join("\n"); + const bounded = Number(core.services?.replicator?.boundedRemoteActivityCount?.value ?? -1); + const finite = Number(core.services?.replicator?.finiteReplicationActivityCount?.value ?? -1); + const requests = Number(core.services?.API?.requestCount?.value ?? -1); + const responses = Number(core.services?.API?.responseCount?.value ?? -1); + const operationIconVisible = statusBarText.includes(operationIcon); + const requestIconVisible = statusBarText.includes(requestIcon); + const trackedRequests = Math.max(0, requests - responses); + + if (expectedState === expectedStates.finiteReplicationActive) { + return ( + gate?.kind === gateKinds.oneShot && + gate.entered === true && + bounded > 0 && + finite > 0 && + operationIconVisible && + !requestIconVisible && + trackedRequests === 0 + ); + } + if (expectedState === expectedStates.chunkFetchActive) { + return ( + gate?.kind === gateKinds.chunkFetch && + gate.entered === true && + bounded > 0 && + finite === 0 && + operationIconVisible && + !requestIconVisible && + trackedRequests === 0 + ); + } + if (expectedState === expectedStates.trackedRequestActive) { + return ( + gate?.kind === gateKinds.trackedRequest && + gate.entered === true && + bounded === 0 && + finite === 0 && + trackedRequests > 0 && + !operationIconVisible && + statusBarText.includes(`${requestIcon}${trackedRequests}`) + ); + } + return ( + bounded === 0 && + finite === 0 && + requests === responses && + !operationIconVisible && + !requestIconVisible + ); + }, + { + expectedState: expected, + expectedStates: REMOTE_ACTIVITY_EXPECTED_STATE, + gateKinds: REMOTE_ACTIVITY_GATE_KIND, + operationIcon: REMOTE_OPERATION_ACTIVITY_ICON, + pluginId: "obsidian-livesync", + requestIcon: REMOTE_REQUEST_ACTIVITY_ICON, + stateKey: REMOTE_ACTIVITY_E2E_STATE_KEY, + }, + { timeout: timeoutMs } + ); + return await readRemoteActivitySnapshotFromPage(page); + }); + } catch (error) { + const snapshot = await readRemoteActivitySnapshot(port).catch(() => undefined); + throw formatWaitFailure(expected, snapshot, error); + } +} + +export type RemoteActivityDiagnostics = { + screenshotPath: string; + snapshot: RemoteActivitySnapshot; + snapshotPath: string; +}; + +export async function captureRemoteActivityDiagnostics( + port: number, + label: string +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + await mkdir(outputDirectory, { recursive: true }); + const safeLabel = label.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "remote-activity"; + const prefix = `${safeLabel}-${new Date().toISOString().replace(/[:.]/g, "-")}`; + const screenshotPath = join(outputDirectory, `${prefix}.png`); + const snapshotPath = join(outputDirectory, `${prefix}.json`); + const snapshot = await withObsidianPage(port, async (page) => { + const current = await readRemoteActivitySnapshotFromPage(page); + await page.screenshot({ path: screenshotPath, fullPage: true }); + return current; + }); + await writeFile(snapshotPath, `${JSON.stringify(snapshot, undefined, 2)}\n`, "utf8"); + return { screenshotPath, snapshot, snapshotPath }; +} diff --git a/test/e2e-obsidian/runner/remoteActivityWorkflow.ts b/test/e2e-obsidian/runner/remoteActivityWorkflow.ts new file mode 100644 index 00000000..376d338c --- /dev/null +++ b/test/e2e-obsidian/runner/remoteActivityWorkflow.ts @@ -0,0 +1,236 @@ +import { evalObsidianJson } from "./cli.ts"; +import { + REMOTE_ACTIVITY_E2E_STATE_KEY, + REMOTE_ACTIVITY_GATE_KIND, + type RemoteActivityGateKind, +} from "./remoteActivity.ts"; + +export type HeldRemoteActivityResult = { + done: boolean; + entered: boolean; + error?: string; + kind: RemoteActivityGateKind; + requestedIds?: string[]; + result?: boolean; + resultCount?: number; +}; + +const stateKeySource = JSON.stringify(REMOTE_ACTIVITY_E2E_STATE_KEY); + +export async function startHeldOneShotReplication(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ started: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const host=globalThis;", + "if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "if(!replicator) throw new Error('No active replicator is available.');", + "const original=replicator.openReplication;", + "let releaseGate;", + "const gate=new Promise((resolve)=>{releaseGate=resolve;});", + `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.oneShot)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`, + "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", + "state.restore=()=>{replicator.openReplication=original;};", + "host[stateKey]=state;", + "replicator.openReplication=async function(...args){", + "state.entered=true;", + "await gate;", + "return await original.apply(this,args);", + "};", + "state.promise=(async()=>{", + "try{", + "if(!(await core.services.fileProcessing.commitPendingFileEvents())) throw new Error('Pending file events could not be committed.');", + "state.result=!!(await core.services.replication.replicate(true));", + "}catch(error){", + "state.error=error instanceof Error?error.message:String(error);", + "}finally{", + "state.restore();", + "state.done=true;", + "}", + "})();", + "return JSON.stringify({started:true});", + "})()", + ].join(""), + env + ); +} + +export async function startHeldChunkFetch(cliBinary: string, env: NodeJS.ProcessEnv, chunkId: string): Promise { + await evalObsidianJson<{ started: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + `const chunkId=${JSON.stringify(chunkId)};`, + "const host=globalThis;", + "if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "if(!replicator) throw new Error('No active replicator is available.');", + "const localDb=core.localDatabase.localDatabase;", + "const existing=await localDb.get(chunkId).catch(()=>undefined);", + "if(existing&&!existing._deleted) throw new Error(`The remote-only chunk already exists locally: ${chunkId}`);", + "const original=replicator.fetchRemoteChunks;", + "let releaseGate;", + "let resolveDone;", + "const gate=new Promise((resolve)=>{releaseGate=resolve;});", + "const donePromise=new Promise((resolve)=>{resolveDone=resolve;});", + `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.chunkFetch)},entered:false,done:false,released:false,error:undefined,resultCount:undefined,requestedIds:undefined,promise:donePromise,release:undefined,restore:undefined};`, + "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", + "state.restore=()=>{replicator.fetchRemoteChunks=original;};", + "host[stateKey]=state;", + "replicator.fetchRemoteChunks=async function(...args){", + "state.entered=true;", + "state.requestedIds=Array.isArray(args[0])?[...args[0]]:[];", + "await gate;", + "try{", + "const result=await original.apply(this,args);", + "state.resultCount=Array.isArray(result)?result.length:0;", + "return result;", + "}catch(error){", + "state.error=error instanceof Error?error.message:String(error);", + "throw error;", + "}finally{", + "state.restore();", + "state.done=true;", + "resolveDone();", + "}", + "};", + "core.localDatabase.managers.chunkFetcher.onEvent([chunkId]);", + "return JSON.stringify({started:true});", + "})()", + ].join(""), + env + ); +} + +export async function startHeldTrackedRequest(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ started: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const host=globalThis;", + "if(host[stateKey]) throw new Error('A remote activity E2E gate is already installed.');", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const remote=core.services.remote;", + "const api=core.services.API;", + "const settings=core.services.setting.currentSettings();", + "const original=api.webCompatFetch;", + "let releaseGate;", + "const gate=new Promise((resolve)=>{releaseGate=resolve;});", + `const state={kind:${JSON.stringify(REMOTE_ACTIVITY_GATE_KIND.trackedRequest)},entered:false,done:false,released:false,error:undefined,result:undefined,promise:undefined,release:undefined,restore:undefined};`, + "state.release=()=>{if(!state.released){state.released=true;releaseGate();}};", + "state.restore=()=>{api.webCompatFetch=original;};", + "host[stateKey]=state;", + "api.webCompatFetch=async function(...args){", + "state.entered=true;", + "await gate;", + "return await original.apply(this,args);", + "};", + "state.promise=(async()=>{", + "try{", + "const base=String(settings.couchDB_URI).replace(/\\/$/,'');", + "const database=encodeURIComponent(settings.couchDB_DBNAME);", + "const credentials=btoa(`${settings.couchDB_USER}:${settings.couchDB_PASSWORD}`);", + "const response=await remote.performFetch(`${base}/${database}/_all_docs?limit=0`,{headers:{Authorization:`Basic ${credentials}`}});", + "state.result=response.ok;", + "}catch(error){", + "state.error=error instanceof Error?error.message:String(error);", + "}finally{", + "state.restore();", + "state.done=true;", + "}", + "})();", + "return JSON.stringify({started:true});", + "})()", + ].join(""), + env + ); +} + +export async function finishHeldRemoteActivity( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const state=globalThis[stateKey];", + "if(!state) throw new Error('No remote activity E2E gate is installed.');", + "state.release();", + "await state.promise;", + "return JSON.stringify({kind:state.kind,entered:state.entered,done:state.done,error:state.error,result:state.result,resultCount:state.resultCount,requestedIds:state.requestedIds});", + "})()", + ].join(""), + env + ); +} + +export async function waitForRestoredChunk( + cliBinary: string, + env: NodeJS.ProcessEnv, + chunkId: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_ACTIVITY_TIMEOUT_MS ?? 30000) +): Promise<{ id: string; type: string }> { + return await evalObsidianJson<{ id: string; type: string }>( + cliBinary, + [ + "(async()=>{", + `const chunkId=${JSON.stringify(chunkId)};`, + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const localDb=core.localDatabase.localDatabase;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()undefined);", + "if(chunk&&!chunk._deleted&&chunk.type==='leaf'&&typeof chunk.data==='string') return JSON.stringify({id:chunk._id,type:chunk.type});", + "await sleep(100);", + "}", + "throw new Error(`Timed out waiting for the fetched chunk to return: ${chunkId}`);", + "})()", + ].join(""), + env + ); +} + +export async function clearHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ cleared: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const state=globalThis[stateKey];", + "if(!state) return JSON.stringify({cleared:false});", + "if(!state.done) throw new Error('The remote activity E2E gate is still running.');", + "delete globalThis[stateKey];", + "return JSON.stringify({cleared:true});", + "})()", + ].join(""), + env + ); +} + +export async function cleanUpHeldRemoteActivity(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ cleared: boolean }>( + cliBinary, + [ + "(async()=>{", + `const stateKey=${stateKeySource};`, + "const state=globalThis[stateKey];", + "if(!state) return JSON.stringify({cleared:false});", + "state.release?.();", + "await Promise.race([Promise.resolve(state.promise),new Promise((resolve)=>setTimeout(resolve,5000))]);", + "state.restore?.();", + "delete globalThis[stateKey];", + "return JSON.stringify({cleared:true});", + "})()", + ].join(""), + env + ); +} 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..6892f424 --- /dev/null +++ b/test/e2e-obsidian/runner/securitySeed.ts @@ -0,0 +1,88 @@ +/** + * Supplies the runner-owned CouchDB fixture operations for the Security Seed + * reconnect scenario. Production code remains responsible for fetching, + * caching, and applying the Seed; this helper only validates the managed + * synchronisation-parameter document, replaces the one intended field, and + * compares document snapshots. + * + * Callers expose only short SHA-256 fingerprints. The original and replacement + * Seed values must stay inside the isolated test process and must not be + * written to diagnostics, screenshots, or machine-readable results. + */ +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/runner/session.test.ts b/test/e2e-obsidian/runner/session.test.ts new file mode 100644 index 00000000..8977cb96 --- /dev/null +++ b/test/e2e-obsidian/runner/session.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { startObsidianPluginSession } from "@vrtmrz/obsidian-test-session"; +import { + startObsidianLiveSyncSession, + type StartObsidianLiveSyncSessionOptions, +} from "./session.ts"; + +vi.mock("@vrtmrz/obsidian-test-session", () => ({ + startObsidianPluginSession: vi.fn(async () => ({ + app: {}, + cliEnv: {}, + install: {}, + readiness: {}, + pluginId: "obsidian-livesync", + remoteDebuggingPort: 28052, + })), +})); + +describe("LiveSync real-Obsidian session", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("installs an explicitly selected plug-in artefact while retaining the supplied Vault and profile", async () => { + const vault = { + path: "/tmp/upgrade-vault", + statePath: "/tmp/upgrade-state", + name: "upgrade-vault", + id: "upgrade-vault-id", + homePath: "/tmp/upgrade-state/home", + xdgConfigPath: "/tmp/upgrade-state/xdg-config", + xdgCachePath: "/tmp/upgrade-state/xdg-cache", + xdgDataPath: "/tmp/upgrade-state/xdg-data", + userDataPath: "/tmp/upgrade-state/user-data", + processMarker: "/tmp/upgrade-state", + dispose: vi.fn(async () => undefined), + }; + const options: StartObsidianLiveSyncSessionOptions & { artifactRoot: string } = { + binary: "/Applications/Obsidian", + cliBinary: "obsidian-cli", + vault, + artifactRoot: "/tmp/obsidian-livesync-0.25.83", + }; + + await startObsidianLiveSyncSession(options); + + expect(startObsidianPluginSession).toHaveBeenCalledWith( + expect.objectContaining({ + artifactRoot: options.artifactRoot, + pluginId: "obsidian-livesync", + vault, + }) + ); + }); + + 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 5d7bdd1d..8347f5e3 100644 --- a/test/e2e-obsidian/runner/session.ts +++ b/test/e2e-obsidian/runner/session.ts @@ -1,119 +1,40 @@ -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, + type ObsidianPluginSessionLifecycle, + type ObsidianPluginStartupMode, +} 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; cliBinary: string; vault: TemporaryVault; + artifactRoot?: string; startupGraceMs?: number; + pluginData?: Record; + localStorageEntries?: Readonly>; + pluginStartup?: ObsidianPluginStartupMode; + lifecycle?: ObsidianPluginSessionLifecycle; + env?: NodeJS.ProcessEnv; }; -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: options.artifactRoot ?? process.cwd(), startupGraceMs: options.startupGraceMs, + pluginData: options.pluginData, + localStorageEntries: options.localStorageEntries, + pluginStartup: options.pluginStartup, + lifecycle: options.lifecycle, + env: options.env, }); - 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/setupUri.ts b/test/e2e-obsidian/runner/setupUri.ts new file mode 100644 index 00000000..0c1697be --- /dev/null +++ b/test/e2e-obsidian/runner/setupUri.ts @@ -0,0 +1,439 @@ +import type { Locator, Page } from "playwright"; +import { evalObsidianJson } from "./cli.ts"; +import { captureObsidianDialogue, captureObsidianElement, withObsidianPage } from "./ui.ts"; + +export type SetupArtifact = { + setupURI: string; + setupPassphrase: string; +}; + +export type SetupState = { + configured: boolean; + databaseReady: boolean; + appReady: boolean; + suspended: boolean; + remoteType: string; + activeConfigurationId: string; + remoteConfigurationCount: number; + endpoint: string; + bucket: string; + bucketPrefix: string; + p2pEnabled: boolean; + p2pRelays: string; + p2pRoomId: string; +}; + +export type SetupCaptureNames = { + scenario: string; + guide: string; +}; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); +const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000); + +export function modalByTitle(page: Page, title: string): Locator { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); +} + +export async function captureGuideDialogue(port: number, filename: string, title: string): Promise { + return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first()); +} + +export async function assertVerticalActionLayout(port: number, title: string): Promise { + await withObsidianPage(port, async (page) => { + const actions = modalByTitle(page, title).locator(".vpk-action-dialog__actions").first(); + await actions.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const flexDirection = await actions.evaluate((element) => getComputedStyle(element).flexDirection); + if (flexDirection !== "column") { + throw new Error(`Expected vertically stacked actions in '${title}', received '${flexDirection}'.`); + } + }); +} + +export async function selectRadioOption(modal: Locator, title: string): Promise { + const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first(); + await radio.check({ timeout: uiTimeoutMs }); +} + +export async function selectCheckbox(modal: Locator, title: string): Promise { + const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first(); + await checkbox.check({ timeout: uiTimeoutMs }); +} + +export async function enterSetupURI( + port: number, + mode: "new" | "existing", + artifact: SetupArtifact, + captures: SetupCaptureNames +): Promise { + await withObsidianPage(port, async (page) => { + const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + + const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync"); + await intro.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "new") { + await selectRadioOption(intro, "I am setting this up for the first time"); + await intro + .getByRole("button", { name: "Yes, I want to set up a new synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } else { + await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup"); + await intro + .getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } + + const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method"); + await method.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await selectRadioOption(method, "Use a Setup URI (Recommended)"); + await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs }); + + const setup = modalByTitle(page, "Enter Setup URI"); + await setup.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI); + await setup.locator('input[name="password"]').fill(artifact.setupPassphrase); + }); + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-${mode === "new" ? "first" : "second"}-setup-uri.png`, + "Enter Setup URI" + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, "Enter Setup URI") + .getByRole("button", { name: "Test Settings and Continue" }) + .click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function generateSetupURIFromDevice( + port: number, + setupPassphrase: string, + captures: SetupCaptureNames +): Promise<{ artifact: SetupArtifact; screenshots: string[] }> { + const opened = await withObsidianPage(port, async (page) => { + return await page.evaluate( + (commandId) => + ( + globalThis as typeof globalThis & { + app?: { commands?: { executeCommandById(id: string): boolean } }; + } + ).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:livesync-copysetupuri" + ); + }); + if (!opened) throw new Error("The command for generating a Setup URI was not registered."); + + const promptTitle = "Encrypt your settings"; + await withObsidianPage(port, async (page) => { + const prompt = modalByTitle(page, promptTitle); + await prompt.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await prompt.locator('input[type="password"]').fill(setupPassphrase); + }); + const promptScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-copy-setup-uri-passphrase.png`, + promptTitle + ); + await withObsidianPage(port, async (page) => { + const prompt = modalByTitle(page, promptTitle); + await prompt.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); + await prompt.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const resultTitle = "Your Setup URI is ready to be copied"; + const setupURI = await withObsidianPage(port, async (page) => { + const result = modalByTitle(page, resultTitle); + await result.waitFor({ state: "visible", timeout: uiTimeoutMs }); + return await result.locator("textarea[readonly]").inputValue(); + }); + if (!setupURI.startsWith("obsidian://setuplivesync?settings=")) { + throw new Error("The first device did not generate a valid Setup URI."); + } + const resultScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-copy-setup-uri-result.png`, + resultTitle + ); + await withObsidianPage(port, async (page) => { + const result = modalByTitle(page, resultTitle); + await result.getByRole("button", { name: "OK", exact: true }).click({ timeout: uiTimeoutMs }); + await result.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { + artifact: { setupURI, setupPassphrase }, + screenshots: [promptScreenshot, resultScreenshot], + }; +} + +export async function captureAndStartInitialisation( + port: number, + mode: "new" | "existing", + captures: SetupCaptureNames +): Promise { + const p2pFirstDevice = mode === "new" && captures.guide === "p2p-setup"; + const p2pAdditionalDevice = mode === "existing" && captures.guide === "p2p-setup"; + const title = p2pFirstDevice + ? "Setup Complete: Preparing This P2P Device" + : p2pAdditionalDevice + ? "Setup Complete: Preparing to Fetch from Another Device" + : mode === "new" + ? "Setup Complete: Preparing to Initialise Server" + : "Setup Complete: Preparing to Fetch Synchronisation Data"; + const button = p2pFirstDevice + ? "Restart and Prepare This Device" + : p2pAdditionalDevice + ? "Restart and Select Source Device" + : mode === "new" + ? "Restart and Initialise Server" + : "Restart and Fetch Data"; + if (p2pAdditionalDevice) { + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal + .getByText("After restarting, select an online source device for the initial Fetch.", { + exact: false, + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await modal.getByText("downloaded from the server", { exact: false }).count()) !== 0) { + throw new Error("P2P additional-device setup still describes the initial Fetch as a server download."); + } + }); + } + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, + title + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function confirmRebuild(port: number, captures: SetupCaptureNames): Promise { + const isP2P = captures.guide === "p2p-setup"; + const title = isP2P + ? "Final Confirmation: Prepare This Device for P2P" + : "Final Confirmation: Overwrite Server Data with This Device's Files"; + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-first-rebuild-confirmation.png`, + title + ); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + if (isP2P) { + await selectCheckbox( + modal, + "I understand that this resets only this device's local synchronisation database." + ); + await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); + await modal + .getByRole("button", { name: "I Understand, Prepare This Device" }) + .click({ timeout: uiTimeoutMs }); + return; + } + await selectCheckbox( + modal, + "I understand that all changes made on other smartphones or computers possibly could be lost." + ); + await selectCheckbox( + modal, + "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information." + ); + await selectCheckbox(modal, "I understand that this action is irreversible once performed."); + await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); + await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function skipMissingRemoteConfiguration(port: number, captures: SetupCaptureNames): Promise { + const title = "Fetch Remote Configuration Failed"; + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-missing-remote-configuration.png`, + title + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title) + .getByRole("button", { name: "Skip and proceed" }) + .click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function acknowledgeDisabledOptionalFeatures(port: number, captures: SetupCaptureNames): Promise { + const title = "All optional features are disabled"; + const screenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-optional-features-disabled.png`, + title + ); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal.getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +export async function confirmFastFetch(port: number, captures: SetupCaptureNames): Promise { + const firstTitle = "Data retrieval scheduled"; + await assertVerticalActionLayout(port, firstTitle); + const firstScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-retrieval-method.png`, + firstTitle + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, firstTitle) + .getByRole("button", { name: "Overwrite all with remote files" }) + .click({ timeout: uiTimeoutMs }); + }); + + const secondTitle = "How to handle extra existing local files?"; + await assertVerticalActionLayout(port, secondTitle); + const secondScreenshot = await captureGuideDialogue( + port, + `guide-${captures.guide}-local-file-policy.png`, + secondTitle + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, secondTitle) + .getByRole("button", { name: "Keep local files even if not on remote" }) + .click({ timeout: uiTimeoutMs }); + }); + return [firstScreenshot, secondScreenshot]; +} + +function isConfiguredSetupReady(state: SetupState): boolean { + return ( + state.configured && + state.databaseReady && + state.appReady && + !state.suspended && + state.activeConfigurationId !== "" && + state.remoteConfigurationCount === 1 + ); +} + +export async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "configured:settings.isConfigured===true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "suspended:core.services.appLifecycle.isSuspended(),", + "remoteType:settings.remoteType||'',", + "activeConfigurationId:settings.activeConfigurationId||'',", + "remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,", + "endpoint:settings.endpoint||'',", + "bucket:settings.bucket||'',", + "bucketPrefix:settings.bucketPrefix||'',", + "p2pEnabled:settings.P2P_Enabled===true,", + "p2pRelays:settings.P2P_relays||'',", + "p2pRoomId:settings.P2P_roomID||'',", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function waitForConfiguredSetup( + cliBinary: string, + environment: NodeJS.ProcessEnv, + timeoutMs = initialisationTimeoutMs +): Promise { + const deadline = Date.now() + timeoutMs; + let lastState: SetupState | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastState = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(lastState)) return lastState; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }` + ); +} + +export async function finishInitialisation( + port: number, + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + const message = "Do you want to resume file and database processing, and restart obsidian now?"; + const deadline = Date.now() + initialisationTimeoutMs; + let readySince: number | undefined; + while (Date.now() < deadline) { + const resumeVisible = await withObsidianPage(port, async (page) => { + return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible(); + }).catch(() => false); + if (resumeVisible) { + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, "Confirmation").filter({ hasText: message }); + await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return await waitForConfiguredSetup(cliBinary, environment); + } + try { + const state = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(state)) { + readySince ??= Date.now(); + if (Date.now() - readySince >= 1000) return state; + } else { + readySince = undefined; + } + } catch { + // Obsidian may be reloading while the scheduled operation runs. + readySince = undefined; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error("Timed out waiting for Setup URI initialisation to finish."); +} + +export async function resumeCompatibilityReviewIfShown(port: number): Promise { + const title = "Synchronisation paused for compatibility review"; + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + let available = false; + while (Date.now() < deadline && !available) { + available = await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + if (await modal.isVisible()) return true; + const reminder = page.locator(".notice.livesync-compatibility-review-notice"); + if (!(await reminder.isVisible())) return false; + await reminder.getByRole("link", { name: "Review why" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + return true; + }).catch(() => false); + if (!available) await new Promise((resolve) => setTimeout(resolve, 200)); + } + if (!available) return false; + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await modal.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return true; +} diff --git a/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts b/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts new file mode 100644 index 00000000..7ee47ffd --- /dev/null +++ b/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ + events: [] as string[], + sessions: [] as Array<{ app: { stop: ReturnType } }>, + vaultCount: 0, +})); + +vi.mock("./cli.ts", () => ({ + evalObsidianJson: vi.fn(async () => ({ ok: true })), +})); + +vi.mock("./couchdb.ts", () => ({ + assertCouchDbReachable: vi.fn(async () => undefined), + createCouchDbDatabase: vi.fn(async () => undefined), + deleteCouchDbDatabase: vi.fn(async () => undefined), + loadCouchDbConfig: vi.fn(async () => ({ + uri: "http://localhost:5984", + username: "admin", + password: "password", + dbPrefix: "e2e", + })), + makeUniqueDatabaseName: vi.fn((_prefix: string, suffix: string) => suffix), + waitForCouchDbDocs: vi.fn(async () => undefined), +})); + +vi.mock("./environment.ts", () => ({ + discoverObsidianCli: vi.fn(() => ({ binary: "obsidian-cli", checked: [] })), + requireObsidianBinary: vi.fn(() => "Obsidian"), +})); + +vi.mock("./pathAssertions.ts", () => ({ + waitForExactCaseOnlyRename: vi.fn(async () => undefined), +})); + +vi.mock("./liveSyncWorkflow.ts", () => ({ + assertEqual: vi.fn(), + assertE2eCompatibilityMarker: vi.fn(async () => undefined), + assertE2eCompatibilityReviewPending: vi.fn(async () => undefined), + configureCouchDb: vi.fn(async () => undefined), + createE2eCouchDbPluginData: vi.fn(() => ({})), + prepareRemote: vi.fn(async () => undefined), + pushLocalChanges: vi.fn(async () => { + throw new Error("simulated Obsidian CLI timeout"); + }), + resumeCompatibilityReview: vi.fn(async () => undefined), + waitForLiveSyncCoreReady: vi.fn(async () => undefined), + waitForLocalDatabaseEntry: vi.fn(async () => ({ id: "note-id", children: [] })), +})); + +vi.mock("./session.ts", () => ({ + startObsidianLiveSyncSession: vi.fn(async () => { + const session = { + app: { + stop: vi.fn(async () => { + state.events.push("session:stop"); + }), + }, + cliEnv: {}, + remoteDebuggingPort: 28052, + }; + state.sessions.push(session); + return session; + }), +})); + +vi.mock("./vault.ts", () => ({ + createTemporaryVault: vi.fn(async () => { + state.vaultCount += 1; + const name = `vault-${state.vaultCount}`; + return { + name, + path: `/tmp/${name}`, + dispose: vi.fn(async () => { + state.events.push(`${name}:dispose`); + }), + }; + }), +})); + +describe("two-vault runner lifecycle", () => { + beforeEach(() => { + vi.resetModules(); + state.events.length = 0; + state.sessions.length = 0; + state.vaultCount = 0; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("stops the active Obsidian session before disposing temporary Vaults when synchronisation fails", async () => { + let resolveExit!: (code: number) => void; + const exitCode = new Promise((resolve) => { + resolveExit = resolve; + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(process, "exit").mockImplementation((code) => { + resolveExit(Number(code)); + return undefined as never; + }); + + await import("../scripts/two-vault-sync.ts"); + + expect(await exitCode).toBe(1); + expect(state.sessions).toHaveLength(1); + expect(state.sessions[0].app.stop).toHaveBeenCalledOnce(); + expect(state.events.indexOf("session:stop")).toBeLessThan(state.events.indexOf("vault-1:dispose")); + }); +}); diff --git a/test/e2e-obsidian/runner/ui.ts b/test/e2e-obsidian/runner/ui.ts index d6c88488..27efa226 100644 --- a/test/e2e-obsidian/runner/ui.ts +++ b/test/e2e-obsidian/runner/ui.ts @@ -1,73 +1,85 @@ -import { chromium, type Page } from "playwright"; +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; +import type { Locator, Page } from "playwright"; -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; -} +export { + obsidianRemoteDebuggingPort, + preseedTrustedVaultState, + trustVaultIfPrompted, + withObsidianPage, +} from "@vrtmrz/obsidian-test-session"; -async function waitForCdp(port: number): Promise { - const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_CDP_TIMEOUT_MS ?? 30000); - while (Date.now() < deadline) { +export async function captureObsidianPage( + port: number, + filename: string, + assertReady: (page: Page) => Promise +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, filename); + await mkdir(dirname(screenshotPath), { recursive: true }); + + await withObsidianPage(port, async (page) => { 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 assertReady(page); + } catch (error) { + const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png"); + await page.screenshot({ path: failurePath, fullPage: true }); + console.error(`UI failure screenshot: ${failurePath}`); + throw error; } - 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); + await page.screenshot({ path: screenshotPath, fullPage: true }); }); + + return screenshotPath; } -export async function trustVaultIfPrompted(port: number): Promise { +export async function captureObsidianDialogue( + port: number, + filename: string, + assertReady: (page: Page) => Promise +): Promise { + return await captureObsidianPage(port, filename, assertReady); +} + +export async function captureObsidianElement( + port: number, + filename: string, + resolveElement: (page: Page) => Locator | Promise +): Promise { + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, filename); + await mkdir(dirname(screenshotPath), { recursive: true }); + 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; - } + try { + const element = await resolveElement(page); + await element.waitFor({ state: "visible", timeout: 10000 }); + await element.screenshot({ + path: screenshotPath, + animations: "disabled", + style: ".notice-container { visibility: hidden !important; }", + }); + } catch (error) { + const failurePath = screenshotPath.replace(/\.png$/u, ".failure.png"); + await page.screenshot({ path: failurePath, fullPage: true }); + console.error(`UI element failure screenshot: ${failurePath}`); + throw error; } }); + + return screenshotPath; +} + +export async function captureJsonResolveDialogue(port: number): Promise { + return await captureObsidianDialogue(port, "hidden-file-json-resolve-dialogue.png", async (page) => { + const optionAB = page.locator('label:has(input[name="disp"][value="AB"])'); + const optionBA = page.locator('label:has(input[name="disp"][value="BA"])'); + const applyButton = page.getByRole("button", { name: "Apply" }); + await optionAB.waitFor({ state: "visible", timeout: 10000 }); + await optionBA.waitFor({ state: "visible", timeout: 10000 }); + await applyButton.waitFor({ state: "visible", timeout: 10000 }); + }); } export async function clickJsonResolveOption(port: number, mode: "AB" | "BA"): Promise { diff --git a/test/e2e-obsidian/runner/upgradeContinuity.test.ts b/test/e2e-obsidian/runner/upgradeContinuity.test.ts new file mode 100644 index 00000000..505cecc8 --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeContinuity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { + assertCouchDbCheckpointContinuity, + assertJournalCheckpointLoaded, + assertNoJournalReplay, + type JournalCheckpointSnapshot, +} from "./upgradeContinuity.ts"; + +const journalCheckpoint: JournalCheckpointSnapshot = { + remoteKey: "remote-a", + lastLocalSeq: 42, + journalEpoch: "2:salt", + knownIDs: ["known-a"], + sentIDs: ["sent-a"], + receivedFiles: ["100-docs.jsonl.gz"], + sentFiles: ["101-docs.jsonl.gz"], +}; + +describe("upgrade synchronisation continuity assertions", () => { + it("rejects a fresh CouchDB checkpoint lineage even when final documents could still converge", () => { + expect(() => + assertCouchDbCheckpointContinuity( + [{ id: "_local/original", lastSequence: 42 }], + [{ id: "_local/replacement", lastSequence: 42 }] + ) + ).toThrow("checkpoint identity changed"); + }); + + it("rejects an Object Storage checkpoint which was reset to its initial state", () => { + expect(() => + assertJournalCheckpointLoaded(journalCheckpoint, { + remoteKey: journalCheckpoint.remoteKey, + lastLocalSeq: 0, + journalEpoch: "", + knownIDs: [], + sentIDs: [], + receivedFiles: [], + sentFiles: [], + }) + ).toThrow(/lastLocalSeq regressed|history was lost/u); + }); + + it("rejects hidden Object Storage replay during an otherwise unchanged sync", () => { + expect(() => + assertNoJournalReplay( + journalCheckpoint, + journalCheckpoint, + [{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }], + [{ key: "101-docs.jsonl.gz", size: 10, etag: "etag" }], + { downloadedJournalKeys: ["101-docs.jsonl.gz"], uploadedJournalKeys: [] } + ) + ).toThrow("downloaded previously processed journals"); + }); +}); diff --git a/test/e2e-obsidian/runner/upgradeContinuity.ts b/test/e2e-obsidian/runner/upgradeContinuity.ts new file mode 100644 index 00000000..b5426265 --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeContinuity.ts @@ -0,0 +1,199 @@ +export type CouchDbCheckpointSnapshot = { + id: string; + lastSequence: unknown; +}; + +export type CouchDbDocumentRevision = { + id: string; + revision: string; + deleted: boolean; +}; + +export type JournalCheckpointSnapshot = { + remoteKey: string; + lastLocalSeq: number | string; + journalEpoch: string; + knownIDs: readonly string[]; + sentIDs: readonly string[]; + receivedFiles: readonly string[]; + sentFiles: readonly string[]; +}; + +export type JournalIoObservation = { + downloadedJournalKeys: readonly string[]; + uploadedJournalKeys: readonly string[]; +}; + +export type RemoteObjectSnapshot = { + key: string; + size: number; + etag: string; +}; + +export type MilestoneIdentity = { + created: unknown; + locked: boolean; + acceptedNodes: readonly string[]; +}; + +function sorted(values: readonly string[]): string[] { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function assertEqualStrings(actual: readonly string[], expected: readonly string[], message: string): void { + const actualSorted = sorted(actual); + const expectedSorted = sorted(expected); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + throw new Error(`${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}`); + } +} + +function assertSubset(previous: readonly string[], current: readonly string[], message: string): void { + const currentSet = new Set(current); + const missing = previous.filter((value) => !currentSet.has(value)); + if (missing.length > 0) throw new Error(`${message}: ${missing.join(", ")}`); +} + +function sequenceNumber(sequence: unknown): number | undefined { + if (typeof sequence === "number" && Number.isFinite(sequence)) return sequence; + if (typeof sequence !== "string") return undefined; + const match = /^(\d+)/u.exec(sequence); + return match ? Number(match[1]) : undefined; +} + +function assertSequenceDidNotRegress(before: unknown, after: unknown, label: string): void { + const beforeNumber = sequenceNumber(before); + const afterNumber = sequenceNumber(after); + if (beforeNumber !== undefined && afterNumber !== undefined) { + if (afterNumber < beforeNumber) { + throw new Error(`${label} regressed from ${String(before)} to ${String(after)}.`); + } + return; + } + if (before !== after) { + throw new Error(`${label} changed from an opaque sequence ${String(before)} to ${String(after)}.`); + } +} + +export function assertCouchDbCheckpointContinuity( + before: readonly CouchDbCheckpointSnapshot[], + after: readonly CouchDbCheckpointSnapshot[] +): void { + if (before.length === 0) throw new Error("The stable release did not create a CouchDB replication checkpoint."); + assertEqualStrings( + after.map(({ id }) => id), + before.map(({ id }) => id), + "The CouchDB replication checkpoint identity changed during the upgrade." + ); + const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint])); + for (const checkpoint of before) { + assertSequenceDidNotRegress( + checkpoint.lastSequence, + afterById.get(checkpoint.id)?.lastSequence, + `CouchDB checkpoint ${checkpoint.id}` + ); + } +} + +export function assertSomeCouchDbCheckpointAdvanced( + before: readonly CouchDbCheckpointSnapshot[], + after: readonly CouchDbCheckpointSnapshot[] +): void { + assertCouchDbCheckpointContinuity(before, after); + const afterById = new Map(after.map((checkpoint) => [checkpoint.id, checkpoint])); + const advanced = before.some((checkpoint) => { + const previous = sequenceNumber(checkpoint.lastSequence); + const current = sequenceNumber(afterById.get(checkpoint.id)?.lastSequence); + return previous !== undefined && current !== undefined && current > previous; + }); + if (!advanced) throw new Error("No CouchDB replication checkpoint advanced after the post-upgrade change."); +} + +export function assertCouchDbDocumentsUnchanged( + before: readonly CouchDbDocumentRevision[], + after: readonly CouchDbDocumentRevision[] +): void { + const serialise = (documents: readonly CouchDbDocumentRevision[]) => + [...documents].sort((left, right) => left.id.localeCompare(right.id)); + if (JSON.stringify(serialise(before)) !== JSON.stringify(serialise(after))) { + throw new Error("A no-op post-upgrade CouchDB synchronisation changed ordinary remote documents."); + } +} + +export function assertJournalCheckpointLoaded( + before: JournalCheckpointSnapshot, + after: JournalCheckpointSnapshot +): void { + if (sequenceNumber(before.lastLocalSeq) === 0) { + throw new Error("The stable release did not advance the Object Storage local checkpoint."); + } + if (after.remoteKey !== before.remoteKey) { + throw new Error(`The Object Storage checkpoint key changed from ${before.remoteKey} to ${after.remoteKey}.`); + } + assertSequenceDidNotRegress(before.lastLocalSeq, after.lastLocalSeq, "Object Storage lastLocalSeq"); + assertSubset(before.knownIDs, after.knownIDs, "Object Storage known revision history was lost"); + assertSubset(before.sentIDs, after.sentIDs, "Object Storage sent revision history was lost"); + assertSubset(before.receivedFiles, after.receivedFiles, "Object Storage received journal history was lost"); + assertSubset(before.sentFiles, after.sentFiles, "Object Storage sent journal history was lost"); + if (before.journalEpoch && after.journalEpoch !== before.journalEpoch) { + throw new Error( + `The Object Storage journal epoch changed from ${before.journalEpoch} to ${after.journalEpoch}.` + ); + } +} + +export function assertNoJournalReplay( + beforeCheckpoint: JournalCheckpointSnapshot, + afterCheckpoint: JournalCheckpointSnapshot, + beforeObjects: readonly RemoteObjectSnapshot[], + afterObjects: readonly RemoteObjectSnapshot[], + observation: JournalIoObservation +): void { + assertJournalCheckpointLoaded(beforeCheckpoint, afterCheckpoint); + assertEqualStrings( + afterObjects.map(({ key }) => key), + beforeObjects.map(({ key }) => key), + "A no-op post-upgrade Object Storage synchronisation changed the journal object set." + ); + if (observation.downloadedJournalKeys.length > 0) { + throw new Error( + `The no-op synchronisation downloaded previously processed journals: ${observation.downloadedJournalKeys.join(", ")}` + ); + } + if (observation.uploadedJournalKeys.length > 0) { + throw new Error( + `The no-op synchronisation uploaded replay journals: ${observation.uploadedJournalKeys.join(", ")}` + ); + } +} + +export function assertJournalCheckpointAdvanced( + before: JournalCheckpointSnapshot, + after: JournalCheckpointSnapshot, + observation: JournalIoObservation +): void { + assertJournalCheckpointLoaded(before, after); + const beforeSequence = sequenceNumber(before.lastLocalSeq); + const afterSequence = sequenceNumber(after.lastLocalSeq); + if (beforeSequence === undefined || afterSequence === undefined || afterSequence <= beforeSequence) { + throw new Error( + `The Object Storage checkpoint did not advance after the post-upgrade change (${String(before.lastLocalSeq)} -> ${String(after.lastLocalSeq)}).` + ); + } + if (observation.uploadedJournalKeys.length === 0) { + throw new Error("The post-upgrade Object Storage change did not create a new journal."); + } +} + +export function assertMilestoneContinuity(before: MilestoneIdentity, after: MilestoneIdentity): void { + if (before.created === undefined || before.created === null) { + throw new Error("The stable release milestone does not expose a remote generation identity."); + } + if (after.created !== before.created) { + throw new Error(`The remote milestone generation changed from ${String(before.created)} to ${String(after.created)}.`); + } + if (after.locked !== before.locked) { + throw new Error(`The remote milestone lock changed from ${String(before.locked)} to ${String(after.locked)}.`); + } + assertSubset(before.acceptedNodes, after.acceptedNodes, "The remote milestone lost an accepted device"); +} diff --git a/test/e2e-obsidian/runner/upgradeWorkflow.test.ts b/test/e2e-obsidian/runner/upgradeWorkflow.test.ts new file mode 100644 index 00000000..efe4d3da --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeWorkflow.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { evalObsidianJson } = vi.hoisted(() => ({ + evalObsidianJson: vi.fn(), +})); + +vi.mock("./cli.ts", () => ({ evalObsidianJson })); + +import { prepareStableRemote } from "./upgradeWorkflow.ts"; + +describe("stable remote preparation", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + it("waits for a readable Security Seed before marking the remote as resolved", async () => { + vi.stubEnv("E2E_OBSIDIAN_REMOTE_READY_INTERVAL_MS", "0"); + vi.stubEnv("E2E_OBSIDIAN_REMOTE_READY_TIMEOUT_MS", "100"); + evalObsidianJson + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce({ ok: true }); + + await prepareStableRemote("obsidian-cli", {}); + + expect(evalObsidianJson).toHaveBeenCalledTimes(4); + const scripts = evalObsidianJson.mock.calls.map(([, script]) => String(script)); + expect(scripts[0]).toContain("tryCreateRemoteDatabase"); + expect(scripts[0]).not.toContain("markRemoteResolved"); + expect(scripts[1]).toContain("ensurePBKDF2Salt"); + expect(scripts[2]).toContain("ensurePBKDF2Salt"); + expect(scripts[3]).toContain("markRemoteResolved"); + }); +}); diff --git a/test/e2e-obsidian/runner/upgradeWorkflow.ts b/test/e2e-obsidian/runner/upgradeWorkflow.ts new file mode 100644 index 00000000..76b6c7aa --- /dev/null +++ b/test/e2e-obsidian/runner/upgradeWorkflow.ts @@ -0,0 +1,874 @@ +import { mkdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { evalObsidianJson } from "./cli.ts"; +import type { CouchDbConfig } from "./couchdb.ts"; +import type { ObjectStorageConfig } from "./objectStorage.ts"; +import { withObsidianPage } from "./ui.ts"; +import type { + CouchDbCheckpointSnapshot, + JournalCheckpointSnapshot, + JournalIoObservation, +} from "./upgradeContinuity.ts"; +import { waitForLocalDatabaseEntry } from "./liveSyncWorkflow.ts"; +import type { TemporaryVault } from "./vault.ts"; + +export const STABLE_RELEASE_VERSION = "0.25.83"; + +export type UpgradeTransportConfiguration = + | { + kind: "couchdb"; + config: CouchDbConfig; + databaseName: string; + } + | { + kind: "object-storage"; + config: ObjectStorageConfig; + bucketPrefix: string; + }; + +export type UpgradeScenarioPaths = { + original: string; + renamed: string; + deleted: string; + postUpgrade: string; + returnFromVerifier: string; +}; + +export type RuntimeUpgradeState = { + pluginVersion: string; + vaultName: string; + localDatabaseName: string; + localDatabaseUpdateSequence: number | string; + localDatabaseDocumentCount: number; + nodeId: string; + legacyCompatibilityMarker: string | null; + compatibilityMarker: string; + compatibilityStorageEntries: Record; + migrationState?: { + sourceVersion: number; + targetVersion: number; + isNewVault: boolean; + isFromFutureSchema: boolean; + changed: boolean; + requiresSyncReview: boolean; + reviewReasons: Array<{ code: string; fromVersion: number; toVersion: number }>; + }; + settings: { + isConfigured: boolean | undefined; + settingVersion: number; + versionUpFlash: string; + liveSync: boolean; + syncOnStart: boolean; + syncOnSave: boolean; + syncOnEditorSave: boolean; + syncOnFileOpen: boolean; + syncAfterMerge: boolean; + periodicReplication: boolean; + encrypt: boolean; + usePathObfuscation: boolean; + syncInternalFiles: boolean; + customChunkSize: number; + usePluginSyncV2: boolean; + enableCompression: boolean; + useEden: boolean; + filenameCaseType: string; + handleFilenameCaseSensitive?: boolean; + doNotUseFixedRevisionForChunks: boolean; + chunkSplitterVersion: string; + E2EEAlgorithm: string; + additionalSuffixOfDatabaseName: string; + remoteType: string; + couchDB_DBNAME: string; + endpoint: string; + bucket: string; + bucketPrefix: string; + activeConfigurationId: string; + remoteConfigurationIds: string[]; + doctorProcessedVersion: string; + }; +}; + +export type RuntimeSettingsUpgradeState = Pick & { + compatibilityMarker: string; +}; + +export type CouchDbReplicationObservation = { + succeeded: boolean; + sentDocuments: number; + arrivedDocuments: number; +}; + +export type JournalReplicationObservation = JournalIoObservation & { + succeeded: boolean; +}; + +const firstContent = "# Stable release history\n\nCreated before the 1.0 upgrade.\n"; +const editedContent = "# Stable release history\n\nEdited and renamed before the 1.0 upgrade.\n"; +const deletedContent = "# Deleted before upgrade\n\nThis note must not be resurrected.\n"; +const postUpgradeContent = "# Post-upgrade delta\n\nCreated by the upgraded 1.0 device.\n"; +const returnContent = "# Return journey\n\nCreated by a fresh 1.0 verifier device.\n"; + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function assertStringArraysEqual(actual: readonly string[], expected: readonly string[], message: string): void { + const actualSorted = [...actual].sort(); + const expectedSorted = [...expected].sort(); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + throw new Error( + `${message}\nExpected: ${JSON.stringify(expectedSorted)}\nActual: ${JSON.stringify(actualSorted)}` + ); + } +} + +export function createUpgradeScenarioPaths(label: string): UpgradeScenarioPaths { + const root = `E2E/upgrade-from-${STABLE_RELEASE_VERSION}/${label}`; + return { + original: `${root}/rename-source.md`, + renamed: `${root}/renamed.md`, + deleted: `${root}/deleted.md`, + postUpgrade: `${root}/post-upgrade.md`, + returnFromVerifier: `${root}/return-from-verifier.md`, + }; +} + +function remoteSettings(configuration: UpgradeTransportConfiguration): Record { + if (configuration.kind === "couchdb") { + return { + remoteType: "", + couchDB_URI: configuration.config.uri, + couchDB_USER: configuration.config.username, + couchDB_PASSWORD: configuration.config.password, + couchDB_DBNAME: configuration.databaseName, + isConfigured: true, + }; + } + return { + remoteType: "MINIO", + endpoint: configuration.config.endpoint, + accessKey: configuration.config.accessKey, + secretKey: configuration.config.secretKey, + bucket: configuration.config.bucket, + region: configuration.config.region, + forcePathStyle: configuration.config.forcePathStyle, + bucketPrefix: configuration.bucketPrefix, + bucketCustomHeaders: "", + isConfigured: true, + }; +} + +export async function configureStableRelease( + cliBinary: string, + environment: NodeJS.ProcessEnv, + configuration: UpgradeTransportConfiguration +): Promise { + const partial = remoteSettings(configuration); + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const partial=${JSON.stringify(partial)};`, + "await core.services.setting.applyExternalSettings(partial,true);", + "await core.services.control.applySettings();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +export async function prepareStableRemote(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "await replicator.tryCreateRemoteDatabase(settings);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + + const timeoutMs = Number(process.env.E2E_OBSIDIAN_REMOTE_READY_TIMEOUT_MS ?? 15000); + const intervalMs = Number(process.env.E2E_OBSIDIAN_REMOTE_READY_INTERVAL_MS ?? 250); + const deadline = Date.now() + timeoutMs; + let securitySeedReady = false; + do { + securitySeedReady = await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "return JSON.stringify(!!(await replicator.ensurePBKDF2Salt(settings,true,false)));", + "})()", + ].join(""), + environment + ); + if (securitySeedReady) break; + if (Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } while (Date.now() < deadline); + if (!securitySeedReady) { + throw new Error(`Timed out waiting for the stable release Security Seed after ${timeoutMs}ms.`); + } + + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const replicator=core.services.replicator.getActiveReplicator();", + "await replicator.markRemoteResolved(settings);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +export async function waitForPersistentNodeIdentity( + cliBinary: string, + environment: NodeJS.ProcessEnv, + timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000) +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const database=core.localDatabase.localDatabase;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "let persistent='';let active='';", + "while(Date.now()null);", + "persistent=typeof nodeInfo?.nodeid==='string'?nodeInfo.nodeid:'';", + "active=core.services.replicator.getActiveReplicator()?.nodeid??'';", + "if(persistent!==''&&active===persistent) return JSON.stringify(persistent);", + "await sleep(100);", + "}", + "throw new Error(`Timed out waiting for persistent node identity: persistent=${persistent}, active=${active}`);", + "})()", + ].join(""), + environment + ); +} + +export async function readRuntimeUpgradeState( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const core=plugin.core;", + "const setting=core.services.setting;", + "const settings=setting.currentSettings();", + "const vaultName=core.services.vault.getVaultName();", + "const replicator=core.services.replicator.getActiveReplicator();", + "const databaseInfo=await core.localDatabase.localDatabase.info();", + "const nodeInfo=await core.localDatabase.localDatabase.get('_local/obsydian_livesync_nodeinfo').catch(()=>null);", + "const migrationState=setting.getSettingsMigrationState?.();", + "return JSON.stringify({", + "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',", + "vaultName,", + "localDatabaseName:databaseInfo.db_name,", + "localDatabaseUpdateSequence:databaseInfo.update_seq,", + "localDatabaseDocumentCount:databaseInfo.doc_count,", + "nodeId:nodeInfo?.nodeid??replicator?.nodeid??'',", + "legacyCompatibilityMarker:localStorage.getItem(`obsidian-live-sync-ver${vaultName}`),", + "compatibilityMarker:setting.getSmallConfig('database-compatibility-version')??'',", + "compatibilityStorageEntries:Object.fromEntries(Array.from({length:localStorage.length},(_,index)=>localStorage.key(index))", + ".filter((key)=>key!==null)", + ".filter(key=>key.startsWith('obsidian-live-sync-ver')||key.endsWith('-database-compatibility-version'))", + ".map(key=>[key,localStorage.getItem(key)??''])),", + "migrationState,", + "settings:{", + "isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,", + "versionUpFlash:settings.versionUpFlash,", + "liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,", + "syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,", + "syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,", + "encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,", + "syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,", + "usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,", + "useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,", + "handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,", + "doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,", + "chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,", + "additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',", + "remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',", + "endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',", + "activeConfigurationId:settings.activeConfigurationId??'',", + "remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),", + "doctorProcessedVersion:settings.doctorProcessedVersion??'',", + "}", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function readRuntimeSettingsUpgradeState( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const setting=plugin.core.services.setting;", + "const settings=setting.currentSettings();", + "const migrationState=setting.getSettingsMigrationState?.();", + "return JSON.stringify({", + "pluginVersion:app.plugins.manifests['obsidian-livesync']?.version??'unknown',", + "compatibilityMarker:setting.getSmallConfig?.('database-compatibility-version')??'',", + "migrationState,", + "settings:{", + "isConfigured:settings.isConfigured,settingVersion:settings.settingVersion,", + "versionUpFlash:settings.versionUpFlash,", + "liveSync:settings.liveSync,syncOnStart:settings.syncOnStart,syncOnSave:settings.syncOnSave,", + "syncOnEditorSave:settings.syncOnEditorSave,syncOnFileOpen:settings.syncOnFileOpen,", + "syncAfterMerge:settings.syncAfterMerge,periodicReplication:settings.periodicReplication,", + "encrypt:settings.encrypt,usePathObfuscation:settings.usePathObfuscation,", + "syncInternalFiles:settings.syncInternalFiles,customChunkSize:settings.customChunkSize,", + "usePluginSyncV2:settings.usePluginSyncV2,enableCompression:settings.enableCompression,", + "useEden:settings.useEden,filenameCaseType:typeof settings.handleFilenameCaseSensitive,", + "handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive,", + "doNotUseFixedRevisionForChunks:settings.doNotUseFixedRevisionForChunks,", + "chunkSplitterVersion:settings.chunkSplitterVersion,E2EEAlgorithm:settings.E2EEAlgorithm,", + "additionalSuffixOfDatabaseName:settings.additionalSuffixOfDatabaseName??'',", + "remoteType:settings.remoteType,couchDB_DBNAME:settings.couchDB_DBNAME??'',", + "endpoint:settings.endpoint??'',bucket:settings.bucket??'',bucketPrefix:settings.bucketPrefix??'',", + "activeConfigurationId:settings.activeConfigurationId??'',", + "remoteConfigurationIds:Object.keys(settings.remoteConfigurations??{}),", + "doctorProcessedVersion:settings.doctorProcessedVersion??'',", + "}", + "});", + "})()", + ].join(""), + environment + ); +} + +export function assertStableReleaseDefaults(state: RuntimeSettingsUpgradeState, configured: boolean): void { + assertEqual( + state.pluginVersion, + STABLE_RELEASE_VERSION, + "The source session did not load the pinned stable release." + ); + assertEqual(state.settings.isConfigured, configured, "The stable release configuration lifecycle was unexpected."); + assertEqual(state.settings.settingVersion, 10, "The stable release settings schema was not version 10."); + assertEqual(state.settings.liveSync, false, "The stable release LiveSync default changed."); + assertEqual(state.settings.syncOnStart, false, "The stable release sync-on-start default changed."); + assertEqual(state.settings.syncOnSave, false, "The stable release sync-on-save default changed."); + assertEqual(state.settings.syncOnEditorSave, false, "The stable release editor-save default changed."); + assertEqual(state.settings.syncOnFileOpen, false, "The stable release file-open default changed."); + assertEqual(state.settings.syncAfterMerge, false, "The stable release post-merge default changed."); + assertEqual(state.settings.periodicReplication, false, "The stable release periodic default changed."); + assertEqual(state.settings.encrypt, false, "The stable release encryption default changed."); + assertEqual(state.settings.usePathObfuscation, false, "The stable release path-obfuscation default changed."); + assertEqual(state.settings.syncInternalFiles, false, "The stable release Hidden File default changed."); + assertEqual(state.settings.customChunkSize, 0, "The stable release custom chunk default changed."); + assertEqual(state.settings.usePluginSyncV2, false, "The stable release Customisation Sync V2 default changed."); + assertEqual(state.settings.enableCompression, false, "The stable release compression default changed."); + assertEqual(state.settings.useEden, false, "The stable release Eden default changed."); + assertEqual( + state.settings.filenameCaseType, + "undefined", + "The stable release filename-case decision was preselected." + ); + assertEqual( + state.settings.doNotUseFixedRevisionForChunks, + true, + "The stable release fixed-revision compatibility value changed." + ); + assertEqual(state.settings.chunkSplitterVersion, "v3-rabin-karp", "The stable release chunk splitter changed."); + assertEqual(state.settings.E2EEAlgorithm, "v2", "The stable release E2EE algorithm changed."); + assertEqual( + state.settings.remoteConfigurationIds.length, + configured ? 1 : 0, + "The stable release remote-profile count was unexpected." + ); +} + +export function assertUnconfiguredUpgradeReady( + stable: RuntimeSettingsUpgradeState, + upgraded: RuntimeSettingsUpgradeState, + targetVersion: string +): void { + assertStableReleaseDefaults(stable, false); + assertEqual(upgraded.pluginVersion, targetVersion, "The unconfigured Vault did not load the target artefact."); + assertEqual(upgraded.settings.isConfigured, false, "The upgrade changed an unconfigured Vault to configured."); + assertEqual( + upgraded.settings.usePluginSyncV2, + stable.settings.usePluginSyncV2, + "The upgrade applied a new-Vault recommendation to a non-empty legacy store." + ); + assertEqual( + upgraded.settings.handleFilenameCaseSensitive, + false, + "The unconfigured legacy Vault did not retain case-insensitive handling." + ); + assertEqual(upgraded.settings.versionUpFlash, "", "The unconfigured Vault was paused for compatibility review."); + assertEqual( + upgraded.compatibilityMarker, + "", + "The unconfigured Vault acknowledged database compatibility before activation." + ); + if (!upgraded.migrationState) throw new Error("The unconfigured settings migration state was not available."); + assertEqual(upgraded.migrationState.isNewVault, false, "The non-empty legacy store was treated as a new store."); + // The real-session helper deliberately reloads an already enabled plug-in. + // The first target load performs and persists the migration; the observed + // post-reload state can therefore report changed=false. The workflow reads + // data.json after stopping the session to prove the persisted values. + assertEqual( + upgraded.migrationState.requiresSyncReview, + false, + "The unconfigured legacy settings unexpectedly required compatibility review." + ); + assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The unconfigured migration emitted a review reason."); +} + +export function assertUnconfiguredUpgradeRestarted(state: RuntimeSettingsUpgradeState, targetVersion: string): void { + assertEqual(state.pluginVersion, targetVersion, "The unconfigured restart did not load the target artefact."); + assertEqual(state.settings.isConfigured, false, "The unconfigured state was not persisted across restart."); + assertEqual(state.settings.usePluginSyncV2, false, "Restart applied a new-Vault recommendation."); + assertEqual(state.settings.handleFilenameCaseSensitive, false, "Restart lost the case-insensitive policy."); + assertEqual( + state.compatibilityMarker, + "", + "Restart acknowledged database compatibility while the Vault remained unconfigured." + ); + if (!state.migrationState) throw new Error("The restarted settings migration state was not available."); + assertEqual(state.migrationState.changed, false, "The settings migration was not idempotent after restart."); + assertEqual(state.migrationState.requiresSyncReview, false, "Restart introduced a compatibility review."); +} + +export function assertStableRemoteSelection( + state: RuntimeUpgradeState, + configuration: UpgradeTransportConfiguration +): void { + assertEqual(state.settings.isConfigured, true, "The stable release was not marked as configured."); + if (!state.settings.activeConfigurationId) throw new Error("The stable release did not select its remote profile."); + if (!state.settings.remoteConfigurationIds.includes(state.settings.activeConfigurationId)) { + throw new Error("The stable release active remote profile was not persisted."); + } + if (configuration.kind === "couchdb") { + assertEqual(state.settings.remoteType, "", "The stable release did not select CouchDB."); + assertEqual( + state.settings.couchDB_DBNAME, + configuration.databaseName, + "The stable release CouchDB database changed." + ); + } else { + assertEqual(state.settings.remoteType, "MINIO", "The stable release did not select Object Storage."); + assertEqual(state.settings.endpoint, configuration.config.endpoint, "The Object Storage endpoint changed."); + assertEqual(state.settings.bucket, configuration.config.bucket, "The Object Storage bucket changed."); + assertEqual(state.settings.bucketPrefix, configuration.bucketPrefix, "The Object Storage prefix changed."); + } +} + +export function assertUpgradeCompatibilityReady( + stable: RuntimeUpgradeState, + upgraded: RuntimeUpgradeState, + targetVersion: string, + configuration: UpgradeTransportConfiguration +): void { + assertEqual(upgraded.pluginVersion, targetVersion, "The upgraded session did not load the target artefact."); + assertEqual( + upgraded.localDatabaseName, + stable.localDatabaseName, + "The upgrade opened a different local synchronisation database." + ); + if (upgraded.localDatabaseDocumentCount < stable.localDatabaseDocumentCount) { + throw new Error("The upgrade lost local synchronisation documents before its first sync."); + } + if (stable.nodeId.length === 0) { + throw new Error("The stable release did not persist a device node identity."); + } + assertEqual(upgraded.nodeId, stable.nodeId, "The upgrade changed the persistent device node identity."); + assertEqual( + upgraded.settings.additionalSuffixOfDatabaseName, + stable.settings.additionalSuffixOfDatabaseName, + "The upgrade changed the local database suffix." + ); + assertStringArraysEqual( + upgraded.settings.remoteConfigurationIds, + stable.settings.remoteConfigurationIds, + "The upgrade changed the stored remote-profile identities." + ); + assertEqual( + upgraded.settings.activeConfigurationId, + stable.settings.activeConfigurationId, + "The upgrade changed the active remote profile." + ); + assertStableRemoteSelection(upgraded, configuration); + for (const key of [ + "liveSync", + "syncOnStart", + "syncOnSave", + "syncOnEditorSave", + "syncOnFileOpen", + "syncAfterMerge", + "periodicReplication", + "customChunkSize", + "usePluginSyncV2", + "enableCompression", + "useEden", + "doNotUseFixedRevisionForChunks", + ] as const) { + assertEqual(upgraded.settings[key], stable.settings[key], `The upgrade rewrote the stored ${key} preference.`); + } + if (!upgraded.migrationState) throw new Error("The 1.0 settings migration state was not available."); + // The session helper reloads the enabled target after its first load has + // persisted the normalised case value. Runtime settings below and the + // later restart prove that persisted result without depending on whether + // this observation came from the first or second load. + assertEqual( + upgraded.migrationState.requiresSyncReview, + false, + "The legacy case-insensitive setting unexpectedly required compatibility review." + ); + assertEqual(upgraded.migrationState.reviewReasons.length, 0, "The settings migration emitted a spurious review."); + assertEqual(stable.legacyCompatibilityMarker, "12", "The stable release did not persist its legacy marker."); + assertEqual(upgraded.legacyCompatibilityMarker, null, "The upgrade did not retire the legacy marker."); + assertEqual( + upgraded.compatibilityMarker, + "12", + [ + "The upgrade did not migrate the legacy compatibility marker.", + `Vault: ${upgraded.vaultName}`, + `Database suffix: ${upgraded.settings.additionalSuffixOfDatabaseName}`, + `Device-local entries: ${JSON.stringify(upgraded.compatibilityStorageEntries)}`, + ].join("\n") + ); + assertEqual(upgraded.settings.versionUpFlash, "", "Synchronisation was unexpectedly paused after migration."); + assertEqual( + upgraded.settings.handleFilenameCaseSensitive, + false, + "The missing legacy filename-case value did not preserve case-insensitive handling." + ); +} + +export function assertUpgradeRemainsReady(state: RuntimeUpgradeState, targetVersion: string): void { + assertEqual(state.pluginVersion, targetVersion, "The upgraded session changed target artefact."); + assertEqual(state.settings.versionUpFlash, "", "A compatibility pause reappeared."); + assertEqual(state.compatibilityMarker, "12", "The compatibility acknowledgement was not persisted."); + assertEqual( + state.settings.handleFilenameCaseSensitive, + false, + "The migrated legacy case-insensitive policy was not persisted." + ); +} + +export async function dismissConfigDoctorIfShown(port: number): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 10000); + return await withObsidianPage(port, async (page) => { + const doctor = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }), + }); + const visible = await doctor + .waitFor({ state: "visible", timeout: Math.min(timeoutMs, 5000) }) + .then(() => true) + .catch(() => false); + if (!visible) return false; + await doctor.getByRole("button", { name: /No, and do not ask again/u }).click(); + await doctor.waitFor({ state: "hidden", timeout: timeoutMs }); + return true; + }); +} + +async function writeNote( + cliBinary: string, + environment: 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(""), + environment + ); +} + +async function renameNote( + cliBinary: string, + environment: NodeJS.ProcessEnv, + fromPath: string, + toPath: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const fromPath=${JSON.stringify(fromPath)};`, + `const toPath=${JSON.stringify(toPath)};`, + "const folder=toPath.split('/').slice(0,-1).join('/');", + "if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);", + "const existing=app.vault.getAbstractFileByPath(fromPath);", + "if(!existing) throw new Error(`Could not find note to rename: ${fromPath}`);", + "await app.vault.rename(existing,toPath);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function deleteNote(cliBinary: string, environment: NodeJS.ProcessEnv, path: string): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const existing=app.vault.getAbstractFileByPath(path);", + "if(!existing) throw new Error(`Could not find note to delete: ${path}`);", + "await app.vault.delete(existing);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); +} + +async function waitForChangedRevision( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string, + previousRevision: string +): Promise { + const timeoutMs = Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const previousRevision=${JSON.stringify(previousRevision)};`, + `const deadline=Date.now()+${JSON.stringify(timeoutMs)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "while(Date.now()false);", + "if(entry&&entry._rev&&entry._rev!==previousRevision) return JSON.stringify({rev:entry._rev});", + "await sleep(250);", + "}", + "throw new Error(`Timed out waiting for a changed local revision: ${path}`);", + "})()", + ].join(""), + environment + ); +} + +export async function runStableFileHistory( + cliBinary: string, + environment: NodeJS.ProcessEnv, + paths: UpgradeScenarioPaths, + synchronise: () => Promise +): Promise { + await writeNote(cliBinary, environment, paths.original, firstContent); + await writeNote(cliBinary, environment, paths.deleted, deletedContent); + const originalEntry = await waitForLocalDatabaseEntry(cliBinary, environment, paths.original); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.deleted); + await synchronise(); + + await writeNote(cliBinary, environment, paths.original, editedContent); + await waitForChangedRevision(cliBinary, environment, paths.original, originalEntry.rev); + await synchronise(); + + await renameNote(cliBinary, environment, paths.original, paths.renamed); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.renamed); + await synchronise(); + + await deleteNote(cliBinary, environment, paths.deleted); + await synchronise(); +} + +export async function createPostUpgradeDelta( + cliBinary: string, + environment: NodeJS.ProcessEnv, + paths: UpgradeScenarioPaths +): Promise { + await writeNote(cliBinary, environment, paths.postUpgrade, postUpgradeContent); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.postUpgrade); +} + +export async function createVerifierReturnDelta( + cliBinary: string, + environment: NodeJS.ProcessEnv, + paths: UpgradeScenarioPaths +): Promise { + await writeNote(cliBinary, environment, paths.returnFromVerifier, returnContent); + await waitForLocalDatabaseEntry(cliBinary, environment, paths.returnFromVerifier); +} + +async function pathExists(vault: TemporaryVault, path: string): Promise { + try { + await readFile(join(vault.path, path)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +async function waitForPathContent(vault: TemporaryVault, path: string, content: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000); + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === content) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +export async function verifyPreUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await waitForPathContent(vault, paths.renamed, editedContent); + if (await pathExists(vault, paths.original)) throw new Error(`Renamed source was resurrected: ${paths.original}`); + if (await pathExists(vault, paths.deleted)) throw new Error(`Deleted note was resurrected: ${paths.deleted}`); +} + +export async function verifyPostUpgradeHistory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await verifyPreUpgradeHistory(vault, paths); + await waitForPathContent(vault, paths.postUpgrade, postUpgradeContent); +} + +export async function verifyReturnDelta(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await waitForPathContent(vault, paths.returnFromVerifier, returnContent); +} + +export async function ensureScenarioDirectory(vault: TemporaryVault, paths: UpgradeScenarioPaths): Promise { + await mkdir(dirname(join(vault.path, paths.original)), { recursive: true }); +} + +export async function runCouchDbReplicationObserved( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "await core.services.fileProcessing.commitPendingFileEvents();", + "const beforeSent=Number(replicator.docSent??0);", + "const beforeArrived=Number(replicator.docArrived??0);", + "const result=await core.services.replication.replicate(true);", + "return JSON.stringify({", + "succeeded:!!result,", + "sentDocuments:Number(replicator.docSent??0)-beforeSent,", + "arrivedDocuments:Number(replicator.docArrived??0)-beforeArrived,", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function runJournalReplicationObserved( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "const client=replicator.client;", + "const storage=client.storage;", + "const originalDownload=storage.download.bind(storage);", + "const originalUpload=storage.upload.bind(storage);", + "const downloadedJournalKeys=[];const uploadedJournalKeys=[];", + "const isJournal=(key)=>!String(key).split('/').pop().startsWith('_');", + "storage.download=async(key,...args)=>{if(isJournal(key))downloadedJournalKeys.push(String(key));return await originalDownload(key,...args);};", + "storage.upload=async(key,...args)=>{if(isJournal(key))uploadedJournalKeys.push(String(key));return await originalUpload(key,...args);};", + "let succeeded=false;", + "try{", + "await core.services.fileProcessing.commitPendingFileEvents();", + "succeeded=!!(await core.services.replication.replicate(true));", + "}finally{storage.download=originalDownload;storage.upload=originalUpload;}", + "return JSON.stringify({succeeded,downloadedJournalKeys,uploadedJournalKeys});", + "})()", + ].join(""), + environment + ); +} + +export async function readJournalCheckpoint( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const replicator=core.services.replicator.getActiveReplicator();", + "const client=replicator.client;", + "const checkpoint=await client.getCheckpointInfo();", + "const sorted=(value)=>[...(value??[])].sort();", + "return JSON.stringify({", + "remoteKey:client.getRemoteKey(),lastLocalSeq:checkpoint.lastLocalSeq,journalEpoch:checkpoint.journalEpoch,", + "knownIDs:sorted(checkpoint.knownIDs),sentIDs:sorted(checkpoint.sentIDs),", + "receivedFiles:sorted(checkpoint.receivedFiles),sentFiles:sorted(checkpoint.sentFiles),", + "});", + "})()", + ].join(""), + environment + ); +} + +export async function readLocalCouchDbCheckpoints( + cliBinary: string, + environment: NodeJS.ProcessEnv, + checkpointIds: readonly string[] +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const ids=${JSON.stringify(checkpointIds)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const database=core.localDatabase.localDatabase;", + "const checkpoints=[];", + "for(const id of ids){", + "const doc=await database.get(id).catch(()=>false);", + "if(doc&&Object.prototype.hasOwnProperty.call(doc,'last_seq')) checkpoints.push({id,lastSequence:doc.last_seq});", + "}", + "return JSON.stringify(checkpoints);", + "})()", + ].join(""), + environment + ); +} 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", + }); } diff --git a/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts b/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts new file mode 100644 index 00000000..d0a13a12 --- /dev/null +++ b/test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts @@ -0,0 +1,355 @@ +import { spawn } from "node:child_process"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; +process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "30000"; + +const liveSyncCli = resolve("src/apps/cli/dist/index.cjs"); +const notePath = "E2E/cli-to-obsidian.md"; +const noteContent = [ + "# CLI to real Obsidian", + "", + "This note was created by the Self-hosted LiveSync CLI.", + "The real Obsidian plug-in must retrieve the same content from CouchDB.", + "0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz", + "", +].join("\n"); +const e2eePassphrase = "real-obsidian-cli-compatibility-e2e"; + +type LiveSyncCliCommand = { + executable: string; + prefixArgs: string[]; +}; + +type CliResult = { + stdout: string; + stderr: string; +}; + +type CliFileInfo = { + id: string; + children: string[]; +}; + +function parseCommandLine(value: string): string[] { + const trimmed = value.trim(); + if (trimmed.startsWith("[")) { + const parsed = JSON.parse(trimmed) as unknown; + if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((part) => typeof part !== "string")) { + throw new Error("LIVESYNC_CLI_COMMAND JSON form must be a non-empty array of strings."); + } + return parsed; + } + + const parts: string[] = []; + let current = ""; + let quote: "'" | '"' | undefined; + let tokenStarted = false; + for (let index = 0; index < trimmed.length; index++) { + const character = trimmed[index]; + if (quote) { + if (character === quote) { + quote = undefined; + continue; + } + if (character === "\\" && quote === '"' && ['"', "\\"].includes(trimmed[index + 1] ?? "")) { + current += trimmed[++index]; + continue; + } + current += character; + continue; + } + if (character === "'" || character === '"') { + quote = character; + tokenStarted = true; + continue; + } + if (character === "\\" && ["'", '"', "\\", " ", "\t"].includes(trimmed[index + 1] ?? "")) { + current += trimmed[++index]; + tokenStarted = true; + continue; + } + if (/\s/u.test(character)) { + if (tokenStarted) { + parts.push(current); + current = ""; + tokenStarted = false; + } + continue; + } + current += character; + tokenStarted = true; + } + if (quote) { + throw new Error("LIVESYNC_CLI_COMMAND contains an unterminated quoted value."); + } + if (tokenStarted) { + parts.push(current); + } + if (parts.length === 0) { + throw new Error("LIVESYNC_CLI_COMMAND must not be empty."); + } + return parts; +} + +function resolveLiveSyncCliCommand(): LiveSyncCliCommand { + const override = process.env.LIVESYNC_CLI_COMMAND; + if (override !== undefined) { + const [executable, ...prefixArgs] = parseCommandLine(override); + return { executable, prefixArgs }; + } + return { executable: process.execPath, prefixArgs: [liveSyncCli] }; +} + +async function runLiveSyncCli(command: LiveSyncCliCommand, args: string[]): Promise { + return await new Promise((resolvePromise, reject) => { + const timeoutMs = Number(process.env.E2E_LIVESYNC_CLI_TIMEOUT_MS ?? 60000); + const child = spawn(command.executable, [...command.prefixArgs, ...args], { + cwd: process.cwd(), + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf-8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf-8"); + }); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + }, timeoutMs); + child.on("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + child.on("exit", (code, signal) => { + clearTimeout(timeout); + const result = { + stdout, + stderr, + }; + if (timedOut) { + reject( + new Error( + `LiveSync CLI timed out after ${timeoutMs} ms\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ) + ); + return; + } + if (code === 0) { + resolvePromise(result); + return; + } + reject( + new Error( + `LiveSync CLI failed with ${signal ? `signal ${signal}` : `exit code ${String(code)}`}\n` + + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ) + ); + }); + }); +} + +async function configureLiveSyncCli( + command: LiveSyncCliCommand, + settingsPath: string, + couchDb: Awaited>, + dbName: string +): Promise { + await runLiveSyncCli(command, ["init-settings", "--force", settingsPath]); + const settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record; + Object.assign(settings, { + couchDB_URI: couchDb.uri, + couchDB_USER: couchDb.username, + couchDB_PASSWORD: couchDb.password, + couchDB_DBNAME: dbName, + remoteType: "", + liveSync: false, + syncOnStart: false, + syncOnSave: false, + usePluginSync: false, + usePluginSyncV2: true, + useEden: false, + customChunkSize: 60, + sendChunksBulk: false, + sendChunksBulkMaxSize: 1, + chunkSplitterVersion: "v3-rabin-karp", + readChunksOnline: true, + disableCheckingConfigMismatch: false, + enableCompression: false, + hashAlg: "xxhash64", + handleFilenameCaseSensitive: false, + doNotUseFixedRevisionForChunks: true, + E2EEAlgorithm: "v2", + encrypt: true, + passphrase: e2eePassphrase, + usePathObfuscation: true, + doctorProcessedVersion: "0.25.27", + isConfigured: true, + }); + await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); +} + +async function writeCliNote( + command: LiveSyncCliCommand, + databasePath: string, + settingsPath: string, + sourcePath: string +): Promise { + await mkdir(dirname(sourcePath), { recursive: true }); + await writeFile(sourcePath, noteContent, "utf-8"); + await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "push", sourcePath, notePath]); + const info = await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "info", notePath]); + const fileInfo = JSON.parse(info.stdout) as CliFileInfo; + if (!fileInfo.id || !Array.isArray(fileInfo.children) || fileInfo.children.length === 0) { + throw new Error(`LiveSync CLI did not create complete metadata for ${notePath}: ${info.stdout}`); + } + await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "sync"]); + return fileInfo; +} + +async function waitForVaultContent( + vaultPath: string, + path: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS) +): Promise { + const fullPath = join(vaultPath, path); + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(fullPath, "utf-8"); + if (lastContent === noteContent) { + return lastContent; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + } + throw new Error(`Timed out waiting for CLI-created note at ${fullPath}. Last content:\n${lastContent}`); +} + +async function main(): Promise { + const liveSyncCliCommand = resolveLiveSyncCliCommand(); + if (process.env.LIVESYNC_CLI_COMMAND === undefined) { + await access(liveSyncCli).catch(() => { + throw new Error( + `Built LiveSync CLI was not found at ${liveSyncCli}. Run 'npm run build -w self-hosted-livesync-cli' first, or set LIVESYNC_CLI_COMMAND.` + ); + }); + } + + const binary = requireObsidianBinary(); + const obsidianCli = discoverObsidianCli(); + if (!obsidianCli.binary) { + throw new Error(`Could not find obsidian-cli. Checked paths: ${obsidianCli.checked.join(", ")}`); + } + + const couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "cli-to-obsidian"); + const cliState = await mkdtemp(join(tmpdir(), "livesync-cli-to-obsidian-e2e-")); + const cliDatabasePath = join(cliState, "database"); + const cliSettingsPath = join(cliState, "settings.json"); + const cliSourcePath = join(cliState, "source", "cli-to-obsidian.md"); + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + + try { + await assertCouchDbReachable(couchDb); + await createCouchDbDatabase(couchDb, dbName); + await mkdir(cliDatabasePath, { recursive: true }); + await configureLiveSyncCli(liveSyncCliCommand, cliSettingsPath, couchDb, dbName); + + if (process.env.LIVESYNC_CLI_COMMAND === undefined) { + console.log(`Using locally built LiveSync CLI: ${liveSyncCli}`); + } else { + console.log( + `Using LiveSync CLI command override: ${JSON.stringify(liveSyncCliCommand.executable)} ` + + `with ${liveSyncCliCommand.prefixArgs.length} prefix argument(s)` + ); + } + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary Obsidian vault: ${vault.path}`); + console.log(`Temporary CouchDB database: ${dbName}`); + + const cliFileInfo = await writeCliNote(liveSyncCliCommand, cliDatabasePath, cliSettingsPath, cliSourcePath); + await waitForCouchDbDocs(couchDb, dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(cliFileInfo.id) && cliFileInfo.children.every((childId) => ids.has(childId)); + }); + + session = await startObsidianLiveSyncSession({ + binary, + 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 prepareRemote(obsidianCli.binary, session.cliEnv); + await pushLocalChanges(obsidianCli.binary, session.cliEnv); + + const received = await waitForVaultContent(vault.path, notePath); + assertEqual(received, noteContent, "The real Obsidian plug-in did not materialise the CLI-created note."); + console.log("CLI-created encrypted note was retrieved by the real Obsidian plug-in with identical content."); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + await rm(cliState, { recursive: true, force: true }); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/conflict-dialog-policy.ts b/test/e2e-obsidian/scripts/conflict-dialog-policy.ts new file mode 100644 index 00000000..254256bc --- /dev/null +++ b/test/e2e-obsidian/scripts/conflict-dialog-policy.ts @@ -0,0 +1,434 @@ +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 = "conflict-dialog-policy.md"; +const baseContent = "Conflict dialogue policy\n\nShared base.\n"; +const leftContent = "Conflict dialogue policy\n\nChanged on the left.\n"; +const rightContent = "Conflict dialogue policy\n\nChanged on the right.\n"; +const thirdContent = "Conflict dialogue policy\n\nChanged on the third branch.\n"; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_CONFLICT_DIALOG_TIMEOUT_MS ?? 10000); + +type ConflictFixture = { + currentRev: string; + currentParentRev?: string; + conflicts: string[]; +}; + +type ObsidianTestApp = { + commands?: { executeCommandById(commandId: string): boolean }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +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 createManualConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + baseRev: string, + contents: readonly string[] +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const baseRev=${JSON.stringify(baseRev)};`, + `const contents=${JSON.stringify(contents)};`, + "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,baseRev);", + " if(!result?.ok) throw new Error(`Could not create conflict branch: ${path}`);", + "}", + "const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);", + "if(!meta?._rev||!meta._conflicts?.length){", + " throw new Error(`Conflict fixture did not produce multiple live leaves: ${path}`);", + "}", + "return JSON.stringify({currentRev:meta._rev,conflicts:meta._conflicts});", + "})()", + ].join(""), + env + ); +} + +async function readConflictFixture(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 meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true,revs:true},true);", + "if(!meta?._rev){", + " throw new Error(`Could not read the conflict fixture: ${path}`);", + "}", + "const revisions=meta._revisions;", + "const currentParentRev=revisions?.ids?.length>1", + " ? `${revisions.start-1}-${revisions.ids[1]}`", + " : undefined;", + "return JSON.stringify({currentRev:meta._rev,currentParentRev,conflicts:meta._conflicts??[]});", + "})()", + ].join(""), + env + ); +} + +async function waitForConflictCount( + cliBinary: string, + env: NodeJS.ProcessEnv, + expectedConflictCount: number +): Promise { + const deadline = Date.now() + uiTimeoutMs; + let fixture = await readConflictFixture(cliBinary, env); + while (fixture.conflicts.length !== expectedConflictCount && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + fixture = await readConflictFixture(cliBinary, env); + } + if (fixture.conflicts.length !== expectedConflictCount) { + throw new Error( + `Expected ${expectedConflictCount + 1} live version(s), but found ${fixture.conflicts.length + 1}: ${JSON.stringify(fixture)}` + ); + } + return fixture; +} + +async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv, waitForCompletion = false) { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const waitForCompletion=${JSON.stringify(waitForCompletion)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.conflict.queueCheckFor(path);", + "if(waitForCompletion){", + " await core.services.conflict.ensureAllProcessed();", + "}", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function waitForConflictChecks(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.conflict.ensureAllProcessed();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +async function applyReplicatedConflictResolution( + cliBinary: string, + env: NodeJS.ProcessEnv, + revisionToDelete: string, + expectedConflictCount = 0 +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const revisionToDelete=${JSON.stringify(revisionToDelete)};`, + `const expectedConflictCount=${JSON.stringify(expectedConflictCount)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "if(!(await core.fileHandler.deleteRevisionFromDB(path,revisionToDelete))){", + " throw new Error(`Could not apply the replicated conflict resolution: ${path} ${revisionToDelete}`);", + "}", + "const entry=await core.databaseFileAccess.fetchEntryMeta(path,undefined,true);", + "if(!entry){", + " throw new Error(`Could not read the surviving revision after replicated resolution: ${path}`);", + "}", + // This is the same Commonlib consumer boundary invoked after a remote + // document has already entered the local database. Calling it here + // isolates the dialogue policy from transport and second-device setup. + "await core.fileHandler._anyProcessReplicatedDoc(entry);", + "const conflicts=await core.databaseFileAccess.getConflictedRevs(path);", + "if(conflicts.length!==expectedConflictCount){", + " throw new Error(`Replicated resolution left an unexpected conflict count: ${path} ${JSON.stringify(conflicts)}`);", + "}", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + env + ); +} + +function conflictDialogue(page: Parameters[1]>[0]) { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Conflicting changes" }), + }); +} + +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-conflict-dialog-"); + 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: "http://127.0.0.1:5984", + couchDB_DBNAME: "conflict-dialog-policy", + couchDB_USER: "", + couchDB_PASSWORD: "", + notifyThresholdOfRemoteStorageSize: -1, + periodicReplication: false, + syncAfterMerge: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncOnSave: false, + syncOnStart: false, + disableMarkdownAutoMerge: true, + showMergeDialogOnlyOnActive: true, + showStatusOnEditor: true, + }, + 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 createManualConflict(cliBinary, session.cliEnv, base.rev, [ + leftContent, + rightContent, + thirdContent, + ]); + if (fixture.conflicts.length !== 2) { + throw new Error(`Expected exactly three live leaves: ${JSON.stringify(fixture)}`); + } + + await requestConflictCheck(cliBinary, session.cliEnv); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ + hasText: "This file has 3 unresolved versions. They will be reviewed one pair at a time.", + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Concat both", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const actionButtonBounds = await modal.locator(".conflict-action-button").evaluateAll((buttons) => + buttons.map((button) => { + const bounds = button.getBoundingClientRect(); + return { top: bounds.top, bottom: bounds.bottom }; + }) + ); + if ( + actionButtonBounds.length !== 4 || + actionButtonBounds.some( + (bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom + ) + ) { + throw new Error( + `Conflict action buttons are not stacked vertically: ${JSON.stringify(actionButtonBounds)}` + ); + } + }); + const firstDialogueScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "conflict-dialog-three-versions.png", + (page) => conflictDialogue(page).locator(".modal").first() + ); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.getByRole("button", { name: "Concat both", exact: true }).click({ timeout: uiTimeoutMs }); + }); + + const remainingAfterConcatenation = await waitForConflictCount(cliBinary, session.cliEnv, 1); + if ( + remainingAfterConcatenation.currentRev === fixture.currentRev || + remainingAfterConcatenation.currentParentRev !== fixture.currentRev + ) { + throw new Error( + `Concatenation did not extend the compared winner before retaining the remaining branch: ${JSON.stringify( + { + before: fixture, + after: remainingAfterConcatenation, + } + )}` + ); + } + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const warning = page.locator(".livesync-status-messagearea").filter({ + hasText: "This file has unresolved conflicts.", + }); + await warning.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + const warningScreenshot = await captureObsidianElement( + session.remoteDebuggingPort, + "conflict-dialog-postponed-warning.png", + (page) => + page.locator(".livesync-status-messagearea").filter({ + hasText: "This file has unresolved conflicts.", + }) + ); + + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + const remainingAfterRestart = await waitForConflictCount(cliBinary, session.cliEnv, 1); + + await requestConflictCheck(cliBinary, session.cliEnv); + const restartedSession = session; + await withObsidianPage(restartedSession.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ hasText: "This file has unresolved conflicts." }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await applyReplicatedConflictResolution( + cliBinary, + restartedSession.cliEnv, + remainingAfterRestart.conflicts[0] + ); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ hasText: "This file has unresolved conflicts." }) + .waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + // End the replicated-resolution episode before creating another + // conflict at the same path. This prevents a late cancellation event + // from the first episode from closing the later episode's dialogue. + await session.app.stop(); + session = undefined; + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + await waitForLiveSyncCoreReady(cliBinary, session.cliEnv); + await createAndOpenBaseFile(cliBinary, session.cliEnv); + + const resolved = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path); + const laterFixture = await createManualConflict(cliBinary, session.cliEnv, resolved.rev, [ + leftContent, + rightContent, + ]); + if (laterFixture.conflicts.length !== 1) { + throw new Error(`Expected a later conflict with exactly two live leaves: ${JSON.stringify(laterFixture)}`); + } + await requestConflictCheck(cliBinary, session.cliEnv); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Not now", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await waitForConflictChecks(cliBinary, session.cliEnv); + + await requestConflictCheck(cliBinary, session.cliEnv, true); + await withObsidianPage(session.remoteDebuggingPort, async (page) => { + await page.waitForTimeout(1500); + if (await conflictDialogue(page).isVisible()) { + throw new Error("The postponed conflict dialogue reopened during an ordinary conflict check."); + } + }); + await waitForConflictCount(cliBinary, session.cliEnv, 1); + + const laterCommandExecuted = await withObsidianPage(session.remoteDebuggingPort, async (page) => { + return await page.evaluate( + (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:livesync-checkdoc-conflicted" + ); + }); + if (!laterCommandExecuted) { + throw new Error("The explicit conflict-resolution command was not registered for the active editor."); + } + const laterActiveSession = session; + await withObsidianPage(laterActiveSession.remoteDebuggingPort, async (page) => { + const modal = conflictDialogue(page); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await applyReplicatedConflictResolution(cliBinary, laterActiveSession.cliEnv, laterFixture.conflicts[0]); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await page + .locator(".livesync-status-messagearea") + .filter({ hasText: "This file has unresolved conflicts." }) + .waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + console.log( + "Real Obsidian reviewed three versions pairwise, retained the completed stage across restart, suppressed an ordinary repeat prompt after Not now, reopened the dialogue after the explicit command, and cleared both postponed and open-dialogue states after replicated resolutions." + ); + console.log(`Dialogue screenshot: ${firstDialogueScreenshot}`); + console.log(`Postponed warning screenshot: ${warningScreenshot}`); + } 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/couchdb-manual-setup-workflow.ts b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts new file mode 100644 index 00000000..2a662a6f --- /dev/null +++ b/test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts @@ -0,0 +1,369 @@ +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, + type CouchDbConfig, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { assertEqual, pushLocalChanges, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + captureGuideDialogue, + confirmFastFetch, + confirmRebuild, + enterSetupURI, + finishInitialisation, + generateSetupURIFromDevice, + modalByTitle, + resumeCompatibilityReviewIfShown, + selectRadioOption, + skipMissingRemoteConfiguration, + type SetupArtifact, +} from "../runner/setupUri.ts"; +import { captureObsidianPage, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); +const notePath = "E2E/manual-couchdb/from-first-device.md"; +const noteContent = "# Manual CouchDB setup\n\nThis note was sent by the manually configured first device.\n"; +const returnNotePath = "E2E/manual-couchdb/from-second-device.md"; +const returnNoteContent = + "# Manual CouchDB return journey\n\nThis note returned through a Setup URI generated by the first device.\n"; +const captures = { + scenario: "couchdb-manual-setup-workflow", + guide: "couchdb-manual", +} as const; + +type RunnerContext = { + binary: string; + cliBinary: string; + couchDb: CouchDbConfig; + dbName: string; + activeSessions: Set; +}; + +async function startUnconfiguredSession( + context: RunnerContext, + vault: TemporaryVault +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + context.activeSessions.add(session); + return session; +} + +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 { + for (const session of [...context.activeSessions]) { + await stopTrackedSession(context, session); + } +} + +async function captureFailure(session: ObsidianLiveSyncSession, label: string): Promise { + const screenshot = await captureObsidianPage( + session.remoteDebuggingPort, + `couchdb-manual-${label}-failure.png`, + async () => undefined + ).catch(() => undefined); + if (screenshot) { + console.error(`Manual CouchDB failure screenshot: ${screenshot}`); + } +} + +async function enterManualCouchDBSettings(port: number, couchDb: CouchDbConfig, dbName: string): Promise { + const screenshots: string[] = []; + await withObsidianPage(port, async (page) => { + const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + + const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync"); + await intro.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await selectRadioOption(intro, "I am setting this up for the first time"); + await intro + .getByRole("button", { name: "Yes, I want to set up a new synchronisation" }) + .click({ timeout: uiTimeoutMs }); + }); + + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-connection-method.png", "Connection Method") + ); + await withObsidianPage(port, async (page) => { + const method = modalByTitle(page, "Connection Method"); + await selectRadioOption(method, "Configure a remote manually"); + await method + .getByRole("button", { name: "Proceed with manual configuration" }) + .click({ timeout: uiTimeoutMs }); + + const encryption = modalByTitle(page, "End-to-End Encryption"); + await encryption.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await encryption + .locator("label.row") + .filter({ hasText: "End-to-End Encryption" }) + .locator('input[type="checkbox"]') + .first() + .check({ timeout: uiTimeoutMs }); + await encryption + .locator("label.row") + .filter({ hasText: "Obfuscate Properties" }) + .locator('input[type="checkbox"]') + .first() + .check({ timeout: uiTimeoutMs }); + await encryption.locator('input[name="e2ee-passphrase"]').fill(randomBytes(24).toString("base64url")); + }); + screenshots.push(await captureGuideDialogue(port, "guide-couchdb-manual-encryption.png", "End-to-End Encryption")); + await withObsidianPage(port, async (page) => { + const encryption = modalByTitle(page, "End-to-End Encryption"); + await encryption.getByRole("button", { name: "Proceed", exact: true }).click({ timeout: uiTimeoutMs }); + }); + + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-remote-selection.png", "Choose a synchronisation remote") + ); + await withObsidianPage(port, async (page) => { + const remoteSelection = modalByTitle(page, "Choose a synchronisation remote"); + await selectRadioOption(remoteSelection, "CouchDB"); + await remoteSelection + .getByRole("button", { name: "Continue to CouchDB setup", exact: true }) + .click({ timeout: uiTimeoutMs }); + + const couchDB = modalByTitle(page, "CouchDB Configuration"); + await couchDB.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await couchDB.locator('input[name="couchdb-url"]').fill(couchDb.uri); + await couchDB.locator('input[name="couchdb-username"]').fill(couchDb.username); + await couchDB.locator('input[name="couchdb-password"]').fill(couchDb.password); + await couchDB.locator('input[name="couchdb-database"]').fill(dbName); + }); + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-connection-details.png", "CouchDB Configuration") + ); + + await withObsidianPage(port, async (page) => { + const couchDB = modalByTitle(page, "CouchDB Configuration"); + await couchDB + .getByRole("button", { name: "Check server requirements", exact: true }) + .click({ timeout: uiTimeoutMs }); + const summary = couchDB.locator(".check-results summary"); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await summary + .filter({ hasText: /All checks passed successfully!|issue\(s\) detected!/u }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const errors = couchDB.locator(".check-result.error"); + if ((await errors.count()) > 0) { + const messages = await errors.locator(".message").allTextContents(); + throw new Error(`The documented CouchDB fixture failed its server requirements: ${messages.join(" | ")}`); + } + const details = couchDB.locator(".check-results details"); + if (!(await details.evaluate((element) => (element as HTMLDetailsElement).open))) { + await summary.click({ timeout: uiTimeoutMs }); + } + }); + screenshots.push( + await captureGuideDialogue(port, "guide-couchdb-manual-server-requirements.png", "CouchDB Configuration") + ); + + await withObsidianPage(port, async (page) => { + const couchDB = modalByTitle(page, "CouchDB Configuration"); + await couchDB + .getByRole("button", { name: "Create or connect to database and continue", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modalByTitle(page, "Setup Complete: Preparing to Initialise Server").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + return screenshots; +} + +async function writeNoteViaObsidian( + cliBinary: string, + environment: 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(""), + environment + ); +} + +async function waitForVaultFile( + vault: TemporaryVault, + path: string, + expected: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === expected) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function waitForRemoteEntry(context: RunnerContext, entry: { id: string; children: string[] }): Promise { + await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + }); +} + +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 couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "manual-setup"); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName, + activeSessions: new Set(), + }; + const screenshots: string[] = []; + let secondDeviceArtifact: SetupArtifact | undefined; + + try { + await assertCouchDbReachable(couchDb); + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary Vault A: ${vaultA.path}`); + console.log(`Temporary Vault B: ${vaultB.path}`); + console.log(`CouchDB database to be created by the onboarding dialogue: ${dbName}`); + + let session = await startUnconfiguredSession(context, vaultA); + try { + screenshots.push(...(await enterManualCouchDBSettings(session.remoteDebuggingPort, couchDb, dbName))); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new", captures)); + screenshots.push(await confirmRebuild(session.remoteDebuggingPort, captures)); + screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort, captures)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort, captures)); + const state = await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + assertEqual(state.activeConfigurationId !== "", true, "Manual CouchDB setup did not activate a profile."); + assertEqual( + state.remoteConfigurationCount, + 1, + "Manual CouchDB setup did not persist exactly one remote profile." + ); + + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); + const entry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, entry); + + const generated = await generateSetupURIFromDevice( + session.remoteDebuggingPort, + randomBytes(24).toString("base64url"), + captures + ); + secondDeviceArtifact = generated.artifact; + screenshots.push(...generated.screenshots); + } catch (error) { + await captureFailure(session, "first-device"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultB); + try { + if (!secondDeviceArtifact) { + throw new Error("The manually configured first device did not generate a Setup URI."); + } + screenshots.push( + await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact, captures) + ); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing", captures)); + screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort, captures))); + await finishInitialisation(session.remoteDebuggingPort, context.cliBinary, session.cliEnv); + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForVaultFile(vaultB, notePath, noteContent); + + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent); + const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, returnEntry); + } catch (error) { + await captureFailure(session, "second-device"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultA); + try { + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForVaultFile(vaultA, returnNotePath, returnNoteContent); + } catch (error) { + await captureFailure(session, "return-journey"); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + console.log( + `Manual CouchDB onboarding created and tested its database, generated a second-device Setup URI, and completed a bidirectional note round-trip. Screenshots: ${screenshots.join(", ")}` + ); + } finally { + await stopTrackedSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await vaultA.dispose(); + await vaultB.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/couchdb-upload.ts b/test/e2e-obsidian/scripts/couchdb-upload.ts index 70a53688..ee76f73c 100644 --- a/test/e2e-obsidian/scripts/couchdb-upload.ts +++ b/test/e2e-obsidian/scripts/couchdb-upload.ts @@ -5,17 +5,35 @@ import { deleteCouchDbDatabase, loadCouchDbConfig, makeUniqueDatabaseName, + putCouchDbDocument, waitForCouchDbDocs, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, + assertE2eCompatibilityMarker, + assertE2eCompatibilityReviewPending, configureCouchDb, + createE2eCouchDbPluginData, prepareRemote, - pushLocalChanges, + resumeCompatibilityReview, waitForLiveSyncCoreReady, type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; +import { + REMOTE_ACTIVITY_EXPECTED_STATE, + captureRemoteActivityDiagnostics, + waitForRemoteActivityState, +} from "../runner/remoteActivity.ts"; +import { + cleanUpHeldRemoteActivity, + clearHeldRemoteActivity, + finishHeldRemoteActivity, + startHeldChunkFetch, + startHeldOneShotReplication, + startHeldTrackedRequest, + waitForRestoredChunk, +} from "../runner/remoteActivityWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; @@ -72,6 +90,7 @@ async function main(): Promise { const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "obsidian-upload"); const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; + let activityStage = "session-startup"; try { await assertCouchDbReachable(couchDb); @@ -86,8 +105,20 @@ async function main(): Promise { cliBinary: cli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData({ + uri: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + dbName, + }), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await assertE2eCompatibilityReviewPending(cli.binary, session.cliEnv); + await resumeCompatibilityReview(session.remoteDebuggingPort, { + verifyMissingDeviceMarkerExplanation: true, + screenshotPrefix: "compatibility-review-copied-vault", + }); + await assertE2eCompatibilityMarker(cli.binary, session.cliEnv); const configured = await configureCouchDb(cli.binary, session.cliEnv, { uri: couchDb.uri, @@ -104,8 +135,50 @@ async function main(): Promise { assertEqual(configured.syncOnSave, false, "Sync on save should remain disabled during this workflow."); await prepareRemote(cli.binary, session.cliEnv); + activityStage = "initial-idle"; + const initialIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); - await pushLocalChanges(cli.binary, session.cliEnv); + + activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive; + await startHeldTrackedRequest(cli.binary, session.cliEnv); + const trackedRequestActive = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.trackedRequestActive + ); + const trackedRequestResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); + assertEqual(trackedRequestResult.error, undefined, "The observed CouchDB request failed."); + assertEqual(trackedRequestResult.result, true, "The observed CouchDB request did not report success."); + activityStage = "tracked-request-idle"; + const trackedRequestIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (trackedRequestIdle.requestCount <= initialIdle.requestCount) { + throw new Error("The held CouchDB request did not advance the tracked remote-request count."); + } + await clearHeldRemoteActivity(cli.binary, session.cliEnv); + + activityStage = "one-shot-active"; + await startHeldOneShotReplication(cli.binary, session.cliEnv); + const oneShotActive = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.finiteReplicationActive + ); + const oneShotResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); + assertEqual(oneShotResult.error, undefined, "One-shot replication failed while its activity was observed."); + assertEqual(oneShotResult.result, true, "One-shot replication did not report success."); + activityStage = "one-shot-idle"; + const oneShotIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (oneShotIdle.requestCount <= trackedRequestIdle.requestCount) { + throw new Error("One-shot replication did not make an observed remote request."); + } + await clearHeldRemoteActivity(cli.binary, session.cliEnv); const remoteDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => { const ids = new Set(docs.map((doc) => doc._id)); @@ -118,11 +191,80 @@ async function main(): Promise { "Remote metadata path did not match the local database entry." ); + const sourceChunkId = localEntry.children[0]; + if (!sourceChunkId) throw new Error("The uploaded note did not produce a chunk for the fetch workflow."); + const sourceChunk = remoteDocs.find((document) => document._id === sourceChunkId); + if (!sourceChunk || sourceChunk.type !== "leaf") { + throw new Error(`The uploaded source chunk was not found in CouchDB: ${sourceChunkId}`); + } + const { _rev: _sourceRevision, ...remoteOnlyChunk } = sourceChunk; + const chunkId = `h:e2e-remote-activity-${Date.now().toString(36)}`; + await putCouchDbDocument(couchDb, dbName, { ...remoteOnlyChunk, _id: chunkId }); + activityStage = REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive; + await startHeldChunkFetch(cli.binary, session.cliEnv, chunkId); + const chunkFetchActive = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.chunkFetchActive + ); + const chunkFetchResult = await finishHeldRemoteActivity(cli.binary, session.cliEnv); + assertEqual( + chunkFetchResult.error, + undefined, + "On-demand chunk fetching failed while its activity was observed." + ); + if (!chunkFetchResult.requestedIds?.includes(chunkId)) { + throw new Error(`The on-demand chunk request did not include the selected chunk: ${chunkId}`); + } + if ((chunkFetchResult.resultCount ?? 0) < 1) { + throw new Error(`The remote did not return the selected chunk: ${chunkId}`); + } + const restoredChunk = await waitForRestoredChunk(cli.binary, session.cliEnv, chunkId); + assertEqual(restoredChunk.id, chunkId, "The restored chunk ID did not match the requested chunk."); + activityStage = "chunk-fetch-idle"; + const chunkFetchIdle = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (chunkFetchIdle.requestCount <= oneShotIdle.requestCount) { + throw new Error("On-demand chunk fetching did not make an observed remote request."); + } + await clearHeldRemoteActivity(cli.binary, session.cliEnv); + console.log( `Uploaded metadata ${localEntry.id} and ${localEntry.children.length} chunk(s) to CouchDB database ${dbName}` ); + console.log( + [ + `Tracked request: ${trackedRequestActive.statusBarText.trim()} -> idle`, + `One-shot activity: ${oneShotActive.statusBarText.trim()} -> idle`, + `Chunk-fetch activity: ${chunkFetchActive.statusBarText.trim()} -> idle`, + `Balanced remote requests: ${chunkFetchIdle.requestCount}/${chunkFetchIdle.responseCount}`, + ].join("\n") + ); + } catch (error) { + if (session) { + const diagnostics = await captureRemoteActivityDiagnostics( + session.remoteDebuggingPort, + `couchdb-upload-${activityStage}` + ).catch((diagnosticError: unknown) => { + console.warn( + `Could not capture remote activity diagnostics: ${ + diagnosticError instanceof Error ? diagnosticError.message : String(diagnosticError) + }` + ); + return undefined; + }); + if (diagnostics) { + console.error(`Remote activity screenshot: ${diagnostics.screenshotPath}`); + console.error(`Remote activity snapshot: ${diagnostics.snapshotPath}`); + } + } + throw error; } finally { if (session) { + await cleanUpHeldRemoteActivity(cli.binary, session.cliEnv).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); await session.app.stop(); } await vault.dispose(); diff --git a/test/e2e-obsidian/scripts/dialog-mounts.ts b/test/e2e-obsidian/scripts/dialog-mounts.ts new file mode 100644 index 00000000..86af117a --- /dev/null +++ b/test/e2e-obsidian/scripts/dialog-mounts.ts @@ -0,0 +1,1118 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { createE2eCouchDbPluginData, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { assertMobileDialogueLayout, assertMobileNoticeLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + captureObsidianDialogue, + captureObsidianElement, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +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"; + +type DialogueRunState = { + done: boolean; + error?: string; + expected?: unknown; + kind: string; + result?: unknown; +}; + +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: 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 }; + +async function openRemoteSelectionDialogue(): 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 manager = plugin.core.modules.find((module) => module.constructor.name === "SetupManager"); + if (typeof manager?.onSelectServer !== "function") throw new Error("Could not find SetupManager"); + const state: DialogueRunState = { kind: "remote-selection", done: false }; + (globalThis as unknown as Record)[stateKey] = state; + void manager.onSelectServer(plugin.core.settings, "unknown").then( + (result) => { + state.result = result; + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ); + }, dialogRunStateKey); + }); +} + +async function openSetupUriDialogue(): Promise { + const opened = await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + return await page.evaluate( + (commandId) => (globalThis as ObsidianTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:livesync-opensetupuri" + ); + }); + if (!opened) { + throw new Error("The Setup URI command was not registered or could not be executed."); + } +} + +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) => + (globalThis as unknown as Record)[stateKey]?.done === true, + dialogRunStateKey, + { timeout: uiTimeoutMs } + ); + return await page.evaluate( + (stateKey) => (globalThis as unknown as Record)[stateKey], + dialogRunStateKey + ); + }); + if (!state) { + throw new Error("The mounted dialogue did not record its completion state."); + } + if (state.error) { + throw new Error(`The mounted dialogue failed: ${state.error}`); + } + return state; +} + +async function verifyRemoteSizeNoticeAndDialogue(): Promise<{ + compatibilityReview: string; + notice: string; + dialogue: string; +}> { + const compatibilityReviewScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "compatibility-review-dialogue.png", + async (page) => { + const compatibilityReview = page.locator(".modal-container").filter({ + has: page + .locator(".modal-title") + .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); + if (flexDirection !== "column") { + throw new Error(`Expected vertically stacked compatibility actions, received ${flexDirection}.`); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const compatibilityReview = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await compatibilityReview + .getByRole("button", { name: "Keep synchronisation paused" }) + .click({ timeout: uiTimeoutMs }); + await compatibilityReview.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const noticeScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "remote-size-startup-notice.png", + async (page) => { + const notice = page.locator(".notice").filter({ + hasText: "Remote storage size notifications are not configured.", + }); + await notice.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await notice.getByRole("link", { name: "Review options" }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const notice = page.locator(".notice").filter({ + hasText: "Remote storage size notifications are not configured.", + }); + await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs }); + await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const dialogueScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "remote-size-review-dialogue.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const action of [ + "No, never warn please", + "800MB (Cloudant, fly.io)", + "2GB (Standard)", + "Ask me later", + ]) { + await modal.getByRole("button", { name: action }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + } + ); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { + compatibilityReview: compatibilityReviewScreenshot, + notice: noticeScreenshot, + dialogue: dialogueScreenshot, + }; +} + +async function verifyRemoteSelectionDialogue(mode: DialogueMode): Promise { + await openRemoteSelectionDialogue(); + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + `setup-remote-selection-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + const dialogue = modal.locator(".dialog-host"); + await modal.waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + for (const label of ["CouchDB", "S3-compatible Object Storage", "Peer-to-Peer (P2P)"]) { + await dialogue.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + const p2pDescription = dialogue.getByText( + "No central data-storage server is required, but a signalling relay is required for peer discovery.", + { exact: false } + ); + await p2pDescription.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const textSelection = await p2pDescription.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("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 }); + if (mode === "mobile") { + await assertMobileDialogueLayout(page, modal, "remote selection dialogue"); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + if (mode === "mobile") { + const previousDialogue = await modal.locator(".modal").last().elementHandle(); + if (previousDialogue === null) { + throw new Error("The remote selection dialogue did not expose a close control."); + } + await modal.locator(".modal-close-button").click({ timeout: uiTimeoutMs }); + await page.waitForFunction((element) => !element.isConnected, previousDialogue, { timeout: uiTimeoutMs }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + await modal.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + return screenshotPath; +} + +async function verifyCouchDBSettingsDialogue(mode: DialogueMode): Promise { + await openRemoteSelectionDialogue(); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const remoteSelection = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Choose a synchronisation remote" }), + }); + await remoteSelection + .locator("label") + .filter({ hasText: "CouchDB" }) + .locator('input[type="radio"]') + .first() + .check({ timeout: uiTimeoutMs }); + await remoteSelection + .getByRole("button", { name: "Continue to CouchDB setup", exact: true }) + .click({ timeout: uiTimeoutMs }); + }); + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + `setup-couchdb-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const label of [ + "Check server requirements", + "Test connection and save", + "Save without connecting", + "Cancel", + ]) { + await modal.getByRole("button", { name: label, exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + } + await modal + .getByText( + "This optional check uses Obsidian's internal request API and sends the credentials above to the CouchDB server.", + { exact: false } + ) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByText("CouchDB validates the database name when you connect.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await modal.getByRole("button", { name: "Continue anyway", exact: true }).count()) !== 0) { + throw new Error("CouchDB onboarding still exposes the ambiguous Continue anyway action."); + } + const buttonGroups = modal.locator(".button-group"); + for (let index = 0; index < (await buttonGroups.count()); index++) { + const flexDirection = await buttonGroups.nth(index).evaluate((element) => { + return getComputedStyle(element).flexDirection; + }); + if (flexDirection !== "column") { + throw new Error(`Expected vertical CouchDB actions, received ${flexDirection}.`); + } + } + if (mode === "mobile") { + await assertMobileDialogueLayout(page, modal, "CouchDB settings dialogue"); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "CouchDB Configuration" }), + }); + await modal.getByRole("button", { name: "Cancel", exact: true }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + await assertDialogueRunCompleted(); + return screenshotPath; +} + +async function verifySetupUriDialogue(mode: DialogueMode): Promise { + await openSetupUriDialogue(); + const screenshotPath = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + `setup-uri-dialogue${mode === "mobile" ? "-mobile" : ""}.png`, + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }), + }); + await modal.waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal + .locator('input[placeholder^="obsidian://setuplivesync"]') + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator('input[name="password"]').waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Test Settings and Continue" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Cancel" }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "mobile") { + await assertMobileDialogueLayout(page, modal, "Setup URI dialogue"); + } + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Enter Setup URI" }), + }); + await modal.getByRole("button", { name: "Cancel" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + 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 chunks for current Vault 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: "Begin inspection", exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await liveSyncSettings + .locator(".setting-item-name", { hasText: "Recreate chunks for current Vault 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 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, + 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 page + .locator(".sls-setting:visible") + .last() + .getByRole("button", { name: "Begin inspection", 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(), + "compatibility-review-dialogue-mobile.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page + .locator(".modal-title") + .filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator(".vpk-action-dialog__actions--vertical").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await assertMobileDialogueLayout(page, modal, "compatibility review dialogue"); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Synchronisation paused for compatibility review" }), + }); + await modal.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + + const compatibilityReminder = page.locator(".livesync-compatibility-review-notice"); + await compatibilityReminder.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertMobileNoticeLayout(page, compatibilityReminder, "compatibility review reminder"); + + const notice = page.locator(".notice").filter({ + hasText: "Remote storage size notifications are not configured.", + }); + await notice.getByRole("link", { name: "Review options" }).click({ timeout: uiTimeoutMs }); + await notice.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const remoteSizeReviewScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "remote-size-review-dialogue-mobile.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertMobileDialogueLayout(page, modal, "remote size review dialogue"); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Setting up database size notification" }), + }); + await modal.getByRole("button", { name: "Ask me later" }).click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + return { + compatibilityReview: compatibilityReviewScreenshot, + remoteSizeReview: remoteSizeReviewScreenshot, + }; +} + +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 vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData( + { + uri: "http://127.0.0.1:5984", + username: "", + password: "", + dbName: "dialog-mounts-ui-only", + }, + { + notifyThresholdOfRemoteStorageSize: -1, + syncOnStart: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + periodicReplication: false, + useAdvancedMode: true, + } + ), + }); + try { + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + } catch (error) { + const screenshot = await captureObsidianPage( + obsidianRemoteDebuggingPort(), + "dialog-mounts-core-not-ready.png", + async () => undefined + ); + console.error(`Core readiness diagnostic screenshot: ${screenshot}`); + const persistedSettings = JSON.parse( + await readFile(join(session.install.pluginDir, "data.json"), "utf8") + ) as Record; + console.error( + `Persisted readiness settings: ${JSON.stringify({ + isConfigured: persistedSettings.isConfigured, + remoteType: persistedSettings.remoteType, + settingVersion: persistedSettings.settingVersion, + couchDB_URI: persistedSettings.couchDB_URI, + couchDB_DBNAME: persistedSettings.couchDB_DBNAME, + remoteConfigurationCount: Object.keys( + (persistedSettings.remoteConfigurations as Record | undefined) ?? {} + ).length, + })}` + ); + throw error; + } + + const remoteSizeScreenshots = await verifyRemoteSizeNoticeAndDialogue(); + console.log( + `Compatibility review actions were stacked vertically, and the remote-size startup notice opened an untimed review dialogue successfully. Screenshots: ${remoteSizeScreenshots.compatibilityReview}, ${remoteSizeScreenshots.notice}, ${remoteSizeScreenshots.dialogue}` + ); + + const remoteScreenshot = await verifyRemoteSelectionDialogue("desktop"); + console.log(`Remote selection dialogue mounted and closed successfully. Screenshot: ${remoteScreenshot}`); + const couchDBScreenshot = await verifyCouchDBSettingsDialogue("desktop"); + console.log( + `CouchDB settings mode exposed explicit connection, unverified-save, and server-check actions. Screenshot: ${couchDBScreenshot}` + ); + 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 { + const mobileStartupScreenshots = await verifyMobileStartupReviews(); + console.log( + `Mobile compatibility and remote-size reviews passed viewport, safe-area, touch-target, and vertical-action checks. Screenshots: ${mobileStartupScreenshots.compatibilityReview}, ${mobileStartupScreenshots.remoteSizeReview}` + ); + const mobileRemoteScreenshot = await verifyRemoteSelectionDialogue("mobile"); + console.log( + `Mobile remote selection dialogue passed viewport, safe-area, touch-target, and close-control checks. Screenshot: ${mobileRemoteScreenshot}` + ); + const mobileCouchDBScreenshot = await verifyCouchDBSettingsDialogue("mobile"); + console.log( + `Mobile CouchDB settings dialogue passed viewport, touch-target, and vertical-action checks. Screenshot: ${mobileCouchDBScreenshot}` + ); + const mobileSetupUriScreenshot = await verifySetupUriDialogue("mobile"); + console.log( + `Mobile Setup URI dialogue passed viewport, safe-area, and touch-target checks. Screenshot: ${mobileSetupUriScreenshot}` + ); + } finally { + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs); + } + } 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/hidden-file-snippet-sync.ts b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts index ae645e1e..4af593c8 100644 --- a/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts +++ b/test/e2e-obsidian/scripts/hidden-file-snippet-sync.ts @@ -1,5 +1,11 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertLocatorWithinViewport, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; import { evalObsidianJson } from "../runner/cli.ts"; import { assertCouchDbReachable, @@ -13,7 +19,10 @@ import { import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, + assertE2eCompatibilityMarker, configureCouchDb, + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, @@ -21,7 +30,14 @@ import { type LocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; -import { clickJsonResolveOption, obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { + captureObsidianPage, + captureJsonResolveDialogue, + clickJsonResolveOption, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; @@ -43,6 +59,7 @@ const mergeJsonPath = ".obsidian/livesync-e2e-merge.json"; const manualMergeJsonPath = ".obsidian/livesync-e2e-manual-merge.json"; const targetPath = ".obsidian/livesync-targeted/only-a.json"; const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000); +const hiddenFileInitialisationStateKey = "__livesyncE2EHiddenFileInitialisation"; type RunnerContext = { binary: string; @@ -300,31 +317,33 @@ async function startConfiguredSession( vault: TemporaryVault, overrides: Record = {} ): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const hiddenFileSettings = { + syncInternalFiles: true, + syncInternalFilesBeforeReplication: true, + watchInternalFileChanges: false, + syncInternalFilesTargetPatterns: "", + ...overrides, + }; const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + // A fresh Vault waits for onboarding before opening its local database. + // Seed the same isolated settings used by configureCouchDb so that the + // application lifecycle can become ready without a user interaction. + pluginData: createE2eCouchDbPluginData(couchDbSettings, hiddenFileSettings), + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await configureCouchDb( - context.cliBinary, - session.cliEnv, - { - uri: context.couchDb.uri, - username: context.couchDb.username, - password: context.couchDb.password, - dbName: context.dbName, - }, - { - syncInternalFiles: true, - syncInternalFilesBeforeReplication: true, - watchInternalFileChanges: false, - syncInternalFilesTargetPatterns: "", - ...overrides, - } - ); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); + await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, hiddenFileSettings); await prepareRemote(context.cliBinary, session.cliEnv); return session; } @@ -431,6 +450,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te const session = await startConfiguredSession(context, vault); await createHiddenJsonConflict(context, session, vault, manualMergeJsonPath, base, left, right); await openHiddenJsonResolveModal(context.cliBinary, session.cliEnv, manualMergeJsonPath); + const screenshotPath = await captureJsonResolveDialogue(obsidianRemoteDebuggingPort()); await clickJsonResolveOption(obsidianRemoteDebuggingPort(), "AB"); const merged = await waitForPathContent(vault.path, manualMergeJsonPath, (content) => @@ -442,7 +462,7 @@ async function runJsonManualConflictResolution(context: RunnerContext, vault: Te assertEqual(parsed.shared, "right", "Manual JSON conflict resolution did not apply the selected merged result."); assertEqual(parsed.fromA, true, "Manual JSON conflict resolution lost the first-side value."); assertEqual(parsed.fromB, true, "Manual JSON conflict resolution lost the second-side value."); - console.log("Hidden JSON conflict modal applied the selected merged result."); + console.log(`Hidden JSON conflict modal applied the selected merged result. Screenshot: ${screenshotPath}`); } async function runTargetMismatch( @@ -489,6 +509,342 @@ async function runTargetMismatch( console.log("Hidden target mismatch respected per-device target patterns, then applied after enabling the target."); } +async function setHiddenFileNoticeFixtures(port: number, itemIds: string[], includeRestart: boolean): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate( + ({ nextItemIds, nextIncludeRestart }) => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + const core = plugin.core; + const addOn = core.getAddOn("HiddenFileSync"); + for (const id of ["alpha", "beta", "gamma"]) { + const pluginId = `livesync-e2e-${id}`; + obsidianApp.plugins.manifests[pluginId] = { + id: pluginId, + name: `E2E ${id[0]?.toUpperCase()}${id.slice(1)}`, + version: "1.0.0", + minAppVersion: "1.0.0", + description: "E2E fixture", + author: "Self-hosted LiveSync", + isDesktopOnly: false, + dir: `.obsidian/plugins/${pluginId}`, + }; + obsidianApp.plugins.enabledPlugins.add(pluginId); + } + addOn.queuedNotificationFiles.clear(); + for (const id of nextItemIds) { + addOn.queuedNotificationFiles.add(`.obsidian/plugins/livesync-e2e-${id}`); + } + if (nextIncludeRestart) { + addOn.queuedNotificationFiles.add(core.services.API.getSystemConfigDir()); + } + addOn.notifyConfigChange(); + }, + { nextItemIds: itemIds, nextIncludeRestart: includeRestart } + ); + }); +} + +async function clearHiddenFileNoticeFixtures(port: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + plugin.core.services.context.noticeGroups.hide("hidden-file-changes"); + for (const id of ["alpha", "beta", "gamma"]) { + const pluginId = `livesync-e2e-${id}`; + obsidianApp.plugins.enabledPlugins.delete(pluginId); + delete obsidianApp.plugins.manifests[pluginId]; + } + }); + }); +} + +async function runInitialisationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise { + const session = await startConfiguredSession(context, vault, { + syncInternalFiles: false, + syncInternalFilesBeforeReplication: false, + }); + const port = session.remoteDebuggingPort; + const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000); + try { + await withObsidianPage(port, async (page) => { + const deadline = Date.now() + timeoutMs; + while ((await page.locator(".notice:visible").count()) > 0 && Date.now() < deadline) { + await page.locator(".notice:visible").first().click({ + force: true, + position: { x: 2, y: 2 }, + timeout: timeoutMs, + }); + } + assertEqual( + await page.locator(".notice:visible").count(), + 0, + "Transient start-up Notices remained before the Hidden File Sync initialisation check." + ); + }); + await withObsidianPage(port, async (page) => { + await page.evaluate((stateKey) => { + const obsidianApp = (globalThis as typeof globalThis & { app: any }).app; + const plugin = obsidianApp.plugins.plugins["obsidian-livesync"]; + const core = plugin.core; + const addOn = core.getAddOn("HiddenFileSync"); + const setting = core.services.setting; + const originalApplyPartial = setting.applyPartial; + const originalRebuildMerging = addOn.rebuildMerging; + const state = { + done: false, + reachedPreparation: false, + reachedInitialisation: false, + maxVisibleProgressNotices: 0, + visibleProgressNoticeTexts: [] as string[], + sawStandaloneGatheringNotice: false, + sawStandaloneRestartNotice: false, + releasePreparation: undefined as (() => void) | undefined, + releaseInitialisation: undefined as (() => void) | undefined, + error: undefined as string | undefined, + }; + (globalThis as unknown as Record)[stateKey] = state; + + const observer = new MutationObserver(() => { + const notices = Array.from(document.querySelectorAll(".notice")); + const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙")); + state.sawStandaloneGatheringNotice ||= notices.some((notice) => + notice.textContent?.includes("Gathering files for enabling Hidden File Sync") + ); + state.sawStandaloneRestartNotice ||= notices.some((notice) => + notice.textContent?.includes("Done! Restarting the app is strongly recommended!") + ); + if (progressNotices.length > state.maxVisibleProgressNotices) { + state.maxVisibleProgressNotices = progressNotices.length; + state.visibleProgressNoticeTexts = progressNotices.map( + (notice) => notice.textContent?.trim() ?? "" + ); + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }); + + setting.applyPartial = async (...args: unknown[]) => { + const update = args[0] as { syncInternalFiles?: unknown } | undefined; + if (update?.syncInternalFiles === true && !state.reachedPreparation) { + state.reachedPreparation = true; + await new Promise((resolve) => { + state.releasePreparation = resolve; + }); + } + return await originalApplyPartial.apply(setting, args); + }; + + addOn.rebuildMerging = async (...args: unknown[]) => { + state.reachedInitialisation = true; + await new Promise((resolve) => { + state.releaseInitialisation = resolve; + }); + return await originalRebuildMerging.apply(addOn, args); + }; + + void core.services.setting + .enableOptionalFeature("MERGE") + .then( + () => { + state.done = true; + }, + (error: unknown) => { + state.error = error instanceof Error ? error.message : String(error); + state.done = true; + } + ) + .finally(() => { + setting.applyPartial = originalApplyPartial; + addOn.rebuildMerging = originalRebuildMerging; + const notices = Array.from(document.querySelectorAll(".notice")); + const progressNotices = notices.filter((notice) => notice.textContent?.includes("[⚙")); + state.sawStandaloneGatheringNotice ||= notices.some((notice) => + notice.textContent?.includes("Gathering files for enabling Hidden File Sync") + ); + state.sawStandaloneRestartNotice ||= notices.some((notice) => + notice.textContent?.includes("Done! Restarting the app is strongly recommended!") + ); + if (progressNotices.length > state.maxVisibleProgressNotices) { + state.maxVisibleProgressNotices = progressNotices.length; + state.visibleProgressNoticeTexts = progressNotices.map( + (notice) => notice.textContent?.trim() ?? "" + ); + } + observer.disconnect(); + }); + }, hiddenFileInitialisationStateKey); + }); + + const screenshotPath = await captureObsidianPage( + port, + "hidden-file-initial-scan-progress.png", + async (page) => { + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[ + stateKey + ]?.reachedPreparation === true, + hiddenFileInitialisationStateKey, + { timeout: timeoutMs } + ); + const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" }); + await progressNotices.first().waitFor({ state: "visible", timeout: timeoutMs }); + assertEqual( + await progressNotices.count(), + 1, + "Hidden File Sync showed more than one progress Notice before saving its enabled setting." + ); + await progressNotices + .filter({ hasText: "Preparing Hidden File Sync..." }) + .waitFor({ state: "visible", timeout: timeoutMs }); + } + ); + + const result = await withObsidianPage(port, async (page) => { + await page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record< + string, + { releasePreparation?: () => void } | undefined + >)[stateKey]; + state?.releasePreparation?.(); + }, hiddenFileInitialisationStateKey); + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[ + stateKey + ]?.reachedInitialisation === true, + hiddenFileInitialisationStateKey, + { timeout: timeoutMs } + ); + const progressNotices = page.locator(".notice").filter({ hasText: "[⚙" }); + assertEqual( + await progressNotices.count(), + 1, + "Hidden File Sync replaced its parent progress Notice when the first child phase started." + ); + await page.evaluate((stateKey) => { + const state = ( + globalThis as unknown as Record void } | undefined> + )[stateKey]; + state?.releaseInitialisation?.(); + }, hiddenFileInitialisationStateKey); + await page.waitForFunction( + (stateKey) => + (globalThis as unknown as Record)[stateKey]?.done === true, + hiddenFileInitialisationStateKey, + { timeout: hiddenFileCliTimeoutMs } + ); + return await page.evaluate( + (stateKey) => + ( + globalThis as unknown as Record< + string, + { + error?: string; + maxVisibleProgressNotices: number; + visibleProgressNoticeTexts: string[]; + sawStandaloneGatheringNotice: boolean; + sawStandaloneRestartNotice: boolean; + } + > + )[stateKey], + hiddenFileInitialisationStateKey + ); + }); + if (result.error) { + throw new Error(`Hidden File Sync initialisation failed: ${result.error}`); + } + assertEqual( + result.maxVisibleProgressNotices, + 1, + `Hidden File Sync split initialisation across multiple progress Notices: ${JSON.stringify( + result.visibleProgressNoticeTexts + )}` + ); + assertEqual( + result.sawStandaloneGatheringNotice, + false, + "Hidden File Sync showed the old standalone gathering Notice." + ); + assertEqual( + result.sawStandaloneRestartNotice, + false, + "Hidden File Sync showed the old standalone restart recommendation." + ); + console.log( + `Hidden File Sync showed one progress Notice before settings were saved and retained it throughout initialisation. Screenshot: ${screenshotPath}` + ); + } finally { + await session.app.stop(); + } +} + +async function runConfigurationNoticeGrouping(context: RunnerContext, vault: TemporaryVault): Promise { + const session = await startConfiguredSession(context, vault); + const port = session.remoteDebuggingPort; + const timeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_NOTICE_TIMEOUT_MS ?? 10_000); + try { + await setObsidianMobileTestMode(port, true, timeoutMs); + await setHiddenFileNoticeFixtures(port, ["alpha", "beta"], true); + + await withObsidianPage(port, async (page) => { + const visibleGroups = page.locator(".notice:has(.vpk-keyed-notice-group):visible"); + await visibleGroups.first().waitFor({ state: "visible", timeout: timeoutMs }); + assertEqual(await visibleGroups.count(), 1, "Hidden File Sync created more than one visible Notice group."); + + const notice = visibleGroups.first(); + const rows = notice.locator(".vpk-keyed-notice-group__item"); + assertEqual(await rows.count(), 3, "Hidden File Sync did not group every configuration-change action."); + await notice.getByText("Files in E2E Alpha were updated.", { exact: true }).waitFor(); + await notice.getByText("Files in E2E Beta were updated.", { exact: true }).waitFor(); + await notice.getByText("Other Obsidian settings files were updated.", { exact: true }).waitFor(); + + await assertLocatorWithinViewport(page, notice, { label: "Hidden File Sync notification group" }); + await assertNoHorizontalOverflow(page, notice, { label: "Hidden File Sync notification group" }); + await assertLocatorWithinSafeArea(page, notice, { + label: "Hidden File Sync notification group", + safeAreaInsets: iPhoneSafeArea, + }); + const buttons = notice.getByRole("button"); + for (let index = 0; index < (await buttons.count()); index += 1) { + await assertLocatorHasMinimumTouchTarget(page, buttons.nth(index), { + label: `Hidden File Sync notification action ${index + 1}`, + }); + } + + const outputDirectory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e"; + const screenshotPath = join(outputDirectory, "hidden-file-notice-group-mobile.png"); + await mkdir(dirname(screenshotPath), { recursive: true }); + await page.screenshot({ path: screenshotPath, fullPage: true, animations: "disabled" }); + + await rows.first().getByText("Files in E2E Alpha were updated.", { exact: true }).click(); + await notice.waitFor({ state: "hidden", timeout: timeoutMs }); + }); + + await setHiddenFileNoticeFixtures(port, ["gamma"], false); + await withObsidianPage(port, async (page) => { + const notice = page.locator(".notice:has(.vpk-keyed-notice-group):visible").first(); + await notice.waitFor({ state: "visible", timeout: timeoutMs }); + const rows = notice.locator(".vpk-keyed-notice-group__item"); + assertEqual(await rows.count(), 1, "A dismissed Hidden File Sync Notice repeated acknowledged rows."); + await rows.getByText("Files in E2E Gamma were updated.", { exact: true }).waitFor(); + }); + + console.log( + "Hidden File Sync grouped configuration notifications passed the mobile regression for issue #555." + ); + } finally { + await clearHiddenFileNoticeFixtures(port).catch(() => undefined); + await setObsidianMobileTestMode(port, false, timeoutMs).catch(() => undefined); + await session.app.stop(); + } +} + async function main(): Promise { const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); @@ -516,6 +872,8 @@ async function main(): Promise { await runJsonConflictRoundTrip(context, vaultA, vaultB); await runJsonManualConflictResolution(context, vaultB); await runTargetMismatch(context, vaultA, vaultB); + await runInitialisationNoticeGrouping(context, vaultB); + await runConfigurationNoticeGrouping(context, vaultB); } finally { await vaultA.dispose(); await vaultB.dispose(); diff --git a/test/e2e-obsidian/scripts/local-suite.ts b/test/e2e-obsidian/scripts/local-suite.ts index 6b804c4c..9dfc225f 100644 --- a/test/e2e-obsidian/scripts/local-suite.ts +++ b/test/e2e-obsidian/scripts/local-suite.ts @@ -8,12 +8,35 @@ type Step = { const testSteps: Step[] = [ { name: "build", args: ["run", "build"] }, + ...(process.env.LIVESYNC_CLI_COMMAND === undefined + ? [{ name: "CLI build", args: ["run", "build", "-w", "self-hosted-livesync-cli"] }] + : []), { name: "discover", args: ["run", "test:e2e:obsidian:discover"] }, { 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"] }, { name: "vault reflection", args: ["run", "test:e2e:obsidian:vault-reflection"] }, { name: "CouchDB upload", args: ["run", "test:e2e:obsidian:couchdb-upload"] }, + { + name: "manual CouchDB setup workflow", + args: ["run", "test:e2e:obsidian:couchdb-manual-setup-workflow"], + }, + { + name: "CLI to real Obsidian synchronisation", + args: ["run", "test:e2e:obsidian:cli-to-obsidian-sync"], + }, { name: "Object Storage upload", args: ["run", "test:e2e:obsidian:minio-upload"] }, + { + name: "Object Storage Setup URI workflow", + args: ["run", "test:e2e:obsidian:object-storage-setup-uri-workflow"], + }, + { name: "P2P Setup URI workflow", args: ["run", "test:e2e:obsidian:p2p-setup-uri-workflow"] }, { name: "startup scan", args: ["run", "test:e2e:obsidian:startup-scan"] }, + { name: "provisioned Setup URI workflow", args: ["run", "test:e2e:obsidian:setup-uri-workflow"] }, { name: "two-vault synchronisation", args: ["run", "test:e2e:obsidian:two-vault-sync"] }, { name: "hidden file snippet synchronisation", args: ["run", "test:e2e:obsidian:hidden-file-snippet-sync"] }, { name: "Customisation Sync", args: ["run", "test:e2e:obsidian:customisation-sync"] }, @@ -22,9 +45,11 @@ const testSteps: Step[] = [ const manageCouchDb = process.argv.includes("--manage-couchdb") || process.argv.includes("--manage-services"); const manageMinio = process.argv.includes("--manage-minio") || process.argv.includes("--manage-services"); +const manageP2P = process.argv.includes("--manage-p2p") || process.argv.includes("--manage-services"); const keepServices = process.argv.includes("--keep-services"); const keepCouchDb = keepServices || process.argv.includes("--keep-couchdb"); const keepMinio = keepServices || process.argv.includes("--keep-minio"); +const keepP2P = keepServices || process.argv.includes("--keep-p2p"); function npmBinary(): string { return process.platform === "win32" ? "npm.cmd" : "npm"; @@ -71,9 +96,18 @@ async function stopManagedMinio(): Promise { }); } +async function stopManagedP2P(): Promise { + await runStep({ + name: "stop P2P relay fixture", + args: ["run", "test:docker-p2p:stop"], + optional: true, + }); +} + async function main(): Promise { let shouldStopCouchDb = false; let shouldStopMinio = false; + let shouldStopP2P = false; try { if (manageCouchDb) { await stopManagedCouchDb(); @@ -85,11 +119,19 @@ async function main(): Promise { await runStep({ name: "start MinIO fixture", args: ["run", "test:docker-s3:start"] }); shouldStopMinio = !keepMinio; } + if (manageP2P) { + await stopManagedP2P(); + await runStep({ name: "start P2P relay fixture", args: ["run", "test:docker-p2p:start"] }); + shouldStopP2P = !keepP2P; + } for (const step of testSteps) { await runStep(step); } } finally { + if (shouldStopP2P) { + await stopManagedP2P(); + } if (shouldStopMinio) { await stopManagedMinio(); } diff --git a/test/e2e-obsidian/scripts/minio-upload.ts b/test/e2e-obsidian/scripts/minio-upload.ts index 36733fc9..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, @@ -17,6 +36,7 @@ import { } from "../runner/objectStorage.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; +import { REMOTE_ACTIVITY_EXPECTED_STATE, waitForRemoteActivityState } from "../runner/remoteActivity.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; @@ -99,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); @@ -115,13 +140,29 @@ async function main(): Promise { assertEqual(configured.liveSync, false, "LiveSync should remain disabled during this one-shot workflow."); await prepareRemote(cli.binary, session.cliEnv); + const activityBeforeUpload = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); const localEntry = await createNoteAndWaitForLocalDb(cli.binary, session.cliEnv); await pushLocalChanges(cli.binary, session.cliEnv); + const activityAfterUpload = await waitForRemoteActivityState( + session.remoteDebuggingPort, + REMOTE_ACTIVITY_EXPECTED_STATE.idle + ); + if (activityAfterUpload.requestCount <= activityBeforeUpload.requestCount) { + throw new Error("Object Storage synchronisation did not advance the tracked remote-request count."); + } + assertEqual( + activityAfterUpload.responseCount, + activityAfterUpload.requestCount, + "Object Storage remote-request counters did not rebalance after synchronisation." + ); const keys = await waitForObjectStorageObjects(bucketPrefix); console.log( - `Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s))` + `Uploaded ${localEntry.path} through Journal Sync to ${objectStorage.bucket}/${bucketPrefix} (${keys.length} object(s)); tracked requests: ${activityAfterUpload.requestCount - activityBeforeUpload.requestCount}` ); } finally { if (session) { diff --git a/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts new file mode 100644 index 00000000..19276252 --- /dev/null +++ b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts @@ -0,0 +1,324 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + pushLocalChanges, + waitForLiveSyncCoreReady, + waitForLocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { + deleteObjectStoragePrefix, + ensureObjectStorageBucket, + listObjectStorageObjects, + loadObjectStorageConfig, + makeUniqueBucketPrefix, + type ObjectStorageConfig, +} from "../runner/objectStorage.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + confirmFastFetch, + confirmRebuild, + enterSetupURI, + finishInitialisation, + generateSetupURIFromDevice, + resumeCompatibilityReviewIfShown, + skipMissingRemoteConfiguration, + type SetupArtifact, + type SetupCaptureNames, +} from "../runner/setupUri.ts"; +import { + captureObsidianElement, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +const execFileAsync = promisify(execFile); +const captures: SetupCaptureNames = { scenario: "object-storage-setup-uri", guide: "object-storage-setup" }; +const noteFromFirst = "E2E/object-storage/from-first.md"; +const noteFromSecond = "E2E/object-storage/from-second.md"; +const firstContent = + "# Object Storage from the first device\n\nThis note travelled through the first device's Setup URI.\n"; +const secondContent = "# Object Storage from the second device\n\nThis note completed the return journey.\n"; + +type RunnerContext = { + binary: string; + cliBinary: string; + activeSessions: Set; +}; + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(process.env); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise { + const { stdout } = await execFileAsync( + "deno", + [ + "run", + "--minimum-dependency-age=0", + "--config=utils/flyio/deno.jsonc", + "--frozen", + "--lock=utils/flyio/deno.lock", + "--allow-env", + script, + ], + { cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 } + ); + return stdout; +} + +async function generateBootstrapSetupURI( + objectStorage: ObjectStorageConfig, + bucketPrefix: string +): Promise { + const setupPassphrase = randomBytes(24).toString("base64url"); + const output = await runDeno("utils/setup/generate_setup_uri.ts", { + ...process.env, + remote_type: "s3", + endpoint: objectStorage.endpoint, + access_key: objectStorage.accessKey, + secret_key: objectStorage.secretKey, + bucket: objectStorage.bucket, + region: objectStorage.region, + force_path_style: String(objectStorage.forcePathStyle), + bucket_prefix: bucketPrefix, + passphrase: randomBytes(24).toString("base64url"), + uri_passphrase: setupPassphrase, + }); + const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); + if (!setupURI) throw new Error("The public Setup URI generator did not emit an Object Storage Setup URI."); + return { setupURI, setupPassphrase }; +} + +async function startSession( + context: RunnerContext, + vault: TemporaryVault, + port: number +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(port), + }); + context.activeSessions.add(session); + return session; +} + +async function stopSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await stopSession(context, session); + } +} + +async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function writeNote( + cliBinary: string, + environment: 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(""), + environment + ); + await waitForLocalDatabaseEntry(cliBinary, environment, path); +} + +async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000); + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === expected) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function waitForObjectStorageData(config: ObjectStorageConfig, prefix: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_OBJECT_STORAGE_TIMEOUT_MS ?? 30000); + while (Date.now() < deadline) { + if ((await listObjectStorageObjects(config, prefix)).length > 0) return; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`); +} + +async function captureNote(port: number, path: string, text: string, filename: string): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((notePath) => { + const obsidian = globalThis as typeof globalThis & { + app?: { + workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; + }; + }; + return obsidian.app?.workspace?.openLinkText(notePath, "", false); + }, path); + }); + await captureObsidianPage(port, `${filename}.full.png`, async (page) => { + await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: 30000 }); + }); + return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first()); +} + +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 objectStorage = await loadObjectStorageConfig(); + const bucketPrefix = makeUniqueBucketPrefix("setup-uri-workflow"); + const bootstrapArtifact = await generateBootstrapSetupURI(objectStorage, bucketPrefix); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const [portA, portB] = sessionPorts(); + const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() }; + const screenshots: string[] = []; + + try { + await ensureObjectStorageBucket(objectStorage); + console.log(`Temporary Object Storage target: ${objectStorage.bucket}/${bucketPrefix}`); + + const sessionA = await startSession(context, vaultA, portA); + screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures)); + screenshots.push(await captureAndStartInitialisation(portA, "new", captures)); + screenshots.push(await confirmRebuild(portA, captures)); + screenshots.push(await skipMissingRemoteConfiguration(portA, captures)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures)); + const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv); + await resumeCompatibilityReviewIfShown(portA); + assertEqual( + firstState.endpoint, + objectStorage.endpoint, + "The first device did not activate the Object Storage endpoint." + ); + assertEqual( + firstState.bucket, + objectStorage.bucket, + "The first device did not activate the Object Storage bucket." + ); + assertEqual( + firstState.bucketPrefix, + bucketPrefix, + "The first device did not activate the unique bucket prefix." + ); + + await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent); + await pushLocalChanges(context.cliBinary, sessionA.cliEnv); + await waitForObjectStorageData(objectStorage, bucketPrefix); + const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures); + if (generated.artifact.setupURI === bootstrapArtifact.setupURI) { + throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); + } + screenshots.push(...generated.screenshots); + await stopSession(context, sessionA); + + const sessionB = await startSession(context, vaultB, portB); + screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures)); + screenshots.push(await captureAndStartInitialisation(portB, "existing", captures)); + screenshots.push(...(await confirmFastFetch(portB, captures))); + const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv); + await resumeCompatibilityReviewIfShown(portB); + assertEqual( + secondState.endpoint, + objectStorage.endpoint, + "The second device did not import the Object Storage endpoint." + ); + assertEqual( + secondState.bucketPrefix, + bucketPrefix, + "The second device did not import the unique bucket prefix." + ); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + await waitForPathContent(vaultB, noteFromFirst, firstContent); + screenshots.push( + await captureNote( + portB, + noteFromFirst, + "Object Storage from the first device", + "guide-object-storage-setup-first-to-second.png" + ) + ); + + await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent); + await pushLocalChanges(context.cliBinary, sessionB.cliEnv); + await stopSession(context, sessionB); + + const returningSessionA = await startSession(context, vaultA, portA); + await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv); + await resumeCompatibilityReviewIfShown(portA); + await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv); + await waitForPathContent(vaultA, noteFromSecond, secondContent); + screenshots.push( + await captureNote( + portA, + noteFromSecond, + "Object Storage from the second device", + "guide-object-storage-setup-second-to-first.png" + ) + ); + + console.log( + `Object Storage Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}` + ); + } finally { + await stopSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await vaultA.dispose(); + await vaultB.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") { + await deleteObjectStoragePrefix(objectStorage, bucketPrefix).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/onboarding-invitation.ts b/test/e2e-obsidian/scripts/onboarding-invitation.ts new file mode 100644 index 00000000..8367fb82 --- /dev/null +++ b/test/e2e-obsidian/scripts/onboarding-invitation.ts @@ -0,0 +1,246 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { assertMobileDialogueLayout, iPhoneSafeArea, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_ONBOARDING_TIMEOUT_MS ?? 15000); +const markerPath = "E2E/unconfigured-startup-must-not-scan.md"; + +type UnconfiguredStartupEvidence = { + configured: boolean; + markerInDatabase: boolean; + offlineScanInitialised: boolean; + recommendedDefaults: { + usePluginSyncV2: boolean; + handleFilenameCaseSensitive: boolean; + }; +}; + +type ObsidianTestApp = { + setting?: { + open(): void; + openTabById(tabId: string): void; + }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function writeMarker(vaultPath: string): Promise { + const fullPath = join(vaultPath, markerPath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, "# This file must remain outside the database until setup completes.\n", "utf8"); +} + +async function inspectUnconfiguredStartup( + cliBinary: string, + env: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + `const markerPath=${JSON.stringify(markerPath)};`, + "let entry=false;", + "try{entry=await core.localDatabase.getDBEntry(markerPath,undefined,false,false);}catch{}", + "let initialised=false;", + "try{initialised=(await core.kvDB.get('initialized'))===true;}catch{}", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "configured:settings?.isConfigured===true,", + "markerInDatabase:Boolean(entry&&entry._id),", + "offlineScanInitialised:initialised,", + "recommendedDefaults:{", + "usePluginSyncV2:settings?.usePluginSyncV2,", + "handleFilenameCaseSensitive:settings?.handleFilenameCaseSensitive,", + "},", + "});", + "})()", + ].join(""), + env + ); +} + +function onboardingNotice(page: Parameters[1]>[0]) { + return page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); +} + +function onboardingDialogue(page: Parameters[1]>[0]) { + return page.locator(".modal-container").filter({ hasText: "Welcome to Self-hosted LiveSync" }); +} + +async function requireInvitationWithoutDialogue(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const invitation = onboardingNotice(page); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await onboardingDialogue(page).count()) !== 0) { + throw new Error("The onboarding dialogue opened before the user selected the invitation."); + } + const compatibilityReview = page.locator(".modal-container").filter({ + hasText: "Synchronisation paused for compatibility review", + }); + if ((await compatibilityReview.count()) !== 0) { + throw new Error("A new unconfigured Vault was incorrectly treated as an existing compatibility state."); + } + }); +} + +async function captureDesktopInvitation(): Promise { + return await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "onboarding-invitation-desktop.png", + async (page) => { + const invitation = onboardingNotice(page); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, invitation, { label: "desktop onboarding invitation" }); + } + ); +} + +async function captureAndSelectMobileInvitation(): Promise { + const port = obsidianRemoteDebuggingPort(); + await setObsidianMobileTestMode(port, true, uiTimeoutMs); + const screenshot = await captureObsidianDialogue(port, "onboarding-invitation-mobile.png", async (page) => { + const invitation = onboardingNotice(page); + const action = invitation.locator(".sls-onboarding-invitation-action"); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertLocatorWithinSafeArea(page, invitation, { + label: "mobile onboarding invitation", + safeAreaInsets: iPhoneSafeArea, + }); + await assertNoHorizontalOverflow(page, invitation, { label: "mobile onboarding invitation" }); + await assertLocatorHasMinimumTouchTarget(page, action, { + label: "mobile onboarding invitation action", + }); + }); + await withObsidianPage(port, async (page) => { + await onboardingNotice(page).locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function captureAndCloseIntro(filename: string, mobile: boolean): Promise { + const port = obsidianRemoteDebuggingPort(); + const screenshot = await captureObsidianDialogue(port, filename, async (page) => { + const container = onboardingDialogue(page); + await container.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await container.getByText("I am setting this up for the first time", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await container + .getByText("I am adding a device to an existing synchronisation setup", { exact: true }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mobile) await assertMobileDialogueLayout(page, container, "mobile onboarding introduction"); + }); + await withObsidianPage(port, async (page) => { + const container = onboardingDialogue(page); + await container.getByRole("button", { name: "No, please take me back" }).click({ timeout: uiTimeoutMs }); + await container.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +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 }); + }); +} + +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 vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + await writeMarker(vault.path); + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + + await requireInvitationWithoutDialogue(); + const evidence = await inspectUnconfiguredStartup(cli.binary, session.cliEnv); + if ( + evidence.configured || + evidence.markerInDatabase || + evidence.offlineScanInitialised || + evidence.recommendedDefaults.usePluginSyncV2 !== true || + evidence.recommendedDefaults.handleFilenameCaseSensitive !== false + ) { + throw new Error(`Fresh Vault startup state did not match its contract: ${JSON.stringify(evidence)}`); + } + console.log(`Fresh Vault startup evidence: ${JSON.stringify(evidence)}`); + + const desktopInvitation = await captureDesktopInvitation(); + 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); + + console.log( + `Onboarding remained opt-in and kept unconfigured startup inert. Screenshots: ${[ + desktopInvitation, + mobileInvitation, + mobileIntro, + settingsIntro, + ].join(", ")}` + ); + } finally { + if (session) { + await setObsidianMobileTestMode(obsidianRemoteDebuggingPort(), false, uiTimeoutMs).catch(() => undefined); + 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/p2p-pane.ts b/test/e2e-obsidian/scripts/p2p-pane.ts new file mode 100644 index 00000000..fc57b6c8 --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-pane.ts @@ -0,0 +1,415 @@ +/** + * 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 { ConsoleMessage, Page } from "playwright"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + createE2eCouchDbPluginData, + createE2eObsidianDeviceLocalState, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.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(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, + }; + }); +} + +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 { + 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(); + 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]" + ); + await pane.getByText("Connection:", { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + await pane.getByRole("button", { name: "Open connection" }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + const remoteSelector = pane.getByRole("combobox", { name: "Select active P2P remote" }); + await remoteSelector.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const remoteSelectionDeadline = Date.now() + uiTimeoutMs; + let remoteConfigurationId = ""; + while (Date.now() < remoteSelectionDeadline) { + remoteConfigurationId = (await remoteSelector.inputValue()).trim(); + if (remoteConfigurationId !== "") break; + await page.waitForTimeout(50); + } + if (remoteConfigurationId === "") { + throw new Error("The configured P2P status pane did not select an active P2P remote."); + } + if ( + (await pane.getByText("Please select an active P2P remote configuration to change P2P sync targets.").count()) !== + 0 + ) { + throw new Error("The configured P2P status pane still requested an active P2P remote."); + } + await assertNoHorizontalOverflow(page, pane, { label: "P2P status pane" }); + if (mobile) { + await assertLocatorWithinViewport(page, pane, { label: "mobile P2P status pane" }); + } + await dismissOpenNotices(page); + }); +} + +async function assertP2PUIIsOptIn(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const state = await page.evaluate(() => { + const commands = (globalThis as ObsidianTestGlobal).app?.commands?.commands ?? {}; + return { + currentCommand: commands["obsidian-livesync:open-p2p-server-status"] !== undefined, + legacyCommand: commands["obsidian-livesync:open-p2p-replicator"] !== undefined, + }; + }); + if (!state.currentCommand) { + throw new Error("The current P2P status command was not registered."); + } + if (state.legacyCommand) { + 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 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."); + } + }); +} + +async function assertConfiguredP2PUIIsAvailable(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.locator(".livesync-ribbon-p2p-server-status").waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await page.locator(".workspace-leaf-content[data-type='p2p-server-status']:visible").count()) !== 0) { + throw new Error("The configured P2P status pane opened before the user requested it."); + } + }); +} + +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 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; + } + await page.waitForTimeout(100); + continue; + } + quietSince = Date.now(); + await page.waitForTimeout(50); + } + throw new Error("Transient Obsidian notices did not become quiet before the P2P status screenshot."); +} + +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, + cliBinary: string, + pluginData: Record, + verify: () => Promise, + options: { mobileBeforePluginStart?: boolean } = {} +): Promise { + const vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary, + vault, + 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(); + } finally { + if (session) { + await session.app.stop(); + } + await vault.dispose(); + } +} + +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(", ")}`); + } + + await withP2PSession(binary, cli.binary, createBaseP2PPluginData(), async () => { + await assertP2PUIIsOptIn(); + }); + + await withP2PSession( + binary, + cli.binary, + createConfiguredP2PPluginData(), + async () => { + await assertConfiguredP2PUIIsAvailable(); + const desktopScreenshot = await verifyP2PStatusPane("p2p-status-pane.png", false); + 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) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts new file mode 100644 index 00000000..1a67edb6 --- /dev/null +++ b/test/e2e-obsidian/scripts/p2p-setup-uri-workflow.ts @@ -0,0 +1,589 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { connect } from "node:net"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { assertEqual, waitForLocalDatabaseEntry } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + acknowledgeDisabledOptionalFeatures, + captureAndStartInitialisation, + captureGuideDialogue, + confirmFastFetch, + confirmRebuild, + enterSetupURI, + finishInitialisation, + generateSetupURIFromDevice, + modalByTitle, + resumeCompatibilityReviewIfShown, + type SetupArtifact, + type SetupCaptureNames, +} from "../runner/setupUri.ts"; +import { + captureObsidianElement, + captureObsidianPage, + obsidianRemoteDebuggingPort, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +const execFileAsync = promisify(execFile); +const captures: SetupCaptureNames = { scenario: "p2p-setup-uri", guide: "p2p-setup" }; +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_P2P_WORKFLOW_TIMEOUT_MS ?? 60000); +const noteFromFirst = "E2E/p2p/from-first.md"; +const noteFromSecond = "E2E/p2p/from-second.md"; +const firstContent = "# P2P from the first device\n\nThis note was fetched directly from the first device.\n"; +const secondContent = "# P2P from the second device\n\nThis note completed the return journey.\n"; + +type RunnerContext = { + binary: string; + cliBinary: string; + activeSessions: Set; +}; + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(process.env); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +async function runDeno(script: string, environment: NodeJS.ProcessEnv): Promise { + const { stdout } = await execFileAsync( + "deno", + [ + "run", + "--minimum-dependency-age=0", + "--config=utils/flyio/deno.jsonc", + "--frozen", + "--lock=utils/flyio/deno.lock", + "--allow-env", + script, + ], + { cwd: process.cwd(), env: environment, maxBuffer: 4 * 1024 * 1024 } + ); + return stdout; +} + +async function generateBootstrapSetupURI(relay: string): Promise { + const setupPassphrase = randomBytes(24).toString("base64url"); + const output = await runDeno("utils/setup/generate_setup_uri.ts", { + ...process.env, + remote_type: "p2p", + p2p_relays: relay, + p2p_room_id: `real-obsidian-${randomBytes(12).toString("hex")}`, + p2p_passphrase: randomBytes(24).toString("base64url"), + p2p_app_id: "self-hosted-livesync-real-obsidian-e2e", + p2p_auto_start: "false", + p2p_auto_broadcast: "false", + passphrase: randomBytes(24).toString("base64url"), + uri_passphrase: setupPassphrase, + }); + const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); + if (!setupURI) throw new Error("The public Setup URI generator did not emit a P2P Setup URI."); + return { setupURI, setupPassphrase }; +} + +async function waitForRelay(relay: string): Promise { + const endpoint = new URL(relay); + const port = Number(endpoint.port || (endpoint.protocol === "wss:" ? 443 : 80)); + const host = endpoint.hostname === "localhost" ? "127.0.0.1" : endpoint.hostname; + const deadline = Date.now() + Number(process.env.E2E_P2P_RELAY_READY_TIMEOUT_MS ?? 30000); + let lastError: unknown; + while (Date.now() < deadline) { + try { + await new Promise((resolve, reject) => { + const socket = connect({ host, port }); + socket.setTimeout(1000); + socket.once("connect", () => { + socket.destroy(); + resolve(); + }); + socket.once("timeout", () => { + socket.destroy(); + reject(new Error("connection timed out")); + }); + socket.once("error", reject); + }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + throw new Error( + `P2P relay is not ready at ${relay}: ${lastError instanceof Error ? lastError.message : lastError}` + ); +} + +async function startSession( + context: RunnerContext, + vault: TemporaryVault, + port: number +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(port), + }); + context.activeSessions.add(session); + return session; +} + +async function stopSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) { + await session.app.stop(); + context.activeSessions.delete(session); + } +} + +async function writeNote( + cliBinary: string, + environment: 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(""), + environment + ); + await waitForLocalDatabaseEntry(cliBinary, environment, path); +} + +async function waitForPathContent(vault: TemporaryVault, path: string, expected: string): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 60000); + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readFile(join(vault.path, path), "utf8"); + if (lastContent === expected) return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function readReflectionDiagnostics( + cliBinary: string, + environment: NodeJS.ProcessEnv, + path: string +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "const entry=await core.localDatabase.getDBEntry(path,undefined,false,true).catch(()=>false);", + "const chunks=entry&&Array.isArray(entry.children)?await Promise.all(entry.children.map(async(id)=>{", + "const chunk=await core.localDatabase.getDBEntry(id,undefined,false,true).catch(()=>false);", + "return {id,found:!!chunk};", + "})):[];", + "return JSON.stringify({", + "suspendFileWatching:settings.suspendFileWatching,", + "suspendParseReplicationResult:settings.suspendParseReplicationResult,", + "configured:settings.isConfigured,", + "entry:entry?{id:entry._id,path:entry.path,children:entry.children||[]}:false,", + "chunks,", + "databaseQueueCount:core.services.replication.databaseQueueCount?.value,", + "storageApplyingCount:core.services.replication.storageApplyingCount?.value,", + "replicationResultCount:core.services.replication.replicationResultCount?.value,", + "});", + "})()", + ].join(""), + environment + ); +} + +async function executeCommand(port: number, commandId: string): Promise { + const opened = await withObsidianPage(port, async (page) => { + return await page.evaluate( + (id) => + ( + globalThis as typeof globalThis & { + app?: { commands?: { executeCommandById(commandId: string): boolean } }; + } + ).app?.commands?.executeCommandById(id) === true, + commandId + ); + }); + if (!opened) throw new Error(`Obsidian command was not available: ${commandId}`); +} + +async function openP2PStatus(port: number, filename: string): Promise { + await executeCommand(port, "obsidian-livesync:open-p2p-server-status"); + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + const open = pane.getByRole("button", { name: "Open connection" }); + if (await open.isVisible()) { + const blockingDialogues = await page.locator(".modal-container:visible").evaluateAll((elements) => + elements.map((element) => ({ + title: element.querySelector(".modal-title")?.textContent?.trim() ?? "", + text: element.textContent?.trim().replace(/\s+/gu, " ").slice(0, 240) ?? "", + })) + ); + if (blockingDialogues.length > 0) { + throw new Error( + `P2P connection control is blocked by a dialogue: ${JSON.stringify(blockingDialogues)}` + ); + } + await open.click({ timeout: uiTimeoutMs }); + } + await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + return await captureObsidianElement(port, filename, (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + return heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + }); +} + +async function reconnectP2PStatus(port: number): Promise { + await executeCommand(port, "obsidian-livesync:open-p2p-server-status"); + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Signalling Status" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + const disconnect = pane.getByRole("button", { name: "Disconnect", exact: true }); + if (await disconnect.isVisible()) { + await disconnect.click({ timeout: uiTimeoutMs }); + } + const open = pane.getByRole("button", { name: "Open connection", exact: true }); + await open.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await open.click({ timeout: uiTimeoutMs }); + await pane.locator(".status-value.connected").waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function acceptConnectionRequests( + ports: readonly number[], + stop: () => boolean, + screenshots: string[] +): Promise { + const captured = new Set(); + while (!stop()) { + for (const port of ports) { + const visible = await withObsidianPage(port, async (page) => { + return await modalByTitle(page, "P2P Connection Request").isVisible(); + }).catch(() => false); + if (!visible) continue; + if (!captured.has(port)) { + const requestNumber = + screenshots.filter((filename) => filename.includes("guide-p2p-setup-connection-request-")).length + + 1; + screenshots.push( + await captureGuideDialogue( + port, + `guide-p2p-setup-connection-request-${requestNumber}.png`, + "P2P Connection Request" + ) + ); + captured.add(port); + } + await withObsidianPage(port, async (page) => { + await modalByTitle(page, "P2P Connection Request") + .getByRole("button", { name: "Accept", exact: true }) + .click({ timeout: uiTimeoutMs }); + }); + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } +} + +async function fetchFromFirstPeer( + sessionA: ObsidianLiveSyncSession, + portA: number, + portB: number, + screenshots: string[] +): Promise { + try { + await withObsidianPage(portB, async (page) => { + const modal = modalByTitle(page, "P2P Rebuild"); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.locator(".peer-item").first().waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + } catch (error) { + const firstDeviceAlive = sessionA.app.process.exitCode === null && sessionA.app.process.signalCode === null; + const firstDeviceUi = await withObsidianPage(portA, async (page) => { + return await page.locator("body").innerText(); + }).catch(() => undefined); + const secondDeviceDialogue = await withObsidianPage(portB, async (page) => { + return await modalByTitle(page, "P2P Rebuild").innerText(); + }).catch(() => undefined); + throw new Error( + [ + error instanceof Error ? error.message : String(error), + `First Obsidian process alive: ${firstDeviceAlive}`, + `First Obsidian CDP reachable: ${firstDeviceUi !== undefined}`, + firstDeviceUi === undefined ? undefined : `First device UI: ${firstDeviceUi.slice(0, 1_500)}`, + secondDeviceDialogue === undefined + ? undefined + : `Second-device P2P Rebuild dialogue: ${secondDeviceDialogue.slice(0, 1_500)}`, + sessionA.app.output().stderr + ? `First Obsidian stderr: ${sessionA.app.output().stderr.slice(-2_000)}` + : undefined, + ] + .filter(Boolean) + .join("\n") + ); + } + screenshots.push(await captureGuideDialogue(portB, "guide-p2p-setup-select-first-device.png", "P2P Rebuild")); + + let finished = false; + const acceptor = acceptConnectionRequests([portA, portB], () => finished, screenshots); + try { + await withObsidianPage(portB, async (page) => { + const modal = modalByTitle(page, "P2P Rebuild"); + await modal + .locator(".peer-item") + .first() + .getByRole("button", { name: "Sync", exact: true }) + .click({ timeout: uiTimeoutMs }); + await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + } finally { + finished = true; + await acceptor; + } + + await withObsidianPage(portB, async (page) => { + const modal = modalByTitle(page, "P2P Rebuild"); + if (await modal.isVisible()) { + await modal.getByRole("button", { name: "Skip and close" }).click({ timeout: uiTimeoutMs }); + } + }); +} + +async function replicateFromStatusPane(port: number): Promise { + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Detected Peers" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs }); + const replicate = pane.getByRole("button", { name: "Replicate now" }).first(); + await replicate.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await replicate.click({ timeout: uiTimeoutMs }); + }); +} + +async function waitForDetectedPeer(port: number): Promise { + await withObsidianPage(port, async (page) => { + const heading = page.getByRole("heading", { name: "Detected Peers" }).last(); + await heading.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const pane = heading.locator( + "xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' workspace-leaf-content ')][1]" + ); + await pane.getByRole("button", { name: "Refresh", exact: true }).click({ timeout: uiTimeoutMs }); + await pane.getByRole("button", { name: "Replicate now" }).first().waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); +} + +async function capturePeerActionsMenu(port: number): Promise { + await withObsidianPage(port, async (page) => { + const moreActions = page.getByRole("button", { name: /^More actions for /u }).first(); + await moreActions.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await moreActions.click({ timeout: uiTimeoutMs }); + const menu = page.locator(".menu:visible").last(); + await menu.waitFor({ state: "visible", timeout: uiTimeoutMs }); + for (const label of [ + "Synchronise when this device connects", + "Follow whenever this device connects", + "Include in the P2P synchronisation command", + ]) { + await menu.getByText(label, { exact: true }).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + const layout = await menu.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + insideViewport: + rect.left >= 0 && + rect.top >= 0 && + rect.right <= document.documentElement.clientWidth && + rect.bottom <= document.documentElement.clientHeight, + hasHorizontalOverflow: element.scrollWidth > element.clientWidth, + }; + }); + if (!layout.insideViewport || layout.hasHorizontalOverflow) { + throw new Error(`P2P peer actions menu did not fit the viewport: ${JSON.stringify(layout)}`); + } + }); + const screenshot = await captureObsidianElement( + port, + "guide-p2p-setup-peer-actions-menu.png", + (page) => page.locator(".menu:visible").last() + ); + await withObsidianPage(port, async (page) => { + await page.keyboard.press("Escape"); + await page.locator(".menu:visible").waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function captureNote(port: number, path: string, text: string, filename: string): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((notePath) => { + const obsidian = globalThis as typeof globalThis & { + app?: { + workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; + }; + }; + return obsidian.app?.workspace?.openLinkText(notePath, "", false); + }, path); + }); + await captureObsidianPage(port, `${filename}.full.png`, async (page) => { + await page.getByText(text, { exact: false }).first().waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + return await captureObsidianElement(port, filename, (page) => page.locator(".workspace-leaf.mod-active").first()); +} + +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 relay = process.env.E2E_P2P_RELAY_URL ?? `ws://127.0.0.1:${process.env.E2E_P2P_RELAY_PORT ?? "4010"}/`; + await waitForRelay(relay); + const bootstrapArtifact = await generateBootstrapSetupURI(relay); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const [portA, portB] = sessionPorts(); + const context: RunnerContext = { binary, cliBinary: cli.binary, activeSessions: new Set() }; + const screenshots: string[] = []; + + try { + console.log(`Temporary P2P relay: ${relay}`); + console.log(`Temporary P2P devices: ${vaultA.name}, ${vaultB.name}`); + + const sessionA = await startSession(context, vaultA, portA); + screenshots.push(await enterSetupURI(portA, "new", bootstrapArtifact, captures)); + screenshots.push(await captureAndStartInitialisation(portA, "new", captures)); + screenshots.push(await confirmRebuild(portA, captures)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(portA, captures)); + const firstState = await finishInitialisation(portA, context.cliBinary, sessionA.cliEnv); + await resumeCompatibilityReviewIfShown(portA); + assertEqual(firstState.p2pEnabled, true, "The first device did not enable P2P."); + assertEqual(firstState.p2pRelays, relay, "The first device did not activate the P2P relay."); + await writeNote(context.cliBinary, sessionA.cliEnv, noteFromFirst, firstContent); + + const generated = await generateSetupURIFromDevice(portA, randomBytes(24).toString("base64url"), captures); + if (generated.artifact.setupURI === bootstrapArtifact.setupURI) { + throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); + } + screenshots.push(...generated.screenshots); + screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-first-device-connected.png")); + + const sessionB = await startSession(context, vaultB, portB); + screenshots.push(await enterSetupURI(portB, "existing", generated.artifact, captures)); + screenshots.push(await captureAndStartInitialisation(portB, "existing", captures)); + screenshots.push(...(await confirmFastFetch(portB, captures))); + await fetchFromFirstPeer(sessionA, portA, portB, screenshots); + await waitForLocalDatabaseEntry(context.cliBinary, sessionB.cliEnv, noteFromFirst, { + timeoutMs: uiTimeoutMs, + }); + const secondState = await finishInitialisation(portB, context.cliBinary, sessionB.cliEnv); + await resumeCompatibilityReviewIfShown(portB); + assertEqual(secondState.p2pEnabled, true, "The second device did not enable P2P."); + assertEqual(secondState.p2pRelays, relay, "The second device did not import the P2P relay."); + assertEqual(secondState.p2pRoomId, firstState.p2pRoomId, "The two devices did not join the same P2P room."); + try { + await waitForPathContent(vaultB, noteFromFirst, firstContent); + } catch (error) { + const diagnostics = await readReflectionDiagnostics(context.cliBinary, sessionB.cliEnv, noteFromFirst); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\nReflection diagnostics: ${JSON.stringify(diagnostics)}` + ); + } + screenshots.push( + await captureNote(portB, noteFromFirst, "P2P from the first device", "guide-p2p-setup-first-to-second.png") + ); + console.log("P2P workflow: initial Fetch from the first device completed."); + + await writeNote(context.cliBinary, sessionB.cliEnv, noteFromSecond, secondContent); + await reconnectP2PStatus(portA); + await reconnectP2PStatus(portB); + await waitForDetectedPeer(portA); + screenshots.push(await openP2PStatus(portA, "guide-p2p-setup-devices-connected.png")); + screenshots.push(await capturePeerActionsMenu(portA)); + console.log("P2P workflow: peer actions menu verified; starting the return journey."); + let returnJourneyFinished = false; + const returnJourneyAcceptor = acceptConnectionRequests( + [portA, portB], + () => returnJourneyFinished, + screenshots + ); + try { + await replicateFromStatusPane(portA); + console.log("P2P workflow: return replication requested; waiting for the second device's note."); + await waitForPathContent(vaultA, noteFromSecond, secondContent); + console.log("P2P workflow: return note reached the first device."); + } finally { + returnJourneyFinished = true; + await returnJourneyAcceptor; + console.log("P2P workflow: return connection approval loop stopped."); + } + screenshots.push( + await captureNote( + portA, + noteFromSecond, + "P2P from the second device", + "guide-p2p-setup-second-to-first.png" + ) + ); + + console.log(`P2P Setup URI and two-device roundtrip succeeded. Screenshots: ${screenshots.join(", ")}`); + } finally { + console.log("P2P workflow: stopping tracked Obsidian sessions."); + await stopSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + console.log("P2P workflow: disposing temporary Vaults."); + await vaultA.dispose(); + await vaultB.dispose(); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/scripts/review-harness.ts b/test/e2e-obsidian/scripts/review-harness.ts new file mode 100644 index 00000000..3c7fb96f --- /dev/null +++ b/test/e2e-obsidian/scripts/review-harness.ts @@ -0,0 +1,469 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertNoHorizontalOverflow, +} from "@vrtmrz/obsidian-test-session"; +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, + 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); + +type ObsidianTestApp = { + commands?: { executeCommandById(commandId: string): boolean }; + plugins?: { plugins: Record }; + vault?: { getAbstractFileByPath(path: string): unknown | null }; +}; + +type ReviewHarnessTestGlobal = typeof globalThis & { + app?: ObsidianTestApp; + 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( + (commandId) => (globalThis as ReviewHarnessTestGlobal).app?.commands?.executeCommandById(commandId) === true, + "obsidian-livesync:open-review-harness" + ); + }); + if (!opened) throw new Error("The Review Harness command was not registered."); +} + +async function waitForHarness(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.locator('[data-testid="review-harness"]').waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function keepCompatibilityPaused(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await summary.getByRole("button", { name: "Keep synchronisation paused" }).click({ timeout: uiTimeoutMs }); + await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function runAutomaticScenarios(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness.locator('[data-testid="review-harness-run-automatic"]').click({ timeout: uiTimeoutMs }); + for (const id of ["settings-lifecycle", "p2p-composition"]) { + await harness + .locator(`[data-testid="review-harness-result-${id}"]`) + .getByText("Passed:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + }); +} + +async function runVaultFixture(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page + .locator('[data-testid="review-harness-run-vault-round-trip"]') + .click({ timeout: uiTimeoutMs }); + const confirmation = page.locator(".modal-container").filter({ + has: page.getByText("Review Harness: Vault fixture access", { exact: true }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + const screenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-vault-confirmation.png", + async (page) => { + const confirmation = page.locator(".modal-container").filter({ + has: page.getByText("Review Harness: Vault fixture access", { exact: true }), + }); + await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, confirmation, { label: "Vault fixture confirmation" }); + } + ); + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + const confirmation = page.locator(".modal-container").filter({ + has: page.getByText("Review Harness: Vault fixture access", { exact: true }), + }); + await confirmation.getByRole("button", { name: "Yes" }).click({ timeout: uiTimeoutMs }); + await harness + .locator('[data-testid="review-harness-result-vault-round-trip"]') + .getByText("Passed:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const fixtureRemoved = await page.evaluate( + (root) => (globalThis as ReviewHarnessTestGlobal).app?.vault?.getAbstractFileByPath(root) === null, + REVIEW_HARNESS_FIXTURE_ROOT + ); + if (!fixtureRemoved) throw new Error("The Review Harness fixture root remained after the scenario."); + }); + return screenshot; +} + +async function restartAndResumeHarness(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-run-compatibility-review"]') + .click({ timeout: uiTimeoutMs }); + await harness + .locator('[data-testid="review-harness-result-compatibility-review"]') + .getByText("Waiting for review:", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await harness.locator('[data-testid="review-harness-restart"]').click({ timeout: uiTimeoutMs }); + }); + + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.waitForFunction( + () => { + const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) return false; + const core = (plugin as { core: { services: { appLifecycle: { isReady(): boolean } } } }).core; + return core.services.appLifecycle.isReady(); + }, + undefined, + { timeout: uiTimeoutMs * 2 } + ); + }); + await keepCompatibilityPaused(); + await waitForHarness(); + return await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-resumed.png", + async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-resumed"]') + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + const continuationRemoved = await page.evaluate((stateKey) => { + const plugin = (globalThis as ReviewHarnessTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (typeof plugin !== "object" || plugin === null || !("core" in plugin)) { + throw new Error("Self-hosted LiveSync is unavailable after restart."); + } + const core = (plugin as { core: { services: { setting: { getSmallConfig(key: string): string } } } }) + .core; + return core.services.setting.getSmallConfig(stateKey) === ""; + }, REVIEW_HARNESS_STATE_KEY); + if (!continuationRemoved) throw new Error("The one-shot continuation was not removed before use."); + await assertNoHorizontalOverflow(page, harness, { label: "resumed Review Harness" }); + } + ); +} + +async function completeResumedCompatibilityStep(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness + .locator('[data-testid="review-harness-open-compatibility-review"]') + .click({ timeout: uiTimeoutMs }); + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await summary.getByRole("button", { name: "Resume synchronisation" }).click({ timeout: uiTimeoutMs }); + await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await harness + .locator('[data-testid="review-harness-result-compatibility-review"]') + .getByText("The device-local compatibility pause was reviewed and cleared.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); +} + +async function copyAndReadReport(): Promise { + return await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + await page.evaluate(` + globalThis.reviewHarnessCopiedReport = undefined; + navigator.clipboard.writeText = function (value) { + globalThis.reviewHarnessCopiedReport = value; + return Promise.resolve(); + }; + `); + await page.locator('[data-testid="review-harness-copy-report"]').click({ timeout: uiTimeoutMs }); + await page.waitForFunction( + () => typeof (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport === "string", + undefined, + { timeout: uiTimeoutMs } + ); + return await page.evaluate( + () => (globalThis as ReviewHarnessTestGlobal).reviewHarnessCopiedReport ?? "" + ); + }); +} + +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)) { + throw new Error("Self-hosted LiveSync is unavailable in mobile test mode."); + } + const core = (plugin as { + core: { services: { API: { showWindow(type: string): Promise } } }; + }).core; + await core.services.API.showWindow(viewType); + }, "self-hosted-livesync-review-harness"); + }); + return await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-mobile.png", + async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, harness, { label: "mobile Review Harness" }); + const heading = harness.getByRole("heading", { name: "Self-hosted LiveSync review harness" }); + await assertLocatorWithinSafeArea(page, heading, { + label: "mobile Review Harness heading", + safeAreaInsets: iPhoneSafeArea, + }); + for (const testId of [ + "review-harness-run-automatic", + "review-harness-run-full", + "review-harness-copy-report", + ]) { + await assertLocatorHasMinimumTouchTarget(page, harness.locator(`[data-testid="${testId}"]`), { + label: testId, + }); + } + } + ); +} + +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 vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "1.0.0", + settingVersion: CURRENT_SETTING_VERSION, + isConfigured: true, + additionalSuffixOfDatabaseName: "", + enableDebugTools: true, + notifyThresholdOfRemoteStorageSize: 0, + P2P_Enabled: false, + P2P_AutoStart: false, + liveSync: false, + syncOnSave: false, + syncOnEditorSave: true, + syncOnStart: false, + syncOnFileOpen: true, + syncAfterMerge: false, + periodicReplication: true, + }, + }); + 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(); + + const initialScreenshot = await captureObsidianDialogue( + obsidianRemoteDebuggingPort(), + "review-harness-initial.png", + async (page) => { + const harness = page.locator('[data-testid="review-harness"]'); + await harness.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertNoHorizontalOverflow(page, harness, { label: "Review Harness" }); + } + ); + + await runAutomaticScenarios(); + const vaultConfirmationScreenshot = await runVaultFixture(); + const resumedScreenshot = await restartAndResumeHarness(); + await completeResumedCompatibilityStep(); + const report = await copyAndReadReport(); + if (!report.includes("## Self-hosted LiveSync Review Harness report")) { + throw new Error("The copied Review Harness report was not Markdown evidence."); + } + for (const forbidden of [vault.name, REVIEW_HARNESS_FIXTURE_ROOT]) { + if (report.includes(forbidden)) throw new Error(`The Review Harness report exposed local state: ${forbidden}`); + } + + const mobileScreenshot = await verifyMobileHarness(); + console.log( + `Review Harness passed one-shot, fixture, report, and mobile checks. Screenshots: ${[ + initialScreenshot, + vaultConfirmationScreenshot, + resumedScreenshot, + mobileScreenshot, + ].join(", ")}` + ); + } 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/revision-repair.ts b/test/e2e-obsidian/scripts/revision-repair.ts new file mode 100644 index 00000000..8dbab659 --- /dev/null +++ b/test/e2e-obsidian/scripts/revision-repair.ts @@ -0,0 +1,735 @@ +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"; +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`, + `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 VaultWinnerState = { + matches: boolean; + winnerRevision: 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 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, + 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 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 { + 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(); + 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, + [ + "(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 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); + 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("Inspect conflicts and file/database differences", { + exact: true, + }), + }); + await verifySetting.getByRole("button", { name: "Begin inspection", 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( + `File/database inspection reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).` + ); + } + const winnerRevision = revisionCard(settings, fixture.winnerRevision); + const brokenRevision = revisionCard(settings, fixture.conflictRevision); + await brokenRevision + .getByText(/🧩 Missing chunks: 1/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."); + } + 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, + }); + 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 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"); + 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: "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 }); + await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); + + const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv); + 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"); + 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: "Yes", exact: true }).click({ 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) { + throw new Error( + `Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({ + fixture, + afterDiscard, + })}` + ); + } + 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 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 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(); + } + 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 new file mode 100644 index 00000000..a46bc515 --- /dev/null +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -0,0 +1,89 @@ +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Keep the public wrapper deliberately narrower than package.json. Discovery, +// installation, runner contracts, and the complete suite have different setup +// requirements and remain separate entry points. +const focusedScenarios = new Set([ + "smoke", + "onboarding-invitation", + "dialog-mounts", + "revision-repair", + "settings-ui", + "review-harness", + "p2p-pane", + "vault-reflection", + "couchdb-upload", + "couchdb-manual-setup-workflow", + "cli-to-obsidian-sync", + "minio-upload", + "object-storage-setup-uri-workflow", + "p2p-setup-uri-workflow", + "startup-scan", + "setup-uri-workflow", + "two-vault-sync", + "security-seed-reconnect", + "hidden-file-snippet-sync", + "customisation-sync", + "setting-markdown-export", + "upgrade-from-stable", +]); + +function usage(): string { + return `Usage: npm run test:e2e:obsidian:focused -- [scenario arguments] + +Builds the current Self-hosted LiveSync plug-in before running one maintained +real-Obsidian scenario. Supported scenarios: + +${[...focusedScenarios].map((scenario) => ` ${scenario}`).join("\n")} + +This wrapper does not start CouchDB, Object Storage, or the P2P signalling +relay. Use the documented service commands or the complete +local-suite:services wrapper when required.`; +} + +// npm receives each argument directly. In particular, environment values and +// scenario arguments never pass through a shell for re-interpretation. +function runNpm(args: string[]): void { + const npm = process.platform === "win32" ? "npm.cmd" : "npm"; + const result = spawnSync(npm, args, { + cwd: fileURLToPath(new URL("../../..", import.meta.url)), + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`npm ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`); + } +} + +function main(): void { + const [scenario, ...scenarioArguments] = process.argv.slice(2); + if (!scenario || scenario === "-h" || scenario === "--help") { + process.stdout.write(`${usage()}\n`); + return; + } + if (!focusedScenarios.has(scenario)) { + throw new Error(`Unsupported focused real-Obsidian scenario: ${scenario}\n\n${usage()}`); + } + + // Individual scenario scripts intentionally remain fast, raw entry points. + // The wrapper owns the freshness guarantee which was previously easy to + // miss after changing TypeScript source. + runNpm(["run", "build"]); + + // The compatibility scenario defaults to the repository CLI. Build it only + // when the caller has not selected an external CLI distribution. + if (scenario === "cli-to-obsidian-sync" && !process.env.LIVESYNC_CLI_COMMAND) { + runNpm(["run", "build", "--workspace", "self-hosted-livesync-cli"]); + } + + const script = `test:e2e:obsidian:${scenario}`; + runNpm(["run", script, ...(scenarioArguments.length > 0 ? ["--", ...scenarioArguments] : [])]); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} 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..d75ffc21 --- /dev/null +++ b/test/e2e-obsidian/scripts/security-seed-reconnect.ts @@ -0,0 +1,768 @@ +/** + * 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"; +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; +}); diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts new file mode 100644 index 00000000..93b85786 --- /dev/null +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -0,0 +1,294 @@ +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { assertMobileDialogueLayout, setObsidianMobileTestMode } from "../runner/mobileUi.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { captureObsidianDialogue, obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { createTemporaryVault } from "../runner/vault.ts"; + +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETTINGS_TIMEOUT_MS ?? 10000); +const compatibilityReviewMessage = "Review the internal database compatibility change before synchronisation resumes."; + +type ObsidianSettingsController = { + open(): void; + openTabById(tabId: string): void; +}; + +type LiveSyncTestPlugin = { + core: { + services: { + setting: { + currentSettings(): { versionUpFlash: string }; + getSmallConfig(key: string): string | null; + }; + }; + }; +}; + +type ObsidianTestApp = { + setting?: ObsidianSettingsController; + plugins?: { plugins: Record }; +}; + +type ObsidianTestGlobal = typeof globalThis & { app?: ObsidianTestApp }; + +async function verifyCompatibilityReview(): Promise { + const port = obsidianRemoteDebuggingPort(); + const summaryScreenshot = await captureObsidianDialogue(port, "compatibility-review-summary.png", async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByText("Your automatic synchronisation preferences have not been changed.", { exact: false }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Review compatibility details" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { + name: "Resume synchronisation", + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByRole("button", { name: "Keep synchronisation paused" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + await withObsidianPage(port, async (page) => { + const markerBeforeAcknowledgement = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); + return plugin.core.services.setting.getSmallConfig("database-compatibility-version"); + }); + if (markerBeforeAcknowledgement !== null && markerBeforeAcknowledgement !== "") { + throw new Error( + `The database version was marked as acknowledged before review: ${markerBeforeAcknowledgement}` + ); + } + }); + + await setObsidianMobileTestMode(port, true, uiTimeoutMs); + const mobileSummaryScreenshot = await captureObsidianDialogue( + port, + "compatibility-review-summary-mobile.png", + async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await assertMobileDialogueLayout(page, summary, "compatibility review summary"); + const doctor = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }), + }); + if (await doctor.isVisible()) { + throw new Error("Config Doctor must wait until the initial compatibility review has closed."); + } + } + ); + + await withObsidianPage(port, async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.getByRole("button", { name: "Review compatibility details" }).click(); + }); + + const detailsScreenshot = await captureObsidianDialogue( + port, + "compatibility-review-details-mobile.png", + async (page) => { + const modal = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal.getByText("Why synchronisation is paused", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal.getByText("Remote replication is blocked before work begins.", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await modal + .getByRole("button", { name: "Back to compatibility review" }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await modal.getByRole("button", { name: "Keep synchronisation paused" }).count()) !== 0) { + throw new Error("The explanatory details dialogue must not make the pause decision."); + } + await assertMobileDialogueLayout(page, modal, "compatibility review details"); + } + ); + + await withObsidianPage(port, async (page) => { + const details = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Compatibility review details" }), + }); + await details.getByRole("button", { name: "Back to compatibility review" }).click(); + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary.waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + + await setObsidianMobileTestMode(port, false, uiTimeoutMs); + await withObsidianPage(port, async (page) => { + const summary = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ + hasText: "Synchronisation paused for compatibility review", + }), + }); + await summary + .getByRole("button", { + name: "Resume synchronisation", + }) + .click(); + await summary.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + await page.waitForFunction( + (expectedVersion) => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) return false; + const setting = plugin.core.services.setting; + return ( + setting.getSmallConfig("database-compatibility-version") === expectedVersion && + setting.currentSettings().versionUpFlash === "" + ); + }, + `${VER}`, + { timeout: uiTimeoutMs } + ); + }); + + console.log( + `Compatibility review screenshots: ${summaryScreenshot}, ${mobileSummaryScreenshot}, ${detailsScreenshot}` + ); +} + +async function verifyConfigDoctorFollowsCompatibilityReview(): Promise { + await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const doctor = page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }), + }); + await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) { + throw new Error("Config Doctor still treats supported Data Compression as a problem."); + } + await doctor.getByRole("button", { name: /No, and do not ask again/u }).click(); + await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs }); + }); +} + +async function verifyEffectiveSettings(): 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="Change Log"]').click(); + const removedAcknowledgements = liveSyncSettings.getByRole("button", { + name: /I got it and updated|OK, I have read everything/u, + }); + if ((await removedAcknowledgements.count()) !== 0) { + 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") + .filter({ hasText: "Deletion Propagation" }) + .locator(".."); + await deletionPanel + .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( + `The obsolete LiveSync trash toggle is still present in the settings UI (${obsoleteToggleCount} found).` + ); + } + }); +} + +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 vault = await createTemporaryVault(); + let session: ObsidianLiveSyncSession | undefined; + try { + session = await startObsidianLiveSyncSession({ + binary, + cliBinary: cli.binary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: { + doctorProcessedVersion: "0.25.27", + isConfigured: true, + liveSync: false, + versionUpFlash: compatibilityReviewMessage, + notifyThresholdOfRemoteStorageSize: 0, + syncOnStart: false, + syncOnSave: false, + syncOnEditorSave: false, + syncOnFileOpen: false, + syncAfterMerge: false, + periodicReplication: false, + handleFilenameCaseSensitive: false, + useAdvancedMode: true, + useEdgeCaseMode: true, + }, + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await verifyCompatibilityReview(); + await verifyConfigDoctorFollowsCompatibilityReview(); + await verifyEffectiveSettings(); + console.log("Compatibility review and settings expose only effective user controls."); + } 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/setup-uri-workflow.ts b/test/e2e-obsidian/scripts/setup-uri-workflow.ts new file mode 100644 index 00000000..a53ca5ac --- /dev/null +++ b/test/e2e-obsidian/scripts/setup-uri-workflow.ts @@ -0,0 +1,859 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { VER } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { Locator, Page } from "playwright"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { + assertCouchDbReachable, + deleteCouchDbDatabase, + loadCouchDbConfig, + makeUniqueDatabaseName, + waitForCouchDbDocs, + type CouchDbConfig, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + assertEqual, + pushLocalChanges, + waitForLocalDatabaseEntry, + type LocalDatabaseEntry, +} from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + assertVerticalActionLayout, + generateSetupURIFromDevice, + resumeCompatibilityReviewIfShown, +} from "../runner/setupUri.ts"; +import { + captureObsidianDialogue, + captureObsidianElement, + captureObsidianPage, + withObsidianPage, +} from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; +process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; + +const execFileAsync = promisify(execFile); +const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_URI_TIMEOUT_MS ?? 30000); +const initialisationTimeoutMs = Number(process.env.E2E_OBSIDIAN_SETUP_INITIALISATION_TIMEOUT_MS ?? 120000); +const hiddenFileCliTimeoutMs = Number(process.env.E2E_OBSIDIAN_HIDDEN_FILE_CLI_TIMEOUT_MS ?? 90000); +const notePath = "E2E/setup-uri/provisioned-workflow.md"; +const noteContent = "# Provisioned Setup URI\n\nThis note travelled through the generated CouchDB Setup URI.\n"; +const returnNotePath = "E2E/setup-uri/from-second-device.md"; +const returnNoteContent = + "# CouchDB from the second device\n\nThis note completed the return journey through CouchDB.\n"; +const snippetPath = ".obsidian/snippets/setup-uri-workflow.css"; +const snippetContent = [ + "body {", + " --setup-uri-workflow-colour: #245a70;", + "}", + "", + ".setup-uri-workflow {", + " color: var(--setup-uri-workflow-colour);", + "}", + "", +].join("\n"); + +type SetupArtifact = { + setupURI: string; + setupPassphrase: string; +}; + +type SetupState = { + configured: boolean; + databaseReady: boolean; + appReady: boolean; + suspended: boolean; + remoteType: string; + activeConfigurationId: string; + remoteConfigurationCount: number; + syncInternalFiles: boolean; + syncInternalFilesBeforeReplication: boolean; +}; + +type RunnerContext = { + binary: string; + cliBinary: string; + couchDb: CouchDbConfig; + dbName: string; + activeSessions: Set; +}; + +function modalByTitle(page: Page, title: string): Locator { + return page.locator(".modal-container").filter({ + has: page.locator(".modal-title").filter({ hasText: title }), + }); +} + +function settingPanelByTitle(page: Page, title: string): Locator { + return page + .locator(".sls-setting") + .locator("h4.sls-setting-panel-title:visible") + .filter({ hasText: title }) + .locator(".."); +} + +async function captureGuideDialogue(port: number, filename: string, title: string): Promise { + return await captureObsidianElement(port, filename, (page) => modalByTitle(page, title).locator(".modal").first()); +} + +async function selectRadioOption(modal: Locator, title: string): Promise { + const radio = modal.locator("label").filter({ hasText: title }).locator('input[type="radio"]').first(); + await radio.check({ timeout: uiTimeoutMs }); +} + +async function selectCheckbox(modal: Locator, title: string): Promise { + const checkbox = modal.locator("label").filter({ hasText: title }).locator('input[type="checkbox"]').first(); + await checkbox.check({ timeout: uiTimeoutMs }); +} + +async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { + const fullPath = join(vaultPath, path); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, content, "utf8"); +} + +async function readVaultFile(vaultPath: string, path: string): Promise { + return await readFile(join(vaultPath, path), "utf8"); +} + +async function waitForPathContent( + vaultPath: string, + path: string, + expected: string, + timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 30000) +): Promise { + const deadline = Date.now() + timeoutMs; + let lastContent = ""; + while (Date.now() < deadline) { + try { + lastContent = await readVaultFile(vaultPath, path); + if (lastContent === expected) return lastContent; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for ${path}. Last content:\n${lastContent}`); +} + +async function runDeno(script: string, permissions: string[], environment: NodeJS.ProcessEnv): Promise { + const { stdout } = await execFileAsync( + "deno", + [ + "run", + "--minimum-dependency-age=0", + "--config=utils/flyio/deno.jsonc", + "--frozen", + "--lock=utils/flyio/deno.lock", + ...permissions, + script, + ], + { + cwd: process.cwd(), + env: environment, + maxBuffer: 4 * 1024 * 1024, + } + ); + return stdout; +} + +async function provisionAndGenerateSetupURI(couchDb: CouchDbConfig, dbName: string): Promise { + const setupPassphrase = randomBytes(24).toString("base64url"); + const environment = { + ...process.env, + hostname: couchDb.uri, + username: couchDb.username, + password: couchDb.password, + database: dbName, + passphrase: randomBytes(24).toString("base64url"), + uri_passphrase: setupPassphrase, + remote_type: "couchdb", + retry_count: "3", + retry_delay_ms: "250", + }; + + await runDeno("utils/couchdb/provision.ts", ["--allow-env", "--allow-net"], environment); + const output = await runDeno("utils/setup/generate_setup_uri.ts", ["--allow-env"], environment); + const setupURI = output.split(/\r?\n/u).find((line) => line.startsWith("obsidian://setuplivesync?settings=")); + if (!setupURI) throw new Error("The public Setup URI generator did not emit a Setup URI."); + return { setupURI, setupPassphrase }; +} + +async function startUnconfiguredSession( + context: RunnerContext, + vault: TemporaryVault +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + }); + context.activeSessions.add(session); + return session; +} + +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 { + for (const session of [...context.activeSessions]) { + await stopTrackedSession(context, session); + } +} + +async function enterSetupURI(port: number, mode: "new" | "existing", artifact: SetupArtifact): Promise { + await withObsidianPage(port, async (page) => { + const invitation = page.locator(".notice").filter({ hasText: "Welcome to Self-hosted LiveSync" }); + await invitation.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await invitation.locator(".sls-onboarding-invitation-action").click({ timeout: uiTimeoutMs }); + + const intro = modalByTitle(page, "Welcome to Self-hosted LiveSync"); + await intro.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if (mode === "new") { + await selectRadioOption(intro, "I am setting this up for the first time"); + await intro + .getByRole("button", { name: "Yes, I want to set up a new synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } else { + await selectRadioOption(intro, "I am adding a device to an existing synchronisation setup"); + await intro + .getByRole("button", { name: "Yes, I want to add this device to my existing synchronisation" }) + .click({ timeout: uiTimeoutMs }); + } + + const method = modalByTitle(page, mode === "new" ? "Connection Method" : "Device Setup Method"); + await method.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await selectRadioOption(method, "Use a Setup URI (Recommended)"); + await method.getByRole("button", { name: "Proceed with Setup URI" }).click({ timeout: uiTimeoutMs }); + + const setup = modalByTitle(page, "Enter Setup URI"); + await setup.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await setup.locator('input[placeholder^="obsidian://setuplivesync"]').fill(artifact.setupURI); + await setup.locator('input[name="password"]').fill(artifact.setupPassphrase); + }); + await captureGuideDialogue( + port, + `guide-quick-setup-${mode === "new" ? "first" : "second"}-setup-uri.png`, + "Enter Setup URI" + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, "Enter Setup URI") + .getByRole("button", { name: "Test Settings and Continue" }) + .click({ timeout: uiTimeoutMs }); + }); +} + +async function captureAndStartInitialisation(port: number, mode: "new" | "existing"): Promise { + const title = + mode === "new" + ? "Setup Complete: Preparing to Initialise Server" + : "Setup Complete: Preparing to Fetch Synchronisation Data"; + const button = mode === "new" ? "Restart and Initialise Server" : "Restart and Fetch Data"; + const screenshot = await captureObsidianDialogue( + port, + `setup-uri-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, + async (page) => { + await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue( + port, + `guide-quick-setup-${mode === "new" ? "first-initialise" : "second-fetch"}.png`, + title + ); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title).getByRole("button", { name: button }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function confirmRebuild(port: number): Promise { + const title = "Final Confirmation: Overwrite Server Data with This Device's Files"; + const screenshot = await captureObsidianDialogue(port, "setup-uri-first-rebuild-confirmation.png", async (page) => { + await modalByTitle(page, title).waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + await captureGuideDialogue(port, "guide-quick-setup-first-rebuild-confirmation.png", title); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, title); + await selectCheckbox( + modal, + "I understand that all changes made on other smartphones or computers possibly could be lost." + ); + await selectCheckbox( + modal, + "I understand that other devices will no longer be able to synchronise, and will need to be reset the synchronisation information." + ); + await selectCheckbox(modal, "I understand that this action is irreversible once performed."); + await selectRadioOption(modal, "I understand the risks and will proceed without a backup."); + await modal.getByRole("button", { name: "I Understand, Overwrite Server" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function skipMissingRemoteConfiguration(port: number): Promise { + const title = "Fetch Remote Configuration Failed"; + const screenshot = await captureObsidianDialogue( + port, + "setup-uri-first-missing-remote-configuration.png", + async (page) => { + const modal = modalByTitle(page, title); + await modal.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await modal + .getByText("If you are new to the Self-hosted LiveSync, this might be expected.", { + exact: false, + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-missing-remote-configuration.png", title); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title) + .getByRole("button", { name: "Skip and proceed" }) + .click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function acknowledgeDisabledOptionalFeatures(port: number): Promise { + const title = "All optional features are disabled"; + const screenshot = await captureObsidianDialogue( + port, + "setup-uri-first-optional-features-disabled.png", + async (page) => { + const modal = modalByTitle(page, title); + await modal.waitFor({ state: "visible", timeout: initialisationTimeoutMs }); + await modal + .getByText("Please enable them from the settings screen after setup is complete.", { + exact: false, + }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-optional-features-disabled.png", title); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, title).getByRole("button", { name: "OK" }).click({ timeout: uiTimeoutMs }); + }); + return screenshot; +} + +async function confirmFastFetch(port: number): Promise { + const firstTitle = "Data retrieval scheduled"; + await assertVerticalActionLayout(port, firstTitle); + const firstScreenshot = await captureObsidianDialogue( + port, + "setup-uri-second-retrieval-method.png", + async (page) => { + await modalByTitle(page, firstTitle).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-retrieval-method.png", firstTitle); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, firstTitle) + .getByRole("button", { name: "Overwrite all with remote files" }) + .click({ timeout: uiTimeoutMs }); + }); + + const secondTitle = "How to handle extra existing local files?"; + await assertVerticalActionLayout(port, secondTitle); + const secondScreenshot = await captureObsidianDialogue( + port, + "setup-uri-second-local-file-policy.png", + async (page) => { + await modalByTitle(page, secondTitle).waitFor({ state: "visible", timeout: uiTimeoutMs }); + } + ); + await captureGuideDialogue(port, "guide-quick-setup-local-file-policy.png", secondTitle); + await withObsidianPage(port, async (page) => { + await modalByTitle(page, secondTitle) + .getByRole("button", { name: "Keep local files even if not on remote" }) + .click({ timeout: uiTimeoutMs }); + }); + return [firstScreenshot, secondScreenshot]; +} + +function isConfiguredSetupReady(state: SetupState): boolean { + return ( + state.configured && + state.databaseReady && + state.appReady && + !state.suspended && + state.activeConfigurationId !== "" && + state.remoteConfigurationCount === 1 + ); +} + +async function finishInitialisation( + port: number, + filename: string, + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise<{ state: SetupState; screenshot?: string }> { + const message = "Do you want to resume file and database processing, and restart obsidian now?"; + const deadline = Date.now() + initialisationTimeoutMs; + let lastState: SetupState | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + const resumeVisible = await withObsidianPage(port, async (page) => { + return await modalByTitle(page, "Confirmation").filter({ hasText: message }).isVisible(); + }).catch(() => false); + if (resumeVisible) { + const screenshot = await captureObsidianDialogue(port, filename, async (page) => { + await modalByTitle(page, "Confirmation") + .filter({ hasText: message }) + .waitFor({ state: "visible", timeout: uiTimeoutMs }); + }); + await withObsidianPage(port, async (page) => { + const modal = modalByTitle(page, "Confirmation").filter({ hasText: message }); + await modal.getByText(message, { exact: true }).click({ timeout: uiTimeoutMs }); + await modal.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs }); + }); + return { + state: await waitForConfiguredSetup(cliBinary, environment, initialisationTimeoutMs), + screenshot, + }; + } + try { + lastState = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(lastState)) return { state: lastState }; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for Setup URI initialisation to finish: ${JSON.stringify(lastState)}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }` + ); +} + +async function readSetupState(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const settings=core.services.setting.currentSettings();", + "return JSON.stringify({", + "configured:settings.isConfigured===true,", + "databaseReady:core.services.database.isDatabaseReady(),", + "appReady:core.services.appLifecycle.isReady(),", + "suspended:core.services.appLifecycle.isSuspended(),", + "remoteType:settings.remoteType,", + "activeConfigurationId:settings.activeConfigurationId||'',", + "remoteConfigurationCount:Object.keys(settings.remoteConfigurations||{}).length,", + "syncInternalFiles:settings.syncInternalFiles===true,", + "syncInternalFilesBeforeReplication:settings.syncInternalFilesBeforeReplication===true,", + "});", + "})()", + ].join(""), + environment + ); +} + +async function waitForConfiguredSetup( + cliBinary: string, + environment: NodeJS.ProcessEnv, + timeoutMs = initialisationTimeoutMs +): Promise { + const deadline = Date.now() + timeoutMs; + let lastState: SetupState | undefined; + let lastError: unknown; + while (Date.now() < deadline) { + try { + lastState = await readSetupState(cliBinary, environment); + if (isConfiguredSetupReady(lastState)) { + return lastState; + } + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error( + `Timed out waiting for configured Setup URI state: ${JSON.stringify(lastState)}${ + lastError instanceof Error ? `; last error: ${lastError.message}` : "" + }` + ); +} + +async function enableHiddenFileSync(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyPartial({", + "syncInternalFiles:true,", + "syncInternalFilesBeforeReplication:true,", + "},true);", + "await core.services.control.applySettings();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + const state = await waitForConfiguredSetup(cliBinary, environment); + if (!state.syncInternalFiles || !state.syncInternalFilesBeforeReplication) { + throw new Error(`Hidden File Sync was not enabled after setup: ${JSON.stringify(state)}`); + } + return state; +} + +async function captureHiddenFileGuideSettings( + port: number, + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyPartial({", + "useAdvancedMode:true,", + "syncInternalFilesTargetPatterns:'^\\\\.obsidian(?:$|/snippets(?:/|$))',", + "},true);", + "await core.services.control.applySettings();", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment + ); + + await withObsidianPage(port, async (page) => { + await page.evaluate(() => { + const obsidian = globalThis as typeof globalThis & { + app?: { + setting?: { + open(): void; + openTabById(tabId: string): void; + }; + }; + }; + const setting = obsidian.app?.setting; + if (!setting) 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="Setup"]').click({ timeout: uiTimeoutMs }); + }); + + const screenshots = [ + await captureObsidianElement(port, "guide-hidden-file-advanced-features.png", (page) => + settingPanelByTitle(page, "Enable extra and advanced features") + ), + ]; + + await withObsidianPage(port, async (page) => { + await page + .locator(".sls-setting") + .locator('.sls-setting-menu-btn[title="Selector"]') + .click({ timeout: uiTimeoutMs }); + }); + screenshots.push( + await captureObsidianElement(port, "guide-hidden-file-selector.png", (page) => + settingPanelByTitle(page, "Hidden Files") + ) + ); + + await withObsidianPage(port, async (page) => { + await page + .locator(".sls-setting") + .locator('.sls-setting-menu-btn[title="Sync Settings"]') + .click({ timeout: uiTimeoutMs }); + }); + screenshots.push( + await captureObsidianElement(port, "guide-hidden-file-enable.png", (page) => + settingPanelByTitle(page, "Hidden Files") + ) + ); + + await withObsidianPage(port, async (page) => { + await page.keyboard.press("Escape"); + }); + return screenshots; +} + +async function writeNoteViaObsidian( + cliBinary: string, + environment: 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(""), + environment + ); +} + +async function scanHiddenStorage(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.scanAllStorageChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment, + hiddenFileCliTimeoutMs + ); +} + +async function scanHiddenDatabase(cliBinary: string, environment: NodeJS.ProcessEnv): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const addOn=core.getAddOn('HiddenFileSync');", + "await addOn.scanAllDatabaseChanges(true);", + "return JSON.stringify({ok:true});", + "})()", + ].join(""), + environment, + hiddenFileCliTimeoutMs + ); +} + +async function waitForRemoteEntry(context: RunnerContext, entry: LocalDatabaseEntry): Promise { + await waitForCouchDbDocs(context.couchDb, context.dbName, (docs) => { + const ids = new Set(docs.map((doc) => doc._id)); + return ids.has(entry.id) && entry.children.every((childId) => ids.has(childId)); + }); +} + +async function uploadWorkflowFiles( + context: RunnerContext, + session: ObsidianLiveSyncSession, + vault: TemporaryVault +): Promise { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, notePath, noteContent); + await writeVaultFile(vault.path, snippetPath, snippetContent); + await scanHiddenStorage(context.cliBinary, session.cliEnv); + const noteEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, notePath); + const snippetEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, snippetPath, { + hidden: true, + }); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, noteEntry); + await waitForRemoteEntry(context, snippetEntry); +} + +async function captureSynchronisedNote(port: number): Promise { + await withObsidianPage(port, async (page) => { + await page.evaluate((path) => { + const obsidian = globalThis as typeof globalThis & { + app?: { + workspace?: { openLinkText(path: string, sourcePath: string, newLeaf: boolean): Promise }; + }; + }; + return obsidian.app?.workspace?.openLinkText(path, "", false); + }, notePath); + }); + await captureObsidianPage(port, "setup-uri-synchronised-note.png", async (page) => { + await page.getByText("Provisioned Setup URI", { exact: false }).first().waitFor({ + state: "visible", + timeout: uiTimeoutMs, + }); + }); + return await captureObsidianElement(port, "guide-quick-setup-synchronised-note.png", (page) => + page.locator(".workspace-leaf.mod-active").first() + ); +} + +async function captureFailure(session: ObsidianLiveSyncSession): Promise { + await captureObsidianPage( + session.remoteDebuggingPort, + "setup-uri-workflow.failure.png", + async () => undefined + ).catch(() => undefined); +} + +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 couchDb = await loadCouchDbConfig(); + const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "setup-uri-workflow"); + const vaultA = await createTemporaryVault(); + const vaultB = await createTemporaryVault(); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName, + activeSessions: new Set(), + }; + const screenshots: string[] = []; + let secondDeviceArtifact: SetupArtifact | undefined; + + try { + await assertCouchDbReachable(couchDb); + const artifact = await provisionAndGenerateSetupURI(couchDb, dbName); + const provisionedDocs = await waitForCouchDbDocs(couchDb, dbName, (docs) => + docs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER) + ); + if (!provisionedDocs.some((doc) => doc._id === "obsydian_livesync_version" && doc.version === VER)) { + throw new Error("The public provisioning tool did not initialise the Commonlib database version."); + } + + console.log(`Using Obsidian executable: ${binary}`); + console.log(`Temporary vault A: ${vaultA.path}`); + console.log(`Temporary vault B: ${vaultB.path}`); + console.log(`Temporary provisioned CouchDB database: ${dbName}`); + + let session = await startUnconfiguredSession(context, vaultA); + try { + await enterSetupURI(session.remoteDebuggingPort, "new", artifact); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "new")); + screenshots.push(await confirmRebuild(session.remoteDebuggingPort)); + screenshots.push(await skipMissingRemoteConfiguration(session.remoteDebuggingPort)); + screenshots.push(await acknowledgeDisabledOptionalFeatures(session.remoteDebuggingPort)); + const firstCompletion = await finishInitialisation( + session.remoteDebuggingPort, + "setup-uri-first-initialisation-complete.png", + context.cliBinary, + session.cliEnv + ); + if (firstCompletion.screenshot) screenshots.push(firstCompletion.screenshot); + const firstState = firstCompletion.state; + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + assertEqual(firstState.remoteType, "", "The first device did not activate the CouchDB remote profile."); + assertEqual( + firstState.syncInternalFiles, + false, + "Rebuild did not retain the documented optional-feature safety boundary." + ); + screenshots.push( + ...(await captureHiddenFileGuideSettings( + session.remoteDebuggingPort, + context.cliBinary, + session.cliEnv + )) + ); + await enableHiddenFileSync(context.cliBinary, session.cliEnv); + await uploadWorkflowFiles(context, session, vaultA); + const generated = await generateSetupURIFromDevice( + session.remoteDebuggingPort, + randomBytes(24).toString("base64url"), + { scenario: "setup-uri-workflow", guide: "quick-setup" } + ); + if (generated.artifact.setupURI === artifact.setupURI) { + throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); + } + secondDeviceArtifact = generated.artifact; + screenshots.push(...generated.screenshots); + } catch (error) { + await captureFailure(session); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultB); + try { + if (!secondDeviceArtifact) + throw new Error("The first device did not generate the second-device Setup URI."); + await enterSetupURI(session.remoteDebuggingPort, "existing", secondDeviceArtifact); + screenshots.push(await captureAndStartInitialisation(session.remoteDebuggingPort, "existing")); + screenshots.push(...(await confirmFastFetch(session.remoteDebuggingPort))); + const secondCompletion = await finishInitialisation( + session.remoteDebuggingPort, + "setup-uri-second-initialisation-complete.png", + context.cliBinary, + session.cliEnv + ); + if (secondCompletion.screenshot) screenshots.push(secondCompletion.screenshot); + const secondState = secondCompletion.state; + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + assertEqual(secondState.remoteType, "", "The second device did not activate the CouchDB remote profile."); + await enableHiddenFileSync(context.cliBinary, session.cliEnv); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await scanHiddenDatabase(context.cliBinary, session.cliEnv); + const receivedNote = await waitForPathContent(vaultB.path, notePath, noteContent); + const receivedSnippet = await waitForPathContent(vaultB.path, snippetPath, snippetContent); + assertEqual(receivedNote, noteContent, "The ordinary note did not reach the second Setup URI device."); + assertEqual( + receivedSnippet, + snippetContent, + "The hidden snippet did not reach the second Setup URI device." + ); + screenshots.push(await captureSynchronisedNote(session.remoteDebuggingPort)); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, returnNotePath, returnNoteContent); + const returnEntry = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, returnNotePath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await waitForRemoteEntry(context, returnEntry); + } catch (error) { + await captureFailure(session); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + session = await startUnconfiguredSession(context, vaultA); + try { + await resumeCompatibilityReviewIfShown(session.remoteDebuggingPort); + await pushLocalChanges(context.cliBinary, session.cliEnv); + const receivedReturnNote = await waitForPathContent(vaultA.path, returnNotePath, returnNoteContent); + assertEqual( + receivedReturnNote, + returnNoteContent, + "The second device's ordinary note did not return to the first Setup URI device." + ); + } catch (error) { + await captureFailure(session); + throw error; + } finally { + await stopTrackedSession(context, session); + } + + console.log( + `The public provisioning and first-device-generated Setup URI workflow configured two fresh devices, completed an ordinary-note round-trip, and synchronised a hidden snippet. Screenshots: ${screenshots.join(", ")}` + ); + } finally { + await stopTrackedSessions(context).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + await vaultA.dispose(); + await vaultB.dispose(); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); 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/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); diff --git a/test/e2e-obsidian/scripts/two-vault-sync.ts b/test/e2e-obsidian/scripts/two-vault-sync.ts index f1c23b8c..ca69c038 100644 --- a/test/e2e-obsidian/scripts/two-vault-sync.ts +++ b/test/e2e-obsidian/scripts/two-vault-sync.ts @@ -11,11 +11,16 @@ import { type CouchDbConfig, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts"; import { assertEqual, + assertE2eCompatibilityMarker, + assertE2eCompatibilityReviewPending, configureCouchDb, + createE2eCouchDbPluginData, prepareRemote, pushLocalChanges, + resumeCompatibilityReview, waitForLiveSyncCoreReady, waitForLocalDatabaseEntry, type LocalDatabaseEntry, @@ -31,7 +36,15 @@ const updatePath = "E2E/two-vault/update.md"; const deletePath = "E2E/two-vault/delete.md"; const renameFromPath = "E2E/two-vault/rename-source.md"; const renameToPath = "E2E/two-vault/renamed/rename-target.md"; +const caseRenameFromPath = "E2E/two-vault/Case-Rename.md"; +const caseRenameToPath = "E2E/two-vault/case-rename.md"; const conflictPath = "E2E/two-vault/conflict.md"; +const conflictEditPath = "E2E/two-vault/conflict-operations/edit.md"; +const conflictDeletePath = "E2E/two-vault/conflict-operations/delete.md"; +const conflictCaseFromPath = "E2E/two-vault/conflict-operations/Case-Rename.md"; +const conflictCaseToPath = "E2E/two-vault/conflict-operations/case-rename.md"; +const conflictRenameFromPath = "E2E/two-vault/conflict-operations/rename-source.md"; +const conflictRenameToPath = "E2E/two-vault/conflict-operations/renamed/rename-target.md"; const targetMismatchPath = "E2E/two-vault/target-mismatch.md"; const encryptedPath = "E2E/two-vault/encrypted.md"; @@ -40,6 +53,19 @@ type RunnerContext = { cliBinary: string; couchDb: CouchDbConfig; dbName: string; + reviewedVaults: Set; + activeSessions: Set; +}; + +type FileConflictState = { + currentRev: string; + branches: { + rev: string; + parentRev?: string; + content: string; + deleted: boolean; + path: string; + }[]; }; async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { @@ -68,6 +94,18 @@ async function pathExists(vaultPath: string, path: string): Promise { } } +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 { + for (const session of [...context.activeSessions]) { + await stopTrackedSession(context, session); + } +} + async function waitForPathContent( vaultPath: string, path: string, @@ -161,27 +199,44 @@ async function startConfiguredSession( vault: TemporaryVault, overrides: Record = {} ): Promise { + const couchDbSettings = { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }; + const reviewAlreadyCompleted = context.reviewedVaults.has(vault.path); const session = await startObsidianLiveSyncSession({ binary: context.binary, cliBinary: context.cliBinary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + pluginData: createE2eCouchDbPluginData(couchDbSettings, overrides), }); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await configureCouchDb( - context.cliBinary, - session.cliEnv, - { - uri: context.couchDb.uri, - username: context.couchDb.username, - password: context.couchDb.password, - dbName: context.dbName, - }, - overrides - ); - await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); - await prepareRemote(context.cliBinary, session.cliEnv); - return session; + context.activeSessions.add(session); + try { + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + if (!reviewAlreadyCompleted) { + await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv); + await resumeCompatibilityReview(session.remoteDebuggingPort); + } + await assertE2eCompatibilityMarker(context.cliBinary, session.cliEnv); + if (!reviewAlreadyCompleted) context.reviewedVaults.add(vault.path); + await configureCouchDb(context.cliBinary, session.cliEnv, couchDbSettings, overrides); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); + return session; + } catch (error) { + try { + await stopTrackedSession(context, session); + } catch (stopError) { + throw Object.assign(new Error("Could not stop Obsidian after session setup failed."), { + cause: error, + stopError, + }); + } + throw error; + } } async function uploadNote( @@ -241,25 +296,116 @@ async function storeFileRevision( return result.rev; } -async function createMarkdownConflict( - context: RunnerContext, - session: ObsidianLiveSyncSession, - vault: TemporaryVault, - path: string, - base: string, - left: string, - right: string -): Promise { - const baseRev = await storeFileRevision(context.cliBinary, session.cliEnv, path, base); - await pushLocalChanges(context.cliBinary, session.cliEnv); - await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); - await storeFileRevision(context.cliBinary, session.cliEnv, path, left, baseRev); - await storeFileRevision(context.cliBinary, session.cliEnv, path, right, baseRev); - await writeVaultFile(vault.path, path, right); +async function readFileConflictState( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const meta=await core.localDatabase.getDBEntryMeta(path,{conflicts:true},true);", + "if(!meta) throw new Error(`Could not find conflict metadata: ${path}`);", + "const revisions=[meta._rev,...(meta._conflicts??[])];", + "const branches=[];", + "for(const rev of revisions){", + " const branchMeta=await core.localDatabase.getDBEntryMeta(path,{rev,revs:true},true);", + " const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);", + " if(!branchMeta||!entry) throw new Error(`Could not read conflict revision: ${path} ${rev}`);", + " const content=Array.isArray(entry.data)?entry.data.join(''):entry.data;", + " if(typeof content!=='string') throw new Error(`Conflict revision was not text: ${path} ${rev}`);", + " const ids=branchMeta._revisions?.ids??[];", + " const parentRev=ids[1]?`${branchMeta._revisions.start-1}-${ids[1]}`:undefined;", + " branches.push({rev,parentRev,content,deleted:Boolean(branchMeta.deleted||branchMeta._deleted),path:branchMeta.path});", + "}", + "return JSON.stringify({currentRev:meta._rev,branches});", + "})()", + ].join(""), + env + ); } -async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { - await evalObsidianJson( +async function waitForFileConflict( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + let state = await readFileConflictState(cliBinary, env, path); + while (state.branches.length < 2 && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + state = await readFileConflictState(cliBinary, env, path); + } + if (state.branches.length < 2) { + throw new Error(`Timed out waiting for a file conflict: ${path}`); + } + return state; +} + +async function waitForConflictBranch( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + predicate: (branch: FileConflictState["branches"][number]) => boolean +): Promise { + const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ?? 15000); + let state = await readFileConflictState(cliBinary, env, path); + while (Date.now() < deadline) { + const branch = state.branches.find(predicate); + if (branch) return branch; + await new Promise((resolve) => setTimeout(resolve, 250)); + state = await readFileConflictState(cliBinary, env, path); + } + throw new Error(`Timed out waiting for the expected conflict branch: ${path}; ${JSON.stringify(state)}`); +} + +async function readFileReflectionProvenance( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string +): Promise<{ revision: string; observedStorageMtime?: number } | null> { + return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + "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 + ); +} + +async function readPathIdentity( + cliBinary: string, + env: NodeJS.ProcessEnv, + paths: readonly string[] +): Promise<{ caseSensitive: boolean; ids: Record }> { + return await evalObsidianJson<{ caseSensitive: boolean; ids: Record }>( + cliBinary, + [ + "(async()=>{", + `const paths=${JSON.stringify(paths)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const ids={};", + "for(const path of paths) ids[path]=await core.services.path.path2id(path);", + "return JSON.stringify({", + " caseSensitive:Boolean(core.services.setting.currentSettings().handleFilenameCaseSensitive),", + " ids,", + "});", + "})()", + ].join(""), + env + ); +} + +async function calculateMarkdownAutoMerge(cliBinary: string, env: NodeJS.ProcessEnv, path: string): Promise { + const result = await evalObsidianJson<{ content: string }>( cliBinary, [ "(async()=>{", @@ -269,11 +415,29 @@ async function autoMergeMarkdownConflict(cliBinary: string, env: NodeJS.ProcessE "if(!('result' in result)){", " throw new Error(`Markdown conflict was not auto-mergeable: ${path}; ${JSON.stringify(result)}`);", "}", - "if(!(await core.databaseFileAccess.storeContent(path,result.result))){", - " throw new Error(`Could not store merged Markdown content: ${path}`);", - "}", - "if(!(await core.fileHandler.deleteRevisionFromDB(path,result.conflictedRev))){", - " throw new Error(`Could not delete conflicted revision: ${path}`);", + "return JSON.stringify({content:result.result});", + "})()", + ].join(""), + env + ); + return result.content; +} + +async function deleteRevisionAndReflect( + cliBinary: string, + env: NodeJS.ProcessEnv, + path: string, + revision: string +): Promise { + await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(path)};`, + `const revision=${JSON.stringify(revision)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "if(!(await core.fileHandler.deleteRevisionFromDB(path,revision))){", + " throw new Error(`Could not delete conflicted revision: ${path} ${revision}`);", "}", "if(!(await core.fileHandler.dbToStorage(path,path,true))){", " throw new Error(`Could not reflect merged Markdown content: ${path}`);", @@ -294,12 +458,12 @@ async function runCreateUpdateDelete( let session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, createPath, createdContent); await uploadNote(context, session, createPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const createdOnB = await waitForPathContent(vaultB.path, createPath, (content) => content === createdContent); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(createdOnB, createdContent, "Created note did not round-trip to the second vault."); const initialUpdateContent = "# Update target\n\nInitial content.\n"; @@ -309,34 +473,34 @@ async function runCreateUpdateDelete( await uploadNote(context, session, updatePath); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, updatePath, updatedContent); await uploadNote(context, session, updatePath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const updatedOnB = await waitForPathContent(vaultB.path, updatePath, (content) => content === updatedContent); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(updatedOnB, updatedContent, "Updated note content did not round-trip to the second vault."); const deleteContent = "# Delete target\n\nThis note should be removed from B.\n"; session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath, deleteContent); await uploadNote(context, session, deletePath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); await waitForPathContent(vaultB.path, deletePath, (content) => content === deleteContent); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultA); await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, deletePath); await pushLocalChanges(context.cliBinary, session.cliEnv); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); await waitForPathDeleted(vaultB.path, deletePath); - await session.app.stop(); + await stopTrackedSession(context, session); console.log("Two-vault note creation, update, and deletion round-tripped."); } @@ -350,18 +514,51 @@ async function runRename(context: RunnerContext, vaultA: TemporaryVault, vaultB: await renameNoteViaObsidian(context.cliBinary, session.cliEnv, renameFromPath, renameToPath); await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, renameToPath); await pushLocalChanges(context.cliBinary, session.cliEnv); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const renamedOnB = await waitForPathContent(vaultB.path, renameToPath, (content) => content === renamedContent); await waitForPathDeleted(vaultB.path, renameFromPath); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(renamedOnB, renamedContent, "Renamed note content did not round-trip to the second vault."); console.log("Two-vault note rename round-tripped."); } +async function runCaseOnlyRename( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const fileContent = "# Case-only rename\n\nThe document ID should remain live.\n"; + + let session = await startConfiguredSession(context, vaultA); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, fileContent); + await uploadNote(context, session, caseRenameFromPath); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + await waitForPathContent(vaultB.path, caseRenameFromPath, (content) => content === fileContent); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA); + await renameNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, caseRenameToPath); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, caseRenameToPath); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB); + await syncAndApply(context, session); + const renamedOnB = await waitForPathContent(vaultB.path, caseRenameToPath, (content) => content === fileContent); + await waitForExactCaseOnlyRename(vaultB.path, caseRenameFromPath, caseRenameToPath); + await stopTrackedSession(context, session); + + assertEqual(renamedOnB, fileContent, "Case-only note rename did not round-trip to the second vault."); + console.log("Two-vault case-only note rename round-tripped without a tombstone."); +} + async function runEncryptedRoundTrip( context: RunnerContext, vaultA: TemporaryVault, @@ -378,12 +575,12 @@ async function runEncryptedRoundTrip( let session = await startConfiguredSession(context, vaultA, encryptedOverrides); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, encryptedPath, encryptedContent); await uploadNote(context, session, encryptedPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB, encryptedOverrides); await syncAndApply(context, session); const received = await waitForPathContent(vaultB.path, encryptedPath, (content) => content === encryptedContent); - await session.app.stop(); + await stopTrackedSession(context, session); assertEqual(received, encryptedContent, "Encrypted note did not round-trip to the second vault."); console.log("Two-vault encrypted note synchronisation round-tripped."); @@ -397,31 +594,290 @@ async function runMarkdownAutoMerge( const base = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nBottom anchor\n"; const left = "# Conflict\n\nTop anchor\n\nLeft line\n\nMiddle anchor\n\nBottom anchor\n"; const right = "# Conflict\n\nTop anchor\n\nMiddle anchor\n\nRight tail\n\nBottom anchor\n"; + const conflictOverrides = { + disableMarkdownAutoMerge: true, + checkConflictOnlyOnOpen: true, + showMergeDialogOnlyOnActive: true, + }; - let session = await startConfiguredSession(context, vaultB); - await createMarkdownConflict(context, session, vaultB, conflictPath, base, left, right); - await autoMergeMarkdownConflict(context.cliBinary, session.cliEnv, conflictPath); + let session = await startConfiguredSession(context, vaultA, conflictOverrides); + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictPath, base); + await uploadNote(context, session, conflictPath); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + await syncAndApply(context, session); + const baseOnB = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath); + await waitForPathContent(vaultB.path, conflictPath, (content) => content === base); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); + const baseOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictPath); + await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, left, baseOnA.rev); + await writeVaultFile(vaultA.path, conflictPath, left); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, right, baseOnB.rev); + await writeVaultFile(vaultB.path, conflictPath, right); + await pushLocalChanges(context.cliBinary, session.cliEnv); + const conflict = await waitForFileConflict(context.cliBinary, session.cliEnv, conflictPath); + const leftBranch = conflict.branches.find((branch) => branch.content === left); + const rightBranch = conflict.branches.find((branch) => branch.content === right); + if (!leftBranch || !rightBranch) { + throw new Error(`The two Vault edits did not form the expected conflict: ${JSON.stringify(conflict)}`); + } + + const merged = await calculateMarkdownAutoMerge(context.cliBinary, session.cliEnv, conflictPath); + if (!merged.includes("Left line") || !merged.includes("Right tail")) { + throw new Error(`Markdown auto-merge discarded a non-overlapping edit: ${JSON.stringify({ merged })}`); + } + const mergedRev = await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, merged, rightBranch.rev); + await deleteRevisionAndReflect(context.cliBinary, session.cliEnv, conflictPath, leftBranch.rev); await pushLocalChanges(context.cliBinary, session.cliEnv); const mergedOnB = await waitForPathContent( vaultB.path, conflictPath, - (content) => content.includes("Left line") && content.includes("Right tail"), + (content) => content === merged, Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000) ); - await session.app.stop(); - session = await startConfiguredSession(context, vaultA); + const afterResolution = `${merged.trimEnd()}\n\nPost-resolution edit on B.\n`; + await storeFileRevision(context.cliBinary, session.cliEnv, conflictPath, afterResolution, mergedRev); + await writeVaultFile(vaultB.path, conflictPath, afterResolution); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); await syncAndApply(context, session); - const mergedOnA = await waitForPathContent( + const resolvedOnA = await waitForPathContent( vaultA.path, conflictPath, - (content) => content.includes("Left line") && content.includes("Right tail"), + (content) => content === afterResolution, Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000) ); - await session.app.stop(); + const resolvedState = await readFileConflictState(context.cliBinary, session.cliEnv, conflictPath); + await stopTrackedSession(context, session); - assertEqual(mergedOnA, mergedOnB, "Merged Markdown content was not consistent across both vaults."); - console.log("Markdown conflict was automatically merged and propagated by the next synchronisation."); + assertEqual(mergedOnB, merged, "The resolving Vault did not reflect the merged Markdown content."); + assertEqual( + resolvedOnA, + afterResolution, + "The resolved Markdown content did not replace the known losing revision." + ); + assertEqual( + resolvedState.branches.length, + 1, + "The receiving Vault recreated a conflict from the known losing revision." + ); + console.log( + "A two-Vault Markdown conflict was merged, edited again, and propagated to the Vault holding the resolved losing revision." + ); +} + +async function runConflictTimeStorageOperations( + context: RunnerContext, + vaultA: TemporaryVault, + vaultB: TemporaryVault +): Promise { + const paths = [conflictEditPath, conflictDeletePath, conflictCaseFromPath, conflictRenameFromPath] as const; + const conflictOverrides = { + disableMarkdownAutoMerge: true, + checkConflictOnlyOnOpen: true, + showMergeDialogOnlyOnActive: true, + handleFilenameCaseSensitive: false, + }; + const baseContent = Object.fromEntries(paths.map((path) => [path, `# Conflict operation\n\nBase for ${path}.\n`])) as Record< + (typeof paths)[number], + string + >; + const leftContent = Object.fromEntries( + paths.map((path) => [path, `${baseContent[path]}\nEdit made on Vault A.\n`]) + ) as Record<(typeof paths)[number], string>; + const rightContent = Object.fromEntries( + paths.map((path) => [path, `${baseContent[path]}\nDisplayed edit made on Vault B.\n`]) + ) as Record<(typeof paths)[number], string>; + + let session = await startConfiguredSession(context, vaultA, conflictOverrides); + for (const path of paths) { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, baseContent[path]); + await uploadNote(context, session, path); + } + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + await syncAndApply(context, session); + for (const path of paths) { + await waitForPathContent(vaultB.path, path, (content) => content === baseContent[path]); + } + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); + for (const path of paths) { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, leftContent[path]); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); + } + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, conflictOverrides); + for (const path of paths) { + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, path, rightContent[path]); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, path); + } + await pushLocalChanges(context.cliBinary, session.cliEnv); + + const displayedRevisions = new Map(); + const initialBranchRevisions = new Map>(); + for (const path of paths) { + const state = await waitForFileConflict(context.cliBinary, session.cliEnv, path); + const displayedBranch = state.branches.find((branch) => branch.content === rightContent[path] && !branch.deleted); + if (!displayedBranch) { + throw new Error(`Could not identify the branch displayed by Vault B: ${path}; ${JSON.stringify(state)}`); + } + const provenance = await readFileReflectionProvenance(context.cliBinary, session.cliEnv, path); + assertEqual( + provenance?.revision, + displayedBranch.rev, + `Vault B did not retain the exact displayed revision for ${path}.` + ); + displayedRevisions.set(path, displayedBranch.rev); + initialBranchRevisions.set(path, new Set(state.branches.map((branch) => branch.rev))); + } + + const editedAgain = `${rightContent[conflictEditPath]}\nSecond edit while the conflict is active.\n`; + await writeNoteViaObsidian(context.cliBinary, session.cliEnv, conflictEditPath, editedAgain); + const editedBranch = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictEditPath, + (branch) => branch.content === editedAgain + ); + assertEqual( + editedBranch.parentRev, + displayedRevisions.get(conflictEditPath), + "A conflict-time edit did not extend the displayed revision." + ); + + await deleteNoteViaObsidian(context.cliBinary, session.cliEnv, conflictDeletePath); + const deletedBranch = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictDeletePath, + (branch) => branch.deleted + ); + assertEqual( + deletedBranch.parentRev, + displayedRevisions.get(conflictDeletePath), + "A conflict-time deletion did not extend the displayed revision." + ); + + await renameNoteViaObsidian( + context.cliBinary, + session.cliEnv, + conflictCaseFromPath, + conflictCaseToPath + ); + const caseRenamedBranch = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictCaseToPath, + (branch) => + !initialBranchRevisions.get(conflictCaseFromPath)?.has(branch.rev) && + branch.path === conflictCaseToPath && + branch.content === rightContent[conflictCaseFromPath] && + !branch.deleted + ); + const expectedCaseParent = displayedRevisions.get(conflictCaseFromPath); + if (caseRenamedBranch.parentRev !== expectedCaseParent) { + const [state, oldProvenance, newProvenance, identity] = await Promise.all([ + readFileConflictState(context.cliBinary, session.cliEnv, conflictCaseToPath), + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath), + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath), + readPathIdentity(context.cliBinary, session.cliEnv, [conflictCaseFromPath, conflictCaseToPath]), + ]); + throw new Error( + `A conflict-time case-only rename did not extend the displayed revision: ${JSON.stringify({ + expectedCaseParent, + caseRenamedBranch, + state, + oldProvenance, + newProvenance, + identity, + })}` + ); + } + const [oldCaseProvenance, newCaseProvenance] = await Promise.all([ + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseFromPath), + readFileReflectionProvenance(context.cliBinary, session.cliEnv, conflictCaseToPath), + ]); + assertEqual(oldCaseProvenance, null, "A conflict-time case-only rename retained the old provenance path."); + assertEqual( + newCaseProvenance?.revision, + caseRenamedBranch.rev, + "A conflict-time case-only rename did not record the new displayed revision." + ); + + await renameNoteViaObsidian( + context.cliBinary, + session.cliEnv, + conflictRenameFromPath, + conflictRenameToPath + ); + const renamedTarget = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath); + const renamedSourceDeletion = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + conflictRenameFromPath, + (branch) => branch.deleted + ); + assertEqual( + renamedSourceDeletion.parentRev, + displayedRevisions.get(conflictRenameFromPath), + "A conflict-time cross-path rename did not soft-delete the displayed source revision." + ); + await pushLocalChanges(context.cliBinary, session.cliEnv); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultA, conflictOverrides); + await syncAndApply(context, session); + const replicatedBranches = [ + [conflictEditPath, editedBranch], + [conflictDeletePath, deletedBranch], + [conflictCaseToPath, caseRenamedBranch], + [conflictRenameFromPath, renamedSourceDeletion], + ] as const; + for (const [path, expectedBranch] of replicatedBranches) { + const replicated = await waitForConflictBranch( + context.cliBinary, + session.cliEnv, + path, + (branch) => branch.rev === expectedBranch.rev + ); + assertEqual( + replicated.parentRev, + expectedBranch.parentRev, + `The exact conflict-operation revision tree did not replicate for ${path}.` + ); + } + await waitForPathContent( + vaultA.path, + conflictRenameToPath, + (content) => content === rightContent[conflictRenameFromPath] + ); + const targetOnA = await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, conflictRenameToPath); + assertEqual(targetOnA.id, renamedTarget.id, "The cross-path rename target did not replicate as the same document."); + assertEqual( + await readVaultFile(vaultA.path, conflictDeletePath), + leftContent[conflictDeletePath], + "A logical deletion from one conflict branch removed the other Vault's live branch." + ); + await stopTrackedSession(context, session); + + console.log( + "Conflict-time edit, logical deletion, case-only rename, and cross-path rename extended the displayed branches and replicated their revision trees." + ); } async function runTargetMismatch( @@ -435,23 +891,57 @@ async function runTargetMismatch( let session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, ignoredContent); await uploadNote(context, session, targetMismatchPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB, { syncOnlyRegEx: "^E2E/two-vault/allowed/.*", }); await syncAndApply(context, session); + await waitForLocalDatabaseEntry(context.cliBinary, session.cliEnv, targetMismatchPath); assertEqual( await pathExists(vaultB.path, targetMismatchPath), false, "A note was reflected on a device where it was not a target file." ); - await session.app.stop(); + await stopTrackedSession(context, session); + + session = await startConfiguredSession(context, vaultB, { + syncOnlyRegEx: "^E2E/two-vault/allowed/.*", + }); + assertEqual( + await pathExists(vaultB.path, targetMismatchPath), + false, + "A checkpointed non-target note was reflected before its target filter changed." + ); + await configureCouchDb( + context.cliBinary, + session.cliEnv, + { + uri: context.couchDb.uri, + username: context.couchDb.username, + password: context.couchDb.password, + dbName: context.dbName, + }, + { syncOnlyRegEx: "" } + ); + await syncAndApply(context, session); + const reflectedAfterEnabling = await waitForPathContent( + vaultB.path, + targetMismatchPath, + (content) => content === ignoredContent + ); + await stopTrackedSession(context, session); + + assertEqual( + reflectedAfterEnabling, + ignoredContent, + "Target file was not reflected after the device accepted the path." + ); session = await startConfiguredSession(context, vaultA); await writeNoteViaObsidian(context.cliBinary, session.cliEnv, targetMismatchPath, acceptedContent); await uploadNote(context, session, targetMismatchPath); - await session.app.stop(); + await stopTrackedSession(context, session); session = await startConfiguredSession(context, vaultB, { syncOnlyRegEx: "", @@ -462,10 +952,12 @@ async function runTargetMismatch( targetMismatchPath, (content) => content === acceptedContent ); - await session.app.stop(); + await stopTrackedSession(context, session); - assertEqual(received, acceptedContent, "Target file was not reflected after the device accepted the path."); - console.log("Two-vault target mismatch skipped a non-target note, then reflected it after enabling the target."); + assertEqual(received, acceptedContent, "Target file update was not reflected after the device accepted the path."); + console.log( + "Two-vault target mismatch skipped a non-target note, reflected it after enabling the target, and accepted a later update." + ); } async function main(): Promise { @@ -482,8 +974,22 @@ async function main(): Promise { const vaultB = await createTemporaryVault(); const encryptedVaultA = await createTemporaryVault(); const encryptedVaultB = await createTemporaryVault(); - const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName }; - const encryptedContext: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName: encryptedDbName }; + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName, + reviewedVaults: new Set(), + activeSessions: new Set(), + }; + const encryptedContext: RunnerContext = { + binary, + cliBinary: cli.binary, + couchDb, + dbName: encryptedDbName, + reviewedVaults: new Set(), + activeSessions: new Set(), + }; try { await assertCouchDbReachable(couchDb); @@ -496,14 +1002,25 @@ async function main(): Promise { console.log(`Temporary CouchDB database: ${dbName}`); console.log(`Temporary encrypted CouchDB database: ${encryptedDbName}`); - await runCreateUpdateDelete(context, vaultA, vaultB); - await runRename(context, vaultA, vaultB); - if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") { - await runMarkdownAutoMerge(context, vaultA, vaultB); + const onlyConflictOperations = process.env.E2E_OBSIDIAN_ONLY_CONFLICT_OPERATIONS === "true"; + if (!onlyConflictOperations) { + await runCreateUpdateDelete(context, vaultA, vaultB); + await runRename(context, vaultA, vaultB); + await runCaseOnlyRename(context, vaultA, vaultB); + if (process.env.E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT === "true") { + await runMarkdownAutoMerge(context, vaultA, vaultB); + } + } + if (onlyConflictOperations || process.env.E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS === "true") { + await runConflictTimeStorageOperations(context, vaultA, vaultB); + } + if (!onlyConflictOperations) { + await runTargetMismatch(context, vaultA, vaultB); + await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB); } - await runTargetMismatch(context, vaultA, vaultB); - await runEncryptedRoundTrip(encryptedContext, encryptedVaultA, encryptedVaultB); } finally { + await stopTrackedSessions(context); + await stopTrackedSessions(encryptedContext); await vaultA.dispose(); await vaultB.dispose(); await encryptedVaultA.dispose(); diff --git a/test/e2e-obsidian/scripts/upgrade-from-stable.ts b/test/e2e-obsidian/scripts/upgrade-from-stable.ts new file mode 100644 index 00000000..fb7114e5 --- /dev/null +++ b/test/e2e-obsidian/scripts/upgrade-from-stable.ts @@ -0,0 +1,756 @@ +import { spawn } from "node:child_process"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { + assertCouchDbReachable, + createCouchDbDatabase, + deleteCouchDbDatabase, + fetchAllCouchDbDocs, + fetchCouchDbDatabaseInfo, + fetchCouchDbLocalDocs, + loadCouchDbConfig, + makeUniqueDatabaseName, + type CouchDbConfig, + type CouchDbDatabaseInfo, + type CouchDbDocument, +} from "../runner/couchdb.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { + configureCouchDb, + configureObjectStorage, + createE2eCouchDbPluginData, + createE2eObjectStoragePluginData, + createE2eObsidianDeviceLocalState, + prepareRemote, + pushLocalChanges, + waitForLiveSyncCoreReady, +} from "../runner/liveSyncWorkflow.ts"; +import { + deleteObjectStoragePrefix, + ensureObjectStorageBucket, + listObjectStorageObjects, + loadObjectStorageConfig, + makeUniqueBucketPrefix, + readObjectStorageJson, + type ObjectStorageConfig, +} from "../runner/objectStorage.ts"; +import { ensurePinnedReleaseArtifact, UPGRADE_SOURCE_RELEASE } from "../runner/releaseArtifact.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { + assertCouchDbCheckpointContinuity, + assertCouchDbDocumentsUnchanged, + assertJournalCheckpointAdvanced, + assertJournalCheckpointLoaded, + assertMilestoneContinuity, + assertNoJournalReplay, + assertSomeCouchDbCheckpointAdvanced, + type CouchDbCheckpointSnapshot, + type CouchDbDocumentRevision, + type MilestoneIdentity, + type RemoteObjectSnapshot, +} from "../runner/upgradeContinuity.ts"; +import { + assertStableReleaseDefaults, + assertStableRemoteSelection, + assertUnconfiguredUpgradeReady, + assertUnconfiguredUpgradeRestarted, + assertUpgradeCompatibilityReady, + assertUpgradeRemainsReady, + configureStableRelease, + createPostUpgradeDelta, + createUpgradeScenarioPaths, + createVerifierReturnDelta, + dismissConfigDoctorIfShown, + prepareStableRemote, + readJournalCheckpoint, + readLocalCouchDbCheckpoints, + readRuntimeUpgradeState, + readRuntimeSettingsUpgradeState, + runCouchDbReplicationObserved, + runJournalReplicationObserved, + runStableFileHistory, + STABLE_RELEASE_VERSION, + verifyPostUpgradeHistory, + verifyPreUpgradeHistory, + verifyReturnDelta, + waitForPersistentNodeIdentity, + type CouchDbReplicationObservation, + type RuntimeUpgradeState, + type UpgradeTransportConfiguration, +} from "../runner/upgradeWorkflow.ts"; +import { obsidianRemoteDebuggingPort } from "../runner/ui.ts"; +import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; + +process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "90000"; + +type Transport = "couchdb" | "object-storage"; + +type RemoteMilestone = CouchDbDocument & { + created?: unknown; + locked?: unknown; + accepted_nodes?: unknown; + tweak_values?: unknown; +}; + +type CouchDbRemoteSnapshot = { + checkpoints: CouchDbCheckpointSnapshot[]; + documents: CouchDbDocumentRevision[]; + info: CouchDbDatabaseInfo; + milestone: MilestoneIdentity; + preferredTweaks: Record; +}; + +type ObjectStorageRemoteSnapshot = { + journalObjects: RemoteObjectSnapshot[]; + milestone: MilestoneIdentity; + preferredTweaks: Record; +}; + +type RunnerContext = { + binary: string; + cliBinary: string; + sourceArtifactRoot: string; + targetArtifactRoot: string; + targetVersion: string; + activeSessions: Set; +}; + +type ParsedArguments = { + transports: Transport[]; + manageServices: boolean; + keepServices: boolean; +}; + +type StartSessionOptions = { + pluginData?: Record; + localStorageEntries?: Readonly>; + waitForCoreReady?: boolean; +}; + +const MILESTONE_ID = "_local/obsydian_livesync_milestone"; +const JOURNAL_MILESTONE_NAME = "_00000000-milestone.json"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`); + } +} + +function parseArguments(argv: readonly string[]): ParsedArguments { + let transportValue = "all"; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--transport") { + transportValue = argv[index + 1] ?? ""; + index++; + } else if (argument.startsWith("--transport=")) { + transportValue = argument.slice("--transport=".length); + } + } + const transports: Transport[] = + transportValue === "all" + ? ["couchdb", "object-storage"] + : transportValue === "couchdb" || transportValue === "object-storage" + ? [transportValue] + : (() => { + throw new Error(`Unsupported transport '${transportValue}'. Use couchdb, object-storage, or all.`); + })(); + return { + transports, + manageServices: argv.includes("--manage-services"), + keepServices: argv.includes("--keep-services"), + }; +} + +function sessionEnvironment(port: number): NodeJS.ProcessEnv { + return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; +} + +function sessionPorts(): readonly [number, number] { + const first = obsidianRemoteDebuggingPort(process.env); + const second = Number(process.env.E2E_OBSIDIAN_SECONDARY_REMOTE_DEBUGGING_PORT ?? first + 1); + if (!Number.isInteger(second) || second < 1 || second > 65535 || second === first) { + throw new Error(`Invalid secondary Obsidian remote debugging port: ${second}`); + } + return [first, second]; +} + +function npmBinary(): string { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + +function runNpmScript(name: string, optional = false): Promise { + return new Promise((resolvePromise, reject) => { + console.log(`\n# ${name}`); + const child = spawn(npmBinary(), ["run", name], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + }); + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0 || optional) { + if (code !== 0) { + console.warn(`${name} did not complete cleanly (${signal ? `signal ${signal}` : `exit ${code}`}).`); + } + resolvePromise(); + return; + } + reject(new Error(`${name} failed (${signal ? `signal ${signal}` : `exit ${code}`}).`)); + }); + }); +} + +async function validateTargetArtifact(root: string): Promise { + await Promise.all( + ["main.js", "manifest.json", "styles.css"].map(async (name) => await access(resolve(root, name))) + ); + const manifest = JSON.parse(await readFile(resolve(root, "manifest.json"), "utf8")) as { + id?: unknown; + version?: unknown; + }; + assertEqual(manifest.id, UPGRADE_SOURCE_RELEASE.pluginId, "The target artefact has an unexpected plug-in id."); + assert(typeof manifest.version === "string" && manifest.version.length > 0, "The target manifest has no version."); + assert( + manifest.version !== STABLE_RELEASE_VERSION, + `The target artefact is still the source release ${STABLE_RELEASE_VERSION}.` + ); + return manifest.version; +} + +async function startSession( + context: RunnerContext, + vault: TemporaryVault, + port: number, + artifactRoot: string, + options: StartSessionOptions = {} +): Promise { + const session = await startObsidianLiveSyncSession({ + binary: context.binary, + cliBinary: context.cliBinary, + vault, + artifactRoot, + pluginData: options.pluginData, + localStorageEntries: options.localStorageEntries, + startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), + env: sessionEnvironment(port), + }); + context.activeSessions.add(session); + try { + if (options.waitForCoreReady !== false) { + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + } + return session; + } catch (error) { + await stopSession(context, session).catch(() => undefined); + throw error; + } +} + +async function readStoredPluginData(vault: TemporaryVault): Promise> { + const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"); + return JSON.parse(await readFile(path, "utf8")) as Record; +} + +async function writeStoredPluginData(vault: TemporaryVault, data: Record): Promise { + const path = resolve(vault.path, ".obsidian", "plugins", "obsidian-livesync", "data.json"); + await writeFile(path, `${JSON.stringify(data, null, 2)}\n`); +} + +async function runUnconfiguredSettingsUpgrade(context: RunnerContext, port: number): Promise { + console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: unconfigured legacy settings`); + const vault = await createTemporaryVault("obsidian-livesync-upgrade-unconfigured-"); + + try { + let session = await startSession(context, vault, port, context.sourceArtifactRoot, { + pluginData: { liveSync: false }, + waitForCoreReady: false, + }); + const stableState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(stableState, false); + await stopSession(context, session); + + const stableData = await readStoredPluginData(vault); + if (stableData.isConfigured !== undefined) { + assertEqual( + stableData.isConfigured, + false, + "The stable release persisted a configured state for its default-equivalent settings." + ); + } + + // 0.25.83 infers the runtime boolean, but persistence depends on an + // unrelated settings-save event. Restore the pre-flag document + // explicitly so the target proves the direct legacy migration in + // either case rather than depending on that timing. + await writeStoredPluginData(vault, { liveSync: false }); + + session = await startSession(context, vault, port, context.targetArtifactRoot, { + waitForCoreReady: false, + }); + const upgradedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); + assertUnconfiguredUpgradeReady(stableState, upgradedState, context.targetVersion); + await stopSession(context, session); + + const migratedData = await readStoredPluginData(vault); + assertEqual(migratedData.isConfigured, false, "The inferred unconfigured state was not saved."); + assertEqual( + migratedData.handleFilenameCaseSensitive, + false, + "The inferred case-insensitive setting was not saved." + ); + + session = await startSession(context, vault, port, context.targetArtifactRoot, { + waitForCoreReady: false, + }); + const restartedState = await readRuntimeSettingsUpgradeState(context.cliBinary, session.cliEnv); + assertUnconfiguredUpgradeRestarted(restartedState, context.targetVersion); + await stopSession(context, session); + + console.log( + `PASS unconfigured settings: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; legacy inference, persistence, and restart idempotence verified.` + ); + } finally { + await stopSessions(context); + await vault.dispose(); + } +} + +async function stopSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopSessions(context: RunnerContext): Promise { + for (const session of [...context.activeSessions]) await stopSession(context, session); +} + +function milestoneIdentity(document: RemoteMilestone): MilestoneIdentity { + assert(document.created !== undefined && document.created !== null, "The remote milestone has no generation."); + assert(typeof document.locked === "boolean", "The remote milestone has no lock state."); + assert(Array.isArray(document.accepted_nodes), "The remote milestone has no accepted-device list."); + assert( + document.accepted_nodes.every((value) => typeof value === "string"), + "The remote milestone accepted-device list is malformed." + ); + return { + created: document.created, + locked: document.locked, + acceptedNodes: document.accepted_nodes, + }; +} + +function preferredTweaks(document: RemoteMilestone): Record { + const values = document.tweak_values; + assert(values !== null && typeof values === "object" && !Array.isArray(values), "The remote has no tweak map."); + const preferred = (values as Record).PREFERRED; + assert( + preferred !== null && typeof preferred === "object" && !Array.isArray(preferred), + "The remote has no preferred tweak settings." + ); + return { ...(preferred as Record) }; +} + +async function readCouchDbRemoteSnapshot(config: CouchDbConfig, databaseName: string): Promise { + const [allDocs, localDocs, info] = await Promise.all([ + fetchAllCouchDbDocs(config, databaseName), + fetchCouchDbLocalDocs(config, databaseName), + fetchCouchDbDatabaseInfo(config, databaseName), + ]); + const milestone = localDocs.rows.find(({ id }) => id === MILESTONE_ID)?.doc as RemoteMilestone | undefined; + assert(milestone, "The CouchDB remote milestone is missing."); + const checkpoints = localDocs.rows.flatMap(({ id, doc }) => + doc && Object.prototype.hasOwnProperty.call(doc, "last_seq") ? [{ id, lastSequence: doc.last_seq }] : [] + ); + const documents = allDocs.rows.map(({ id, value }) => ({ + id, + revision: value.rev, + deleted: value.deleted === true, + })); + return { + checkpoints, + documents, + info, + milestone: milestoneIdentity(milestone), + preferredTweaks: preferredTweaks(milestone), + }; +} + +async function readObjectStorageRemoteSnapshot( + config: ObjectStorageConfig, + prefix: string +): Promise { + const [objects, milestone] = await Promise.all([ + listObjectStorageObjects(config, prefix), + readObjectStorageJson(config, `${prefix}${JOURNAL_MILESTONE_NAME}`), + ]); + const journalObjects = objects.flatMap((object) => { + if (!object.Key || basename(object.Key).startsWith("_")) return []; + return [ + { + key: object.Key, + size: object.Size ?? 0, + etag: object.ETag ?? "", + }, + ]; + }); + return { + journalObjects, + milestone: milestoneIdentity(milestone), + preferredTweaks: preferredTweaks(milestone), + }; +} + +function assertNoOpCouchDbObservation(observation: CouchDbReplicationObservation): void { + assert(observation.succeeded, "The first post-upgrade CouchDB synchronisation failed."); + assertEqual(observation.sentDocuments, 0, "The no-op CouchDB synchronisation resent documents."); + assertEqual(observation.arrivedDocuments, 0, "The no-op CouchDB synchronisation refetched documents."); +} + +function assertNoOpCouchDbDatabase(before: CouchDbRemoteSnapshot, after: CouchDbRemoteSnapshot): void { + assertCouchDbCheckpointContinuity(before.checkpoints, after.checkpoints); + assertCouchDbDocumentsUnchanged(before.documents, after.documents); + assertEqual( + after.info.update_seq, + before.info.update_seq, + "The no-op CouchDB synchronisation advanced update_seq." + ); + assertEqual(after.info.doc_count, before.info.doc_count, "The no-op CouchDB synchronisation changed doc_count."); + assertMilestoneContinuity(before.milestone, after.milestone); +} + +function assertRestartContinuity(before: RuntimeUpgradeState, after: RuntimeUpgradeState): void { + assertEqual(after.localDatabaseName, before.localDatabaseName, "Restart opened a different local database."); + assertEqual(after.nodeId, before.nodeId, "Restart changed the device node identity."); + assertEqual( + after.settings.activeConfigurationId, + before.settings.activeConfigurationId, + "Restart changed the active remote profile." + ); +} + +async function configureFreshCouchDbVerifier( + context: RunnerContext, + session: ObsidianLiveSyncSession, + config: CouchDbConfig, + databaseName: string, + tweaks: Record +): Promise { + await configureCouchDb( + context.cliBinary, + session.cliEnv, + { uri: config.uri, username: config.username, password: config.password, dbName: databaseName }, + tweaks + ); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); +} + +async function configureFreshObjectStorageVerifier( + context: RunnerContext, + session: ObsidianLiveSyncSession, + config: ObjectStorageConfig, + prefix: string, + tweaks: Record +): Promise { + await configureObjectStorage(context.cliBinary, session.cliEnv, { ...config, bucketPrefix: prefix }, tweaks); + await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv); + await prepareRemote(context.cliBinary, session.cliEnv); +} + +async function runCouchDbUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise { + console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: CouchDB`); + const config = await loadCouchDbConfig(); + const databaseName = makeUniqueDatabaseName(config.dbPrefix, "upgrade-from-stable"); + const remote: UpgradeTransportConfiguration = { kind: "couchdb", config, databaseName }; + const paths = createUpgradeScenarioPaths("couchdb"); + const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-"); + const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-couchdb-verifier-"); + let upgradedSession: ObsidianLiveSyncSession | undefined; + + try { + await assertCouchDbReachable(config); + await createCouchDbDatabase(config, databaseName); + + let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false); + await configureStableRelease(context.cliBinary, session.cliEnv, remote); + const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(configuredStable, true); + assertStableRemoteSelection(configuredStable, remote); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(restartedStable, true); + assertStableRemoteSelection(restartedStable, remote); + await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv); + await prepareStableRemote(context.cliBinary, session.cliEnv); + await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => { + const result = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assert(result.succeeded, "The stable CouchDB synchronisation failed."); + }); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + const stableRemote = await readCouchDbRemoteSnapshot(config, databaseName); + const stableLocalCheckpoints = await readLocalCouchDbCheckpoints( + context.cliBinary, + session.cliEnv, + stableRemote.checkpoints.map(({ id }) => id) + ); + assertCouchDbCheckpointContinuity(stableRemote.checkpoints, stableLocalCheckpoints); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + upgradedSession = session; + await dismissConfigDoctorIfShown(session.remoteDebuggingPort); + const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const loadedLocalCheckpoints = await readLocalCouchDbCheckpoints( + context.cliBinary, + session.cliEnv, + stableRemote.checkpoints.map(({ id }) => id) + ); + assertCouchDbCheckpointContinuity(stableLocalCheckpoints, loadedLocalCheckpoints); + const noOpObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assertNoOpCouchDbObservation(noOpObservation); + const noOpRemote = await readCouchDbRemoteSnapshot(config, databaseName); + assertNoOpCouchDbDatabase(stableRemote, noOpRemote); + + await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths); + const deltaObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assert(deltaObservation.succeeded, "The post-upgrade CouchDB delta failed."); + assert(deltaObservation.sentDocuments > 0, "The post-upgrade CouchDB delta sent no documents."); + const deltaRemote = await readCouchDbRemoteSnapshot(config, databaseName); + assertSomeCouchDbCheckpointAdvanced(noOpRemote.checkpoints, deltaRemote.checkpoints); + assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone); + + const verifierSettings = { + uri: config.uri, + username: config.username, + password: config.password, + dbName: databaseName, + }; + const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, { + pluginData: createE2eCouchDbPluginData(verifierSettings, deltaRemote.preferredTweaks), + localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name), + }); + await configureFreshCouchDbVerifier(context, verifier, config, databaseName, deltaRemote.preferredTweaks); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + await verifyPostUpgradeHistory(verifierVault, paths); + await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + + const returnObservation = await runCouchDbReplicationObserved(context.cliBinary, session.cliEnv); + assert(returnObservation.succeeded, "The upgraded CouchDB device could not receive the verifier delta."); + assert(returnObservation.arrivedDocuments > 0, "The verifier CouchDB delta did not arrive."); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, verifier); + await stopSession(context, session); + upgradedSession = undefined; + + const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv); + assertUpgradeRemainsReady(restartedState, context.targetVersion); + assertRestartContinuity(upgradedState, restartedState); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, restarted); + + console.log( + `PASS CouchDB: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no-op sync, delta sync, fresh-device round-trip, and restart continuity verified.` + ); + } finally { + if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined); + await stopSessions(context); + await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]); + if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { + await deleteCouchDbDatabase(config, databaseName).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +async function runObjectStorageUpgrade(context: RunnerContext, ports: readonly [number, number]): Promise { + console.log(`\n# Upgrade from ${STABLE_RELEASE_VERSION}: Object Storage`); + const config = await loadObjectStorageConfig(); + const prefix = makeUniqueBucketPrefix("upgrade-from-stable"); + const remote: UpgradeTransportConfiguration = { kind: "object-storage", config, bucketPrefix: prefix }; + const paths = createUpgradeScenarioPaths("object-storage"); + const upgradeVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-"); + const verifierVault = await createTemporaryVault("obsidian-livesync-upgrade-object-storage-verifier-"); + let upgradedSession: ObsidianLiveSyncSession | undefined; + + try { + await ensureObjectStorageBucket(config); + + let session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + assertStableReleaseDefaults(await readRuntimeUpgradeState(context.cliBinary, session.cliEnv), false); + await configureStableRelease(context.cliBinary, session.cliEnv, remote); + const configuredStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(configuredStable, true); + assertStableRemoteSelection(configuredStable, remote); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.sourceArtifactRoot); + const restartedStable = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertStableReleaseDefaults(restartedStable, true); + assertStableRemoteSelection(restartedStable, remote); + await waitForPersistentNodeIdentity(context.cliBinary, session.cliEnv); + await prepareStableRemote(context.cliBinary, session.cliEnv); + await runStableFileHistory(context.cliBinary, session.cliEnv, paths, async () => { + const result = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert( + result.succeeded, + `The stable Object Storage synchronisation failed.\nObservation: ${JSON.stringify(result)}` + ); + }); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const stableState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + const stableCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + const stableRemote = await readObjectStorageRemoteSnapshot(config, prefix); + await stopSession(context, session); + + session = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + upgradedSession = session; + await dismissConfigDoctorIfShown(session.remoteDebuggingPort); + const upgradedState = await readRuntimeUpgradeState(context.cliBinary, session.cliEnv); + assertUpgradeCompatibilityReady(stableState, upgradedState, context.targetVersion, remote); + await verifyPreUpgradeHistory(upgradeVault, paths); + + const loadedCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + assertJournalCheckpointLoaded(stableCheckpoint, loadedCheckpoint); + const noOpObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert(noOpObservation.succeeded, "The first post-upgrade Object Storage synchronisation failed."); + const noOpCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + const noOpRemote = await readObjectStorageRemoteSnapshot(config, prefix); + assertNoJournalReplay( + stableCheckpoint, + noOpCheckpoint, + stableRemote.journalObjects, + noOpRemote.journalObjects, + noOpObservation + ); + assertMilestoneContinuity(stableRemote.milestone, noOpRemote.milestone); + + await createPostUpgradeDelta(context.cliBinary, session.cliEnv, paths); + const deltaObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert(deltaObservation.succeeded, "The post-upgrade Object Storage delta failed."); + const deltaCheckpoint = await readJournalCheckpoint(context.cliBinary, session.cliEnv); + assertJournalCheckpointAdvanced(noOpCheckpoint, deltaCheckpoint, deltaObservation); + const deltaRemote = await readObjectStorageRemoteSnapshot(config, prefix); + assertMilestoneContinuity(noOpRemote.milestone, deltaRemote.milestone); + + const verifierSettings = { ...config, bucketPrefix: prefix }; + const verifier = await startSession(context, verifierVault, ports[1], context.targetArtifactRoot, { + pluginData: createE2eObjectStoragePluginData(verifierSettings, deltaRemote.preferredTweaks), + localStorageEntries: createE2eObsidianDeviceLocalState(verifierVault.name), + }); + await configureFreshObjectStorageVerifier(context, verifier, config, prefix, deltaRemote.preferredTweaks); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + await verifyPostUpgradeHistory(verifierVault, paths); + await createVerifierReturnDelta(context.cliBinary, verifier.cliEnv, paths); + await pushLocalChanges(context.cliBinary, verifier.cliEnv); + + const returnObservation = await runJournalReplicationObserved(context.cliBinary, session.cliEnv); + assert(returnObservation.succeeded, "The upgraded Object Storage device could not receive the verifier delta."); + assert(returnObservation.downloadedJournalKeys.length > 0, "The verifier Object Storage delta did not arrive."); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, verifier); + await stopSession(context, session); + upgradedSession = undefined; + + const restarted = await startSession(context, upgradeVault, ports[0], context.targetArtifactRoot); + const restartedState = await readRuntimeUpgradeState(context.cliBinary, restarted.cliEnv); + assertUpgradeRemainsReady(restartedState, context.targetVersion); + assertRestartContinuity(upgradedState, restartedState); + await verifyReturnDelta(upgradeVault, paths); + await stopSession(context, restarted); + + console.log( + `PASS Object Storage: ${STABLE_RELEASE_VERSION} -> ${context.targetVersion}; checkpoint lineage, no replay, delta sync, fresh-device round-trip, and restart continuity verified.` + ); + } finally { + if (upgradedSession) await stopSession(context, upgradedSession).catch(() => undefined); + await stopSessions(context); + await Promise.all([upgradeVault.dispose(), verifierVault.dispose()]); + if (process.env.E2E_OBSIDIAN_KEEP_OBJECT_STORAGE !== "true") { + await deleteObjectStoragePrefix(config, prefix).catch((error: unknown) => { + console.warn(error instanceof Error ? error.message : error); + }); + } + } +} + +async function startManagedServices(transports: readonly Transport[]): Promise { + if (transports.includes("couchdb")) { + await runNpmScript("test:docker-couchdb:stop", true); + await runNpmScript("test:docker-couchdb:start"); + } + if (transports.includes("object-storage")) { + await runNpmScript("test:docker-s3:stop", true); + await runNpmScript("test:docker-s3:start"); + } +} + +async function stopManagedServices(transports: readonly Transport[]): Promise { + if (transports.includes("object-storage")) await runNpmScript("test:docker-s3:stop", true); + if (transports.includes("couchdb")) await runNpmScript("test:docker-couchdb:stop", true); +} + +async function main(): Promise { + const arguments_ = parseArguments(process.argv.slice(2)); + const binary = requireObsidianBinary(); + const cli = discoverObsidianCli(); + if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`); + + const targetArtifactRoot = resolve(process.env.E2E_LIVESYNC_TARGET_ARTIFACT_ROOT?.trim() || process.cwd()); + const targetVersion = await validateTargetArtifact(targetArtifactRoot); + const sourceArtifactRoot = await ensurePinnedReleaseArtifact(); + const context: RunnerContext = { + binary, + cliBinary: cli.binary, + sourceArtifactRoot, + targetArtifactRoot, + targetVersion, + activeSessions: new Set(), + }; + const ports = sessionPorts(); + let managedServicesStarted = false; + + console.log(`Using exact source release: ${STABLE_RELEASE_VERSION}`); + console.log(`Using target release candidate: ${targetVersion}`); + console.log(`Source artefact cache: ${sourceArtifactRoot}`); + console.log(`Target artefact root: ${targetArtifactRoot}`); + + try { + await runUnconfiguredSettingsUpgrade(context, ports[0]); + if (arguments_.manageServices) { + await startManagedServices(arguments_.transports); + managedServicesStarted = true; + } + for (const transport of arguments_.transports) { + if (transport === "couchdb") await runCouchDbUpgrade(context, ports); + else await runObjectStorageUpgrade(context, ports); + } + } finally { + await stopSessions(context); + if (managedServicesStarted && !arguments_.keepServices) { + await stopManagedServices(arguments_.transports); + } + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/fixtures/p2p-relay/compose.yml b/test/fixtures/p2p-relay/compose.yml new file mode 100644 index 00000000..44ab17ce --- /dev/null +++ b/test/fixtures/p2p-relay/compose.yml @@ -0,0 +1,12 @@ +services: + p2p-relay: + image: ghcr.io/hoytech/strfry:latest + container_name: livesync-e2e-p2p-relay + entrypoint: ["/app/strfry"] + command: ["--config", "/etc/strfry/strfry.conf", "relay"] + ports: + - "${E2E_P2P_RELAY_PORT:-4010}:7777" + volumes: + - ./strfry.conf:/etc/strfry/strfry.conf:ro + tmpfs: + - /app/strfry-db:rw,size=256m,mode=1777 diff --git a/test/fixtures/p2p-relay/strfry.conf b/test/fixtures/p2p-relay/strfry.conf new file mode 100644 index 00000000..5e91d3e7 --- /dev/null +++ b/test/fixtures/p2p-relay/strfry.conf @@ -0,0 +1,19 @@ +db = "./strfry-db/" + +relay { + bind = "0.0.0.0" + port = 7777 + nofiles = 100000 + + info { + name = "Self-hosted LiveSync E2E relay" + description = "Local Nostr signalling fixture for real-Obsidian P2P tests" + } + + maxWebsocketPayloadSize = 131072 + autoPingSeconds = 55 + + writePolicy { + plugin = "" + } +} diff --git a/test/harness/harness.ts b/test/harness/harness.ts deleted file mode 100644 index ef2d20eb..00000000 --- a/test/harness/harness.ts +++ /dev/null @@ -1,148 +0,0 @@ -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 { SettingCache } from "./obsidian-mock"; -import { delay, fireAndForget, promiseWithResolvers } from "octagonal-wheels/promises"; -import { EVENT_PLATFORM_UNLOADED } from "@lib/events/coreEvents"; -import { EVENT_LAYOUT_READY, eventHub } from "@/common/events"; - -import { env } from "../suite/variables"; - -export type LiveSyncHarness = { - app: App; - plugin: ObsidianLiveSyncPlugin; - dispose: () => Promise; - disposalPromise: Promise; - isDisposed: () => boolean; -}; -const isLiveSyncLogEnabled = env?.PRINT_LIVESYNC_LOGS === "true"; -function overrideLogFunction(vaultName: string) { - setGlobalLogFunction((msg, level, key) => { - if (!isLiveSyncLogEnabled) { - return; - } - if (level && level < LOG_LEVEL_VERBOSE) { - return; - } - if (msg instanceof Error) { - console.error(msg.stack); - } else { - console.log( - `[${vaultName}] :: [${key ?? "Global"}][${level ?? 1}]: ${msg instanceof Error ? msg.stack : msg}` - ); - } - }); -} - -export async function generateHarness( - paramVaultName?: string, - settings?: Partial -): Promise { - // return await serialized("harness-generation-lock", async () => { - // Dispose previous harness to avoid multiple harness running at the same time - // if (previousHarness && !previousHarness.isDisposed()) { - // console.log(`Previous harness detected, waiting for disposal...`); - // await previousHarness.disposalPromise; - // previousHarness = null; - // await delay(100); - // } - const vaultName = paramVaultName ?? "TestVault" + Date.now(); - const setting = { - ...DEFAULT_SETTINGS, - ...settings, - }; - overrideLogFunction(vaultName); - //@ts-ignore Mocked in harness - const app = new App(vaultName); - // setting and vault name - SettingCache.set(app, setting); - SettingCache.set(app.vault, vaultName); - - //@ts-ignore - const manifest_version = `${MANIFEST_VERSION || "0.0.0-harness"}`; - overrideLogFunction(vaultName); - const manifest = { - id: "obsidian-livesync", - name: "Self-hosted LiveSync (Harnessed)", - version: manifest_version, - minAppVersion: "0.15.0", - description: "Testing", - author: "vrtmrz", - authorUrl: "", - isDesktopOnly: false, - }; - - const plugin = new ObsidianLiveSyncPlugin(app, manifest); - overrideLogFunction(vaultName); - // Initial load - await delay(100); - await plugin.onload(); - let isDisposed = false; - const waitPromise = promiseWithResolvers(); - eventHub.once(EVENT_PLATFORM_UNLOADED, () => { - fireAndForget(async () => { - console.log(`Harness for vault '${vaultName}' disposed.`); - await delay(100); - eventHub.offAll(); - isDisposed = true; - waitPromise.resolve(); - }); - }); - eventHub.once(EVENT_LAYOUT_READY, () => { - plugin.app.vault.trigger("layout-ready"); - }); - const harness: LiveSyncHarness = { - app, - plugin, - dispose: async () => { - await plugin.onunload(); - return waitPromise.promise; - }, - disposalPromise: waitPromise.promise, - isDisposed: () => isDisposed, - }; - await delay(100); - console.log(`Harness for vault '${vaultName}' is ready.`); - // previousHarness = harness; - return harness; -} -export async function waitForReady(harness: LiveSyncHarness): Promise { - for (let i = 0; i < 10; i++) { - if (harness.plugin.core.services.appLifecycle.isReady()) { - console.log("App Lifecycle is ready"); - return; - } - await delay(100); - } - throw new Error(`Initialisation Timed out!`); -} - -export async function waitForIdle(harness: LiveSyncHarness): Promise { - for (let i = 0; i < 20; i++) { - await delay(25); - const processing = - harness.plugin.core.services.replication.databaseQueueCount.value + - harness.plugin.core.services.fileProcessing.totalQueued.value + - harness.plugin.core.services.fileProcessing.batched.value + - harness.plugin.core.services.fileProcessing.processing.value + - harness.plugin.core.services.replication.storageApplyingCount.value; - - if (processing === 0) { - if (i > 0) { - console.log(`Idle after ${i} loops`); - } - return; - } - } -} -export async function waitForClosed(harness: LiveSyncHarness): Promise { - await delay(100); - for (let i = 0; i < 10; i++) { - if (harness.plugin.core.services.control.hasUnloaded()) { - console.log("App has unloaded"); - return; - } - await delay(100); - } -} diff --git a/test/harness/utils/intercept.ts b/test/harness/utils/intercept.ts deleted file mode 100644 index 098a924e..00000000 --- a/test/harness/utils/intercept.ts +++ /dev/null @@ -1,51 +0,0 @@ -export function interceptFetchForLogging() { - const originalFetch = globalThis.fetch; - globalThis.fetch = async (...params: any[]) => { - const paramObj = params[0]; - const initObj = params[1]; - const url = typeof paramObj === "string" ? paramObj : paramObj.url; - const method = initObj?.method || "GET"; - const headers = initObj?.headers || {}; - const body = initObj?.body || null; - const headersObj: Record = {}; - if (headers instanceof Headers) { - headers.forEach((value, key) => { - headersObj[key] = value; - }); - } - console.dir({ - mockedFetch: { - url, - method, - headers: headersObj, - }, - }); - try { - const res = await originalFetch.apply(globalThis, params as any); - console.log(`[Obsidian Mock] Fetch response: ${res.status} ${res.statusText} for ${method} ${url}`); - const resClone = res.clone(); - const contentType = resClone.headers.get("content-type") || ""; - const isJson = contentType.includes("application/json"); - if (isJson) { - const data = await resClone.json(); - console.dir({ mockedFetchResponseJson: data }); - } else { - const ab = await resClone.arrayBuffer(); - const text = new TextDecoder().decode(ab); - const isText = /^text\//.test(contentType); - if (isText) { - console.dir({ - mockedFetchResponseText: ab.byteLength < 1000 ? text : text.slice(0, 1000) + "...(truncated)", - }); - } else { - console.log(`[Obsidian Mock] Fetch response is of content-type ${contentType}, not logging body.`); - } - } - return res; - } catch (e) { - // console.error("[Obsidian Mock] Fetch error:", e); - console.error(`[Obsidian Mock] Fetch failed for ${method} ${url}, error:`, e); - throw e; - } - }; -} diff --git a/test/lib/commands.ts b/test/lib/commands.ts deleted file mode 100644 index 762b5c0c..00000000 --- a/test/lib/commands.ts +++ /dev/null @@ -1,165 +0,0 @@ -import type { P2PSyncSetting } from "@/lib/src/common/types"; -import { delay } from "octagonal-wheels/promises"; -import type { BrowserContext, Page } from "playwright"; -import type { Plugin } from "vitest/config"; -import type { BrowserCommand } from "vitest/node"; -import { serialized } from "octagonal-wheels/concurrency/lock"; -export const grantClipboardPermissions: BrowserCommand = async (ctx) => { - if (ctx.provider.name === "playwright") { - await ctx.context.grantPermissions(["clipboard-read", "clipboard-write"]); - console.log("Granted clipboard permissions"); - return; - } -}; -let peerPage: Page | undefined; -let peerPageContext: BrowserContext | undefined; -let previousName = ""; -async function setValue(page: Page, selector: string, value: string) { - const e = await page.waitForSelector(selector); - await e.fill(value); -} -async function closePeerContexts() { - const peerPageLocal = peerPage; - const peerPageContextLocal = peerPageContext; - if (peerPageLocal) { - await peerPageLocal.close(); - } - if (peerPageContextLocal) { - await peerPageContextLocal.close(); - } -} -export const openWebPeer: BrowserCommand<[P2PSyncSetting, serverPeerName: string]> = async ( - ctx, - setting: P2PSyncSetting, - serverPeerName: string = "p2p-livesync-web-peer" -) => { - if (ctx.provider.name === "playwright") { - const previousPage = ctx.page; - if (peerPage !== undefined) { - if (previousName === serverPeerName) { - console.log(`WebPeer for ${serverPeerName} already opened`); - return; - } - console.log(`Closing previous WebPeer for ${previousName}`); - await closePeerContexts(); - } - console.log(`Opening webPeer`); - return serialized("webpeer", async () => { - const browser = ctx.context.browser()!; - const context = await browser.newContext(); - peerPageContext = context; - peerPage = await context.newPage(); - previousName = serverPeerName; - console.log(`Navigating...`); - await peerPage.goto("http://localhost:8081"); - await peerPage.waitForLoadState(); - console.log(`Navigated!`); - await setValue(peerPage, "#app > main [placeholder*=wss]", setting.P2P_relays); - await setValue(peerPage, "#app > main [placeholder*=anything]", setting.P2P_roomID); - await setValue(peerPage, "#app > main [placeholder*=password]", setting.P2P_passphrase); - await setValue(peerPage, "#app > main [placeholder*=iphone]", serverPeerName); - // await peerPage.getByTitle("Enable P2P Replicator").setChecked(true); - await peerPage.getByRole("checkbox").first().setChecked(true); - // (await peerPage.waitForSelector("Save and Apply")).click(); - await peerPage.getByText("Save and Apply").click(); - await delay(100); - await peerPage.reload(); - await delay(500); - for (let i = 0; i < 10; i++) { - await delay(100); - const btn = peerPage.getByRole("button").filter({ hasText: /^connect/i }); - if ((await peerPage.getByText(/disconnect/i).count()) > 0) { - break; - } - await btn.click(); - } - await previousPage.bringToFront(); - ctx.context.on("close", async () => { - console.log("Browser context is closing, closing peer page if exists"); - await closePeerContexts(); - }); - console.log("Web peer page opened"); - }); - } -}; - -export const closeWebPeer: BrowserCommand = async (ctx) => { - if (ctx.provider.name === "playwright") { - return serialized("webpeer", async () => { - await closePeerContexts(); - peerPage = undefined; - peerPageContext = undefined; - previousName = ""; - console.log("Web peer page closed"); - }); - } -}; -export const acceptWebPeer: BrowserCommand = async (ctx) => { - if (peerPage) { - // Detect dialogue - const buttonsOnDialogs = await peerPage.$$("popup .buttons button"); - for (const b of buttonsOnDialogs) { - const text = (await b.innerText()).toLowerCase(); - // console.log(`Dialog button found: ${text}`); - if (text === "accept") { - console.log("Accepting dialog"); - await b.click({ timeout: 300 }); - await delay(500); - } - } - const buttons = peerPage.getByRole("button").filter({ hasText: /^accept$/i }); - const a = await buttons.all(); - for (const b of a) { - await b.click({ timeout: 300 }); - } - } - return false; -}; - -/** Write arbitrary text to a file on the Node.js host (used for phase handoff). */ -export const writeHandoffFile: BrowserCommand<[filePath: string, content: string]> = async ( - _ctx, - filePath: string, - content: string -) => { - const fs = await import("node:fs/promises"); - await fs.writeFile(filePath, content, "utf-8"); -}; - -/** Read a file from the Node.js host (used for phase handoff). */ -export const readHandoffFile: BrowserCommand<[filePath: string]> = async (_ctx, filePath: string): Promise => { - const fs = await import("node:fs/promises"); - return fs.readFile(filePath, "utf-8"); -}; - -export default function BrowserCommands(): Plugin { - return { - name: "vitest:custom-commands", - config() { - return { - test: { - browser: { - commands: { - grantClipboardPermissions, - openWebPeer, - closeWebPeer, - acceptWebPeer, - writeHandoffFile, - readHandoffFile, - }, - }, - }, - }; - }, - }; -} -declare module "vitest/browser" { - interface BrowserCommands { - grantClipboardPermissions: () => Promise; - openWebPeer: (setting: P2PSyncSetting, serverPeerName: string) => Promise; - closeWebPeer: () => Promise; - acceptWebPeer: () => Promise; - writeHandoffFile: (filePath: string, content: string) => Promise; - readHandoffFile: (filePath: string) => Promise; - } -} diff --git a/test/lib/ui.ts b/test/lib/ui.ts deleted file mode 100644 index 3d2381a6..00000000 --- a/test/lib/ui.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { page } from "vitest/browser"; -import { delay } from "@/lib/src/common/utils"; - -export async function waitForDialogShown(dialogText: string, timeout = 500) { - const ttl = Date.now() + timeout; - while (Date.now() < ttl) { - try { - await delay(50); - const dialog = page - .getByText(dialogText) - .elements() - .filter((e) => e.classList.contains("modal-title")) - .filter((e) => e.checkVisibility()); - if (dialog.length === 0) { - continue; - } - return true; - } catch (e) { - // Ignore - } - } - return false; -} -export async function waitForDialogHidden(dialogText: string | RegExp, timeout = 500) { - const ttl = Date.now() + timeout; - while (Date.now() < ttl) { - try { - await delay(50); - const dialog = page - .getByText(dialogText) - .elements() - .filter((e) => e.classList.contains("modal-title")) - .filter((e) => e.checkVisibility()); - if (dialog.length > 0) { - // console.log(`Still exist ${dialogText.toString()}`); - continue; - } - return true; - } catch (e) { - // Ignore - } - } - return false; -} - -export async function waitForButtonClick(buttonText: string | RegExp, timeout = 500) { - const ttl = Date.now() + timeout; - while (Date.now() < ttl) { - try { - await delay(100); - const buttons = page - .getByText(buttonText) - .elements() - .filter((e) => e.checkVisibility() && e.tagName.toLowerCase() == "button"); - if (buttons.length == 0) { - // console.log(`Could not found ${buttonText.toString()}`); - continue; - } - console.log(`Button detected: ${buttonText.toString()}`); - // console.dir(buttons[0]) - await page.elementLocator(buttons[0]).click(); - await delay(100); - return true; - } catch (e) { - console.error(e); - // Ignore - } - } - return false; -} diff --git a/test/lib/util.ts b/test/lib/util.ts deleted file mode 100644 index 502d0d2c..00000000 --- a/test/lib/util.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { delay } from "@/lib/src/common/utils"; - -export async function waitTaskWithFollowups( - task: Promise, - followup: () => Promise, - timeout: number = 10000, - interval: number = 1000 -): Promise { - const symbolNotCompleted = Symbol("notCompleted"); - const isCompleted = () => Promise.race([task, Promise.resolve(symbolNotCompleted)]); - const ttl = Date.now() + timeout; - do { - const state = await isCompleted(); - if (state !== symbolNotCompleted) { - return state; - } - await followup(); - await delay(interval); - } while (Date.now() < ttl); - throw new Error("Task did not complete in time"); -} diff --git a/test/shell/p2p-init.sh b/test/shell/p2p-init.sh deleted file mode 100755 index dd865c9c..00000000 --- a/test/shell/p2p-init.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/bash -echo "P2P Init - No additional initialization required." \ No newline at end of file diff --git a/test/shell/p2p-start.sh b/test/shell/p2p-start.sh deleted file mode 100755 index 8c86a45c..00000000 --- a/test/shell/p2p-start.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/bash -set -e -script_dir=$(dirname "$0") -webpeer_dir=$script_dir/../../src/apps/webpeer - -docker run -d --name relay-test -p 4000:7777 \ - --tmpfs /app/strfry-db:rw,size=256m \ - --entrypoint sh \ - ghcr.io/hoytech/strfry:latest \ - -lc 'cat > /tmp/strfry.conf <<"EOF" -db = "./strfry-db/" - -relay { - bind = "0.0.0.0" - port = 7777 - nofiles = 100000 - - info { - name = "livesync test relay" - description = "local relay for livesync p2p tests" - } - - maxWebsocketPayloadSize = 131072 - autoPingSeconds = 55 - - writePolicy { - plugin = "" - } -} -EOF -exec /app/strfry --config /tmp/strfry.conf relay' -npm run --prefix $webpeer_dir build -docker run -d --name webpeer-test -p 8081:8043 -v $webpeer_dir/dist:/srv/http pierrezemb/gostatic \ No newline at end of file diff --git a/test/shell/p2p-stop.sh b/test/shell/p2p-stop.sh deleted file mode 100755 index 22925ad4..00000000 --- a/test/shell/p2p-stop.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -docker stop relay-test -docker rm relay-test -docker stop webpeer-test -docker rm webpeer-test \ No newline at end of file diff --git a/test/suite/db_common.ts b/test/suite/db_common.ts deleted file mode 100644 index f81f084f..00000000 --- a/test/suite/db_common.ts +++ /dev/null @@ -1,129 +0,0 @@ -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 { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { expect } from "vitest"; - -export const defaultFileOption = { - mtime: new Date(2026, 0, 1, 0, 1, 2, 3).getTime(), -} as const satisfies DataWriteOptions; -export async function storeFile( - harness: LiveSyncHarness, - path: string, - content: string | Blob, - deleteBeforeSend = false, - fileOptions = defaultFileOption -) { - if (deleteBeforeSend && harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - if (content instanceof Blob) { - console.log(`Creating binary file ${path}`); - await harness.app.vault.createBinary(path, await content.arrayBuffer(), fileOptions); - } else { - await harness.app.vault.create(path, content, fileOptions); - } - - // Ensure file is created - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - expect(compareMTime(file.stat.mtime, fileOptions?.mtime ?? defaultFileOption.mtime)).toBe(EVEN); - if (content instanceof Blob) { - const readContent = await harness.app.vault.readBinary(file); - expect(await isDocContentSame(readContent, content)).toBe(true); - } else { - const readContent = await harness.app.vault.read(file); - expect(readContent).toBe(content); - } - } - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - return file; -} -export async function readFromLocalDB(harness: LiveSyncHarness, path: string) { - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - return entry; -} -export async function readFromVault( - harness: LiveSyncHarness, - path: string, - isBinary: boolean = false, - fileOptions = defaultFileOption -): Promise { - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - // console.log(`MTime: ${file.stat.mtime}, Expected: ${fileOptions.mtime}`); - if (fileOptions.mtime !== undefined) { - expect(compareMTime(file.stat.mtime, fileOptions.mtime)).toBe(EVEN); - } - const content = isBinary ? await harness.app.vault.readBinary(file) : await harness.app.vault.read(file); - return content; - } - - throw new Error("File not found in vault"); -} -export async function checkStoredFileInDB( - harness: LiveSyncHarness, - path: string, - content: string | Blob, - fileOptions = defaultFileOption -) { - const entry = await readFromLocalDB(harness, path); - if (entry === false) { - throw new Error("DB Content not found"); - } - const contentToCheck = content instanceof Blob ? await content.arrayBuffer() : content; - const isDocSame = await isDocContentSame(readContent(entry), contentToCheck); - if (fileOptions.mtime !== undefined) { - expect(compareMTime(entry.mtime, fileOptions.mtime)).toBe(EVEN); - } - expect(isDocSame).toBe(true); - return Promise.resolve(); -} -export async function testFileWrite( - harness: LiveSyncHarness, - path: string, - content: string | Blob, - skipCheckToBeWritten = false, - fileOptions = defaultFileOption -) { - const file = await storeFile(harness, path, content, false, fileOptions); - expect(file).toBeInstanceOf(TFile); - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - const vaultFile = await readFromVault(harness, path, content instanceof Blob, fileOptions); - expect(await isDocContentSame(vaultFile, content)).toBe(true); - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - if (skipCheckToBeWritten) { - return Promise.resolve(); - } - await checkStoredFileInDB(harness, path, content); - return Promise.resolve(); -} -export async function testFileRead( - harness: LiveSyncHarness, - path: string, - expectedContent: string | Blob, - fileOptions = defaultFileOption -) { - await waitForIdle(harness); - const file = await readFromVault(harness, path, expectedContent instanceof Blob, fileOptions); - const isDocSame = await isDocContentSame(file, expectedContent); - expect(isDocSame).toBe(true); - // Check local database entry - const entry = await readFromLocalDB(harness, path); - expect(entry).not.toBe(false); - if (entry === false) { - throw new Error("DB Content not found"); - } - const isDBDocSame = await isDocContentSame(readContent(entry), expectedContent); - expect(isDBDocSame).toBe(true); - return await Promise.resolve(); -} diff --git a/test/suite/onlylocaldb.test.ts b/test/suite/onlylocaldb.test.ts deleted file mode 100644 index acfbb65b..00000000 --- a/test/suite/onlylocaldb.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -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 { DummyFileSourceInisialised, generateBinaryFile, generateFile, init } from "../utils/dummyfile"; - -const localdb_test_setting = { - ...DEFAULT_SETTINGS, - isConfigured: true, - handleFilenameCaseSensitive: false, -} as ObsidianLiveSyncSettings; - -describe.skip("Plugin Integration Test (Local Database)", async () => { - let harness: LiveSyncHarness; - const vaultName = "TestVault" + Date.now(); - - beforeAll(async () => { - await DummyFileSourceInisialised; - harness = await generateHarness(vaultName, localdb_test_setting); - await waitForReady(harness); - }); - - it("should be instantiated and defined", async () => { - expect(harness.plugin).toBeDefined(); - expect(harness.plugin.app).toBe(harness.app); - return await Promise.resolve(); - }); - - it("should have services initialized", async () => { - expect(harness.plugin.core.services).toBeDefined(); - return await Promise.resolve(); - }); - it("should have local database initialized", async () => { - expect(harness.plugin.core.localDatabase).toBeDefined(); - expect(harness.plugin.core.localDatabase.isReady).toBe(true); - return await Promise.resolve(); - }); - - it("should store the changes into the local database", async () => { - const path = "test-store6.md"; - const content = "Hello, World!"; - if (harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - await harness.app.vault.create(path, content); - - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - - if (file instanceof TFile) { - const readContent = await harness.app.vault.read(file); - expect(readContent).toBe(content); - } - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - // await delay(100); // Wait a bit for the local database to process - - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - if (entry) { - expect(readContent(entry)).toBe(content); - } - return await Promise.resolve(); - }); - test.each([10, 100, 1000, 10000, 50000, 100000])("should handle large file of size %i bytes", async (size) => { - const path = `test-large-file-${size}.md`; - const content = Array.from(generateFile(size)).join(""); - if (harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - await harness.app.vault.create(path, content); - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - const readContent = await harness.app.vault.read(file); - expect(readContent).toBe(content); - } - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - if (entry) { - expect(readContent(entry)).toBe(content); - } - return await Promise.resolve(); - }); - - const binaryMap = Array.from({ length: 7 }, (_, i) => Math.pow(2, i * 4)); - test.each(binaryMap)("should handle binary file of size %i bytes", async (size) => { - const path = `test-binary-file-${size}.bin`; - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - if (harness.app.vault.getAbstractFileByPath(path)) { - console.log(`Deleting existing file ${path}`); - await harness.app.vault.delete(harness.app.vault.getAbstractFileByPath(path) as TFile); - } - // Create file via vault - await harness.app.vault.createBinary(path, await content.arrayBuffer()); - const file = harness.app.vault.getAbstractFileByPath(path); - expect(file).toBeInstanceOf(TFile); - if (file instanceof TFile) { - const readContent = await harness.app.vault.readBinary(file); - expect(await isDocContentSame(readContent, content)).toBe(true); - } - - await harness.plugin.core.services.fileProcessing.commitPendingFileEvents(); - await waitForIdle(harness); - const entry = await harness.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).not.toBe(false); - if (entry) { - const entryContent = await readContent(entry); - if (!(entryContent instanceof ArrayBuffer)) { - throw new Error("Entry content is not an ArrayBuffer"); - } - // const expectedContent = await content.arrayBuffer(); - expect(await isDocContentSame(entryContent, content)).toBe(true); - } - return await Promise.resolve(); - }); -}); diff --git a/test/suite/sync.senario.basic.ts b/test/suite/sync.senario.basic.ts deleted file mode 100644 index 3eafe3cb..00000000 --- a/test/suite/sync.senario.basic.ts +++ /dev/null @@ -1,275 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// 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 { - DummyFileSourceInisialised, - FILE_SIZE_BINS, - FILE_SIZE_MD, - generateBinaryFile, - generateFile, -} from "../utils/dummyfile"; -import { checkStoredFileInDB, testFileRead, testFileWrite } from "./db_common"; -import { delay } from "@/lib/src/common/utils"; -import { commands } from "vitest/browser"; -import { closeReplication, performReplication, prepareRemote } from "./sync_common"; -import type { DataWriteOptions } from "@/deps.ts"; - -type MTimedDataWriteOptions = DataWriteOptions & { mtime: number }; -export type TestOptions = { - setting: ObsidianLiveSyncSettings; - fileOptions: MTimedDataWriteOptions; -}; -function generateName(prefix: string, type: string, ext: string, size: number) { - return `${prefix}-${type}-file-${size}.${ext}`; -} -export function syncBasicCase(label: string, { setting, fileOptions }: TestOptions) { - describe("Replication Suite Tests - " + label, () => { - const nameFile = (type: string, ext: string, size: number) => generateName("sync-test", type, ext, size); - let serverPeerName = ""; - // TODO: Harness disposal may broke the event loop of P2P replication - // so we keep the harnesses alive until all tests are done. - // It may trystero's somethong, or not. - let harnessUpload: LiveSyncHarness; - let harnessDownload: LiveSyncHarness; - beforeAll(async () => { - await DummyFileSourceInisialised; - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - // await commands.closeWebPeer(); - serverPeerName = "t-" + Date.now(); - setting.P2P_AutoAcceptingPeers = serverPeerName; - setting.P2P_AutoSyncPeers = serverPeerName; - setting.P2P_DevicePeerName = "client-" + Date.now(); - await commands.openWebPeer(setting, serverPeerName); - } - }); - afterAll(async () => { - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - await commands.closeWebPeer(); - // await closeP2PReplicatorConnections(harnessUpload); - } - }); - - describe("Remote Database Initialization", () => { - let harnessInit: LiveSyncHarness; - const sync_test_setting_init = { - ...setting, - } as ObsidianLiveSyncSettings; - beforeAll(async () => { - const vaultName = "TestVault" + Date.now(); - console.log(`BeforeAll - Remote Database Initialization - Vault: ${vaultName}`); - harnessInit = await generateHarness(vaultName, sync_test_setting_init); - await waitForReady(harnessInit); - expect(harnessInit.plugin).toBeDefined(); - expect(harnessInit.plugin.app).toBe(harnessInit.app); - await waitForIdle(harnessInit); - }); - afterAll(async () => { - await harnessInit.plugin.core.services.replicator.getActiveReplicator()?.closeReplication(); - await harnessInit.dispose(); - await delay(1000); - }); - - it("should reset remote database", async () => { - // harnessInit = await generateHarness(vaultName, sync_test_setting_init); - await waitForReady(harnessInit); - await prepareRemote(harnessInit, sync_test_setting_init, true); - }); - it("should be prepared for replication", async () => { - await waitForReady(harnessInit); - if (setting.remoteType !== RemoteTypes.REMOTE_P2P) { - const status = await harnessInit.plugin.core.services.replicator - .getActiveReplicator() - ?.getRemoteStatus(sync_test_setting_init); - console.log("Connected devices after reset:", status); - expect(status).not.toBeFalsy(); - } - }); - }); - - describe("Replication - Upload", () => { - const sync_test_setting_upload = { - ...setting, - } as ObsidianLiveSyncSettings; - - beforeAll(async () => { - const vaultName = "TestVault" + Date.now(); - console.log(`BeforeAll - Replication Upload - Vault: ${vaultName}`); - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - sync_test_setting_upload.P2P_AutoAcceptingPeers = serverPeerName; - sync_test_setting_upload.P2P_AutoSyncPeers = serverPeerName; - sync_test_setting_upload.P2P_DevicePeerName = "up-" + Date.now(); - } - harnessUpload = await generateHarness(vaultName, sync_test_setting_upload); - await waitForReady(harnessUpload); - expect(harnessUpload.plugin).toBeDefined(); - expect(harnessUpload.plugin.app).toBe(harnessUpload.app); - await waitForIdle(harnessUpload); - }); - - afterAll(async () => { - await closeReplication(harnessUpload); - }); - - it("should be instantiated and defined", () => { - expect(harnessUpload.plugin).toBeDefined(); - expect(harnessUpload.plugin.app).toBe(harnessUpload.app); - }); - - it("should have services initialized", () => { - expect(harnessUpload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessUpload.plugin.core.localDatabase).toBeDefined(); - expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should prepare remote database", async () => { - await prepareRemote(harnessUpload, sync_test_setting_upload, false); - }); - - // describe("File Creation", async () => { - it("should a file has been created", async () => { - const content = "Hello, World!"; - const path = nameFile("store", "md", 0); - await testFileWrite(harnessUpload, path, content, false, fileOptions); - // Perform replication - // await harness.plugin.core.services.replication.replicate(true); - }); - it("should different content of several files have been created correctly", async () => { - await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions); - }); - - test.each(FILE_SIZE_MD)("should large file of size %i bytes has been created", async (size) => { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(true).toBe(true); - } else { - await testFileWrite(harnessUpload, path, content, false, fileOptions); - } - }); - - test.each(FILE_SIZE_BINS)("should binary file of size %i bytes has been created", async (size) => { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - const path = nameFile("binary", "bin", size); - await testFileWrite(harnessUpload, path, content, true, fileOptions); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(true).toBe(true); - } else { - await checkStoredFileInDB(harnessUpload, path, content, fileOptions); - } - }); - - it("Replication after uploads", async () => { - await performReplication(harnessUpload); - await performReplication(harnessUpload); - }); - }); - - describe("Replication - Download", () => { - // Download into a new vault - const sync_test_setting_download = { - ...setting, - } as ObsidianLiveSyncSettings; - beforeAll(async () => { - const vaultName = "TestVault" + Date.now(); - console.log(`BeforeAll - Replication Download - Vault: ${vaultName}`); - if (setting.remoteType === RemoteTypes.REMOTE_P2P) { - sync_test_setting_download.P2P_AutoAcceptingPeers = serverPeerName; - sync_test_setting_download.P2P_AutoSyncPeers = serverPeerName; - sync_test_setting_download.P2P_DevicePeerName = "down-" + Date.now(); - } - harnessDownload = await generateHarness(vaultName, sync_test_setting_download); - await waitForReady(harnessDownload); - await prepareRemote(harnessDownload, sync_test_setting_download, false); - - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - await delay(1000); - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - }); - afterAll(async () => { - await closeReplication(harnessDownload); - }); - - it("should be instantiated and defined", () => { - expect(harnessDownload.plugin).toBeDefined(); - expect(harnessDownload.plugin.app).toBe(harnessDownload.app); - }); - - it("should have services initialized", () => { - expect(harnessDownload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessDownload.plugin.core.localDatabase).toBeDefined(); - expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should a file has been synchronised", async () => { - const expectedContent = "Hello, World!"; - const path = nameFile("store", "md", 0); - await testFileRead(harnessDownload, path, expectedContent, fileOptions); - }); - it("should different content of several files have been synchronised", async () => { - await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions); - }); - - test.each(FILE_SIZE_MD)("should the file %i bytes had been synchronised", async (size) => { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(entry).toBe(false); - } else { - await testFileRead(harnessDownload, path, content, fileOptions); - } - }); - - test.each(FILE_SIZE_BINS)("should binary file of size %i bytes had been synchronised", async (size) => { - const path = nameFile("binary", "bin", size); - - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - console.log(`Skipping file of size ${size} bytes as it is too large to sync.`); - expect(entry).toBe(false); - } else { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - await testFileRead(harnessDownload, path, content, fileOptions); - } - }); - }); - afterAll(async () => { - if (harnessDownload) { - await closeReplication(harnessDownload); - await harnessDownload.dispose(); - await delay(1000); - } - if (harnessUpload) { - await closeReplication(harnessUpload); - await harnessUpload.dispose(); - await delay(1000); - } - }); - it("Wait for idle state", async () => { - await delay(100); - }); - }); -} diff --git a/test/suite/sync.single.test.ts b/test/suite/sync.single.test.ts deleted file mode 100644 index 9be98b44..00000000 --- a/test/suite/sync.single.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { describe } from "vitest"; -import { - PREFERRED_JOURNAL_SYNC, - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; - -import { defaultFileOption } from "./db_common"; -import { syncBasicCase } from "./sync.senario.basic.ts"; -import { settingBase } from "./variables.ts"; -const sync_test_setting_base = settingBase; -export const env = (import.meta as any).env; -function* generateCase() { - const passpharse = "thetest-Passphrase3+9-for-e2ee!"; - const REMOTE_RECOMMENDED = { - [RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED, - [RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC, - [RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED, - }; - const remoteTypes = [RemoteTypes.REMOTE_COUCHDB]; - // const remoteTypes = [RemoteTypes.REMOTE_P2P]; - const e2eeOptions = [false]; - // const e2eeOptions = [true]; - for (const remoteType of remoteTypes) { - for (const useE2EE of e2eeOptions) { - yield { - setting: { - ...sync_test_setting_base, - ...REMOTE_RECOMMENDED[remoteType], - remoteType, - encrypt: useE2EE, - passphrase: useE2EE ? passpharse : "", - usePathObfuscation: useE2EE, - } as ObsidianLiveSyncSettings, - }; - } - } -} - -describe.skip("Replication Suite Tests (Single)", async () => { - const cases = Array.from(generateCase()); - const fileOptions = defaultFileOption; - describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions }); - }); -}); diff --git a/test/suite/sync.test.ts b/test/suite/sync.test.ts deleted file mode 100644 index aa284c17..00000000 --- a/test/suite/sync.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { describe } from "vitest"; -import { - PREFERRED_JOURNAL_SYNC, - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; - -import { defaultFileOption } from "./db_common"; -import { syncBasicCase } from "./sync.senario.basic.ts"; -import { settingBase } from "./variables.ts"; -const sync_test_setting_base = settingBase; -export const env = (import.meta as any).env; -function* generateCase() { - const passpharse = "thetest-Passphrase3+9-for-e2ee!"; - const REMOTE_RECOMMENDED = { - [RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED, - [RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC, - [RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED, - }; - const remoteTypes = [RemoteTypes.REMOTE_COUCHDB, RemoteTypes.REMOTE_MINIO]; - // const remoteTypes = [RemoteTypes.REMOTE_P2P]; - const e2eeOptions = [false, true]; - // const e2eeOptions = [true]; - for (const remoteType of remoteTypes) { - for (const useE2EE of e2eeOptions) { - yield { - setting: { - ...sync_test_setting_base, - ...REMOTE_RECOMMENDED[remoteType], - remoteType, - encrypt: useE2EE, - passphrase: useE2EE ? passpharse : "", - usePathObfuscation: useE2EE, - } as ObsidianLiveSyncSettings, - }; - } - } -} - -describe("Replication Suite Tests (Normal)", async () => { - const cases = Array.from(generateCase()); - const fileOptions = defaultFileOption; - describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions }); - }); -}); diff --git a/test/suite/sync_common.ts b/test/suite/sync_common.ts deleted file mode 100644 index 74da8664..00000000 --- a/test/suite/sync_common.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { expect } from "vitest"; -import { waitForIdle, type LiveSyncHarness } from "../harness/harness"; -import { RemoteTypes, type ObsidianLiveSyncSettings } from "@/lib/src/common/types"; - -import { delay, fireAndForget } from "@/lib/src/common/utils"; -import { commands } from "vitest/browser"; -import { LiveSyncTrysteroReplicator } from "@/lib/src/replication/trystero/LiveSyncTrysteroReplicator"; -import { waitTaskWithFollowups } from "../lib/util"; -async function waitForP2PPeers(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - // Wait for peers to connect - const maxRetries = 20; - let retries = maxRetries; - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - while (retries-- > 0) { - fireAndForget(() => commands.acceptWebPeer()); - await delay(1000); - const peers = replicator.knownAdvertisements; - - if (peers && peers.length > 0) { - console.log("P2P peers connected:", peers); - return; - } - fireAndForget(() => commands.acceptWebPeer()); - console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`); - console.dir(peers); - await delay(1000); - } - console.log("Failed to connect P2P peers after retries"); - throw new Error("P2P peers did not connect in time."); - } -} -export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - replicator.closeReplication(); - await delay(30); - replicator.closeReplication(); - await delay(1000); - console.log("P2P replicator connections closed"); - // if (replicator instanceof LiveSyncTrysteroReplicator) { - // replicator.closeReplication(); - // await delay(1000); - // } - } -} - -export async function performReplication(harness: LiveSyncHarness) { - await waitForP2PPeers(harness); - await delay(500); - const p = harness.plugin.core.services.replication.replicate(true); - const task = - harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P - ? waitTaskWithFollowups( - p, - () => { - // Accept any peer dialogs during replication (fire and forget) - fireAndForget(() => commands.acceptWebPeer()); - return Promise.resolve(); - }, - 30000, - 500 - ) - : p; - const result = await task; - // await waitForIdle(harness); - // if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - // await closeP2PReplicatorConnections(harness); - // } - return result; -} - -export async function closeReplication(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - return await closeP2PReplicatorConnections(harness); - } - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!replicator) { - console.log("No active replicator to close"); - return; - } - await replicator.closeReplication(); - await waitForIdle(harness); - console.log("Replication closed"); -} - -export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) { - if (setting.remoteType !== RemoteTypes.REMOTE_P2P) { - if (shouldReset) { - await delay(1000); - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryResetRemoteDatabase(harness.plugin.core.settings); - } else { - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryCreateRemoteDatabase(harness.plugin.core.settings); - } - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.markRemoteResolved(harness.plugin.core.settings); - // No exceptions should be thrown - const status = await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.getRemoteStatus(harness.plugin.core.settings); - console.log("Remote status:", status); - expect(status).not.toBeFalsy(); - } -} diff --git a/test/suite/variables.ts b/test/suite/variables.ts deleted file mode 100644 index f55cce26..00000000 --- a/test/suite/variables.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DoctorRegulation } from "@/lib/src/common/configForDoc"; -import { - DEFAULT_SETTINGS, - ChunkAlgorithms, - AutoAccepting, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; -export const env = (import.meta as any).env; -export const settingBase = { - ...DEFAULT_SETTINGS, - isConfigured: true, - handleFilenameCaseSensitive: false, - couchDB_URI: `${env.hostname}`, - couchDB_DBNAME: `${env.dbname}`, - couchDB_USER: `${env.username}`, - couchDB_PASSWORD: `${env.password}`, - bucket: `${env.bucketName}`, - region: "us-east-1", - endpoint: `${env.minioEndpoint}`, - accessKey: `${env.accessKey}`, - secretKey: `${env.secretKey}`, - useCustomRequestHandler: true, - forcePathStyle: true, - bucketPrefix: "", - usePluginSyncV2: true, - chunkSplitterVersion: ChunkAlgorithms.RabinKarp, - doctorProcessedVersion: DoctorRegulation.version, - notifyThresholdOfRemoteStorageSize: 800, - P2P_AutoAccepting: AutoAccepting.ALL, - P2P_AutoBroadcast: true, - P2P_AutoStart: true, - P2P_Enabled: true, - P2P_passphrase: "p2psync-test", - P2P_roomID: "p2psync-test", - P2P_DevicePeerName: "p2psync-test", - P2P_relays: "ws://localhost:4000/", - P2P_AutoAcceptingPeers: "p2p-livesync-web-peer", - P2P_SyncOnReplication: "p2p-livesync-web-peer", -} as ObsidianLiveSyncSettings; diff --git a/test/suitep2p/run-p2p-tests.sh b/test/suitep2p/run-p2p-tests.sh deleted file mode 100755 index 4d8a50c0..00000000 --- a/test/suitep2p/run-p2p-tests.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)" -CLI_DIR="$REPO_ROOT/src/apps/cli" -CLI_TEST_HELPERS="$CLI_DIR/test/test-helpers.sh" - -source "$CLI_TEST_HELPERS" - -RUN_BUILD="${RUN_BUILD:-1}" -KEEP_TEST_DATA="${KEEP_TEST_DATA:-1}" -VERBOSE_TEST_LOGGING="${VERBOSE_TEST_LOGGING:-1}" - -RELAY="${RELAY:-ws://localhost:4000/}" -USE_INTERNAL_RELAY="${USE_INTERNAL_RELAY:-1}" -APP_ID="${APP_ID:-self-hosted-livesync-vitest-p2p}" -HOST_PEER_NAME="${HOST_PEER_NAME:-p2p-cli-host}" - -ROOM_ID="p2p-room-$(date +%s)-$RANDOM-$RANDOM" -PASSPHRASE="p2p-pass-$(date +%s)-$RANDOM-$RANDOM" -UPLOAD_PEER_NAME="p2p-upload-$(date +%s)-$RANDOM" -DOWNLOAD_PEER_NAME="p2p-download-$(date +%s)-$RANDOM" -UPLOAD_VAULT_NAME="TestVaultUpload-$(date +%s)-$RANDOM" -DOWNLOAD_VAULT_NAME="TestVaultDownload-$(date +%s)-$RANDOM" - -# ---- Build CLI ---- -if [[ "$RUN_BUILD" == "1" ]]; then - echo "[INFO] building CLI" - (cd "$CLI_DIR" && npm run build) -fi - -# ---- Temp directory ---- -WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-vitest-p2p.XXXXXX")" -VAULT_HOST="$WORK_DIR/vault-host" -SETTINGS_HOST="$WORK_DIR/settings-host.json" -HOST_LOG="$WORK_DIR/p2p-host.log" -# Handoff file: upload phase writes this; download phase reads it. -HANDOFF_FILE="$WORK_DIR/p2p-test-handoff.json" -mkdir -p "$VAULT_HOST" - -# ---- Setup CLI command (uses npm run cli from CLI_DIR) ---- -# Override run_cli to invoke the built binary directly from CLI_DIR -run_cli() { - (cd "$CLI_DIR" && node dist/index.cjs "$@") -} - -# ---- Create host settings ---- -echo "[INFO] relay=$RELAY room=$ROOM_ID app=$APP_ID host=$HOST_PEER_NAME" -cli_test_init_settings_file "$SETTINGS_HOST" -cli_test_apply_p2p_settings "$SETTINGS_HOST" "$ROOM_ID" "$PASSPHRASE" "$APP_ID" "$RELAY" "~.*" - -# Set host peer name -SETTINGS_HOST_FILE="$SETTINGS_HOST" HOST_PEER_NAME_VAL="$HOST_PEER_NAME" HOST_PASSPHRASE_VAL="$PASSPHRASE" node <<'NODE' -const fs = require("node:fs"); -const data = JSON.parse(fs.readFileSync(process.env.SETTINGS_HOST_FILE, "utf-8")); - -// Keep tweak values aligned with browser-side P2P test settings. -data.remoteType = "ONLY_P2P"; -data.encrypt = true; -data.passphrase = process.env.HOST_PASSPHRASE_VAL; -data.usePathObfuscation = true; -data.handleFilenameCaseSensitive = false; -data.customChunkSize = 50; -data.usePluginSyncV2 = true; -data.doNotUseFixedRevisionForChunks = false; - -data.P2P_DevicePeerName = process.env.HOST_PEER_NAME_VAL; -fs.writeFileSync(process.env.SETTINGS_HOST_FILE, JSON.stringify(data, null, 2), "utf-8"); -NODE - -# ---- Cleanup trap ---- -cleanup() { - local exit_code=$? - if [[ -n "${HOST_PID:-}" ]] && kill -0 "$HOST_PID" >/dev/null 2>&1; then - echo "[INFO] stopping CLI host (PID=$HOST_PID)" - kill -TERM "$HOST_PID" >/dev/null 2>&1 || true - wait "$HOST_PID" >/dev/null 2>&1 || true - fi - - if [[ "${P2P_RELAY_STARTED:-0}" == "1" ]]; then - cli_test_stop_p2p_relay - fi - - if [[ "$KEEP_TEST_DATA" != "1" ]]; then - rm -rf "$WORK_DIR" - else - echo "[INFO] KEEP_TEST_DATA=1, preserving artefacts at $WORK_DIR" - fi - - exit "$exit_code" -} -trap cleanup EXIT - -start_host() { - local attempt=0 - while [[ "$attempt" -lt 5 ]]; do - attempt=$((attempt + 1)) - echo "[INFO] starting CLI p2p-host (attempt $attempt/5)" - : >"$HOST_LOG" - (cd "$CLI_DIR" && node dist/index.cjs "$VAULT_HOST" --settings "$SETTINGS_HOST" -d p2p-host) >"$HOST_LOG" 2>&1 & - HOST_PID=$! - - local host_ready=0 - local exited_early=0 - for i in $(seq 1 30); do - if grep -qF "P2P host is running" "$HOST_LOG" 2>/dev/null; then - host_ready=1 - break - fi - if ! kill -0 "$HOST_PID" >/dev/null 2>&1; then - exited_early=1 - break - fi - echo "[INFO] waiting for p2p-host to be ready... ($i/30)" - sleep 1 - done - - if [[ "$host_ready" == "1" ]]; then - echo "[INFO] p2p-host is ready (PID=$HOST_PID)" - return 0 - fi - - wait "$HOST_PID" >/dev/null 2>&1 || true - HOST_PID= - - if grep -qF "Resource temporarily unavailable" "$HOST_LOG" 2>/dev/null; then - echo "[INFO] p2p-host database lock is still being released, retrying..." - sleep 2 - continue - fi - - if [[ "$exited_early" == "1" ]]; then - echo "[FAIL] CLI host process exited unexpectedly" >&2 - else - echo "[FAIL] p2p-host did not become ready within 30 seconds" >&2 - fi - cat "$HOST_LOG" >&2 - exit 1 - done - - echo "[FAIL] p2p-host could not be restarted after multiple attempts" >&2 - cat "$HOST_LOG" >&2 - exit 1 -} - -# ---- Start local relay if needed ---- -if [[ "$USE_INTERNAL_RELAY" == "1" ]]; then - if cli_test_is_local_p2p_relay "$RELAY"; then - cli_test_start_p2p_relay - P2P_RELAY_STARTED=1 - else - echo "[INFO] USE_INTERNAL_RELAY=1 but RELAY is not local ($RELAY), skipping" - fi -fi - -start_host - -# Common env vars passed to both vitest runs -P2P_ENV=( - P2P_TEST_ROOM_ID="$ROOM_ID" - P2P_TEST_PASSPHRASE="$PASSPHRASE" - P2P_TEST_HOST_PEER_NAME="$HOST_PEER_NAME" - P2P_TEST_RELAY="$RELAY" - P2P_TEST_APP_ID="$APP_ID" - P2P_TEST_HANDOFF_FILE="$HANDOFF_FILE" - P2P_TEST_UPLOAD_PEER_NAME="$UPLOAD_PEER_NAME" - P2P_TEST_DOWNLOAD_PEER_NAME="$DOWNLOAD_PEER_NAME" - P2P_TEST_UPLOAD_VAULT_NAME="$UPLOAD_VAULT_NAME" - P2P_TEST_DOWNLOAD_VAULT_NAME="$DOWNLOAD_VAULT_NAME" -) - -cd "$REPO_ROOT" - -# ---- Phase 1: Upload ---- -# Each vitest run gets a fresh browser process, so Trystero's module-level -# global state (occupiedRooms, didInit, etc.) is clean for every phase. -echo "[INFO] running P2P vitest — upload phase" -env "${P2P_ENV[@]}" \ - npx dotenv-cli -e .env -e .test.env -- \ - vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-up.test.ts -echo "[INFO] upload phase completed" - -# ---- Phase 2: Download ---- -# Keep the same host process alive so its database handle and relay presence stay stable. -echo "[INFO] waiting 5s before download phase..." -sleep 5 -echo "[INFO] running P2P vitest — download phase" -env "${P2P_ENV[@]}" \ - npx dotenv-cli -e .env -e .test.env -- \ - vitest run --config vitest.config.p2p.ts test/suitep2p/syncp2p.p2p-down.test.ts -echo "[INFO] download phase completed" - -echo "[INFO] P2P vitest suite completed" diff --git a/test/suitep2p/sync_common_p2p.ts b/test/suitep2p/sync_common_p2p.ts deleted file mode 100644 index 53009894..00000000 --- a/test/suitep2p/sync_common_p2p.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * P2P-specific sync helpers. - * - * Derived from test/suite/sync_common.ts but with all acceptWebPeer() calls - * removed. When using a CLI p2p-host with P2P_AutoAcceptingPeers="~.*", peer - * acceptance is automatic and no Playwright dialog interaction is needed. - */ -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 { waitTaskWithFollowups } from "../lib/util"; - -const P2P_REPLICATION_TIMEOUT_MS = 180000; - -async function testWebSocketConnection(relayUrl: string): Promise { - return new Promise((resolve, reject) => { - console.log(`[P2P Debug] Testing WebSocket connection to ${relayUrl}`); - try { - const ws = new WebSocket(relayUrl); - const timer = setTimeout(() => { - ws.close(); - reject(new Error(`WebSocket connection to ${relayUrl} timed out`)); - }, 5000); - ws.onopen = () => { - clearTimeout(timer); - console.log(`[P2P Debug] WebSocket connected to ${relayUrl} successfully`); - ws.close(); - resolve(); - }; - ws.onerror = (e) => { - clearTimeout(timer); - console.error(`[P2P Debug] WebSocket error connecting to ${relayUrl}:`, e); - reject(new Error(`WebSocket connection to ${relayUrl} failed`)); - }; - } catch (e) { - console.error(`[P2P Debug] WebSocket constructor threw:`, e); - reject(e); - } - }); -} - -async function waitForP2PPeers(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const maxRetries = 20; - let retries = maxRetries; - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - console.log("[P2P Debug] replicator type:", replicator?.constructor?.name); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - - // Ensure P2P is open (getActiveReplicator returns a fresh instance that may not be open yet) - if (!replicator.server?.isServing) { - console.log("[P2P Debug] P2P not yet serving, calling open()"); - // Test WebSocket connectivity first - const relay = harness.plugin.core.settings.P2P_relays?.split(",")[0]?.trim(); - if (relay) { - try { - await testWebSocketConnection(relay); - } catch (e) { - console.error("[P2P Debug] WebSocket connectivity test failed:", e); - } - } - try { - await replicator.open(); - console.log("[P2P Debug] open() completed, isServing:", replicator.server?.isServing); - } catch (e) { - console.error("[P2P Debug] open() threw:", e); - } - } - - // Wait for P2P server to actually start (room joined) - for (let i = 0; i < 30; i++) { - const serving = replicator.server?.isServing; - console.log(`[P2P Debug] isServing: ${serving} (${i}/30)`); - if (serving) break; - await delay(500); - if (i === 29) throw new Error("P2P server did not start in time."); - } - - while (retries-- > 0) { - await delay(1000); - const peers = replicator.knownAdvertisements; - if (peers && peers.length > 0) { - console.log("P2P peers connected:", peers); - return; - } - console.log(`Waiting for any P2P peers to be connected... ${maxRetries - retries}/${maxRetries}`); - console.dir(peers); - await delay(1000); - } - console.log("Failed to connect P2P peers after retries"); - throw new Error("P2P peers did not connect in time."); - } -} - -export async function closeP2PReplicatorConnections(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - replicator.closeReplication(); - await delay(30); - replicator.closeReplication(); - await delay(1000); - console.log("P2P replicator connections closed"); - } -} - -export async function performReplication(harness: LiveSyncHarness) { - await waitForP2PPeers(harness); - await delay(500); - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!(replicator instanceof LiveSyncTrysteroReplicator)) { - throw new Error("Replicator is not an instance of LiveSyncTrysteroReplicator"); - } - const knownPeers = replicator.knownAdvertisements; - - const targetPeer = knownPeers.find((peer) => peer.name.startsWith("vault-host")) ?? knownPeers[0] ?? undefined; - if (!targetPeer) { - throw new Error("No connected P2P peer to synchronise with"); - } - - const p = replicator.sync(targetPeer.peerId, true); - const result = await waitTaskWithFollowups(p, () => Promise.resolve(), P2P_REPLICATION_TIMEOUT_MS, 500); - if (result && typeof result === "object" && "error" in result && result.error) { - throw result.error; - } - return result; - } - - return await harness.plugin.core.services.replication.replicate(true); -} - -export async function closeReplication(harness: LiveSyncHarness) { - if (harness.plugin.core.settings.remoteType === RemoteTypes.REMOTE_P2P) { - return await closeP2PReplicatorConnections(harness); - } - const replicator = await harness.plugin.core.services.replicator.getActiveReplicator(); - if (!replicator) { - console.log("No active replicator to close"); - return; - } - await replicator.closeReplication(); - await waitForIdle(harness); - console.log("Replication closed"); -} - -export async function prepareRemote(harness: LiveSyncHarness, setting: ObsidianLiveSyncSettings, shouldReset = false) { - // P2P has no remote database to initialise — skip - if (setting.remoteType === RemoteTypes.REMOTE_P2P) return; - - if (shouldReset) { - await delay(1000); - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryResetRemoteDatabase(harness.plugin.core.settings); - } else { - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.tryCreateRemoteDatabase(harness.plugin.core.settings); - } - await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.markRemoteResolved(harness.plugin.core.settings); - const status = await harness.plugin.core.services.replicator - .getActiveReplicator() - ?.getRemoteStatus(harness.plugin.core.settings); - console.log("Remote status:", status); - expect(status).not.toBeFalsy(); -} diff --git a/test/suitep2p/syncp2p.p2p-down.test.ts b/test/suitep2p/syncp2p.p2p-down.test.ts deleted file mode 100644 index 7f3b77f7..00000000 --- a/test/suitep2p/syncp2p.p2p-down.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * P2P Replication Tests — Download phase (process 2 of 2) - * - * Executed by run-p2p-tests.sh as the second vitest process, after the - * upload phase has completed and the CLI host holds all the data. - * - * Reads the handoff JSON written by the upload phase to know which files - * to verify, then replicates from the CLI host and checks every file. - */ -import { afterAll, beforeAll, beforeEach, describe, expect, it, test } from "vitest"; -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type FilePath, - type ObsidianLiveSyncSettings, - AutoAccepting, -} from "@/lib/src/common/types"; -import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile"; -import { defaultFileOption, testFileRead } from "../suite/db_common"; -import { delay } from "@/lib/src/common/utils"; -import { closeReplication, performReplication } from "./sync_common_p2p"; -import { settingBase } from "../suite/variables"; - -const env = (import.meta as any).env; - -const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room"; -const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass"; -const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host"; -const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/"; -const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p"; -const DOWNLOAD_PEER_NAME: string = env.P2P_TEST_DOWNLOAD_PEER_NAME ?? `p2p-download-${Date.now()}`; -const DOWNLOAD_VAULT_NAME: string = env.P2P_TEST_DOWNLOAD_VAULT_NAME ?? `TestVaultDownload-${Date.now()}`; -const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json"; - -console.log("[P2P Down] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID); -console.log("[P2P Down] HANDOFF_FILE:", HANDOFF_FILE); - -const p2pSetting: ObsidianLiveSyncSettings = { - ...settingBase, - ...PREFERRED_SETTING_SELF_HOSTED, - showVerboseLog: true, - remoteType: RemoteTypes.REMOTE_P2P, - encrypt: true, - passphrase: PASSPHRASE, - usePathObfuscation: true, - P2P_Enabled: true, - P2P_AppID: APP_ID, - handleFilenameCaseSensitive: false, - P2P_AutoAccepting: AutoAccepting.ALL, - P2P_AutoBroadcast: true, - P2P_AutoStart: true, - P2P_passphrase: PASSPHRASE, - P2P_roomID: ROOM_ID, - P2P_relays: RELAY, - P2P_AutoAcceptingPeers: "~.*", - P2P_SyncOnReplication: HOST_PEER_NAME, -}; - -const fileOptions = defaultFileOption; -const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`; - -/** Read the handoff JSON produced by the upload phase. */ -async function readHandoff(): Promise<{ fileSizeMd: number[]; fileSizeBins: number[] }> { - const { commands } = await import("@vitest/browser/context"); - const raw = await commands.readHandoffFile(HANDOFF_FILE); - return JSON.parse(raw); -} - -describe("P2P Replication — Download", () => { - let harnessDownload: LiveSyncHarness; - let fileSizeMd: number[] = []; - let fileSizeBins: number[] = []; - - const downloadSetting: ObsidianLiveSyncSettings = { - ...p2pSetting, - P2P_DevicePeerName: DOWNLOAD_PEER_NAME, - }; - - beforeAll(async () => { - await DummyFileSourceInisialised; - - const handoff = await readHandoff(); - fileSizeMd = handoff.fileSizeMd; - fileSizeBins = handoff.fileSizeBins; - console.log("[P2P Down] handoff loaded — md sizes:", fileSizeMd, "bin sizes:", fileSizeBins); - - const vaultName = DOWNLOAD_VAULT_NAME; - console.log(`[P2P Down] BeforeAll - Vault: ${vaultName}`); - console.log(`[P2P Down] Peer name: ${DOWNLOAD_PEER_NAME}`); - harnessDownload = await generateHarness(vaultName, downloadSetting); - await waitForReady(harnessDownload); - - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - await delay(1000); - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - await delay(3000); - }); - beforeEach(async () => { - await performReplication(harnessDownload); - await waitForIdle(harnessDownload); - }); - - afterAll(async () => { - await closeReplication(harnessDownload); - await harnessDownload.dispose(); - await delay(1000); - }); - - it("should be instantiated and defined", () => { - expect(harnessDownload.plugin).toBeDefined(); - expect(harnessDownload.plugin.app).toBe(harnessDownload.app); - }); - - it("should have services initialized", () => { - expect(harnessDownload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessDownload.plugin.core.localDatabase).toBeDefined(); - expect(harnessDownload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should have synchronised the stored file", async () => { - await testFileRead(harnessDownload, nameFile("store", "md", 0), "Hello, World!", fileOptions); - }); - - it("should have synchronised files with different content", async () => { - await testFileRead(harnessDownload, nameFile("test-diff-1", "md", 0), "Content A", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-2", "md", 0), "Content B", fileOptions); - await testFileRead(harnessDownload, nameFile("test-diff-3", "md", 0), "Content C", fileOptions); - }); - - // NOTE: test.each cannot use variables populated in beforeAll, so we use - // a single it() that iterates over the sizes loaded from the handoff file. - it("should have synchronised all large md files", async () => { - for (const size of fileSizeMd) { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).toBe(false); - } else { - await testFileRead(harnessDownload, path, content, fileOptions); - } - } - }); - - it("should have synchronised all binary files", async () => { - for (const size of fileSizeBins) { - const path = nameFile("binary", "bin", size); - const isTooLarge = harnessDownload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - const entry = await harnessDownload.plugin.core.localDatabase.getDBEntry(path as FilePath); - expect(entry).toBe(false); - } else { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - await testFileRead(harnessDownload, path, content, fileOptions); - } - } - }); -}); diff --git a/test/suitep2p/syncp2p.p2p-up.test.ts b/test/suitep2p/syncp2p.p2p-up.test.ts deleted file mode 100644 index 7c463eb3..00000000 --- a/test/suitep2p/syncp2p.p2p-up.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * P2P Replication Tests — Upload phase (process 1 of 2) - * - * Executed by run-p2p-tests.sh as the first vitest process. - * Writes files into the local DB, replicates them to the CLI host, - * then writes a handoff JSON so the download process knows what to verify. - * - * Trystero has module-level global state (occupiedRooms, didInit, etc.) - * that cannot be safely reused across upload→download within the same - * browser process. Running upload and download as separate vitest - * invocations gives each phase a fresh browser context. - */ -import { afterAll, beforeAll, describe, expect, it, test } from "vitest"; -import { generateHarness, waitForIdle, waitForReady, type LiveSyncHarness } from "../harness/harness"; -import { - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, - AutoAccepting, -} from "@/lib/src/common/types"; -import { - DummyFileSourceInisialised, - FILE_SIZE_BINS, - FILE_SIZE_MD, - generateBinaryFile, - generateFile, -} from "../utils/dummyfile"; -import { checkStoredFileInDB, defaultFileOption, testFileWrite } from "../suite/db_common"; -import { delay } from "@/lib/src/common/utils"; -import { closeReplication, performReplication } from "./sync_common_p2p"; -import { settingBase } from "../suite/variables"; - -const env = (import.meta as any).env; - -const ROOM_ID: string = env.P2P_TEST_ROOM_ID ?? "p2p-test-room"; -const PASSPHRASE: string = env.P2P_TEST_PASSPHRASE ?? "p2p-test-pass"; -const HOST_PEER_NAME: string = env.P2P_TEST_HOST_PEER_NAME ?? "p2p-cli-host"; -const RELAY: string = env.P2P_TEST_RELAY ?? "ws://localhost:4000/"; -const APP_ID: string = env.P2P_TEST_APP_ID ?? "self-hosted-livesync-vitest-p2p"; -const UPLOAD_PEER_NAME: string = env.P2P_TEST_UPLOAD_PEER_NAME ?? `p2p-upload-${Date.now()}`; -const UPLOAD_VAULT_NAME: string = env.P2P_TEST_UPLOAD_VAULT_NAME ?? `TestVaultUpload-${Date.now()}`; -// Path written by run-p2p-tests.sh; the download phase reads it back. -const HANDOFF_FILE: string = env.P2P_TEST_HANDOFF_FILE ?? "/tmp/p2p-test-handoff.json"; - -console.log("[P2P Up] ROOM_ID:", ROOM_ID, "HOST:", HOST_PEER_NAME, "RELAY:", RELAY, "APP_ID:", APP_ID); -console.log("[P2P Up] HANDOFF_FILE:", HANDOFF_FILE); - -const p2pSetting: ObsidianLiveSyncSettings = { - ...settingBase, - ...PREFERRED_SETTING_SELF_HOSTED, - showVerboseLog: true, - remoteType: RemoteTypes.REMOTE_P2P, - encrypt: true, - passphrase: PASSPHRASE, - usePathObfuscation: true, - P2P_Enabled: true, - P2P_AppID: APP_ID, - handleFilenameCaseSensitive: false, - P2P_AutoAccepting: AutoAccepting.ALL, - P2P_AutoBroadcast: true, - P2P_AutoStart: true, - P2P_passphrase: PASSPHRASE, - P2P_roomID: ROOM_ID, - P2P_relays: RELAY, - P2P_AutoAcceptingPeers: "~.*", - P2P_SyncOnReplication: HOST_PEER_NAME, -}; - -const fileOptions = defaultFileOption; -const nameFile = (type: string, ext: string, size: number) => `p2p-cli-test-${type}-file-${size}.${ext}`; - -/** Write the handoff JSON so the download phase knows which files to verify. */ -async function writeHandoff() { - const handoff = { - fileSizeMd: FILE_SIZE_MD, - fileSizeBins: FILE_SIZE_BINS, - }; - const { commands } = await import("@vitest/browser/context"); - await commands.writeHandoffFile(HANDOFF_FILE, JSON.stringify(handoff)); - console.log("[P2P Up] handoff written to", HANDOFF_FILE); -} - -describe("P2P Replication — Upload", () => { - let harnessUpload: LiveSyncHarness; - - const uploadSetting: ObsidianLiveSyncSettings = { - ...p2pSetting, - P2P_DevicePeerName: UPLOAD_PEER_NAME, - }; - - beforeAll(async () => { - await DummyFileSourceInisialised; - const vaultName = UPLOAD_VAULT_NAME; - console.log(`[P2P Up] BeforeAll - Vault: ${vaultName}`); - console.log(`[P2P Up] Peer name: ${UPLOAD_PEER_NAME}`); - harnessUpload = await generateHarness(vaultName, uploadSetting); - await waitForReady(harnessUpload); - expect(harnessUpload.plugin).toBeDefined(); - await waitForIdle(harnessUpload); - }); - - afterAll(async () => { - await closeReplication(harnessUpload); - await harnessUpload.dispose(); - await delay(1000); - }); - - it("should be instantiated and defined", () => { - expect(harnessUpload.plugin).toBeDefined(); - expect(harnessUpload.plugin.app).toBe(harnessUpload.app); - }); - - it("should have services initialized", () => { - expect(harnessUpload.plugin.core.services).toBeDefined(); - }); - - it("should have local database initialized", () => { - expect(harnessUpload.plugin.core.localDatabase).toBeDefined(); - expect(harnessUpload.plugin.core.localDatabase.isReady).toBe(true); - }); - - it("should create a file", async () => { - await testFileWrite(harnessUpload, nameFile("store", "md", 0), "Hello, World!", false, fileOptions); - }); - - it("should create several files with different content", async () => { - await testFileWrite(harnessUpload, nameFile("test-diff-1", "md", 0), "Content A", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-2", "md", 0), "Content B", false, fileOptions); - await testFileWrite(harnessUpload, nameFile("test-diff-3", "md", 0), "Content C", false, fileOptions); - }); - - test.each(FILE_SIZE_MD)("should create large md file of size %i bytes", async (size) => { - const content = Array.from(generateFile(size)).join(""); - const path = nameFile("large", "md", size); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (isTooLarge) { - expect(true).toBe(true); - } else { - await testFileWrite(harnessUpload, path, content, false, fileOptions); - } - }); - - test.each(FILE_SIZE_BINS)("should create binary file of size %i bytes", async (size) => { - const content = new Blob([...generateBinaryFile(size)], { type: "application/octet-stream" }); - const path = nameFile("binary", "bin", size); - await testFileWrite(harnessUpload, path, content, true, fileOptions); - const isTooLarge = harnessUpload.plugin.core.services.vault.isFileSizeTooLarge(size); - if (!isTooLarge) { - await checkStoredFileInDB(harnessUpload, path, content, fileOptions); - } - }); - - it("should replicate uploads to CLI host", async () => { - await performReplication(harnessUpload); - await performReplication(harnessUpload); - }); - - it("should write handoff file for download phase", async () => { - await writeHandoff(); - }); -}); diff --git a/test/suitep2p/syncp2p.test.ts b/test/suitep2p/syncp2p.test.ts deleted file mode 100644 index 08c2c101..00000000 --- a/test/suitep2p/syncp2p.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// Functional Test on Main Cases -// This test suite only covers main functional cases of synchronisation. Event handling, error cases, -// and edge, resolving conflicts, etc. will be covered in separate test suites. -import { describe } from "vitest"; -import { - PREFERRED_JOURNAL_SYNC, - PREFERRED_SETTING_SELF_HOSTED, - RemoteTypes, - type ObsidianLiveSyncSettings, -} from "@/lib/src/common/types"; - -import { settingBase } from "../suite/variables.ts"; -import { defaultFileOption } from "../suite/db_common"; -import { syncBasicCase } from "../suite/sync.senario.basic.ts"; - -export const env = (import.meta as any).env; -function* generateCase() { - const sync_test_setting_base = settingBase; - const passpharse = "thetest-Passphrase3+9-for-e2ee!"; - const REMOTE_RECOMMENDED = { - [RemoteTypes.REMOTE_COUCHDB]: PREFERRED_SETTING_SELF_HOSTED, - [RemoteTypes.REMOTE_MINIO]: PREFERRED_JOURNAL_SYNC, - [RemoteTypes.REMOTE_P2P]: PREFERRED_SETTING_SELF_HOSTED, - }; - // const remoteTypes = [RemoteTypes.REMOTE_COUCHDB, RemoteTypes.REMOTE_MINIO, RemoteTypes.REMOTE_P2P]; - const remoteTypes = [RemoteTypes.REMOTE_P2P]; - // const e2eeOptions = [false, true]; - const e2eeOptions = [true]; - for (const remoteType of remoteTypes) { - for (const useE2EE of e2eeOptions) { - yield { - setting: { - ...sync_test_setting_base, - ...REMOTE_RECOMMENDED[remoteType], - remoteType, - encrypt: useE2EE, - passphrase: useE2EE ? passpharse : "", - usePathObfuscation: useE2EE, - } as ObsidianLiveSyncSettings, - }; - } - } -} - -describe("Replication Suite Tests (P2P)", async () => { - const cases = Array.from(generateCase()); - const fileOptions = defaultFileOption; - describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - syncBasicCase(`Remote: ${setting.remoteType}, E2EE: ${setting.encrypt}`, { setting, fileOptions }); - }); -}); diff --git a/test/testtest/dummyfile.test.ts b/test/testtest/dummyfile.test.ts deleted file mode 100644 index 32349963..00000000 --- a/test/testtest/dummyfile.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { writeFile } from "../utils/fileapi.vite"; -import { DummyFileSourceInisialised, generateBinaryFile, generateFile } from "../utils/dummyfile"; -import { describe, expect, it } from "vitest"; - -describe("Test File Teet", async () => { - await DummyFileSourceInisialised; - - it("should generate binary file correctly", async () => { - const size = 5000; - let generatedSize = 0; - const chunks: Uint8Array[] = []; - const generator = generateBinaryFile(size); - const blob = new Blob([...generator], { type: "application/octet-stream" }); - const buf = await blob.arrayBuffer(); - const hexDump = new Uint8Array(buf) - //@ts-ignore - .toHex() - .match(/.{1,32}/g) - ?.join("\n"); - const secondDummy = generateBinaryFile(size); - const secondBlob = new Blob([...secondDummy], { type: "application/octet-stream" }); - const secondBuf = await secondBlob.arrayBuffer(); - const secondHexDump = new Uint8Array(secondBuf) - //@ts-ignore - .toHex() - .match(/.{1,32}/g) - ?.join("\n"); - if (hexDump !== secondHexDump) { - throw new Error("Generated binary files do not match"); - } - expect(hexDump).toBe(secondHexDump); - // await writeFile("test/testtest/dummyfile.test.bin", buf); - // await writeFile("test/testtest/dummyfile.test.bin.hexdump.txt", hexDump || ""); - }); - it("should generate text file correctly", async () => { - const size = 25000; - let generatedSize = 0; - let content = ""; - const generator = generateFile(size); - const out = [...generator]; - // const blob = new Blob(out, { type: "text/plain" }); - content = out.join(""); - - const secondDummy = generateFile(size); - const secondOut = [...secondDummy]; - const secondContent = secondOut.join(""); - if (content !== secondContent) { - throw new Error("Generated text files do not match"); - } - expect(content).toBe(secondContent); - // await writeFile("test/testtest/dummyfile.test.txt", await blob.text()); - }); -}); diff --git a/test/unit/dialog.test.ts b/test/unit/dialog.test.ts deleted file mode 100644 index 86c424cc..00000000 --- a/test/unit/dialog.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Dialog Unit Tests -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 { DummyFileSourceInisialised } from "../utils/dummyfile"; - -import { page } from "vitest/browser"; -import { DoctorRegulation } from "@/lib/src/common/configForDoc"; -import { waitForDialogHidden, waitForDialogShown } from "../lib/ui"; -const env = (import.meta as any).env; -const dialog_setting_base = { - ...DEFAULT_SETTINGS, - isConfigured: true, - handleFilenameCaseSensitive: false, - couchDB_URI: `${env.hostname}`, - couchDB_DBNAME: `${env.dbname}`, - couchDB_USER: `${env.username}`, - couchDB_PASSWORD: `${env.password}`, - bucket: `${env.bucketName}`, - region: "us-east-1", - endpoint: `${env.minioEndpoint}`, - accessKey: `${env.accessKey}`, - secretKey: `${env.secretKey}`, - useCustomRequestHandler: true, - forcePathStyle: true, - bucketPrefix: "", - usePluginSyncV2: true, - chunkSplitterVersion: ChunkAlgorithms.RabinKarp, - doctorProcessedVersion: DoctorRegulation.version, - notifyThresholdOfRemoteStorageSize: 800, -} as ObsidianLiveSyncSettings; - -function checkDialogVisibility(dialogText: string, shouldBeVisible: boolean): void { - const dialog = page.getByText(dialogText); - expect(dialog).toHaveClass(/modal-title/); - if (!shouldBeVisible) { - expect(dialog).not.toBeVisible(); - } else { - expect(dialog).toBeVisible(); - } - return; -} -function checkDialogShown(dialogText: string) { - checkDialogVisibility(dialogText, true); -} -function checkDialogHidden(dialogText: string) { - checkDialogVisibility(dialogText, false); -} - -describe("Dialog Tests", async () => { - // describe.each(cases)("Replication Tests - Remote: $setting.remoteType, E2EE: $setting.encrypt", ({ setting }) => { - const setting = dialog_setting_base; - beforeAll(async () => { - await DummyFileSourceInisialised; - await commands.grantClipboardPermissions(); - }); - let harness: LiveSyncHarness; - const vaultName = "TestVault" + Date.now(); - beforeAll(async () => { - harness = await generateHarness(vaultName, setting); - await waitForReady(harness); - expect(harness.plugin).toBeDefined(); - expect(harness.plugin.app).toBe(harness.app); - await waitForIdle(harness); - }); - it("should show copy to clipboard dialog and confirm", async () => { - const testString = "This is a test string to copy to clipboard."; - const title = "Copy Test"; - const result = harness.plugin.core.services.UI.promptCopyToClipboard(title, testString); - const isDialogShown = await waitForDialogShown(title, 500); - expect(isDialogShown).toBe(true); - const copyButton = page.getByText("📋"); - expect(copyButton).toBeDefined(); - expect(copyButton).toBeVisible(); - await copyButton.click(); - const copyResultButton = page.getByText("âœ”ī¸"); - expect(copyResultButton).toBeDefined(); - expect(copyResultButton).toBeVisible(); - const clipboardText = await navigator.clipboard.readText(); - expect(clipboardText).toBe(testString); - const okButton = page.getByText("OK"); - expect(okButton).toBeDefined(); - expect(okButton).toBeVisible(); - await okButton.click(); - const resultValue = await result; - expect(resultValue).toBe(true); - // Check that the dialog is closed - const isDialogHidden = await waitForDialogHidden(title, 500); - expect(isDialogHidden).toBe(true); - }); -}); diff --git a/test/utils/dummyfile.ts b/test/utils/dummyfile.ts deleted file mode 100644 index ab4b8b3f..00000000 --- a/test/utils/dummyfile.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { DEFAULT_SETTINGS } from "@/lib/src/common/types.ts"; -import { readFile } from "../utils/fileapi.vite.ts"; -let charset = ""; -export async function init() { - console.log("Initializing dummyfile utils..."); - - charset = (await readFile("test/utils/testcharvariants.txt")).toString(); - console.log(`Loaded charset of length ${charset.length}`); - console.log(charset); -} -export const DummyFileSourceInisialised = init(); -function* indexer(range: number = 1000, seed: number = 0): Generator { - let t = seed | 0; - while (true) { - t = (t + 0x6d2b79f5) | 0; - let z = t; - z = Math.imul(z ^ (z >>> 15), z | 1); - z ^= z + Math.imul(z ^ (z >>> 7), z | 61); - const float = ((z ^ (z >>> 14)) >>> 0) / 4294967296; - yield Math.floor(float * range); - } -} - -export function* generateFile(size: number): Generator { - const chunkSourceStr = charset; - const chunkStore = [...chunkSourceStr]; // To support indexing avoiding multi-byte issues - const bufSize = 1024; - let buf = ""; - let generated = 0; - const indexGen = indexer(chunkStore.length); - while (generated < size) { - const f = indexGen.next().value; - buf += chunkStore[f]; - generated += 1; - if (buf.length >= bufSize) { - yield buf; - buf = ""; - } - } - if (buf.length > 0) { - yield buf; - } -} -export function* generateBinaryFile(size: number): Generator> { - let generated = 0; - const pattern = Array.from({ length: 256 }, (_, i) => i); - const indexGen = indexer(pattern.length); - const bufSize = 1024; - const buf = new Uint8Array(bufSize); - let bufIdx = 0; - while (generated < size) { - const f = indexGen.next().value; - buf[bufIdx] = pattern[f]; - bufIdx += 1; - generated += 1; - if (bufIdx >= bufSize) { - yield buf; - bufIdx = 0; - } - } - if (bufIdx > 0) { - yield buf.subarray(0, bufIdx); - } -} - -// File size for markdown test files (10B to 1MB, roughly logarithmic scale) -export const FILE_SIZE_MD = [10, 100, 1000, 10000, 100000, 1000000]; -// File size for test files (10B to 40MB, roughly logarithmic scale) -export const FILE_SIZE_BINS = [ - 10, - 100, - 1000, - 50000, - 100000, - 5000000, - DEFAULT_SETTINGS.syncMaxSizeInMB * 1024 * 1024 + 1, -]; diff --git a/test/utils/fileapi.vite.ts b/test/utils/fileapi.vite.ts deleted file mode 100644 index e84f5bde..00000000 --- a/test/utils/fileapi.vite.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { server } from "vitest/browser"; -const { readFile, writeFile } = server.commands; -export { readFile, writeFile }; diff --git a/test/utils/testcharvariants.txt b/test/utils/testcharvariants.txt deleted file mode 100644 index b517a531..00000000 --- a/test/utils/testcharvariants.txt +++ /dev/null @@ -1,17 +0,0 @@ -åœ‹į ´åąąæ˛ŗåœ¨īŧŒåŸŽæ˜Ĩč‰æœ¨æˇąã€‚ -æ„Ÿæ™‚čŠąæŋ翎šīŧŒæ¨åˆĨéŗĨ驚åŋƒã€‚ -įƒŊįĢé€Ŗä¸‰æœˆīŧŒåŽļ書æŠĩčŦ金。 -į™Ŋé ­æ”æ›´įŸ­īŧŒæ¸žæŦ˛ä¸å‹į°Ē。 -ÂĢNel mezzo del cammin di nostra vita -mi ritrovai per una selva oscura, -chÊ la diritta via era smarrita.Âģ -Đ”ŅƒŅ…ĐžĐ˛ĐŊОК ĐļаĐļĐ´ĐžŅŽ Ņ‚ĐžĐŧиĐŧ, -В ĐŋŅƒŅŅ‚Ņ‹ĐŊĐĩ ĐŧŅ€Đ°Ņ‡ĐŊОК Ņ вĐģĐ°Ņ‡Đ¸ĐģŅŅ, — -И ҈ĐĩŅŅ‚Đ¸ĐēҀҋĐģŅ‹Đš ҁĐĩŅ€Đ°Ņ„Đ¸Đŧ -На ĐŋĐĩŅ€ĐĩĐŋŅƒŅ‚ŅŒĐĩ ĐŧĐŊĐĩ ŅĐ˛Đ¸ĐģŅŅ. -Shall I compare thee to a summer’s day? -Thou art more lovely and more temperate: -Rough winds do shake the darling buds of May, -And summer’s lease hath all too short a date: - -đŸ“œđŸ–‹ī¸ đŸē đŸ›ī¸ æ˜Ĩæœ›đ Žˇâ€ŒcheĖđŸ‡ˇđŸ‡ēАa‮RTLOđŸŗī¸â€đŸŒˆđŸ‘¨â€đŸ‘Šâ€đŸ‘§â€đŸ‘ĻlĘŧanatraīŊąīŊ˛īŊŗīŊ´īŊĩ \ No newline at end of file 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/update-workspaces.mjs b/update-workspaces.mjs index 36646123..a8a0f159 100644 --- a/update-workspaces.mjs +++ b/update-workspaces.mjs @@ -23,6 +23,23 @@ if (!workspaces || !Array.isArray(workspaces)) { process.exit(1); } +const packageLockPath = resolve('package-lock.json'); +let packageLock; +let packageLockChanged = false; +try { + packageLock = JSON.parse(readFileSync(packageLockPath, 'utf8')); + if (packageLock.version !== mainVersion) { + packageLock.version = mainVersion; + packageLockChanged = true; + } + if (packageLock.packages?.[''] && packageLock.packages[''].version !== mainVersion) { + packageLock.packages[''].version = mainVersion; + packageLockChanged = true; + } +} catch (error) { + console.warn('Could not update package-lock.json:', error.message); +} + // Collect all root dependencies for version matching. const rootDeps = { ...(rootPackage.dependencies || {}), @@ -68,10 +85,15 @@ for (const dir of workspaceDirs) { const workspaceName = basename(dir); const targetVersion = `${mainVersion}-${workspaceName}`; + const lockWorkspace = packageLock?.packages?.[dir.replaceAll('\\', '/')]; console.log(`Updating ${pkg.name || dir}:`); console.log(` Version: ${pkg.version} -> ${targetVersion}`); pkg.version = targetVersion; + if (lockWorkspace && lockWorkspace.version !== targetVersion) { + lockWorkspace.version = targetVersion; + packageLockChanged = true; + } // Synchronise dependencies. if (pkg.dependencies) { @@ -83,6 +105,13 @@ for (const dir of workspaceDirs) { console.log(` Dependency '${dep}': ${oldVer} -> ${newVer}`); pkg.dependencies[dep] = newVer; } + if (lockWorkspace) { + lockWorkspace.dependencies ??= {}; + if (lockWorkspace.dependencies[dep] !== newVer) { + lockWorkspace.dependencies[dep] = newVer; + packageLockChanged = true; + } + } } } } @@ -97,6 +126,13 @@ for (const dir of workspaceDirs) { console.log(` DevDependency '${dep}': ${oldVer} -> ${newVer}`); pkg.devDependencies[dep] = newVer; } + if (lockWorkspace) { + lockWorkspace.devDependencies ??= {}; + if (lockWorkspace.devDependencies[dep] !== newVer) { + lockWorkspace.devDependencies[dep] = newVer; + packageLockChanged = true; + } + } } } } @@ -109,3 +145,7 @@ for (const dir of workspaceDirs) { console.error(` Failed to write ${pkgJsonPath}:`, error); } } + +if (packageLockChanged) { + writeFileSync(packageLockPath, JSON.stringify(packageLock, null, 4) + '\n', 'utf8'); +} diff --git a/updates.md b/updates.md index 94167ae6..078e03fc 100644 --- a/updates.md +++ b/updates.md @@ -1,116 +1,110 @@ -# 0.25 -Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) +# 1.0 -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. +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. -## 0.25.80 +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. -7th July, 2026 +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. -### Fixed +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. -- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993). - - Behaviour change: - - When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result. - - When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side. -- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994). - - Behaviour change: - - Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text. -- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992). - - Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled. +Earlier releases remain available in the 0.25 release history and the legacy release history. -## 0.25.79 +## Unreleased -29th June, 2026 +## 1.0.0 -### Fixed +27th July, 2026 -- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix! -- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix! -- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix! -- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix! +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). -### Improved +### Setup and compatibility -- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement! -- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62). +#### Improved -### Documentation +- 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. +- 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. -- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement! +#### Fixed -### Miscellaneous +- Existing Vaults retain their effective legacy settings, including the case-insensitive file-name fallback used when an older release had no explicit case setting. -- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules. +#### Security -## 0.25.78 +- 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. -23rd June, 2026 +### Conflict handling and recovery -### Fixed -- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix! +#### Improved -### Improved +- **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. -- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition'). - - It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults. - - Decoupled the physical storage logic to make it easier to add new storage backends in the future. - - Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage. +#### Fixed -### Testing - - Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation. - - Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables. +- 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. -## 0.25.77 +### P2P and optional synchronisation features -19th June, 2026 +#### Improved -This update is mostly meaningless for users. But for maintainers, not, I hope. I wonder if I were done well in the start, there would be no hassles. It really was a great opportunity. +- 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. +- 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. -Also, this update is a very large one, even if we had a lot of time, and we had CI tests, and mostly only fixing the types. Please let me know if you find any issues! +#### Fixed -### Improved +- 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. -- File deletion now respects the user's deletion preferences (by utilising the `FileManager.trashFile` API) on Obsidian v1.7.2 or newer, regardless of the plug-in's internal trashbin setting. +### Interface, translation, and operations -### Miscellaneous -- Typings of the library are now included -- Many typing errors have been improved. -- Import paths have been normalised to be relative to the root and to the `lib/src` directory, to avoid breaking the boundary between the library and the plug-in. -- Subprojects, such as the CLI and the webapp, are now in the workspace. +#### Improved -## 0.25.76 +- 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. +- 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. +- 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. -15th June, 2026 +#### Fixed -### Fixed +- 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. -- Now the S3 connection with custom headers works properly (#875). - - Previously, custom headers injected for proxy authentication were incorrectly included in the AWS Signature v4 calculation. This led to a '400 Bad Request' error (such as 'signed header is not present') on strict S3 backends (for example, Garage), or when reverse proxies modified, renamed, or stripped these headers before they reached the storage service. -- No longer connection information of the P2P synchronisation is broken on the specific platform (#956). +### Storage and file selection -## 0.25.75 +#### Fixed -13th June, 2026 +- 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. -### Fixed +### Command-line tool -- Fixed an issue where using fast synchronisation caused a TypeError in some environments (#953). +#### Fixed -### New features -- Now we can configure to keep replication active in the background on desktop platforms (#939, PR #949). Thank you so much for @migsferro! +- 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. -### Fixed (CLI, automated) +#### Security -- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled. +- The CLI rejects detected path traversal and symbolic-link components before Vault operations. -### Improved +### Validation -- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation. +#### Testing -### Miscellaneous -- Some dependencies have been updated. -- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies. - -Full notes are in -[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). +- 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 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 ad706bf8..6d6e4ee6 100644 --- a/updates_old.md +++ b/updates_old.md @@ -1,3553 +1,10 @@ -# 0.25 -Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) +# Release history has moved -The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope. +The 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) -## 0.25.80 - -7th July, 2026 - -### Fixed - -- Improved Markdown conflict auto-merge so that non-overlapping edits are merged while overlapping delete-and-edit cases remain visible for manual resolution (#993). - - Behaviour change: - - When one side deletes an unchanged line and the other side edits a different region, the deleted line is no longer reintroduced into the merged result. - - When one side deletes a line and the other side modifies that same line, the conflict is preserved instead of silently choosing one side. -- Fixed an issue where applying a newer database entry to storage could incorrectly preserve an older local file as a conflict (#994). - - Behaviour change: - - Local storage is preserved as a conflict when it may contain unsynchronised changes that are not represented in the revision history. A newer incoming text entry is applied without creating a conflict only when it clearly extends the existing local text. -- Fixed an issue where choosing Disable and then Overwrite in Hidden File Sync could silently skip hidden files, because the overwrite setup ran while hidden file synchronisation was still disabled (#989, PR #992). - - Hidden File Sync is now re-enabled before the Fetch, Overwrite, or Merge initialisation runs, instead of after it completes. If that initialisation fails, the setting may remain enabled. - -## 0.25.79 - -29th June, 2026 - -### Fixed - -- Fast Fetch now retries transient stream interruptions and resumes from the latest persisted checkpoint, instead of starting over after ordinary network or platform interruptions (#977, PR #978; commonlib PR #59). Thank you so much for @apple-ouyang for the fix! -- Simple Fetch now remembers the selected setup choices while an interrupted Fetch All operation is still pending, so users are not asked the same questions again on retry (#977, PR #978). Thank you so much for @apple-ouyang for the fix! -- No longer hidden storage events, such as `.git` paths, reach the normal target-file filter when internal file synchronisation is disabled. This avoids noisy non-target logs before those files are skipped (commonlib PR #60). Thank you so much for @apple-ouyang for the fix! -- Fixed an issue where a file deleted from storage could be resurrected by the offline scanner because the database tombstone was not written when the storage file was already gone (commonlib PR #56). Thank you so much for @cosmic-fire-eng for the fix! - -### Improved - -- Local database maintenance commands now ask before applying the required chunk settings, and can apply those prerequisites before continuing (#980, PR #981). Thank you so much for @apple-ouyang for the improvement! -- Improved CouchDB replication event handling by using the new `StreamInbox` helper from `octagonal-wheels` (commonlib PR #62). - -### Documentation - -- Added `nginx` to the setup documentation table of contents (PR #976). Thank you so much for @kiraventom for the improvement! - -### Miscellaneous - -- Updated `octagonal-wheels` to `0.1.47` across the plug-in and workspace packages to use the newly published helper modules. - -## 0.25.78 - -23rd June, 2026 - -### Fixed -- No longer fast synchronisation (a.k.a. Fast Fetch) causes a rewind and re-fetch of the entire database when some errors occur during the process (#972, PR #973). Thank you so much for @apple-ouyang for the fix! - -### Improved - -- Overhauled the Object Storage (e.g., MinIO and S3) replication engine ('Journal Replicator 2nd Edition'). - - It now leverages the standard Web Streams API for a resilient, backpressure-aware architecture, reducing memory footprints/temporary storage usage on large vaults. - - Decoupled the physical storage logic to make it easier to add new storage backends in the future. - - Stricter compliance with CouchDB's replication protocol (proper `_revisions` transfers with `new_edits: false`) when using Object Storage. - -### Testing - - Added comprehensive unit tests for the new `JournalSyncCore` engine, covering streams, backpressure, and `new_edits: false` validation. - - Improved integration test workflows in the CI pipeline to run MinIO tests automatically using standard environment variables. - -## 0.25.77 - -19th June, 2026 - -This update is mostly meaningless for users. But for maintainers, not, I hope. I wonder if I were done well in the start, there would be no hassles. It really was a great opportunity. - -Also, this update is a very large one, even if we had a lot of time, and we had CI tests, and mostly only fixing the types. Please let me know if you find any issues! - -### Improved - -- File deletion now respects the user's deletion preferences (by utilising the `FileManager.trashFile` API) on Obsidian v1.7.2 or newer, regardless of the plug-in's internal trashbin setting. - -### Miscellaneous -- Typings of the library are now included -- Many typing errors have been improved. -- Import paths have been normalised to be relative to the root and to the `lib/src` directory, to avoid breaking the boundary between the library and the plug-in. -- Subprojects, such as the CLI and the webapp, are now in the workspace. - -## 0.25.76 - -15th June, 2026 - -### Fixed - -- Now the S3 connection with custom headers works properly (#875). - - Previously, custom headers injected for proxy authentication were incorrectly included in the AWS Signature v4 calculation. This led to a '400 Bad Request' error (such as 'signed header is not present') on strict S3 backends (for example, Garage), or when reverse proxies modified, renamed, or stripped these headers before they reached the storage service. -- No longer connection information of the P2P synchronisation is broken on the specific platform (#956). - -## 0.25.75 - -13th June, 2026 - -### Fixed - -- Fixed an issue where using fast synchronisation caused a TypeError in some environments (#953). - -### New features -- Now we can configure to keep replication active in the background on desktop platforms (#939, PR #949). Thank you so much for @migsferro! - -### Fixed (CLI, automated) - -- Fixed an issue where the mirror command could fail to apply updates when conflict preservation checks prevented overwriting unsynchronised local changes, even when the `force` parameter or `writeDocumentsIfConflicted` setting was enabled. - -### Improved - -- (CLI) Ported the remaining bash regression tests (`test-daemon-linux.sh`, `test-decoupled-vault-linux.sh`, and `test-remote-commands-linux.sh`) to Deno for cross-platform validation. - -### Miscellaneous -- Some dependencies have been updated. -- Now we check the compatibility with iOS 15 in the CI tests to ensure the plugin continues to work on older iOS versions even after we upgrade some dependencies. - -## 0.25.74 - -8th June, 2026 - -### Fixed - -- Fixed an issue where disabling hidden file synchronisation did not take effect, allowing non-target hidden files to continue to be processed and synchronised by replication or boot-sequence scan (#941). -- Prevented the automatic merging of conflicted revisions when one of the revisions has been deleted, which was causing deleted files to reappear (#911). -- The startup sequence now saves the state more effectively (Thank you so much for @bmcyver)! - -## Only CLI - -8th June, 2026 - -I should also consider the version numbering for the CLI... - -### Improved - -- Added new remote database management commands: `remote-status`, `unlock-remote`, `lock-remote`, and `mark-resolved`. -- --vault option is now available for daemon and mirror commands! (Thank you so much for @starskyzheng)! -- Decoupled the database directory path from the actual vault directory path using the `--vault` (or `-V`) option. - -### Fixed (preventive) - -- Validated that the specified vault path exists and is indeed a directory before starting the CLI. -- Integrated path resolution and validations for one-off commands (such as `'push'`, `'pull'`, `'cat'`, `'rm'`, `'info'`, and `'resolve'`) against the decoupled vault path instead of the database path. - -## 0.25.73 - -4th June, 2026 - -### Fixed - -- Adjust CouchDB's database name checking to its specification (#926). -- `Reset Syncronisation on This Device` for minio and P2P is now working properly. - -## ~~0.25.71~~ 0.25.72 - -0.25.71 was cancelled due to the fixes needed (Object Storage related) - -3rd June, 2026 - -### Improved - -- Database fetching (a.k.a. Reset Synchronisation on This Device) on the initialisation now supports streaming and is faster (CouchDB only) -- The database fetching process has been streamlined, and database operations are now suspended until it has been completed -- The initial synchronisation process has been simplified, making it easier to synchronise files with the remote server -- We can select the remote database to fetch from during the initialisation, when there are multiple remote databases configured (e.g. multiple CouchDBs or S3 remotes) -- Hebrew (he) Translation has been added (Thank you so much, @MusiCode1)! -- Translation loading time has been reduced (Thank you so much, @bmcyver)! - -### Fixed - -- No longer does the status element break other plugins' interaction (#930). -- No longer does file events occured during initial database fetching using Object Storage. - -### Refactored - -To support the new Community automated tests, we fixed numerous lint warnings. This may have also resolved potential issues. - -## 0.25.70 - -25th May, 2026 - -### New features -- Diff dialogue now has great tools to navigate and understand the differences, including: - - A checkbox to toggle the visibility of collapsed identical sections, making it easier to focus on the actual differences (PR #889). - - A search feature to find specific text in past revisions, and navigate revisions with search results highlighted in the dialogue (PR #890). - -- Conflict resolution dialogue now has a navigation feature to jump between conflicts (PR #891). - -Thank you so much to @SeleiXi for implementing these features! - -### Improved - -- More diagnostic information for P2P connections is now shown, including why a connection failure occurred and the current connection status. - -## 0.25.69 - -22nd May, 2026 - -### Fixed -- No longer does the P2P passphrase mismatch cause a server shutdown. -- Settings related to P2P synchronisation are now correctly applied on start-up and no longer reverted. - -### New features -- Diagnostic P2P connection stats are now available. - - These stats indicate the number of connection trials, successes, and failures. - -## 0.25.68 - -22nd May, 2026 - -### Improved - -- P2P connections have improved slightly - - Upgrade to `trystero` v0.24.0, and fixes event handler assignment. This should fix some edge cases where P2P connections fail to establish or messages are not properly handled. - - Weaken terser options to avoid potential issues with minification that could cause runtime errors in some environments. - -## ~~0.25.66~~ 0.25.67 - -20th May, 2026 - -0.25.66 had a bug that the auto-accept logic for compatible but lossy mismatches was not working as intended. - -### New features -- Implement an auto-accept compatible tweak setting and enhance the mismatch resolution logic. - -### Improved -- Many messages related to tweak mismatch resolution have been updated for clarity. - -## 0.25.65 - -19th May, 2026 - -### Fixed -- Fix an issue about resuming from background on iOS (#888). -- Now Chunk Splitter: `V3: Fine Deduplication` is working fine again (#866). - - It has some drawbacks, such as fewer chunks are generated. However, it makes less transfer and storage when the files are modified but not completely changed. -- Unsynchronised local changes (which means changes that have not been sent) are now correctly preserved as a conflict (Thank you so much for @SeleiXi!). -- Avoid creating a new revision when the current and conflicted revisions have identical content (Thank you so much for @daichi-629). - -### Improved -- Improved the error verbosity on concurrent processing during the start-up process. -- Now the `report` includes recent logs (of verbosity `verbose` even settings is not set to `verbose`). -- Updating logs is now debounced to avoid excessive updates during rapid log generation. -- Added a `Generate full report for opening the issue with debug info` command to the command palette, which generates a report without opening the settings dialogue. - -## 0.25.64 - -17th May, 2026 - -### P2P Status Pane - -- Added active P2P remote selector (combo box) and `+` action to create/select a P2P remote from the P2P setup dialogue. -- Added per-peer immediate replication action on accepted peers. -- Updated status control icons for clarity: - - Replicate now: `🔄` (`âŗ` while running) - - Watch: `🔔` / `🔕` - - Sync target: `🔗` / `â›“ī¸â€đŸ’Ĩ` -- Added warning state when no active P2P remote is selected. - -### P2P Status Card - -- Added stable Room ID suffix display and placed it above Peer ID for better identification. - -### Non behavioural internal changes - -#### P2P - -- Added `P2P_ActiveRemoteConfigurationId` as a dedicated active remote selection for P2P features, separate from the normal active remote. -- Added activation logic for P2P dedicated remote configuration that reflects P2P settings while keeping `remoteType` unchanged. -- Added migration support to carry over P2P active remote selection when appropriate. -- Added shared Room ID utility functions and applied them across P2P setup and P2P panes. - -#### Tests - -- Added/updated unit test coverage around settings load behaviour for P2P active remote application. - -## 0.25.63 - -17th May, 2026 - -### Fixed -- The issue which cannot synchronise in Only-P2P mode has been fixed. -- Fixed an issue where "Failed to connect to the remote server" was shown during the redFlag rebuild flow when P2P was the primary remote type. Remote configuration fetch is now skipped for P2P. - -### P2P Replication UI Improvements -- Brand-new P2P Server Status pane has been added to provide real-time visibility into your connection status and peer network. - - For detailed instructions on using the new P2P features, please refer to the updated [User Guide: Peer-to-Peer Synchronisation (2026 Edition)](./docs/p2p_sync_updates_2026.md). -- Now `Replicate` button or ribbon icon opens a redesigned interactive replication dialogue that performs smart bidirectional sync with a single click. -- The vault rebuild flow (`replicateAllFromServer`) now opens the redesigned P2P Replication modal instead of a plain text selection dialogue, providing a consistent UI experience. - -## 0.25.62 - -14th May, 2026 - -### Fixed - -- Fixed an issue where a connection could not be established when attempting to connect to a brand-new remote database without going through the set-up wizard or configuration checking (#660). - -## 0.25.61 - -13th May, 2026 - -Reviews have started on the Obsidian Community, haven't they? It was quite a struggle, what with having to fix the outdated ESLint. -I am a bit nervous, but it is far better than just plodding along aimlessly, so let us get on with it. If you spot any issues, please let me know straight away. - -From now on, I am avoiding committing directly to the main branch. This is because you lots have all been sending so much PRs. I wanted to keep things harmonious. -That said, I am still not used to rebasing, so there are some parts where the commit history is a right mess. I will work on improving that. - -### Improved - -- P2P synchronisation has been made more robust - Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation. -This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated. - -### Fixed - -- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation. -- Network error notice pop-ups are now suppressed when 'NetworkWarningStyle' is set to 'Hidden'. (Thank you so much @SeleiXi!) - -### New features - -- Diff navigation buttons have been added to the diff view, making it easier to move between differences. (Thank you so much @SeleiXi! #871) - -### Translations - -- Chinese (Simplified) translations for settings and the Setup Wizard have been added. (Thank you so much @zombiek731!) -- Common UI controls and signal words are now localised into Chinese (Simplified). (Thank you so much @zombiek731!) -- i18n runtime behaviour and locale coverage have been improved. (Thank you so much @52sanmao!) - -### CLI - -#### New features - -- Daemon synchronisation is now supported. (Thank you so much @andrewleech! #843) -- `HeadlessConfirm` has been implemented with sensible defaults, enabling unattended operation in headless environments. (Thank you so much @andrewleech!) -- The CLI onboarding experience has been improved. (Thank you so much @OriBoharon! #872) - -#### Fixed - -- Sub-millisecond CLI mtimes are now truncated to prevent mobile crash. (Thank you so much @brian-spackman! #893) - -## 0.25.60 - -29th April, 2026 - -### Fixed - -- Now larger settings can be exported and imported via QR code without issues. (#595) - - When the settings data exceeds the QR code capacity, it is now split into multiple QR codes. - - These QR codes are reassembled by the aggregator page, which collects the split data and reconstructs the original settings. - - Aggregator page is available at `https://vrtmrz.github.io/obsidian-livesync/aggregator.html`, and this file is also included in the repository. - - We will not send the settings data to any server. The QR code data is generated and processed entirely on the client side, ensuring that your settings remain private and secure. HOWEVER, please be careful your network environment. -- Fixed some errors during serialisation and deserialisation of the settings, which caused issues in some cases when importing/exporting settings via QR code. - -### Fixed (CLI) - -- `ls` and `mirror` commands now provide informative feedback when no documents are found or filters skip all files, resolving the issue where they would exit silently (#860). - - Improved the clarity of CLI command logs by including the total count of processed items. -- The command-line argument `vault` has been renamed to a more appropriate name, `databaseDir`. -- The `mirror` command now accepts a `vault` directory, which specifies the location where the actual files are stored. For compatibility reasons, the previous behaviour is still supported. - -## 0.25.59 - -### Fixed - -- No longer Setup-wizard drops username and password silently. (#865) - - Thank you so much for @koteitan ! -- Setup URI is now correctly imported (#859). - - Also thank you so much for @koteitan ! - -### Improved - -- now French translation is added by @foXaCe ! Thank you so much! - -## 0.25.58 - -### Fixed - -- No longer credentials are broken during object storage configuration (related: #852). -- Fixed a worker-side recursion issue that could raise `Maximum call stack size exceeded` during chunk splitting (related: #855). -- Improved background worker crash cleanup so pending split/encryption tasks are released cleanly instead of being left in a waiting state (related: #855). -- On start-up, the selected remote configuration is now applied to runtime connection fields as well, reducing intermittent authentication failures caused by stale runtime settings (related: #855). -- Issue report generation now redacts `remoteConfigurations` connection strings and keeps only the scheme (e.g. `sls+https://`), so credentials are not exposed in reports. -- Hidden file JSON conflicts no longer keep re-opening and dismissing the merge dialogue before we can act, which fixes persistent unresolvable `data.json` conflicts in plug-in settings sync (related: #850). - -## 0.25.57 - -9th April, 2026 - -- Packing a batch during the journal sync now continues even if the batch contains no items to upload. -- No unexpected error (about a replicator) during the early stage of initialisation. -- Now error messages are kept hidden if the show status inside the editor is disabled (related: #829). -- Fixed an issue where devices could no longer upload after another device performed 'Fresh Start Wipe' and 'Overwrite remote' in Object Storage mode (#848). - - Each device's local deduplication caches (`knownIDs`, `sentIDs`, `receivedFiles`, `sentFiles`) now track the remote journal epoch (derived from the encryption parameters stored on the remote). - - When the epoch changes, the plugin verifies whether the device's last uploaded file still exists on the remote. If the file is gone, it confirms a remote wipe and automatically clears the stale caches. If the file is still present (e.g. a protocol upgrade without a wipe), the caches are preserved, and only the epoch is updated. This means normal upgrades never cause unnecessary re-processing. - -### Translations - -- Russian translation has been added! Thank you so much for the contribution, @vipka1n! (#845) - -### New features - -- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes. - - A user interface for managing multiple remote databases has been added to the settings dialogue. I think no explanation is needed for the UI, but please let me know if you have any questions. -- We can switch between multiple Remote Databases in the settings dialogue. - -### CLI - -#### Fixed - -- Replication progress is now correctly saved and restored in the CLI (related: #846). - -## ~~0.25.55~~ 0.25.56 - -30th March, 2026 - -### Fixed - -- No longer `Peer-to-Peer Sync is not enabled. We cannot open a new connection.` error occurs when we have not enabled P2P sync and are not expected to use it (#830). - -### CLI - -- Fixed incomplete localStorage support in the CLI (#831). Thank you so much @rewse ! -- Fixed the issue where the CLI could not be connected to the remote which had been locked once (#833), also thanks to @rewse ! - -## 0.25.54 - -18th March, 2026 - -### Fixed - -- Remote storage size check now works correctly again (#818). -- Some buttons on the settings dialogue now respond correctly again (#827). - -### Refactored - -- P2P replicator has been refactored to be a little more robust and easier to understand. -- Delete items which are no longer used that might cause potential problems - -### CLI - -- Fixed the corrupted display of the help message. -- Remove some unnecessary code. - -### WebApp - -- Fixed the issue where the detail level was not being applied in the log pane. -- Pop-ups are now shown. -- Add coverage for the test. -- Pop-ups are now shown in the web app as well. - -## 0.25.53 - -17th March, 2026 - -I did wonder whether I should have released a minor version update, but when I actually tested it, compatibility seemed to be intact, so I didn’t. Hmm. - -### Fixed - -#### P2P Synchronisation - -- Fixed flaky timing issues in P2P synchronisation. -- No longer unexpected `Unhandled Rejections` during P2P operations (waiting for acceptance). - -#### Journal Sync - -- Fixed an issue where some conflicts cannot be resolved in Journal Sync. -- Many minor fixes have been made for better stability and reliability. - -### Tests - -- Rewrite P2P end-to-end tests to use the CLI as a host. - -### CLI - -We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details. -- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version. -- P2P sync and Object Storage are also supported in the CLI. - - Yes, we have finally managed to 'get one file'. - - Also, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can do it. - -- Now binary files are also supported in the CLI. - -### Refactored or internal changes - -- ServiceFileAccessBase now correctly handles the reading of binary files. -- HeadlessAPIService now correctly provides the online status (always online) to the plug-in. -- Non-worker version of bgWorker now correctly handles some functions. -- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. -- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. -- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. -- Offline change scanner and the local database preparation have been separated. -- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks. -- ControlService now provides the readiness for processing operations. -- DatabaseService is now able to modify database opening options on derived classes. -- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`. -- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. -- Journal Replicator now yields true after the replication is done. - -### R&D - -- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. -- We can see the code in `src/apps/webapp` for the browser version. - - -## 0.25.52-patched-3 - -16th March, 2026 - -### Fixed - -- Fixed flaky timing issues in P2P synchronisation. -- Fixed more binary file handling issues in CLI. - -### Tests - -- Rewrite P2P end-to-end tests to use the CLI as host. - - -## 0.25.52-patched-2 - -14th March, 2026 - -### Fixed - -- No longer unexpected `Unhandled Rejections` during P2P operations (waiting acceptance). -- Fixed an issue where conflicts cannot be resolved in Journal Sync - -### CLI new features - -- `mirror` command has been added to the CLI. This command is intended to mirror the storage to the local database. -- `p2p-sync`, `p2p-peers`, and `p2p-host` commands have been added to the CLI. These commands are intended for P2P synchronisation. - - Yes, no more need for a [LiveSync PeerServer](https://github.com/vrtmrz/livesync-serverpeer) for virtual environments! The CLI can handle it by itself. - -## 0.25.52-patched-1 - -12th March, 2026 - -### Fixed - -- Fixed Journal Sync had not been working on some timing, due to a compatibility issue (for a long time). -- ServiceFileAccessBase now correctly handles the reading of binary files. -- HeadlessAPIService now correctly provides the online status (always online) to the plug-in. -- Non-worker version of bgWorker now correctly handles some functions. - -### Refactored - -- Separated `ObsidianLiveSyncPlugin` into `ObsidianLiveSyncPlugin` and `LiveSyncBaseCore`. -- Now `LiveSyncCore` indicates the type specified version of `LiveSyncBaseCore`. -- Referencing `plugin.xxx` has been rewritten to referencing the corresponding service or `core.xxx`. -- Offline change scanner and the local database preparation have been separated. -- Set default priority for processFileEvent and processSynchroniseResult for the place to add hooks. -- ControlService now provides the readiness for processing operations. -- DatabaseService is now able to modify database opening options on derived classes. -- Now `useOfflineScanner`, `useCheckRemoteSize`, and `useRedFlagFeatures` are set from `main.ts`, instead of `LiveSyncBaseCore`. - -### Internal API changes - -- Storage Access APIs are now yielding Promises. This is to allow more limited storage platforms to be supported. -- Journal Replicator now yields true after the replication is done. - -### CLI - -We have previously developed FileSystem LiveSync and various other components in a separate repository, but updates have been significantly delayed, and we have been plagued by compatibility issues. Now, a CLI tool using the same core logic is emerging. This does not directly manipulate the file system, but it offers a more convenient way of working and can also communicate with Object Storage. We can also resolve conflicts. Please refer to the code in `src/apps/cli` for the [self-hosted-livesync-cli](./src/apps/cli/README.md) for more details. - -- Add `self-hosted-livesync-cli` to `src/apps/cli` as a headless and dedicated version. -- Add more tests. -- Object Storage support has also been confirmed (and fixed) in CLI. - - Yes, we have finally managed to 'get one file'. -- Now binary files are also supported in the CLI. - -### R&D - -- Browser-version of Self-hosted LiveSync is now in development. This is not intended for public use now, but I will eventually make it available for testing. -- We can see the code in `src/apps/webapp` for the browser version. - - -## 0.25.52 - -9th March, 2026 - -Excuses: Too much `I`. -Whilst I had a fever, I could not figure it out at all, but once I felt better, I spotted the problem in about thirty seconds. I apologise for causing you concern. I am grateful for your patience. -I would like to devise a mechanism for running simple test scenarios. Now that we have got the Obsidian CLI up and running, it seems the perfect opportunity. - -To improve the bus factor, we really need to organise the source code more thoroughly. Your cooperation and contributions would be greatly appreciated. - -### Fixed - -- No longer unexpected deletion-propagation occurs when the parent directory is not empty (#813). - -### Revert reversions - -- Reverted the reversion of ModuleCheckRemoteSize. Now it is back to the service feature. - -## 0.25.51 - -7th March, 2026 - -### Reverted - -- Reverted to ModuleRedFlag and ModuleInitializerFile to the previous version because of some unexpected issues. (#813) - - I will re-implement them in the future with better design and tests. - -## 0.25.50 - -3rd March, 2026 - -Note: 0.25.49 has been skipped because of too verbose logging (credentials are logged in verbose level, but I realised that could lead to unexpected exposure on issue reporting). Please bump to 0.25.50 to get the fix if you are on 0.25.49. (No expected behaviour changes except the logging). - -### Fixed - -- No longer deleted files are not clickable in the Global History pane. -- Diff view now uses more specific classes (#803). -- A message of configuration mismatching slightly added for better understanding. - - Now it says `When replication is initiated manually via the command palette or ribbon, a dialogue box will open to address this.` to make it clear that the user can fix the issue by themselves. - -### Refactored - -- `ModuleRedFlag` has been refactored to `serviceFeatures/redFlag` and also tested. -- `ModuleInitializerFile` has been refactored to `lib/serviceFeatures/offlineScanner` and also tested. - -## 0.25.48 - -2nd March, 2026 - -No behavioural changes except unidentified faults. Please report if you find any unexpected behaviour after this update. - -### Refactored - -- Many storage-related functions have been refactored for better maintainability and testability. - - Now all platform-specific logics are supplied as adapters, and the core logic has become platform-agnostic. - - Quite a number of tests have been added for the core logic, and the platform-specific logics are also tested with mocked adapters. - -## 0.25.47 - -27th February, 2026 - -Phew, the financial year is still not over yet, but I have got some time to work on the plug-in again! - -### Fixed and refactored - -- Fixed the inexplicable behaviour when retrieving chunks from the network. - - The chunk manager has been layered to be responsible for its own areas and duties. e.g., `DatabaseWriteLayer`, `DatabaseReadLayer`, `NetworkLayer`, `CacheLayer`, and `ArrivalWaitLayer`. - - All layers have been tested now! - - `LayeredChunkManager` has been implemented to manage these layers. Also tested. - - `EntryManager` has been mostly rewritten and also tested. - -- Now we can configure `Never warn` for remote storage size notification again. - -### Tests - -- The following test has been added: - - `ConflictManager`. - -## 0.25.46 - -26th February, 2026 - -### Fixed - -- Unexpected errors no longer occurred when the plug-in was unloaded. -- Hidden File Sync now respects selectors. -- Registering protocol-handlers now works safely without causing unexpected errors. - -### Refactored - -- `ModuleCheckRemoteSize` has been ported to a serviceFeature, and tests have also been added. -- Some unnecessary things have been removed. -- LiveSyncManagers has now explicit dependencies. -- LiveSyncLocalDB is now responsible for LiveSyncManagers, not accepting the managers as dependencies. - - This is to avoid circular dependencies and clarify the ownership of the managers. -- ChangeManager has been refactored. This had a potential issue, so something had been fixed, possibly. -- Some tests have been ported from Deno's test runner to Vitest to accumulate coverage. - -## 0.25.45 - -25th February, 2026 - -As a result of recent refactoring, we are able to write tests more easily now! - -### Refactored - -- `ModuleTargetFilter`, which was responsible for checking if a file is a target file, has been ported to a serviceFeature. - - And also tests have been added. The middleware-style-power. -- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`. -- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`. - -## 0.25.44 - -24th February, 2026 - -This release represents a significant architectural overhaul of the plug-in, focusing on modularity, testability, and stability. While many changes are internal, they pave the way for more robust features and easier maintenance. -However, as this update is very substantial, please do feel free to let me know if you encounter any issues. - -### Fixed - -- Ignore files (e.g., `.ignore`) are now handled efficiently. -- Replication & Database: - - Replication statistics are now correctly reset after switching replicators. -- Fixed `File already exists` for .md files has been merged (PR #802) So thanks @waspeer for the contribution! - -### Improved - -- Now we can configure network-error banners as icons, or hide them completely with the new `Network Warning Style` setting in the `General` pane of the settings dialogue. (#770, PR #804) - - Thanks so much to @A-wry! - -### Refactored - -#### Architectural Overhaul: - -- A major transition from Class-based Modules to a Service/Middleware architecture has begun. - - Many modules (for example, `ModulePouchDB`, `ModuleLocalDatabaseObsidian`, `ModuleKeyValueDB`) have been removed or integrated into specific Services (`database`, `keyValueDB`, etc.). - - Reduced reliance on dynamic binding and inverted dependencies; dependencies are now explicit. - - `ObsidianLiveSyncPlugin` properties (`replicator`, `localDatabase`, `storageAccess`, etc.) have been moved to their respective services for better separation of concerns. - - In this refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding. - - They will be implemented directly in the service. - - However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted. -- Lifecycle: - - Application LifeCycle now starts in `Main` rather than `ServiceHub` or `ObsidianMenuModule`, ensuring smoother startup coordination. - -#### New Services & Utilities: - -- Added a `control` service to orchestrate other services (for example, handling stop/start logic during settings realisation). -- Added `UnresolvedErrorManager` to handle and display unresolved errors in a unified way. -- Added `logUtils` to unify logging injection and formatting. -- `VaultService.isTargetFile` now uses multiple, distinct checkers for better extensibility. - -#### Code Separation: - -- Separated Obsidian-specific logic from base logic for `StorageEventManager` and `FileAccess` modules. -- Moved reactive state values and statistics from the main plug-in instance to the services responsible for them. - -#### Internal Cleanups: - -- Many functions have been renamed for clarity (for example, `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`). -- Added `override` keywords to overridden items and removed dynamic binding for clearer code inheritance. -- Moved common functions to the common library. - -#### Dependencies: - -- Bumped dependencies simply to a point where they can be considered problem-free (by human-powered-artefacts-diff). - - Svelte, terser, and more something will be bumped later. They have a significant impact on the diff and paint it totally. - - You may be surprised, but when I bump the library, I am actually checking for any unintended code. - -## 0.25.43-patched-9 a.k.a. 0.25.44-rc1 - -We are finally ready for release. I think I will go ahead and release it after using it for a few days. - -### Fixed - -- Hidden file synchronisation now works! -- Now Hidden file synchronisation respects `.ignore` files. -- Replicator initialisation during rebuilding now works correctly. - -### Refactored - -- Some methods naming have been changed for better clarity, i.e., `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`. - -### Follow-up tasks memo (After 0.25.44) - -Going forward, functionality that does not span multiple events is expected to be implemented as middleware-style functions rather than modules based on classes. - -Consequently, the existing modules will likely be gradually dismantled. -For reference, `ModuleReplicator.ts` has extracted several functionalities as functions. - -However, this does not negate object-oriented design. Where lifecycles and state are present, and the Liskov Substitution Principle can be upheld, we design using classes. After all, a visible state is preferable to a hidden state. In other words, the handler still accepts both functions and member methods, so formally there is no change. - -As undertaking this for everything would be a bit longer task, I intend to release it at this stage. - -Note: I left using `setHandler`s that as a mark of `need to be refactored`. Basically, they should be implemented in the service itself. That is because it is just a mis-designed, separated implementation. - -## 0.25.43-patched-8 - -I really must thank you all. You know that it seems we have just a little more to do. -Note: This version is not fully tested yet. Be careful to use this. Very dogfood-y one. - -### Fixed - -- Now the device name is saved correctly. - -### Refactored - -- Add `override` keyword to all overridden items. -- More dynamic binding has been removed. -- The number of inverted dependencies has decreased much more. -- Some check-logic; i.e., like pre-replication check is now separated into check functions and added to the service as handlers, layered. - - This may help with better testing and better maintainability. - - -## 0.25.43-patched-7 - -19th February, 2026 - -Right then, let us make a decision already. - -Last time, since I found a bug, I ended up doing a few other things as well, but next time I intend to release it with just the bug fix. It is quite substantial, after all. - -Customisation Sync has mostly been verified. Hidden file synchronisation has not been done yet. - -Vite's build system is not in the production. However, I possibly migrate to it in the future. - -And, the `daily-progress` will be tidied on releasing 0.25.44. Do not worry! - -### Fixed - -- Fixed an issue where the StorageEventManager was not correctly loading the settings. -- Replication statistics are now correctly reset after switching replicators. - -### Refactored - -- Now, many reactive values which keep the state or statistics of the plugin are moved to the services which have the responsibility for these states. -- `serviceFeatures` are now able to be added to the services; this is not a class module, but a function which accepts dependencies and returns an addHandler-able function. This is for better separation of concerns, better maintainability, and testability. -- `control` service; is a meta-service which is responsible for orchestrating services has been added. - - Don't you think stopping replication or something occurs during `settingService.realiseSetting` is quite weird? It may be done by the control service, which can orchestrate the setting service and the replicator service. - - -- Some functions on services have been moved. e.g., `getSystemVaultName` is now on the API service. -- Setting Service is now responsible for the setting, no longer using dynamic binding for the modules. - -## 0.25.43-patched-6 - -18th February, 2026 - -Let me confess that I have lied about `now all ambiguous properties`... I have found some more implicit calling. - -Note: I have not checked hidden file sync and customisation sync yet. Please report if you find any unexpected behaviour in these features. - -### Fixed - -- Now ReplicatorService responds to database reset and database initialisation events to dispose of the active replicator. - - Fixes some unlocking issues during rebuilding. - -### Refactored - -- Now `StorageEventManagerBase` is separated from `StorageEventManagerObsidian` following their concerns. - - No longer using `ObsidianFileAccess` indirectly during checking duplicated-file events. - - Last event memorisation is now moved into the StorageAccessManager, just like the file processing interlocking. - - These methods, i.e., `ObsidianFileAccess.touch`. `StorageEventManager.recentlyTouched`, and `StorageEventManager.touch` are still available, but simply call the StorageAccessManager's methods. -- Now `FileAccessBase` is separated from `FileAccessObsidian` following their concerns. - -## 0.25.43-patched-5 - -17th February, 2026 - -Yes, we mostly have got refactored! - -### Refactored - -- Following properties of `ObsidianLiveSyncPlugin` are now initialised more explicitly: - - - property : what is responsible - - `storageAccess` : `ServiceFileAccessObsidian` - - `databaseFileAccess` : `ServiceDatabaseFileAccess` - - `fileHandler` : `ServiceFileHandler` - - `rebuilder` : `ServiceRebuilder` - - Not so long from now, ServiceFileAccessObsidian might be abstracted to a more general FileAccessService, and make more testable and maintainable. - - These properties are initialised in `initialiseServiceModules` on `ObsidianLiveSyncPlugin`. - - They are `ServiceModule`s. - - Which means they do not use dynamic binding themselves, but they use bound services. - - ServiceModules are in src/lib/src/serviceModules for common implementations, and src/serviceModules for Obsidian-specific implementations. - - Hence, now all ambiguous properties of `ObsidianLiveSyncPlugin` are initialised explicitly. We can proceed to testing. - - Well, I will release v0.25.44 after testing this. - -- Conflict service is now responsible for `resolveAllConflictedFilesByNewerOnes` function, which has been in the rebuilder. -- New functions `updateSettings`, and `applyPartial` have been added to the setting service. We should use these functions instead of directly writing the settings on `ObsidianLiveSyncPlugin.setting`. -- Some interfaces for services have been moved to src/lib/src/interfaces. -- `RemoteService.tryResetDatabase` and `tryCreateDatabase` are now moved to the replicator service. - - You know that these functions are surely performed by the replicator. - - Probably, most of the functions in `RemoteService` should be moved to the replicator service, but for now, these two functions are moved as they are the most related ones, to rewrite the rebuilder service. -- Common functions are gradually moved to the common library. -- Now, binding functions on modules have been delayed until the services and service modules are initialised, to avoid fragile behaviour. - -## 0.25.43-patched-4 - -16th February, 2026 - -I have been working on it little by little in my spare time. Sorry for the delayed response for issues! ! However, thanks for your patience, we seems the `revert to 0.25.43` is not necessary, and I will keep going with this version. - -### Refactored - -- No longer `DatabaseService` is an injectable service. It is now actually a service which has its own handlers. No dynamic binding for necessary functions. -- Now the following properties of `ObsidianLiveSyncPlugin` belong to each service: - - `replicator` : `services.replicator` (still we can access `ObsidianLiveSyncPlugin.replicator` for the active replicator) -- A Handy class `UnresolvedErrorManager` has been added, which is responsible for managing unresolved errors and their handlers (we will see `unresolved errors` on a red-background-banner in the editor when they occur). - - This manager can be used to handle unresolved errors in a unified way, and it can also be used to display notifications or something when unresolved errors occur. - -## 0.25.43-patched-3 - -16th February, 2026 - -### Refactored - -- Now following properties of `ObsidianLiveSyncPlugin` belong to each service: - - property : service (still we can access these properties from `ObsidianLiveSyncPlugin` for better usability, but probably we should access these from services to clarify the dependencies) - - `localDatabase` : `services.database` - - `managers` : `services.database` - - `simpleStore` : `services.keyValueDB` - - `kvDB`: `services.keyValueDB` -- Initialising modules, addOns, and services are now explicitly separated in the `_startUp` function of the main plug-in class. -- LiveSyncLocalDB now depends more explicitly on specified services, not the whole `ServiceHub`. -- New service `keyValueDB` has been added. This had been separated from the `database` service. -- Non-trivial modules, such as `ModuleExtraSyncObsidian` (which only holds deviceAndVaultName), are simply implemented in the service. -- Add `logUtils` for unifying logging method injection and formatting. This utility is able to accept the API service for log writing. -- `ModuleKeyValueDB` has been removed, and its functionality is now implemented in the `keyValueDB` service. -- `ModulePouchDB` and `ModuleLocalDatabaseObsidian` have been removed, and their functionality is now implemented in the `database` service. - - Please be aware that you have overridden createPouchDBInstance or something by dynamic binding; you should now override the createPouchDBInstance in the database service instead of using the module. - - You can refer to the `DirectFileManipulatorV2` for an example of how to override the createPouchDBInstance function in the database service. - -## 0.25.43-patched-2 - -14th February, 2026 - -### Fixed - -- Application LifeCycle has now started in Main, not ServiceHub. - - Indeed, ServiceHub cannot be known other things in main have got ready, so it is quite natural to start the lifecycle in main. - -## 0.25.43-patched-1 - -13th February, 2026 - -**NOTE: Hidden File Sync and Customisation Sync may not work in this version.** - -Just a heads-up: this is a patch version, which is essentially a beta release. Do not worry about the following memos, as they are indeed freaking us out. I trust that you have thought this was too large; you're right. - -If this cannot be stable, I will revert to 0.24.43 and try again. - -### Refactored - -- Now resolving unexpected and inexplicable dependency order issues... -- The function which is able to implement to the service is now moved to each service. - - AppLifecycleService.performRestart -- VaultService.isTargetFile is now using multiple checkers instead of a single function. - - This change allows better separation of concerns and easier extension in the future. -- Application LifeCycle has now started in ServiceHub, not ObsidianMenuModule. - - - It was in a QUITE unexpected place..., isn't it? - - Instead of, we should call `await this.services.appLifecycle.onReady()` in other platforms. - - As in the browser platform, it will be called at `DOMContentLoaded` event. - -- ModuleTargetFilter, which is responsible for parsing ignore files, has been refined. - - This should be separated to a TargetFilter and an IgnoreFileFilter for better maintainability. -- Using `API.addCommand` or some Obsidian API and shimmer APIs, Many modules have been refactored to be derived to AbstractModule from AbstractObsidianModule, to clarify the dependencies. (we should make `app` usage clearer...) -- Fixed initialising `storageAccess` too late in `FileAccessObsidian` module (I am still wondering why it worked before...). -- Remove some redundant overrides in modules. - -### Planned - -- Some services have an ambiguous name, such as `Injectable`. These will be renamed in the future for better clarity. -- Following properties of `ObsidianLiveSyncPlugin` should be initialised more explicitly: - - property : where it is initialised currently - - `localDatabase` : `ModuleLocalDatabaseObsidian` - - `managers` : `ModuleLocalDatabaseObsidian` - - `replicator` : `ModuleReplicator` - - `simpleStore` : `ModuleKeyValueDB` - - `storageAccess` : `ModuleFileAccessObsidian` - - `databaseFileAccess` : `ModuleDatabaseFileAccess` - - `fileHandler` : `ModuleFileHandler` - - `rebuilder` : `ModuleRebuilder` - - `kvDB`: `ModuleKeyValueDB` - - And I think that having a feature in modules directly is not good for maintainability, these should be separated to some module (loader) and implementation (not only service, but also independent something). -- Plug-in statuses such as requestCount, responseCount... should be moved to a status service or somewhere for better separation of concerns. - -## 0.25.43 - -5th, February, 2026 - -### Fixed - -- Encryption/decryption issues when using Object Storage as remote have been fixed. - - Now the plug-in falls back to V1 encryption/decryption when V2 fails (if not configured as ForceV1). - - This may fix the issue reported in #772. - -### Notice - -Quite a few packages have been updated in this release. Please report if you find any unexpected behaviour after this update. - -## 0.25.42 - -2nd, February, 2026 - -This release is identical to 0.25.41-patched-3, except for the version number. - -### Refactored - -- Now the service context is `protected` instead of `private` in `ServiceBase`. - - This change allows derived classes to access the context directly. -- Some dynamically bound services have been moved to services for better dependency management. -- `WebPeer` has been moved to the main repository from the sub repository `livesync-commonlib` for correct dependency management. -- Migrated from the outdated, unstable platform abstraction layer to services. - - A bit more services will be added in the future for better maintainability. - -## 0.25.41 - -24th January, 2026 - -### Fixed - -- No longer `No available splitter for settings!!` errors occur after fetching old remote settings while rebuilding local database. (#748) - -### Improved - -- Boot sequence warning is now kept in the in-editor notification area. - -### New feature - -- We can now set the maximum modified time for reflect events in the settings. (for #754) - - This setting can be configured from `Patches` -> `Remediation` in the settings dialogue. - - Enabling this setting will restrict the propagation from the database to storage to only those changes made before the specified date and time. - - This feature is primarily intended for recovery purposes. After placing `redflag.md` in an empty vault and importing the Self-hosted LiveSync configuration, please perform this configuration, and then fetch the local database from the remote. - - This feature is useful when we want to prevent recent unwanted changes from being reflected in the local storage. - -### Refactored - -- Module to service refactoring has been started for better maintainability: - - UI module has been moved to UI service. - -### Behaviour change - -- Default chunk splitter version has been changed to `Rabin-Karp` for new installations. - -## 0.25.40 - -23rd January, 2026 - -### Fixed - -- Fixed an issue where some events were not triggered correctly after the refactoring in 0.25.39. - -## 0.25.39 - -23rd January, 2026 - -Also no behaviour changes or fixes in this release. Just refactoring for better maintainability. Thank you for your patience! I will address some of the reported issues soon. -However, this is not a minor refactoring, so please be careful. Let me know if you find any unexpected behaviour after this update. - -### Refactored - -- Rewrite the service's binding/handler assignment systems -- Removed loopholes that allowed traversal between services to clarify dependencies. -- Consolidated the hidden state-related state, the handler, and the addition of bindings to the handler into a single object. - - Currently, functions that can have handlers added implement either addHandler or setHandler directly on the function itself. - I understand there are differing opinions on this, but for now, this is how it stands. -- Services now possess a Context. Please ensure each platform has a class that inherits from ServiceContext. -- To permit services to be dynamically bound, the services themselves are now defined by interfaces. - -## 0.25.38 - -17th January, 2026 - -### Fixed - -- Fixed an issue where indexedDB would not close correctly on some environments, causing unexpected errors during database operations. - -## 0.25.37 - -15th January, 2026 - -Thank you for your patience until my return! - -This release contains minor changes discovered and fixed during test implementation. -There are no changes affecting usage. - -### Refactored - -- Logging system has been slightly refactored to improve maintainability. -- Some import statements have been unified. - -## 0.25.36 - -25th December, 2025 - -### Improved - -- Now the garbage collector (V3) has been implemented. (Beta) - - This garbage collector ensures that all devices are synchronised to the latest progress to prevent inconsistencies. - - In other words, it makes sure that no new conflicts would have arisen. - - This feature requires additional information (via node information), but it should be more reliable. - - This feature requires all devices have v0.25.36 or later. - - After the garbage collector runs, the database size may be reduced (Compaction will be run automatically after GC). - - We should have an administrative privilege on the remote database to run this garbage collector. -- Now the plug-in and device information is stored in the remote database. - - This information is used for the garbage collector (V3). - - Some additional features may be added in the future using this information. - -## 0.25.35 - -24th December, 2025 - -Sorry for a small release! I would like to keep things moving along like this if possible. After all, the holidays seem to be starting soon. I will be doubled by my business until the 27th though, indeed. - -### Fixed - -- Now the conflict resolution dialogue shows correctly which device only has older APIs (#764). - -## 0.25.34 - -10th December, 2025 - -### Behaviour change - -- The plug-in automatically fetches the missing chunks even if `Fetch chunks on demand` is disabled. - - This change is to avoid loss of data when receiving a bulk of revisions. - - This can be prevented by enabling `Use Only Local Chunks` in the settings. -- Storage application now saved during each event and restored on startup. -- Synchronisation result application is also now saved during each event and restored on startup. - - These may avoid some unexpected loss of data when the editor crashes. - -### Fixed - -- Now the plug-in waits for the application of pended batch changes before the synchronisation starts. - - This may avoid some unexpected loss or unexpected conflicts. - Plug-in sends custom headers correctly when RequestAPI is used. -- No longer causing unexpected chunk creation during `Reset synchronisation on This Device` with bucket sync. - -### Refactored - -- Synchronisation result application process has been refactored. -- Storage application process has been refactored. - - Please report if you find any unexpected behaviour after this update. A bit of large refactoring. - -## 0.25.33 - -05th December, 2025 - -### New feature - -- We can analyse the local database with the `Analyse database usage` command. - - This command makes a TSV-style report of the database usage, which can be pasted into spreadsheet applications. - - The report contains the number of unique chunks and shared chunks for each document revision. - - Unique chunks indicate the actual consumption. - - Shared chunks indicate the reference counts from other chunks with no consumption. - - We can find which notes or files are using large amounts of storage in the database. Or which notes cannot share chunks effectively. - - This command is useful when optimising the database size or investigating an unexpectedly large database size. -- We can reset the notification threshold and check the remote usage at once with the `Reset notification threshold and check the remote database usage` command. -- Commands are available from the Command Palette, or `Hatch` pane in the settings dialogue. - -### Fixed - -- Now the plug-in resets the remote size notification threshold after rebuild. - -## 0.25.32 - -02nd December, 2025 - -Now I am back from a short (?) break! Thank you all for your patience. (It is nothing major, but the first half of the year has finally come to an end). -Anyway, I will release the things a bit by bit. I think that we need a rehabilitation or getting gears in again. - -### Improved - -- Now the plugin warns when we are in several file-related situations that may cause unexpected behaviour (#300). - - These errors are displayed alongside issues such as file size exceeding limits. - - Such situations include: - - When the document has a name which is not supported by some file systems. - - When the vault has the same file names with different letter cases. - -## 0.25.31 - -18th November, 2025 - -### Fixed - -- Now fetching configuration from the server can handle the empty remote correctly (reported on #756). -- No longer asking to switch adapters during rebuilding. - -# 0.25 - -(0.25.0 through 0.25.30) - -Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025) - -After reading Issue #668, I conducted another self-review of the E2EE-related code. In retrospect, it was clearly written by someone inexperienced, which is understandable, but it is still rather embarrassing. Three years is certainly enough time for growth. - -I have now rewritten the E2EE code to be more robust and easier to understand. It is significantly more readable and should be easier to maintain in the future. The performance issue, previously considered a concern, has been addressed by introducing a master key and deriving keys using HKDF. This approach is both fast and robust, and it provides protection against rainbow table attacks. (In addition, this implementation has been [a dedicated package on the npm registry](https://github.com/vrtmrz/octagonal-wheels), and tested in 100% branch-coverage). - -As a result, this is the first time in a while that forward compatibility has been broken. We have also taken the opportunity to change all metadata to use encryption rather than obfuscation. Furthermore, the `Dynamic Iteration Count` setting is now redundant and has been moved to the `Patches` pane in the settings. Thanks to Rabin-Karp, the eden setting is also no longer necessary and has been relocated accordingly. Therefore, v0.25.0 represents a legitimate and correct evolution. - ---- - -## 0.25.30 - -17th November, 2025 - -So sorry for the quick follow-up release, due to a humble mistake in a quick causing a matter. - -### Fixed - -- Now we can save settings correctly again (#756). - -## ~~0.25.28~~ 0.25.29 - -(0.25.28 was skipped due to a packaging issue.) - -17th November, 2025 - -### New feature - -- We can now configure hidden file synchronisation to always overwrite with the latest version (#579). - -### Fixed - -- Timing dependency issues during initialisation have been mitigated (#714) - -### Improved - -- Error logs now contain stack-traces for better inspection. - -## 0.25.27 - -12th November, 2025 - -### Improved - -- Now we can switch the database adapter between IndexedDB and IDB without rebuilding (#747). - - Just a local migration will be required, but faster than a full rebuild. -- No longer checking for the adapter by `Doctor`. - -### Changes - -- The default adapter is reverted to IDB to avoid memory leaks (#747). - -### Fixed (?) - -- Reverted QR code library to v1.4.4 (To make sure #752). - -## 0.25.26 - -07th November, 2025 - -### Improved - -- Some JWT notes have been added to the setting dialogue (#742). - -### Fixed - -- No longer wrong values encoded into the QR code. -- We can acknowledge why the QR codes have not been generated. - - Probably too large a dataset to encode. When this happens, please consider using Setup-URI via text instead of QR code, or reduce the settings temporarily. - -### Refactored - -- Some dependencies have been updated. -- Internal functions have been modularised into `octagonal-wheels` packages and are well tested. - - `dataobject/Computed` for caching computed values. - - `encodeAnyArray/decodeAnyArray` for encoding and decoding any array-like data into compact strings (#729). -- Fixed importing from the parent project in library codes. (#729). - -## 0.25.25 - -06th November, 2025 - -### Fixed - -#### JWT Authentication - -- Now we can use JWT Authentication ES512 correctly (#742). -- Several misdirections in the Setting dialogues have been fixed (i.e., seconds and minutes confusion...). -- The key area in the Setting dialogue has been enlarged and accepts newlines correctly. -- Caching of JWT tokens now works correctly - - Tokens are now cached and reused until they expire. - - They will be kept until 10% of the expiration duration is remaining or 10 seconds, whichever is longer (but at a maximum of 1 minute). -- JWT settings are now correctly displayed on the Setting dialogue. - -And, tips about JWT Authentication on CouchDB have been added to the documentation (docs/tips/jwt-on-couchdb.md). - -#### Other fixes - -- Receiving non-latest revisions no longer causes unexpected overwrites. - - On receiving revisions that made conflicting changes, we are still able to handle them. - -### Improved - -- No longer duplicated message notifications are shown when a connection to the remote server fails. - - Instead, a single notification is shown, and it will be kept on the notification area inside the editor until the situation is resolved. -- The notification area is no longer imposing, distracting, and overwhelming. - - With a pale background, but bordered and with icons. - -## 0.25.24 - -04th November, 2025 - -(Beta release notes have been consolidated to this note). - -### Guidance and UI improvements! - -Since several issues were pointed out, our setup procedure had been quite `system-oriented`. This is not good for users. Therefore, I have changed the procedure to be more `goal-oriented`. I have made extensive use of Svelte, resulting in a very straightforward setup. -While I would like to accelerate documentation and i18n adoption, I do not want to confuse everyone who's already working on it. Therefore, I have decided to release a Beta version at this stage. Significant changes are not expected from this point onward, so I will proceed to stabilise the codebase. (However, this is significant). - -### TURN server support and important notice - -TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P -connections. In most cases, you do not need to set up a TURN server. - -Using public TURN servers may have privacy implications, as your data will be relayed through third-party -servers. Even if your data are encrypted, your existence may be known to them. Please ensure you trust the TURN -server provider before using their services. Also your `network administrator` too. You should consider setting -up your own TURN server for your FQDN, if possible. - -### New features - -- We can use the TURN server for P2P connections now. - -### Fixed - -- P2P Replication got more robust and stable. - - Update [Trystero](https://github.com/dmotz/trystero) to the official v0.22.0! - - Fixed a bug that caused P2P connections to drop or (unwanted reconnection to the relay server) unexpectedly in some environments. - - Now, the connection status is more accurately reported. - - While in the background, the connection to the signalling server is now disconnected to save resources. - - When returning to the foreground, it will not reconnect automatically for safety. Please reconnect manually. -- All connection configurations should be edited in each dedicated dialogue now. -- No longer will larger files create chunks during preparing `Reset Synchronisation on This Device`. -- Now hidden file synchronisation respects the filters correctly (#631, #735) - - And `ignore-files` settings are also respected and surely read during the start-up. - -### Behaviour changes - -- The setup wizard is now more `goal-oriented`. Brand-new screens are introduced. -- `Fetch everything` and `Rebuild everything` are now `Reset Synchronisation on This Device` and `Overwrite Server Data with This Device's Files`. -- Remote configuration and E2EE settings are now separated into each modal dialogue. - - Remote configuration is now more straightforward. And if we need the rebuild (No... `Overwrite Server Data with This Device's Files`), it is now clearly indicated. -- Peer-to-Peer settings are also separated into their own modal dialogue (still in progress, and we need to open a P2P pane, still). -- Setup-URI, and Report for the Issue are now not copied to the clipboard automatically. Instead, there are copy-dialogue and buttons to copy them explicitly. - - This is to avoid confusion for users who do not want to use these features. -- No longer optional features are introduced during the setup, or `Reset Synchronisation on This Device`, `Overwrite Server Data with This Device's Files`. - - This is to avoid confusion for users who do not want to use these features. Instead, we will be informed that optional features are available after the setup is completed. -- We cannot perform `Fetch everything` and `Rebuild everything` (Removed, so the old name) without restarting Obsidian now. - -### Miscellaneous - -- Setup QR Code generation is separated into a src/lib/src/API/processSetting.ts file. Please use it as a subrepository if you want to generate QR codes in your own application. -- Setup-URI is also separated into a src/lib/src/API/processSetting.ts -- Some direct access to web APIs is now wrapped into the services layer. - -### Dependency updates - -- Many dependencies are updated. Please see `package.json`. - - This is the hardest part of this update. I read most of the changes in the dependencies. If you find any extra information, please let me know. -- As upgrading TypeScript, Fixed many UInt8Array and Uint8Array type mismatches. -- - -### Breaking changes - -- Sending configuration via Peer-to-Peer connection is not compatible with older versions. - - Please upgrade all devices to v0.25.24.beta1 or later to use this feature again. - - This is due to security improvements in the encryption scheme. - -## 0.25.23 - -26th October, 2025 - -The next version we are preparing (you know that as 0.25.23.beta1) is now still on beta, resulting in this rather unfortunate versioning situation. Apologies for the confusion. The next v0.25.23.beta2 will be v0.25.24.beta1. In other words, this is a v0.25.22.patch-1 actually, but possibly not allowed by Obsidian's rule. -(Perhaps we ought to declare 1.0.0 with a little more confidence. The current minor part has been effectively a major one for a long time. If it were 1.22.1 and 1.23.0.beta1, no confusion ). - -### Fixed - -- We are now able to enable optional features correctly again (#732). -- No longer oversized files have been processed, furthermore. - - - Before creating a chunk, the file is verified as the target. - - The behaviour upon receiving replication has been changed as follows: - - If the remote file is oversized, it is ignored. - - If not, but while the local file is oversized, it is also ignored. - -- We are now able to enable optional features correctly again (#732). -- No longer oversized files have been processed, furthermore. - - Before creating a chunk, the file is verified as the target. - - The behaviour upon receiving replication has been changed as follows: - - If the remote file is oversized, it is ignored. - - If not, but while the local file is oversized, it is also ignored. - -## 0.25.22 - -15th October, 2025 - -### Fixed - -- Fixed a bug that caused wrong event bindings and flag inversion (#727) - - This caused following issues: - - In some cases, settings changes were not applied or saved correctly. - - Automatic synchronisation did not begin correctly. - -### Improved - -- Too large diffs are not shown in the file comparison view, due to performance reasons. - -### Notes - -- The checking algorithm implemented in 0.25.20 is also raised as PR (#237). And completely I merged it manually. - - Sorry for lacking merging this PR, and let me say thanks to the great contribution, @bioluks ! -- Known issues: - - Sync on Editor save seems not to work correctly in some cases. - - I am investigating this issue. If you have any information, please let me know. - -## 0.25.21 - -13th October, 2025 - -This release including 0.25.21.beta1 and 0.25.21.beta2. - -Apologies for taking a little time. I was seriously tackling this. -(Of course, being caught up in an unfamiliar structure due to personnel changes on my workplace played a part, but fortunately I have returned to a place where I can do research and development rather than production. Completely beside the point, though). -Now then, this time, moving away from 'convention over configuration', I have changed to a mechanism for manually binding events. This makes it much easier to leverage IDE assistance. -And, also, we are ready to separate `Features` and `APIs` from `Module`. Features are still in the module, but APIs will be moved to a Service layer. This will make it easier to maintain and extend the codebase in the future. - -If you have found any issues, please let me know. I am now on the following: - -- GitHub [Issues](https://github.com/vrtmrz/obsidian-livesync/issues) Excellent! May the other contributors will help you too. -- Twitter [@vorotamoroz](https://twitter.com/vorotamoroz) Quickest! -- Matrix [@vrtmrz:matrix.org](https://matrix.to/#/@vrtmrz:matrix.org) Also quick, and if you need to keep it private! - I am creating rooms too, but I'm struggling to figure out how to use them effectively because I cannot tell the difference of use-case between them and discussions. However, if you want to use Discord, this is a answer; We should on E2E encrypted platform. - -## 0.25.21.beta2 - -8th October, 2025 - -### Fixed - -- Fixed wrong event type bindings (which caused some events not to be handled correctly). -- Fixed detected a timing issue in StorageEventManager - - When multiple events for the same file are fired in quick succession, metadata has been kept older information. This induces unexpected wrong notifications and write prevention. - -## 0.25.21.beta1 - -6th October, 2025 - -### Refactored - -- Event handling now does not rely on 'convention over configuration'. - - Services.ts now have a proper event handler registration system. - -## 0.25.20 - -26th September, 2025 - -### Fixed - -- Chunk fetching no longer reports errors when the fetched chunk could not be saved (#710). - - Just using the fetched chunk temporarily. -- Chunk fetching reports errors when the fetched chunk is surely corrupted (#710, #712). -- It no longer detects files that the plug-in has modified. - - It may reduce unnecessary file comparisons and unexpected file states. - -### Improved - -- Now checking the remote database configuration respecting the CouchDB version (#714). - -## 0.25.19 - -18th September, 2025 - -### Improved - -- Now encoding/decoding for chunk data and encryption/decryption are performed in native functions (if they were available). - - This uses Uint8Array.fromBase64 and Uint8Array.toBase64, which are natively available in iOS 18.2+ and Android with Chrome 140+. - - In Android, WebView is by default updated with Chrome, so it should be available in most cases. - - Note that this is not available in Desktop yet (due to being based on Electron). We are staying tuned for future updates. - - This realised by an external(?) package [octagonal-wheels](https://github.com/vrtmrz/octagonal-wheels). Therefore, this update only updates the dependency. - -## 0.25.18 - -17th September, 2025 - -### Fixed - -- Property encryption detection now works correctly (On Self-hosted LiveSync, it was not broken, but as a library, it was not working correctly). -- Initialising the chunk splitter is now surely performed. -- DirectFileManipulator now works fine (as a library) - - Old `DirectFileManipulatorV1` is now removed. - -### Refactored - -- Removed some unnecessary intermediate files. - -## 0.25.17 - -16th September, 2025 - -### Fixed - -- No longer information-level logs have produced during toggling `Show only notifications` in the settings (#708). -- Ignoring filters for Hidden file sync now works correctly (#709). - -### Refactored - -- Removed some unnecessary intermediate files. - -## 0.25.16 - -4th September, 2025 - -### Improved - -- Improved connectivity for P2P connections -- The connection to the signalling server can now be disconnected while in the background or when explicitly disconnected. - - These features use a patch that has not been incorporated upstream. - - This patch is available at [vrtmrz/trystero](https://github.com/vrtmrz/trystero). - -## 0.25.15 - -3rd September, 2025 - -### Improved - -- Now we can configure `forcePathStyle` for bucket synchronisation (#707). - -## 0.25.14 - -2nd September, 2025 - -### Fixed - -- Opening IndexedDB handling has been ensured. -- Migration check of corrupted files detection has been fixed. - - Now informs us about conflicted files as non-recoverable, but noted so. - - No longer errors on not-found files. - -## 0.25.13 - -1st September, 2025 - -### Fixed - -- Conflict resolving dialogue now properly displays the changeset name instead of A or B (#691). - -## 0.25.12 - -29th August, 2025 - -### Fixed - -- Fixed an issue with automatic synchronisation starting (#702). - -## 0.25.11 - -28th August, 2025 - -### Fixed - -- Automatic translation detection on the first launch now works correctly (#630). -- No errors are shown during synchronisations in offline (if not explicitly requested) (#699). -- Missing some checking during automatic-synchronisation now works correctly. - -## 0.25.10 - -26th August, 2025 - -### New experimental feature - -- We can perform Garbage Collection (Beta2) without rebuilding the entire database, and also fetch the database. - - Note that this feature is very experimental and should be used with caution. - - This feature requires disabling `Fetch chunks on demand`. - -### Fixed - -- Resetting the bucket now properly clears all uploaded files. - -### Refactored - -- Some files have been moved to better reflect their purpose and improve maintainability. -- The extensive LiveSyncLocalDB has been split into separate files for each role. - -### Fixed - -- Unexpected `Failed to obtain PBKDF2 salt` or similar errors during bucket-synchronisation no longer occur. -- Unexpected long delays for chunk-missing documents when using bucket-synchronisation have been resolved. -- Fetched remote chunks are now properly stored in the local database if `Fetch chunks on demand` is enabled. -- The 'fetch' dialogue's message has been refined. -- No longer overwriting any corrupted documents to the storage on boot-sequence. - -### Refactored - -- Type errors have been corrected. - -## 0.25.9 - -20th August, 2025 - -### Fixed - -- CORS Checking messages now use replacements. -- Configuring CORS setting via the UI now respects the existing rules. -- Now startup-checking works correctly again, performs migration check serially and then it will also fix starting LiveSync or start-up sync. (#696) -- Statusline in editor now supported 'Bases'. - -## 0.25.8 - -18th August, 2025 - -### New feature - -- Insecure chunk detection has been implemented. - - A notification dialogue will be shown if any insecure chunks are detected; these may have been created by v0.25.6 due to its issue. If this dialogue appears, please ensure you rebuild the database after backing it up. - -## 0.25.7 - -15th August, 2025 - -**Since the release of 0.25.6, there are two large problem. Please update immediately.** - -- We may have corrupted some documents during the migration process. **Please check your documents on the wizard.** -- Due to a chunk ID assignment issue, some data has not been encrypted. **Please rebuild the database using Rebuild Everything** if you have enabled E2EE. - -**_So, If you have enabled E2EE, please perform `Rebuild everything`. If not, please check your documents on the wizard._** - -In next version, insecure chunk detection will be implemented. - -### Fixed - -- Off-loaded chunking have been fixed to ensure proper functionality (#693). -- Chunk document ID assignment has been fixed. -- Replication prevention message during version up detection has been improved (#686). -- `Keep A` and `Keep B` on Conflict resolving dialogue has been renamed to `Use Base` and `Use Conflicted` (#691). - -### Improved - -- Metadata and content-size unmatched documents are now detected and reported, prevented to be applied to the storage. - - This behaviour can be configured in `Patch` -> `Edge case addressing (Behaviour)` -> `Process files even if seems to be corrupted` - - Note: this toggle is for the direct-database-manipulation users. - -### New Features - -- `Scan for Broken files` has been implemented on `Hatch` -> `TroubleShooting`. - -### Refactored - -- Off-loaded processes have been refactored for the better maintainability. - - Files prefixed `bg.worker` are now work on the worker threads. - - Files prefixed `bgWorker.` are now also controls these worker threads. (I know what you want to say... I will rename them). -- Removed unused code. - -## ~~0.25.5~~ 0.25.6 - -(0.25.5 has been withdrawn due to a bug in the `Fetch chunks on demand` feature). - -9th August, 2025 - -### Fixed - -- Storage scanning no longer occurs when `Suspend file watching` is enabled (including boot-sequence). - - This change improves safety when troubleshooting or fetching the remote database. -- `Fetch chunks on demand` is now working again (if you installed 0.25.5, other versions are not affected). - -### Improved - -- Saving notes and files now consumes less memory. - - Data is no longer fully buffered in memory and written at once; instead, it is now written in each over-2MB increments. -- Chunk caching is now more efficient. - - Chunks are now managed solely by their count (still maintained as LRU). If memory usage becomes excessive, they will be automatically released by the system-runtime. - - Reverse-indexing is also no longer used. It is performed as scanning caches and act also as a WeakRef thinning. -- Both of them (may) are effective for #692, #680, and some more. - -### Changed - -- `Incubate Chunks in Document` (also known as `Eden`) is now fully sunset. - - Existing chunks can still be read, but new ones will no longer be created. -- The `Compute revisions for chunks` setting has also been removed. - - This feature is now always enabled and is no longer configurable (restoring the original behaviour). -- As mentioned, `Memory cache size (by total characters)` has been removed. - - The `Memory cache size (by total items)` setting is now the only option available (but it has 10x ratio compared to the previous version). - -### Refactored - -- A significant refactoring of the core codebase is underway. - - This is part of our ongoing efforts to improve code maintainability, readability, and to unify interfaces. - - Previously, complex files posed a risk due to a low bus factor. Fortunately, as our devices have become faster and more capable, we can now write code that is clearer and more maintainable (And not so much costs on performance). - - Hashing functions have been refactored into the `HashManager` class and its derived classes. - - Chunk splitting functions have been refactored into the `ContentSplitterCore` class and its derived classes. - - Change tracking functions have been refactored into the `ChangeManager` class. - - Chunk read/write functions have been refactored into the `ChunkManager` class. - - Fetching chunks on demand is now handled separately from the `ChunkManager` and chunk reading functions. Chunks are queued by the `ChunkManager` and then processed by the `ChunkFetcher`, simplifying the process and reducing unnecessary complexity. - - Then, local database access via `LiveSyncLocalDB` has been refactored to use the new classes. -- References to external sources from `commonlib` have been corrected. -- Type definitions in `types.ts` have been refined. -- Unit tests are being added incrementally. - - I am using `Deno` for testing, to simplify testing and coverage reporting. - - While this is not identical to the Obsidian environment, `jest` may also have limitations. It is certainly better than having no tests. - - In other words, recent manual scenario testing has highlighted some shortcomings. - - `pouchdb-test`, used for testing PouchDB with Deno, has been added, utilising the `memory` adapter. - -Side note: Although class-oriented programming is sometimes considered an outdated style, However, I have come to re-evaluate it as valuable from the perspectives of maintainability and readability. - -## 0.25.4 - -29th July, 2025 - -### Fixed - -- The PBKDF2Salt is no longer corrupted when attempting replication while the device is offline. (#686) - - If this issue has already occurred, please use `Maintenance` -> `Rebuilding Operations (Remote Only)` -> `Overwrite Remote` and `Send` to resolve it. - - Please perform this operation on the device that is most reliable. - - I am so sorry for the inconvenience; there are no patching workarounds. The rebuilding operation is the only solution. - - This issue only affects the encryption of the remote database and does not impact the local databases on any devices. - - (Preventing synchronisation is by design and expected behaviour, even if it is sometimes inconvenient. This is also why we should avoid using workarounds; it is, admittedly, an excuse). - - In any case, we can unlock the remote from the warning dialogue on receiving devices. We are performing replication, instead of simple synchronisation at the expense of a little complexity (I would love to express thank you again for your every effort to manage and maintain the settings! Your all understanding saves our notes). - - This process may require considerable time and bandwidth (as usual), so please wait patiently and ensure a stable network connection. - -### Side note - -The PBKDF2Salt will be referred to as the `Security Seed`, and it is used to derive the encryption key for replication. Therefore, it should be stored on the server prior to synchronisation. We apologise for the lack of explanation in previous updates! - -## 0.25.3 - -22nd July, 2025 - -### Fixed - -- Now the `Doctor` at migration will save the configuration. - -## 0.25.2 ~~0.25.1~~ - -(0.25.1 was missed due to a mistake in the versioning process). -19th July, 2025 - -### Refined and New Features - -- Fetching the remote database on `RedFlag` now also retrieves remote configurations optionally. - - This is beneficial if we have already set up another device and wish to use the same configuration. We will see a much less frequent `Unmatched` dialogue. -- The setup wizard using Set-up URI and QR code has been improved. - - The message is now more user-friendly. - - The obsolete method (manual setting application) has been removed. - - The `Cancel` button has been added to the setup wizard. - - We can now fetch the remote configuration from the server if it exists, which is useful for adding new devices. - - Mostly same as a `RedFlag` fetching remote configuration. - - We can also use the `Doctor` to check and fix the imported (and fetched) configuration before applying it. - -### Changes - -- The Set-up URI is now encrypted with a new encryption algorithm (mostly the same as `V2`). - - The new Set-up URI is not compatible with version 0.24.x or earlier. - -## 0.25.0 - -### Fixed - -- The encryption algorithm now uses HKDF with a master key. - - This is more robust and faster than the previous implementation. - - It is now more secure against rainbow table attacks. - - The previous implementation can still be used via `Patches` -> `End-to-end encryption algorithm` -> `Force V1`. - - Note that `V1: Legacy` can decrypt V2, but produces V1 output. -- `Fetch everything from the remote` now works correctly. - - It no longer creates local database entries before synchronisation. -- Extra log messages during QR code decoding have been removed. - -### Changed - -- The following settings have been moved to the `Patches` pane: - - `Remote Database Tweak` - - `Incubate Chunks in Document` - - `Data Compression` - -### Behavioural and API Changes - -- `DirectFileManipulatorV2` now requires new settings (as you may already know, E2EEAlgorithm). -- The database version has been increased to `12` from `10`. - - If an older version is detected, we will be notified and synchronisation will be paused until the update is acknowledged. (It has been a long time since this behaviour was last encountered; we always err on the side of caution, even if it is less convenient.) - -### Refactored - -- `couchdb_utils.ts` has been separated into several explicitly named files. -- Some missing functions in `bgWorker.mock.ts` have been added. - -## 0.24.0 - -I know that we have been waiting for a long time. It is finally released! - -Over the past three years since the inception of the plugin, various features have been implemented to address diverse user needs. This is truly honourable, and I am grateful for your years of support. However, this process has led to an increasingly disorganised codebase, with features becoming entangled. Consequently, this has led to a situation where bugs can go unnoticed and resolving one issue may inadvertently introduce another. - -In 0.24.0, I reorganised the previously jumbled main codebase into clearly defined modules. Although I had assumed that the total size of the code would not increase, I discovered that it has in fact increased. While the complexity is still considerable, the refactoring has improved the clarity of the code's structure. Additionally, while testing the release candidates, we still found many bugs to fix, which helped to make this plug-in robust and stable. Therefore, we are now ready to use the updated plug-in, and in addition to that, proceed to the next step. - -This is also the first step towards a fully-fledged-fancy LiveSync, not just a plug-in from Obsidian. Of course, it will still be a plug-in primarily and foremost, but this development marks a significant step towards the self-hosting concept. - -Finally, I would like to once again express my respect and gratitude to all of you. My gratitude extends to all of the dev testers! Your contributions have certainly made the plug-in robust and stable! - -Thank you, and I hope your troubles will be resolved! - ---- - -## 0.24.31 - -10th July, 2025 - -### Fixed - -- The description of `Enable Developers' Debug Tools.` has been refined. - - Now performance impact is more clearly stated. -- Automatic conflict checking and resolution has been improved. - - It now works parallelly for each other file, instead of sequentially. It makes significantly faster on first synchronisation when with local files information. -- Resolving conflicts dialogue will not be shown for the multiple files at once. - - It will be shown for each file, one by one. - -## 0.24.30 - -9th July, 2025 - -### New Feature - -- New chunking algorithm `V3: Fine deduplication` has been added, and will be recommended after updates. - - The Rabin-Karp algorithm is used for efficient chunking. - - This will be the default in the new installations. - - It is more robust and faster than the previous one. - - We can change it in the `Advanced` pane of the settings. -- New language `ko` (Korean) has been added. - - Thank you for your contribution, [@ellixspace](https://x.com/ellixspace)! - - Any contributions are welcome, from any route. Please let me know if I seem to be unaware of this. It is often the case that I am not really aware of it. -- Chinese (Simplified) translation has been updated. - - Thank you for your contribution, [@52sanmao](https://github.com/52sanmao)! - -### Fixed - -- Numeric settings are now never lost the focus during value changing. -- Doctor now redacts more sensitive information on error reports. - -### Improved - -- All translations have been rewritten into YAML format, to easier to manage and contribute. - - We can write them with comments, newlines, and other YAML features. -- Doctor recommendations are now shown in a user-friendly notation. - - We can now see the recommended as `V3: Fine deduplication` instead of `v3-rabin-karp`. - -### Refactored - -- Never-ending `ObsidianLiveSyncSettingTab.ts` has finally been separated into each pane's file. -- Some commented-out code has been removed. - -### Acknowledgement - -- Jun Murakami, Shun Ishiguro, and Yoshihiro Oyama. 2012. Implementation and Evaluation of a Cache Deduplication Mechanism with Content-Defined Chunking. In _IPSJ SIG Technical Report_, Vol.2012-ARC-202, No.4. Information Processing Society of Japan, 1-7. - -## 0.24.29 - -20th June, 2025 - -### Fixed - -- Synchronisation with buckets now works correctly, regardless of whether a prefix is set or the bucket has been (re-) initialised (#664). -- An information message is now displayed again, during any automatic synchronisation is enabled (#662). - -### Tidied up - -- Importing paths have been tidied up. - -## 0.24.28 - -15th June, 2025 - -### Fixed - -- Batch Update is no longer available in LiveSync mode to avoid unexpected behaviour. (#653) -- Now compatible with Cloudflare R2 again for bucket synchronisation. - - @edo-bari-ikutsu, thank you for [your contribution](https://github.com/vrtmrz/livesync-commonlib/pull/12)! -- Prevention of broken behaviour due to database connection failures added (#649). - -## 0.24.27 - -10th June, 2025 - -### Improved - -- We can use prefix for path for the Bucket synchronisation. - - For example, if you set the `vaultName/` as a prefix for the bucket in the root directory, all data will be transferred to the bucket under the `vaultName/` directory. -- The "Use Request API to avoid `inevitable` CORS problem" option is now promoted to the normal setting, not a niche patch. - -### Fixed - -- Now switching replicators applied immediately, without the need to restart Obsidian. - -### Tidied up - -- Some dependencies have been updated to the latest version. - -## 0.24.26 - -14th May, 2025 - -This update introduces an option to circumvent Cross-Origin Resource Sharing -(CORS) constraints for CouchDB requests, by leveraging Obsidian's native request -API. The implementation of such a feature had previously been deferred due to -significant security considerations. - -CORS is a vital security mechanism, enabling servers like CouchDB -- which -functions as a sophisticated REST API -- to control access from different -origins, thereby ensuring secure communication across trust boundaries. I had -long hesitated to offer a CORS circumvention method, as it deviates from -security best practices; My preference was for users to configure CORS correctly -on the server-side. - -However, this policy has shifted due to specific reports of intractable -CORS-related configuration issues, particularly within enterprise proxy -environments where proxy servers can unpredictably alter or block -communications. Given that a primary objective of the "Self-hosted LiveSync" -plugin is to facilitate secure Obsidian usage within stringent corporate -settings, addressing these 'unavoidable' user-reported problems became -essential. Mostly raison d'ÃĒtre of this plugin. - -Consequently, the option "Use Request API to avoid `inevitable` CORS problem" -has been implemented. Users are strongly advised to enable this _only_ when -operating within a trusted environment. We can enable this option in the `Patch` pane. - -However, just to whisper, this is tremendously fast. - -### New Features - -- Automatic display-language changing according to the Obsidian language - setting. - - We will be asked on the migration or first startup. - - **Note: Please revert to the default language if you report any issues.** - - Not all messages are translated yet. We welcome your contribution! -- Now we can limit files to be synchronised even in the hidden files. -- "Use Request API to avoid `inevitable` CORS problem" has been implemented. - - Less secure, please use it only if you are sure that you are in the trusted - environment and be able to ignore the CORS. No `Web viewer` or similar tools - are recommended. (To avoid the origin forged attack). If you are able to - configure the server setting, always that is recommended. -- `Show status icon instead of file warnings banner` has been implemented. - - If enabled, the ⛔ icon will be shown inside the status instead of the file - warnings banner. No details will be shown. - -### Improved - -- All regular expressions can be inverted by prefixing `!!` now. - -### Fixed - -- No longer unexpected files will be gathered during hidden file sync. -- No longer broken `\n` and new-line characters during the bucket - synchronisation. -- We can purge the remote bucket again if we using MinIO instead of AWS S3 or - Cloudflare R2. -- Purging the remote bucket is now more reliable. - - 100 files are purged at a time. -- Some wrong messages have been fixed. - -### Behaviour changed - -- Entering into the deeper directories to gather the hidden files is now limited - by `/` or `\/` prefixed ignore filters. (It means that directories are scanned - deeper than before). - - However, inside the these directories, the files are still limited by the - ignore filters. - -### Etcetera - -- Some code has been tidied up. -- Trying less warning-suppressing and be more safer-coding. -- Dependent libraries have been updated to the latest version. -- Some build processes have been separated to `pre` and `post` processes. - -## 0.24.25 - -22nd April, 2025 - -### Improved - -- Peer-to-peer synchronisation has been got more robust. - -### Fixed - -- No longer broken falsy values in settings during set-up by the QR code - generation. - -### Refactored - -- Some `window` references now have pointed to `globalThis`. -- Some sloppy-import has been fixed. -- A server side implementation `Synchromesh` has been suffixed with `deno` - instead of `server` now. - -## 0.24.24 - -15th April, 2025 - -### Fixed - -- No longer broken JSON files including `\n`, during the bucket synchronisation. - (#623) -- Custom headers and JWT tokens are now correctly sent to the server during - configuration checking. (#624) - -### Improved - -- Bucket synchronisation has been enhanced for better performance and - reliability. - - Now less duplicated chunks are sent to the server. Note: If you have - encountered about too less chunks, please let me know. However, you can send - it to the server by `Overwrite remote`. - - Fetching conflicted files from the server is now more reliable. - - Dependent libraries have been updated to the latest version. - - Also, let me know if you have encountered any issues with this update. - Especially you are using a device that has been in use for a little - longer. - -## 0.24.23 - -10th April, 2025 - -### New Feature - -- Now, we can send custom headers to the server. - - They can be sent to either CouchDB or Object Storage. -- Authentication with JWT in CouchDB is now supported. - - I will describe steps later, but please refer to the - [CouchDB document](https://docs.couchdb.org/en/stable/config/auth.html#authentication-configuration). - - A JWT keypair for testing can be generated in the setting dialogue. - -### Improved - -- The QR Code for set-up can be shown also from the setting dialogue now. -- Conflict checking for preventing unexpected overwriting on the boot-up process - has been quite faster. - -### Fixed - -- Some bugs on Dev and Testing modules have been fixed. - -## 0.24.22 ~~0.24.21~~ - -1st April, 2025 - -(Really sorry for the confusion. I have got a miss at releasing...). - -### Fixed - -- No longer conflicted files are handled in the boot-up process. No more - unexpected overwriting. - - It ignores `Always overwrite with a newer file`, and always be prevented for - the safety. Please pick it manually or open the file. -- Some log messages on conflict resolution has been corrected. -- Automatic merge notifications, displayed on the grounds of `same`, have been - degraded to logs. - -### Improved - -- Now we can fetch the remote database with keeping local files completely - intact. - - In new option, all files are stored into the local database before the - fetching, and will be merged automatically or detected as conflicts. -- The dialogue presenting options when performing `Fetch` are now more - informative. - -### Refactored - -- Some class methods have been fixed its arguments to be more consistent. -- Types have been defined for some conditional results. - -## 0.24.20 - -24th March, 2025 - -### Improved - -- Now we can see the detail of `TypeError` using Obsidian API during remote - database access. - -### Behaviour and default changed - -- **NOW INDEED AND ACTUALLY** `Compute revisions for chunks` are backed into - enabled again. it is necessary for garbage collection of chunks. - - As far as existing users are concerned, this will not automatically change, - but the Doctor will inform us. - -## 0.24.19 - -5th March, 2025 - -### New Feature - -- Now we can generate a QR Code for transferring the configuration to another device. - - This QR Code can be scanned by the camera app or something QR Code Reader of another device, and via Obsidian URL, the configuration will be transferred. - - Note: This QR Code is not encrypted. So, please be careful when transferring the configuration. - -## 0.24.18 - -28th February, 2025 - -### Fixed - -- Now no chunk creation errors will be raised after switching `Compute revisions for chunks`. -- Some invisible file can be handled correctly (e.g., `writing-goals-history.csv`). -- Fetching configuration from the server is now saves the configuration immediately (if we are not in the wizard). - -### Improved - -- Mismatched configuration dialogue is now more informative, and rewritten to more user-friendly. -- Applying configuration mismatch is now without rebuilding (at our own risks). -- Now, rebuilding is decided more fine grained. - -### Improved internally - -- Translations can be nested. i.e., task:`Some procedure`, check: `%{task} checking`, checkfailed: `%{check} failed` produces `Some procedure checking failed`. - - Max to 10 levels of nesting - -## 0.24.17 - -27th February, 2025 - -Confession. I got the default values wrong. So scary and sorry. - -## 0.24.16 - -### Improved - -#### Peer-to-Peer - -- Now peer-to-peer synchronisation checks the settings are compatible with each other. - - No longer unexpected database broken, phew. -- Peer-to-peer synchronisation now handles the platform and detects pseudo-clients. - - Pseudo clients will not decrypt/encrypt anything, just relay the data. Hence, always settings are not compatible. Therefore, we have to accept the incompatibility for pseudo clients. - -#### General - -- New migration method has been implemented, that called `Doctor`. - - - `Doctor` checks the difference between the ideal and actual values and encourages corrective action. To facilitate our decision, the reasons for this and the recommendations are also presented. - - This can be used not only during migration. We can invoke the doctor from the settings for trouble-shooting. - -- The minimum interval for replication to be caused when an event occurs can now be configurable. -- Some detail note has been added and change nuance about the `Report` in the setting dialogue, which had less informative. - -### Behaviour and default changed - -- `Compute revisions for chunks` are backed into enabled again. it is necessary for garbage collection of chunks. - - As far as existing users are concerned, this will not automatically change, but the Doctor will inform us. - -### Refactored - -- Platform specific codes are more separated. No longer `node` modules were used in the browser and Obsidian. - -## 0.24.15 - -### Fixed - -- Now, even without WeakRef, Polyfill is used and the whole thing works without error. However, if you can switch WebView Engine, it is recommended to switch to a WebView Engine that supports WeakRef. - -## 0.24.14 - -### Fixed - -- Resolving conflicts of JSON files (and sensibly merging them) is now working fine, again! - - And, failure logs are more informative. -- More robust to release the event listeners on unwatching the local database. - -### Refactored - -- JSON file conflict resolution dialogue has been rewritten into svelte v5. -- Upgrade eslint. -- Remove unnecessary pragma comments for eslint. - -## 0.24.13 - -Sorry for the lack of replies. The ones that were not good are popping up, so I am just going to go ahead and get this one... However, they realised that refactoring and restructuring is about clarifying the problem. Your patience and understanding is much appreciated. - -### Fixed - -#### General Replication - -- No longer unexpected errors occur when the replication is stopped during for some reason (e.g., network disconnection). - -#### Peer-to-Peer Synchronisation - -- Set-up process will not receive data from unexpected sources. -- No longer resource leaks while enabling the `broadcasting changes` -- Logs are less verbose. -- Received data is now correctly dispatched to other devices. -- `Timeout` error now more informative. -- No longer timeout error occurs for reporting the progress to other devices. -- Decision dialogues for the same thing are not shown multiply at the same time anymore. -- Disconnection of the peer-to-peer synchronisation is now more robust and less error-prone. - -#### Webpeer - -- Now we can toggle Peers' configuration. - -### Refactored - -- Cross-platform compatibility layer has been improved. -- Common events are moved to the common library. -- Displaying replication status of the peer-to-peer synchronisation is separated from the main-log-logic. -- Some file names have been changed to be more consistent. - -## 0.24.12 - -I created a SPA called [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) (well, right... I will think of a name again), which replaces the server when using Peer-to-Peer synchronisation. This is a pseudo-client that appears to other devices as if it were one of the clients. . As with the client, it receives and sends data without storing it as a file. -And, this is just a single web page, without any server-side code. It is a static web page that can be hosted on any static web server, such as GitHub Pages, Netlify, or Vercel. All you have to do is to open the page and enter several items, and leave it open. - -### Fixed - -- No longer unnecessary acknowledgements are sent when starting peer-to-peer synchronisation. - -### Refactored - -- Platform impedance-matching-layer has been improved. - - And you can see the actual usage of this on [webpeer](https://github.com/vrtmrz/livesync-commonlib/tree/main/apps/webpeer) that a pseudo client for peer-to-peer synchronisation. -- Some UIs have been got isomorphic among Obsidian and web applications (for `webpeer`). - -## 0.24.11 - -Peer-to-peer synchronisation has been implemented! - -Until now, I have not provided a synchronisation server. More people may not -even know that I have shut down the test server. I confess that this is a bit -repetitive, but I confess it is a cautionary tale. This is out of a sense of -self-discipline that someone has occurred who could see your data. Even if the -'someone' is me. I should not be unaware of its superiority, even though -well-meaning and am a servant of all. (Half joking, but also serious). However, -now I can provide you with a signalling server. Because, to the best of my -knowledge, it is only the network that is connected to your device. Also, this -signalling server is just a Nostr relay, not my implementation. You can run your -implementation, which you consider trustworthy, on a trustworthy server. You do -not even have to trust me. Mate, it is great, isn't it? For your information, -strfry is running on my signalling server. - -Nevertheless, that being said, to be more honest, I still have not decided what -to do with this signalling server if too much traffic comes in. - -Note: Already you have noticed this, but let me mention it again, this is a -significantly large update. If you have noticed anything, please let me know. I -will try to fix it as soon as possible (Some address is on my -[profile](https://github.com/vrtmrz)). - -### Improved - -- New Translation: `es` (Spanish) by @zeedif (Thank you so much)! -- Now all of messages can be selectable and copyable, also on the iPhone, iPad, and Android devices. Now we can copy or share the messages easily. - -### New Feature - -- Peer-to-Peer Synchronisation has been implemented! - - This feature is still in early beta, and it is recommended to use it with caution. - - However, it is a significant step towards the self-hosting concept. It is now possible to synchronise your data without using any remote database or storage. It is a direct connection between your devices. - - Note: We should keep the device online to synchronise the data. It is not a background synchronisation. Also it needs a signalling server to establish the connection. But, the signalling server is used only for establishing the connection, and it does not store any data. - -### Fixed - -- No longer memory or resource leaks when the plug-in is disabled. -- Now deleted chunks are correctly detected on conflict resolution, and we are guided to resurrect them. -- Hanging issue during the initial synchronisation has been fixed. -- Some unnecessary logs have been removed. -- Now all modal dialogues are correctly closed when the plug-in is disabled. - -### Refactor - -- Several interfaces have been moved to the separated library. -- Translations have been moved to each language file, and during the build, they are merged into one file. -- Non-mobile friendly code has been removed and replaced with the safer code. - - (Now a days, mostly server-side engine can use webcrypto, so it will be rewritten in the future more). -- Started writing Platform impedance-matching-layer. -- Svelte has been updated to v5. -- Some function have got more robust type definitions. -- Terser optimisation has slightly improved. -- During the build, analysis meta-file of the bundled codes will be generated. - -## 0.24.10 - -### Fixed - -- Fixed the issue which the filename is shown as `undefined`. -- Fixed the issue where files transferred at short intervals were not reflected. - -### Improved - -- Add more translations: `ja-JP` (Japanese) by @kohki-shikata (Thank you so much)! - -### Internal - -- Some files have been prettified. - -## 0.24.9 - -Skipped. - -## 0.24.8 - -### Fixed - -- Some parallel-processing tasks are now performed more safely. -- Some error messages has been fixed. - -### Improved - -- Synchronisation is now more efficient and faster. -- Saving chunks is a bit more robust. - -### New Feature - -- We can remove orphaned chunks again, now! - - Without rebuilding the database! - - Note: Please synchronise devices completely before removing orphaned chunks. - - Note2: Deleted files are using chunks, if you want to remove them, please commit the deletion first. (`Commit File Deletion`) - - Note3: If you lost some chunks, do not worry. They will be resurrected if not so much time has passed. Try `Resurrect deleted chunks`. - - Note4: This feature is still beta. Please report any issues you encounter. - - Note5: Please disable `On demand chunk fetching`, and enable `Compute revisions for each chunk` before using this feature. - - These settings is going to be default in the future. - -## 0.24.7 - -### Fixed (Security) - -- Assigning IDs to chunks has been corrected for more safety. - - Before version 0.24.6, there were possibilities in End-to-End encryption where a brute-force attack could be carried out against an E2EE passphrase via a chunk ID if a zero-byte file was present. Now the chunk ID should be assigned more safely, and not all of passphrases are used for generating the chunk ID. - - This is a security fix, and it is recommended to update and rebuild database to this version as soon as possible. - - Note: It keeps the compatibility with the previous versions, but the chunk ID will be changed for the new files and modified files. Hence, deduplication will not work for the files which are modified after the update. It is recommended to rebuild the database to avoid the potential issues, and reduce the database size. - - Note2: This fix is only for with E2EE. Plain synchronisation is not affected by this issue. - -### Fixed - -- Now the conflict resolving dialogue is automatically closed after the conflict has been resolved (and transferred from other devices; or written by some other resolution). -- Resolving conflicts by timestamp is now working correctly. - - It also fixes customisation sync. - -### Improved - -- Notifications can be suppressed for the hidden files update now. -- No longer uses the old-xxhash and sha1 for generating the chunk ID. Chunk ID is now generated with the new algorithm (Pure JavaScript hash implementation; which is using Murmur3Hash and FNV-1a now used). - -## 0.24.6 - -### Fixed (Quick Fix) - -- Fixed the issue of log is not displayed on the log pane if the pane has not been shown on startup. - - This release is only for it. However, fixing this had been necessary to report any other issues. - -## 0.24.5 - -### Fixed - -- Fixed incorrect behaviour when comparing objects with undefined as a property value. - -### Improved - -- The status line and the log summary are now displayed more smoothly and efficiently. - - This improvement has also been applied to the logs displayed in the log pane. - -## 0.24.4 - -### Fixed - -- Fixed so many inefficient and buggy modules inherited from the past. - -### Improved - -- Tasks are now executed in an efficient asynchronous library. -- On-demand chunk fetching is now more efficient and keeps the interval between requests. - - This will reduce the load on the server and the network. - - And, safe for the Cloudant. - -## 0.24.3 - -### Improved - -- Many messages have been improved for better understanding as thanks to the fine works of @Volkor3-16! Thank you so much! -- Documentations also have been updated to reflect the changes in the messages. -- Now the style of In-Editor Status has been solid for some Android devices. - -## 0.24.2 - -### Rewritten - -- Hidden File Sync is now respects the file changes on the storage. Not simply comparing modified times. - - This makes hidden file sync more robust and reliable. - -### Fixed - -- `Scan hidden files before replication` is now configurable again. -- Some unexpected errors are now handled more gracefully. -- Meaningless event passing during boot sequence is now prevented. -- Error handling for non-existing files has been fixed. -- Hidden files will not be batched to avoid the potential error. - - This behaviour had been causing the error in the previous versions in specific situations. -- The log which checking automatic conflict resolution is now in verbose level. -- Replication log (skipping non-targetting files) shows the correct information. -- The dialogue that asking enabling optional feature during `Rebuild Everything` now prevents to show the `overwrite` option. - - The rebuilding device is the first, meaningless. -- Files with different modified time but identical content are no longer processed repeatedly. -- Some unexpected errors which caused after terminating plug-in are now avoided. -- - -### Improved - -- JSON files are now more transferred efficiently. - - Now the JSON files are transferred in more fine chunks, which makes the transfer more efficient. - -## 0.24.1 - -### Fixed - -- Vault History can show the correct information of match-or-not for each file and database even if it is a binary file. -- `Sync settings via markdown` is now hidden during the setup wizard. -- Verify and Fix will ignore the hidden files if the hidden file sync is disabled. - -#### New feature - -- Now we can fetch the tweaks from the remote database while the setting dialogue and wizard are processing. - -### Improved - -- More things are moved to the modules. - - Includes the Main codebase. Now `main.ts` is almost stub. -- EventHub is now more robust and typesafe. - -## 0.24.0 - -### Improved - -- The welcome message is now more simple to encourage the use of the Setup-URI. - - The secondary message is also simpler to guide users to Minimal Setup. - - But Setup-URI will be recommended again, due to its importance. - - These dialogues contain a link to the documentation which can be clicked. -- The minimal setup is more minimal now. And, the setup is more user-friendly. - - Now the Configuration of the remote database is checked more robustly, but we can ignore the warning and proceed with the setup. -- Before we are asked about each feature, we are asked if we want to use optional features in the first place. - - This is to prevent the user from being overwhelmed by the features. - - And made it clear that it is not recommended for new users. -- Many messages have been improved for better understanding. - - Ridiculous messages have been (carefully) refined. - - Dialogues are more informative and friendly. - - A lot of messages have been mostly rewritten, leveraging Markdown. - - Especially auto-closing dialogues are now explicitly labelled: `To stop the countdown, tap anywhere on the dialogue`. -- Now if the is plugin configured to ignore some events, we will get a chance to fix it, in addition to the warning. - - And why that has happened is also explained in the dialogue. -- A note relating to device names has been added to Customisation Sync on the setting dialogue. -- We can verify and resolve also the hidden files now. - -### Fixed - -- We can resolve the conflict of the JSON file correctly now. -- Verifying files between the local database and storage is now working correctly. -- While restarting the plug-in, the shown dialogues will be automatically closed to avoid unexpected behaviour. -- Replicated documents that the local device has configured to ignore are now correctly ignored. -- The chunks of the document on the local device during the first transfer will be created correctly. - - And why we should create them is now explained in the dialogue. -- If optional features have been enabled in the wizard, `Enable advanced features` will be toggled correctly. - The hidden file sync is now working correctly. - Now the deletion of hidden files is correctly synchronised. -- Customisation Sync is now working correctly together with hidden file sync. -- No longer database suffix is stored in the setting sharing markdown. -- A fair number of bugs have been fixed. - -### Changed - -- Some default settings have been changed for an easier new user experience. - - Preventing the meaningless migration of the settings. - -### Tiding - -- The codebase has been reorganised into clearly defined modules. -- Commented-out codes have been gradually removed. - -### 0.23.0 - -Incredibly new features! - -Now, we can use object storage (MinIO, S3, R2 or anything you like) for synchronising! Moreover, despite that, we can use all the features as if we were using CouchDB. -Note: As this is a pretty experimental feature, hence we have some limitations. - -- This is built on the append-only architecture. It will not shrink used storage if we do not perform a rebuild. -- A bit fragile. However, our version x.yy.0 is always so. -- When the first synchronisation, the entire history to date is transferred. For this reason, it is preferable to do this under the WiFi network. -- Do not worry, from the second synchronisation, we always transfer only differences. - -I hope this feature empowers users to maintain independence and self-host their data, offering an alternative for those who prefer to manage their own storage solutions and avoid being stuck on the right side of a sudden change in business model. - -Of course, I use Self-hosted MinIO for testing and recommend this. It is for the same reason as using CouchDB. -- open, controllable, auditable and indeed already audited by numerous eyes. - -Let me write one more acknowledgement. - -I have a lot of respect for that plugin, even though it is sometimes treated as if it is a competitor, remotely-save. I think it is a great architecture that embodies a different approach to my approach of recreating history. This time, with all due respect, I have used some of its code as a reference. -Hooray for open source, and generous licences, and the sharing of knowledge by experts. - -#### Version history - -- 0.23.23: - - Refined: - - Setting dialogue very slightly refined. - - The hodgepodge inside the `Hatch` pane has been sorted into more explicit categorised panes. - - Now we have new panes for: - - `Selector` - - `Advanced` - - `Power users` - - `Patches (Edge case)` - - Applying the settings will now be more informative. - - The header bar will be shown for applying the settings which needs a database rebuild. - - Applying methods are now more clearly navigated. - - Definitely, drastic change. I hope this will be more user-friendly. However, if you notice any issues, please let me know. I hope that nothing missed. - - New features: - - Word-segmented chunk building on users language - - Chunks can now be built with word-segmented data, enhancing efficiency for markdown files which contains the multiple sentences in a single line. - - This feature is enabled by default through `Use Segmented-splitter`. - - (Default: Disabled, Please be relived, I have learnt). - - Fixed: - - Sending chunks on `Send chunk in bulk` are now buffered to avoid the out-of-memory error. - - `Send chunk in bulk` is back to default disabled. (Sorry, not applied to the migrated users; I did not think we should deepen the wound any further "automatically"). - - Merging conflicts of JSON files are now works fine even if it contains `null`. - - Development: - - Implemented the logic for automatically generating the stub of document for the setting dialogue. -- 0.23.22: - - Fixed: - - Case-insensitive file handling - - Full-lower-case files are no longer created during database checking. - - Bulk chunk transfer - - The default value will automatically adjust to an acceptable size when using IBM Cloudant. -- 0.23.21: - - New Features: - - Case-insensitive file handling - - Files can now be handled case-insensitively. - - This behaviour can be modified in the settings under `Handle files as Case-Sensitive` (Default: Prompt, Enabled for previous behaviour). - - Improved chunk revision fixing - - Revisions for chunks can now be fixed for faster chunk creation. - - This can be adjusted in the settings under `Compute revisions for chunks` (Default: Prompt, Enabled for previous behaviour). - - Bulk chunk transfer - - Chunks can now be transferred in bulk during uploads. - - This feature is enabled by default through `Send chunks in bulk`. - - Creation of missing chunks without - - Missing chunks can be created without storing notes, enhancing efficiency for first synchronisation or after prolonged periods without synchronisation. - - Improvements: - - File status scanning on the startup - - Quite significant performance improvements. - - No more missing scans of some files. - - Status in editor enhancements - - Significant performance improvements in the status display within the editor. - - Notifications for files that will not be synchronised will now be properly communicated. - - Encryption and Decryption - - These processes are now performed in background threads to ensure fast and stable transfers. - - Verify and repair all files - - Got faster through parallel checking. - - Migration on update - - Migration messages and wizards have become more helpful. - - Behavioural changes: - - Chunk size adjustments - - Large chunks will no longer be created for older, stable files, addressing storage consumption issues. - - Flag file automation - - Confirmation will be shown and we can cancel it. - - Fixed: - - Database File Scanning - - All files in the database will now be enumerated correctly. - - Miscellaneous - - Dependency updated. - - Now, tree shaking is left to terser, from esbuild. -- 0.23.20: - - Fixed: - - Customisation Sync now checks the difference while storing or applying the configuration. - - No longer storing the same configuration multiple times. - - Time difference in the dialogue has been fixed. - - Remote Storage Limit Notification dialogue has been fixed, now the chosen value is saved. - - Improved: - - The Enlarging button on the enlarging threshold dialogue now displays the new value. -- 0.23.19: - - Not released. -- 0.23.18: - - New feature: - - Per-file-saved customization sync has been shipped. - - We can synchronise plug-igs etc., more smoothly. - - Default: disabled. We need a small migration when enabling this. And all devices should be updated to v0.23.18. Once we enabled this, we lost compatibility with old versions. - - Customisation sync has got beta3. - - We can set `Flag` to each item to select the newest, automatically. - - This configuration is per device. - - Improved: - - Start-up speed has been improved. - - Fixed: - - On the customisation sync dialogue, buttons are kept within the screen. - - No more unnecessary entries on `data.json` for customisation sync. - - Selections are no longer lost while updating customisation items. - - Tidied on source codes: - - Many typos have been fixed. - - Some unnecessary type casting removed. -- 0.23.17: - - Improved: - - Overall performance has been improved by using PouchDB 9.0.0. - - Configuration mismatch detection is refined. We can resolve mismatches more smoothly and naturally. - More detail is on `troubleshooting.md` on the repository. - - Fixed: - - Customisation Sync will be disabled when a corrupted configuration is detected. - Therefore, the Device Name can be changed even in the event of a configuration mismatch. - - New feature: - - We can get a notification about the storage usage of the remote database. - - Default: We will be asked. - - If the remote storage usage approaches the configured value, we will be asked whether we want to Rebuild or increase the limit. -- 0.23.16: - - Maintenance Update: - - Library refining (Phase 1 - step 2). There are no significant changes on the user side. - - Including the following fixes of potentially problems: - - the problem which the path had been obfuscating twice has been resolved. - - Note: Potential problems of the library; which has not happened in Self-hosted LiveSync for some reasons. -- 0.23.15: - - Maintenance Update: - - Library refining (Phase 1). There are no significant changes on the user side. -- 0.23.14: - - Fixed: - - No longer batch-saving ignores editor inputs. - - The file-watching and serialisation processes have been changed to the one which is similar to previous implementations. - - We can configure the settings (Especially about text-boxes) even if we have configured the device name. - - Improved: - - We can configure the delay of batch-saving. - - Default: 5 seconds, the same as the previous hard-coded value. (Note: also, the previous behaviour was not correct). - - Also, we can configure the limit of delaying batch-saving. - - The performance of showing status indicators has been improved. -- 0.23.13: - - Fixed: - - No longer files have been trimmed even delimiters have been continuous. - - Fixed the toggle title to `Do not split chunks in the background` from `Do not split chunks in the foreground`. - - Non-configured item mismatches are no longer detected. -- 0.23.12: - - Improved: - - Now notes will be split into chunks in the background thread to improve smoothness. - - Default enabled, to disable, toggle `Do not split chunks in the foreground` on `Hatch` -> `Compatibility`. - - If you want to process very small notes in the foreground, please enable `Process small files in the foreground` on `Hatch` -> `Compatibility`. - - We can use a `splitting-limit-capped chunk splitter`; which performs more simple and make less amount of chunks. - - Default disabled, to enable, toggle `Use splitting-limit-capped chunk splitter` on `Sync settings` -> `Performance tweaks` - - Tidied - - Some files have been separated into multiple files to make them more explicit in what they are responsible for. -- 0.23.11: - - Fixed: - - Now we _surely_ can set the device name and enable customised synchronisation. - - Unnecessary dialogue update processes have been eliminated. - - Customisation sync no longer stores half-collected files. - - No longer hangs up when removing or renaming files with the `Sync on Save` toggle enabled. - - Improved: - - Customisation sync now performs data deserialization more smoothly. - - New translations have been merged. -- 0.23.10 - - Fixed: - - No longer configurations have been locked in the minimal setup. -- 0.23.9 - - Fixed: - - No longer unexpected parallel replication is performed. - - Now we can set the device name and enable customised synchronisation again. -- 0.23.8 - - New feature: - - Now we are ready for i18n. - - Patch or PR of `rosetta.ts` are welcome! - - The setting dialogue has been refined. Very controllable, clearly displayed disabled items, and ready to i18n. - - Fixed: - - Many memory leaks have been rescued. - - Chunk caches now work well. - - Many trivial but potential bugs are fixed. - - No longer error messages will be shown on retrieving checkpoint or server information. - - Now we can check and correct tweak mismatch during the setup - - Improved: - - Customisation synchronisation has got more smoother. - - Tidied - - Practically unused functions have been removed or are being prepared for removal. - - Many of the type-errors and lint errors have been corrected. - - Unused files have been removed. - - Note: - - From this version, some test files have been included. However, they are not enabled and released in the release build. - - To try them, please run Self-hosted LiveSync in the dev build. -- 0.23.7 - - Fixed: - - No longer missing tasks which have queued as the same key (e.g., for the same operation to the same file). - - This occurs, for example, with hidden files that have been changed multiple times in a very short period of time, such as `appearance.json`. Thanks for the report! - - Some trivial issues have been fixed. - - New feature: - - Reloading Obsidian can be scheduled until that file and database operations are stable. -- 0.23.6: - - Fixed: - - Now the remote chunks could be decrypted even if we are using `Incubate chunks in Document`. (The note of 0.23.6 has been fixed). - - Chunk retrieving with `Incubate chunks in document` got more efficiently. - - No longer task processor misses the completed tasks. - - Replication is no longer started automatically during changes in window visibility (e.g., task switching on the desktop) when off-focused. -- 0.23.5: - - New feature: - - Now we can check configuration mismatching between clients before synchronisation. - - Default: enabled / Preferred: enabled / We can disable this by the `Do not check configuration mismatch before replication` toggle in the `Hatch` pane. - - It detects configuration mismatches and prevents synchronisation failures and wasted storage. - - Now we can perform remote database compaction from the `Maintenance` pane. - - Fixed: - - We can detect the bucket could not be reachable. - - Note: - - Known inexplicable behaviour: Recently, (Maybe while enabling `Incubate chunks in Document` and `Fetch chunks on demand` or some more toggles), our customisation sync data is sometimes corrupted. It will be addressed by the next release. -- 0.23.4 - - Fixed: - - No longer experimental configuration is shown on the Minimal Setup. - - New feature: - - We can now use `Incubate Chunks in Document` to reduce non-well-formed chunks. - - Default: disabled / Preferred: enabled in all devices. - - When we enabled this toggle, newly created chunks are temporarily kept within the document, and graduated to become independent chunks once stabilised. - - The [design document](https://github.com/vrtmrz/obsidian-livesync/blob/3925052f9290b3579e45a4b716b3679c833d8ca0/docs/design_docs_of_keep_newborn_chunks.md) has been also available.. -- 0.23.3 - - Fixed: No longer unwanted `\f` in journal sync. -- 0.23.2 - - Sorry for all the fixes to experimental features. (These things were also critical for dogfooding). The next release would be the main fixes! Thank you for your patience and understanding! - - Fixed: - - Journal Sync will not hang up during big replication, especially the initial one. - - All changes which have been replicated while rebuilding will not be postponed (Previous behaviour). - - Improved: - - Now Journal Sync works efficiently in download and parse, or pack and upload. - - Less server storage and faster packing/unpacking usage by the new chunk format. -- 0.23.1 - - - Fixed: - - Now journal synchronisation considers untransferred each from sent and received. - - Journal sync now handles retrying. - - Journal synchronisation no longer considers the synchronisation of chunks as revision updates (Simply ignored). - - Journal sync now splits the journal pack to prevent mobile device rebooting. - - Maintenance menus which had been on the command palette are now back in the maintain pane on the setting dialogue. - - Improved: - - Now all changes which have been replicated while rebuilding will be postponed. - -- 0.23.0 - - New feature: - - Now we can use Object Storage. - -### 0.22.0 - -A few years passed since Self-hosted LiveSync was born, and our codebase had been very complicated. This could be patient now, but it should be a tremendous hurt. -Therefore at v0.22.0, for future maintainability, I refined task scheduling logic totally. - -Of course, I think this would be our suffering in some cases. However, I would love to ask you for your cooperation and contribution. - -Sorry for being absent so much long. And thank you for your patience! - -Note: we got a very performance improvement. -Note at 0.22.2: **Now, to rescue mobile devices, Maximum file size is set to 50 by default**. Please configure the limit as you need. If you do not want to limit the sizes, set zero manually, please. - -#### Version history - -- 0.22.19 - - Fixed: - - No longer data corrupting due to false BASE64 detections. - - Improved: - - A bit more efficient in Automatic data compression. -- 0.22.18 - - New feature (Very Experimental): - - Now we can use `Automatic data compression` to reduce amount of traffic and the usage of remote database. - - Please make sure all devices are updated to v0.22.18 before trying this feature. - - If you are using some other utilities which connected to your vault, please make sure that they have compatibilities. - - Note: Setting `File Compression` on the remote database works for shrink the size of remote database. Please refer the [Doc](https://docs.couchdb.org/en/stable/config/couchdb.html#couchdb/file_compression). -- 0.22.17: - - Fixed: - - Error handling on booting now works fine. - - Replication is now started automatically in LiveSync mode. - - Batch database update is now disabled in LiveSync mode. - - No longer automatically reconnection while off-focused. - - Status saves are thinned out. - - Now Self-hosted LiveSync waits for all files between the local database and storage to be surely checked. - - Improved: - - The job scheduler is now more robust and stable. - - The status indicator no longer flickers and keeps zero for a while. - - No longer meaningless frequent updates of status indicators. - - Now we can configure regular expression filters in handy UI. Thank you so much, @eth-p! - - `Fetch` or `Rebuild everything` is now more safely performed. - - Minor things - - Some utility function has been added. - - Customisation sync now less wrong messages. - - Digging the weeds for eradication of type errors. -- 0.22.16: - - Fixed: - - Fixed the issue that binary files were sometimes corrupted. - - Fixed customisation sync data could be corrupted. - - Improved: - - Now the remote database costs lower memory. - - This release requires a brief wait on the first synchronisation, to track the latest changeset again. - - Description added for the `Device name`. - - Refactored: - - Many type-errors have been resolved. - - Obsolete file has been deleted. -- 0.22.15: - - Improved: - Faster start-up by removing too many logs which indicates normality - By streamlined scanning of customised synchronisation extra phases have been deleted. - ... To continue on to `updates_old.md`. -- 0.22.14: - - New feature: - - We can disable the status bar in the setting dialogue. - - Improved: - - Now some files are handled as correct data type. - - Customisation sync now uses the digest of each file for better performance. - - The status in the Editor now works performant. - - Refactored: - - Common functions have been ready and the codebase has been organised. - - Stricter type checking following TypeScript updates. - - Remove old iOS workaround for simplicity and performance. -- 0.22.13: - - Improved: - - Now using HTTP for the remote database URI warns of an error (on mobile) or notice (on desktop). - - Refactored: - - Dependencies have been polished. -- 0.22.12: - - Changed: - - The default settings has been changed. - - Improved: - - Default and preferred settings are applied on completion of the wizard. - - Fixed: - - Now Initialisation `Fetch` will be performed smoothly and there will be fewer conflicts. - - No longer stuck while Handling transferred or initialised documents. -- 0.22.11: - - Fixed: - - `Verify and repair all files` is no longer broken. - - New feature: - - Now `Verify and repair all files` is able to... - - Restore if the file only in the local database. - - Show the history. - - Improved: - - Performance improved. -- 0.22.10 - - Fixed: - - No longer unchanged hidden files and customisations are saved and transferred now. - - File integrity of vault history indicates the integrity correctly. - - Improved: - - In the report, the schema of the remote database URI is now printed. -- 0.22.9 - - Fixed: - - Fixed a bug on `fetch chunks on demand` that could not fetch the chunks on demand. - - Improved: - - `fetch chunks on demand` works more smoothly. - - Initialisation `Fetch` is now more efficient. - - Tidied: - - Removed some meaningless codes. -- 0.22.8 - - Fixed: - - Now fetch and unlock the locked remote database works well again. - - No longer crash on symbolic links inside hidden folders. - - Improved: - - Chunks are now created more efficiently. - - Splitting old notes into a larger chunk. - - Better performance in saving notes. - - Network activities are indicated as an icon. - - Less memory used for binary processing. - - Tidied: - - Cleaned unused functions up. - - Sorting out the codes that have become nonsense. - - Changed: - - Now no longer `fetch chunks on demand` needs `Pacing replication` - - The setting `Do not pace synchronization` has been deleted. -- 0.22.7 - - Fixed: - - No longer deleted hidden files were ignored. - - The document history dialogue is now able to process the deleted revisions. - - Deletion of a hidden file is now surely performed even if the file is already conflicted. -- 0.22.6 - - Fixed: - - Fixed a problem with synchronisation taking a long time to start in some cases. - - The first synchronisation after update might take a bit longer. - - Now we can disable E2EE encryption. - - Improved: - - `Setup Wizard` is now more clear. - - `Minimal Setup` is now more simple. - - Self-hosted LiveSync now be able to use even if there are vaults with the same name. - - Database suffix will automatically added. - - Now Self-hosted LiveSync waits until set-up is complete. - - Show reload prompts when possibly recommended while settings. - - New feature: - - A guidance dialogue prompting for settings will be shown after the installation. - - Changed - - `Open setup URI` is now `Use the copied setup URI` - - `Copy setup URI` is now `Copy current settings as a new setup URI` - - `Setup Wizard` is now `Minimal Setup` - - `Check database configuration` is now `Check and Fix database configuration` -- 0.22.5 - - Fixed: - - Some description of settings have been refined - - New feature: - - TroubleShooting is now shown in the setting dialogue. -- 0.22.4 - - Fixed: - - Now the result of conflict resolution could be surely written into the storage. - - Deleted files can be handled correctly again in the history dialogue and conflict dialogue. - - Some wrong log messages were fixed. - - Change handling now has become more stable. - - Some event handling became to be safer. - - Improved: - - Dumping document information shows conflicts and revisions. - - The timestamp-only differences can be surely cached. - - Timestamp difference detection can be rounded by two seconds. - - Refactored: - - A bit of organisation to write the test. -- 0.22.3 - - Fixed: - - No longer detects storage changes which have been caused by Self-hosted LiveSync itself. - - Setting sync file will be detected only if it has been configured now. - - And its log will be shown only while the verbose log is enabled. - - Customisation file enumeration has got less blingy. - - Deletion of files is now reliably synchronised. - - Fixed and improved: - - In-editor-status is now shown in the following areas: - - Note editing pane (Source mode and live-preview mode). - - New tab pane. - - Canvas pane. -- 0.22.2 - - Fixed: - - Now the results of resolving conflicts are surely synchronised. - - Modified: - - Some setting items got new clear names. (`Sync Settings` -> `Targets`). - - New feature: - - We can limit the synchronising files by their size. (`Sync Settings` -> `Targets` -> `Maximum file size`). - - It depends on the size of the newer one. - - At Obsidian 1.5.3 on mobile, we should set this to around 50MB to avoid restarting Obsidian. - - Now the settings could be stored in a specific markdown file to synchronise or switch it (`General Setting` -> `Share settings via markdown`). - - [Screwdriver](https://github.com/vrtmrz/obsidian-screwdriver) is quite good, but mostly we only need this. - - Customisation of the obsoleted device is now able to be deleted at once. - - We have to put the maintenance mode in at the Customisation sync dialogue. -- 0.22.1 - - New feature: - - We can perform automatic conflict resolution for inactive files, and postpone only manual ones by `Postpone manual resolution of inactive files`. - - Now we can see the image in the document history dialogue. - - We can see the difference of the image, in the document history dialogue. - - And also we can highlight differences. - - Improved: - - Hidden file sync has been stabilised. - - Now automatically reloads the conflict-resolution dialogue when new conflicted revisions have arrived. - - Fixed: - - No longer periodic process runs after unloading the plug-in. - - Now the modification of binary files is surely stored in the storage. -- 0.22.0 - - Refined: - - Task scheduling logics has been rewritten. - - Screen updates are also now efficient. - - Possibly many bugs and fragile behaviour has been fixed. - - Status updates and logging have been thinned out to display. - - Fixed: - - Remote-chunk-fetching now works with keeping request intervals - - New feature: - - We can show only the icons in the editor. - - Progress indicators have been more meaningful: - - đŸ“Ĩ Unprocessed transferred items - - 📄 Working database operation - - 💾 Working write storage processes - - âŗ Working read storage processes - - đŸ›Ģ Pending read storage processes - - âš™ī¸ Working or pending storage processes of hidden files - - 🧩 Waiting chunks - - 🔌 Working Customisation items (Configuration, snippets and plug-ins) - -... To continue on to `updates_old.md`. - -### 0.21.0 - -The E2EE encryption V2 format has been reverted. That was probably the cause of the glitch. -Instead, to maintain efficiency, files are treated with Blob until just before saving. Along with this, the old-fashioned encryption format has also been discontinued. -There are both forward and backwards compatibilities, with recent versions. However, unfortunately, we lost compatibility with filesystem-livesync or some. -It will be addressed soon. Please be patient if you are using filesystem-livesync with E2EE. - -- 0.21.5 - - Improved: - - Now all revisions will be shown only its first a few letters. - - Now ID of the documents is shown in the log with the first 8 letters. - - Fixed: - - Check before modifying files has been implemented. - - Content change detection has been improved. -- 0.21.4 - - This release had been skipped. -- 0.21.3 - - Implemented: - - Now we can use SHA1 for hash function as fallback. -- 0.21.2 - - IMPORTANT NOTICE: **0.21.1 CONTAINS A BUG WHILE REBUILDING THE DATABASE. IF YOU HAVE BEEN REBUILT, PLEASE MAKE SURE THAT ALL FILES ARE SANE.** - - This has been fixed in this version. - - Fixed: - - No longer files are broken while rebuilding. - - Now, Large binary files can be written correctly on a mobile platform. - - Any decoding errors now make zero-byte files. - - Modified: - - All files are processed sequentially for each. -- 0.21.1 - - Fixed: - - No more infinity loops on larger files. - - Show message on decode error. - - Refactored: - - Fixed to avoid obsolete global variables. -- 0.21.0 - - Changes and performance improvements: - - Now the saving files are processed by Blob. - - The V2-Format has been reverted. - - New encoding format has been enabled in default. - - WARNING: Since this version, the compatibilities with older Filesystem LiveSync have been lost. - -## 0.20.0 - -At 0.20.0, Self-hosted LiveSync has changed the binary file format and encrypting format, for efficient synchronisation. -The dialogue will be shown and asks us to decide whether to keep v1 or use v2. Once we have enabled v2, all subsequent edits will be saved in v2. Therefore, devices running 0.19 or below cannot understand this and they might say that decryption error. Please update all devices. -Then we will have an impressive performance. - -Of course, these are very impactful changes. If you have any questions or troubled things, please feel free to open an issue and mention me. - -Note: if you want to roll it back to v1, please enable `Use binary and encryption version 1` on the `Hatch` pane and perform the `rebuild everything` once. - -Extra but notable information: - -This format change gives us the ability to detect some `marks` in the binary files as same as text files. Therefore, we can split binary files and some specific sort of them (i.e., PDF files) at the specific character. It means that editing the middle of files could be detected with marks. - -Now only a few chunks are transferred, even if we add a comment to the PDF or put new files into the ZIP archives. - -- 0.20.7 - - Fixed - - To better replication, path obfuscation is now deterministic even if with E2EE. - Note: Compatible with previous database without any conversion. Only new files will be obfuscated in deterministic. -- 0.20.6 - - Fixed - - Now empty file could be decoded. - - Local files are no longer pre-saved before fetching from a remote database. - - No longer deadlock while applying customisation sync. - - Configuration with multiple files is now able to be applied correctly. - - Deleting folder propagation now works without enabling the use of a trash bin. -- 0.20.5 - - Fixed - - Now the files which having digit or character prefixes in the path will not be ignored. -- 0.20.4 - - Fixed - - The text-input-dialogue is no longer broken. - - Finally, we can use the Setup URI again on mobile. -- 0.20.3 - - New feature: - - We can launch Customization sync from the Ribbon if we enabled it. - - Fixed: - - Setup URI is now back to the previous spec; be encrypted by V1. - - It may avoid the trouble with iOS 17. - - The Settings dialogue is now registered at the beginning of the start-up process. - - We can change the configuration even though LiveSync could not be launched in normal. - - Improved: - - Enumerating documents has been faster. -- 0.20.2 - - New feature: - - We can delete all data of customization sync from the `Delete all customization sync data` on the `Hatch` pane. - - Fixed: - - Prevent keep restarting on iOS by yielding microtasks. -- 0.20.1 - - Fixed: - - No more UI freezing and keep restarting on iOS. - - Diff of Non-markdown documents are now shown correctly. - - Improved: - - Performance has been a bit improved. - - Customization sync has gotten faster. - - However, We lost forward compatibility again (only for this feature). Please update all devices. - - Misc - - Terser configuration has been more aggressive. -- 0.20.0 - - Improved: - - A New binary file handling implemented - - A new encrypted format has been implemented - - Now the chunk sizes will be adjusted for efficient sync - - Fixed: - - levels of exception in some logs have been fixed - - Tidied: - - Some Lint warnings have been suppressed. - -### 0.19.0 - -#### Customization sync - -Since `Plugin and their settings` have been broken, so I tried to fix it, not just fix it, but fix it the way it should be. - -Now, we have `Customization sync`. - -It is a real shame that the compatibility between these features has been broken. However, this new feature is surely useful and I believe that worth getting over the pain. -We can use the new feature with the same configuration. Only the menu on the command palette has been changed. The dialog can be opened by `Show customization sync dialog`. - -I hope you will give it a try. - -#### Minors - -- 0.19.1 - - Fixed: Fixed hidden file handling on Linux - - Improved: Now customization sync works more smoothly. -- 0.19.2 - - Fixed: - - Fixed garbage collection error while unreferenced chunks exist many. - - Fixed filename validation on Linux. - - Improved: - - Showing status is now thinned for performance. - - Enhance caching while collecting chunks. -- 0.19.3 - - Improved: - - Now replication will be paced by collecting chunks. If synchronisation has been deadlocked, please enable `Do not pace synchronization` once. -- 0.19.4 - - Improved: - - Reduced remote database checking to improve speed and reduce bandwidth. - - Fixed: - - Chunks which previously misinterpreted are now interpreted correctly. - - No more missing chunks which not be found forever, except if it has been actually missing. - - Deleted file detection on hidden file synchronising now works fine. - - Now the Customisation sync is surely quiet while it has been disabled. -- 0.19.5 - - Fixed: - - Now hidden file synchronisation would not be hanged, even if so many files exist. - - Improved: - - Customisation sync works more smoothly. - - Note: Concurrent processing has been rollbacked into the original implementation. As a result, the total number of processes is no longer shown next to the hourglass icon. However, only the processes that are running concurrently are shown. -- 0.19.6 - - Fixed: - - Logging has been tweaked. - - No more too many planes and rockets. - - The batch database update now surely only works in non-live mode. - - Internal things: - - Some frameworks has been upgraded. - - Import declaration has been fixed. - - Improved: - - The plug-in now asks to enable a new adaptor, when rebuilding, if it is not enabled yet. - - The setting dialogue refined. - - Configurations for compatibilities have been moved under the hatch. - - Made it clear that disabled is the default. - - Ambiguous names configuration have been renamed. - - Items that have no meaning in the settings are no longer displayed. - - Some items have been reordered for clarity. - - Each configuration has been grouped. -- 0.19.7 - - Fixed: - - The initial pane of Setting dialogue is now changed to General Settings. - - The Setup Wizard is now able to flush existing settings and get into the mode again. -- 0.19.8 - - New feature: - - Vault history: A tab has been implemented to give a birds-eye view of the changes that have occurred in the vault. - - Improved: - - Now the passphrases on the dialogue masked out. Thank you @antoKeinanen! - - Log dialogue is now shown as one of tabs. - - Fixed: - - Some minor issues has been fixed. -- 0.19.9 - - New feature (For fixing a problem): - - We can fix the database obfuscated and plain paths that have been mixed up. - - Improvements - - Customisation Sync performance has been improved. -- 0.19.10 - - Fixed - - Fixed the issue about fixing the database. -- 0.19.11 - - Improvements: - - Hashing ChunkID has been improved. - - Logging keeps 400 lines now. - - Refactored: - - Import statement has been fixed about types. -- 0.19.12 - - Improved: - - Boot-up performance has been improved. - - Customisation sync performance has been improved. - - Synchronising performance has been improved. -- 0.19.13 - - Implemented: - - Database clean-up is now in beta 2! - We can shrink the remote database by deleting unused chunks, with keeping history. - Note: Local database is not cleaned up totally. We have to `Fetch` again to let it done. - **Note2**: Still in beta. Please back your vault up anything before. - - Fixed: - - The log updates are not thinned out now. -- 0.19.14 - - Fixed: - - Internal documents are now ignored. - - Merge dialogue now respond immediately to button pressing. - - Periodic processing now works fine. - - The checking interval of detecting conflicted has got shorter. - - Replication is now cancelled while cleaning up. - - The database locking by the cleaning up is now carefully unlocked. - - Missing chunks message is correctly reported. - - New feature: - - Suspend database reflecting has been implemented. - - This can be disabled by `Fetch database with previous behaviour`. - - Now fetch suspends the reflecting database and storage changes temporarily to improve the performance. - - We can choose the action when the remote database has been cleaned - - Merge dialogue now show `↲` before the new line. - - Improved: - - Now progress is reported while the cleaning up and fetch process. - - Cancelled replication is now detected. -- 0.19.15 - - Fixed: - - Now storing files after cleaning up is correct works. - - Improved: - - Cleaning the local database up got incredibly fastened. - Now we can clean instead of fetching again when synchronising with the remote which has been cleaned up. -- 0.19.16 - - Many upgrades on this release. I have tried not to let that happen, if something got corrupted, please feel free to notify me. - - New feature: - - (Beta) ignore files handling - We can use `.gitignore`, `.dockerignore`, and anything you like to filter the synchronising files. - - Fixed: - - Buttons on lock-detected-dialogue now can be shown in narrow-width devices. - - Improved: - - Some constant has been flattened to be evaluated. - - The usage of the deprecated API of obsidian has been reduced. - - Now the indexedDB adapter will be enabled while the importing configuration. - - Misc: - - Compiler, framework, and dependencies have been upgraded. - - Due to standing for these impacts (especially in esbuild and svelte,) terser has been introduced. - Feel free to notify your opinion to me! I do not like to obfuscate the code too. -- 0.19.17 - - Fixed: - - Now nested ignore files could be parsed correctly. - - The unexpected deletion of hidden files in some cases has been corrected. - - Hidden file change is no longer reflected on the device which has made the change itself. - - Behaviour changed: - - From this version, the file which has `:` in its name should be ignored even if on Linux devices. -- 0.19.18 - - Fixed: - - Now the empty (or deleted) file could be conflict-resolved. -- 0.19.19 - - Fixed: - - Resolving conflicted revision has become more robust. - - LiveSync now try to keep local changes when fetching from the rebuilt remote database. - Local changes now have been kept as a revision and fetched things will be new revisions. - - Now, all files will be restored after performing `fetch` immediately. -- 0.19.20 - - New feature: - - `Sync on Editor save` has been implemented - - We can start synchronisation when we save from the Obsidian explicitly. - - Now we can use the `Hidden file sync` and the `Customization sync` cooperatively. - - We can exclude files from `Hidden file sync` which is already handled in Customization sync. - - We can ignore specific plugins in Customization sync. - - Now the message of leftover conflicted files accepts our click. - - We can open `Resolve all conflicted files` in an instant. - - Refactored: - - Parallelism functions made more explicit. - - Type errors have been reduced. - - Fixed: - - Now documents would not be overwritten if they are conflicted. - It will be saved as a new conflicted revision. - - Some error messages have been fixed. - - Missing dialogue titles have been shown now. - - We can click close buttons on mobile now. - - Conflicted Customisation sync files will be resolved automatically by their modified time. -- 0.19.21 - - Fixed: - - Hidden files are no longer handled in the initial replication. - - Report from `Making report` fixed - - No longer contains customisation sync information. - - Version of LiveSync has been added. -- 0.19.22 - - Fixed: - - Now the synchronisation will begin without our interaction. - - No longer puts the configuration of the remote database into the log while checking configuration. - - Some outdated description notes have been removed. - - Options that are meaningless depending on other settings configured are now hidden. - - Scan for hidden files before replication - - Scan customization periodically -- 0.19.23 - -Improved: - - We can open the log pane also from the command palette now. - - Now, the hidden file scanning interval could be configured to 0. - - `Check database configuration` now points out that we do not have administrator permission. - -### 0.18.0 - -#### Now, paths of files in the database can now be obfuscated. (Experimental Feature) - -At before v0.18.0, Self-hosted LiveSync used the path of files, to detect and resolve conflicts. In naive. The ID of the document stored in the CouchDB was naturally the filename. -However, it means a sort of lacking confidentiality. If the credentials of the database have been leaked, the attacker (or an innocent bystander) can read the path of files. So we could not use confidential things in the filename in some environments. -Since v0.18.0, they can be obfuscated. so it is no longer possible to decipher the path from the ID. Instead of that, it costs a bit CPU load than before, and the data structure has been changed a bit. - -We can configure the `Path Obfuscation` in the `Remote database configuration` pane. -Note: **When changing this configuration, we need to rebuild both of the local and the remote databases**. - -#### Minors - -- 0.18.1 - - Fixed: - - Some messages are fixed (Typo) - - File type detection now works fine! -- 0.18.2 - - Improved: - - The setting pane has been refined. - - We can enable `hidden files sync` with several initial behaviours; `Merge`, `Fetch` remote, and `Overwrite` remote. - - No longer `Touch hidden files`. -- 0.18.3 - - Fixed Pop-up is now correctly shown after hidden file synchronisation. -- 0.18.4 - - Fixed: - - `Fetch` and `Rebuild database` will work more safely. - - Case-sensitive renaming now works fine. - Revoked the logic which was made at #130, however, looks fine now. -- 0.18.5 - - - Improved: - - Actions for maintaining databases moved to the `đŸŽ›ī¸Maintain databases`. - - Clean-up of unreferenced chunks has been implemented on an **experimental**. - - This feature requires enabling `Use new adapter`. - - Be sure to fully all devices synchronised before perform it. - - After cleaning up the remote, all devices will be locked out. If we are sure had it be synchronised, we can perform only cleaning-up locally. If not, we have to perform `Fetch`. - -- 0.18.6 - - New features: - - Now remote database cleaning-up will be detected automatically. - - A solution selection dialogue will be shown if synchronisation is rejected after cleaning or rebuilding the remote database. - - During fetching or rebuilding, we can configure `Hidden file synchronisation` on the spot. - - It let us free from conflict resolution on initial synchronising. - -### 0.17.0 - -- 0.17.0 has no surfaced changes but the design of saving chunks has been changed. They have compatibility but changing files after upgrading makes different chunks than before 0.16.x. - Please rebuild databases once if you have been worried about storage usage. - - - Improved: - - - Splitting markdown - - Saving chunks - - - Changed: - - Chunk ID numbering rules - -#### Minors - -- 0.17.1 - - - Fixed: Now we can verify and repair the database. - - Refactored inside. - -- 0.17.2 - - - New feature - - We can merge conflicted documents automatically if sensible. - - Fixed - - Writing to the storage will be pended while they have conflicts after replication. - -- 0.17.3 - - - Now we supported canvas! And conflicted JSON files are also synchronised with merging its content if they are obvious. - -- 0.17.4 - - - Canvases are now treated as a sort of plain text file. now we transfer only the metadata and chunks that have differences. - -- 0.17.5 Now `read chunks online` had been fixed, and a new feature: `Use dynamic iteration count` to reduce the load on encryption/decryption. - Note: `Use dynamic iteration count` is not compatible with earlier versions. -- 0.17.6 Now our renamed/deleted files have been surely deleted again. -- 0.17.7 - - Fixed: - - Fixed merging issues. - - Fixed button styling. - - Changed: - - Conflict checking on synchronising has been enabled for every note in default. -- 0.17.8 - - Improved: Performance improved. Prebuilt PouchDB is no longer used. - - Fixed: Merging hidden files is also fixed. - - New Feature: Now we can synchronise automatically after merging conflicts. -- 0.17.9 - - Fixed: Conflict merge of internal files is no longer broken. - - Improved: Smoother status display inside the editor. -- 0.17.10 - - Fixed: Large file synchronising has been now addressed! - Note: When synchronising large files, we have to set `Chunk size` to lower than 50, disable `Read chunks online`, `Batch size` should be set 50-100, and `Batch limit` could be around 20. -- 0.17.11 - - Fixed: - - Performance improvement - - Now `Chunk size` can be set to under one hundred. - - New feature: - - The number of transfers required before replication stabilises is now displayed. -- 0.17.12: Skipped. -- 0.17.13 - - Fixed: Document history is now displayed again. - - Reorganised: Many files have been refactored. -- 0.17.14: Skipped. -- 0.17.15 - - Improved: - - Confidential information has no longer stored in data.json as is. - - Synchronising progress has been shown in the notification. - - We can commit passphrases with a keyboard. - - Configuration which had not been saved yet is marked now. - - Now the filename is shown on the Conflict resolving dialog - - Fixed: - - Hidden files have been synchronised again. - - Rename of files has been fixed again. - And, minor changes have been included. -- 0.17.16: - - Improved: - - Plugins and their settings no longer need scanning if changes are monitored. - - Now synchronising plugins and their settings are performed parallelly and faster. - - We can place `redflag2.md` to rebuild the database automatically while the boot sequence. - - Experimental: - - We can use a new adapter on PouchDB. This will make us smoother. - - Note: Not compatible with the older version. - - Fixed: - - The default batch size is smaller again. - - Plugins and their setting can be synchronised again. - - Hidden files and plugins are correctly scanned while rebuilding. - - Files with the name started `_` are also being performed conflict-checking. -- 0.17.17 - - Fixed: Now we can merge JSON files even if we failed to compare items like null. -- 0.17.18 - - Fixed: Fixed lack of error handling. -- 0.17.19 - - Fixed: Error reporting has been ensured. -- 0.17.20 - - Improved: Changes of hidden files will be notified to Obsidian. -- 0.17.21 - - Fixed: Skip patterns now handle capital letters. - - Improved - - New configuration to avoid exceeding throttle capacity. - - We have been grateful to @karasevm! - - The conflicted `data.json` is no longer merged automatically. - - This behaviour is not configurable, unlike the `Use newer file if conflicted` of normal files. -- 0.17.22 - - Fixed: - - Now hidden files will not be synchronised while we are not configured. - - Some processes could start without waiting for synchronisation to complete, but now they will wait for. - - Improved - - Now, by placing `redflag3.md`, we can discard the local database and fetch again. - - The document has been updated! Thanks to @hilsonp! -- 0.17.23 - - Improved: - - Now we can preserve the logs into the file. - - Note: This option will be enabled automatically also when we flagging a red flag. - - File names can now be made platform-appropriate. - - Refactored: - - Some redundant implementations have been sorted out. -- 0.17.24 - - New feature: - - If any conflicted files have been left, they will be reported. - - Fixed: - - Now the name of the conflicting file is shown on the conflict-resolving dialogue. - - Hidden files are now able to be merged again. - - No longer error caused at plug-in being loaded. - - Improved: - - Caching chunks are now limited in total size of cached chunks. -- 0.17.25 - - Fixed: - - Now reading error will be reported. -- 0.17.26 - - Fixed(Urgent): - - The modified document will be reflected in the storage now. -- 0.17.27 - - Improved: - - Now, the filename of the conflicted settings will be shown on the merging dialogue - - The plugin data can be resolved when conflicted. - - The semaphore status display has been changed to count only. - - Applying to the storage will be concurrent with a few files. -- 0.17.28 - -Fixed: - - Some messages have been refined. - - Boot sequence has been speeded up. - - Opening the local database multiple times in a short duration has been suppressed. - - Older migration logic. - - Note: If you have used 0.10.0 or lower and have not upgraded, you will need to run 0.17.27 or earlier once or reinstall Obsidian. -- 0.17.29 - - Fixed: - - Requests of reading chunks online are now split into a reasonable(and configurable) size. - - No longer error message will be shown on Linux devices with hidden file synchronisation. - - Improved: - - The interval of reading chunks online is now configurable. - - Boot sequence has been speeded up, more. - - Misc: - - Messages on the boot sequence will now be more detailed. If you want to see them, please enable the verbose log. - - Logs became be kept for 1000 lines while the verbose log is enabled. -- 0.17.30 - - Implemented: - - `Resolve all conflicted files` has been implemented. - - Fixed: - - Fixed a problem about reading chunks online when a file has more chunks than the concurrency limit. - - Rollbacked: - - Logs are kept only for 100 lines, again. -- 0.17.31 - - Fixed: - - Now `redflag3` can be run surely. - - Synchronisation can now be aborted. - - Note: The synchronisation flow has been rewritten drastically. Please do not haste to inform me if you have noticed anything. -- 0.17.32 - - Fixed: - - Now periodic internal file scanning works well. - - The handler of Window-visibility-changed has been fixed. - - And minor fixes possibly included. - - Refactored: - - Unused logic has been removed. - - Some utility functions have been moved into suitable files. - - Function names have been renamed. -- 0.17.33 - - Maintenance update: Refactored; the responsibilities that `LocalDatabase` had were shared. (Hoping) No changes in behaviour. -- 0.17.34 - - Fixed: The `Fetch` that was broken at 0.17.33 has been fixed. - - Refactored again: Internal file sync, plug-in sync and Set up URI have been moved into each file. - -### 0.16.0 - -- Now hidden files need not be scanned. Changes will be detected automatically. - - If you want it to back to its previous behaviour, please disable `Monitor changes to internal files`. - - Due to using an internal API, this feature may become unusable with a major update. If this happens, please disable this once. - -#### Minors - -- 0.16.1 Added missing log updates. -- 0.16.2 Fixed many problems caused by combinations of `Sync On Save` and the tracking logic that changed at 0.15.6. -- 0.16.3 - - Fixed detection of IBM Cloudant (And if there are some issues, be fixed automatically). - - A configuration information reporting tool has been implemented. -- 0.16.4 Fixed detection failure. Please set the `Chunk size` again when using a self-hosted database. -- 0.16.5 - - Fixed - - Conflict detection and merging now be able to treat deleted files. - - Logs while the boot-up sequence has been tidied up. - - Fixed incorrect log entries. - - New Feature - - The feature of automatically deleting old expired metadata has been implemented. - We can configure it in `Delete old metadata of deleted files on start-up` in the `General Settings` pane. -- 0.16.6 - - Fixed - - Automatic (temporary) batch size adjustment has been restored to work correctly. - - Chunk splitting has been backed to the previous behaviour for saving them correctly. - - Improved - - Corrupted chunks will be detected automatically. - - Now on the case-insensitive system, `aaa.md` and `AAA.md` will be treated as the same file or path at applying changesets. -- 0.16.7 Nothing has been changed except toolsets, framework library, and as like them. Please inform me if something had been getting strange! -- 0.16.8 Now we can synchronise without `bad_request:invalid UTF-8 JSON` even while end-to-end encryption has been disabled. - -Note: -Before 0.16.5, LiveSync had some issues making chunks. In this case, synchronisation had became been always failing after a corrupted one should be made. After 0.16.6, the corrupted chunk is automatically detected. Sorry for troubling you but please do `rebuild everything` when this plug-in notified so. - -### 0.15.0 - -- Outdated configuration items have been removed. -- Setup wizard has been implemented! - -I appreciate for reviewing and giving me advice @Pouhon158! - -#### Minors - -- 0.15.1 Missed the stylesheet. -- 0.15.2 The wizard has been improved and documented! -- 0.15.3 Fixed the issue about locking/unlocking remote database while rebuilding in the wizard. -- 0.15.4 Fixed issues about asynchronous processing (e.g., Conflict check or hidden file detection) -- 0.15.5 Add new features for setting Self-hosted LiveSync up more easier. -- 0.15.6 File tracking logic has been refined. -- 0.15.7 Fixed bug about renaming file. -- 0.15.8 Fixed bug about deleting empty directory, weird behaviour on boot-sequence on mobile devices. -- 0.15.9 Improved chunk retrieving, now chunks are retrieved in batch on continuous requests. -- 0.15.10 Fixed: - - The boot sequence has been corrected and now boots smoothly. - - Auto applying of batch save will be processed earlier than before. - -### 0.14.1 - -- The target selecting filter was implemented. - Now we can set what files are synchronised by regular expression. -- We can configure the size of chunks. - We can use larger chunks to improve performance. - (This feature can not be used with IBM Cloudant) -- Read chunks online. - Now we can synchronise only metadata and retrieve chunks on demand. It reduces local database size and time for replication. -- Added this note. -- Use local chunks in preference to remote them if present, - -#### Recommended configuration for Self-hosted CouchDB - -- Set chunk size to around 100 to 250 (10MB - 25MB per chunk) -- _Set batch size to 100 and batch limit to 20 (0.14.2)_ -- Be sure to `Read chunks online` checked. - -#### Minors - -- 0.14.2 Fixed issue about retrieving files if synchronisation has been interrupted or failed -- 0.14.3 New test items have been added to `Check database configuration`. -- 0.14.4 Fixed issue of importing configurations. -- 0.14.5 Auto chunk size adjusting implemented. -- 0.14.6 Change Target to ES2018 -- 0.14.7 Refactor and fix typos. -- 0.14.8 Refactored again. There should be no change in behaviour, but please let me know if there is any. - -### 0.13.0 - -- The metadata of the deleted files will be kept on the database by default. If you want to delete this as the previous version, please turn on `Delete metadata of deleted files.`. And, if you have upgraded from the older version, please ensure every device has been upgraded. -- Please turn on `Delete metadata of deleted files.` if you are using livesync-classroom or filesystem-livesync. -- We can see the history of deleted files. -- `Pick file to show` was renamed to `Pick a file to show. -- Files in the `Pick a file to show` are now ordered by their modified date descent. -- Update information became to be shown on the major upgrade. - -#### Minors - -- 0.13.1 Fixed on conflict resolution. -- 0.13.2 Fixed file deletion failures. -- 0.13.4 - - Now, we can synchronise hidden files that conflicted on each devices. - - We can search for conflicting docs. - - Pending processes can now be run at any time. - - Performance improved on synchronising large numbers of files at once. +This compatibility page remains at its previous path so existing links continue to work. diff --git a/utils/bench/splitPiecesRabinKarp.ts b/utils/bench/splitPiecesRabinKarp.ts index 1c4642c7..d5ee4e5c 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/settings"; +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/commonlib-package-boundary.unit.spec.ts b/utils/commonlib-package-boundary.unit.spec.ts new file mode 100644 index 00000000..dc254759 --- /dev/null +++ b/utils/commonlib-package-boundary.unit.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +type PackageJson = Partial< + Record<"dependencies" | "devDependencies" | "optionalDependencies" | "peerDependencies", Record> +>; + +const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as PackageJson; + +describe("Commonlib package ownership", () => { + it("does not retain a host-owned Trystero dependency", () => { + const directDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + ...packageJson.optionalDependencies, + ...packageJson.peerDependencies, + }; + expect(directDependencies).not.toHaveProperty("@trystero-p2p/nostr"); + }); +}); diff --git a/utils/couchdb/couchdb-init.sh b/utils/couchdb/couchdb-init.sh index 323c13f3..3e41c3f8 100755 --- a/utils/couchdb/couchdb-init.sh +++ b/utils/couchdb/couchdb-init.sh @@ -1,33 +1,26 @@ #!/bin/bash -if [[ -z "$hostname" ]]; then - echo "ERROR: Hostname missing" - exit 1 -fi -if [[ -z "$username" ]]; then - echo "ERROR: Username missing" +set -euo pipefail + +if ! command -v deno >/dev/null 2>&1; then + echo "ERROR: Deno is required to run the Commonlib-backed CouchDB provisioning tool." >&2 exit 1 fi -if [[ -z "$password" ]]; then - echo "ERROR: Password missing" - exit 1 -fi -if [[ -z "$node" ]]; then - echo "INFO: defaulting to _local" - node=_local +script_url="${provision_script_url:-https://raw.githubusercontent.com/vrtmrz/obsidian-livesync/main/utils/couchdb/provision.ts}" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" +deno_dependency_options=() +if [[ -n "$script_dir" && -f "$script_dir/provision.ts" ]]; then + script_url="$script_dir/provision.ts" + lockfile="$script_dir/../flyio/deno.lock" + deno_config="$script_dir/../flyio/deno.jsonc" + if [[ -f "$lockfile" && -f "$deno_config" ]]; then + deno_dependency_options+=("--config=$deno_config" --frozen "--lock=$lockfile") + fi fi -echo "-- Configuring CouchDB by REST APIs... -->" - -until (curl -X POST "${hostname}/_cluster_setup" -H "Content-Type: application/json" -d "{\"action\":\"enable_single_node\",\"username\":\"${username}\",\"password\":\"${password}\",\"bind_address\":\"0.0.0.0\",\"port\":5984,\"singlenode\":true}" --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd_auth/require_valid_user" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/WWW-Authenticate" -H "Content-Type: application/json" -d '"Basic realm=\"couchdb\""' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/httpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/enable_cors" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/chttpd/max_http_request_size" -H "Content-Type: application/json" -d '"4294967296"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/couchdb/max_document_size" -H "Content-Type: application/json" -d '"50000000"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/cors/credentials" -H "Content-Type: application/json" -d '"true"' --user "${username}:${password}"); do sleep 5; done -until (curl -X PUT "${hostname}/_node/${node}/_config/cors/origins" -H "Content-Type: application/json" -d '"app://obsidian.md,capacitor://localhost,http://localhost"' --user "${username}:${password}"); do sleep 5; done - -echo "<-- Configuring CouchDB by REST APIs Done!" +exec deno run \ + --minimum-dependency-age=0 \ + "${deno_dependency_options[@]}" \ + --allow-env \ + --allow-net \ + "$script_url" diff --git a/utils/couchdb/livesync-commonlib.ts b/utils/couchdb/livesync-commonlib.ts new file mode 100644 index 00000000..b1a3a30a --- /dev/null +++ b/utils/couchdb/livesync-commonlib.ts @@ -0,0 +1,5 @@ +// Keep CouchDB database-version negotiation isolated from Setup URI generation. +// The exact release must match utils/livesync-commonlib-version.ts; the setup +// tool suite checks every static specifier before release. +export { checkRemoteVersion } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/negotiation"; +export { PouchDB } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/pouchdb/pouchdb-browser"; diff --git a/utils/couchdb/provision.test.ts b/utils/couchdb/provision.test.ts new file mode 100644 index 00000000..c4942d16 --- /dev/null +++ b/utils/couchdb/provision.test.ts @@ -0,0 +1,83 @@ +import { provisionCouchDB } from "./provision.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("configures CouchDB and delegates database-version initialisation", async () => { + const requests: Array<{ url: string; method: string; body: string }> = []; + const initialisations: Array<[string, string, string]> = []; + await provisionCouchDB( + { + hostname: "https://couch.example.test/", + username: "alice", + password: "secret", + database: "notes", + retryCount: 1, + retryDelayMs: 0, + }, + { + fetch: async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: String(init?.body ?? ""), + }); + return new Response("{}", { status: 201 }); + }, + sleep: async () => {}, + initialiseDatabaseVersion: async (...args) => { + initialisations.push(args); + }, + }, + ); + + assert( + requests[0].url.endsWith("/_cluster_setup"), + "cluster setup was not first", + ); + assert( + requests.some((request) => + request.url.endsWith("/_config/cors/origins") && + request.body.includes("app://obsidian.md") + ), + "the Obsidian CORS origins were not configured", + ); + assert( + requests.at(-1)?.url === "https://couch.example.test/notes", + "the requested database was not created last", + ); + assert( + initialisations.length === 1, + "database-version initialisation was not delegated once", + ); + assert( + initialisations[0][0] === "https://couch.example.test/notes", + "database-version initialisation used the wrong URL", + ); +}); + +Deno.test("leaves database creation to the client when no database is supplied", async () => { + let initialised = false; + await provisionCouchDB( + { + hostname: "http://127.0.0.1:5984", + username: "admin", + password: "secret", + retryCount: 1, + retryDelayMs: 0, + }, + { + fetch: async () => new Response("{}", { status: 200 }), + sleep: async () => {}, + initialiseDatabaseVersion: async () => { + initialised = true; + }, + }, + ); + + assert( + !initialised, + "database-version initialisation ran without a database", + ); +}); diff --git a/utils/couchdb/provision.ts b/utils/couchdb/provision.ts new file mode 100644 index 00000000..21b6283b --- /dev/null +++ b/utils/couchdb/provision.ts @@ -0,0 +1,249 @@ +import { checkRemoteVersion, PouchDB } from "./livesync-commonlib.ts"; + +export interface CouchDBProvisioningOptions { + hostname: string; + username: string; + password: string; + node?: string; + database?: string; + origins?: string; + retryCount?: number; + retryDelayMs?: number; +} + +interface ProvisioningDependencies { + fetch: typeof fetch; + sleep: (milliseconds: number) => Promise; + initialiseDatabaseVersion: ( + databaseURL: string, + username: string, + password: string, + ) => Promise; +} + +const DEFAULT_ORIGINS = + "app://obsidian.md,capacitor://localhost,http://localhost"; + +function requireValue(value: string, name: string): string { + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${name} is required`); + return trimmed; +} + +function normaliseHostname(hostname: string): string { + const parsed = new URL(requireValue(hostname, "hostname")); + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.href.replace(/\/$/, ""); +} + +function validateDatabaseName(database: string): string { + const trimmed = database.trim(); + if (!/^[a-z][a-z0-9_$()+-]*$/.test(trimmed)) { + throw new Error( + "database must begin with a lower-case letter and contain only lower-case letters, digits, _, $, (, ), +, or -", + ); + } + return trimmed; +} + +function basicAuthorisation(username: string, password: string): string { + return `Basic ${btoa(`${username}:${password}`)}`; +} + +async function requestWithRetry( + dependencies: ProvisioningDependencies, + label: string, + url: string, + init: RequestInit, + accept: (response: Response, body: string) => boolean, + retryCount: number, + retryDelayMs: number, +): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= retryCount; attempt++) { + try { + const response = await dependencies.fetch(url, init); + const body = await response.text(); + if (accept(response, body)) return; + const error = new Error( + `${label} failed with HTTP ${response.status}: ${body}`, + ); + if (response.status < 500) throw error; + lastError = error; + } catch (error) { + lastError = error; + if ( + error instanceof Error && + error.message.startsWith(`${label} failed with HTTP 4`) + ) { + throw error; + } + } + if (attempt < retryCount) await dependencies.sleep(retryDelayMs); + } + throw lastError instanceof Error + ? lastError + : new Error(`${label} failed after ${retryCount} attempts`); +} + +export async function initialiseLiveSyncDatabaseVersion( + databaseURL: string, + username: string, + password: string, +): Promise { + const database = new PouchDB(databaseURL, { + adapter: "http", + auth: { username, password }, + skip_setup: true, + }); + try { + const compatible = await checkRemoteVersion( + database, + async () => false, + ); + if (!compatible) { + throw new Error( + "the remote database uses an incompatible LiveSync database version", + ); + } + } finally { + await database.close(); + } +} + +export async function provisionCouchDB( + options: CouchDBProvisioningOptions, + overrides: Partial = {}, +): Promise { + const hostname = normaliseHostname(options.hostname); + const username = requireValue(options.username, "username"); + const password = requireValue(options.password, "password"); + const node = encodeURIComponent(options.node?.trim() || "_local"); + const origins = options.origins?.trim() || DEFAULT_ORIGINS; + const retryCount = options.retryCount ?? 12; + const retryDelayMs = options.retryDelayMs ?? 5_000; + if (!Number.isInteger(retryCount) || retryCount < 1) { + throw new Error("retryCount must be a positive integer"); + } + if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) { + throw new Error("retryDelayMs must be zero or greater"); + } + + const dependencies: ProvisioningDependencies = { + fetch, + sleep: (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)), + initialiseDatabaseVersion: initialiseLiveSyncDatabaseVersion, + ...overrides, + }; + const headers = { + "Content-Type": "application/json", + Authorization: basicAuthorisation(username, password), + }; + const configure = async ( + label: string, + path: string, + body: string, + method = "PUT", + accept: (response: Response, body: string) => boolean = (response) => + response.ok, + ) => + await requestWithRetry( + dependencies, + label, + `${hostname}${path}`, + { method, headers, body }, + accept, + retryCount, + retryDelayMs, + ); + + await configure( + "single-node cluster setup", + "/_cluster_setup", + JSON.stringify({ + action: "enable_single_node", + username, + password, + bind_address: "0.0.0.0", + port: 5984, + singlenode: true, + }), + "POST", + (response, body) => + response.ok || + ((response.status === 400 || response.status === 409) && + /already|finished/i.test(body)), + ); + + const settings: Array<[string, string, string]> = [ + ["require authenticated HTTP users", "chttpd/require_valid_user", '"true"'], + [ + "require authenticated HTTP users for authentication", + "chttpd_auth/require_valid_user", + '"true"', + ], + [ + "set the HTTP authentication challenge", + "httpd/WWW-Authenticate", + '"Basic realm=\\"couchdb\\""', + ], + ["enable HTTP CORS", "httpd/enable_cors", '"true"'], + ["enable clustered HTTP CORS", "chttpd/enable_cors", '"true"'], + [ + "set the maximum HTTP request size", + "chttpd/max_http_request_size", + '"4294967296"', + ], + [ + "set the maximum document size", + "couchdb/max_document_size", + '"50000000"', + ], + ["enable CORS credentials", "cors/credentials", '"true"'], + ["set allowed CORS origins", "cors/origins", JSON.stringify(origins)], + ]; + for (const [label, key, body] of settings) { + await configure(label, `/_node/${node}/_config/${key}`, body); + } + + if (options.database?.trim()) { + const database = validateDatabaseName(options.database); + const databaseURL = `${hostname}/${encodeURIComponent(database)}`; + await requestWithRetry( + dependencies, + "create database", + databaseURL, + { method: "PUT", headers }, + (response) => response.ok || response.status === 412, + retryCount, + retryDelayMs, + ); + await dependencies.initialiseDatabaseVersion( + databaseURL, + username, + password, + ); + } +} + +function optionalNumber(name: string): number | undefined { + const value = Deno.env.get(name)?.trim(); + return value ? Number(value) : undefined; +} + +if (import.meta.main) { + await provisionCouchDB({ + hostname: Deno.env.get("hostname") ?? "", + username: Deno.env.get("username") ?? "", + password: Deno.env.get("password") ?? "", + node: Deno.env.get("node"), + database: Deno.env.get("database"), + origins: Deno.env.get("origins"), + retryCount: optionalNumber("retry_count"), + retryDelayMs: optionalNumber("retry_delay_ms"), + }); + console.log("CouchDB provisioning completed."); +} diff --git a/utils/flyio/deno.lock b/utils/flyio/deno.lock index 30d3c5eb..7ca3f1f5 100644 --- a/utils/flyio/deno.lock +++ b/utils/flyio/deno.lock @@ -1,25 +1,855 @@ { - "version": "4", + "version": "5", "specifiers": { - "npm:octagonal-wheels@0.1.11": "0.1.11" + "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4": "0.1.0-rc.4" }, "npm": { - "idb@8.0.0": { - "integrity": "sha512-l//qvlAKGmQO31Qn7xdzagVPPaHTxXx199MhrAFuVBTPqydcPYBWjkrbv4Y0ktB+GmWOiwHl237UUOrLmQxLvw==" - }, - "octagonal-wheels@0.1.11": { - "integrity": "sha512-KsXfpziFHmlLEBe5VAXFz9OyyjJEEdSg7xxASqdzmbe5oo9dhcOeWGrQfyipRJwHAhlFkI4vEf8JCSgkcyRxYg==", + "@aws-sdk/checksums@3.1000.18": { + "integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==", "dependencies": [ - "idb", - "xxhash-wasm@0.4.2", - "xxhash-wasm-102@npm:xxhash-wasm@1.0.2" + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" ] }, - "xxhash-wasm@0.4.2": { - "integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==" + "@aws-sdk/client-s3@3.1090.0": { + "integrity": "sha512-R6GX9cd1jljwzZ8xFmgAI/hHCuX1MobIKBdsymv7WL9SENvO9Vgz9KOR6avTnu0Ao+w1LmxnTe+jqmZXEn7Q/Q==", + "dependencies": [ + "@aws-sdk/checksums", + "@aws-sdk/core", + "@aws-sdk/credential-provider-node", + "@aws-sdk/middleware-sdk-s3", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] }, - "xxhash-wasm@1.0.2": { - "integrity": "sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==" + "@aws-sdk/core@3.975.3": { + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "dependencies": [ + "@aws-sdk/types", + "@aws-sdk/xml-builder", + "@aws/lambda-invoke-store", + "@smithy/core", + "@smithy/signature-v4", + "@smithy/types", + "bowser", + "tslib" + ] + }, + "@aws-sdk/credential-provider-env@3.972.59": { + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-http@3.972.61": { + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-ini@3.973.4": { + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-login", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-login@3.972.66": { + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-node@3.972.70": { + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", + "dependencies": [ + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-ini", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-process@3.972.59": { + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-sso@3.973.3": { + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/token-providers", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-web-identity@3.972.65": { + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/middleware-sdk-s3@3.972.64": { + "integrity": "sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/nested-clients@3.997.33": { + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/signature-v4-multi-region@3.996.41": { + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "dependencies": [ + "@aws-sdk/types", + "@smithy/signature-v4", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/token-providers@3.1088.0": { + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/types@3.974.2": { + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/xml-builder@3.972.36": { + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@aws/lambda-invoke-store@0.3.0": { + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, + "@noble/secp256k1@3.1.0": { + "integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==" + }, + "@smithy/core@3.29.5": { + "integrity": "sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@smithy/credential-provider-imds@4.4.10": { + "integrity": "sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/fetch-http-handler@5.6.7": { + "integrity": "sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/md5-js@4.4.10": { + "integrity": "sha512-XI5xhWxRkWuiLNj0/Z30vLTPpZe9UQcg57Ox/n4vzGmFiHSrD+xAmx6ubEcWt6u69IOH+4LAucpaZk5AMxB8oQ==", + "dependencies": [ + "@smithy/core", + "tslib" + ] + }, + "@smithy/middleware-apply-body-checksum@4.5.10": { + "integrity": "sha512-1qFwlILFq+QnI1oqZIBpLda1W6oCkI667GPTyAn0eRan72ddQ/zA1CoKiIezTHLQKxINpPvDKVwsw/znLmKEbg==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/node-http-handler@4.9.7": { + "integrity": "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/signature-v4@5.6.6": { + "integrity": "sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/types@4.16.1": { + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dependencies": [ + "tslib" + ] + }, + "@smithy/util-retry@4.5.10": { + "integrity": "sha512-hYu5ieq8myuO29xCQV0IIhicRm1aO0lcNBeMcF1mVVAVQC0oylPGKFliMq5NbxtdOvRxJWCMuir2gwKI91f1lw==", + "dependencies": [ + "@smithy/core", + "tslib" + ] + }, + "@trystero-p2p/core@0.25.3": { + "integrity": "sha512-lQKNq/ha+vF6kQZrpaXJGzlzxLF/Fhizoy5dwJUYQlkqRrbPup/Jov2EpPX/CPr2X+f79HxVzonkeni3MxmCuQ==" + }, + "@trystero-p2p/nostr@0.25.3": { + "integrity": "sha512-nZV9Fl/GXuhIkJSQ+wIkdMoRLO1oSTUL/+vZorukaojeAdOpT/61e8b9Vzbt8L1ktMTaGPEJHw2BG/B5BCwBuw==", + "dependencies": [ + "@noble/secp256k1", + "@trystero-p2p/core" + ] + }, + "@vrtmrz/livesync-commonlib@0.1.0-rc.4": { + "integrity": "sha512-u4FdbjnYg7lAf38z7eUv4eq4vxEdrl4rFMxiDZiJ7T701awKiflkXGIJQnaHdLaMa3zkRBU15qqEo7GtextmlA==", + "dependencies": [ + "@aws-sdk/client-s3", + "@smithy/fetch-http-handler", + "@smithy/md5-js", + "@smithy/middleware-apply-body-checksum", + "@smithy/types", + "@smithy/util-retry", + "@trystero-p2p/nostr", + "diff-match-patch", + "events", + "fflate", + "idb", + "markdown-it", + "minimatch", + "octagonal-wheels", + "pouchdb-adapter-http", + "pouchdb-adapter-idb", + "pouchdb-adapter-indexeddb", + "pouchdb-adapter-memory", + "pouchdb-core", + "pouchdb-errors", + "pouchdb-find", + "pouchdb-mapreduce", + "pouchdb-merge", + "pouchdb-replication", + "pouchdb-utils", + "qrcode-generator", + "transform-pouch", + "xxhash-wasm-102@npm:xxhash-wasm@1.1.0" + ] + }, + "abstract-leveldown@2.7.2": { + "integrity": "sha512-+OVvxH2rHVEhWLdbudP6p0+dNMXu8JA1CbhP19T8paTYAcX7oJ4OVjT+ZUVpv7mITxXHqDMej+GdqXBmXkw09w==", + "dependencies": [ + "xtend" + ], + "deprecated": true + }, + "abstract-leveldown@6.2.3": { + "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", + "dependencies": [ + "buffer", + "immediate", + "level-concat-iterator", + "level-supports", + "xtend" + ], + "deprecated": true + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "balanced-match@4.0.4": { + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bowser@2.14.1": { + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, + "brace-expansion@5.0.7": { + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dependencies": [ + "balanced-match" + ] + }, + "buffer@5.7.1": { + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "core-util-is@1.0.3": { + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "deferred-leveldown@5.3.0": { + "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", + "dependencies": [ + "abstract-leveldown@6.2.3", + "inherits" + ], + "deprecated": true + }, + "diff-match-patch@1.0.5": { + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==" + }, + "double-ended-queue@2.1.0-0": { + "integrity": "sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ==" + }, + "entities@4.5.0": { + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "errno@0.1.8": { + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dependencies": [ + "prr" + ], + "bin": true + }, + "events@3.3.0": { + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, + "fetch-cookie@2.2.0": { + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "dependencies": [ + "set-cookie-parser", + "tough-cookie" + ] + }, + "fflate@0.8.3": { + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, + "functional-red-black-tree@1.0.1": { + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==" + }, + "idb@8.0.3": { + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==" + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "immediate@3.3.0": { + "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "isarray@0.0.1": { + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "level-codec@9.0.2": { + "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", + "dependencies": [ + "buffer" + ], + "deprecated": true + }, + "level-concat-iterator@2.0.1": { + "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", + "deprecated": true + }, + "level-errors@2.0.1": { + "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", + "dependencies": [ + "errno" + ], + "deprecated": true + }, + "level-iterator-stream@4.0.2": { + "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", + "dependencies": [ + "inherits", + "readable-stream@3.6.2", + "xtend" + ] + }, + "level-supports@1.0.1": { + "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", + "dependencies": [ + "xtend" + ] + }, + "levelup@4.4.0": { + "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", + "dependencies": [ + "deferred-leveldown", + "level-errors", + "level-iterator-stream", + "level-supports", + "xtend" + ], + "deprecated": true + }, + "linkify-it@5.0.2": { + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dependencies": [ + "uc.micro" + ] + }, + "ltgt@2.2.1": { + "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==" + }, + "markdown-it@14.3.0": { + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dependencies": [ + "argparse", + "entities", + "linkify-it", + "mdurl", + "punycode.js", + "uc.micro" + ], + "bin": true + }, + "mdurl@2.0.0": { + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, + "memdown@1.4.1": { + "integrity": "sha512-iVrGHZB8i4OQfM155xx8akvG9FIj+ht14DX5CQkCTG4EHzZ3d3sgckIf/Lm9ivZalEsFuEVnWv2B2WZvbrro2w==", + "dependencies": [ + "abstract-leveldown@2.7.2", + "functional-red-black-tree", + "immediate", + "inherits", + "ltgt", + "safe-buffer@5.1.2" + ], + "deprecated": true + }, + "minimatch@10.2.5": { + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dependencies": [ + "brace-expansion" + ] + }, + "node-fetch@2.6.9": { + "integrity": "sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==", + "dependencies": [ + "whatwg-url" + ] + }, + "octagonal-wheels@0.1.51": { + "integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==", + "dependencies": [ + "idb" + ] + }, + "pouchdb-abstract-mapreduce@9.0.0": { + "integrity": "sha512-SnTtqwAEiAa3uxKbc1J7LfiBViwEkKe2xkK92zxyTXPqWBvMnh4UU3GXxx7GrXTM4L9llsQ3lSjpbH4CNqG1Mw==", + "dependencies": [ + "pouchdb-binary-utils", + "pouchdb-collate", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-mapreduce-utils", + "pouchdb-md5", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-http@9.0.0": { + "integrity": "sha512-2eL008XeRZkdyp3hMHHOhdIPqK9H6Mn4SLlQvit4zCbqnOFfAswzPjUmHULGMbDUCrQBTu6y82FnV6NHXv9kgw==", + "dependencies": [ + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-idb@9.0.0": { + "integrity": "sha512-2oLlgwMyOQwdKuzrEmOv8T7jFVgX7JgT4Cr81zX3eiiRClp7xXGgjv41ZRdVCAbM530sIN8BudafaQRVFKRVmA==", + "dependencies": [ + "pouchdb-adapter-utils", + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-json", + "pouchdb-merge", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-indexeddb@9.0.0": { + "integrity": "sha512-/mcCbnVR0VKwtVZWKf8lVSdADLD0yApjFudu4d+0jeLWAeBSGZBRKYlogz2PGs4uTA7GVc2TXjVCNGUdkCM9ZQ==", + "dependencies": [ + "pouchdb-adapter-utils", + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-md5", + "pouchdb-merge", + "pouchdb-utils" + ] + }, + "pouchdb-adapter-leveldb-core@9.0.0": { + "integrity": "sha512-b3ZGPtVXyivGL5SK3AIDG7PrNsZdoDpGFkmTytDTtctkVhxOg71gnXXP+CrupENPqSNG/eGbKW4w+bbMpxy6aA==", + "dependencies": [ + "double-ended-queue", + "levelup", + "pouchdb-adapter-utils", + "pouchdb-binary-utils", + "pouchdb-core", + "pouchdb-errors", + "pouchdb-json", + "pouchdb-md5", + "pouchdb-merge", + "pouchdb-utils", + "sublevel-pouchdb", + "through2" + ] + }, + "pouchdb-adapter-memory@9.0.0": { + "integrity": "sha512-XbCwJ5f5U9dGdkiDikzYjTebdPHuA6Ghylx1Pq0lDe4y6l8R9xhjDSUy56pJ8G2F4Z+8QdB5FBY9EQoFlFSXWQ==", + "dependencies": [ + "memdown", + "pouchdb-adapter-leveldb-core" + ] + }, + "pouchdb-adapter-utils@9.0.0": { + "integrity": "sha512-hmbm4ey0HL0vtoY1tRTPIt2FfYjvMh3DWoGGSxXDTS73qTFQ+Fhhi5I0AnN9PcD2omfKQAVXiYks4kkMvlAHqA==", + "dependencies": [ + "pouchdb-binary-utils", + "pouchdb-errors", + "pouchdb-md5", + "pouchdb-merge", + "pouchdb-utils" + ] + }, + "pouchdb-binary-utils@9.0.0": { + "integrity": "sha512-2OMtgDZi82vqs+zNDE0YiYjOaWkYCUcZJZKK3WkRr+XYRu+2B7umJrnygJFhUwoGedBbHSrlQBLhdNV3F1AX1A==" + }, + "pouchdb-changes-filter@9.0.0": { + "integrity": "sha512-ig0fo0WLgIjAniFJ19Uw1Y+oxiypqC+Skhd8BCETRVXOhLBzueRwEQR4thffyo0UayYVqldJfSR5wHSDvEVk/A==", + "dependencies": [ + "pouchdb-errors", + "pouchdb-selector-core", + "pouchdb-utils" + ] + }, + "pouchdb-checkpointer@9.0.0": { + "integrity": "sha512-yu1OlWw78oTHKOkg1GoxxF2qB7YUsjK3rUDJOChMs/sVlZwOTZ4mGdWFPBr3udxSGvR77E+g89kpdmAWhPpHvA==", + "dependencies": [ + "pouchdb-collate", + "pouchdb-utils" + ] + }, + "pouchdb-collate@9.0.0": { + "integrity": "sha512-TrnEDNZEmIIl+W3xKUO8h+geqVLQ90oZe5ujPkl8myUzpREULWXWQBnV5EzPXVEKDBpJlb8T3I6oy/zdWGQpdA==" + }, + "pouchdb-core@9.0.0": { + "integrity": "sha512-98SJgs8bqXhr4gMGuOTR8yVeLlMYy797zlOtdlvlXIxIicvocyA8ColhVVhdBXPNOGxT2HwReIMywdIVAgibpg==", + "dependencies": [ + "pouchdb-changes-filter", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-merge", + "pouchdb-utils", + "uuid" + ] + }, + "pouchdb-errors@9.0.0": { + "integrity": "sha512-961PSMLhW0UqqdJ566g+CdLZ5pkBJRd6l4WWpCDdD0USvE4xYfYGzv43w7nZZBw1k3Xdy092yqPge7yX/tfnyw==" + }, + "pouchdb-fetch@9.0.0": { + "integrity": "sha512-TbE3cUcAJQrwb9kr44tDP0X+NAbcqgjsTvcL30L4xzBNJeCPTIRjukYX80s154SHJUXBxcWRiPsMmNqpXsjfCA==", + "dependencies": [ + "fetch-cookie", + "node-fetch" + ] + }, + "pouchdb-find@9.0.0": { + "integrity": "sha512-vvVhq4eEOmSkwSRwf2NBYtdhURB7ryJ7sUI4WDN00GuLUj2g8jAXBJuZIryVgdYt/5S5cfn70iRL6Eow+LFhpA==", + "dependencies": [ + "pouchdb-abstract-mapreduce", + "pouchdb-collate", + "pouchdb-errors", + "pouchdb-fetch", + "pouchdb-md5", + "pouchdb-selector-core", + "pouchdb-utils" + ] + }, + "pouchdb-generate-replication-id@9.0.0": { + "integrity": "sha512-wetxjU0W/qNYtfHIoKwBO73ddUr0/eqzYOkoKHSFXCgOzYmTglDeqXiVY9LPysRXTgaHUJPKC5LoknZZw7e+Dw==", + "dependencies": [ + "pouchdb-collate", + "pouchdb-md5" + ] + }, + "pouchdb-json@9.0.0": { + "integrity": "sha512-aI41mYVyI195GXuT1Ys7mLIB/Mvrz11ihoTP6km6hYqVgSuaUxuZcFUozlyTJiZXr7H5kdhNgclhlVnjir4JAA==", + "dependencies": [ + "vuvuzela" + ] + }, + "pouchdb-mapreduce-utils@9.0.0": { + "integrity": "sha512-Bjh8W6QXqp1j7MKmHhYYp5cYlcQsm5drD8Jd/F+ZlfNt18uiD2SQXWzGM5797+tiW/LszFGb8ttw0uHWjxufCQ==", + "dependencies": [ + "pouchdb-utils" + ] + }, + "pouchdb-mapreduce@9.0.0": { + "integrity": "sha512-ZD8PleQ9atzQAzT2LZWsvooUVEfsen5QGv/SDfci20IleCaFW2A2q7OERrqY0YWKDCCNRsWhPWPmsFvZC9K8DQ==", + "dependencies": [ + "pouchdb-abstract-mapreduce", + "pouchdb-mapreduce-utils", + "pouchdb-utils" + ] + }, + "pouchdb-md5@9.0.0": { + "integrity": "sha512-58xUYBvW3/s+aH0j4uOhhN8yCk0LQ254cxBzI/gbKA9PrfwHpe4zrr0L/ia5ml3A30oH1f8aTnuVMwWDkFcuww==", + "dependencies": [ + "pouchdb-binary-utils", + "spark-md5" + ] + }, + "pouchdb-merge@9.0.0": { + "integrity": "sha512-Xh+TgOZCkGoZpI589btKf/cTiuQ5CsnPl9YpdW4h0cAPusniN6XNsR62F+/HbL9wirI6XTEPHUrk7MsQbk3S3A==", + "dependencies": [ + "pouchdb-utils" + ] + }, + "pouchdb-replication@9.0.0": { + "integrity": "sha512-EZ68KJ3ZUWuPe35NxP6WnRw8J6Zudf0j/tZ/6mOSrCcp3EbtBNt8Ke2FaAThUgiFahVnHD5Y8nd53EGs2DLygg==", + "dependencies": [ + "pouchdb-checkpointer", + "pouchdb-errors", + "pouchdb-generate-replication-id", + "pouchdb-utils" + ] + }, + "pouchdb-selector-core@9.0.0": { + "integrity": "sha512-ZYHYsdoedwm8j5tYofz+3+uUSK8i+7tRCBb01T0OuqDQb17+w5mzjHF8Ppi160xdPUPaWCo1Un+nLWGJzkmA3g==", + "dependencies": [ + "pouchdb-collate", + "pouchdb-utils" + ] + }, + "pouchdb-utils@9.0.0": { + "integrity": "sha512-xWZE5c+nAslgmLC8JBZbky8AYgdz7pKtv7KTSi6CD2tuQD0WyNKib0YnhZndeE84dksTeZlqlg56RQHsHoB2LQ==", + "dependencies": [ + "pouchdb-errors", + "pouchdb-md5", + "uuid" + ] + }, + "pouchdb-wrappers@5.0.0": { + "integrity": "sha512-fXqsVn+rmlPtxaAIGaQP5TkiaT39OMwvMk+ScLLtHrmfXD2KBO6fe/qBl38N/rpTn0h/A058dPN4fLAHt550zA==" + }, + "prr@1.0.1": { + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==" + }, + "psl@1.15.0": { + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dependencies": [ + "punycode" + ] + }, + "punycode.js@2.3.1": { + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "qrcode-generator@1.5.2": { + "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==" + }, + "querystringify@2.2.0": { + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "readable-stream@1.1.14": { + "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==", + "dependencies": [ + "core-util-is", + "inherits", + "isarray", + "string_decoder@0.10.31" + ] + }, + "readable-stream@3.6.2": { + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": [ + "inherits", + "string_decoder@1.3.0", + "util-deprecate" + ] + }, + "requires-port@1.0.0": { + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "safe-buffer@5.1.2": { + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "set-cookie-parser@2.7.2": { + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "spark-md5@3.0.2": { + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==" + }, + "string_decoder@0.10.31": { + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" + }, + "string_decoder@1.3.0": { + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": [ + "safe-buffer@5.2.1" + ] + }, + "sublevel-pouchdb@9.0.0": { + "integrity": "sha512-pX4r8+F7wuts0C81kUJ341h4bl2aRe7qV572FE8X1FMz9VkKlmi2nPD1vfeiOJXz5Y09I4MHjGULAbqvTfQZEQ==", + "dependencies": [ + "level-codec", + "ltgt", + "readable-stream@1.1.14" + ] + }, + "through2@3.0.2": { + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "dependencies": [ + "inherits", + "readable-stream@3.6.2" + ] + }, + "tough-cookie@4.1.4": { + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dependencies": [ + "psl", + "punycode", + "universalify", + "url-parse" + ] + }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "transform-pouch@2.0.0": { + "integrity": "sha512-nDZovo0U5o0UdMNL93fMQgGjrwH9h4F/a7qqRTnF6cVA+FfgyXiJPTrSuD+LmWSO7r2deZt0P0oeCD8hkgxl5g==", + "dependencies": [ + "pouchdb-wrappers" + ] + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "uc.micro@2.1.0": { + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "universalify@0.2.0": { + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==" + }, + "url-parse@1.5.10": { + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": [ + "querystringify", + "requires-port" + ] + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "uuid@8.3.2": { + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": true, + "bin": true + }, + "vuvuzela@1.0.3": { + "integrity": "sha512-Tm7jR1xTzBbPW+6y1tknKiEhz04Wf/1iZkcTJjSFcpNko43+dFW6+OOeQe9taJIug3NdfUAjFKgUSyQrIKaDvQ==" + }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==" + }, + "xxhash-wasm@1.1.0": { + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" } } } diff --git a/utils/flyio/deploy-server.sh b/utils/flyio/deploy-server.sh index 967c1760..9c59329c 100755 --- a/utils/flyio/deploy-server.sh +++ b/utils/flyio/deploy-server.sh @@ -1,8 +1,15 @@ #!/bin/bash ## Script for deploy and automatic setup CouchDB onto fly.io. -## We need Deno for generating the Setup-URI. +## Deno is used for Commonlib-backed provisioning and Setup URI generation. -source setenv.sh $@ +set -euo pipefail + +if ! command -v deno >/dev/null 2>&1; then + echo "ERROR: Deno 2 is required for CouchDB provisioning and Setup URI generation." >&2 + exit 1 +fi + +source setenv.sh "$@" export hostname="https://$appname.fly.dev" @@ -14,30 +21,16 @@ echo "region : $region" echo "" echo "-- START DEPLOYING --> " -set -e fly launch --name=$appname --env="COUCHDB_USER=$username" --copy-config=true --detach --no-deploy --region ${region} --yes fly secrets set COUCHDB_PASSWORD=$password fly deploy -set +e ../couchdb/couchdb-init.sh -# flyctl deploy echo "OK!" -if command -v deno >/dev/null 2>&1; then - echo "Setup finished! Also, we can set up Self-hosted LiveSync instantly, by the following setup uri." - echo "Passphrase of setup-uri will be printed only one time. Keep it safe!" - echo "--- configured ---" - echo "database : ${database}" - echo "E2EE passphrase: ${passphrase}" - echo "--- setup uri ---" - deno run -A generate_setupuri.ts -else - echo "Setup finished! Here is the configured values (reprise)!" - echo "-- YOUR CONFIGURATION --" - echo "URL : $hostname" - echo "username: $username" - echo "password: $password" - echo "-- YOUR CONFIGURATION --" - echo "If we had Deno, we would got the setup uri directly!" -fi +echo "Setup finished. The Commonlib-generated Setup URI follows." +echo "Its passphrase is printed only once, so store it safely." +echo "--- configured ---" +echo "database: ${database}" +echo "--- setup URI ---" +deno run --minimum-dependency-age=0 --allow-env generate_setupuri.ts diff --git a/utils/flyio/generate_setupuri.test.ts b/utils/flyio/generate_setupuri.test.ts new file mode 100644 index 00000000..ec36f369 --- /dev/null +++ b/utils/flyio/generate_setupuri.test.ts @@ -0,0 +1,72 @@ +import { + decodeSettingsFromSetupURI, + DEFAULT_SETTINGS, +} from "../setup/livesync-commonlib.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("generates a current self-hosted Setup URI through the published Commonlib contract", async () => { + const scriptPath = new URL("./generate_setupuri.ts", import.meta.url); + const command = new Deno.Command(Deno.execPath(), { + args: ["run", "-A", scriptPath.pathname], + env: { + hostname: "https://couch.example.test", + username: "alice", + password: "couch-secret", + database: "notes", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }, + stdout: "piped", + stderr: "piped", + }); + + const result = await command.output(); + const stdout = new TextDecoder().decode(result.stdout); + const stderr = new TextDecoder().decode(result.stderr); + assert(result.success, `generator failed:\n${stdout}\n${stderr}`); + + const setupURI = stdout.match(/obsidian:\/\/setuplivesync\?settings=\S+/) + ?.[0]; + assert(setupURI, `generator did not print a Setup URI:\n${stdout}`); + + const decoded = await decodeSettingsFromSetupURI(setupURI, "setup-secret"); + assert(decoded, "Commonlib could not decode the generated Setup URI"); + const effectiveSettings = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + effectiveSettings.isConfigured, + "the CouchDB Setup URI left the imported device unconfigured", + ); + assert( + effectiveSettings.customChunkSize === 60, + "the Setup URI did not use the current self-hosted chunk-size recommendation", + ); + assert( + effectiveSettings.chunkSplitterVersion === "v3-rabin-karp", + "the Setup URI did not use the current chunk splitter", + ); + assert( + effectiveSettings.E2EEAlgorithm === "v2", + "the Setup URI did not use the current E2EE algorithm", + ); + assert( + !Object.hasOwn(decoded, "doNotUseFixedRevisionForChunks"), + "the Setup URI serialised the obsolete fixed-revision compatibility setting", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one CouchDB remote profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the CouchDB remote profile was not selected", + ); + assert( + profiles[0].uri.startsWith("sls+https://"), + "the selected remote profile was not a CouchDB connection URI", + ); +}); diff --git a/utils/flyio/generate_setupuri.ts b/utils/flyio/generate_setupuri.ts index 390c2e1c..5df0e7d6 100644 --- a/utils/flyio/generate_setupuri.ts +++ b/utils/flyio/generate_setupuri.ts @@ -1,175 +1,6 @@ -import { encrypt } from "npm:octagonal-wheels@0.1.30/encryption/encryption"; +import { runSetupURIGenerator } from "../setup/generate_setup_uri.ts"; -const noun = [ - "waterfall", - "river", - "breeze", - "moon", - "rain", - "wind", - "sea", - "morning", - "snow", - "lake", - "sunset", - "pine", - "shadow", - "leaf", - "dawn", - "glitter", - "forest", - "hill", - "cloud", - "meadow", - "sun", - "glade", - "bird", - "brook", - "butterfly", - "bush", - "dew", - "dust", - "field", - "fire", - "flower", - "firefly", - "feather", - "grass", - "haze", - "mountain", - "night", - "pond", - "darkness", - "snowflake", - "silence", - "sound", - "sky", - "shape", - "surf", - "thunder", - "violet", - "water", - "wildflower", - "wave", - "water", - "resonance", - "sun", - "log", - "dream", - "cherry", - "tree", - "fog", - "frost", - "voice", - "paper", - "frog", - "smoke", - "star", -]; -const adjectives = [ - "autumn", - "hidden", - "bitter", - "misty", - "silent", - "empty", - "dry", - "dark", - "summer", - "icy", - "delicate", - "quiet", - "white", - "cool", - "spring", - "winter", - "patient", - "twilight", - "dawn", - "crimson", - "wispy", - "weathered", - "blue", - "billowing", - "broken", - "cold", - "damp", - "falling", - "frosty", - "green", - "long", - "late", - "lingering", - "bold", - "little", - "morning", - "muddy", - "old", - "red", - "rough", - "still", - "small", - "sparkling", - "thrumming", - "shy", - "wandering", - "withered", - "wild", - "black", - "young", - "holy", - "solitary", - "fragrant", - "aged", - "snowy", - "proud", - "floral", - "restless", - "divine", - "polished", - "ancient", - "purple", - "lively", - "nameless", -]; -function friendlyString() { - return `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${noun[Math.floor(Math.random() * noun.length)]}`; -} - -const uri_passphrase = `${Deno.env.get("uri_passphrase") ?? friendlyString()}`; - -const URIBASE = "obsidian://setuplivesync?settings="; -async function main() { - const conf = { - couchDB_URI: `${Deno.env.get("hostname")}`, - couchDB_USER: `${Deno.env.get("username")}`, - couchDB_PASSWORD: `${Deno.env.get("password")}`, - couchDB_DBNAME: `${Deno.env.get("database")}`, - syncOnStart: true, - gcDelay: 0, - periodicReplication: true, - syncOnFileOpen: true, - encrypt: true, - passphrase: `${Deno.env.get("passphrase")}`, - usePathObfuscation: true, - batchSave: true, - batch_size: 50, - batches_limit: 50, - useHistory: true, - disableRequestURI: true, - customChunkSize: 50, - syncAfterMerge: false, - concurrencyOfReadChunksOnline: 100, - minimumIntervalOfReadChunksOnline: 100, - handleFilenameCaseSensitive: false, - doNotUseFixedRevisionForChunks: false, - settingVersion: 10, - notifyThresholdOfRemoteStorageSize: 800, - }; - const encryptedConf = encodeURIComponent(await encrypt(JSON.stringify(conf), uri_passphrase, false)); - const theURI = `${URIBASE}${encryptedConf}`; - console.log("\nYour passphrase of Setup-URI is: ", uri_passphrase); - console.log("This passphrase is never shown again, so please note it in a safe place."); - console.log(theURI); -} -await main(); +await runSetupURIGenerator({ + ...Deno.env.toObject(), + remote_type: "couchdb", +}); diff --git a/utils/flyio/setenv.sh b/utils/flyio/setenv.sh index 1c1f4c1f..50e2dff3 100755 --- a/utils/flyio/setenv.sh +++ b/utils/flyio/setenv.sh @@ -1,19 +1,23 @@ random_num() { - echo $RANDOM + echo "$RANDOM" } random_noun() { nouns=("waterfall" "river" "breeze" "moon" "rain" "wind" "sea" "morning" "snow" "lake" "sunset" "pine" "shadow" "leaf" "dawn" "glitter" "forest" "hill" "cloud" "meadow" "sun" "glade" "bird" "brook" "butterfly" "bush" "dew" "dust" "field" "fire" "flower" "firefly" "feather" "grass" "haze" "mountain" "night" "pond" "darkness" "snowflake" "silence" "sound" "sky" "shape" "surf" "thunder" "violet" "water" "wildflower" "wave" "water" "resonance" "sun" "log" "dream" "cherry" "tree" "fog" "frost" "voice" "paper" "frog" "smoke" "star") - echo ${nouns[$(($RANDOM % ${#nouns[*]}))]} + echo "${nouns[$((RANDOM % ${#nouns[*]}))]}" } random_adjective() { adjectives=("autumn" "hidden" "bitter" "misty" "silent" "empty" "dry" "dark" "summer" "icy" "delicate" "quiet" "white" "cool" "spring" "winter" "patient" "twilight" "dawn" "crimson" "wispy" "weathered" "blue" "billowing" "broken" "cold" "damp" "falling" "frosty" "green" "long" "late" "lingering" "bold" "little" "morning" "muddy" "old" "red" "rough" "still" "small" "sparkling" "thrumming" "shy" "wandering" "withered" "wild" "black" "young" "holy" "solitary" "fragrant" "aged" "snowy" "proud" "floral" "restless" "divine" "polished" "ancient" "purple" "lively" "nameless") - echo ${adjectives[$(($RANDOM % ${#adjectives[*]}))]} + echo "${adjectives[$((RANDOM % ${#adjectives[*]}))]}" +} + +random_secret() { + deno eval 'const bytes=crypto.getRandomValues(new Uint8Array(24));console.log(btoa(String.fromCharCode(...bytes)).replaceAll("+","-").replaceAll("/","_").replace(/=+$/, ""));' } cp ./fly.template.toml ./fly.toml -if [ "$1" = "renew" ]; then +if [ "${1:-}" = "renew" ]; then unset appname unset username unset password @@ -22,9 +26,9 @@ if [ "$1" = "renew" ]; then unset region fi -[ -z $appname ] && export appname=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $username ] && export username=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $password ] && export password=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $database ] && export database="obsidiannotes" -[ -z $passphrase ] && export passphrase=$(random_adjective)-$(random_noun)-$(random_num) -[ -z $region ] && export region="nrt" +[ -z "${appname:-}" ] && export appname="$(random_adjective)-$(random_noun)-$(random_num)" +[ -z "${username:-}" ] && export username="$(random_adjective)-$(random_noun)-$(random_num)" +[ -z "${password:-}" ] && export password="$(random_secret)" +[ -z "${database:-}" ] && export database="obsidiannotes" +[ -z "${passphrase:-}" ] && export passphrase="$(random_secret)" +[ -z "${region:-}" ] && export region="nrt" diff --git a/utils/flyio/setenv.test.sh b/utils/flyio/setenv.test.sh new file mode 100644 index 00000000..82886754 --- /dev/null +++ b/utils/flyio/setenv.test.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -euo pipefail + +fixture_dir="$(mktemp -d)" +trap 'rm -rf "$fixture_dir"' EXIT +cp "$(dirname "$0")/setenv.sh" "$(dirname "$0")/fly.template.toml" "$fixture_dir/" + +( + cd "$fixture_dir" + appname="" + username="" + password="" + database="" + passphrase="" + region="" + source ./setenv.sh keep + + [[ "$password" =~ ^[A-Za-z0-9_-]{32}$ ]] || { + echo "generated CouchDB password is not a 32-character base64url secret" >&2 + exit 1 + } + [[ "$passphrase" =~ ^[A-Za-z0-9_-]{32}$ ]] || { + echo "generated Vault encryption passphrase is not a 32-character base64url secret" >&2 + exit 1 + } +) diff --git a/utils/livesync-commonlib-version.test.ts b/utils/livesync-commonlib-version.test.ts new file mode 100644 index 00000000..8ffacdc3 --- /dev/null +++ b/utils/livesync-commonlib-version.test.ts @@ -0,0 +1,27 @@ +import { LIVESYNC_COMMONLIB_VERSION } from "./livesync-commonlib-version.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("standalone utilities select one exact Commonlib registry version", async () => { + const facadeURLs = [ + new URL("./setup/livesync-commonlib.ts", import.meta.url), + new URL("./couchdb/livesync-commonlib.ts", import.meta.url), + ]; + const selectedVersions = ( + await Promise.all(facadeURLs.map((url) => Deno.readTextFile(url))) + ).flatMap((source) => + [...source.matchAll( + /npm:@vrtmrz\/livesync-commonlib@([^/"]+)\//gu, + )].map((match) => match[1]) + ); + + assert(selectedVersions.length > 0, "no Commonlib npm specifier was found"); + assert( + selectedVersions.every((version) => version === LIVESYNC_COMMONLIB_VERSION), + `Commonlib specifiers do not all select ${LIVESYNC_COMMONLIB_VERSION}: ${ + selectedVersions.join(", ") + }`, + ); +}); diff --git a/utils/livesync-commonlib-version.ts b/utils/livesync-commonlib-version.ts new file mode 100644 index 00000000..945f0039 --- /dev/null +++ b/utils/livesync-commonlib-version.ts @@ -0,0 +1,5 @@ +// The standalone Deno utilities deliberately remain pinned to one immutable +// Commonlib registry release. Static npm specifiers cannot interpolate this +// value, so livesync-commonlib-version.test.ts verifies the domain-specific +// facades against it. +export const LIVESYNC_COMMONLIB_VERSION = "0.1.0-rc.4"; diff --git a/utils/readme.md b/utils/readme.md index 6fe52ac9..df4c3c64 100644 --- a/utils/readme.md +++ b/utils/readme.md @@ -1,167 +1,84 @@ - # Utilities -Here are some useful things. -## couchdb +These utilities support self-hosted CouchDB provisioning and Setup URI generation. They consume an exact immutable `@vrtmrz/livesync-commonlib` registry version declared in `livesync-commonlib-version.ts`; the utility lockfile records its resolved package integrity. This selection is independent of the plug-in's runtime dependency and advances only when the utility behaviour is revalidated against a newer package. The setup-tool test keeps the domain-specific static npm specifiers aligned with that declaration. Update the declaration, the two facades, and the lockfile together when selecting a newer release. -### couchdb-init.sh -This script can configure CouchDB with the necessary settings by REST APIs. +## CouchDB provisioning -#### Materials -- Mandatory: curl +`couchdb/couchdb-init.sh` is a Bash wrapper for the Deno provisioning tool. Deno 2 is required. The tool configures single-node CouchDB, authenticated access, CORS for Obsidian, and the request and document size limits used by Self-hosted LiveSync. -#### Usage +Set `database` to create a database as part of provisioning. The tool then uses Commonlib's database negotiation contract to initialise and verify the LiveSync database version. If `database` is omitted, only the CouchDB server is configured and the first LiveSync client remains responsible for creating its database. ```sh -export hostname=http://localhost:5984/ +export hostname=http://localhost:5984 export username=couchdb-admin-username export password=couchdb-admin-password -./couchdb-init.sh +export database=obsidiannotes +./couchdb/couchdb-init.sh ``` -curl result will be shown, however, all of them can be ignored if the script has been run completely. +Optional variables are: -## fly.io +- `node`, which defaults to `_local`; +- `origins`, which defaults to the supported Obsidian desktop, mobile, and local origins; +- `retry_count`, which defaults to `12`; and +- `retry_delay_ms`, which defaults to `5000`. -### deploy-server.sh +Authentication and other non-retryable HTTP failures stop immediately. Network and server failures are retried within the configured bound. -A fully automated CouchDB deployment script. We can deploy CouchDB onto fly.io. The only we need is an account of it. +## Setup URI generation -All omitted configurations will be determined at random. (And, it is preferred). The region is configured to `nrt`. -If Japan is not close to you, please choose a region closer to you. However, the deployed database will work if you leave it at all. +`setup/generate_setup_uri.ts` creates current CouchDB, Object Storage, and P2P configurations from Commonlib's new-Vault and remote-specific presets. It stores the connection as the selected remote profile and encodes the result with Commonlib's Setup URI contract. Set `remote_type` to `couchdb`, `s3`, or `p2p`; CouchDB is the default. -#### Materials -- Mandatory: curl, flyctl -- Recommended: deno +The existing `flyio/generate_setupuri.ts` path remains a CouchDB-only compatibility wrapper for the Fly.io deployment script. + +### CouchDB -#### Usage ```sh -#export appname= -#export username= -#export password= -#export database= -#export passphrase= -export region=nrt #pick your nearest location +export hostname=https://couch.example.com +export username=couchdb-admin-username +export password=couchdb-admin-password +export database=obsidiannotes +export passphrase=a-strong-vault-encryption-passphrase +export uri_passphrase=a-separate-setup-uri-passphrase # Optional +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +If `uri_passphrase` is omitted, the tool generates and prints a cryptographically random one. Store the Setup URI and its passphrase separately. The `passphrase` value protects synchronised Vault data and must also be stored safely. + +### Object Storage + +```sh +export remote_type=s3 +export endpoint=https://objects.example.com +export access_key= +export secret_key= +export bucket=vault-data +export region=auto +export bucket_prefix=team-a # Optional +export passphrase= +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +Optional Object Storage variables are `use_custom_request_handler`, `force_path_style`, and `bucket_custom_headers`. + +### P2P + +```sh +export remote_type=p2p +export passphrase= +deno run --minimum-dependency-age=0 --config=./flyio/deno.jsonc --frozen --lock=./flyio/deno.lock --allow-env ./setup/generate_setup_uri.ts +``` + +If `p2p_room_id` or `p2p_passphrase` is omitted, the tool generates it with Commonlib's room-ID contract or cryptographically secure randomness. Optional variables are `p2p_relays`, `p2p_app_id`, `p2p_auto_start`, and `p2p_auto_broadcast`. Auto-start and auto-broadcast retain Commonlib's disabled defaults unless explicitly enabled. A peer name is deliberately absent because it identifies one device and must not be copied to another device through a Setup URI. + +## Fly.io deployment + +`flyio/deploy-server.sh` deploys CouchDB through `flyctl`, provisions the selected database through the tool above, and prints a Commonlib-generated Setup URI. Both `flyctl` and Deno 2 are required. + +```sh +export region=nrt # Choose a nearby Fly.io region. +cd flyio ./deploy-server.sh ``` -The result of this command is as follows. - -``` --- YOUR CONFIGURATION -- -URL : https://young-darkness-25342.fly.dev -username: billowing-cherry-22580 -password: misty-dew-13571 -region : nrt - --- START DEPLOYING --> -An existing fly.toml file was found -Using build strategies '[the "couchdb:latest" docker image]'. Remove [build] from fly.toml to force a rescan -Creating app in /home/vorotamoroz/dev/obsidian-livesync/utils/flyio -We're about to launch your app on Fly.io. Here's what you're getting: - -Organization: vorotamoroz (fly launch defaults to the personal org) -Name: young-darkness-25342 (specified on the command line) -Region: Tokyo, Japan (specified on the command line) -App Machines: shared-cpu-1x, 256MB RAM (specified on the command line) -Postgres: (not requested) -Redis: (not requested) - -Created app 'young-darkness-25342' in organization 'personal' -Admin URL: https://fly.io/apps/young-darkness-25342 -Hostname: young-darkness-25342.fly.dev -Wrote config file fly.toml -Validating /home/vorotamoroz/dev/obsidian-livesync/utils/flyio/fly.toml -Platform: machines -✓ Configuration is valid -Your app is ready! Deploy with `flyctl deploy` -Secrets are staged for the first deployment -==> Verifying app config -Validating /home/vorotamoroz/dev/obsidian-livesync/utils/flyio/fly.toml -Platform: machines -✓ Configuration is valid ---> Verified app config -==> Building image -Searching for image 'couchdb:latest' remotely... -image found: img_ox20prk63084j1zq - -Watch your deployment at https://fly.io/apps/young-darkness-25342/monitoring - -Provisioning ips for young-darkness-25342 - Dedicated ipv6: 2a09:8280:1::37:fde9 - Shared ipv4: 66.241.124.163 - Add a dedicated ipv4 with: fly ips allocate-v4 - -Creating a 1 GB volume named 'couchdata' for process group 'app'. Use 'fly vol extend' to increase its size -This deployment will: - * create 1 "app" machine - -No machines in group app, launching a new machine - -WARNING The app is not listening on the expected address and will not be reachable by fly-proxy. -You can fix this by configuring your app to listen on the following addresses: - - 0.0.0.0:5984 -Found these processes inside the machine with open listening sockets: - PROCESS | ADDRESSES ------------------*--------------------------------------- - /.fly/hallpass | [fdaa:0:73b9:a7b:22e:3851:7f28:2]:22 - -Finished launching new machines - -NOTE: The machines for [app] have services with 'auto_stop_machines = true' that will be stopped when idling - -------- -Checking DNS configuration for young-darkness-25342.fly.dev - -Visit your newly deployed app at https://young-darkness-25342.fly.dev/ --- Configuring CouchDB by REST APIs... --> -curl: (35) OpenSSL SSL_connect: Connection reset by peer in connection to young-darkness-25342.fly.dev:443 -{"ok":true} -"" -"" -"" -"" -"" -"" -"" -"" -"" -<-- Configuring CouchDB by REST APIs Done! -OK! -Setup finished! Also, we can set up Self-hosted LiveSync instantly, by the following setup uri. -Passphrase of setup-uri will be printed only one time. Keep it safe! ---- configured --- -database : obsidiannotes -E2EE passphrase: dark-wildflower-26467 ---- setup uri --- -obsidian://setuplivesync?settings=%5B%22gZkBwjFbLqxbdSIbJymU%2FmTPBPAKUiHVGDRKYiNnKhW0auQeBgJOfvnxexZtMCn8sNiIUTAlxNaMGF2t%2BCEhpJoeCP%2FO%2BrwfN5LaNDQyky1Uf7E%2B64A5UWyjOYvZDOgq4iCKSdBAXp9oO%2BwKh4MQjUZ78vIVvJp8Mo6NWHfm5fkiWoAoddki1xBMvi%2BmmN%2FhZatQGcslVb9oyYWpZocduTl0a5Dv%2FQviGwlYQ%2F4NY0dVDIoOdvaYS%2FX4GhNAnLzyJKMXhPEJHo9FvR%2FEOBuwyfMdftV1SQUZ8YDCuiR3T7fh7Kn1c6OFgaFMpFm%2BWgIJ%2FZpmAyhZFpEcjpd7ty%2BN9kfd9gQsZM4%2BYyU9OwDd2DahVMBWkqoV12QIJ8OlJScHHdcUfMW5ex%2F4UZTWKNEHJsigITXBrtq11qGk3rBfHys8O0vY6sz%2FaYNM3iAOsR1aoZGyvwZm4O6VwtzK8edg0T15TL4O%2B7UajQgtCGxgKNYxb8EMOGeskv7NifYhjCWcveeTYOJzBhnIDyRbYaWbkAXQgHPBxzJRkkG%2FpBPfBBoJarj7wgjMvhLJ9xtL4FbP6sBNlr8jtAUCoq4L7LJcRNF4hlgvjJpL2BpFZMzkRNtUBcsRYR5J%2BM1X2buWi2BHncbSiRRDKEwNOQkc%2FmhMJjbAn%2F8eNKRuIICOLD5OvxD7FZNCJ0R%2BWzgrzcNV%22%2C%22ec7edc900516b4fcedb4c7cc01000000%22%2C%22fceb5fe54f6619ee266ed9a887634e07%22%5D - -Your passphrase of Setup-URI is: patient-haze -This passphrase is never shown again, so please note it in a safe place. -``` - -All we have to do is copy the setup-URI (`obsidian`://...`) and open it from Self-hosted LiveSync on Obsidian. - -If you did not install Deno, configurations will be printed again, instead of the setup-URI. In this case, we should configure it manually. - -### delete-server.sh - -The pair script of `deploy-server.sh`. We can delete the deployed server by this with fly.toml. - -#### Materials - -- Mandatory: flyctl, jq -- Recommended: none - -#### Usage -```sh -./delete-server.sh -``` - -``` -App 'young-darkness-25342 is going to be scaled according to this plan: - -1 machines for group 'app' on region 'nrt' of size 'shared-cpu-1x' -Executing scale plan - Destroyed e28667eec57158 group:app region:nrt size:shared-cpu-1x -Destroyed app young-darkness-25342 -``` \ No newline at end of file +Set `appname`, `username`, `password`, `database`, `passphrase`, or `region` before running the script to override its generated values. Use `delete-server.sh` to remove the Fly.io application described by the generated `fly.toml`. diff --git a/utils/release-notes.mjs b/utils/release-notes.mjs new file mode 100644 index 00000000..ed2ef2f8 --- /dev/null +++ b/utils/release-notes.mjs @@ -0,0 +1,148 @@ +import { readFileSync, writeFileSync, writeSync } from "fs"; + +const updatesPath = "updates.md"; + +// Utility used by the release workflows to rotate and validate `updates.md`. +// It intentionally keeps the Markdown format simple: top-level `##` headings +// are treated as release boundaries, and only one `## Unreleased` section is +// allowed. + +function fail(message) { + writeSync(process.stderr.fd, `${message}\n`); + process.exit(1); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function assertVersion(version) { + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { + fail(`Invalid release version: ${version}`); + } +} + +function formatReleaseDate(date = new Date()) { + const day = date.getUTCDate(); + const suffix = + day % 10 === 1 && day !== 11 + ? "st" + : day % 10 === 2 && day !== 12 + ? "nd" + : day % 10 === 3 && day !== 13 + ? "rd" + : "th"; + const month = new Intl.DateTimeFormat("en-GB", { month: "long", timeZone: "UTC" }).format(date); + const year = date.getUTCFullYear(); + return `${day}${suffix} ${month}, ${year}`; +} + +function headingPattern(heading) { + return new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m"); +} + +// Return a `##` section body without interpreting lower-level headings. +function findSection(markdown, heading) { + const pattern = headingPattern(heading); + const match = pattern.exec(markdown); + if (!match) return undefined; + + const headingStart = match.index; + const bodyStart = match.index + match[0].length; + const rest = markdown.slice(bodyStart); + const next = /^## .+$/m.exec(rest); + const end = next ? bodyStart + next.index : markdown.length; + + return { + headingStart, + bodyStart, + end, + body: markdown.slice(bodyStart, end), + }; +} + +function assertSingleUnreleased(markdown) { + const matches = markdown.match(/^## Unreleased\s*$/gm) || []; + if (matches.length !== 1) { + fail(`Expected exactly one '## Unreleased' section in ${updatesPath}, found ${matches.length}.`); + } +} + +function prepare(version) { + assertVersion(version); + const markdown = readFileSync(updatesPath, "utf8"); + assertSingleUnreleased(markdown); + + if (headingPattern(version).test(markdown)) { + fail(`Release notes for ${version} already exist in ${updatesPath}.`); + } + + const unreleased = findSection(markdown, "Unreleased"); + if (!unreleased) fail(`Could not find '## Unreleased' in ${updatesPath}.`); + + const allowEmpty = process.env.ALLOW_EMPTY_UPDATES === "true"; + if (!allowEmpty && unreleased.body.trim() === "") { + fail(`The '## Unreleased' section is empty. Set ALLOW_EMPTY_UPDATES=true if this is intentional.`); + } + + // Keep a fresh empty Unreleased section above the newly dated release notes. + const releaseSuffix = unreleased.end < markdown.length ? "\n\n" : "\n"; + const releasedBody = `${unreleased.body.trim()}${releaseSuffix}`; + const releaseDate = process.env.RELEASE_DATE || formatReleaseDate(); + const replacement = `## Unreleased\n\n## ${version}\n\n${releaseDate}\n\n${releasedBody}`; + const nextMarkdown = markdown.slice(0, unreleased.headingStart) + replacement + markdown.slice(unreleased.end); + writeFileSync(updatesPath, nextMarkdown, "utf8"); +} + +function validate(version) { + assertVersion(version); + + const rootPackage = readJson("package.json"); + const manifest = readJson("manifest.json"); + if (rootPackage.version !== version) { + fail(`package.json version is ${rootPackage.version}, expected ${version}.`); + } + if (manifest.version !== version) { + fail(`manifest.json version is ${manifest.version}, expected ${version}.`); + } + + for (const workspace of ["cli", "webpeer", "webapp"]) { + const workspacePackage = readJson(`src/apps/${workspace}/package.json`); + const expected = `${version}-${workspace}`; + if (workspacePackage.version !== expected) { + fail(`src/apps/${workspace}/package.json version is ${workspacePackage.version}, expected ${expected}.`); + } + } + + const versions = readJson("versions.json"); + if (versions[version] !== manifest.minAppVersion) { + fail(`versions.json does not map ${version} to manifest minAppVersion ${manifest.minAppVersion}.`); + } + + const markdown = readFileSync(updatesPath, "utf8"); + assertSingleUnreleased(markdown); + + const releaseSection = findSection(markdown, version); + if (!releaseSection) { + fail(`Could not find '## ${version}' in ${updatesPath}.`); + } + if (releaseSection.body.trim() === "") { + fail(`The release notes for ${version} are empty.`); + } + if (/\b(?:TODO|WIP)\b/i.test(releaseSection.body)) { + fail(`The release notes for ${version} still contain TODO or WIP markers.`); + } +} + +const [command, version] = process.argv.slice(2); +if (!command || !version) { + fail("Usage: node utils/release-notes.mjs "); +} + +if (command === "prepare") { + prepare(version); +} else if (command === "validate") { + validate(version); +} else { + fail(`Unknown command: ${command}`); +} diff --git a/utils/release-pr-body.mjs b/utils/release-pr-body.mjs new file mode 100644 index 00000000..18baa602 --- /dev/null +++ b/utils/release-pr-body.mjs @@ -0,0 +1,99 @@ +import { pathToFileURL } from "node:url"; + +/** + * Escape a value for use as inline Markdown code. + * + * Use a fence longer than any backtick run in the value so that branch names + * supplied by a caller cannot break the surrounding Markdown. + * + * @param {string} value + * @returns {string} + */ +function inlineCode(value) { + const backtickRuns = value.match(/`+/g) ?? []; + const fenceLength = Math.max(1, ...backtickRuns.map((run) => run.length + 1)); + const fence = "`".repeat(fenceLength); + const padding = value.startsWith("`") || value.endsWith("`") ? " " : ""; + return `${fence}${padding}${value}${padding}${fence}`; +} + +/** + * Render the reader-facing checklist for a draft release pull request. + * + * 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 + * @returns {string} + */ +export function renderReleasePrBody(version, baseBranch) { + const selectedVersion = version.trim(); + const selectedBaseBranch = baseBranch.trim(); + if (selectedVersion.length === 0) throw new Error("A release version is required."); + if (selectedBaseBranch.length === 0) throw new Error("A base branch is required."); + + const versionCode = inlineCode(selectedVersion); + const baseBranchCode = inlineCode(selectedBaseBranch); + const isPrerelease = selectedVersion.includes("-"); + const purpose = isPrerelease + ? `an immutable pre-release for BRAT validation without replacing the latest 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, `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 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. 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", + ] + : [ + `- [ ] 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", + ]; + + return [ + `This release pull request prepares Self-hosted LiveSync ${versionCode} from ${baseBranchCode} as ${purpose}.`, + "", + "> [!IMPORTANT]", + "> **Merge intentionally on hold**", + ">", + `> ${holdInstruction}`, + "", + "## Release checklist", + "", + "- [ ] Review and polish `updates.md`", + "- [ ] Confirm the release date", + "- [ ] Confirm `manifest.json`, `versions.json`, workspace package versions, and the locked Commonlib package version", + "- [ ] Confirm CI has passed", + `- [ ] ${finaliseInstruction}`, + `- [ ] ${assetInstruction}`, + `- [ ] ${publicationInstruction}`, + "- [ ] Validate the exact published release with BRAT", + ...completionInstructions, + "", + ].join("\n"); +} + +const invokedPath = process.argv[1]; +if (invokedPath !== undefined && pathToFileURL(invokedPath).href === import.meta.url) { + const [, , version, baseBranch] = process.argv; + try { + process.stdout.write(renderReleasePrBody(version ?? "", baseBranch ?? "")); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/utils/release-process.unit.spec.ts b/utils/release-process.unit.spec.ts new file mode 100644 index 00000000..8f6edf90 --- /dev/null +++ b/utils/release-process.unit.spec.ts @@ -0,0 +1,476 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +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 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)); +const prepareReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/prepare-release.yml", import.meta.url)); +const finaliseReleaseWorkflow = fileURLToPath(new URL("../.github/workflows/finalise-release.yml", import.meta.url)); +const releaseWorkflow = fileURLToPath(new URL("../.github/workflows/release.yml", import.meta.url)); +const cliDockerWorkflow = fileURLToPath(new URL("../.github/workflows/cli-docker.yml", import.meta.url)); +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function makeTemporaryDirectory(): string { + const directory = mkdtempSync(join(tmpdir(), "livesync-release-notes-")); + temporaryDirectories.push(directory); + return directory; +} + +function writeJson(directory: string, path: string, value: unknown): void { + const fullPath = join(directory, path); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${JSON.stringify(value, null, 4)}\n`, "utf8"); +} + +function runNode(script: string, args: string[], cwd: string, env: Record = {}) { + return spawnSync(process.execPath, [script, ...args], { + cwd, + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +function runNpm(args: string[], cwd: string) { + return spawnSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { + cwd, + encoding: "utf8", + env: process.env, + }); +} + +function createTagGit(expectedRevision: string, initialTags: Record = {}) { + const tags = new Map(Object.entries(initialTags)); + const git = (args: string[], allowMissing = false): string | undefined => { + if (args[0] === "rev-parse") { + const revision = args.at(-1); + if (revision === `${expectedRevision}^{commit}`) return expectedRevision; + const tagMatch = revision?.match(/^refs\/tags\/(.+)\^\{commit\}$/); + if (tagMatch) { + const commit = tags.get(tagMatch[1]); + if (commit !== undefined) return commit; + if (allowMissing) return undefined; + } + } + if (args[0] === "tag" && args.length === 3) { + tags.set(args[1], args[2]); + return ""; + } + throw new Error(`Unexpected git command: ${args.join(" ")}`); + }; + return { git, tags }; +} + +function createReleaseFixture(version = "0.25.81"): string { + const directory = makeTemporaryDirectory(); + writeJson(directory, "package.json", { version }); + writeJson(directory, "manifest.json", { version, minAppVersion: "1.7.2" }); + writeJson(directory, "versions.json", { [version]: "1.7.2" }); + for (const workspace of ["cli", "webpeer", "webapp"]) { + writeJson(directory, `src/apps/${workspace}/package.json`, { version: `${version}-${workspace}` }); + } + writeFileSync( + join(directory, "updates.md"), + "# 0.25\n\n## Unreleased\n\n### Fixed\n\n- Preserved file content.\n\n## 0.25.80\n\n7th July, 2026\n\n- Previous release.\n", + "utf8" + ); + return directory; +} + +describe("release notes", () => { + it("moves Unreleased notes into a dated release and validates the result", () => { + const directory = createReleaseFixture(); + + const prepared = runNode(releaseNotesScript, ["prepare", "0.25.81"], directory, { + RELEASE_DATE: "14th July, 2026", + }); + + expect(prepared.status, prepared.stderr).toBe(0); + expect(readFileSync(join(directory, "updates.md"), "utf8")).toBe( + "# 0.25\n\n## Unreleased\n\n## 0.25.81\n\n14th July, 2026\n\n### Fixed\n\n- Preserved file content.\n\n## 0.25.80\n\n7th July, 2026\n\n- Previous release.\n" + ); + + const validated = runNode(releaseNotesScript, ["validate", "0.25.81"], directory); + expect(validated.status, validated.stderr).toBe(0); + }); + + it("ends rotated notes with one newline when Unreleased is the final release section", () => { + const directory = createReleaseFixture(); + writeFileSync( + join(directory, "updates.md"), + "# 1.0\n\n## Unreleased\n\n### Fixed\n\n- Preserved file content.\n", + "utf8" + ); + + const prepared = runNode(releaseNotesScript, ["prepare", "1.0.0-beta.0"], directory, { + RELEASE_DATE: "22nd July, 2026", + }); + + expect(prepared.status, prepared.stderr).toBe(0); + expect(readFileSync(join(directory, "updates.md"), "utf8")).toBe( + "# 1.0\n\n## Unreleased\n\n## 1.0.0-beta.0\n\n22nd July, 2026\n\n### Fixed\n\n- Preserved file content.\n" + ); + }); + + it("rejects an empty Unreleased section unless explicitly allowed", () => { + const directory = createReleaseFixture(); + writeFileSync( + join(directory, "updates.md"), + "# 0.25\n\n## Unreleased\n\n## 0.25.80\n\nPrevious release.\n", + "utf8" + ); + + const rejected = runNode(releaseNotesScript, ["prepare", "0.25.81"], directory); + expect(rejected.status).toBe(1); + expect(rejected.stderr).toContain("The '## Unreleased' section is empty."); + + const allowed = runNode(releaseNotesScript, ["prepare", "0.25.81"], directory, { + ALLOW_EMPTY_UPDATES: "true", + RELEASE_DATE: "14th July, 2026", + }); + expect(allowed.status, allowed.stderr).toBe(0); + }); + + it("rejects unfinished release notes", () => { + const directory = createReleaseFixture(); + writeFileSync( + join(directory, "updates.md"), + "# 0.25\n\n## Unreleased\n\n## 0.25.81\n\n14th July, 2026\n\n- TODO: finish these notes.\n", + "utf8" + ); + + const result = runNode(releaseNotesScript, ["validate", "0.25.81"], directory); + expect(result.status).toBe(1); + expect(result.stderr).toContain("still contain TODO or WIP markers"); + }); +}); + +describe("release workflow", () => { + it("uses the locked Commonlib package instead of generated fallback declarations", () => { + const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); + 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).toContain("locked Commonlib package version"); + }); + + it("reruns the version lifecycle when the integration branch already selects the release version", () => { + const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); + + expect(workflow).toContain('npm version "${VERSION}" --no-git-tag-version --allow-same-version'); + }); + + it("generates the release PR body from the selected version and base branch", () => { + const workflow = readFileSync(prepareReleaseWorkflow, "utf8"); + + expect(workflow).toContain('node utils/release-pr-body.mjs "${VERSION}" "${BASE_BRANCH}"'); + expect(workflow).not.toContain("leave \\`main\\`"); + expect(workflow).not.toContain("latest stable release"); + }); + + 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).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).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("publishes a stable version initially as a GitHub pre-release for BRAT validation", () => { + const stable = renderReleasePrBody("1.0.0", "main"); + + 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( + "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.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"); + }); + + 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, 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' + ); + 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." + ); + }); + + 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).toContain("explicitly dispatched the CLI container workflow"); + }); + + it("publishes only by explicit dispatch and validates the selected release", () => { + const workflow = readFileSync(releaseWorkflow, "utf8"); + + expect(workflow).not.toMatch(/^\s+push:/m); + expect(workflow).toContain("ref: ${{ inputs.tag }}"); + expect(workflow).toContain('node utils/release-notes.mjs validate "${TAG}"'); + expect(workflow).toContain('TAG_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")"'); + expect(workflow).not.toContain("Get Version"); + }); + + it("supports a pre-release plug-in without creating or publishing a CLI release", () => { + const workflow = readFileSync(finaliseReleaseWorkflow, "utf8"); + + expect(workflow).toContain("prerelease:"); + expect(workflow).toContain("publish_cli:"); + expect(workflow).toContain('--field prerelease="${PRERELEASE}"'); + expect(workflow).toContain("--plugin-only"); + }); + + it("does not attach an unsupported release archive", () => { + const workflow = readFileSync(releaseWorkflow, "utf8"); + + expect(workflow).not.toContain("zip -r"); + expect(workflow).not.toContain("${{ github.event.repository.name }}.zip"); + }); + + it("does not promote a pre-release CLI image to stable moving tags", () => { + const workflow = readFileSync(cliDockerWorkflow, "utf8"); + + expect(workflow).toContain('if [[ "${VERSION}" == *-* ]]; then'); + expect(workflow).toContain('TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"'); + }); +}); + +describe("release tags", () => { + it("creates missing tags and accepts matching tags on retry", () => { + const head = "a".repeat(40); + const { git, tags } = createTagGit(head); + const messages: string[] = []; + + ensureTags("0.25.84", head, git, (message) => messages.push(message)); + expect(tags.get("0.25.84")).toBe(head); + expect(tags.get("0.25.84-cli")).toBe(head); + + ensureTags("0.25.84", head, git, (message) => messages.push(message)); + expect(messages).toContain(`Tag 0.25.84 already points to the expected commit ${head}.`); + expect(messages).toContain(`Tag 0.25.84-cli already points to the expected commit ${head}.`); + }); + + it("rejects an existing release tag that points to another commit without creating missing tags", () => { + const previousHead = "b".repeat(40); + const expectedHead = "a".repeat(40); + const { git, tags } = createTagGit(expectedHead, { "0.25.84-cli": previousHead }); + + expect(() => ensureTags("0.25.84", expectedHead, git)).toThrow( + `Tag 0.25.84-cli points to ${previousHead}; expected ${expectedHead}.` + ); + expect(tags.has("0.25.84")).toBe(false); + }); + + it("can create only the plug-in tag for a review release", () => { + const head = "a".repeat(40); + const { git, tags } = createTagGit(head); + + ensureTags("1.0.0-rc.0", head, git, () => undefined, { pluginOnly: true }); + + expect(tags.get("1.0.0-rc.0")).toBe(head); + expect(tags.has("1.0.0-rc.0-cli")).toBe(false); + }); +}); + +describe("version bump", () => { + it("records every release even when its minimum app version is already used", () => { + const directory = makeTemporaryDirectory(); + writeJson(directory, "manifest.json", { version: "0.25.80", minAppVersion: "1.7.2" }); + writeJson(directory, "versions.json", { "0.25.61": "1.7.2" }); + + const result = runNode(versionBumpScript, [], directory, { npm_package_version: "0.25.81" }); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"))).toMatchObject({ + version: "0.25.81", + minAppVersion: "1.7.2", + }); + expect(JSON.parse(readFileSync(join(directory, "versions.json"), "utf8"))).toEqual({ + "0.25.61": "1.7.2", + "0.25.81": "1.7.2", + }); + }); + + it("runs release metadata scripts when the selected version is already the package version", () => { + const directory = makeTemporaryDirectory(); + const workspaces = ["src/apps/cli", "src/apps/webpeer", "src/apps/webapp"]; + writeJson(directory, "package.json", { + name: "release-lifecycle-fixture", + version: "1.0.0-beta.0", + private: true, + workspaces, + scripts: { + version: `node ${JSON.stringify(versionBumpScript)} && node ${JSON.stringify(workspaceUpdateScript)}`, + }, + }); + writeJson(directory, "manifest.json", { version: "1.0.0-alpha.9", minAppVersion: "1.7.2" }); + writeJson(directory, "versions.json", { "0.25.83": "1.7.2" }); + const lockPackages: Record = { + "": { version: "1.0.0-beta.0", workspaces }, + }; + for (const workspace of ["cli", "webpeer", "webapp"]) { + writeJson(directory, `src/apps/${workspace}/package.json`, { + name: `release-lifecycle-${workspace}`, + version: `1.0.0-alpha.9-${workspace}`, + }); + lockPackages[`src/apps/${workspace}`] = { version: `1.0.0-alpha.9-${workspace}` }; + } + writeJson(directory, "package-lock.json", { + name: "release-lifecycle-fixture", + version: "1.0.0-beta.0", + lockfileVersion: 3, + requires: true, + packages: lockPackages, + }); + + const result = runNpm(["version", "1.0.0-beta.0", "--no-git-tag-version", "--allow-same-version"], directory); + + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(readFileSync(join(directory, "manifest.json"), "utf8"))).toMatchObject({ + version: "1.0.0-beta.0", + minAppVersion: "1.7.2", + }); + expect(JSON.parse(readFileSync(join(directory, "versions.json"), "utf8"))).toEqual({ + "0.25.83": "1.7.2", + "1.0.0-beta.0": "1.7.2", + }); + const packageLock = JSON.parse(readFileSync(join(directory, "package-lock.json"), "utf8")); + expect(packageLock.version).toBe("1.0.0-beta.0"); + expect(packageLock.packages[""].version).toBe("1.0.0-beta.0"); + for (const workspace of ["cli", "webpeer", "webapp"]) { + const packageJson = JSON.parse(readFileSync(join(directory, `src/apps/${workspace}/package.json`), "utf8")); + expect(packageJson.version).toBe(`1.0.0-beta.0-${workspace}`); + expect(packageLock.packages[`src/apps/${workspace}`].version).toBe(`1.0.0-beta.0-${workspace}`); + } + }); +}); + +describe("workspace version update", () => { + it("keeps workspace package and lockfile versions together", () => { + const directory = makeTemporaryDirectory(); + const workspaces = ["src/apps/cli", "src/apps/webpeer", "src/apps/webapp"]; + writeJson(directory, "package.json", { + version: "0.25.81", + workspaces, + dependencies: { "octagonal-wheels": "^0.1.51" }, + devDependencies: { typescript: "^5.9.3" }, + }); + for (const workspace of ["cli", "webpeer", "webapp"]) { + writeJson(directory, `src/apps/${workspace}/package.json`, { + version: `0.25.80-${workspace}`, + dependencies: { "octagonal-wheels": "^0.1.50" }, + devDependencies: { typescript: "^5.8.0" }, + }); + } + writeJson(directory, "package-lock.json", { + name: "obsidian-livesync", + version: "0.25.80", + lockfileVersion: 3, + packages: { + "": { version: "0.25.80", workspaces }, + "src/apps/cli": { + version: "0.25.80-cli", + dependencies: { "octagonal-wheels": "^0.1.50" }, + devDependencies: { typescript: "^5.8.0" }, + }, + "src/apps/webpeer": { + version: "0.25.80-webpeer", + dependencies: { "octagonal-wheels": "^0.1.50" }, + devDependencies: { typescript: "^5.8.0" }, + }, + "src/apps/webapp": { + version: "0.25.80-webapp", + dependencies: { "octagonal-wheels": "^0.1.50" }, + devDependencies: { typescript: "^5.8.0" }, + }, + }, + }); + + const result = runNode(workspaceUpdateScript, [], directory); + + expect(result.status, result.stderr).toBe(0); + for (const workspace of ["cli", "webpeer", "webapp"]) { + const packageJson = JSON.parse(readFileSync(join(directory, `src/apps/${workspace}/package.json`), "utf8")); + expect(packageJson.version).toBe(`0.25.81-${workspace}`); + expect(packageJson.dependencies["octagonal-wheels"]).toBe("^0.1.51"); + expect(packageJson.devDependencies.typescript).toBe("^5.9.3"); + } + const packageLock = JSON.parse(readFileSync(join(directory, "package-lock.json"), "utf8")); + expect(packageLock.version).toBe("0.25.81"); + expect(packageLock.packages[""].version).toBe("0.25.81"); + expect(packageLock.packages["src/apps/cli"].version).toBe("0.25.81-cli"); + expect(packageLock.packages["src/apps/webpeer"].version).toBe("0.25.81-webpeer"); + expect(packageLock.packages["src/apps/webapp"].version).toBe("0.25.81-webapp"); + for (const workspace of workspaces) { + expect(packageLock.packages[workspace].dependencies["octagonal-wheels"]).toBe("^0.1.51"); + expect(packageLock.packages[workspace].devDependencies.typescript).toBe("^5.9.3"); + } + }); +}); diff --git a/utils/release-tags.mjs b/utils/release-tags.mjs new file mode 100644 index 00000000..62e4fc5d --- /dev/null +++ b/utils/release-tags.mjs @@ -0,0 +1,73 @@ +import { spawnSync } from "node:child_process"; +import { writeSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function fail(message) { + writeSync(process.stderr.fd, `${message}\n`); + process.exit(1); +} + +function assertVersion(version) { + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`Invalid release version: ${version}`); + } +} + +function git(args, allowMissing = false) { + const result = spawnSync("git", args, { encoding: "utf8" }); + if (allowMissing && result.status === 1 && result.stdout === "" && result.stderr === "") { + return undefined; + } + if (result.error) { + throw new Error(`Could not run git ${args.join(" ")}: ${result.error.message}`); + } + if (result.status !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(" ")} exited with status ${result.status}.`); + } + return result.stdout.trim(); +} + +function resolveCommit(revision, runGit) { + return runGit(["rev-parse", "--verify", `${revision}^{commit}`]); +} + +function resolveTag(tag, runGit) { + return runGit(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`], true); +} + +export function ensureTags(version, expectedRevision, runGit = git, log = console.log, options = {}) { + assertVersion(version); + const expectedCommit = resolveCommit(expectedRevision, runGit); + const tags = options.pluginOnly ? [version] : [version, `${version}-cli`]; + const existing = tags.map((tag) => ({ tag, commit: resolveTag(tag, runGit) })); + + for (const { tag, commit } of existing) { + if (commit !== undefined && commit !== expectedCommit) { + throw new Error(`Tag ${tag} points to ${commit}; expected ${expectedCommit}.`); + } + } + + for (const { tag, commit } of existing) { + if (commit === undefined) { + runGit(["tag", tag, expectedCommit]); + log(`Created tag ${tag} at ${expectedCommit}.`); + } else { + log(`Tag ${tag} already points to the expected commit ${expectedCommit}.`); + } + } +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (isMain) { + const [command, version, expectedRevision, mode] = process.argv.slice(2); + if (command !== "ensure" || !version || !expectedRevision || (mode && mode !== "--plugin-only")) { + fail("Usage: node utils/release-tags.mjs ensure [--plugin-only]"); + } + + try { + ensureTags(version, expectedRevision, git, console.log, { pluginOnly: mode === "--plugin-only" }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} diff --git a/utils/setup/generate_setup_uri.test.ts b/utils/setup/generate_setup_uri.test.ts new file mode 100644 index 00000000..725725bf --- /dev/null +++ b/utils/setup/generate_setup_uri.test.ts @@ -0,0 +1,123 @@ +import { + decodeSettingsFromSetupURI, + DEFAULT_SETTINGS, +} from "./livesync-commonlib.ts"; +import { generateSetupURI } from "./generate_setup_uri.ts"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +Deno.test("generates an Object Storage Setup URI with a selected S3 profile", async () => { + const generated = await generateSetupURI({ + remote_type: "s3", + endpoint: "https://objects.example.test", + access_key: "access-key", + secret_key: "secret-key", + bucket: "vault-data", + region: "auto", + bucket_prefix: "team-a", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }); + const decoded = await decodeSettingsFromSetupURI( + generated.setupURI, + generated.setupPassphrase, + ); + assert(decoded, "Commonlib could not decode the Object Storage Setup URI"); + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + effective.isConfigured, + "the Setup URI left the imported device unconfigured", + ); + assert( + effective.customChunkSize === 10, + "the journal chunk-size preset was not applied", + ); + assert( + effective.liveSync, + "Object Storage was not configured for live journal synchronisation", + ); + assert( + effective.endpoint === "https://objects.example.test", + "the endpoint was not preserved", + ); + assert( + effective.bucketPrefix === "team-a", + "the bucket prefix was not preserved", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one Object Storage profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the Object Storage profile was not selected", + ); + assert( + profiles[0].uri.startsWith("sls+s3://"), + "the selected profile was not an S3 connection URI", + ); +}); + +Deno.test("generates a random-room P2P Setup URI without copying a device identity", async () => { + const generated = await generateSetupURI({ + remote_type: "p2p", + passphrase: "vault-secret", + uri_passphrase: "setup-secret", + }); + const decoded = await decodeSettingsFromSetupURI( + generated.setupURI, + generated.setupPassphrase, + ); + assert(decoded, "Commonlib could not decode the P2P Setup URI"); + const effective = { ...DEFAULT_SETTINGS, ...decoded }; + assert( + /^\d{3}-\d{3}-\d{3}-[a-z0-9]{3}$/.test(effective.P2P_roomID), + "Commonlib did not generate the expected random room ID", + ); + assert( + /^[A-Za-z0-9_-]{32}$/.test(effective.P2P_passphrase), + "the generated P2P passphrase was not a 32-character base64url secret", + ); + assert( + !effective.P2P_AutoStart, + "P2P auto-start was enabled without an explicit request", + ); + assert( + !effective.P2P_AutoBroadcast, + "P2P auto-broadcast was enabled without an explicit request", + ); + assert( + effective.customChunkSize === 0, + "the P2P profile inherited the self-hosted CouchDB chunk-size recommendation", + ); + assert( + effective.sendChunksBulkMaxSize === 1, + "the P2P profile did not retain the conservative manual resend size", + ); + assert( + !Object.hasOwn(decoded, "P2P_DevicePeerName"), + "the Setup URI copied a device-specific P2P peer name", + ); + + const profiles = Object.values(decoded.remoteConfigurations ?? {}); + assert( + profiles.length === 1, + "the Setup URI did not contain exactly one P2P profile", + ); + assert( + decoded.activeConfigurationId === profiles[0].id, + "the P2P profile was not selected as the main remote", + ); + assert( + decoded.P2P_ActiveRemoteConfigurationId === profiles[0].id, + "the P2P profile was not selected for P2P features", + ); + assert( + profiles[0].uri.startsWith("sls+p2p://"), + "the selected profile was not a P2P connection URI", + ); +}); diff --git a/utils/setup/generate_setup_uri.ts b/utils/setup/generate_setup_uri.ts new file mode 100644 index 00000000..07c4f1cd --- /dev/null +++ b/utils/setup/generate_setup_uri.ts @@ -0,0 +1,189 @@ +import { + createNewVaultSettings, + encodeSettingsToSetupURI, + generateP2PRoomId, + type ObsidianLiveSyncSettings, + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + PREFERRED_JOURNAL_SYNC, + PREFERRED_SETTING_SELF_HOSTED, + upsertRemoteConfigurationInPlace, +} from "./livesync-commonlib.ts"; + +export type SetupRemoteType = "couchdb" | "s3" | "p2p"; +export type SetupGeneratorEnvironment = Readonly< + Record +>; + +export interface GeneratedSetupURI { + remoteType: SetupRemoteType; + setupURI: string; + setupPassphrase: string; +} + +function requireValue( + environment: SetupGeneratorEnvironment, + name: string, +): string { + const value = environment[name]?.trim(); + if (!value) throw new Error(`${name} is required`); + return value; +} + +function optionalBoolean( + environment: SetupGeneratorEnvironment, + name: string, + fallback: boolean, +): boolean { + const value = environment[name]?.trim().toLowerCase(); + if (!value) return fallback; + if (value === "true" || value === "1") return true; + if (value === "false" || value === "0") return false; + throw new Error(`${name} must be true, false, 1, or 0`); +} + +export function generateSecret(): string { + const bytes = crypto.getRandomValues(new Uint8Array(24)); + return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll( + "/", + "_", + ).replace(/=+$/, ""); +} + +function applyEncryptedVaultSettings( + settings: ObsidianLiveSyncSettings, + environment: SetupGeneratorEnvironment, +): void { + Object.assign(settings, { + isConfigured: true, + encrypt: true, + passphrase: requireValue(environment, "passphrase"), + usePathObfuscation: true, + }); +} + +function createCouchDBSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_SETTING_SELF_HOSTED, { + couchDB_URI: requireValue(environment, "hostname"), + couchDB_USER: requireValue(environment, "username"), + couchDB_PASSWORD: requireValue(environment, "password"), + couchDB_DBNAME: requireValue(environment, "database"), + batchSave: true, + periodicReplication: true, + syncOnStart: true, + syncOnFileOpen: true, + syncAfterMerge: true, + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "couchdb", { activate: true }); + return settings; +} + +function createObjectStorageSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_JOURNAL_SYNC, { + endpoint: requireValue(environment, "endpoint"), + accessKey: requireValue(environment, "access_key"), + secretKey: requireValue(environment, "secret_key"), + bucket: requireValue(environment, "bucket"), + region: environment.region?.trim() || "auto", + bucketPrefix: environment.bucket_prefix?.trim() || "", + bucketCustomHeaders: environment.bucket_custom_headers?.trim() || "", + useCustomRequestHandler: optionalBoolean( + environment, + "use_custom_request_handler", + false, + ), + forcePathStyle: optionalBoolean(environment, "force_path_style", true), + liveSync: true, + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "s3", { activate: true }); + return settings; +} + +function createP2PSettings( + environment: SetupGeneratorEnvironment, +): ObsidianLiveSyncSettings { + const settings = createNewVaultSettings(); + Object.assign(settings, PREFERRED_BASE, P2P_DEFAULT_SETTINGS, { + P2P_Enabled: true, + P2P_roomID: environment.p2p_room_id?.trim() || generateP2PRoomId(), + P2P_passphrase: environment.p2p_passphrase?.trim() || generateSecret(), + P2P_relays: environment.p2p_relays?.trim() || + P2P_DEFAULT_SETTINGS.P2P_relays, + P2P_AppID: environment.p2p_app_id?.trim() || P2P_DEFAULT_SETTINGS.P2P_AppID, + P2P_AutoStart: optionalBoolean( + environment, + "p2p_auto_start", + P2P_DEFAULT_SETTINGS.P2P_AutoStart, + ), + P2P_AutoBroadcast: optionalBoolean( + environment, + "p2p_auto_broadcast", + P2P_DEFAULT_SETTINGS.P2P_AutoBroadcast, + ), + }); + applyEncryptedVaultSettings(settings, environment); + upsertRemoteConfigurationInPlace(settings, "p2p", { + activate: true, + activateForP2P: true, + }); + return settings; +} + +function parseRemoteType( + environment: SetupGeneratorEnvironment, +): SetupRemoteType { + const remoteType = environment.remote_type?.trim().toLowerCase() || "couchdb"; + if (remoteType === "couchdb" || remoteType === "s3" || remoteType === "p2p") { + return remoteType; + } + throw new Error("remote_type must be couchdb, s3, or p2p"); +} + +export function createSetupSettings( + environment: SetupGeneratorEnvironment, +): { remoteType: SetupRemoteType; settings: ObsidianLiveSyncSettings } { + const remoteType = parseRemoteType(environment); + if (remoteType === "couchdb") { + return { remoteType, settings: createCouchDBSettings(environment) }; + } + if (remoteType === "s3") { + return { remoteType, settings: createObjectStorageSettings(environment) }; + } + return { remoteType, settings: createP2PSettings(environment) }; +} + +export async function generateSetupURI( + environment: SetupGeneratorEnvironment, +): Promise { + const setupPassphrase = environment.uri_passphrase?.trim() || + generateSecret(); + const { remoteType, settings } = createSetupSettings(environment); + const setupURI = await encodeSettingsToSetupURI(settings, setupPassphrase, [ + "pluginSyncExtendedSetting", + "doNotUseFixedRevisionForChunks", + ], true); + return { remoteType, setupURI: setupURI.trim(), setupPassphrase }; +} + +export async function runSetupURIGenerator( + environment: SetupGeneratorEnvironment = Deno.env.toObject(), +): Promise { + const generated = await generateSetupURI(environment); + console.log(`\nGenerated ${generated.remoteType} Setup URI.`); + console.log( + "Your passphrase for the Setup URI is:", + generated.setupPassphrase, + ); + console.log("This passphrase is never shown again, so store it safely."); + console.log(generated.setupURI); +} + +if (import.meta.main) await runSetupURIGenerator(); diff --git a/utils/setup/livesync-commonlib.ts b/utils/setup/livesync-commonlib.ts new file mode 100644 index 00000000..d8091d4d --- /dev/null +++ b/utils/setup/livesync-commonlib.ts @@ -0,0 +1,18 @@ +// Keep Setup URI generation on its own static Commonlib module graph. This is +// intentionally separate from the CouchDB facade so that a raw-URL invocation +// does not load the PouchDB browser adapter. +export { + decodeSettingsFromSetupURI, + encodeSettingsToSetupURI, +} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/API/processSetting"; +export { generateP2PRoomId } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/compat/common/utils"; +export { upsertRemoteConfigurationInPlace } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/remote-configurations"; +export { + createNewVaultSettings, + DEFAULT_SETTINGS, + P2P_DEFAULT_SETTINGS, + PREFERRED_BASE, + PREFERRED_JOURNAL_SYNC, + PREFERRED_SETTING_SELF_HOSTED, +} from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; +export type { ObsidianLiveSyncSettings } from "npm:@vrtmrz/livesync-commonlib@0.1.0-rc.4/settings"; diff --git a/utilsdeno/README.md b/utilsdeno/README.md index dc46fc49..d0b2a76e 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/version-bump.mjs b/version-bump.mjs index 32dcf7e2..1733c0bc 100644 --- a/version-bump.mjs +++ b/version-bump.mjs @@ -10,8 +10,8 @@ writeFileSync("manifest.json", JSON.stringify(manifest, null, 4)); // update versions.json with target version and minAppVersion from manifest.json // but only if the target version is not already in versions.json -const versions = JSON.parse(readFileSync('versions.json', 'utf8')); -if (!Object.values(versions).includes(minAppVersion)) { +const versions = JSON.parse(readFileSync("versions.json", "utf8")); +if (!(targetVersion in versions)) { versions[targetVersion] = minAppVersion; - writeFileSync('versions.json', JSON.stringify(versions, null, 4)); -} \ No newline at end of file + writeFileSync("versions.json", JSON.stringify(versions, null, 4)); +} diff --git a/versions.json b/versions.json index fa2aaa6b..6ffd7703 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,16 @@ { "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", + "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.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": "1.7.2" } 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.e2e-runner.ts b/vitest.config.e2e-runner.ts new file mode 100644 index 00000000..ce2c2743 --- /dev/null +++ b/vitest.config.e2e-runner.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/e2e-obsidian/runner/*.test.ts"], + }, +}); diff --git a/vitest.config.p2p.ts b/vitest.config.p2p.ts deleted file mode 100644 index 9478db52..00000000 --- a/vitest.config.p2p.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @file vitest.config.p2p.ts - * @description Configuration for running browser-based Peer-to-Peer (P2P) replication tests - * in Playwright (Chromium) using Trystero and Nostr relays. - * This is executed via the `npm run test:p2p` command (which runs `test/suitep2p/run-p2p-tests.sh` internally). - */ -import { defineConfig, mergeConfig } from "vitest/config"; -import { playwright } from "@vitest/browser-playwright"; -import viteConfig from "./vitest.config.common"; -import path from "path"; -import { existsSync, readFileSync } from "node:fs"; -import { parseEnv } from "node:util"; -import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands"; - -// P2P test environment variables -// Configure these in .env or .test.env, or inject via shell before running tests. -// Shell-injected values take precedence over dotenv files. -// -// Required: -// P2P_TEST_ROOM_ID - Shared room identifier for peers to discover each other -// P2P_TEST_PASSPHRASE - Encryption passphrase shared between test peers -// -// Optional: -// P2P_TEST_HOST_PEER_NAME - Name used to identify the host peer (default varies) -// P2P_TEST_RELAY - Nostr relay server URL used for peer signalling/discovery -// P2P_TEST_APP_ID - Application ID scoping the P2P session -// P2P_TEST_HANDOFF_FILE - File path used to pass state between up/down test phases -// -// General test options (also read from env): -// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests -// ENABLE_UI - Set to "true" to open a visible browser window during tests -const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined); -const defEnv = loadEnvFile(".env"); -const testEnv = loadEnvFile(".test.env"); -// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence) -const p2pEnv: Record = {}; -if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID; -if (process.env.P2P_TEST_PASSPHRASE) p2pEnv.P2P_TEST_PASSPHRASE = process.env.P2P_TEST_PASSPHRASE; -if (process.env.P2P_TEST_HOST_PEER_NAME) p2pEnv.P2P_TEST_HOST_PEER_NAME = process.env.P2P_TEST_HOST_PEER_NAME; -if (process.env.P2P_TEST_RELAY) p2pEnv.P2P_TEST_RELAY = process.env.P2P_TEST_RELAY; -if (process.env.P2P_TEST_APP_ID) p2pEnv.P2P_TEST_APP_ID = process.env.P2P_TEST_APP_ID; -if (process.env.P2P_TEST_HANDOFF_FILE) p2pEnv.P2P_TEST_HANDOFF_FILE = process.env.P2P_TEST_HANDOFF_FILE; -const env = Object.assign({}, defEnv, testEnv, p2pEnv); -const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; -const enableUI = env?.ENABLE_UI === "true"; -const headless = !debuggerEnabled && !enableUI; - -export default mergeConfig( - viteConfig, - defineConfig({ - resolve: { - alias: { - obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"), - }, - }, - test: { - env: env, - testTimeout: 240000, - hookTimeout: 240000, - fileParallelism: false, - isolate: true, - watch: false, - // Run all CLI-host P2P test files (*.p2p.test.ts, *.p2p-up.test.ts, *.p2p-down.test.ts) - include: ["test/suitep2p/**/*.p2p*.test.ts"], - browser: { - isolate: true, - // Only grantClipboardPermissions is needed; no openWebPeer/acceptWebPeer - commands: { - grantClipboardPermissions, - writeHandoffFile, - readHandoffFile, - }, - provider: playwright({ - launchOptions: { - args: [ - "--js-flags=--expose-gc", - "--allow-insecure-localhost", - "--disable-web-security", - "--ignore-certificate-errors", - ], - }, - }), - enabled: true, - screenshotFailures: false, - instances: [ - { - execArgv: ["--js-flags=--expose-gc"], - browser: "chromium", - headless, - isolate: true, - inspector: debuggerEnabled ? { waitForDebugger: true, enabled: true } : undefined, - printConsoleTrace: true, - onUnhandledError(error) { - const msg = error.message || ""; - if (msg.includes("Cannot create so many PeerConnections")) { - return false; - } - }, - }, - ], - headless, - fileParallelism: false, - ui: debuggerEnabled || enableUI ? true : false, - }, - }, - }) -); 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 deleted file mode 100644 index a62992d3..00000000 --- a/vitest.config.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @file vitest.config.ts - * @description Configuration for running browser-based end-to-end (E2E) integration tests - * using Playwright (Chromium) to test replication and synchronisation scenarios. - * This is executed when running the full test suite via `npm run test` or `npm run test:full`. - */ -import { defineConfig, mergeConfig } from "vitest/config"; -import { playwright } from "@vitest/browser-playwright"; -import viteConfig from "./vitest.config.common"; -import path from "path"; -import { existsSync, readFileSync } from "node:fs"; -import { parseEnv } from "node:util"; -import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands"; - -const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined); -const defEnv = loadEnvFile(".env"); -const testEnv = loadEnvFile(".test.env"); -const env = Object.assign({}, defEnv, testEnv); -const debuggerEnabled = env?.ENABLE_DEBUGGER === "true"; -const enableUI = env?.ENABLE_UI === "true"; -const headless = !debuggerEnabled && !enableUI; -export default mergeConfig( - viteConfig, - defineConfig({ - resolve: { - alias: { - obsidian: path.resolve(__dirname, "./test/harness/obsidian-mock.ts"), - }, - }, - test: { - env: env, - testTimeout: 40000, - hookTimeout: 50000, - fileParallelism: false, - isolate: true, - watch: false, - - // environment: "browser", - include: ["test/**/*.test.ts"], - coverage: { - include: ["src/**/*.ts", "src/lib/src/**/*.ts", "src/**/*.svelte"], - exclude: ["**/*.test.ts", "src/lib/**"], - provider: "v8", - reporter: ["text", "json", "html"], - // ignoreEmptyLines: true, - }, - browser: { - isolate: true, - commands: { - grantClipboardPermissions, - openWebPeer, - closeWebPeer, - acceptWebPeer, - }, - provider: playwright({ - launchOptions: { - args: ["--js-flags=--expose-gc"], - // chromiumSandbox: true, - }, - }), - enabled: true, - screenshotFailures: false, - instances: [ - { - execArgv: ["--js-flags=--expose-gc"], - browser: "chromium", - headless, - isolate: true, - inspector: debuggerEnabled - ? { - waitForDebugger: true, - enabled: true, - } - : undefined, - printConsoleTrace: debuggerEnabled, - onUnhandledError(error) { - // Ignore certain errors - const msg = error.message || ""; - if (msg.includes("Cannot create so many PeerConnections")) { - return false; - } - }, - }, - ], - headless, - fileParallelism: false, - ui: debuggerEnabled || enableUI ? true : false, - }, - }, - }) -); 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, ],
    a