diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 9f6d112c..88bb783e 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -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. diff --git a/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts b/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts new file mode 100644 index 00000000..b255114e --- /dev/null +++ b/test/e2e-obsidian/runner/twoVaultSyncLifecycle.test.ts @@ -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 } }>, + 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((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")); + }); +}); diff --git a/test/e2e-obsidian/scripts/two-vault-sync.ts b/test/e2e-obsidian/scripts/two-vault-sync.ts index 4d8bee0c..ece47390 100644 --- a/test/e2e-obsidian/scripts/two-vault-sync.ts +++ b/test/e2e-obsidian/scripts/two-vault-sync.ts @@ -49,6 +49,7 @@ type RunnerContext = { couchDb: CouchDbConfig; dbName: string; reviewedVaults: Set; + activeSessions: Set; }; async function writeVaultFile(vaultPath: string, path: string, content: string): Promise { @@ -77,6 +78,18 @@ async function pathExists(vaultPath: string, path: string): Promise { } } +async function stopTrackedSession(context: RunnerContext, session: ObsidianLiveSyncSession): Promise { + if (!context.activeSessions.has(session)) return; + await session.app.stop(); + context.activeSessions.delete(session); +} + +async function stopTrackedSessions(context: RunnerContext): Promise { + 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 { 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 { 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();