mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 04:52:58 +00:00
test: adopt macOS-safe real-Obsidian sessions
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function hasExactCaseOnlyRename(entries: readonly string[], oldName: string, newName: string): boolean {
|
||||
return entries.includes(newName) && !entries.includes(oldName);
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user