mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-27 13:57:07 +00:00
377 lines
17 KiB
TypeScript
377 lines
17 KiB
TypeScript
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import type { Page } from "playwright";
|
|
import { evalObsidianJson } from "../runner/cli.ts";
|
|
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
|
import {
|
|
createE2eObsidianDeviceLocalState,
|
|
waitForLiveSyncCoreReady,
|
|
waitForLocalDatabaseEntry,
|
|
} from "../runner/liveSyncWorkflow.ts";
|
|
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
|
import { withObsidianPage } from "../runner/ui.ts";
|
|
import { createTemporaryVault } from "../runner/vault.ts";
|
|
|
|
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
|
|
process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000";
|
|
process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000";
|
|
|
|
const notePath = "E2E/document-history-soft-deleted.md";
|
|
const contentMarker = "Recoverable content from the soft-deleted revision";
|
|
const noteContent = [
|
|
"# Document History recovery E2E",
|
|
"",
|
|
contentMarker,
|
|
"",
|
|
...Array.from(
|
|
{ length: 96 },
|
|
(_, index) => `Preserved content line ${String(index + 1).padStart(3, "0")}: ${"R".repeat(64)}`
|
|
),
|
|
"",
|
|
].join("\n");
|
|
|
|
type SoftDeletionState = {
|
|
id: string;
|
|
revision: string;
|
|
revisionCount: number;
|
|
chunkReferences: number;
|
|
availableChunks: number;
|
|
contentReadable: boolean;
|
|
storageExists: boolean;
|
|
};
|
|
|
|
type VaultRestoreState = {
|
|
revision: string;
|
|
revisionCount: number;
|
|
deleted: boolean;
|
|
storageExists: boolean;
|
|
contentMatches: boolean;
|
|
};
|
|
|
|
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
|
if (actual !== expected) {
|
|
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
|
|
}
|
|
}
|
|
|
|
function assertTrue(value: boolean, message: string): void {
|
|
if (!value) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
async function dismissWelcomeWizard(port: number): Promise<void> {
|
|
await withObsidianPage(port, async (page) => {
|
|
const cancel = page.getByText("No, please take me back");
|
|
if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) {
|
|
await cancel.click();
|
|
await page.waitForTimeout(500);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function createNote(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
await evalObsidianJson<unknown>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
`const path=${JSON.stringify(notePath)};`,
|
|
`const content=${JSON.stringify(noteContent)};`,
|
|
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
|
|
"const existing=app.vault.getAbstractFileByPath(path);",
|
|
"if(existing) await app.vault.delete(existing);",
|
|
"const file=await app.vault.create(path,content);",
|
|
"await app.workspace.getLeaf(false).openFile(file);",
|
|
"return JSON.stringify({ok:true});",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
await waitForLocalDatabaseEntry(cliBinary, env, notePath);
|
|
}
|
|
|
|
async function createSoftDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<SoftDeletionState> {
|
|
return await evalObsidianJson<SoftDeletionState>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
`const path=${JSON.stringify(notePath)};`,
|
|
`const expectedContent=${JSON.stringify(noteContent)};`,
|
|
"const timeoutMs=30000;",
|
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
|
"const file=app.vault.getAbstractFileByPath(path);",
|
|
"if(!file) throw new Error(`Recovery fixture is missing from the Vault: ${path}`);",
|
|
"const id=await core.services.path.path2id(path);",
|
|
"await app.vault.delete(file);",
|
|
"const deadline=Date.now()+timeoutMs;",
|
|
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
|
"while(Date.now()<deadline){",
|
|
" await core.services.fileProcessing.commitPendingFileEvents();",
|
|
" const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);",
|
|
" if(raw?.deleted&&!app.vault.getAbstractFileByPath(path)){",
|
|
" const loaded=await core.localDatabase.getDBEntry(path,{rev:raw._rev},false,true,true);",
|
|
" const loadedContent=loaded===false?'':Array.isArray(loaded.data)?loaded.data.join(''):loaded.data;",
|
|
" const children=Array.isArray(raw.children)?raw.children:[];",
|
|
" const chunkRows=children.length===0?{rows:[]}:await core.localDatabase.allDocsRaw({keys:children,include_docs:true});",
|
|
" const availableChunks=chunkRows.rows.filter((row)=>row.doc&&!row.value?.deleted).length;",
|
|
" return JSON.stringify({",
|
|
" id,",
|
|
" revision:raw._rev,",
|
|
" revisionCount:(raw._revs_info||[]).filter((entry)=>entry?.status==='available').length,",
|
|
" chunkReferences:children.length,",
|
|
" availableChunks,",
|
|
" contentReadable:loadedContent===expectedContent,",
|
|
" storageExists:!!app.vault.getAbstractFileByPath(path),",
|
|
" });",
|
|
" }",
|
|
" await sleep(250);",
|
|
"}",
|
|
"throw new Error(`Timed out waiting for a readable soft deletion: ${path}`);",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
}
|
|
|
|
async function openHistoryPicker(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
await evalObsidianJson<unknown>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
"document.querySelectorAll('.modal-close-button').forEach((button)=>button.click());",
|
|
"await new Promise((resolve)=>setTimeout(resolve,300));",
|
|
"await app.commands.executeCommandById('obsidian-livesync:livesync-filehistory');",
|
|
"await new Promise((resolve)=>setTimeout(resolve,500));",
|
|
"return JSON.stringify({opened:!!document.querySelector('.prompt-input')});",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
}
|
|
|
|
async function waitForVaultRestore(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultRestoreState> {
|
|
return await evalObsidianJson<VaultRestoreState>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
`const path=${JSON.stringify(notePath)};`,
|
|
`const expectedContent=${JSON.stringify(noteContent)};`,
|
|
"const timeoutMs=30000;",
|
|
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
|
"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 file=app.vault.getAbstractFileByPath(path);",
|
|
" const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);",
|
|
" const content=file?await app.vault.read(file):'';",
|
|
" if(file&&raw&&!raw.deleted&&!raw._deleted&&content===expectedContent){",
|
|
" return JSON.stringify({",
|
|
" revision:raw._rev,",
|
|
" revisionCount:(raw._revs_info||[]).filter((entry)=>entry?.status==='available').length,",
|
|
" deleted:false,",
|
|
" storageExists:true,",
|
|
" contentMatches:true,",
|
|
" });",
|
|
" }",
|
|
" await sleep(250);",
|
|
"}",
|
|
"const file=app.vault.getAbstractFileByPath(path);",
|
|
"const raw=await core.localDatabase.getRaw(id,{revs_info:true}).catch(()=>false);",
|
|
"const content=file?await app.vault.read(file):'';",
|
|
"throw new Error(`Timed out waiting for History to create and reflect a non-deleted successor revision: ${JSON.stringify({storageExists:!!file,deleted:!!(raw&&((raw.deleted||raw._deleted))),contentMatches:content===expectedContent,revision:raw&&raw._rev})}`);",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
}
|
|
|
|
async function openActiveFileHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
|
await evalObsidianJson<unknown>(
|
|
cliBinary,
|
|
[
|
|
"(async()=>{",
|
|
`const path=${JSON.stringify(notePath)};`,
|
|
"document.querySelectorAll('.modal-close-button').forEach((button)=>button.click());",
|
|
"const file=app.vault.getAbstractFileByPath(path);",
|
|
"if(!file) throw new Error(`Restored file is missing before reopening history: ${path}`);",
|
|
"await app.workspace.getLeaf(false).openFile(file);",
|
|
"await new Promise((resolve)=>setTimeout(resolve,300));",
|
|
"await app.commands.executeCommandById('obsidian-livesync:livesync-history');",
|
|
"await new Promise((resolve)=>setTimeout(resolve,500));",
|
|
"return JSON.stringify({opened:!!document.querySelector('.modal-container .modal-title')});",
|
|
"})()",
|
|
].join(""),
|
|
env
|
|
);
|
|
}
|
|
|
|
async function captureStep(page: Page, screenshotDir: string, step: string): Promise<string> {
|
|
await mkdir(screenshotDir, { recursive: true });
|
|
const path = join(screenshotDir, `${step}.png`);
|
|
await page.screenshot({ path, fullPage: true, animations: "disabled" });
|
|
console.log(`Screenshot: ${path}`);
|
|
return path;
|
|
}
|
|
|
|
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 vault = await createTemporaryVault();
|
|
let session: ObsidianLiveSyncSession | undefined;
|
|
const screenshotDir =
|
|
process.env.E2E_OBSIDIAN_HISTORY_RESTORE_SCREENSHOT_DIR ??
|
|
join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-restore");
|
|
const reportPath =
|
|
process.env.E2E_OBSIDIAN_HISTORY_RESTORE_REPORT ?? join(screenshotDir, "document-history-restore.json");
|
|
|
|
try {
|
|
console.log(`Using Obsidian executable: ${binary}`);
|
|
console.log(`Temporary vault: ${vault.path}`);
|
|
|
|
session = await startObsidianLiveSyncSession({
|
|
binary,
|
|
cliBinary: cli.binary,
|
|
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,
|
|
deleteMetadataOfDeletedFiles: false,
|
|
},
|
|
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
|
});
|
|
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
|
|
await dismissWelcomeWizard(session.remoteDebuggingPort);
|
|
|
|
await createNote(cli.binary, session.cliEnv);
|
|
const deletion = await createSoftDeletion(cli.binary, session.cliEnv);
|
|
assertEqual(deletion.storageExists, false, "The deletion fixture still existed in the Vault.");
|
|
assertTrue(deletion.chunkReferences > 0, "The deleted document did not retain chunk references.");
|
|
assertEqual(
|
|
deletion.availableChunks,
|
|
deletion.chunkReferences,
|
|
"Not all chunks referenced by the deleted document remained available."
|
|
);
|
|
assertEqual(
|
|
deletion.contentReadable,
|
|
true,
|
|
"The soft-deleted revision could not be reconstructed from chunks."
|
|
);
|
|
|
|
await openHistoryPicker(cli.binary, session.cliEnv);
|
|
|
|
const screenshots = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
|
const screenshotPaths: string[] = [];
|
|
const prompt = page.locator(".prompt");
|
|
await prompt.waitFor({ state: "visible", timeout: 10000 });
|
|
const promptInput = prompt.locator(".prompt-input");
|
|
assertEqual(
|
|
await promptInput.getAttribute("placeholder"),
|
|
"File to view History",
|
|
"Unexpected history picker placeholder."
|
|
);
|
|
await promptInput.fill(notePath);
|
|
const suggestion = prompt.locator(".suggestion-item").filter({ hasText: notePath }).first();
|
|
await suggestion.waitFor({ state: "visible", timeout: 10000 });
|
|
screenshotPaths.push(await captureStep(page, screenshotDir, "01-soft-deleted-file-picker"));
|
|
|
|
await suggestion.click();
|
|
const modal = page.locator(".modal-container").filter({ hasText: "Document History" });
|
|
await modal.waitFor({ state: "visible", timeout: 10000 });
|
|
await modal.getByText("(At this revision, the file has been deleted)", { exact: false }).waitFor({
|
|
state: "visible",
|
|
timeout: 10000,
|
|
});
|
|
await modal.getByText(contentMarker, { exact: false }).waitFor({ state: "visible", timeout: 10000 });
|
|
const restoreButton = modal.getByRole("button", { name: "Back to this revision", exact: true });
|
|
await restoreButton.waitFor({ state: "visible", timeout: 10000 });
|
|
screenshotPaths.push(await captureStep(page, screenshotDir, "02-readable-deleted-revision"));
|
|
|
|
await restoreButton.click();
|
|
await modal.waitFor({ state: "hidden", timeout: 10000 });
|
|
return screenshotPaths;
|
|
});
|
|
|
|
const restored = await waitForVaultRestore(cli.binary, session.cliEnv);
|
|
assertEqual(restored.storageExists, true, "Document History did not restore the Vault file.");
|
|
assertEqual(restored.contentMatches, true, "The restored Vault file did not match the deleted revision.");
|
|
assertEqual(restored.deleted, false, "Document History did not produce a non-deleted database revision.");
|
|
assertTrue(
|
|
restored.revision !== deletion.revision,
|
|
"Document History did not create a successor database revision."
|
|
);
|
|
assertEqual(
|
|
restored.revisionCount,
|
|
deletion.revisionCount + 1,
|
|
"Document History did not add exactly one non-deleted successor revision."
|
|
);
|
|
|
|
screenshots.push(
|
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
|
await page
|
|
.getByText(contentMarker, { exact: false })
|
|
.first()
|
|
.waitFor({ state: "visible", timeout: 10000 });
|
|
return await captureStep(page, screenshotDir, "03-non-deleted-successor-after-history-restore");
|
|
})
|
|
);
|
|
|
|
await openActiveFileHistory(cli.binary, session.cliEnv);
|
|
screenshots.push(
|
|
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
|
const modal = page.locator(".modal-container").filter({ hasText: "Document History" });
|
|
await modal.waitFor({ state: "visible", timeout: 10000 });
|
|
await modal.getByText(contentMarker, { exact: false }).waitFor({ state: "visible", timeout: 10000 });
|
|
assertEqual(
|
|
await modal.getByText("(At this revision, the file has been deleted)", { exact: false }).count(),
|
|
0,
|
|
"The non-deleted successor revision was still displayed as deleted."
|
|
);
|
|
assertEqual(
|
|
(await modal.locator(".history-rev-indicator").innerText()).trim(),
|
|
"Rev 3/3",
|
|
"Document History did not open at the new non-deleted successor revision."
|
|
);
|
|
return await captureStep(page, screenshotDir, "04-restored-revision-in-history");
|
|
})
|
|
);
|
|
|
|
await mkdir(screenshotDir, { recursive: true });
|
|
await writeFile(
|
|
reportPath,
|
|
`${JSON.stringify({ notePath, deletion, restored, screenshots }, null, 2)}\n`,
|
|
"utf-8"
|
|
);
|
|
console.log("Document History soft-deletion restoration E2E passed.");
|
|
console.log(`Report: ${reportPath}`);
|
|
console.log(`Screenshots: ${screenshotDir}`);
|
|
} 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);
|
|
});
|