Improve recovery diagnostics and actions

This commit is contained in:
vorotamoroz
2026-07-25 09:01:25 +00:00
parent bea0e68091
commit 12fc43a69c
16 changed files with 1980 additions and 243 deletions
+445 -49
View File
@@ -8,8 +8,10 @@ import {
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
import type { Locator, Page } from "playwright";
const path = "revision-repair.md";
const healthyDeletedPath = "healthy-logical-deletion.md";
const baseContent = "Revision repair\n\nShared base.\n";
const branchContents = [
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
@@ -28,6 +30,11 @@ type RevisionTree = {
conflictRevisions: string[];
};
type VaultWinnerState = {
matches: boolean;
winnerRevision: string;
};
type ObsidianSettingsController = {
open(): void;
openTabById(tabId: string): void;
@@ -56,6 +63,49 @@ async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv):
);
}
async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
await evalObsidianJson<unknown>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`,
"let file=app.vault.getAbstractFileByPath(path);",
"if(!file) file=await app.vault.create(path,content);",
"return JSON.stringify({ok:true});",
"})()",
].join(""),
env
);
await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath);
return await evalObsidianJson<string>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(healthyDeletedPath)};`,
`const timeoutMs=${JSON.stringify(uiTimeoutMs)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);",
"await app.vault.delete(file);",
"const id=await core.services.path.path2id(path);",
"const deadline=Date.now()+timeoutMs;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"while(Date.now()<deadline){",
" await core.services.fileProcessing.commitPendingFileEvents();",
" const doc=await core.localDatabase.localDatabase.get(id,{conflicts:true}).catch(()=>false);",
" if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){",
" return JSON.stringify(doc._rev);",
" }",
" await sleep(250);",
"}",
"throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);",
"})()",
].join(""),
env
);
}
async function createBrokenConflict(
cliBinary: string,
env: NodeJS.ProcessEnv,
@@ -128,6 +178,93 @@ async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Prom
);
}
async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultWinnerState> {
return await evalObsidianJson<VaultWinnerState>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(path)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error(`Vault file is missing: ${path}`);",
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
"if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);",
"const vaultContent=await app.vault.read(file);",
"const data=Array.isArray(entry.data)?entry.data:[entry.data];",
"const databaseContent=await new Blob(data).text();",
"return JSON.stringify({",
" matches:vaultContent===databaseContent,",
" winnerRevision:entry._rev,",
"});",
"})()",
].join(""),
env
);
}
async function readFileReflectionProvenance(
cliBinary: string,
env: NodeJS.ProcessEnv,
targetPath = path
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(targetPath)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
"return JSON.stringify((await store.get(path))??null);",
"})()",
].join(""),
env
);
}
function repairCard(settings: Locator): Locator {
return settings.locator(".sls-repair-result").filter({ hasText: path });
}
function revisionCard(settings: Locator, revision: string): Locator {
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
}
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise<Locator> {
await revisionCard(settings, revision)
.getByRole("button", {
name: `More actions for revision ${revision}`,
exact: true,
})
.click({ timeout: uiTimeoutMs });
const menu = page.locator(".menu:visible").last();
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
const box = await menu.boundingBox();
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
if (
box === null ||
box.y < 0 ||
box.y + box.height > viewport.height - 4
) {
throw new Error(
`Revision action menu is outside the viewport: ${JSON.stringify({
box,
viewport,
})}`
);
}
return menu;
}
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
const menu = await openRevisionActionMenu(page, settings, revision);
const item = menu.getByText(action, { exact: true });
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
await item.click({ timeout: uiTimeoutMs });
}
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
await evalObsidianJson<unknown>(
cliBinary,
@@ -187,7 +324,22 @@ async function main(): Promise<void> {
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
await createAndOpenBaseFile(cliBinary, session.cliEnv);
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
const healthyDeletionProvenance = await readFileReflectionProvenance(
cliBinary,
session.cliEnv,
healthyDeletedPath
);
if (healthyDeletionProvenance !== null) {
throw new Error(
`A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({
healthyDeletedPath,
healthyDeletionRevision,
healthyDeletionProvenance,
})}`
);
}
await requestConflictCheck(cliBinary, session.cliEnv);
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
@@ -219,13 +371,17 @@ async function main(): Promise<void> {
await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({
timeout: uiTimeoutMs,
});
const card = settings.locator(".sls-repair-result").filter({ hasText: path });
const card = repairCard(settings);
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
const brokenRevision = card
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision });
if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) {
throw new Error(
`Verify and Repair reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).`
);
}
const winnerRevision = revisionCard(settings, fixture.winnerRevision);
const brokenRevision = revisionCard(settings, fixture.conflictRevision);
await brokenRevision
.getByText(/Unreadable on this device/u)
.getByText(/🧩 Missing chunks: 1/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
state: "visible",
@@ -234,43 +390,276 @@ async function main(): Promise<void> {
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
}
await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({
for (const label of [
/📦 DB: recorded/u,
/📁 Vault:/u,
/Δsize vs DB/u,
/🕒 DB /u,
/Δtime /u,
/ Differs from Vault/u,
]) {
await winnerRevision.getByText(label).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
await brokenRevision.getByText(/decoded unavailable/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await settings
.locator(".sls-repair-result")
.filter({ hasText: path })
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision })
.getByText(/Unreadable on this device/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
for (const label of [
"Compare with Vault",
"Apply this revision to Vault",
"Store Vault file as a child of this revision",
"Discard this branch",
]) {
await winnerMenu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
if (
(await winnerMenu
.getByText("Mark this revision as the Vault version", {
exact: true,
})
.count()) !== 0
) {
throw new Error("A differing revision incorrectly offered to record an exact Vault match.");
}
await page.keyboard.press("Escape");
});
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
if (!afterRetry.conflictRevisions.includes(fixture.conflictRevision)) {
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
}
const screenshot = await captureObsidianElement(
const repairCardScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? "";
htmlElement.style.width = "360px";
htmlElement.style.maxWidth = "100%";
});
const dimensions = await card.evaluate((element) => ({
clientWidth: element.clientWidth,
scrollWidth: element.scrollWidth,
}));
if (dimensions.scrollWidth > dimensions.clientWidth + 1) {
throw new Error(
`Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}`
);
}
});
const mobileWidthScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-mobile-width.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const card = page.locator(".sls-repair-result").filter({ hasText: path });
await card.evaluate((element) => {
const htmlElement = element as HTMLElement;
const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? "";
if (originalStyle.length > 0) {
htmlElement.setAttribute("style", originalStyle);
} else {
htmlElement.removeAttribute("style");
}
delete htmlElement.dataset.e2eOriginalStyle;
});
});
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const brokenRevision = () =>
settings
.locator(".sls-repair-result")
.filter({ hasText: path })
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision });
await brokenRevision()
.getByRole("button", { name: "Discard unreadable revision", exact: true })
await openRevisionActionMenu(page, settings, fixture.winnerRevision);
});
const readableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-readable-actions.png",
(page) => page.locator(".menu:visible").last()
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault");
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
await modal.getByText(path, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Vault file:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await modal.getByText(/Database revision:/u).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
const actions = modal.locator(".conflict-action-container");
await actions.getByRole("button", { name: "Close", exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) {
if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) {
throw new Error(`Read-only comparison exposed the resolution action '${action}'.`);
}
}
});
const comparisonScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-read-only-comparison.png",
(page) =>
page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
})
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({
hasText: "Vault and database revision",
}),
});
await modal
.locator(".conflict-action-container")
.getByRole("button", { name: "Close", exact: true })
.click({ timeout: uiTimeoutMs });
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const beforeApply = await readRevisionTree(cliBinary, session.cliEnv);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
has: page.locator(".modal-title").filter({
hasText: "Apply database revision to Vault",
}),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
const status = repairCard(settings).locator(".sls-repair-status");
await status
.getByText("✅ Vault matches winner", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await status
.getByText("⚠️ Conflicts: 1", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const matchedWinnerWithConflictScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-winner-match-with-conflict.png",
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
);
const afterApply = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) {
throw new Error(
`Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({
beforeApply,
afterApply,
})}`
);
}
const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!vaultWinner.matches ||
vaultWinner.winnerRevision !== fixture.winnerRevision ||
appliedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({
vaultWinner,
appliedProvenance,
fixture,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Discard this branch", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
await menu
.getByText("Mark this revision as the Vault version", { exact: true })
.click({ timeout: uiTimeoutMs });
await revisionCard(settings, fixture.winnerRevision)
.getByText("✅ Matches Vault", { exact: true })
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv);
const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) ||
markedProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({
beforeApply,
afterExactMark,
markedProvenance,
})}`
);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision);
for (const label of [
"Store Vault file as a child of this revision",
"Retry reading revision",
"Discard this branch",
]) {
await menu.getByText(label, { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
}
});
const unreadableMenuScreenshot = await captureObsidianElement(
session.remoteDebuggingPort,
"revision-repair-unreadable-actions-context.png",
(page) => page.locator("body")
);
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
await page.keyboard.press("Escape");
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision");
await revisionCard(settings, fixture.conflictRevision)
.getByText(/🧩 Missing chunks:/u)
.waitFor({ state: "visible", timeout: uiTimeoutMs });
});
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) {
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
@@ -278,36 +667,23 @@ async function main(): Promise<void> {
});
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) {
if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) {
throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`);
}
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const settings = page.locator(".sls-setting");
const brokenRevision = settings
.locator(".sls-repair-result")
.filter({ hasText: path })
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision });
await brokenRevision
.getByRole("button", { name: "Discard unreadable revision", exact: true })
.click({ timeout: uiTimeoutMs });
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
const confirmation = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
});
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
await settings
.locator(".sls-repair-revision")
.filter({ hasText: fixture.conflictRevision })
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
if (
afterDiscard.winnerRevision !== fixture.winnerRevision ||
afterDiscard.conflictRevisions.length !== 0
) {
if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) {
throw new Error(
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
fixture,
@@ -315,11 +691,31 @@ async function main(): Promise<void> {
})}`
);
}
const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
if (
!finalVaultWinner.matches ||
finalVaultWinner.winnerRevision !== fixture.winnerRevision ||
finalProvenance?.revision !== fixture.winnerRevision
) {
throw new Error(
`Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({
finalVaultWinner,
finalProvenance,
fixture,
})}`
);
}
console.log(
"Real Obsidian kept an unreadable conflict revision through automatic checking and retry, rendered every live revision separately, required confirmation, and discarded only the selected revision."
"Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation."
);
console.log(`Repair screenshot: ${screenshot}`);
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`);
console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`);
console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`);
console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`);
} finally {
if (session) {
await session.app.stop();