From 0e28795997d965455a3ab9cdf8c8dd9597f71b56 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 15 Jul 2026 03:35:14 +0000 Subject: [PATCH] test: cover large mobile restore confirmations --- package-lock.json | 8 +- package.json | 2 +- test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/harness.mts | 102 ++++++++-- test/e2e-obsidian/restore-confirmation.mts | 214 +++++++++++++++++---- 5 files changed, 267 insertions(+), 61 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3300ef4..be06952 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@types/node": "^22.19.19", "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "^8.56.1", - "@vrtmrz/obsidian-test-session": "0.1.0", + "@vrtmrz/obsidian-test-session": "0.2.0", "esbuild": "0.28.1", "esbuild-svelte": "^0.9.5", "eslint": "^9.39.3", @@ -3372,9 +3372,9 @@ } }, "node_modules/@vrtmrz/obsidian-test-session": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.1.0.tgz", - "integrity": "sha512-asBOIRTc3xK5GF5ds5mkxN6vsO4RE8o7puvVjoJiGYSlxoFa9jzr8FwAO13CyoOriHF05pOZfTB+eQmL1aNb/A==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@vrtmrz/obsidian-test-session/-/obsidian-test-session-0.2.0.tgz", + "integrity": "sha512-Mnw1wide/KddJHfq6ulpwqdPLV8rIywsY013m9nQvn2GFm7XnBAqZjZpVEolMJH/mqbLlWvU3PhL1UWJLZsIJA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 5dac4a1..9632c41 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@types/node": "^22.19.19", "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "^8.56.1", - "@vrtmrz/obsidian-test-session": "0.1.0", + "@vrtmrz/obsidian-test-session": "0.2.0", "esbuild": "0.28.1", "esbuild-svelte": "^0.9.5", "eslint": "^9.39.3", diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 724780a..164986c 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -2,7 +2,7 @@ This local-only suite installs the built DiffZip plug-in into an isolated vault and profile through `@vrtmrz/obsidian-test-session`. It is not part of the default CI gate. -The restore-confirmation scenario seeds one backup-information entry, invokes the real restore workflow, and verifies rendered Markdown, the visible Cancel action, explicit cancellation, and Escape dismissal in a real Obsidian Modal. It does not use scripted `UiInteractions` responses. +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 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. diff --git a/test/e2e-obsidian/harness.mts b/test/e2e-obsidian/harness.mts index 1290a7e..d5c4794 100644 --- a/test/e2e-obsidian/harness.mts +++ b/test/e2e-obsidian/harness.mts @@ -10,6 +10,27 @@ import { } from "@vrtmrz/obsidian-test-session"; export const DIFFZIP_PLUGIN_ID = "diffzip"; +export const RESTORE_FIXTURE_ZIP_COUNT = 3; +export const RESTORE_FIXTURE_PATHS = Array.from({ length: 60 }, (_, index) => { + const ordinal = String(index + 1).padStart(3, "0"); + return `notes/restore/batch/restore-${ordinal}-with-a-deliberately-long-file-name-for-mobile-layout-review.md`; +}); +export const MIRROR_DELETION_FIXTURE_PATHS = Array.from({ length: 24 }, (_, index) => { + const ordinal = String(index + 1).padStart(3, "0"); + return `mirror/obsolete-${ordinal}.md`; +}); + +interface RestoreTocEntry { + filename: string; + digest: string; + history: Array<{ + zipName: string; + modified: string; + digest: string; + }>; + mtime: number; + missing?: boolean; +} /** A running real-Obsidian DiffZip test session and its isolated vault. */ export interface DiffZipTestSession { @@ -19,28 +40,75 @@ export interface DiffZipTestSession { vault: TemporaryVault; } -async function seedRestorePlan(vaultPath: string): Promise { +export interface StartDiffZipTestSessionOptions { + /** Restore plan fixture to seed before Obsidian starts. Defaults to the original single-file plan. */ + restorePlan?: "single" | "large"; +} + +async function writeRestorePlan(vaultPath: string, toc: Record): Promise { const backupPath = join(vaultPath, "backup"); await mkdir(backupPath, { recursive: true }); - const toc = { - "notes/restore-me.md": { - filename: "notes/restore-me.md", - digest: "preview-digest", + await writeFile(join(backupPath, "backupinfo.md"), `\`\`\`\n${JSON.stringify(toc)}\n\`\`\`\n`); +} + +async function seedSingleRestorePlan(vaultPath: string): Promise { + const filename = "notes/restore-me.md"; + const modified = "2026-07-10T00:00:00.000Z"; + const digest = "preview-digest"; + await writeRestorePlan(vaultPath, { + [filename]: { + filename, + digest, + history: [{ zipName: "backup-1.zip", modified, digest }], + mtime: Date.parse(modified), + }, + }); +} + +async function seedLargeRestorePlan(vaultPath: string): Promise { + const mirrorPath = join(vaultPath, "mirror"); + await mkdir(mirrorPath, { recursive: true }); + const modified = "2026-07-10T00:00:00.000Z"; + const toc: Record = {}; + for (const [index, filename] of RESTORE_FIXTURE_PATHS.entries()) { + const digest = `restore-digest-${index + 1}`; + toc[filename] = { + filename, + digest, history: [ { - zipName: "backup-1.zip", - modified: "2026-07-10T00:00:00.000Z", - digest: "preview-digest", + zipName: `backup-${(index % RESTORE_FIXTURE_ZIP_COUNT) + 1}.zip`, + modified, + digest, }, ], - mtime: Date.parse("2026-07-10T00:00:00.000Z"), - }, - }; - await writeFile(join(backupPath, "backupinfo.md"), `\`\`\`\n${JSON.stringify(toc)}\n\`\`\`\n`); + mtime: Date.parse(modified), + }; + } + for (const [index, filename] of MIRROR_DELETION_FIXTURE_PATHS.entries()) { + const digest = `missing-digest-${index + 1}`; + toc[filename] = { + filename, + digest, + history: [ + { + zipName: "backup-mirror.zip", + modified, + digest, + }, + ], + mtime: Date.parse(modified), + missing: true, + }; + await writeFile(join(vaultPath, filename), `Local mirror candidate ${index + 1}`); + } + await writeRestorePlan(vaultPath, toc); } -/** Starts DiffZip in an isolated real-Obsidian session with one planned restore. */ -export async function startDiffZipTestSession(): Promise { +/** Starts DiffZip in an isolated real-Obsidian session with the selected restore plan fixture. */ +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({ @@ -49,7 +117,11 @@ export async function startDiffZipTestSession(): Promise { idPrefix: "diffzip-e2e", }); try { - await seedRestorePlan(vault.path); + if (restorePlan === "large") { + await seedLargeRestorePlan(vault.path); + } else { + await seedSingleRestorePlan(vault.path); + } const session = await startObsidianPluginSession({ binary: requireObsidianBinary(), cliBinary: cli.binary, diff --git a/test/e2e-obsidian/restore-confirmation.mts b/test/e2e-obsidian/restore-confirmation.mts index 196061f..fcf70de 100644 --- a/test/e2e-obsidian/restore-confirmation.mts +++ b/test/e2e-obsidian/restore-confirmation.mts @@ -1,61 +1,195 @@ -import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; +import { + assertLocatorHasMinimumTouchTarget, + assertLocatorWithinSafeArea, + assertNoHorizontalOverflow, + withObsidianPage, +} from "@vrtmrz/obsidian-test-session"; +import type { Locator, Page } from "playwright"; import { DIFFZIP_PLUGIN_ID, + MIRROR_DELETION_FIXTURE_PATHS, + RESTORE_FIXTURE_PATHS, + RESTORE_FIXTURE_ZIP_COUNT, startDiffZipTestSession, stopDiffZipTestSession, type DiffZipTestSession, } from "./harness.mts"; -async function requestRestore(page: import("playwright").Page): Promise { - return await page.evaluate(async (pluginId) => { - const obsidianApp = ( - globalThis as typeof globalThis & { - app?: { - plugins?: { - plugins?: Record< - string, - { restoreVault(onlyNew: boolean, deleteMissing: boolean): Promise } - >; +const PHONE_SAFE_AREA_INSETS = { + top: 47, + right: 0, + bottom: 34, + left: 0, +} as const; + +interface RestoreConfirmationScenario { + deleteMissing: boolean; + dismissWithEscape: boolean; +} + +async function requestRestore(page: Page, deleteMissing: boolean): Promise { + return await page.evaluate( + async ({ pluginId, shouldDeleteMissing }) => { + const obsidianApp = ( + globalThis as typeof globalThis & { + app?: { + plugins?: { + plugins?: Record< + string, + { restoreVault(onlyNew: boolean, deleteMissing: boolean): Promise } + >; + }; }; - }; - } - ).app; - const plugin = obsidianApp?.plugins?.plugins?.[pluginId]; - if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); - await plugin.restoreVault(false, false); - }, DIFFZIP_PLUGIN_ID); + } + ).app; + const plugin = obsidianApp?.plugins?.plugins?.[pluginId]; + if (!plugin) throw new Error(`DiffZip is not loaded: ${pluginId}`); + await plugin.restoreVault(false, shouldDeleteMissing); + }, + { pluginId: DIFFZIP_PLUGIN_ID, shouldDeleteMissing: deleteMissing }, + ); } -async function verifyCancellation(testSession: DiffZipTestSession, dismissWithEscape: boolean): Promise { - await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { - const restore = requestRestore(page); - const modal = page.locator(".modal-container").filter({ hasText: "Restore Confirmation" }).last(); - await modal.waitFor({ state: "visible", timeout: 10_000 }); - const content = await modal.textContent(); - if (!content?.includes("We have 1 files to restore on 1 ZIPs.")) { - throw new Error(`Restore summary was not rendered: ${content ?? ""}`); - } - if (!content.includes("notes/restore-me.md")) { - throw new Error(`Restore file was not rendered: ${content}`); +async function enterPhoneReview(page: Page): Promise { + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => { + document.body.classList.add("is-mobile", "is-phone"); + document.body.style.setProperty("--safe-area-inset-top", "47px"); + document.body.style.setProperty("--safe-area-inset-right", "0px"); + document.body.style.setProperty("--safe-area-inset-bottom", "34px"); + document.body.style.setProperty("--safe-area-inset-left", "0px"); + }); +} + +async function leavePhoneReview(page: Page): Promise { + await page.evaluate(() => { + document.body.classList.remove("is-mobile", "is-phone"); + for (const property of [ + "--safe-area-inset-top", + "--safe-area-inset-right", + "--safe-area-inset-bottom", + "--safe-area-inset-left", + ]) { + document.body.style.removeProperty(property); } - if (dismissWithEscape) { - await page.keyboard.press("Escape"); - } else { - await modal.getByRole("button", { name: "Cancel", exact: true }).click(); + }); + await page.setViewportSize({ width: 1280, height: 960 }); +} + +async function assertLargeConfirmationLayout(page: Page, modal: Locator): 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 cancelButton = dialogue.getByRole("button", { name: "Cancel", exact: true }); + + await assertLocatorWithinSafeArea(page, title, { + label: "restore confirmation title", + safeAreaInsets: PHONE_SAFE_AREA_INSETS, + }); + await assertLocatorWithinSafeArea(page, closeButton, { + label: "restore confirmation close button", + safeAreaInsets: PHONE_SAFE_AREA_INSETS, + }); + await assertLocatorHasMinimumTouchTarget(page, closeButton, { + label: "restore confirmation close button", + }); + + await content.locator("details").evaluateAll((elements) => { + for (const element of elements) element.setAttribute("open", ""); + }); + await assertNoHorizontalOverflow(page, content, { + label: "expanded restore confirmation content", + }); + + await actions.scrollIntoViewIfNeeded(); + await assertLocatorWithinSafeArea(page, actions, { + label: "restore confirmation actions", + safeAreaInsets: PHONE_SAFE_AREA_INSETS, + }); + await assertNoHorizontalOverflow(page, actions, { + label: "restore confirmation actions", + }); + await assertLocatorHasMinimumTouchTarget(page, restoreButton, { + label: "restore confirmation restore button", + }); + await assertLocatorHasMinimumTouchTarget(page, cancelButton, { + label: "restore confirmation cancel button", + }); +} + +async function verifyCancellation( + testSession: DiffZipTestSession, + { deleteMissing, dismissWithEscape }: RestoreConfirmationScenario, +): Promise { + await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + await enterPhoneReview(page); + try { + const restore = requestRestore(page, deleteMissing); + 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 }); + + 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}`); + } + for (const path of [RESTORE_FIXTURE_PATHS.at(0), RESTORE_FIXTURE_PATHS.at(-1)]) { + if (!path || !content.includes(path)) { + throw new Error(`Restore file was not rendered: ${path ?? ""}`); + } + } + const deletionSummary = `And ${MIRROR_DELETION_FIXTURE_PATHS.length} files will be deleted.`; + if (deleteMissing) { + 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), + ]) { + if (!path || !content.includes(path)) { + throw new Error(`Mirror deletion candidate was not rendered: ${path ?? ""}`); + } + } + } else if (content.includes(deletionSummary) || content.includes(MIRROR_DELETION_FIXTURE_PATHS[0])) { + throw new Error(`Mirror deletion candidates leaked into a normal restore: ${content}`); + } + + await assertLargeConfirmationLayout(page, modal); + if (dismissWithEscape) { + await page.keyboard.press("Escape"); + } else { + await modal.getByRole("button", { name: "Cancel", exact: true }).click(); + } + await restore; + await modal.waitFor({ state: "detached", timeout: 10_000 }); + await page.getByText("Cancelled", { exact: true }).last().waitFor({ state: "visible", timeout: 5_000 }); + } finally { + await leavePhoneReview(page); } - await restore; - await modal.waitFor({ state: "detached", timeout: 10_000 }); - await page.getByText("Cancelled", { exact: true }).last().waitFor({ state: "visible", timeout: 5_000 }); }); } async function main(): Promise { let testSession: DiffZipTestSession | undefined; try { - testSession = await startDiffZipTestSession(); - await verifyCancellation(testSession, false); - await verifyCancellation(testSession, true); - console.log("DiffZip restore confirmation passed in real Obsidian"); + testSession = await startDiffZipTestSession({ restorePlan: "large" }); + await verifyCancellation(testSession, { deleteMissing: false, dismissWithEscape: false }); + await verifyCancellation(testSession, { deleteMissing: true, dismissWithEscape: true }); + console.log("DiffZip large restore and mirror confirmations passed in real Obsidian"); } finally { if (testSession) await stopDiffZipTestSession(testSession); }