From e79686d2759c0f9c31167e8d153121355757cfa6 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Mon, 20 Jul 2026 08:54:20 +0000 Subject: [PATCH] test: adopt macOS-safe real-Obsidian sessions --- .github/workflows/unit-ci.yml | 3 ++ package-lock.json | 8 ++--- package.json | 3 +- test/e2e-obsidian/README.md | 3 ++ .../runner/liveSyncWorkflow.test.ts | 34 +++++++++++++++++++ test/e2e-obsidian/runner/liveSyncWorkflow.ts | 23 +++++++++---- .../runner/pathAssertions.test.ts | 18 ++++++++++ test/e2e-obsidian/runner/pathAssertions.ts | 30 ++++++++++++++++ test/e2e-obsidian/runner/pathEntries.ts | 3 ++ test/e2e-obsidian/scripts/two-vault-sync.ts | 3 +- vitest.config.e2e-runner.ts | 8 +++++ 11 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 test/e2e-obsidian/runner/liveSyncWorkflow.test.ts create mode 100644 test/e2e-obsidian/runner/pathAssertions.test.ts create mode 100644 test/e2e-obsidian/runner/pathAssertions.ts create mode 100644 test/e2e-obsidian/runner/pathEntries.ts create mode 100644 vitest.config.e2e-runner.ts diff --git a/.github/workflows/unit-ci.yml b/.github/workflows/unit-ci.yml index d6453cac..d8ec75a8 100644 --- a/.github/workflows/unit-ci.yml +++ b/.github/workflows/unit-ci.yml @@ -76,6 +76,9 @@ jobs: - name: Run unit tests suite with coverage run: npm run test:unit:coverage + - name: Run real-Obsidian runner contract tests + run: npm run test:e2e:obsidian:runner + - name: Upload coverage report if: always() uses: actions/upload-artifact@v4 diff --git a/package-lock.json b/package-lock.json index 4aed99cf..3f206aad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.2.1", + "@vrtmrz/obsidian-test-session": "0.2.2", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", @@ -5037,9 +5037,9 @@ } }, "node_modules/@vrtmrz/obsidian-test-session": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.1.tgz", - "integrity": "sha512-dgT+KZRoHpvc6590pWEUmIm/3jYS+41JDAuFKL+6cjWJN3ZzltbvjiyGgA+8MJjIZ8f5pbadf5zyL+MJxTXCsA==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.2.tgz", + "integrity": "sha512-wWC7kKjF/2M6cIIIXTXLK4UkPADt3B81jryt3ZANlfpxCniNmXmoEYMVIhPSnZxbQNZqYLuF5RXARf9FxwYELQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 5a1196ac..a01f7ee3 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:install-playwright": "npx playwright install chromium", "test:install-dependencies": "npm run test:install-playwright", "test:e2e:obsidian:install-appimage": "tsx test/e2e-obsidian/scripts/install-appimage.ts", + "test:e2e:obsidian:runner": "vitest run --config vitest.config.e2e-runner.ts", "test:e2e:obsidian:discover": "tsx test/e2e-obsidian/scripts/discover.ts", "test:e2e:obsidian:cli-help": "tsx test/e2e-obsidian/scripts/cli-help.ts", "test:e2e:obsidian:debug-ui": "tsx test/e2e-obsidian/scripts/debug-ui.ts", @@ -109,7 +110,7 @@ "@vitest/browser": "^4.1.8", "@vitest/browser-playwright": "^4.1.8", "@vitest/coverage-v8": "^4.1.8", - "@vrtmrz/obsidian-test-session": "0.2.1", + "@vrtmrz/obsidian-test-session": "0.2.2", "dotenv-cli": "^11.0.0", "esbuild": "0.28.1", "esbuild-plugin-inline-worker": "^0.1.1", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index a5fac83e..9f6d112c 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -26,6 +26,8 @@ Future workflows should use `startObsidianLiveSyncSession()` from `runner/sessio 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. + ## Local Setup Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location. @@ -51,6 +53,7 @@ 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 diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts new file mode 100644 index 00000000..2bdc51fa --- /dev/null +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.test.ts @@ -0,0 +1,34 @@ +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, 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); + }); +}); diff --git a/test/e2e-obsidian/runner/liveSyncWorkflow.ts b/test/e2e-obsidian/runner/liveSyncWorkflow.ts index efe3e696..2a2e5e2a 100644 --- a/test/e2e-obsidian/runner/liveSyncWorkflow.ts +++ b/test/e2e-obsidian/runner/liveSyncWorkflow.ts @@ -42,6 +42,11 @@ export type CompatibilityMarkerState = { versionUpFlash: string; }; +export type CompatibilityMarkerWaitOptions = { + timeoutMs?: number; + intervalMs?: number; +}; + export type ResumeCompatibilityReviewOptions = { verifyMissingDeviceMarkerExplanation?: boolean; screenshotPrefix?: string; @@ -67,6 +72,7 @@ export type LocalDatabaseEntry = { }; const E2E_PREFERRED_SETTINGS = { + displayLanguage: "def", liveSync: false, syncOnStart: false, syncOnSave: false, @@ -127,14 +133,19 @@ export async function readE2eCompatibilityMarker( export async function assertE2eCompatibilityMarker( cliBinary: string, - env: NodeJS.ProcessEnv + env: NodeJS.ProcessEnv, + options: CompatibilityMarkerWaitOptions = {} ): Promise { - const state = await readE2eCompatibilityMarker(cliBinary, env); - if (state.serviceValue !== `${VER}`) { - throw new Error( - `The E2E compatibility marker was not available on first plug-in load: ${JSON.stringify(state)}` - ); + 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; } 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/scripts/two-vault-sync.ts b/test/e2e-obsidian/scripts/two-vault-sync.ts index 1911ce49..4d8bee0c 100644 --- a/test/e2e-obsidian/scripts/two-vault-sync.ts +++ b/test/e2e-obsidian/scripts/two-vault-sync.ts @@ -11,6 +11,7 @@ import { type CouchDbConfig, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForExactCaseOnlyRename } from "../runner/pathAssertions.ts"; import { assertEqual, assertE2eCompatibilityMarker, @@ -404,7 +405,7 @@ async function runCaseOnlyRename( session = await startConfiguredSession(context, vaultB); await syncAndApply(context, session); const renamedOnB = await waitForPathContent(vaultB.path, caseRenameToPath, (content) => content === fileContent); - await waitForPathDeleted(vaultB.path, caseRenameFromPath); + await waitForExactCaseOnlyRename(vaultB.path, caseRenameFromPath, caseRenameToPath); await session.app.stop(); assertEqual(renamedOnB, fileContent, "Case-only note rename did not round-trip to the second vault."); 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"], + }, +});