From 02b434b81315f9914656bb7b33649ee7cf4f3109 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 15 Jul 2026 04:00:22 +0000 Subject: [PATCH 1/2] Fix mirror restore deletion semantics --- README.md | 5 +- docs/devs.md | 8 +- main.ts | 107 +++- package.json | 2 + src/RestoreView.ts | 6 +- test/e2e-obsidian/README.md | 6 + test/e2e-obsidian/harness.mts | 119 +++- test/e2e-obsidian/legacy-folder-restore.mts | 82 +++ test/e2e-obsidian/mirror-delete-semantics.mts | 542 ++++++++++++++++++ test/e2e-obsidian/restore-confirmation.mts | 24 +- 10 files changed, 842 insertions(+), 59 deletions(-) create mode 100644 test/e2e-obsidian/legacy-folder-restore.mts create mode 100644 test/e2e-obsidian/mirror-delete-semantics.mts diff --git a/README.md b/README.md index a76aa18..557a017 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ The restore dialog shows backup history as a searchable file tree. - `Restore Mode` controls how existing local files are handled: - `Only new`: restore only files that do not exist locally, or files whose backup revision is newer than the local file. - `All`: restore selected files even when local files already exist. - - `All and delete extra`: restore selected files and include deletion records in the confirmation. Deleting local files from those records is not implemented yet. + - `All and delete extra`: restore selected files and remove local files represented by the selected deletion records. The confirmation lists both operations, and deletion starts only after the selected files have been restored successfully. + The confirmation reflects the paths and operations planned when it opened. DiffZip does not revalidate deletion candidates that change while the dialogue remains open; cancel and reopen the restore dialogue before proceeding if the Vault may have changed during review. - `Additional prefix` restores files under an extra path prefix, such as `restored/`. The `Restore folder` setting is used by the legacy restore commands; the current revision selector uses this prefix field instead. @@ -110,7 +111,7 @@ Legacy command meanings: | `Legacy: Restore from backups (previous behaviour)` | Use the older prompt-based restore flow instead of the current revision selector. | | `Legacy: Restore from backups per folder` | Use the older folder-oriented restore flow. | | `Legacy: Fetch all new files from the backups` | Restore files from backup history when the local file is missing or older than the backup revision. Existing local files that are newer or identical are left alone. | -| `Legacy: ⚠ Restore Vault from backups and delete with deletion` | Restore the vault from backup history and include deletion records in the confirmation. Deleting local files from those records is not implemented yet. | +| `Legacy: ⚠ Restore Vault from backups and delete with deletion` | Restore the vault from backup history, then remove local files represented by applicable deletion records. The confirmation lists both operations before they begin. | | `Legacy: Selective Sync Remote Backup` | Open the older command entry for the current `Sync Remote Backup` workflow. | ## Settings diff --git a/docs/devs.md b/docs/devs.md index 985552a..6d657f2 100644 --- a/docs/devs.md +++ b/docs/devs.md @@ -46,7 +46,7 @@ const action = await ui.confirmAction( labels: { restore: "Restore", cancel: "Cancel" }, defaultAction: "cancel", }, - "restore-files", + "restore-files" ); ``` @@ -68,6 +68,10 @@ Real Modal rendering and dismissal use the local-only Obsidian harness documente ## Screen wake lock -The plug-in owns one lifecycle-aware screen wake-lock manager. Differential backups, archive restore, and the Fetch and Send phases of selective sync use its closure-based runner, so normal completion, cancellation, and errors release their logical lease automatically. Overlapping and nested operations share the platform wake lock. Confirmation dialogues do not acquire a lease; restore protection starts only when archive input and Vault output begin. +The plug-in owns one lifecycle-aware screen wake-lock manager. Differential backups, archive restore, and the Fetch and Send phases of selective sync use its closure-based runner, so normal completion, cancellation, and errors release their logical lease automatically. Overlapping and nested operations share the platform wake lock. Restore planning and execution use separate leases; confirmation dialogues do not acquire one. The Screen Wake Lock API is best effort. Consumer workflows must continue when it is unavailable or rejected, and must not rely on it for background execution. Dispose the manager when the plug-in unloads. Keep the manager injectable through the focused helper in `src/wakeLock.ts`; App-free tests use that boundary instead of constructing the Obsidian plug-in. + +## Restore confirmation boundary + +Restore confirmation deliberately presents the paths and operations planned at a point in time; it is not a transactional lock on the Vault. DiffZip does not revalidate deletion candidates after opening the confirmation dialogue. A caller that permits concurrent Vault changes must cancel the operation and prepare a new restore plan when the reviewed state may no longer be current. diff --git a/main.ts b/main.ts index 9fae540..f75e398 100644 --- a/main.ts +++ b/main.ts @@ -443,8 +443,9 @@ export default class DiffZipBackupPlugin extends Plugin { } while (hasNext); } if (files.length == 0) { - this.logMessage("Archived ZIP files were not found!"); - return; + const message = `Archived ZIP files were not found: ${zipFile}`; + this.logMessage(message); + throw new Error(message); } const restored = [] as string[]; @@ -463,7 +464,9 @@ export default class DiffZipBackupPlugin extends Plugin { const files = restored.slice(-5).join("\n"); this.logMessage(`${restored.length} files have been restored! \n${files}\n...`, "proc-zip-extract"); } else { - this.logMessage(`Creating or Overwriting ${file} has been failed!`); + const message = `Creating or overwriting ${file} failed`; + this.logMessage(message); + throw new Error(message); } } ); @@ -473,8 +476,9 @@ export default class DiffZipBackupPlugin extends Plugin { this.logMessage(`Processing ${file}...`, "proc-zip-export-processing"); const binary = await this.backups.readBinary(file); if (binary == null || binary === false) { - this.logMessage(`Could not read ${file}`); - return; + const message = `Could not read ${file}`; + this.logMessage(message); + throw new Error(message); } const chunks = pieces(new Uint8Array(binary), size); for (const chunk of chunks) { @@ -482,6 +486,20 @@ export default class DiffZipBackupPlugin extends Plugin { } } await extractor.finalise(); + const expectedRestorePaths = hasMultipleSupplied + ? extractFiles.map((file) => `${restorePrefix}${file}`) + : [restoreAs ?? extractFiles]; + const missingRestorePaths = expectedRestorePaths.filter((file) => !restored.includes(file)); + if (missingRestorePaths.length > 0) { + const preview = missingRestorePaths.slice(0, 5).join(", "); + const remaining = missingRestorePaths.length - 5; + const noun = missingRestorePaths.length === 1 ? "file" : "files"; + const message = `The archive did not restore ${missingRestorePaths.length} requested ${noun}: ${preview}${ + remaining > 0 ? `, and ${remaining} more` : "" + }`; + this.logMessage(message); + throw new Error(message); + } } async selectAndRestore() { @@ -618,6 +636,10 @@ export default class DiffZipBackupPlugin extends Plugin { } const zipMap = new Map(); for (const [filename, fileInfo] of fileMap) { + if (fileInfo.missing) { + this.logWrite(`${filename}: is a deletion record. Skipping on non-destructive restoration`); + continue; + } const path = fileInfo.zipName; const arr = zipMap.get(path) ?? []; arr.push(filename); @@ -629,6 +651,10 @@ export default class DiffZipBackupPlugin extends Plugin { // fileMap.set(path, zipName); // } const zipList = [...zipMap.entries()].sort((a, b) => a[0].localeCompare(b[0])); + if (zipList.length == 0) { + this.logMessage(`Nothing to restore`); + return; + } const filesCount = zipList.reduce((a, b) => a + b[1].length, 0); if ( (await askSelectString( @@ -715,6 +741,28 @@ export default class DiffZipBackupPlugin extends Plugin { deleteMissing: boolean = false, fileFilter: Record | undefined = undefined, prefix: string = "" + ): Promise { + const { deletingFiles, processFileCount, zipFileMap } = await this.runWhileAwake("archive-restore", () => + this.planVaultRestore(onlyNew, deleteMissing, fileFilter, prefix) + ); + if (processFileCount == 0 && deletingFiles.length == 0) { + this.logMessage(`Nothing to restore`); + return; + } + if ( + !(await confirmRestore(this.ui, { processFileCount, filesByZip: zipFileMap, deleteMissing, deletingFiles })) + ) { + this.logMessage(`Cancelled`); + return; + } + await this.runWhileAwake("archive-restore", () => this.executeVaultRestore(zipFileMap, deletingFiles, prefix)); + } + + private async planVaultRestore( + onlyNew: boolean, + deleteMissing: boolean, + fileFilter: Record | undefined, + prefix: string ) { this.logMessage(`Checking backup information...`); const files = await this.loadTOC(); @@ -749,10 +797,20 @@ export default class DiffZipBackupPlugin extends Plugin { } history.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()); const latest = history[0]; + const selectedRevisionMissing = latest.missing === true; const zipName = latest.zipName; const localFileName = this.vaultAccess.normalizePath(`${prefix}${filename}`); const localStat = await this.vaultAccess.stat(localFileName); if (localStat) { + if (selectedRevisionMissing) { + if (!deleteMissing) { + this.logWrite(`${filename}: is marked as missing, but existing in the vault. Skipping...`); + } else { + this.logWrite(`${filename}: is marked as missing. It will be deleted...`); + deletingFiles.push(localFileName); + } + continue; + } const content = await this.vaultAccess.readBinary(localFileName); if (!content) { this.logWrite(`${filename}: has been failed to read`); @@ -763,16 +821,6 @@ export default class DiffZipBackupPlugin extends Plugin { this.logWrite(`${filename}: is as same as the backup. Skipping...`); continue; } - if (fileInfo.missing) { - if (!deleteMissing) { - this.logWrite(`${filename}: is marked as missing, but existing in the vault. Skipping...`); - continue; - } else { - // this.logWrite(`${filename}: is marked as missing. Deleting...`); - deletingFiles.push(filename); - //TODO: Delete the file - } - } const localMtime = localStat.mtime; const remoteMtime = new Date(latest.modified).getTime(); if (onlyNew && localMtime >= remoteMtime) { @@ -780,7 +828,7 @@ export default class DiffZipBackupPlugin extends Plugin { continue; } } else { - if (fileInfo.missing) { + if (selectedRevisionMissing) { this.logWrite(`${filename}: is missing and not found in the vault. Skipping...`); continue; } @@ -794,21 +842,24 @@ export default class DiffZipBackupPlugin extends Plugin { // latestZipMap.set(filename, zipName); } - if (processFileCount == 0 && deletingFiles.length == 0) { - this.logMessage(`Nothing to restore`); - return; - } - if ( - !(await confirmRestore(this.ui, { processFileCount, filesByZip: zipFileMap, deleteMissing, deletingFiles })) - ) { - this.logMessage(`Cancelled`); - return; - } + return { deletingFiles, processFileCount, zipFileMap }; + } + + private async executeVaultRestore( + zipFileMap: ReadonlyMap, + deletingFiles: readonly string[], + prefix: string + ): Promise { for (const [zipName, files] of zipFileMap) { this.logMessage(`Extracting ${zipName}...`); - await this.extract(zipName, files, undefined, prefix); + await this.extractWithoutWakeLock(zipName, files, undefined, prefix); + } + for (const filename of deletingFiles) { + this.logWrite(`${filename}: deleting from the vault...`); + if (!(await this.vaultAccess.deleteBinary(filename))) { + throw new Error(`Failed to delete ${filename} from the vault`); + } } - // console.dir(zipFileMap); } async onload() { this.register(() => { diff --git a/package.json b/package.json index 9632c41..6583898 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "test:ui:watch": "vitest --config vitest.config.ts", "check:e2e:obsidian": "tsc -p test/e2e-obsidian/tsconfig.json", "test:e2e:obsidian:restore-confirmation": "npm run build && tsx test/e2e-obsidian/restore-confirmation.mts", + "test:e2e:obsidian:legacy-folder-restore": "npm run build && tsx test/e2e-obsidian/legacy-folder-restore.mts", + "test:e2e:obsidian:mirror-delete-semantics": "npm run build && tsx test/e2e-obsidian/mirror-delete-semantics.mts", "test:e2e:obsidian:wake-lock": "npm run build && tsx test/e2e-obsidian/wake-lock.mts", "version": "node version-bump.mjs && git add manifest.json versions.json", "pretty": "npm run prettyNoWrite -- --write --log-level error", diff --git a/src/RestoreView.ts b/src/RestoreView.ts index 0220959..368f5fd 100644 --- a/src/RestoreView.ts +++ b/src/RestoreView.ts @@ -33,12 +33,12 @@ export class RestoreDialog extends Modal { onApply: async ( selectedRevisions: Record, mode: "new" | "all" | "all-delete", - prefix: string, + prefix: string ) => { this.close(); const onlyNew = mode === "new"; - const skipDeleted = mode !== "all-delete"; - await this.plugin.restoreVault(onlyNew, skipDeleted, selectedRevisions, prefix); + const deleteMissing = mode === "all-delete"; + await this.plugin.restoreVault(onlyNew, deleteMissing, selectedRevisions, prefix); }, }, }); diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 164986c..8ec077c 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -4,6 +4,10 @@ This local-only suite installs the built DiffZip plug-in into an isolated vault The restore-confirmation scenario seeds 60 restore entries across three ZIPs and 24 local files represented by mirror deletion records. It invokes the real normal-restore and delete-missing confirmation paths, verifies their rendered Markdown summaries and boundary paths, expands the long file lists, and checks explicit cancellation and Escape dismissal in a real Obsidian Modal. At a fixed phone viewport, the public test-session assertions require the title, Close control, and action row to respect supplied safe-area insets, prevent horizontal overflow in the expanded content and action row, and require 44 CSS-pixel Close, Restore, and Cancel touch targets. It does not use scripted `UiInteractions` responses. +The mirror-delete-semantics scenario opens the real revision selector and verifies that 'All and delete extra' enables deletion without enabling new-files-only mode. It verifies that the selected historical revision, rather than the current TOC state, decides whether a file is restored or deleted. Missing and unreadable archives, missing ZIP entries, and Vault write failures must reject the restore and preserve deletion candidates. Planning and execution each hold one wake-lock lease, while the confirmation wait holds none. The final large-fixture pass observes the extraction plan without reading fixture ZIPs, verifies that no deletion record is sent for extraction, and verifies that all 24 applicable local files are removed through the active Vault storage accessor. + +The legacy-folder-restore scenario selects a restore point containing both an ordinary revision and a deletion record through the real legacy prompt sequence. It verifies that the ordinary file is restored without treating the contentless deletion record as a ZIP extraction request. + The wake-lock scenario creates a Vault fixture, invokes the real differential-backup entry point, removes the original, and restores it from the generated ZIP. It verifies that one logical wake-lock lease is active and released for both operations, confirms that the fixture reached `backupinfo.md`, and checks the restored content. Platform support is collected as evidence rather than required on desktop. Physical display behaviour for backup, restore, and selective sync remains a manual mobile review. The suite is currently validated on Linux only. Set `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the executables are outside the discovery paths inherited from the shared test-session package. @@ -11,6 +15,8 @@ The suite is currently validated on Linux only. Set `OBSIDIAN_BINARY` and `OBSID ```bash npm run check:e2e:obsidian npm run test:e2e:obsidian:restore-confirmation +npm run test:e2e:obsidian:legacy-folder-restore +npm run test:e2e:obsidian:mirror-delete-semantics npm run test:e2e:obsidian:wake-lock ``` diff --git a/test/e2e-obsidian/harness.mts b/test/e2e-obsidian/harness.mts index d5c4794..546facd 100644 --- a/test/e2e-obsidian/harness.mts +++ b/test/e2e-obsidian/harness.mts @@ -1,5 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { strToU8, zipSync } from "fflate"; import { createTemporaryVault, discoverObsidianCli, @@ -19,6 +20,15 @@ export const MIRROR_DELETION_FIXTURE_PATHS = Array.from({ length: 24 }, (_, inde const ordinal = String(index + 1).padStart(3, "0"); return `mirror/obsolete-${ordinal}.md`; }); +export const NORMAL_BEFORE_DELETE_PATH = "history/normal-before-delete.md"; +export const DELETE_BEFORE_RECREATE_PATH = "history/delete-before-recreate.md"; +export const NORMAL_BEFORE_DELETE_TIMESTAMP = Date.parse("2026-07-08T00:00:00.000Z"); +export const DELETE_BEFORE_RECREATE_TIMESTAMP = Date.parse("2026-07-08T00:00:00.000Z"); +export const LEGACY_RESTORED_CONTENT = "Restored history fixture"; +export const FAILED_RESTORE_PATH = "failure/restore.md"; +export const FAILED_RESTORE_DELETION_PATH = "failure/delete-me.md"; +export const EXTRACTION_PROBE_PATH = "failure/write-probe.md"; +export const EXTRACTION_PROBE_ZIP = "write-probe.zip"; interface RestoreTocEntry { filename: string; @@ -27,6 +37,7 @@ interface RestoreTocEntry { zipName: string; modified: string; digest: string; + missing?: boolean; }>; mtime: number; missing?: boolean; @@ -42,7 +53,7 @@ export interface DiffZipTestSession { export interface StartDiffZipTestSessionOptions { /** Restore plan fixture to seed before Obsidian starts. Defaults to the original single-file plan. */ - restorePlan?: "single" | "large"; + restorePlan?: "single" | "large" | "history" | "failure"; } async function writeRestorePlan(vaultPath: string, toc: Record): Promise { @@ -95,6 +106,7 @@ async function seedLargeRestorePlan(vaultPath: string): Promise { zipName: "backup-mirror.zip", modified, digest, + missing: true, }, ], mtime: Date.parse(modified), @@ -105,10 +117,98 @@ async function seedLargeRestorePlan(vaultPath: string): Promise { await writeRestorePlan(vaultPath, toc); } +async function seedHistoryRestorePlan(vaultPath: string): Promise { + const normalModified = "2026-07-07T00:00:00.000Z"; + const deletionModified = "2026-07-08T00:00:00.000Z"; + const recreatedModified = "2026-07-09T00:00:00.000Z"; + const toc: Record = { + [NORMAL_BEFORE_DELETE_PATH]: { + filename: NORMAL_BEFORE_DELETE_PATH, + digest: "", + history: [ + { + zipName: "history-normal.zip", + modified: deletionModified, + digest: "history-normal-digest", + }, + { + zipName: "z-history-delete.zip", + modified: recreatedModified, + digest: "", + missing: true, + }, + ], + mtime: Date.parse(recreatedModified), + missing: true, + }, + [DELETE_BEFORE_RECREATE_PATH]: { + filename: DELETE_BEFORE_RECREATE_PATH, + digest: "history-recreated-digest", + history: [ + { + zipName: "history-original.zip", + modified: normalModified, + digest: "history-original-digest", + }, + { + zipName: "z-history-delete.zip", + modified: deletionModified, + digest: "", + missing: true, + }, + { + zipName: "a-history-recreated.zip", + modified: recreatedModified, + digest: "history-recreated-digest", + }, + ], + mtime: Date.parse(recreatedModified), + }, + }; + for (const path of [NORMAL_BEFORE_DELETE_PATH, DELETE_BEFORE_RECREATE_PATH]) { + await mkdir(dirname(join(vaultPath, path)), { recursive: true }); + await writeFile(join(vaultPath, path), `Local history fixture: ${path}`); + } + await writeRestorePlan(vaultPath, toc); + await writeFile( + join(vaultPath, "backup", "a-history-recreated.zip"), + zipSync({ [DELETE_BEFORE_RECREATE_PATH]: strToU8(LEGACY_RESTORED_CONTENT) }) + ); + await writeFile( + join(vaultPath, "backup", "z-history-delete.zip"), + zipSync({ "backupinfo.md": strToU8("Deletion records do not contain file content") }) + ); +} + +async function seedFailureRestorePlan(vaultPath: string): Promise { + const modified = "2026-07-10T00:00:00.000Z"; + await mkdir(join(vaultPath, "failure"), { recursive: true }); + await writeFile(join(vaultPath, FAILED_RESTORE_DELETION_PATH), "Deletion must wait for restore success"); + await writeRestorePlan(vaultPath, { + [FAILED_RESTORE_PATH]: { + filename: FAILED_RESTORE_PATH, + digest: "failed-restore-digest", + history: [{ zipName: "missing-restore.zip", modified, digest: "failed-restore-digest" }], + mtime: Date.parse(modified), + }, + [FAILED_RESTORE_DELETION_PATH]: { + filename: FAILED_RESTORE_DELETION_PATH, + digest: "", + history: [{ zipName: "missing-delete.zip", modified, digest: "", missing: true }], + mtime: Date.parse(modified), + missing: true, + }, + }); + await writeFile( + join(vaultPath, "backup", EXTRACTION_PROBE_ZIP), + zipSync({ [EXTRACTION_PROBE_PATH]: strToU8("Extraction failure probe") }) + ); +} + /** Starts DiffZip in an isolated real-Obsidian session with the selected restore plan fixture. */ -export async function startDiffZipTestSession( - { restorePlan = "single" }: StartDiffZipTestSessionOptions = {}, -): Promise { +export async function startDiffZipTestSession({ + restorePlan = "single", +}: StartDiffZipTestSessionOptions = {}): Promise { const cli = discoverObsidianCli(); if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked: ${cli.checked.join(", ")}`); const vault = await createTemporaryVault({ @@ -117,11 +217,10 @@ export async function startDiffZipTestSession( idPrefix: "diffzip-e2e", }); try { - if (restorePlan === "large") { - await seedLargeRestorePlan(vault.path); - } else { - await seedSingleRestorePlan(vault.path); - } + if (restorePlan === "large") await seedLargeRestorePlan(vault.path); + else if (restorePlan === "history") await seedHistoryRestorePlan(vault.path); + else if (restorePlan === "failure") await seedFailureRestorePlan(vault.path); + else await seedSingleRestorePlan(vault.path); const session = await startObsidianPluginSession({ binary: requireObsidianBinary(), cliBinary: cli.binary, diff --git a/test/e2e-obsidian/legacy-folder-restore.mts b/test/e2e-obsidian/legacy-folder-restore.mts new file mode 100644 index 0000000..ee218e0 --- /dev/null +++ b/test/e2e-obsidian/legacy-folder-restore.mts @@ -0,0 +1,82 @@ +import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; +import type { Page } from "playwright"; +import { + DELETE_BEFORE_RECREATE_PATH, + DIFFZIP_PLUGIN_ID, + LEGACY_RESTORED_CONTENT, + startDiffZipTestSession, + stopDiffZipTestSession, + type DiffZipTestSession, +} from "./harness.mts"; + +async function chooseNextPromptItem(page: Page, placeholder: string, moveDown: boolean): Promise { + const input = page.locator(`input.prompt-input[placeholder*="${placeholder}"]`).last(); + await input.waitFor({ state: "visible", timeout: 10_000 }); + if (moveDown) await page.keyboard.press("ArrowDown"); + await page.keyboard.press("Enter"); + await input.waitFor({ state: "detached", timeout: 10_000 }); +} + +async function exerciseLegacyFolderRestore(testSession: DiffZipTestSession): Promise { + await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + await page.evaluate((pluginId) => { + interface RestorePlugin { + selectAndRestoreFolder(): Promise; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipLegacyFolderRestore?: Promise; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + root.diffzipLegacyFolderRestore = plugin.selectAndRestoreFolder().then( + () => false, + () => true + ); + }, DIFFZIP_PLUGIN_ID); + + await chooseNextPromptItem(page, "Select file", true); + await chooseNextPromptItem(page, "Until?", true); + await chooseNextPromptItem(page, "Are you sure to restore", false); + + const evidence = await page.evaluate( + async ({ path, pluginId }) => { + const root = globalThis as typeof globalThis & { + app?: { + plugins?: { plugins?: Record }; + vault?: { adapter: { read(path: string): Promise } }; + }; + diffzipLegacyFolderRestore?: Promise; + }; + const rejected = (await root.diffzipLegacyFolderRestore) ?? false; + delete root.diffzipLegacyFolderRestore; + if (!root.app?.plugins?.plugins?.[pluginId]) throw new Error(`DiffZip is not loaded: ${pluginId}`); + const restoredContent = await root.app.vault?.adapter.read(path); + return { rejected, restoredContent }; + }, + { path: DELETE_BEFORE_RECREATE_PATH, pluginId: DIFFZIP_PLUGIN_ID } + ); + if (evidence.restoredContent !== LEGACY_RESTORED_CONTENT) { + throw new Error(`The ordinary historical revision was not restored: ${JSON.stringify(evidence)}`); + } + if (evidence.rejected) { + throw new Error("The legacy folder restore rejected a contentless deletion record"); + } + }); +} + +async function main(): Promise { + let testSession: DiffZipTestSession | undefined; + try { + testSession = await startDiffZipTestSession({ restorePlan: "history" }); + await exerciseLegacyFolderRestore(testSession); + console.log("DiffZip legacy folder restore passed in real Obsidian"); + } finally { + if (testSession) await stopDiffZipTestSession(testSession); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/mirror-delete-semantics.mts b/test/e2e-obsidian/mirror-delete-semantics.mts new file mode 100644 index 0000000..8567e40 --- /dev/null +++ b/test/e2e-obsidian/mirror-delete-semantics.mts @@ -0,0 +1,542 @@ +import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; +import type { Page } from "playwright"; +import { + DELETE_BEFORE_RECREATE_PATH, + DELETE_BEFORE_RECREATE_TIMESTAMP, + DIFFZIP_PLUGIN_ID, + EXTRACTION_PROBE_PATH, + EXTRACTION_PROBE_ZIP, + FAILED_RESTORE_DELETION_PATH, + MIRROR_DELETION_FIXTURE_PATHS, + NORMAL_BEFORE_DELETE_PATH, + NORMAL_BEFORE_DELETE_TIMESTAMP, + startDiffZipTestSession, + stopDiffZipTestSession, + type DiffZipTestSession, +} from "./harness.mts"; + +interface RestoreModeEvidence { + deleteMissing: boolean; + onlyNew: boolean; +} + +interface DeleteExecutionEvidence { + deletionCandidateStillExists: boolean; + deletionCandidateWasExtracted: boolean; +} + +interface RevisionEvidence { + activeLeasesAtConfirmation: number; + activeLeaseCounts: number[]; + activeLeasesAfter: number; + deleted: string[]; + extracted: string[]; +} + +interface FailureEvidence { + failedRestoreRejected: boolean; + deletionCandidateStillExists: boolean; + missingArchiveRejected: boolean; + missingEntryRejected: boolean; + readFailureRejected: boolean; + writeFailureRejected: boolean; +} + +async function executeCommand(page: Page, commandId: string): Promise { + const executed = await page.evaluate((id) => { + const obsidianApp = ( + globalThis as typeof globalThis & { + app?: { commands?: { executeCommandById(commandId: string): boolean } }; + } + ).app; + return obsidianApp?.commands?.executeCommandById(id) ?? false; + }, commandId); + if (!executed) throw new Error(`Command was unavailable: ${commandId}`); +} + +async function observeAllDeleteMode(page: Page): Promise { + await page.evaluate((pluginId) => { + interface RestorePlugin { + restoreVault( + onlyNew?: boolean, + deleteMissing?: boolean, + fileFilter?: Record, + prefix?: string + ): Promise; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipMirrorModeEvidence?: RestoreModeEvidence; + diffzipOriginalRestoreVault?: RestorePlugin["restoreVault"]; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + root.diffzipOriginalRestoreVault = plugin.restoreVault; + plugin.restoreVault = async (onlyNew = true, deleteMissing = false) => { + root.diffzipMirrorModeEvidence = { onlyNew, deleteMissing }; + }; + }, DIFFZIP_PLUGIN_ID); + + try { + await executeCommand(page, `${DIFFZIP_PLUGIN_ID}:a-find-from-backups`); + const modal = page.locator(".modal-container").last(); + await modal.getByLabel("Restore Mode").waitFor({ state: "visible", timeout: 10_000 }); + await modal.getByRole("button", { name: "Select All Latest", exact: true }).click(); + await modal.getByLabel("Restore Mode").selectOption("all-delete"); + await modal.getByRole("button", { name: "Restore", exact: true }).click(); + await modal.waitFor({ state: "detached", timeout: 10_000 }); + return await page.evaluate(() => { + const evidence = (globalThis as typeof globalThis & { diffzipMirrorModeEvidence?: RestoreModeEvidence }) + .diffzipMirrorModeEvidence; + if (!evidence) throw new Error("Restore mode evidence was not captured"); + return evidence; + }); + } finally { + await page.evaluate((pluginId) => { + interface RestorePlugin { + restoreVault( + onlyNew?: boolean, + deleteMissing?: boolean, + fileFilter?: Record, + prefix?: string + ): Promise; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipOriginalRestoreVault?: RestorePlugin["restoreVault"]; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (plugin && root.diffzipOriginalRestoreVault) { + plugin.restoreVault = root.diffzipOriginalRestoreVault; + } + delete root.diffzipOriginalRestoreVault; + delete (root as typeof root & { diffzipMirrorModeEvidence?: RestoreModeEvidence }) + .diffzipMirrorModeEvidence; + }, DIFFZIP_PLUGIN_ID); + } +} + +async function observeDeleteExecution(page: Page): Promise { + await page.evaluate( + ({ pluginId, paths }) => { + interface RestorePlugin { + extractWithoutWakeLock( + zipName: string, + files: string | string[], + restoreAs?: string, + prefix?: string + ): Promise; + restoreVault(onlyNew?: boolean, deleteMissing?: boolean): Promise; + vaultAccess: { stat(path: string): Promise }; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipExtractCalls?: Array<{ files: string[]; zipName: string }>; + diffzipMirrorRestore?: Promise; + diffzipOriginalExtract?: RestorePlugin["extractWithoutWakeLock"]; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + if (paths.length === 0) throw new Error("Mirror deletion fixtures were unavailable"); + root.diffzipExtractCalls = []; + root.diffzipOriginalExtract = plugin.extractWithoutWakeLock; + plugin.extractWithoutWakeLock = async (zipName, files) => { + root.diffzipExtractCalls?.push({ + zipName, + files: Array.isArray(files) ? files : [files], + }); + }; + root.diffzipMirrorRestore = plugin.restoreVault(false, true); + }, + { pluginId: DIFFZIP_PLUGIN_ID, paths: MIRROR_DELETION_FIXTURE_PATHS } + ); + + try { + const modal = page.locator(".modal-container").filter({ hasText: "Restore Confirmation" }).last(); + await modal.waitFor({ state: "visible", timeout: 10_000 }); + await modal.getByRole("button", { name: "Yes, restore them!", exact: true }).click(); + return await page.evaluate( + async ({ pluginId, paths }) => { + interface RestorePlugin { + vaultAccess: { stat(path: string): Promise }; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipExtractCalls?: Array<{ files: string[]; zipName: string }>; + diffzipMirrorRestore?: Promise; + }; + await root.diffzipMirrorRestore; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + const extractCalls = root.diffzipExtractCalls ?? []; + const deletionCandidateStats = await Promise.all(paths.map((path) => plugin.vaultAccess.stat(path))); + return { + deletionCandidateStillExists: deletionCandidateStats.some(Boolean), + deletionCandidateWasExtracted: extractCalls.some(({ files }) => + paths.some((path) => files.includes(path)) + ), + }; + }, + { pluginId: DIFFZIP_PLUGIN_ID, paths: MIRROR_DELETION_FIXTURE_PATHS } + ); + } finally { + await page.evaluate((pluginId) => { + interface RestorePlugin { + extractWithoutWakeLock( + zipName: string, + files: string | string[], + restoreAs?: string, + prefix?: string + ): Promise; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipExtractCalls?: Array<{ files: string[]; zipName: string }>; + diffzipMirrorRestore?: Promise; + diffzipOriginalExtract?: RestorePlugin["extractWithoutWakeLock"]; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (plugin && root.diffzipOriginalExtract) { + plugin.extractWithoutWakeLock = root.diffzipOriginalExtract; + } + delete root.diffzipExtractCalls; + delete root.diffzipMirrorRestore; + delete root.diffzipOriginalExtract; + }, DIFFZIP_PLUGIN_ID); + } +} + +async function observeSelectedRevisionSemantics(testSession: DiffZipTestSession): Promise { + return await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + await page.evaluate( + ({ pluginId, normalPath, normalTimestamp, deletionPath, deletionTimestamp }) => { + interface RestorePlugin { + extractWithoutWakeLock( + zipName: string, + files: string | string[], + restoreAs?: string, + prefix?: string + ): Promise; + restoreVault( + onlyNew?: boolean, + deleteMissing?: boolean, + fileFilter?: Record + ): Promise; + operationWakeLock: { activeLeaseCount: number }; + vaultAccess: { + deleteBinary(path: string): Promise; + stat(path: string): Promise; + }; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipRevisionDeletes?: string[]; + diffzipRevisionExtracts?: string[]; + diffzipRevisionLeaseCounts?: number[]; + diffzipRevisionRestore?: Promise; + diffzipRevisionOriginalDelete?: RestorePlugin["vaultAccess"]["deleteBinary"]; + diffzipRevisionOriginalExtract?: RestorePlugin["extractWithoutWakeLock"]; + diffzipRevisionOriginalStat?: RestorePlugin["vaultAccess"]["stat"]; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + root.diffzipRevisionDeletes = []; + root.diffzipRevisionExtracts = []; + root.diffzipRevisionLeaseCounts = []; + root.diffzipRevisionOriginalDelete = plugin.vaultAccess.deleteBinary; + root.diffzipRevisionOriginalExtract = plugin.extractWithoutWakeLock; + root.diffzipRevisionOriginalStat = plugin.vaultAccess.stat; + plugin.vaultAccess.stat = async (path) => { + root.diffzipRevisionLeaseCounts?.push(plugin.operationWakeLock.activeLeaseCount); + return await root.diffzipRevisionOriginalStat?.call(plugin.vaultAccess, path); + }; + plugin.vaultAccess.deleteBinary = async (path) => { + root.diffzipRevisionLeaseCounts?.push(plugin.operationWakeLock.activeLeaseCount); + root.diffzipRevisionDeletes?.push(path); + return true; + }; + plugin.extractWithoutWakeLock = async (_zipName, files) => { + root.diffzipRevisionLeaseCounts?.push(plugin.operationWakeLock.activeLeaseCount); + root.diffzipRevisionExtracts?.push(...(Array.isArray(files) ? files : [files])); + }; + root.diffzipRevisionRestore = plugin.restoreVault(false, true, { + [normalPath]: normalTimestamp, + [deletionPath]: deletionTimestamp, + }); + }, + { + pluginId: DIFFZIP_PLUGIN_ID, + normalPath: NORMAL_BEFORE_DELETE_PATH, + normalTimestamp: NORMAL_BEFORE_DELETE_TIMESTAMP, + deletionPath: DELETE_BEFORE_RECREATE_PATH, + deletionTimestamp: DELETE_BEFORE_RECREATE_TIMESTAMP, + } + ); + + try { + const modal = page.locator(".modal-container").filter({ hasText: "Restore Confirmation" }).last(); + await modal.waitFor({ state: "visible", timeout: 10_000 }); + const activeLeasesAtConfirmation = await page.evaluate((pluginId) => { + const root = globalThis as typeof globalThis & { + app?: { + plugins?: { + plugins?: Record; + }; + }; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + return plugin.operationWakeLock.activeLeaseCount; + }, DIFFZIP_PLUGIN_ID); + await modal.getByRole("button", { name: "Yes, restore them!", exact: true }).click(); + const evidence = await page.evaluate(async (pluginId) => { + const root = globalThis as typeof globalThis & { + app?: { + plugins?: { + plugins?: Record; + }; + }; + diffzipRevisionDeletes?: string[]; + diffzipRevisionExtracts?: string[]; + diffzipRevisionLeaseCounts?: number[]; + diffzipRevisionRestore?: Promise; + }; + await root.diffzipRevisionRestore; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + return { + activeLeaseCounts: root.diffzipRevisionLeaseCounts ?? [], + activeLeasesAfter: plugin.operationWakeLock.activeLeaseCount, + deleted: root.diffzipRevisionDeletes ?? [], + extracted: root.diffzipRevisionExtracts ?? [], + }; + }, DIFFZIP_PLUGIN_ID); + return { ...evidence, activeLeasesAtConfirmation }; + } finally { + await page.evaluate((pluginId) => { + interface RestorePlugin { + extractWithoutWakeLock( + zipName: string, + files: string | string[], + restoreAs?: string, + prefix?: string + ): Promise; + vaultAccess: { + deleteBinary(path: string): Promise; + stat(path: string): Promise; + }; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipRevisionDeletes?: string[]; + diffzipRevisionExtracts?: string[]; + diffzipRevisionLeaseCounts?: number[]; + diffzipRevisionRestore?: Promise; + diffzipRevisionOriginalDelete?: RestorePlugin["vaultAccess"]["deleteBinary"]; + diffzipRevisionOriginalExtract?: RestorePlugin["extractWithoutWakeLock"]; + diffzipRevisionOriginalStat?: (path: string) => Promise; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (plugin && root.diffzipRevisionOriginalDelete) { + plugin.vaultAccess.deleteBinary = root.diffzipRevisionOriginalDelete; + } + if (plugin && root.diffzipRevisionOriginalExtract) { + plugin.extractWithoutWakeLock = root.diffzipRevisionOriginalExtract; + } + if (plugin && root.diffzipRevisionOriginalStat) { + plugin.vaultAccess.stat = root.diffzipRevisionOriginalStat; + } + delete root.diffzipRevisionDeletes; + delete root.diffzipRevisionExtracts; + delete root.diffzipRevisionLeaseCounts; + delete root.diffzipRevisionRestore; + delete root.diffzipRevisionOriginalDelete; + delete root.diffzipRevisionOriginalExtract; + delete root.diffzipRevisionOriginalStat; + }, DIFFZIP_PLUGIN_ID); + } + }); +} + +async function observeFailureSemantics(testSession: DiffZipTestSession): Promise { + return await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + const extractionFailures = await page.evaluate( + async ({ pluginId, probePath, probeZip }) => { + interface RestorePlugin { + backups: { + readBinary(path: string): Promise; + }; + extract(zipName: string, files: string | string[]): Promise; + vaultAccess: { + writeBinary(path: string, data: ArrayBuffer): Promise; + }; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + let missingArchiveRejected = false; + try { + await plugin.extract("absent.zip", [probePath]); + } catch { + missingArchiveRejected = true; + } + + let missingEntryRejected = false; + try { + await plugin.extract(probeZip, ["failure/not-in-archive.md"]); + } catch { + missingEntryRejected = true; + } + + const originalReadBinary = plugin.backups.readBinary; + plugin.backups.readBinary = async () => false; + let readFailureRejected = false; + try { + await plugin.extract(probeZip, [probePath]); + } catch { + readFailureRejected = true; + } + plugin.backups.readBinary = originalReadBinary; + + const originalWriteBinary = plugin.vaultAccess.writeBinary; + plugin.vaultAccess.writeBinary = async () => false; + let writeFailureRejected = false; + try { + await plugin.extract(probeZip, [probePath]); + } catch { + writeFailureRejected = true; + } + plugin.vaultAccess.writeBinary = originalWriteBinary; + + return { missingArchiveRejected, missingEntryRejected, readFailureRejected, writeFailureRejected }; + }, + { pluginId: DIFFZIP_PLUGIN_ID, probePath: EXTRACTION_PROBE_PATH, probeZip: EXTRACTION_PROBE_ZIP } + ); + + await page.evaluate((pluginId) => { + interface RestorePlugin { + restoreVault(onlyNew?: boolean, deleteMissing?: boolean): Promise; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipFailedRestore?: Promise; + }; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + root.diffzipFailedRestore = plugin.restoreVault(false, true).then( + () => false, + () => true + ); + }, DIFFZIP_PLUGIN_ID); + const modal = page.locator(".modal-container").filter({ hasText: "Restore Confirmation" }).last(); + await modal.waitFor({ state: "visible", timeout: 10_000 }); + await modal.getByRole("button", { name: "Yes, restore them!", exact: true }).click(); + const restoreFailure = await page.evaluate( + async ({ pluginId, deletionPath }) => { + interface RestorePlugin { + vaultAccess: { stat(path: string): Promise }; + } + const root = globalThis as typeof globalThis & { + app?: { plugins?: { plugins?: Record } }; + diffzipFailedRestore?: Promise; + }; + const failedRestoreRejected = (await root.diffzipFailedRestore) ?? false; + delete root.diffzipFailedRestore; + const plugin = root.app?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + return { + failedRestoreRejected, + deletionCandidateStillExists: Boolean(await plugin.vaultAccess.stat(deletionPath)), + }; + }, + { pluginId: DIFFZIP_PLUGIN_ID, deletionPath: FAILED_RESTORE_DELETION_PATH } + ); + return { ...extractionFailures, ...restoreFailure }; + }); +} + +async function verifyMirrorDeleteSemantics(testSession: DiffZipTestSession): Promise { + await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + const mode = await observeAllDeleteMode(page); + const execution = await observeDeleteExecution(page); + const failures: string[] = []; + if (mode.onlyNew || !mode.deleteMissing) { + failures.push(`all-delete mapped to ${JSON.stringify(mode)}`); + } + if (execution.deletionCandidateStillExists) { + failures.push("a confirmed mirror deletion candidate remained in the Vault"); + } + if (execution.deletionCandidateWasExtracted) { + failures.push("a mirror deletion candidate was also sent to extract"); + } + if (failures.length > 0) { + throw new Error(`Mirror delete semantics failed: ${failures.join("; ")}`); + } + }); +} + +async function main(): Promise { + const failures: string[] = []; + let historySession: DiffZipTestSession | undefined; + let failureSession: DiffZipTestSession | undefined; + let largeSession: DiffZipTestSession | undefined; + try { + historySession = await startDiffZipTestSession({ restorePlan: "history" }); + const revision = await observeSelectedRevisionSemantics(historySession); + if (!revision.extracted.includes(NORMAL_BEFORE_DELETE_PATH)) { + failures.push("a selected normal revision was not planned for extraction"); + } + if (revision.deleted.includes(NORMAL_BEFORE_DELETE_PATH)) { + failures.push("a selected normal revision was planned for deletion"); + } + if (revision.extracted.includes(DELETE_BEFORE_RECREATE_PATH)) { + failures.push("a selected deletion revision was planned for extraction"); + } + if (!revision.deleted.includes(DELETE_BEFORE_RECREATE_PATH)) { + failures.push("a selected deletion revision was not planned for deletion"); + } + if (revision.activeLeaseCounts.length === 0 || revision.activeLeaseCounts.some((count) => count !== 1)) { + failures.push( + `restore work ran outside one wake-lock lease: ${JSON.stringify(revision.activeLeaseCounts)}` + ); + } + if (revision.activeLeasesAtConfirmation !== 0) { + failures.push(`restore confirmation held a wake-lock lease: ${revision.activeLeasesAtConfirmation}`); + } + if (revision.activeLeasesAfter !== 0) { + failures.push(`restore wake-lock lease remained active: ${revision.activeLeasesAfter}`); + } + await stopDiffZipTestSession(historySession); + historySession = undefined; + + failureSession = await startDiffZipTestSession({ restorePlan: "failure" }); + const failure = await observeFailureSemantics(failureSession); + if (!failure.missingArchiveRejected) failures.push("a missing archive was reported as restored"); + if (!failure.missingEntryRejected) failures.push("a missing ZIP entry was reported as restored"); + if (!failure.readFailureRejected) failures.push("an archive read failure was reported as restored"); + if (!failure.writeFailureRejected) failures.push("a Vault write failure was reported as restored"); + if (!failure.failedRestoreRejected) failures.push("a failed restore resolved successfully"); + if (!failure.deletionCandidateStillExists) failures.push("a failed restore deleted a mirror candidate"); + await stopDiffZipTestSession(failureSession); + failureSession = undefined; + + if (failures.length > 0) { + throw new Error(`Mirror delete regression cases failed: ${failures.join("; ")}`); + } + + largeSession = await startDiffZipTestSession({ restorePlan: "large" }); + await verifyMirrorDeleteSemantics(largeSession); + console.log("DiffZip mirror delete semantics passed in real Obsidian"); + } finally { + if (historySession) await stopDiffZipTestSession(historySession); + if (failureSession) await stopDiffZipTestSession(failureSession); + if (largeSession) await stopDiffZipTestSession(largeSession); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/test/e2e-obsidian/restore-confirmation.mts b/test/e2e-obsidian/restore-confirmation.mts index fcf70de..9d36cc5 100644 --- a/test/e2e-obsidian/restore-confirmation.mts +++ b/test/e2e-obsidian/restore-confirmation.mts @@ -46,7 +46,7 @@ async function requestRestore(page: Page, deleteMissing: boolean): Promise if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); await plugin.restoreVault(false, shouldDeleteMissing); }, - { pluginId: DIFFZIP_PLUGIN_ID, shouldDeleteMissing: deleteMissing }, + { pluginId: DIFFZIP_PLUGIN_ID, shouldDeleteMissing: deleteMissing } ); } @@ -122,7 +122,7 @@ async function assertLargeConfirmationLayout(page: Page, modal: Locator): Promis async function verifyCancellation( testSession: DiffZipTestSession, - { deleteMissing, dismissWithEscape }: RestoreConfirmationScenario, + { deleteMissing, dismissWithEscape }: RestoreConfirmationScenario ): Promise { await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { await enterPhoneReview(page); @@ -131,20 +131,19 @@ async function verifyCancellation( const modal = page.locator(".modal-container").filter({ hasText: "Restore Confirmation" }).last(); await modal.waitFor({ state: "visible", timeout: 10_000 }); const expectedDetailCount = deleteMissing ? 2 : 1; - await modal.locator("details").nth(expectedDetailCount - 1).waitFor({ state: "attached", timeout: 5_000 }); + await modal + .locator("details") + .nth(expectedDetailCount - 1) + .waitFor({ state: "attached", timeout: 5_000 }); const content = await modal.textContent(); if (content === null) { throw new Error("Restore summary was not rendered"); } const normalRestoreSummary = - `We have ${RESTORE_FIXTURE_PATHS.length} files to restore ` + - `on ${RESTORE_FIXTURE_ZIP_COUNT} ZIPs.`; - if (!deleteMissing && !content.includes(normalRestoreSummary)) { - throw new Error(`Normal restore summary was not rendered: ${content}`); - } - if (deleteMissing && !content.includes("files to restore on")) { - throw new Error(`Mirror restore summary was not rendered: ${content}`); + `We have ${RESTORE_FIXTURE_PATHS.length} files to restore ` + `on ${RESTORE_FIXTURE_ZIP_COUNT} ZIPs.`; + if (!content.includes(normalRestoreSummary)) { + throw new Error(`${deleteMissing ? "Mirror" : "Normal"} restore summary was not rendered: ${content}`); } for (const path of [RESTORE_FIXTURE_PATHS.at(0), RESTORE_FIXTURE_PATHS.at(-1)]) { if (!path || !content.includes(path)) { @@ -156,10 +155,7 @@ async function verifyCancellation( if (!content.includes(deletionSummary)) { throw new Error(`Mirror deletion summary was not rendered: ${content}`); } - for (const path of [ - MIRROR_DELETION_FIXTURE_PATHS.at(0), - MIRROR_DELETION_FIXTURE_PATHS.at(-1), - ]) { + for (const path of [MIRROR_DELETION_FIXTURE_PATHS.at(0), MIRROR_DELETION_FIXTURE_PATHS.at(-1)]) { if (!path || !content.includes(path)) { throw new Error(`Mirror deletion candidate was not rendered: ${path ?? ""}`); } From a2a1b6558ab3b5ac628449bf92bb7cffa30629ea Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 15 Jul 2026 05:45:57 +0000 Subject: [PATCH 2/2] Clarify destructive restore confirmation --- src/restoreConfirmation.test.ts | 35 ++++++++++++++++++++++ src/restoreConfirmation.ts | 16 +++++----- test/e2e-obsidian/restore-confirmation.mts | 13 +++++--- 3 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 src/restoreConfirmation.test.ts diff --git a/src/restoreConfirmation.test.ts b/src/restoreConfirmation.test.ts new file mode 100644 index 0000000..20eae8f --- /dev/null +++ b/src/restoreConfirmation.test.ts @@ -0,0 +1,35 @@ +import type { ConfirmActionOptions, UiInteractions } from "@vrtmrz/obsidian-plugin-kit/ui"; +import { confirmRestore, RESTORE_CONFIRMATION_INTERACTION_ID } from "./restoreConfirmation.ts"; + +declare const Deno: { + test: (name: string, fn: () => void | Promise) => void; +}; + +function assertEquals(actual: T, expected: T, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected=${String(expected)}, actual=${String(actual)}`); + } +} + +Deno.test("restore confirmation: identifies a destructive restore in its title and action", async () => { + let request: { interactionId?: string; options: ConfirmActionOptions } | undefined; + const ui = { + confirmAction: async (options: ConfirmActionOptions, interactionId?: string) => { + request = { interactionId, options }; + return "cancel"; + }, + } as UiInteractions; + + await confirmRestore(ui, { + processFileCount: 1, + filesByZip: new Map([["backup.zip", ["restored.md"]]]), + deleteMissing: true, + deletingFiles: ["deleted.md"], + }); + + if (!request) throw new Error("The confirmation interaction was not requested"); + assertEquals(request.interactionId, RESTORE_CONFIRMATION_INTERACTION_ID, "interaction ID"); + assertEquals(request.options.title, "Restore and Delete Confirmation", "destructive confirmation title"); + assertEquals(request.options.labels?.restore, "Restore and delete", "destructive confirmation action"); + assertEquals(request.options.defaultAction, "cancel", "safe default action"); +}); diff --git a/src/restoreConfirmation.ts b/src/restoreConfirmation.ts index 6c9f980..2698a5b 100644 --- a/src/restoreConfirmation.ts +++ b/src/restoreConfirmation.ts @@ -18,7 +18,7 @@ export interface RestoreConfirmationOptions { /** Requests confirmation for a planned restore through the injected UI capability. */ export async function confirmRestore( ui: UiInteractions, - { processFileCount, filesByZip, deleteMissing, deletingFiles }: RestoreConfirmationOptions, + { processFileCount, filesByZip, deleteMissing, deletingFiles }: RestoreConfirmationOptions ): Promise { const detailFiles = `
@@ -34,25 +34,25 @@ ${[...filesByZip.entries()] ${deletingFiles.map((file) => `- ${file}`).join("\n")}
`; - const deleteMessage = - deleteMissing && deletingFiles.length > 0 - ? `And ${deletingFiles.length} files will be deleted.\n${detailDeletedFiles}\n` - : ""; + const isDestructive = deleteMissing && deletingFiles.length > 0; + const deleteMessage = isDestructive + ? `And ${deletingFiles.length} files will be deleted.\n${detailDeletedFiles}\n` + : ""; const message = `We have ${processFileCount} files to restore on ${filesByZip.size} ZIPs. \n${detailFiles}\n${deleteMessage}Are you sure to proceed?`; const action = await ui.confirmAction( { - title: "Restore Confirmation", + title: isDestructive ? "Restore and Delete Confirmation" : "Restore Confirmation", message, actions: ["restore", "cancel"] as const, labels: { - restore: "Yes, restore them!", + restore: isDestructive ? "Restore and delete" : "Yes, restore them!", cancel: "Cancel", }, defaultAction: "cancel", sourcePath: "/", }, - RESTORE_CONFIRMATION_INTERACTION_ID, + RESTORE_CONFIRMATION_INTERACTION_ID ); return action === "restore"; } diff --git a/test/e2e-obsidian/restore-confirmation.mts b/test/e2e-obsidian/restore-confirmation.mts index 9d36cc5..89039a6 100644 --- a/test/e2e-obsidian/restore-confirmation.mts +++ b/test/e2e-obsidian/restore-confirmation.mts @@ -76,13 +76,16 @@ async function leavePhoneReview(page: Page): Promise { await page.setViewportSize({ width: 1280, height: 960 }); } -async function assertLargeConfirmationLayout(page: Page, modal: Locator): Promise { +async function assertLargeConfirmationLayout(page: Page, modal: Locator, deleteMissing: boolean): Promise { const dialogue = modal.locator(".modal"); const title = dialogue.locator(".modal-title"); const closeButton = dialogue.locator(".modal-close-button"); const content = dialogue.locator(".modal-content"); const actions = dialogue.locator(".setting-item-control").last(); - const restoreButton = dialogue.getByRole("button", { name: "Yes, restore them!", exact: true }); + const restoreButton = dialogue.getByRole("button", { + name: deleteMissing ? "Restore and delete" : "Yes, restore them!", + exact: true, + }); const cancelButton = dialogue.getByRole("button", { name: "Cancel", exact: true }); await assertLocatorWithinSafeArea(page, title, { @@ -128,8 +131,10 @@ async function verifyCancellation( await enterPhoneReview(page); try { const restore = requestRestore(page, deleteMissing); - const modal = page.locator(".modal-container").filter({ hasText: "Restore Confirmation" }).last(); + const confirmationTitle = deleteMissing ? "Restore and Delete Confirmation" : "Restore Confirmation"; + const modal = page.locator(".modal-container").filter({ hasText: confirmationTitle }).last(); await modal.waitFor({ state: "visible", timeout: 10_000 }); + await modal.getByText(confirmationTitle, { exact: true }).waitFor({ state: "visible", timeout: 5_000 }); const expectedDetailCount = deleteMissing ? 2 : 1; await modal .locator("details") @@ -164,7 +169,7 @@ async function verifyCancellation( throw new Error(`Mirror deletion candidates leaked into a normal restore: ${content}`); } - await assertLargeConfirmationLayout(page, modal); + await assertLargeConfirmationLayout(page, modal, deleteMissing); if (dismissWithEscape) { await page.keyboard.press("Escape"); } else {