test: clean up failed two-vault sessions

This commit is contained in:
vorotamoroz
2026-07-20 09:31:09 +00:00
parent e79686d275
commit eb486ed7a4
3 changed files with 184 additions and 34 deletions
+2
View File
@@ -28,6 +28,8 @@ Each test vault uses an isolated Obsidian profile. The runner creates temporary
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.
## Local Setup
Set `OBSIDIAN_BINARY` when Obsidian is not installed in a standard location.
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
events: [] as string[],
sessions: [] as Array<{ app: { stop: ReturnType<typeof vi.fn> } }>,
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(() => ({})),
createE2eObsidianDeviceLocalState: 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<number>((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"));
});
});
+70 -34
View File
@@ -49,6 +49,7 @@ type RunnerContext = {
couchDb: CouchDbConfig;
dbName: string;
reviewedVaults: Set<string>;
activeSessions: Set<ObsidianLiveSyncSession>;
};
async function writeVaultFile(vaultPath: string, path: string, content: string): Promise<void> {
@@ -77,6 +78,18 @@ async function pathExists(vaultPath: string, path: string): Promise<boolean> {
}
}
async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise<void> {
if (!context.activeSessions.has(session)) return;
await session.app.stop();
context.activeSessions.delete(session);
}
async function stopTrackedSessions(context: RunnerContext): Promise<void> {
for (const session of [...context.activeSessions]) {
await stopTrackedSession(context, session);
}
}
async function waitForPathContent(
vaultPath: string,
path: string,
@@ -188,17 +201,30 @@ async function startConfiguredSession(
// not currently preserve localStorage across those launches.
localStorageEntries: reviewAlreadyCompleted ? createE2eObsidianDeviceLocalState(vault.name) : undefined,
});
await waitForLiveSyncCoreReady(context.cliBinary, session.cliEnv);
if (!reviewAlreadyCompleted) {
await assertE2eCompatibilityReviewPending(context.cliBinary, session.cliEnv);
await resumeCompatibilityReview(session.remoteDebuggingPort);
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;
}
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;
}
async function uploadNote(
@@ -311,12 +337,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";
@@ -326,34 +352,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.");
}
@@ -367,13 +393,13 @@ 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.");
@@ -389,24 +415,24 @@ async function runCaseOnlyRename(
let session = await startConfiguredSession(context, vaultA);
await writeNoteViaObsidian(context.cliBinary, session.cliEnv, caseRenameFromPath, fileContent);
await uploadNote(context, session, caseRenameFromPath);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultB);
await syncAndApply(context, session);
await waitForPathContent(vaultB.path, caseRenameFromPath, (content) => content === fileContent);
await session.app.stop();
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 session.app.stop();
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 session.app.stop();
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.");
@@ -428,12 +454,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.");
@@ -458,7 +484,7 @@ async function runMarkdownAutoMerge(
(content) => content.includes("Left line") && content.includes("Right tail"),
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
await stopTrackedSession(context, session);
session = await startConfiguredSession(context, vaultA);
await syncAndApply(context, session);
@@ -468,7 +494,7 @@ async function runMarkdownAutoMerge(
(content) => content.includes("Left line") && content.includes("Right tail"),
Number(process.env.E2E_OBSIDIAN_MERGE_FILE_TIMEOUT_MS ?? 30000)
);
await session.app.stop();
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.");
@@ -485,7 +511,7 @@ 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/.*",
@@ -496,7 +522,7 @@ async function runTargetMismatch(
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: "",
@@ -507,7 +533,7 @@ async function runTargetMismatch(
targetMismatchPath,
(content) => content === ignoredContent
);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(
reflectedAfterEnabling,
@@ -518,7 +544,7 @@ async function runTargetMismatch(
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: "",
@@ -529,7 +555,7 @@ async function runTargetMismatch(
targetMismatchPath,
(content) => content === acceptedContent
);
await session.app.stop();
await stopTrackedSession(context, session);
assertEqual(received, acceptedContent, "Target file update was not reflected after the device accepted the path.");
console.log(
@@ -551,13 +577,21 @@ async function main(): Promise<void> {
const vaultB = await createTemporaryVault();
const encryptedVaultA = await createTemporaryVault();
const encryptedVaultB = await createTemporaryVault();
const context: RunnerContext = { binary, cliBinary: cli.binary, couchDb, dbName, reviewedVaults: new Set() };
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 {
@@ -580,6 +614,8 @@ async function main(): Promise<void> {
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();