mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-26 14:32:58 +00:00
Keep unreadable revisions for explicit repair
This commit is contained in:
@@ -722,7 +722,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
await liveSyncSettings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
for (const label of [
|
||||
"Write logs into the file",
|
||||
"Recreate missing chunks for all files",
|
||||
"Recreate chunks for current Vault files",
|
||||
"Verify and repair all files",
|
||||
]) {
|
||||
await liveSyncSettings.locator(".setting-item-name", { hasText: label }).waitFor({
|
||||
@@ -730,7 +730,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate all", exact: true }).waitFor({
|
||||
await liveSyncSettings.getByRole("button", { name: "Recreate current chunks", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
@@ -739,7 +739,7 @@ async function verifyHatchSurfacesAndSafeActions(): Promise<string> {
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await liveSyncSettings
|
||||
.locator(".setting-item-name", { hasText: "Recreate missing chunks for all files" })
|
||||
.locator(".setting-item-name", { hasText: "Recreate chunks for current Vault files" })
|
||||
.scrollIntoViewIfNeeded();
|
||||
return liveSyncSettings;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const testSteps: Step[] = [
|
||||
{ name: "smoke", args: ["run", "test:e2e:obsidian:smoke"] },
|
||||
{ name: "onboarding invitation", args: ["run", "test:e2e:obsidian:onboarding-invitation"] },
|
||||
{ name: "Svelte dialogue mounts", args: ["run", "test:e2e:obsidian:dialog-mounts"] },
|
||||
{ name: "revision repair", args: ["run", "test:e2e:obsidian:revision-repair"] },
|
||||
{ name: "settings UI", args: ["run", "test:e2e:obsidian:settings-ui"] },
|
||||
{ name: "Review Harness", args: ["run", "test:e2e:obsidian:review-harness"] },
|
||||
{ name: "P2P status pane", args: ["run", "test:e2e:obsidian:p2p-pane"] },
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
createE2eObsidianDeviceLocalState,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
|
||||
const path = "revision-repair.md";
|
||||
const baseContent = "Revision repair\n\nShared base.\n";
|
||||
const branchContents = [
|
||||
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
|
||||
`Revision repair\n\nRight branch.\n${"R".repeat(4096)}\n`,
|
||||
] as const;
|
||||
const uiTimeoutMs = Number(process.env.E2E_OBSIDIAN_REVISION_REPAIR_TIMEOUT_MS ?? 15000);
|
||||
|
||||
type BrokenRevisionFixture = {
|
||||
winnerRevision: string;
|
||||
conflictRevision: string;
|
||||
missingChunkId: string;
|
||||
};
|
||||
|
||||
type RevisionTree = {
|
||||
winnerRevision: string;
|
||||
conflictRevisions: string[];
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
};
|
||||
|
||||
type ObsidianTestGlobal = typeof globalThis & {
|
||||
app?: {
|
||||
setting?: ObsidianSettingsController;
|
||||
};
|
||||
};
|
||||
|
||||
async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(baseContent)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
"await app.workspace.getLeaf(false).openFile(file);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function createBrokenConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
baseRevision: string
|
||||
): Promise<BrokenRevisionFixture> {
|
||||
return await evalObsidianJson<BrokenRevisionFixture>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const baseRevision=${JSON.stringify(baseRevision)};`,
|
||||
`const contents=${JSON.stringify(branchContents)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"for(const [index,content] of contents.entries()){",
|
||||
" const blob=new Blob([content],{type:'text/plain'});",
|
||||
" const now=Date.now()+index;",
|
||||
" const result=await core.localDatabase.putDBEntry({",
|
||||
" _id:id,path,data:blob,ctime:now,mtime:now,",
|
||||
" size:(await blob.arrayBuffer()).byteLength,children:[],",
|
||||
" datatype:'plain',type:'plain',eden:{},",
|
||||
" },false,baseRevision);",
|
||||
" if(!result?.ok) throw new Error(`Could not create repair conflict: ${path}`);",
|
||||
"}",
|
||||
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
|
||||
"const conflictRevision=tree._conflicts?.[0];",
|
||||
"if(!tree._rev||!conflictRevision){",
|
||||
" throw new Error(`Repair fixture did not produce two live revisions: ${path}`);",
|
||||
"}",
|
||||
"const conflict=await core.localDatabase.localDatabase.get(id,{rev:conflictRevision});",
|
||||
"const embedded=new Set(Object.keys(conflict.eden??{}));",
|
||||
"const missingChunkId=(conflict.children??[]).find((child)=>!embedded.has(child));",
|
||||
"if(!missingChunkId){",
|
||||
" throw new Error(`Repair fixture did not create an independent chunk: ${conflictRevision}`);",
|
||||
"}",
|
||||
"const chunk=await core.localDatabase.localDatabase.get(missingChunkId);",
|
||||
"await core.localDatabase.localDatabase.remove(chunk);",
|
||||
"core.localDatabase.clearCaches();",
|
||||
"const unreadable=await core.localDatabase.getDBEntry(path,{rev:conflictRevision},false,true,true);",
|
||||
"if(unreadable!==false){",
|
||||
" throw new Error(`The selected revision remained readable after its chunk was removed: ${conflictRevision}`);",
|
||||
"}",
|
||||
"return JSON.stringify({",
|
||||
" winnerRevision:tree._rev,",
|
||||
" conflictRevision,",
|
||||
" missingChunkId,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RevisionTree> {
|
||||
return await evalObsidianJson<RevisionTree>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"const tree=await core.localDatabase.localDatabase.get(id,{conflicts:true});",
|
||||
"return JSON.stringify({",
|
||||
" winnerRevision:tree._rev,",
|
||||
" conflictRevisions:tree._conflicts??[],",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"core.localDatabase.clearCaches();",
|
||||
"await core.services.conflict.queueCheckFor(path);",
|
||||
"await core.services.conflict.ensureAllProcessed();",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
const cliBinary = cli.binary;
|
||||
const vault = await createTemporaryVault("obsidian-livesync-revision-repair-");
|
||||
let session: ObsidianLiveSyncSession | undefined;
|
||||
try {
|
||||
session = await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
pluginData: {
|
||||
doctorProcessedVersion: "1.0.0",
|
||||
isConfigured: true,
|
||||
liveSync: false,
|
||||
remoteType: "",
|
||||
couchDB_URI: "",
|
||||
couchDB_DBNAME: "",
|
||||
couchDB_USER: "",
|
||||
couchDB_PASSWORD: "",
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
notifyThresholdOfRemoteStorageSize: -1,
|
||||
periodicReplication: false,
|
||||
syncAfterMerge: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncOnSave: false,
|
||||
syncOnStart: false,
|
||||
disableMarkdownAutoMerge: true,
|
||||
showMergeDialogOnlyOnActive: true,
|
||||
useEden: false,
|
||||
},
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
|
||||
|
||||
await requestConflictCheck(cliBinary, session.cliEnv);
|
||||
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (
|
||||
afterAutomaticCheck.winnerRevision !== fixture.winnerRevision ||
|
||||
!afterAutomaticCheck.conflictRevisions.includes(fixture.conflictRevision)
|
||||
) {
|
||||
throw new Error(
|
||||
`Automatic conflict checking discarded the unreadable revision: ${JSON.stringify({
|
||||
fixture,
|
||||
afterAutomaticCheck,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.evaluate(() => {
|
||||
const setting = (globalThis as ObsidianTestGlobal).app?.setting;
|
||||
if (setting === undefined) throw new Error("Obsidian settings are unavailable");
|
||||
setting.open();
|
||||
setting.openTabById("obsidian-livesync");
|
||||
});
|
||||
const settings = page.locator(".sls-setting");
|
||||
await settings.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await settings.locator('.sls-setting-menu-btn[title="Hatch"]').click({ timeout: uiTimeoutMs });
|
||||
const verifySetting = settings.locator(".setting-item").filter({
|
||||
has: page.getByText("Verify and repair all files", { exact: true }),
|
||||
});
|
||||
await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const card = settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const brokenRevision = card
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision
|
||||
.getByText(/Unreadable on this device/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
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({
|
||||
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 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(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-unreadable-conflict.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
|
||||
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 });
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await confirmation.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) {
|
||||
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 });
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
|
||||
});
|
||||
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 });
|
||||
});
|
||||
|
||||
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
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,
|
||||
afterDiscard,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
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."
|
||||
);
|
||||
console.log(`Repair screenshot: ${screenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
}
|
||||
await vault.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const focusedScenarios = new Set([
|
||||
"smoke",
|
||||
"onboarding-invitation",
|
||||
"dialog-mounts",
|
||||
"revision-repair",
|
||||
"settings-ui",
|
||||
"review-harness",
|
||||
"p2p-pane",
|
||||
|
||||
Reference in New Issue
Block a user