mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
test: clean up failed two-vault sessions
This commit is contained in:
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user