mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-27 23:13:03 +00:00
Improve recovery diagnostics and actions
This commit is contained in:
@@ -161,7 +161,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
|
||||
|
||||
`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose the appropriate `…` menu for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
|
||||
@@ -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