From c4d6b768da6eef778d7f252530181aef3a6ccc31 Mon Sep 17 00:00:00 2001 From: SeleiXi Date: Tue, 14 Jul 2026 00:26:59 +0800 Subject: [PATCH 1/2] Add revision prev/next buttons next to history slider --- .../DocumentHistory/DocumentHistoryModal.ts | 80 +++++- styles.css | 45 +++ .../scripts/document-history-nav.ts | 269 ++++++++++++++++++ 3 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 test/e2e-obsidian/scripts/document-history-nav.ts diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 25714b52..efe18433 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -68,6 +68,11 @@ export class DocumentHistoryModal extends Modal { currentDeleted = false; initialRev?: string; + // Revision navigation state (◀/▶ beside the range slider) + revPrevBtn!: HTMLButtonElement; + revNextBtn!: HTMLButtonElement; + revNavIndicator!: HTMLSpanElement; + // Diff navigation state currentDiffIndex = -1; diffNavContainer!: HTMLDivElement; @@ -78,6 +83,8 @@ export class DocumentHistoryModal extends Modal { searchKeyword = ""; searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = []; currentSearchIndex = -1; + searchPrevBtn!: HTMLButtonElement; + searchNextBtn!: HTMLButtonElement; searchResultIndicator!: HTMLSpanElement; searchProgressIndicator!: HTMLSpanElement; searchTimeout: number | null = null; @@ -121,12 +128,14 @@ export class DocumentHistoryModal extends Modal { this.range.value = this.range.max; this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`); await this.loadRevs(initialRev); + this.updateRevisionNavUI(); } catch (ex) { if (isErrorOfMissingDoc(ex)) { this.range.max = "0"; this.range.value = ""; this.range.disabled = true; this.contentView.setText(`We don't have any history for this note.`); + this.updateRevisionNavUI(); } else { this.contentView.setText(`Error while loading file.`); Logger(ex, LOG_LEVEL_VERBOSE); @@ -144,6 +153,37 @@ export class DocumentHistoryModal extends Modal { const index = this.revs_info.length - 1 - (Number(this.range.value) || 0); const rev = this.revs_info[index]; await this.showExactRev(rev.rev); + this.updateRevisionNavUI(); + } + + navigateVersion(direction: "older" | "newer") { + const current = Number(this.range.value) || 0; + const max = Number(this.range.max) || 0; + + if (direction === "older" && current > 0) { + this.range.value = `${current - 1}`; + } else if (direction === "newer" && current < max) { + this.range.value = `${current + 1}`; + } else { + return; + } + + this.updateRevisionNavUI(); + void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); + } + + updateRevisionNavUI() { + if (!this.revNavIndicator) return; + + const total = this.revs_info.length; + const max = Number(this.range.max) || 0; + const current = Number(this.range.value) || 0; + + this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014"); + + const disabled = !!this.range.disabled || total <= 1; + this.revPrevBtn.disabled = disabled || current <= 0; + this.revNextBtn.disabled = disabled || current >= max; } BlobURLs = new Map(); @@ -387,6 +427,7 @@ export class DocumentHistoryModal extends Modal { if (!keyword) { this.searchResultIndicator.setText(""); this.searchProgressIndicator.setText(""); + this.updateSearchUI(); return; } @@ -460,6 +501,10 @@ export class DocumentHistoryModal extends Modal { const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0; this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`); } + + const hasResults = this.searchResults.length > 0; + this.searchPrevBtn.disabled = !hasResults; + this.searchNextBtn.disabled = !hasResults; } navigateSearch(direction: "prev" | "next") { @@ -511,12 +556,14 @@ export class DocumentHistoryModal extends Modal { }, 500); }); - searchRow.createEl("button", { text: "\u25B2" }, (e) => { + this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => { e.title = "Previous match"; + e.disabled = true; e.addEventListener("click", () => this.navigateSearch("prev")); }); - searchRow.createEl("button", { text: "\u25BC" }, (e) => { + this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => { e.title = "Next match"; + e.disabled = true; e.addEventListener("click", () => this.navigateSearch("next")); }); @@ -526,18 +573,37 @@ export class DocumentHistoryModal extends Modal { this.searchProgressIndicator = searchRow.createEl("span", { text: "" }); this.searchProgressIndicator.addClass("history-search-progress-indicator"); - const divView = contentEl.createDiv(""); - divView.addClass("op-flex"); + const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" }); - divView.createEl("input", { type: "range" }, (e) => { + this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => { + e.addClass("history-rev-nav-btn"); + e.title = "Older revision"; + e.disabled = true; + e.addEventListener("click", () => this.navigateVersion("older")); + }); + + revNavRow.createEl("input", { type: "range" }, (e) => { this.range = e; - e.addEventListener("change", (e) => { + e.addEventListener("change", () => { + this.updateRevisionNavUI(); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); - e.addEventListener("input", (e) => { + e.addEventListener("input", () => { + this.updateRevisionNavUI(); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); }); + + this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => { + e.addClass("history-rev-nav-btn"); + e.title = "Newer revision"; + e.disabled = true; + e.addEventListener("click", () => this.navigateVersion("newer")); + }); + + this.revNavIndicator = revNavRow.createEl("span", { text: "\u2014" }, (e) => { + e.addClass("history-rev-indicator"); + }); const diffOptionsRow = contentEl.createDiv(""); diffOptionsRow.addClass("op-info"); diffOptionsRow.addClass("diff-options-row"); diff --git a/styles.css b/styles.css index e478615e..84b222c2 100644 --- a/styles.css +++ b/styles.css @@ -551,6 +551,51 @@ div.workspace-leaf-content[data-type=bases] .livesync-status { color: var(--text-muted); } +.history-search-row button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.history-rev-nav-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.history-rev-nav-row input[type="range"] { + flex-grow: 1; + margin-bottom: 0; +} + +.history-rev-nav-btn { + flex-shrink: 0; + padding: 2px 10px; + font-size: 0.9em; + cursor: pointer; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-secondary); + color: var(--text-normal); +} + +.history-rev-nav-btn:hover:not(:disabled) { + background-color: var(--background-modifier-hover); +} + +.history-rev-nav-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.history-rev-indicator { + flex-shrink: 0; + font-size: 0.85em; + color: var(--text-muted); + min-width: 4.5em; + text-align: right; +} + .history-diff-options-row { justify-content: space-between; } diff --git a/test/e2e-obsidian/scripts/document-history-nav.ts b/test/e2e-obsidian/scripts/document-history-nav.ts new file mode 100644 index 00000000..680fcca0 --- /dev/null +++ b/test/e2e-obsidian/scripts/document-history-nav.ts @@ -0,0 +1,269 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { evalObsidianJson } from "../runner/cli.ts"; +import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; +import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; +import { obsidianRemoteDebuggingPort, 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-nav.md"; +const revisions = ["Version one alpha", "Version two beta keyword", "Version three gamma"]; + +type RevisionInfo = { + revCount: number; + id: string; +}; + +type OpenHistoryResult = { + opened: boolean; + modalTitle: string | null; +}; + +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 { + 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 configureMinimalLocalMode(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + await evalObsidianJson<{ isConfigured: boolean }>( + cliBinary, + [ + "(async()=>{", + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "await core.services.setting.applyExternalSettings({", + "liveSync:false,", + "syncOnStart:false,", + "syncOnSave:false,", + "isConfigured:true,", + "doctorProcessedVersion:'0.25.27',", + "},true);", + "await core.services.control.applySettings();", + "const current=core.services.setting.currentSettings();", + "return JSON.stringify({isConfigured:current.isConfigured});", + "})()", + ].join(""), + env + ); +} + +async function seedRevisions(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + `const revisions=${JSON.stringify(revisions)};`, + "const core=app.plugins.plugins['obsidian-livesync'].core;", + "const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));", + "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 id=await core.services.path.path2id(path);", + "let baseRev='';", + "for(const content of revisions){", + " const blob=new Blob([content],{type:'text/plain'});", + " const now=Date.now();", + " 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,baseRev||undefined);", + " if(!result?.ok) throw new Error(`Could not store revision for ${path}`);", + " baseRev=result.rev;", + " await sleep(100);", + "}", + `await app.vault.create(path,revisions[revisions.length-1]);`, + "await core.services.fileProcessing.commitPendingFileEvents();", + "const raw=await core.localDatabase.getRaw(id,{revs_info:true});", + "const revCount=(raw._revs_info||[]).filter((e)=>e&&e.status==='available').length;", + "return JSON.stringify({revCount,id});", + "})()", + ].join(""), + env + ); +} + +async function openDocumentHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + `const path=${JSON.stringify(notePath)};`, + "const file=app.vault.getAbstractFileByPath(path);", + "if(!file) throw new Error('Note missing before opening history');", + "document.querySelectorAll('.modal-close-button').forEach((btn)=>btn.click());", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "const leaf=app.workspace.getLeaf(false);", + "await leaf.openFile(file);", + "await new Promise((resolve)=>setTimeout(resolve,300));", + "await app.commands.executeCommandById('obsidian-livesync:livesync-history');", + "await new Promise((resolve)=>setTimeout(resolve,500));", + "const modal=document.querySelector('.modal-container .modal-title');", + "return JSON.stringify({opened:!!modal,modalTitle:modal?modal.textContent:null});", + "})()", + ].join(""), + env + ); +} + +async function main(): Promise { + 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 port = obsidianRemoteDebuggingPort(); + + const screenshotDir = process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ?? "/opt/cursor/artifacts/screenshots/document-history-nav"; + const reportPath = process.env.E2E_OBSIDIAN_HISTORY_REPORT ?? join(screenshotDir, "report.txt"); + + async function captureStep(page: import("playwright").Page, step: string): Promise { + await mkdir(screenshotDir, { recursive: true }); + const path = join(screenshotDir, `${step}.png`); + await page.screenshot({ path, fullPage: true }); + console.log(`Screenshot: ${path}`); + return path; + } + + 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), + }); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + await dismissWelcomeWizard(port); + await configureMinimalLocalMode(cli.binary, session.cliEnv); + await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + + const revisionInfo = await seedRevisions(cli.binary, session.cliEnv); + console.log(`Seeded local history: ${revisionInfo.revCount} revisions for ${revisionInfo.id}`); + assertTrue(revisionInfo.revCount >= 2, `Expected at least 2 revisions, got ${revisionInfo.revCount}`); + + const opened = await openDocumentHistory(cli.binary, session.cliEnv); + assertEqual(opened.opened, true, "Document History modal did not open."); + assertEqual(opened.modalTitle, "Document History", "Unexpected modal title."); + + const report = await withObsidianPage(port, async (page) => { + const modal = page.locator(".modal-container").filter({ hasText: "Document History" }); + await modal.waitFor({ state: "visible", timeout: 10000 }); + + const revNavRow = modal.locator(".history-rev-nav-row"); + await revNavRow.waitFor({ state: "visible", timeout: 10000 }); + + const indicator = revNavRow.locator(".history-rev-indicator"); + const initialIndicator = (await indicator.innerText()).trim(); + + const prevBtn = revNavRow.locator(".history-rev-nav-btn").first(); + const nextBtn = revNavRow.locator(".history-rev-nav-btn").last(); + const range = revNavRow.locator('input[type="range"]'); + + assertTrue(/Rev \d+\/\d+/.test(initialIndicator), `Unexpected initial indicator: ${initialIndicator}`); + + const initialRange = await range.inputValue(); + + const screenshotPaths: string[] = []; + screenshotPaths.push(await captureStep(page, "01-initial-latest-rev")); + + await prevBtn.click(); + await page.waitForTimeout(1000); + const afterPrevIndicator = (await indicator.innerText()).trim(); + const afterPrevRange = await range.inputValue(); + assertTrue( + Number(afterPrevRange) < Number(initialRange), + `◀ did not move to an older revision. before=${initialRange}, after=${afterPrevRange}` + ); + assertTrue(afterPrevIndicator !== initialIndicator, "◀ did not update Rev indicator."); + screenshotPaths.push(await captureStep(page, "02-after-click-older-rev")); + + await nextBtn.click(); + await page.waitForTimeout(1000); + const afterNextIndicator = (await indicator.innerText()).trim(); + const afterNextRange = await range.inputValue(); + assertEqual(afterNextRange, initialRange, "▶ did not return to the original revision."); + assertEqual(afterNextIndicator, initialIndicator, "▶ did not restore the Rev indicator."); + screenshotPaths.push(await captureStep(page, "03-after-click-newer-rev")); + + const searchInput = modal.locator(".history-search-input"); + await searchInput.fill("keyword"); + await page.waitForTimeout(1500); + + const searchIndicator = modal.locator(".history-search-result-indicator"); + const searchText = (await searchIndicator.innerText()).trim(); + assertTrue(/matches/.test(searchText), `Search indicator did not report matches: ${searchText}`); + screenshotPaths.push(await captureStep(page, "04-after-search-keyword")); + + const searchPrev = modal.locator(".history-search-row button").nth(0); + const searchNext = modal.locator(".history-search-row button").nth(1); + assertTrue(!(await searchPrev.isDisabled()), "Search ▲ should be enabled when matches exist."); + assertTrue(!(await searchNext.isDisabled()), "Search ▼ should be enabled when matches exist."); + + await searchNext.click(); + await page.waitForTimeout(1000); + + const afterSearchIndicator = (await searchIndicator.innerText()).trim(); + assertTrue(/1\/\d+ matches/.test(afterSearchIndicator), `Search navigation failed: ${afterSearchIndicator}`); + screenshotPaths.push(await captureStep(page, "05-after-search-next-match")); + + return [ + `initialIndicator: ${initialIndicator}`, + `afterPrevIndicator: ${afterPrevIndicator}`, + `afterNextIndicator: ${afterNextIndicator}`, + `searchIndicator: ${searchText}`, + `afterSearchIndicator: ${afterSearchIndicator}`, + "", + "Screenshots:", + ...screenshotPaths.map((p) => `- ${p}`), + ].join("\n"); + }); + + await writeFile(reportPath, report, "utf-8"); + console.log(`Document History UI test 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); +}); + From 93bbab4253212edfdb53b6c5fd0ae09878b5fe39 Mon Sep 17 00:00:00 2001 From: SeleiXi Date: Wed, 29 Jul 2026 23:44:37 +0800 Subject: [PATCH 2/2] fix(history): align revision navigation with v1.0 Use the preferred Obsidian element helper and preseed the real-Obsidian scenario before startup. Register the focused test and strengthen revision boundary coverage. --- package.json | 1 + .../DocumentHistory/DocumentHistoryModal.ts | 2 +- .../scripts/document-history-nav.ts | 96 ++++++++++++------- test/e2e-obsidian/scripts/run-focused.ts | 1 + 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/package.json b/package.json index 3d66a4b5..3e085b02 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts", "test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts", "test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts", + "test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts", "test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts", "test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts", "test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts", diff --git a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts index 9ee40fa3..3a173ebd 100644 --- a/src/modules/features/DocumentHistory/DocumentHistoryModal.ts +++ b/src/modules/features/DocumentHistory/DocumentHistoryModal.ts @@ -605,7 +605,7 @@ export class DocumentHistoryModal extends Modal { e.addEventListener("click", () => this.navigateVersion("newer")); }); - this.revNavIndicator = revNavRow.createEl("span", { text: "\u2014" }, (e) => { + this.revNavIndicator = revNavRow.createSpan({ text: "\u2014" }, (e) => { e.addClass("history-rev-indicator"); }); const diffOptionsRow = contentEl.createDiv(""); diff --git a/test/e2e-obsidian/scripts/document-history-nav.ts b/test/e2e-obsidian/scripts/document-history-nav.ts index 680fcca0..9daf0752 100644 --- a/test/e2e-obsidian/scripts/document-history-nav.ts +++ b/test/e2e-obsidian/scripts/document-history-nav.ts @@ -2,9 +2,9 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { evalObsidianJson } from "../runner/cli.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; -import { waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; +import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; -import { obsidianRemoteDebuggingPort, withObsidianPage } from "../runner/ui.ts"; +import { withObsidianPage } from "../runner/ui.ts"; import { createTemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000"; @@ -46,28 +46,6 @@ async function dismissWelcomeWizard(port: number): Promise { }); } -async function configureMinimalLocalMode(cliBinary: string, env: NodeJS.ProcessEnv): Promise { - await evalObsidianJson<{ isConfigured: boolean }>( - cliBinary, - [ - "(async()=>{", - "const core=app.plugins.plugins['obsidian-livesync'].core;", - "await core.services.setting.applyExternalSettings({", - "liveSync:false,", - "syncOnStart:false,", - "syncOnSave:false,", - "isConfigured:true,", - "doctorProcessedVersion:'0.25.27',", - "},true);", - "await core.services.control.applySettings();", - "const current=core.services.setting.currentSettings();", - "return JSON.stringify({isConfigured:current.isConfigured});", - "})()", - ].join(""), - env - ); -} - async function seedRevisions(cliBinary: string, env: NodeJS.ProcessEnv): Promise { return await evalObsidianJson( cliBinary, @@ -142,9 +120,10 @@ async function main(): Promise { const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; - const port = obsidianRemoteDebuggingPort(); - const screenshotDir = process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ?? "/opt/cursor/artifacts/screenshots/document-history-nav"; + const screenshotDir = + process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ?? + join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-nav"); const reportPath = process.env.E2E_OBSIDIAN_HISTORY_REPORT ?? join(screenshotDir, "report.txt"); async function captureStep(page: import("playwright").Page, step: string): Promise { @@ -164,22 +143,40 @@ async function main(): Promise { 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, + }, + localStorageEntries: createE2eObsidianDeviceLocalState(vault.name), }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); - await dismissWelcomeWizard(port); - await configureMinimalLocalMode(cli.binary, session.cliEnv); - await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); + await dismissWelcomeWizard(session.remoteDebuggingPort); const revisionInfo = await seedRevisions(cli.binary, session.cliEnv); console.log(`Seeded local history: ${revisionInfo.revCount} revisions for ${revisionInfo.id}`); - assertTrue(revisionInfo.revCount >= 2, `Expected at least 2 revisions, got ${revisionInfo.revCount}`); + assertEqual(revisionInfo.revCount, revisions.length, "Unexpected number of seeded revisions."); const opened = await openDocumentHistory(cli.binary, session.cliEnv); assertEqual(opened.opened, true, "Document History modal did not open."); assertEqual(opened.modalTitle, "Document History", "Unexpected modal title."); - const report = await withObsidianPage(port, async (page) => { + const report = await withObsidianPage(session.remoteDebuggingPort, async (page) => { const modal = page.locator(".modal-container").filter({ hasText: "Document History" }); await modal.waitFor({ state: "visible", timeout: 10000 }); @@ -199,6 +196,13 @@ async function main(): Promise { const screenshotPaths: string[] = []; screenshotPaths.push(await captureStep(page, "01-initial-latest-rev")); + assertEqual(initialIndicator, "Rev 3/3", "History did not open at the latest revision."); + assertEqual(initialRange, "2", "History slider did not open at the latest revision."); + assertTrue( + !(await prevBtn.isDisabled()), + "Older revision button should be enabled at the latest revision." + ); + assertTrue(await nextBtn.isDisabled(), "Newer revision button should be disabled at the latest revision."); await prevBtn.click(); await page.waitForTimeout(1000); @@ -209,15 +213,33 @@ async function main(): Promise { `◀ did not move to an older revision. before=${initialRange}, after=${afterPrevRange}` ); assertTrue(afterPrevIndicator !== initialIndicator, "◀ did not update Rev indicator."); + assertTrue(!(await prevBtn.isDisabled()), "Older revision button was disabled before the oldest revision."); + assertTrue(!(await nextBtn.isDisabled()), "Newer revision button was not enabled after moving backwards."); screenshotPaths.push(await captureStep(page, "02-after-click-older-rev")); + await prevBtn.click(); + await page.waitForTimeout(1000); + assertEqual(await range.inputValue(), "0", "◀ did not reach the oldest revision."); + assertTrue(await prevBtn.isDisabled(), "Older revision button should be disabled at the oldest revision."); + assertTrue( + !(await nextBtn.isDisabled()), + "Newer revision button should be enabled at the oldest revision." + ); + screenshotPaths.push(await captureStep(page, "03-at-oldest-rev")); + + await nextBtn.click(); + await page.waitForTimeout(1000); await nextBtn.click(); await page.waitForTimeout(1000); const afterNextIndicator = (await indicator.innerText()).trim(); const afterNextRange = await range.inputValue(); assertEqual(afterNextRange, initialRange, "▶ did not return to the original revision."); assertEqual(afterNextIndicator, initialIndicator, "▶ did not restore the Rev indicator."); - screenshotPaths.push(await captureStep(page, "03-after-click-newer-rev")); + assertTrue( + await nextBtn.isDisabled(), + "Newer revision button should be disabled after returning to latest." + ); + screenshotPaths.push(await captureStep(page, "04-after-click-newer-rev")); const searchInput = modal.locator(".history-search-input"); await searchInput.fill("keyword"); @@ -226,7 +248,7 @@ async function main(): Promise { const searchIndicator = modal.locator(".history-search-result-indicator"); const searchText = (await searchIndicator.innerText()).trim(); assertTrue(/matches/.test(searchText), `Search indicator did not report matches: ${searchText}`); - screenshotPaths.push(await captureStep(page, "04-after-search-keyword")); + screenshotPaths.push(await captureStep(page, "05-after-search-keyword")); const searchPrev = modal.locator(".history-search-row button").nth(0); const searchNext = modal.locator(".history-search-row button").nth(1); @@ -237,8 +259,11 @@ async function main(): Promise { await page.waitForTimeout(1000); const afterSearchIndicator = (await searchIndicator.innerText()).trim(); - assertTrue(/1\/\d+ matches/.test(afterSearchIndicator), `Search navigation failed: ${afterSearchIndicator}`); - screenshotPaths.push(await captureStep(page, "05-after-search-next-match")); + assertTrue( + /1\/\d+ matches/.test(afterSearchIndicator), + `Search navigation failed: ${afterSearchIndicator}` + ); + screenshotPaths.push(await captureStep(page, "06-after-search-next-match")); return [ `initialIndicator: ${initialIndicator}`, @@ -266,4 +291,3 @@ main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); }); - diff --git a/test/e2e-obsidian/scripts/run-focused.ts b/test/e2e-obsidian/scripts/run-focused.ts index a46bc515..957d6fd8 100644 --- a/test/e2e-obsidian/scripts/run-focused.ts +++ b/test/e2e-obsidian/scripts/run-focused.ts @@ -9,6 +9,7 @@ const focusedScenarios = new Set([ "onboarding-invitation", "dialog-mounts", "revision-repair", + "document-history-nav", "settings-ui", "review-harness", "p2p-pane",