import { createHash, randomUUID } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { LOG_LEVEL_URGENT } from "octagonal-wheels/common/logger"; import { evalObsidianJson } from "../runner/cli.ts"; import { requireObsidianBinary, requireObsidianCli } from "../runner/environment.ts"; import { createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; type FixtureLog = { message: string; level?: number; key?: string; }; type ShutdownObservation = { closeCalls: number; disposeCalls: number; disposeCompleted: boolean; fixtureLogs: FixtureLog[]; events: string[]; }; async function evaluate(cliBinary: string, session: ObsidianLiveSyncSession, body: string): Promise { return await evalObsidianJson( cliBinary, `(async()=>{ const require=(condition,message)=>{if(!condition)throw new Error(message);}; const waitFor=async(predicate,message)=>{ const until=Date.now()+15000; while(!predicate()) { if(Date.now()>until)throw new Error(message); await new Promise(resolve=>setTimeout(resolve,20)); } }; ${body} })()`, session.cliEnv ); } function createPluginData(dbName: string): Record { return createE2eCouchDbPluginData( { uri: "http://127.0.0.1:1", username: "unused", password: "unused", dbName }, { syncInternalFiles: true, usePluginSync: true, usePluginSyncV2: true, deviceAndVaultName: "log-lifecycle-device", autoSweepPlugins: false, autoSweepPluginsPeriodic: false, syncInternalFilesInterval: 0, liveSync: false, periodicReplication: false, syncOnStart: false, syncOnSave: false, syncOnFileOpen: false, syncOnEditorSave: false, syncAfterMerge: false, showVerboseLog: true, } ); } async function startSession( binary: string, cliBinary: string, vault: TemporaryVault ): Promise { const session = await startObsidianLiveSyncSession({ binary, cliBinary, vault, pluginData: createPluginData(`log-lifecycle-${vault.name}`), localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), lifecycle: { beforeLaunch: async () => { const registryPath = join(vault.userDataPath, "obsidian.json"); const registry = JSON.parse(await readFile(registryPath, "utf8")); registry.updateDisabled = true; await writeFile(registryPath, JSON.stringify(registry)); }, }, }); return session; } function createShutdownScript(options: { duringMarker: string; failureMarker: string; reloadMarker: string; injectFailure: boolean; urgentLevel: number; }): string { const fixtures = JSON.stringify(options); return ` const fixtures=${fixtures}; const plugin=app.plugins.plugins["obsidian-livesync"]; const core=plugin?.core; require(core,"The LiveSync plug-in did not expose its core"); const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog"); require(module,"The loaded core did not contain ModuleLog"); const api=core.services.API; const database=core.services.database.localDatabaseDirect; require(database,"The loaded core did not expose its local database"); let releaseCloseGate; const closeGate=new Promise(resolve=>{releaseCloseGate=resolve;}); const state={ core, api, module, fixtureLogs:[], events:[], closeStarted:false, closeCalls:0, disposeCalls:0, disposeCompleted:false, closeGateReleased:false, disableError:"", }; app.__livesyncLogLifecycleState=state; const originalAddLog=module.__addLog; module.__addLog=function(message,level,key) { if(typeof message==="string" && [ fixtures.duringMarker, fixtures.failureMarker, fixtures.reloadMarker ].some(marker=>marker && message.includes(marker))) { state.fixtureLogs.push({message,level,key}); const marker=[fixtures.duringMarker,fixtures.failureMarker,fixtures.reloadMarker] .find(candidate=>candidate && message.includes(candidate)); state.events.push("log:"+marker); } return originalAddLog.apply(this,arguments); }; const resource=module.logResource; const originalDispose=resource.dispose; resource.dispose=async function() { state.disposeCalls++; state.events.push("dispose-start"); try { return await originalDispose.apply(this,arguments); } finally { state.disposeCompleted=true; state.events.push("dispose-complete"); } }; const originalClose=database.close; database.close=async function() { state.closeCalls++; if(state.closeCalls===1) { state.closeStarted=true; state.events.push("close-start"); await closeGate; } const result=await originalClose.apply(this,arguments); if(state.closeCalls===1) { state.events.push("close-finished"); if(fixtures.injectFailure) { state.events.push("close-failure-injected"); throw new Error(fixtures.failureMarker); } } return result; }; state.restore=()=>{ module.__addLog=originalAddLog; resource.dispose=originalDispose; database.close=originalClose; }; const run=async()=>{ state.disablePromise=app.plugins.disablePlugin("obsidian-livesync"); state.disablePromise.catch(error=>{state.disableError=String(error);}); await waitFor(()=>state.closeStarted,"The real local database close did not start"); require(state.disposeCalls===0 && !state.disposeCompleted, "The log resource was released before database shutdown completed"); api.addLog(fixtures.duringMarker,undefined,"e2e-log-lifecycle-during-close"); await waitFor( ()=>state.fixtureLogs.some(entry=>entry.message.includes(fixtures.duringMarker)), "ModuleLog did not receive the during-close log" ); require(state.disposeCalls===0,"The log resource was released while database close was gated"); releaseCloseGate(); state.closeGateReleased=true; await waitFor(()=>state.disposeCompleted,"The log resource was not released after database close"); require(state.closeCalls===1,"The local database close was not observed exactly once"); require(state.disposeCalls===1,"The log resource was not disposed exactly once"); require(state.events.indexOf("close-finished")>=0 && state.events.indexOf("close-finished")entry.message.includes(fixtures.failureMarker)) : undefined; if(fixtures.injectFailure) { require(failedLog,"The injected post-close failure was not logged"); require(failedLog.key==="shutdown","The shutdown failure log used the wrong key"); require(failedLog.level===fixtures.urgentLevel,"The shutdown failure log was not urgent"); require( state.events.indexOf("close-finished")state.disposeCompleted,"Cleanup timed out waiting for log resource disposal"); } catch {} } } `; } async function runCase(options: { binary: string; cliBinary: string; caseName: string; injectFailure: boolean; }): Promise> { const vault = await createTemporaryVault(`obsidian-log-lifecycle-${options.caseName}-`); let session: ObsidianLiveSyncSession | undefined; const markers = { duringMarker: `log-lifecycle-${options.caseName}-during-${randomUUID()}`, failureMarker: options.injectFailure ? `log-lifecycle-${options.caseName}-injected-close-failure-${randomUUID()}` : "", reloadMarker: options.injectFailure ? "" : `log-lifecycle-${options.caseName}-reload-${randomUUID()}`, injectFailure: options.injectFailure, urgentLevel: LOG_LEVEL_URGENT, }; try { session = await startSession(options.binary, options.cliBinary, vault); await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv); const shutdown = await evaluate(options.cliBinary, session, createShutdownScript(markers)); if (options.injectFailure) { return { caseName: options.caseName, shutdown }; } await evaluate( options.cliBinary, session, `await app.plugins.enablePlugin("obsidian-livesync"); return JSON.stringify({enabled:true});` ); await waitForLiveSyncCoreReady(options.cliBinary, session.cliEnv); const reload = await evaluate>( options.cliBinary, session, ` const state=app.__livesyncLogLifecycleState; require(state,"The old core observation was not retained across plug-in reload"); const plugin=app.plugins.plugins["obsidian-livesync"]; const core=plugin?.core; require(core && core!==state.core,"Enabling the plug-in did not create a new core"); const module=core.modules.find(candidate=>candidate.constructor.name==="ModuleLog"); require(module,"The reloaded core did not contain ModuleLog"); const originalAddLog=module.__addLog; const observed=[]; module.__addLog=function(message,level,key) { if(typeof message==="string" && message.includes(${JSON.stringify(markers.reloadMarker)})) { observed.push({message,level,key}); } return originalAddLog.apply(this,arguments); }; try { core.services.API.addLog(${JSON.stringify(markers.reloadMarker)},undefined,"e2e-log-lifecycle-reload"); await waitFor(()=>observed.length>0,"The reloaded ModuleLog did not receive its API log"); require(observed.length===1,"The reloaded ModuleLog received the fixture log more than once"); require( !state.fixtureLogs.some(entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)})), "The old ModuleLog observer received the reloaded core's fixture log" ); let oldHandlerDetached=false; try { state.api.addLog("e2e-log-lifecycle-reloaded-old-api"); } catch(error) { oldHandlerDetached=String(error).includes("not assigned"); } require(oldHandlerDetached,"The old API log handler was reassigned after reload"); return JSON.stringify({ coreChanged:core!==state.core, observedByNewModule:observed.length, observedByOldModule:state.fixtureLogs.filter( entry=>entry.message.includes(${JSON.stringify(markers.reloadMarker)}) ).length }); } finally { module.__addLog=originalAddLog; state.restore(); delete app.__livesyncLogLifecycleState; } ` ); return { caseName: options.caseName, shutdown, reload }; } finally { if (session) { try { await evaluate( options.cliBinary, session, ` const state=app.__livesyncLogLifecycleState; state?.restore?.(); delete app.__livesyncLogLifecycleState; return JSON.stringify({cleaned:true}); ` ); } catch {} try { await session.app.stop(); } finally { await vault.dispose(); } } else { await vault.dispose(); } } } async function main(): Promise { const binary = requireObsidianBinary(); const cliBinary = requireObsidianCli(); const bundleHash = createHash("sha256") .update(new Uint8Array(await readFile("main.js"))) .digest("hex"); const observations = [ await runCase({ binary, cliBinary, caseName: "normal", injectFailure: false }), await runCase({ binary, cliBinary, caseName: "controlled-failure", injectFailure: true }), ]; console.log(JSON.stringify({ ok: true, bundleHash, observations }, null, 2)); } await main();