mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-05 15:58:19 +00:00
Stop conflict work after disposal and verify reload preservation
Stop pending dialogue and selected-resolution continuations after disposal, before they log or start further resolution work. Verify released log binders and retain real-Obsidian checks for content and revision history across reload.
This commit is contained in:
1 parent
d557298ceb
commit
6f8df8f731
4 files changed
+511
-50
No files matched your search
@@ -1,3 +1,6 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DoctorRegulation } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
@@ -28,6 +31,11 @@ type ConflictFixture = {
|
||||
conflicts: string[];
|
||||
};
|
||||
|
||||
type ObservedConflictService = {
|
||||
ensureAllProcessed(): Promise<boolean>;
|
||||
conflictProcessQueueCount: { value: number };
|
||||
};
|
||||
|
||||
type ObsidianTestApp = {
|
||||
commands?: { executeCommandById(commandId: string): boolean };
|
||||
plugins?: {
|
||||
@@ -38,10 +46,7 @@ type ObsidianTestApp = {
|
||||
| {
|
||||
core?: {
|
||||
services?: {
|
||||
conflict?: {
|
||||
ensureAllProcessed(): Promise<boolean>;
|
||||
conflictProcessQueueCount: { value: number };
|
||||
};
|
||||
conflict?: ObservedConflictService;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -54,8 +59,43 @@ type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: ObsidianTestApp;
|
||||
__livesyncConflictChecksCompleted?: boolean;
|
||||
__livesyncWaitingConflictCompleted?: boolean;
|
||||
__livesyncConflictChecksError?: string;
|
||||
__livesyncWaitingConflictError?: string;
|
||||
__livesyncObservedConflict?: ObservedConflictService;
|
||||
};
|
||||
|
||||
async function recordConflictDiagnostics(port: number, label: string) {
|
||||
const state = await withObsidianPage(
|
||||
port,
|
||||
async (page) =>
|
||||
await page.evaluate(() => {
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
const plugin = host.app?.plugins?.plugins?.["obsidian-livesync"];
|
||||
const conflict = host.__livesyncObservedConflict ?? plugin?.core?.services?.conflict;
|
||||
return {
|
||||
pluginLoaded: plugin !== undefined,
|
||||
queueCount: conflict?.conflictProcessQueueCount.value,
|
||||
checksCompleted: host.__livesyncConflictChecksCompleted,
|
||||
waitingCompleted: host.__livesyncWaitingConflictCompleted,
|
||||
checksError: host.__livesyncConflictChecksError,
|
||||
waitingError: host.__livesyncWaitingConflictError,
|
||||
visibility: document.visibilityState,
|
||||
dialogues: Array.from(document.querySelectorAll(".modal-container")).map((element) => ({
|
||||
visible: element.getClientRects().length > 0,
|
||||
title: element.querySelector(".modal-title")?.textContent,
|
||||
text: element.textContent?.slice(0, 300),
|
||||
actions: element.querySelectorAll(".conflict-action-button").length,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
const directory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, `conflict-${label}.json`), JSON.stringify(state, null, 2) + "\n");
|
||||
console.log(`Conflict ${label}: ${JSON.stringify(state)}`);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv, targetPath = path): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -249,7 +289,7 @@ async function createTwoVersionConflict(
|
||||
if (fixture.conflicts.length !== 1) {
|
||||
throw new Error(`Expected exactly two live leaves for ${targetPath}: ${JSON.stringify(fixture)}`);
|
||||
}
|
||||
return fixture;
|
||||
return { ...fixture, currentParentRev: base.rev };
|
||||
}
|
||||
|
||||
async function setShowMergeDialogOnlyOnActive(
|
||||
@@ -308,13 +348,19 @@ async function requestInteractiveConflictResolution(
|
||||
`const conflictRev=${JSON.stringify(fixture.conflicts[0])};`,
|
||||
`const trackCompletion=${JSON.stringify(trackCompletion)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"if(trackCompletion) globalThis.__livesyncWaitingConflictCompleted=false;",
|
||||
"if(trackCompletion){",
|
||||
" globalThis.__livesyncWaitingConflictCompleted=false;",
|
||||
" globalThis.__livesyncWaitingConflictError=undefined;",
|
||||
"}",
|
||||
"const pending=core.services.conflict.resolveByUserInteraction(path,{",
|
||||
"left:{rev:currentRev,data:'Current branch',ctime:1,mtime:2},",
|
||||
"right:{rev:conflictRev,data:'Conflict branch',ctime:1,mtime:3},",
|
||||
"diff:[[0,'Current and conflict branches']],",
|
||||
"});",
|
||||
"if(trackCompletion) void pending.then(()=>{globalThis.__livesyncWaitingConflictCompleted=true;});",
|
||||
"if(trackCompletion) void pending.then(",
|
||||
" ()=>{globalThis.__livesyncWaitingConflictCompleted=true;},",
|
||||
" (error)=>{globalThis.__livesyncWaitingConflictError=error?.stack??String(error);}",
|
||||
");",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
@@ -328,10 +374,18 @@ async function disableLiveSyncAndWaitForConflictChecks(port: number): Promise<vo
|
||||
const host = globalThis as ObsidianTestGlobal;
|
||||
const conflict = host.app?.plugins?.plugins?.["obsidian-livesync"]?.core?.services?.conflict;
|
||||
if (conflict === undefined) throw new Error("LiveSync conflict service is unavailable before unload");
|
||||
host.__livesyncObservedConflict = conflict;
|
||||
host.__livesyncConflictChecksCompleted = false;
|
||||
void conflict.ensureAllProcessed().then(() => {
|
||||
host.__livesyncConflictChecksCompleted = true;
|
||||
});
|
||||
host.__livesyncConflictChecksError = undefined;
|
||||
void conflict.ensureAllProcessed().then(
|
||||
() => {
|
||||
host.__livesyncConflictChecksCompleted = true;
|
||||
},
|
||||
(error) => {
|
||||
host.__livesyncConflictChecksError =
|
||||
error instanceof Error ? (error.stack ?? String(error)) : String(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
await page.evaluate(async () => {
|
||||
const plugins = (globalThis as ObsidianTestGlobal).app?.plugins;
|
||||
@@ -362,6 +416,76 @@ async function enableLiveSync(port: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
type UnloadConflictFixture = { path: string; fixture: ConflictFixture };
|
||||
type PreservedConflictState = {
|
||||
path: string;
|
||||
currentRev: string;
|
||||
baseContent: string | false;
|
||||
vaultContent: string;
|
||||
leaves: { rev: string; parentRev: string; content: string | false; deleted: boolean }[];
|
||||
};
|
||||
|
||||
async function recordUnloadPreservation(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
label: string,
|
||||
fixtures: UnloadConflictFixture[],
|
||||
before?: PreservedConflictState[]
|
||||
): Promise<PreservedConflictState[]> {
|
||||
const states = await evalObsidianJson<PreservedConflictState[]>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const fixtures=${JSON.stringify(fixtures)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const contentOf=(entry)=>entry===false?false:Array.isArray(entry.data)?entry.data.join(''):entry.data;",
|
||||
"const states=[];",
|
||||
"for(const {path,fixture} of fixtures){",
|
||||
" const id=await core.services.path.path2id(path);",
|
||||
" const meta=await core.localDatabase.getRaw(id,{conflicts:true});",
|
||||
" const leaves=[];",
|
||||
" for(const rev of [meta._rev,...(meta._conflicts??[])].sort()){",
|
||||
" const raw=await core.localDatabase.getRaw(id,{rev,revs:true});",
|
||||
" const history=raw._revisions;",
|
||||
" const parentRev=history?.ids?.length>1?`${history.start-1}-${history.ids[1]}`:undefined;",
|
||||
" const entry=await core.localDatabase.getDBEntry(path,{rev},false,true,true);",
|
||||
" leaves.push({rev,parentRev,content:contentOf(entry),deleted:!!(raw.deleted||raw._deleted)});",
|
||||
" }",
|
||||
" const base=await core.localDatabase.getDBEntry(path,{rev:fixture.currentParentRev},false,true,true);",
|
||||
" const vaultContent=await app.vault.adapter.read(path);",
|
||||
" states.push({path,currentRev:meta._rev,baseContent:contentOf(base),vaultContent,leaves});",
|
||||
"}",
|
||||
"return JSON.stringify(states);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
const directory = process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e";
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, `conflict-${label}-preservation.json`), JSON.stringify(states, null, 2) + "\n");
|
||||
for (const { path: targetPath, fixture } of fixtures) {
|
||||
const state = states.find((entry) => entry.path === targetPath);
|
||||
const expectedRevs = [fixture.currentRev, ...fixture.conflicts].sort();
|
||||
if (
|
||||
!state ||
|
||||
state.currentRev !== fixture.currentRev ||
|
||||
state.baseContent !== baseContent ||
|
||||
JSON.stringify(state.leaves.map((leaf) => leaf.rev)) !== JSON.stringify(expectedRevs) ||
|
||||
JSON.stringify(state.leaves.map((leaf) => leaf.content).sort()) !==
|
||||
JSON.stringify([leftContent, rightContent].sort()) ||
|
||||
state.leaves.some((leaf) => leaf.deleted || leaf.parentRev !== fixture.currentParentRev)
|
||||
) {
|
||||
throw new Error(`Conflict contents or ancestry changed across ${label}: ${JSON.stringify(state)}`);
|
||||
}
|
||||
const previous = before?.find((entry) => entry.path === targetPath);
|
||||
if (previous && state.vaultContent !== previous.vaultContent) {
|
||||
throw new Error(`Vault content changed across ${label}: ${targetPath}`);
|
||||
}
|
||||
}
|
||||
console.log(`Verified conflict contents, leaf revisions, and ancestry: ${label}`);
|
||||
return states;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
@@ -372,14 +496,17 @@ async function main(): Promise<void> {
|
||||
|
||||
const vault = await createTemporaryVault("obsidian-livesync-conflict-dialog-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
let unloadFixtures: UnloadConflictFixture[] = [];
|
||||
let unloadPreservationBefore: PreservedConflictState[] | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
doctorProcessedVersion: DoctorRegulation.version,
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
remoteType: "",
|
||||
@@ -433,34 +560,31 @@ async function main(): Promise<void> {
|
||||
(page) => conflictDialogue(page).locator(".modal").first()
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
const actionButtons = modal.locator(".conflict-action-button");
|
||||
await actionButtons.nth(3).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const actionButtonBounds = await actionButtons.evaluateAll((buttons) =>
|
||||
buttons.map((button) => {
|
||||
const bounds = button.getBoundingClientRect();
|
||||
return { top: bounds.top, bottom: bounds.bottom };
|
||||
})
|
||||
await page.waitForFunction(
|
||||
(targetPath) => {
|
||||
const dialogues = Array.from(document.querySelectorAll(".modal-container")).filter(
|
||||
(element) =>
|
||||
element.getClientRects().length > 0 &&
|
||||
element.querySelector(".modal-title")?.textContent?.includes("Conflicting changes") &&
|
||||
element.textContent?.includes(targetPath)
|
||||
);
|
||||
if (dialogues.length !== 1) return false;
|
||||
const bounds = Array.from(dialogues[0].querySelectorAll(".conflict-action-button")).map((button) =>
|
||||
button.getBoundingClientRect()
|
||||
);
|
||||
return (
|
||||
bounds.length === 4 &&
|
||||
bounds.every(
|
||||
(button, index) =>
|
||||
button.width > 0 &&
|
||||
button.height > 0 &&
|
||||
(index === 0 || button.top >= bounds[index - 1].bottom)
|
||||
)
|
||||
);
|
||||
},
|
||||
path,
|
||||
{ timeout: uiTimeoutMs }
|
||||
);
|
||||
if (
|
||||
actionButtonBounds.length !== 4 ||
|
||||
actionButtonBounds.some(
|
||||
(bounds, index) => index > 0 && bounds.top < actionButtonBounds[index - 1].bottom
|
||||
)
|
||||
) {
|
||||
const buttonDetails = await modal.locator("button").evaluateAll((buttons) =>
|
||||
buttons.map((button) => ({
|
||||
text: button.textContent,
|
||||
className: button.className,
|
||||
}))
|
||||
);
|
||||
throw new Error(
|
||||
`Conflict action buttons are not stacked vertically: ${JSON.stringify({
|
||||
actionButtonBounds,
|
||||
buttonDetails,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
});
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = conflictDialogue(page);
|
||||
@@ -506,6 +630,7 @@ async function main(): Promise<void> {
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
@@ -542,6 +667,7 @@ async function main(): Promise<void> {
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
artifactRoot: process.env.E2E_OBSIDIAN_ARTIFACT_ROOT,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
@@ -734,8 +860,12 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await waitForConflictChecks(cliBinary, session.cliEnv);
|
||||
|
||||
await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
|
||||
const unloadActiveFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadActivePath);
|
||||
const unloadWaitingFixture = await createTwoVersionConflict(cliBinary, session.cliEnv, unloadWaitingPath);
|
||||
unloadFixtures = [
|
||||
{ path: unloadActivePath, fixture: unloadActiveFixture },
|
||||
{ path: unloadWaitingPath, fixture: unloadWaitingFixture },
|
||||
];
|
||||
await requestConflictCheck(cliBinary, session.cliEnv, false, unloadActivePath);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await conflictDialogue(page, unloadActivePath).waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
@@ -752,7 +882,14 @@ async function main(): Promise<void> {
|
||||
"conflict-dialog-before-unload.png",
|
||||
(page) => conflictDialogue(page, unloadActivePath).locator(".modal").first()
|
||||
);
|
||||
unloadPreservationBefore = await recordUnloadPreservation(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
"before-unload",
|
||||
unloadFixtures
|
||||
);
|
||||
await disableLiveSyncAndWaitForConflictChecks(session.remoteDebuggingPort);
|
||||
await recordConflictDiagnostics(session.remoteDebuggingPort, "unloaded");
|
||||
const afterUnloadScreenshot = await captureObsidianPage(
|
||||
session.remoteDebuggingPort,
|
||||
"conflict-dialog-after-unload.png",
|
||||
@@ -770,6 +907,13 @@ async function main(): Promise<void> {
|
||||
|
||||
await enableLiveSync(session.remoteDebuggingPort);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await recordUnloadPreservation(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
"after-reload",
|
||||
unloadFixtures,
|
||||
unloadPreservationBefore
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.waitForTimeout(500);
|
||||
if (await conflictDialogue(page).isVisible()) {
|
||||
@@ -803,6 +947,27 @@ async function main(): Promise<void> {
|
||||
console.log(`Before unload screenshot: ${beforeUnloadScreenshot}`);
|
||||
console.log(`After unload screenshot: ${afterUnloadScreenshot}`);
|
||||
console.log(`After reload screenshot: ${afterReloadScreenshot}`);
|
||||
} catch (error) {
|
||||
if (session) {
|
||||
try {
|
||||
const state = await recordConflictDiagnostics(session.remoteDebuggingPort, "failure");
|
||||
await captureObsidianPage(session.remoteDebuggingPort, "conflict-failure.png", async () => {});
|
||||
if (!state.pluginLoaded && unloadPreservationBefore) {
|
||||
await enableLiveSync(session.remoteDebuggingPort);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await recordUnloadPreservation(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
"after-failed-unload-reload",
|
||||
unloadFixtures,
|
||||
unloadPreservationBefore
|
||||
);
|
||||
}
|
||||
} catch (diagnosticError) {
|
||||
console.error("Could not capture conflict diagnostics:", diagnosticError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
|
||||
Reference in new issue
Block a user