import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { join } from "node:path"; import type { Page } from "playwright"; import { assertCouchDbReachable, createCouchDbDatabase, deleteCouchDbDatabase, loadCouchDbConfig, makeUniqueDatabaseName, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertE2eCompatibilityUnpaused, configureCouchDb, createE2eCouchDbPluginData, createE2eObsidianDeviceLocalState, prepareRemote, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts"; type Mode = "normal" | "ignored-batch-setting" | "enabled-batch"; type Slot = { name: string; vault: TemporaryVault; session?: ObsidianLiveSyncSession }; type Leaf = { rev: string; ancestry: string[]; logicalDeleted: boolean; pouchDeleted: boolean; body: string | null }; type Snapshot = { path: string; rev: string | null; content: string | null; fileMtime: number | null; databaseMtime: number | null; exists: boolean; leaves: Leaf[]; waiting: Array<{ path: string; type: string; since: number }>; queued: Array<{ path: string | null; type: string }>; shouldBatchSave: boolean; }; type Pair = { a: Page; b: Page }; const diagnostics = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/issue1207-livesync-batch"; const artifactRoot = process.env.ISSUE1207_ARTIFACT_ROOT ?? process.cwd(); const evidence: Record = { cases: [], observations: [] }; const cases = evidence.cases as Array>; const observations = evidence.observations as unknown[]; const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const prefix = "issue1207-batch-"; function check(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } function hashes() { return Object.fromEntries( ["main.js", "manifest.json", "styles.css"].map((name) => [ name, createHash("sha256") .update(new Uint8Array(readFileSync(join(artifactRoot, name)))) .digest("hex"), ]) ); } async function availablePort(): Promise { const server = createServer(); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); check(address && typeof address !== "string", "Missing debug port"); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); return address.port; } async function evaluate(page: Page, code: string): Promise { return await page.evaluate(`(async()=>{ const core=app.plugins.plugins['obsidian-livesync'].core; const manager=core.serviceModules.storageAccess.vaultManager; ${code} })()`); } async function installProbe(page: Page): Promise { await evaluate( page, ` if(globalThis.__issue1207Batch) return; const probe={events:[],changes:[]}; globalThis.__issue1207Batch=probe; for(const type of ['create','modify','rename','delete']) { app.vault.on(type,(file,oldPath)=>{ const path=file.path; if(!path.startsWith(${JSON.stringify(prefix)})) return; const item={type,path,oldPath:oldPath??null,at:Date.now(),content:null}; probe.events.push(item); if(type!=='delete' && file.stat) { void app.vault.read(file).then(body=>item.content=body).catch(error=>item.error=String(error)); } }); } probe.feed=core.localDatabase.localDatabase.changes({live:true,since:'now',include_docs:true}) .on('change',change=>{ if(typeof change.doc?.path==='string' && change.doc.path.startsWith(${JSON.stringify(prefix)})) { probe.changes.push({path:change.doc.path,rev:change.doc._rev, logicalDeleted:!!change.doc.deleted,pouchDeleted:!!change.doc._deleted,at:Date.now()}); } }).on('error',error=>probe.changes.push({error:String(error),at:Date.now()})); ` ); } async function options(page: Page, mode: Mode, minimum = 2, maximum = 6): Promise { await evaluate( page, ` const settings=core.services.setting.currentSettings(); Object.assign(settings,{liveSync:true,periodicReplication:false, batchSave:${mode === "ignored-batch-setting"},batchSaveInLiveSync:${mode === "enabled-batch"}, batchSaveMinimumDelay:${minimum},batchSaveMaximumDelay:${maximum},deleteMetadataOfDeletedFiles:false}); return {liveSync:settings.liveSync,batchSave:settings.batchSave, batchSaveInLiveSync:settings.batchSaveInLiveSync,shouldBatchSave:manager.shouldBatchSave}; ` ); } async function snapshot(page: Page, path: string): Promise { return await evaluate( page, ` const path=${JSON.stringify(path)}; const id=await core.services.path.path2id(path); const row=(await core.localDatabase.allDocsRaw({keys:[id],include_docs:true})).rows[0]; let leaves=[]; if(row?.value?.rev) { const rawLeaves=await core.localDatabase.getRaw(id,{open_revs:'all',revs:true}); for(const item of rawLeaves) { if(!item.ok) continue; const raw=item.ok; let body=null; if(!raw._deleted) { const entry=await core.localDatabase.getDBEntryFromMeta(raw,false,false,true); if(entry!==false) body=Array.isArray(entry.data)?entry.data.join(''):entry.data; } leaves.push({rev:raw._rev,logicalDeleted:!!raw.deleted,pouchDeleted:!!raw._deleted,body, ancestry:raw._revisions?.ids.map((hash,i)=>(raw._revisions.start-i)+'-'+hash)??[]}); } } const file=app.vault.getAbstractFileByPath(path); return {path,rev:row?.value?.rev??null,exists:!!file?.stat, fileMtime:file?.stat?.mtime??null,databaseMtime:row?.doc?.mtime??null, content:file?.stat?await app.vault.read(file):null,leaves, waiting:[...manager._waitingMap].map(([key,value])=>({path:key,type:value.type,since:value.since})), queued:manager.bufferedQueuedItems.map(item=>({path:item.args?.file?.path??null,type:item.type})), shouldBatchSave:!!manager.shouldBatchSave}; ` ); } function liveLeaves(state: Snapshot) { return state.leaves.filter((leaf) => !leaf.logicalDeleted && !leaf.pouchDeleted); } async function waitFor( page: Page, path: string, predicate: (state: Snapshot) => boolean, timeout = 20000 ): Promise { const deadline = Date.now() + timeout; let state: Snapshot; do { state = await snapshot(page, path); if (predicate(state)) return state; await pause(100); } while (Date.now() < deadline); throw new Error(`State did not settle: ${JSON.stringify(state)}`); } async function settled(pair: Pair, path: string, body: string): Promise<[Snapshot, Snapshot]> { const ready = (state: Snapshot) => state.content === body && liveLeaves(state).length === 1 && liveLeaves(state)[0].body === body && state.leaves.length === 1 && !state.waiting.some((item) => item.path === path); const deadline = Date.now() + 20000; let states: [Snapshot, Snapshot]; do { states = await Promise.all([snapshot(pair.a, path), snapshot(pair.b, path)]); // The row, leaves, and storage are read asynchronously. Require each // sample to agree internally, then compare both current devices. if ( states.every((state) => ready(state) && state.rev === liveLeaves(state)[0].rev) && states[0].rev === states[1].rev ) return states; await pause(100); } while (Date.now() < deadline); throw new Error(`Devices did not converge: ${JSON.stringify(states)}`); } async function create(page: Page, path: string, body: string): Promise { await evaluate(page, `await app.vault.create(${JSON.stringify(path)},${JSON.stringify(body)});`); } async function modify(page: Page, path: string, body: string): Promise { await evaluate( page, `await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),${JSON.stringify(body)});` ); } async function trace(page: Page, path: string) { return await evaluate( page, `return { events:globalThis.__issue1207Batch.events.filter(item=>item.path===${JSON.stringify(path)}||item.oldPath===${JSON.stringify(path)}), changes:globalThis.__issue1207Batch.changes.filter(item=>item.path===${JSON.stringify(path)}||item.error)};` ); } async function main(): Promise { await mkdir(diagnostics, { recursive: true }); const binary = requireObsidianBinary(); const cli = discoverObsidianCli(); check(cli.binary, "Missing Obsidian CLI"); const cliBinary = cli.binary; const config = await loadCouchDbConfig(process.env.ISSUE1207_COUCHDB_ENV); check(["127.0.0.1", "localhost"].includes(new URL(config.uri).hostname), "Use a disposable local CouchDB"); await assertCouchDbReachable(config); const dbName = makeUniqueDatabaseName(config.dbPrefix, "live-batch"); const couch = { ...config, dbName }; const overrides = { doctorProcessedVersion: "1.0.0", notifyThresholdOfRemoteStorageSize: -1, liveSync: false, periodicReplication: false, batchSave: false, batchSaveInLiveSync: false, syncOnStart: false, syncOnSave: false, syncOnFileOpen: false, syncOnEditorSave: false, syncAfterMerge: false, checkConflictOnlyOnOpen: true, showMergeDialogOnlyOnActive: true, disableMarkdownAutoMerge: true, resolveConflictsByNewerFile: false, writeDocumentsIfConflicted: false, writeLogToTheFile: false, }; const before = hashes(); evidence.artefactsBefore = before; evidence.manifest = JSON.parse(await readFile(join(artifactRoot, "manifest.json"), "utf8")); evidence.commonlibVersion = JSON.parse( await readFile("node_modules/@vrtmrz/livesync-commonlib/package.json", "utf8") ).version; evidence.sourceBaseRevision = "73a14d4e4b0b414a1acfe170d01500abb3dca2e9"; evidence.unreleasedImplementation = true; if (process.env.ISSUE1207_COMMONLIB_ARTEFACT) { evidence.commonlibArtefactSha256 = createHash("sha256") .update(new Uint8Array(readFileSync(process.env.ISSUE1207_COMMONLIB_ARTEFACT))) .digest("hex"); } evidence.timing = { minimumSeconds: 2, maximumSeconds: 6, incomingAndManualSeconds: { minimum: 5, maximum: 60 } }; const slots: Slot[] = []; let ownedDatabase = false; function redact(value: unknown): string { return JSON.stringify(value, null, 2) .split(config.password) .join("") .split(Buffer.from(`${config.username}:${config.password}`).toString("base64")) .join(""); } async function start(slot: Slot, restart = false) { console.log(JSON.stringify({ phase: "starting", device: slot.name, restart })); slot.session = await startObsidianLiveSyncSession({ binary, cliBinary, artifactRoot, vault: slot.vault, startupGraceMs: 1000, pluginStartup: restart ? "natural" : undefined, pluginData: restart ? undefined : createE2eCouchDbPluginData(couch, overrides), localStorageEntries: restart ? undefined : createE2eObsidianDeviceLocalState(slot.vault.name), env: { E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(await availablePort()) }, lifecycle: { beforeLaunch: async () => { for (const registry of [ join(slot.vault.homePath, ".config", "obsidian", "obsidian.json"), join(slot.vault.homePath, "Library", "Application Support", "obsidian", "obsidian.json"), join(slot.vault.xdgConfigPath, "obsidian", "obsidian.json"), join(slot.vault.userDataPath, "obsidian.json"), ]) { const state = JSON.parse(await readFile(registry, "utf8")); state.updateDisabled = true; await writeFile(registry, JSON.stringify(state)); } }, }, }); console.log( JSON.stringify({ phase: "device-started", device: slot.name, port: slot.session.remoteDebuggingPort, vault: slot.vault.path, }) ); await waitForLiveSyncCoreReady(cliBinary, slot.session.cliEnv); await assertE2eCompatibilityUnpaused(cliBinary, slot.session.cliEnv, slot.session.remoteDebuggingPort); if (!restart) { await configureCouchDb(cliBinary, slot.session.cliEnv, couch, overrides); await waitForLiveSyncCoreReady(cliBinary, slot.session.cliEnv); await prepareRemote(cliBinary, slot.session.cliEnv); } } async function stop(slot: Slot) { if (!slot.session) return; await slot.session.app.stop(); await writeFile( join(diagnostics, `process-${slot.name}-${slot.session.remoteDebuggingPort}.json`), redact(slot.session.app.output()) ); slot.session = undefined; } async function withPair(callback: (pair: Pair) => Promise): Promise { check(slots[0].session && slots[1].session, "Both devices must be running"); return await withObsidianPage( slots[0].session.remoteDebuggingPort, async (a) => await withObsidianPage(slots[1].session!.remoteDebuggingPort, async (b) => { await installProbe(a); await installProbe(b); return await callback({ a, b }); }) ); } async function run(name: string, body: () => Promise) { if (process.env.ISSUE1207_CASE_FILTER && !new RegExp(process.env.ISSUE1207_CASE_FILTER).test(name)) return; console.log(JSON.stringify({ phase: "case-start", name })); try { const result = await body(); cases.push({ name, passed: true, result }); console.log(JSON.stringify({ phase: "case-passed", name })); } catch (error) { const failure = error instanceof Error ? error.stack : String(error); cases.push({ name, passed: false, error: failure }); console.log(JSON.stringify({ phase: "case-failed", name, error: failure })); await withPair(async (pair) => { let flushFailure: unknown; for (const [device, page] of Object.entries(pair)) { observations.push({ name, device, probe: await evaluate( page, `return { events:globalThis.__issue1207Batch.events,changes:globalThis.__issue1207Batch.changes, pending:(await manager.adapter.persistence.loadSnapshot()).map(item=>({ type:item.type,path:item.args?.file?.path,oldPath:item.args?.oldPath}))};` ), }); await page.screenshot({ path: join(diagnostics, `${name}-${device}-failure.png`) }); try { await evaluate(page, "await manager.waitForIdle();"); } catch (error) { observations.push({ name, device, flushFailure: String(error) }); flushFailure ??= error; } } if (flushFailure) throw flushFailure; }); await pause(500); } await writeFile(join(diagnostics, "evidence.json"), redact(evidence)); } try { await createCouchDbDatabase(config, dbName); ownedDatabase = true; for (const name of ["a", "b"]) { const slot = { name, vault: await createTemporaryVault(`issue1207-live-batch-${name}-`) }; slots.push(slot); await start(slot); } evidence.runtime = await withPair(async (pair) => { const versions = []; for (const page of [pair.a, pair.b]) { versions.push( await evaluate( page, `let appVersion=null; try {appVersion=JSON.parse(require('fs').readFileSync(require('path').join(process.resourcesPath,'app.asar','package.json'),'utf8')).version;} catch {} return {appVersion,electronVersion:process.versions.electron, pluginVersion:app.plugins.plugins['obsidian-livesync'].manifest.version};` ) ); await options(page, "normal"); const outcome = await evaluate( page, `return await core.services.replication.startContinuous({ trigger:'daemon',interaction:{kind:'forbidden'}});` ); check( (outcome as { status?: string })?.status === "completed", `Continuous start failed: ${JSON.stringify(outcome)}` ); } return versions; }); evidence.connectivity = await withPair(async (pair) => { const path = `${prefix}connectivity.md`; await create(pair.a, path, "Connectivity probe\n"); const first = await settled(pair, path, "Connectivity probe\n"); await modify(pair.b, path, "Bidirectional connectivity probe\n"); const second = await settled(pair, path, "Bidirectional connectivity probe\n"); return { first, second }; }); for (const mode of ["normal", "ignored-batch-setting", "enabled-batch"] as const) { for (let repeat = 0; repeat < 2; repeat++) { await run(`${mode}-coalesce-${repeat}`, () => withPair(async (pair) => { for (const page of [pair.a, pair.b]) await options(page, mode); const source = repeat === 0 ? pair.a : pair.b; const receiver = repeat === 0 ? pair.b : pair.a; const path = `${prefix}${mode}-edits-${repeat}.md`; await create(source, path, "Initial body\n"); const initial = (await settled(pair, path, "Initial body\n"))[0]; for (let edit = 0; edit < 6; edit++) { await modify(source, path, `Edit ${edit}\n`); await pause(150); } const during = await snapshot(source, path); const final = await settled(pair, path, "Edit 5\n"); const finalSource = repeat === 0 ? final[0] : final[1]; const delta = Number(finalSource.rev?.split("-")[0]) - Number(initial.rev?.split("-")[0]); if (mode === "enabled-batch") { check( during.waiting.some((item) => item.path === path), "Batch did not retain the pending edit" ); check(delta === 1, `Repeated edits created ${delta} revisions`); } else { check(!during.shouldBatchSave, "LiveSync batching was enabled without an explicit choice"); check(delta >= 3, `The control did not expose enough edits: ${delta}`); } return { path, initial, during, final, revisionDelta: delta, source: await trace(source, path), receiver: await trace(receiver, path), }; }) ); } await run(`${mode}-template-lifecycle`, () => withPair(async (pair) => { for (const page of [pair.a, pair.b]) await options(page, mode); const oldPath = `${prefix}${mode}-untitled.md`; const path = `${prefix}${mode}-completed.md`; await create(pair.a, oldPath, ""); await pause(300); await modify(pair.a, oldPath, "Template in progress\n"); await pause(300); await evaluate( pair.a, `await app.fileManager.renameFile(app.vault.getFileByPath(${JSON.stringify(oldPath)}),${JSON.stringify(path)});` ); await pause(300); await modify(pair.a, path, "Completed template\n"); const during = await snapshot(pair.a, path); const final = await settled(pair, path, "Completed template\n"); await waitFor(pair.b, oldPath, (state) => !state.exists && liveLeaves(state).length === 0); return { oldPath, path, during, final, oldSource: await snapshot(pair.a, oldPath), oldReceiver: await snapshot(pair.b, oldPath), source: await trace(pair.a, path), receiver: await trace(pair.b, path), oldSourceTrace: await trace(pair.a, oldPath), oldReceiverTrace: await trace(pair.b, oldPath), }; }) ); for (const physical of [false, true]) { await run(`${mode}-recreate-${physical}`, () => withPair(async (pair) => { for (const page of [pair.a, pair.b]) { await options(page, mode); await evaluate( page, `core.services.setting.currentSettings().deleteMetadataOfDeletedFiles=${physical};` ); } const path = `${prefix}${mode}-recreate-${physical}.md`; await create(pair.a, path, "Transient body\n"); await pause(400); await evaluate( pair.a, `await app.vault.delete(app.vault.getFileByPath(${JSON.stringify(path)}),true);` ); await pause(400); await create(pair.a, path, "Recreated body\n"); const final = await settled(pair, path, "Recreated body\n"); return { physical, final, source: await trace(pair.a, path), receiver: await trace(pair.b, path), }; }) ); } } for (const incoming of ["identical", "different", "delete"] as const) { await run(`enabled-pending-incoming-${incoming}`, () => withPair(async (pair) => { await options(pair.a, "enabled-batch", 5, 60); await options(pair.b, "normal", 5, 60); const path = `${prefix}pending-${incoming}.md`; await create(pair.b, path, "Shared base\n"); const initial = await settled(pair, path, "Shared base\n"); const local = "Local edit\n"; await modify(pair.a, path, local); const pending = await waitFor(pair.a, path, (state) => state.waiting.some((item) => item.path === path) ); check(pending.rev === initial[0].rev, "Local edit reached the DB before incoming delivery"); const sentAt = Date.now(); if (incoming === "delete") { await evaluate( pair.b, `await app.vault.delete(app.vault.getFileByPath(${JSON.stringify(path)}),true);` ); } else await modify(pair.b, path, incoming === "identical" ? local : "Remote edit\n"); const arrived = await waitFor( pair.a, path, (state) => incoming === "identical" ? state.rev !== initial[0].rev && liveLeaves(state).some((leaf) => leaf.body === local) : state.leaves.some((leaf) => leaf.body === local) && state.leaves.some((leaf) => incoming === "delete" ? leaf.logicalDeleted : leaf.body === "Remote edit\n" ), 4000 ); check( arrived.waiting.some((item) => item.path === path), "The incoming assertion did not occur during Batch waiting" ); const leavesBefore = arrived.leaves.map((leaf) => leaf.rev).sort(); if (incoming !== "identical") { check(arrived.content === local, "Incoming reflection overwrote the pending local edit"); check(arrived.leaves.length === 2, "Expected exactly two genuine concurrent branches"); check( arrived.leaves.every((leaf) => leaf.ancestry[1] === initial[0].rev), "Concurrent branches lost their shared base" ); } await pause(5600); const after = await snapshot(pair.a, path); const peer = await snapshot(pair.b, path); check(!after.waiting.some((item) => item.path === path), "Batch timer did not complete"); check( JSON.stringify(after.leaves.map((leaf) => leaf.rev).sort()) === JSON.stringify(leavesBefore), "The delayed event created another revision" ); check( JSON.stringify(peer.leaves.map((leaf) => leaf.rev).sort()) === JSON.stringify(leavesBefore), "Both branches did not converge to the peer" ); if (incoming === "identical") check(after.leaves.length === 1, "Identical content created a branch"); return { initial, pending, arrived, after, peer, incomingLatencyMs: Date.now() - sentAt - 5600 }; }) ); } await run("enabled-maximum-delay", () => withPair(async (pair) => { await options(pair.a, "enabled-batch", 2, 6); await options(pair.b, "normal"); const path = `${prefix}maximum.md`; await create(pair.b, path, "Initial maximum body\n"); const initial = await settled(pair, path, "Initial maximum body\n"); const started = Date.now(); let finalBody = ""; for (let edit = 0; edit < 26; edit++) { finalBody = `Continuous edit ${edit}\n`; await modify(pair.a, path, finalBody); await pause(350); } const stopped = Date.now(); const final = await settled(pair, path, finalBody); const changes = await evaluate>( pair.a, `return globalThis.__issue1207Batch.changes.filter(item=>item.path===${JSON.stringify(path)}&&item.rev!==${JSON.stringify(initial[0].rev)});` ); check(changes.length >= 2, "Continuous edits did not produce bounded intermediate and final saves"); const first = changes[0].at - started; check(first >= 5000 && first <= 8000, `Unexpected first maximum-delay flush: ${first}ms`); check(changes[0].at < stopped - 1000, "The maximum flush only occurred after typing stopped"); return { started, stopped, firstSaveMs: first, changes, final }; }) ); await run("enabled-many-paths", () => withPair(async (pair) => { await options(pair.a, "enabled-batch", 2, 6); await options(pair.b, "normal"); const started = Date.now(); for (let index = 0; index < 12; index++) await create(pair.a, `${prefix}many-${index}.md`, `File ${index}\n`); const final = []; for (let index = 0; index < 12; index++) final.push(await settled(pair, `${prefix}many-${index}.md`, `File ${index}\n`)); return { elapsedMs: Date.now() - started, final }; }) ); await run("enabled-manual-sync", () => withPair(async (pair) => { await options(pair.a, "enabled-batch", 5, 60); await options(pair.b, "normal"); const path = `${prefix}manual.md`; await create(pair.b, path, "Before manual sync\n"); await settled(pair, path, "Before manual sync\n"); await modify(pair.a, path, "After manual sync\n"); const pending = await waitFor(pair.a, path, (state) => state.waiting.some((item) => item.path === path) ); const started = Date.now(); const outcome = await evaluate(pair.a, "return await core.services.replication.replicate(true);"); const atReturn = await snapshot(pair.a, path); const final = await settled(pair, path, "After manual sync\n"); const elapsedMs = Date.now() - started; check(elapsedMs < 4000, `Manual sync waited for the Batch timer: ${elapsedMs}ms`); return { pending, outcome, atReturn, final, elapsedMs }; }) ); const saveAfterEdit = process.env.ISSUE1207_RESTART_SAVE_AFTER_EDIT === "true"; const restartBucket = process.env.ISSUE1207_RESTART_BUCKET; check( !restartBucket || ["same", "exact", "different"].includes(restartBucket), "Unknown restart timestamp condition" ); const restartName = restartBucket ? `enabled-inflight-restart-${restartBucket}` : saveAfterEdit ? "enabled-settings-save-restart" : "enabled-restart"; await run(restartName, async () => { const path = `${prefix}restart.md`; const beforeRestart = await withPair(async (pair) => { await options(pair.a, "enabled-batch", 5, 60); await options(pair.b, "normal"); if (!saveAfterEdit) await evaluate( pair.a, "await core.services.setting.saveSettingData(); await core.services.control.applySettings();" ); await create(pair.b, path, "Before restart\n"); const initial = await settled(pair, path, "Before restart\n"); if (restartBucket) { check(initial[0].databaseMtime !== null, "Missing initial database mtime"); const mtime = restartBucket === "exact" ? initial[0].databaseMtime : Math.floor(initial[0].databaseMtime / 2000) * 2000 + (restartBucket === "same" ? 1000 : 3000); await evaluate( pair.a, `const original=manager.requestProcessQueue.bind(manager); manager.requestProcessQueue=async item=>{ if(item.args?.file?.path===${JSON.stringify(path)}) { globalThis.__issue1207Batch.inflightPaused=true; await new Promise(resolve=>setTimeout(resolve,30000)); } return await original(item); }; await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),"Retained after restart\\n",{mtime:${mtime}});` ); } else await modify(pair.a, path, "Retained after restart\n"); const pending = await waitFor(pair.a, path, (state) => state.waiting.some((item) => item.path === path) ); if (saveAfterEdit) await evaluate(pair.a, "await core.services.setting.saveSettingData();"); if (restartBucket) await evaluate( pair.a, `const deadline=Date.now()+core.services.setting.currentSettings().batchSaveMinimumDelay*1000+3000; while(!globalThis.__issue1207Batch.inflightPaused&&Date.now()setTimeout(resolve,10)); if(!globalThis.__issue1207Batch.inflightPaused) throw new Error('The in-flight test gate did not start');` ); await evaluate(pair.a, "await manager._takeSnapshot();"); check(pending.rev === initial[0].rev, "Restart test did not retain a pending edit"); if (restartBucket) { const inFlight = await snapshot(pair.a, path); const saved = await evaluate>( pair.a, `return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));` ); check( inFlight.rev === initial[0].rev && !inFlight.waiting.some((item) => item.path === path), "The in-flight fixture did not stop between gate release and database storage" ); check( saved.some((item) => item.path === path), "The in-flight event was lost from the recoverable snapshot" ); check(inFlight.fileMtime !== null && inFlight.databaseMtime !== null, "Missing in-flight mtimes"); const sameBucket = Math.floor(inFlight.fileMtime / 2000) === Math.floor(inFlight.databaseMtime / 2000); check(sameBucket === (restartBucket !== "different"), "The timestamp condition was not applied"); if (restartBucket === "exact") check(inFlight.fileMtime === inFlight.databaseMtime, "Raw mtimes differ"); observations.push({ phase: "inflight-before-stop", restartBucket, pauseBeforeDatabaseMs: 30000, inFlight, saved, sameBucket, }); } if (!saveAfterEdit && !restartBucket) { const strictState = await snapshot(pair.a, path); const saved = await evaluate>( pair.a, `return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));` ); check( strictState.rev === initial[0].rev && strictState.waiting.some((item) => item.path === path), "Restart fixture flushed before shutdown" ); check( saved.some((item) => item.path === path), "Restart fixture did not persist the pending event" ); observations.push({ phase: "restart-verified-pending-snapshot", strictState, saved, initialized: await evaluate(pair.a, "return await core.kvDB.get('initialized');"), }); } if (!saveAfterEdit) observations.push({ phase: "restart-before-stop", initial, pending, persisted: await evaluate( pair.a, `const events=await manager.adapter.persistence.loadSnapshot(); const settings=core.services.setting.currentSettings(); return {initialized:await core.kvDB.get('initialized'),restorationRequested:manager.snapShotRestored!==null, settings:{isConfigured:settings.isConfigured,liveSync:settings.liveSync,batchSave:settings.batchSave,batchSaveInLiveSync:settings.batchSaveInLiveSync, suspendFileWatching:settings.suspendFileWatching}, events:events?.map(item=>({type:item.type,path:item.args?.file?.path, stat:item.args?.file?.stat,skipBatchWait:item.skipBatchWait}))};` ), }); return { initial, pending }; }); await stop(slots[0]); const persistedSettings = JSON.parse( await readFile(join(slots[0].vault.path, ".obsidian/plugins/obsidian-livesync/data.json"), "utf8") ); observations.push({ phase: "restart-on-disk-settings", settings: Object.fromEntries( [ "isConfigured", "liveSync", "batchSave", "batchSaveInLiveSync", "suspendFileWatching", "settingVersion", ].map((key) => [key, persistedSettings[key]]) ), }); await start(slots[0], true); const afterRestart = await withPair(async (pair) => { observations.push({ phase: "restart-after-boot", state: await snapshot(pair.a, path), persisted: await evaluate( pair.a, `const events=await manager.adapter.persistence.loadSnapshot(); const settings=core.services.setting.currentSettings(); return {initialized:await core.kvDB.get('initialized'),restorationRequested:manager.snapShotRestored!==null, settings:{isConfigured:settings.isConfigured,liveSync:settings.liveSync,batchSave:settings.batchSave,batchSaveInLiveSync:settings.batchSaveInLiveSync, suspendFileWatching:settings.suspendFileWatching}, events:events?.map(item=>({type:item.type,path:item.args?.file?.path,stat:item.args?.file?.stat}))};` ), }); const result = await settled(pair, path, "Retained after restart\n"); check(result[0].shouldBatchSave, "The saved LiveSync batching choice was not restored"); return { result, restoredGuard: result[0].shouldBatchSave }; }); return { beforeRestart, afterRestart }; }); await run("enabled-after-write-restart", async () => { const path = `${prefix}after-write.md`; const saved = await withPair(async (pair) => { await options(pair.a, "enabled-batch", 1, 3); await options(pair.b, "normal"); await evaluate( pair.a, "await core.services.setting.saveSettingData(); await core.services.control.applySettings();" ); await create(pair.b, path, "Before the durable write\n"); const initial = await settled(pair, path, "Before the durable write\n"); await evaluate( pair.a, ` const original=manager.requestProcessQueue.bind(manager); manager.requestProcessQueue=async item=>{ await original(item); if(item.args?.file?.path===${JSON.stringify(path)}) { globalThis.__issue1207Batch.afterWritePaused=true; await new Promise(resolve=>setTimeout(resolve,30000)); } }; await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}),"Durable before restart\\n"); const deadline=Date.now()+6000; while(!globalThis.__issue1207Batch.afterWritePaused&&Date.now()setTimeout(resolve,20)); if(!globalThis.__issue1207Batch.afterWritePaused) throw new Error('The after-write gate did not start'); await manager._takeSnapshot(); ` ); const durable = await snapshot(pair.a, path); const pending = await evaluate>( pair.a, "return (await manager.adapter.persistence.loadSnapshot()).map(item=>({path:item.args?.file?.path}));" ); check(durable.rev !== initial[0].rev, "The database write did not complete before interruption"); check( pending.some((item) => item.path === path), "The after-write operation was not recoverable" ); return { initial, durable, pending }; }); const restarts = []; for (let attempt = 0; attempt < 2; attempt++) { await stop(slots[0]); await start(slots[0], true); const final = await withPair((pair) => settled(pair, path, "Durable before restart\n")); check(final[0].rev === saved.durable.rev, "Replay created a duplicate database revision"); restarts.push(final); } const later = await withPair(async (pair) => { await modify(pair.a, path, "A later edit survives\n"); return await settled(pair, path, "A later edit survives\n"); }); return { saved, restarts, later }; }); await run("enabled-before-snapshot-restart-exact", async () => { const path = `${prefix}before-snapshot.md`; const beforeStop = await withPair(async (pair) => { await options(pair.a, "enabled-batch", 5, 60); await options(pair.b, "normal"); await evaluate( pair.a, "await core.services.setting.saveSettingData(); await core.services.control.applySettings();" ); await create(pair.b, path, "Before the first snapshot\n"); const initial = await settled(pair, path, "Before the first snapshot\n"); check(initial[0].databaseMtime !== null, "Missing initial database mtime"); await evaluate( pair.a, ` await manager.waitForIdle(); const persistence=manager.adapter.persistence; const original=persistence.saveSnapshot.bind(persistence); persistence.saveSnapshot=async events=>{ if(events.some(item=>item.type==='CHANGED'&&item.args?.file?.path===${JSON.stringify(path)})) { globalThis.__issue1207Batch.beforeSnapshotPaused=true; await new Promise(resolve=>setTimeout(resolve,30000)); } return await original(events); }; await app.vault.modify(app.vault.getFileByPath(${JSON.stringify(path)}), "Recovered without an event snapshot\\n",{mtime:${initial[0].databaseMtime}}); const deadline=Date.now()+3000; while(!globalThis.__issue1207Batch.beforeSnapshotPaused&&Date.now()setTimeout(resolve,10)); if(!globalThis.__issue1207Batch.beforeSnapshotPaused) throw new Error('The snapshot persistence gate did not start'); ` ); const state = await snapshot(pair.a, path); const persisted = await evaluate>( pair.a, "return (await manager.adapter.persistence.loadSnapshot()??[]).map(item=>({path:item.args?.file?.path}));" ); check(state.rev === initial[0].rev, "The unsaved edit reached the database before interruption"); check(state.fileMtime === state.databaseMtime, "Raw mtimes differ before interruption"); check(!persisted.some((item) => item.path === path), "The interrupted edit already has a saved event"); return { initial, state, persisted }; }); await stop(slots[0]); await start(slots[0], true); const recovered = await withPair((pair) => settled(pair, path, "Recovered without an event snapshot\n")); check(recovered[0].shouldBatchSave, "The saved LiveSync batching choice was not restored"); return { beforeStop, recovered }; }); evidence.artefactsAfter = hashes(); check( JSON.stringify(before) === JSON.stringify(evidence.artefactsAfter), "Plug-in artefacts changed during testing" ); evidence.passed = cases.every((item) => item.passed); await writeFile(join(diagnostics, "evidence.json"), redact(evidence)); console.log( JSON.stringify({ phase: "completed", passed: evidence.passed, cases: cases.length, failed: cases.filter((item) => !item.passed).map((item) => item.name), }) ); if (!evidence.passed) process.exitCode = 1; } finally { for (const slot of slots) await stop(slot); for (const slot of slots) await slot.vault.dispose(); if (ownedDatabase) await deleteCouchDbDatabase(config, dbName); evidence.cleanup = { devicesStopped: slots.every((slot) => !slot.session), databaseRemoved: ownedDatabase }; await writeFile(join(diagnostics, "evidence.json"), redact(evidence)); } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exitCode = 1; });