mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-28 07:23:00 +00:00
Improve recovery diagnostics and actions
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user