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); + } }); 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); 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"] }); 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/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`, ); 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 }]]);