test: adopt macOS-safe real-Obsidian sessions

This commit is contained in:
vorotamoroz
2026-07-20 08:54:20 +00:00
parent 0c3c5db6c1
commit e79686d275
11 changed files with 124 additions and 12 deletions
+3
View File
@@ -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
@@ -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);
});
});
+17 -6
View File
@@ -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<CompatibilityMarkerState> {
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;
}
@@ -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
);
});
});
@@ -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<void> {
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)}`
);
}
+3
View File
@@ -0,0 +1,3 @@
export function hasExactCaseOnlyRename(entries: readonly string[], oldName: string, newName: string): boolean {
return entries.includes(newName) && !entries.includes(oldName);
}
+2 -1
View File
@@ -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.");