From 994f2b84e4ddde45f77de63da9e4ad4c823bee75 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 9 Oct 2026 11:34:15 +0200 Subject: [PATCH 1/5] test(e2e): draw menus in the page and render the test window as in front On macOS, Obsidian shows native OS menus unless the vault turns them off, so specs that measure or click the New choice menu found no .menu. A test instance is also usually covered by other windows, which stops animation frames, so transitions and scroll-driven animations never applied. The setup now turns native menus off in memory, restoring the vault's setting so a later config save cannot persist it, and turns on CDP focus emulation before each test. The popout save spec turns emulation off for its hidden-window case, which needs throttled timers. --- tests/e2e/settings-popout-save.test.ts | 5 ++++- tests/e2e/setup.ts | 19 ++++++++++++++++--- tests/e2e/uiHelpers.ts | 10 ++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/e2e/settings-popout-save.test.ts b/tests/e2e/settings-popout-save.test.ts index 0ffcf7d6e..2c58b08c0 100644 --- a/tests/e2e/settings-popout-save.test.ts +++ b/tests/e2e/settings-popout-save.test.ts @@ -4,7 +4,7 @@ import { expect, it } from "vitest"; import type IChoice from "../../src/types/choices/IChoice"; import { TemplateChoice } from "../../src/types/choices/TemplateChoice"; import { createQuickAddE2EHarness } from "./e2eVault"; -import { setVaultConfig } from "./uiHelpers"; +import { setFocusEmulation, setVaultConfig } from "./uiHelpers"; const getContext = createQuickAddE2EHarness("settings-popout-save"); @@ -20,6 +20,9 @@ for (const mainWindow of ["visible", "hidden"] as const) { const dataPath = path.join(await obsidian.vaultPath(), ".obsidian/plugins/quickadd/data.json"); const onDisk = () => (JSON.parse(readFileSync(dataPath, "utf8")).choices as IChoice[]).map((choice) => choice.name); + // The test setup renders the main window as the window in front, which + // would keep its timers running at full speed while hidden. + if (mainWindow === "hidden") await setFocusEmulation(obsidian, false); const popout = await obsidian.dev.evalJson("app.vault.getConfig('settingsPopoutWindow') ?? true"); try { // Reorder by a native mouse drag in the popout, and close it 300 ms after the drop. diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts index 3d06858ec..07ec56f5f 100644 --- a/tests/e2e/setup.ts +++ b/tests/e2e/setup.ts @@ -1,6 +1,6 @@ import { beforeEach } from "vitest"; import { createQuickAddObsidianClient } from "./e2eVault"; -import { takeInputLog } from "./uiHelpers"; +import { setFocusEmulation, takeInputLog } from "./uiHelpers"; const obsidian = createQuickAddObsidianClient(); @@ -8,10 +8,23 @@ beforeEach(async (ctx) => { // Notices outlive the spec that raised them. They stack from the top-right // corner over any modal, so a busy earlier spec can cover a button this test // clicks, or leave a notice this test asserts on. - await obsidian.dev.evalJson(`(() => { + // + // On macOS, Obsidian shows menus as native OS menus unless the vault turns + // them off, so a spec finds no `.menu` to measure or click. Draw them in the + // page as on Linux and Windows. The vault's setting is put back, so a later + // config save cannot write this one to a real vault (#2207). + const inFront = await obsidian.dev.evalJson(`(() => { for (const notice of document.querySelectorAll(".notice")) notice.remove(); - return true; + const nativeMenus = app.vault.config.nativeMenus; + app.vault.config.nativeMenus = false; + app.updateUseNativeMenu(); + app.vault.config.nativeMenus = nativeMenus; + return document.visibilityState === "visible" && document.hasFocus(); })()`); + // Without this, transitions and scroll-driven animations never apply on a + // covered window. A spec about a backgrounded window turns it off. Sent + // only when needed, since under load a CLI call can lose its reply. + if (!inFront) await setFocusEmulation(obsidian, true); takeInputLog(); ctx.onTestFailed(() => { const log = takeInputLog(); diff --git a/tests/e2e/uiHelpers.ts b/tests/e2e/uiHelpers.ts index 3403ca262..f7d5c5661 100644 --- a/tests/e2e/uiHelpers.ts +++ b/tests/e2e/uiHelpers.ts @@ -93,6 +93,16 @@ async function sendInput(obsidian: ObsidianClient, label: string, target: string } } +/** + * Renders the main window as the window in front, or stops doing so. A window + * covered by other windows, as a test instance on macOS usually is, gets no + * animation frames and throttled timers. An app reload turns this off. + */ +export async function setFocusEmulation(obsidian: ObsidianClient, enabled: boolean) { + const reply = await obsidian.execText("dev:cdp", { method: "Emulation.setFocusEmulationEnabled", params: JSON.stringify({ enabled }) }); + if (!reply.startsWith("{")) throw new Error(`dev:cdp Emulation.setFocusEmulationEnabled failed: ${reply}`); +} + /** Inserts text at the focused element, the way an IME commits it. */ export async function insertText(obsidian: ObsidianClient, text: string) { await sendInput(obsidian, `insertText ${JSON.stringify(text)}`, INPUT_TARGET, [["Input.insertText", { text }]]); From 67221710075a9b924a8aa54f554acef746ca21f3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 9 Oct 2026 11:34:15 +0200 Subject: [PATCH 2/5] test(e2e): allow a sub-pixel overhang when checking the import result is in view Scrolling snaps to device pixels, so the summary scrolled flush with the body overhangs it by 0.07px on a 2x display and the strict check failed. --- tests/e2e/package-import-result.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/package-import-result.test.ts b/tests/e2e/package-import-result.test.ts index e009e4105..a60e0a2bc 100644 --- a/tests/e2e/package-import-result.test.ts +++ b/tests/e2e/package-import-result.test.ts @@ -56,8 +56,10 @@ it("shows the import result in view, with a single Close", async () => { if (!summary) return null; const body = document.querySelector(".qa-package-body").getBoundingClientRect(); const rect = summary.getBoundingClientRect(); + // Scrolling snaps to device pixels, so a summary scrolled flush with the + // edge can overhang it by a fraction of one. return { - inView: rect.top >= body.top && rect.bottom <= body.bottom, + inView: rect.top >= body.top - 0.5 && rect.bottom <= body.bottom + 0.5, buttons: Array.from(document.querySelectorAll(".qa-package-dialog .modal-button-container button"), (b) => b.textContent.trim()), }; })()`), POLL_OPTS).toEqual({ inView: true, buttons: ["Close"] }); From 31bd293bc085659b87d4071c74dbf01b85d1d7f8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 9 Oct 2026 11:34:15 +0200 Subject: [PATCH 3/5] test(e2e): measure Submit's focus ring after its transition finishes The ring grows from 0 to 3px over 0.15s, so a read right after Tab could see no ring and fail. --- tests/e2e/one-page-pinned-actions.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/one-page-pinned-actions.test.ts b/tests/e2e/one-page-pinned-actions.test.ts index 48ff18fb4..7ad00937c 100644 --- a/tests/e2e/one-page-pinned-actions.test.ts +++ b/tests/e2e/one-page-pinned-actions.test.ts @@ -96,6 +96,8 @@ it.each(["desktop", "is-phone"])("keeps Submit in view on a form taller than the // footer doesn't clip its focus ring. await typeInto(obsidian, ".onePageInputModal textarea", "Pinned"); await pressKey(obsidian, "Tab"); + // The ring grows in over a transition; measure it at its full size. + await obsidian.dev.evalJsonAsync(`Promise.all(document.activeElement.getAnimations().map((animation) => animation.finished)).then(() => true)`); const focused = await layout(); expect(focused.focused).toBe('button.mod-cta "Submit"'); expect(focused.ringFits).toBe(true); From 01613ed912346a0e9e4eea32a41695d3fa76143a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 9 Oct 2026 11:34:15 +0200 Subject: [PATCH 4/5] test(e2e): restore the setting the own-saves test changes It flipped showCaptureNotification through the plugin's own save and never flipped it back. The harness only restores data.json after a write through plugin.data(), which would count as an external change here. --- tests/e2e/external-settings-change.test.ts | 24 ++++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/e2e/external-settings-change.test.ts b/tests/e2e/external-settings-change.test.ts index 1e34e6d06..9a4ad58f7 100644 --- a/tests/e2e/external-settings-change.test.ts +++ b/tests/e2e/external-settings-change.test.ts @@ -229,16 +229,22 @@ it("does not treat its own saves as external changes", async () => { const { obsidian, plugin } = getContext(); const before = await plugin.data().read(); await obsidian.dev.evalJson(COUNT_EXTERNAL_CHANGES); - - await obsidian.dev.evalJson(`(() => { + const saveShowCaptureNotification = (value: boolean) => obsidian.dev.evalJsonAsync(`(() => { const tab = app.setting.pluginTabs.find((t) => t.id === "quickadd"); - tab.setControlValue("showCaptureNotification", ${!before.showCaptureNotification}); - return true; + tab.setControlValue("showCaptureNotification", ${value}); + return app.plugins.plugins.quickadd.saveSettings().then(() => true); })()`); - await obsidian.dev.evalJsonAsync("app.plugins.plugins.quickadd.saveSettings().then(() => true)"); - expect((await plugin.data().read()).showCaptureNotification).toBe(!before.showCaptureNotification); - // Obsidian debounces its file watcher; give it well past that to call in. - await new Promise((resolve) => setTimeout(resolve, 2_500)); - expect((await obsidian.dev.evalJson(LIVE_STATE)).externalChanges).toBe(0); + // The harness only restores data.json after a write through plugin.data(), + // which would be an external change here, so this test undoes its own save. + try { + await saveShowCaptureNotification(!before.showCaptureNotification); + expect((await plugin.data().read()).showCaptureNotification).toBe(!before.showCaptureNotification); + + // Obsidian debounces its file watcher; give it well past that to call in. + await new Promise((resolve) => setTimeout(resolve, 2_500)); + expect((await obsidian.dev.evalJson(LIVE_STATE)).externalChanges).toBe(0); + } finally { + await saveShowCaptureNotification(before.showCaptureNotification); + } }); From fbf4edb048da1448b0c6013e2640002aa2cecd64 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 9 Oct 2026 11:34:15 +0200 Subject: [PATCH 5/5] test(e2e): disconnect the Templater deprecation spec's notice observer Left connected, it pushed every later notice into __qaNotices, so the choice builder spec that checks a notice shows once saw it twice. --- tests/e2e/templater-rerun-deprecation.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e/templater-rerun-deprecation.test.ts b/tests/e2e/templater-rerun-deprecation.test.ts index 4fd9169f0..21a4bb94f 100644 --- a/tests/e2e/templater-rerun-deprecation.test.ts +++ b/tests/e2e/templater-rerun-deprecation.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { afterEach, expect, it } from "vitest"; import { CaptureChoice } from "../../src/types/choices/CaptureChoice"; import { MacroChoice } from "../../src/types/choices/MacroChoice"; import { TemplateChoice } from "../../src/types/choices/TemplateChoice"; @@ -26,6 +26,11 @@ async function watchNotices() { })()`); } +// A connected observer would keep filling `__qaNotices` for every later spec. +afterEach(async () => { + await getContext().obsidian.dev.evalJson("window.__qaNoticeObserver?.disconnect(), true"); +}); + const noticesContaining = (text: string) => getContext().obsidian.dev.evalJson( `window.__qaNotices.filter(n => n.includes(${JSON.stringify(text)})).length`, );