diff --git a/tests/e2e/audit-gaps.test.ts b/tests/e2e/audit-gaps.test.ts index 888d125..e638f0b 100644 --- a/tests/e2e/audit-gaps.test.ts +++ b/tests/e2e/audit-gaps.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, WAIT_OPTS } from "./harness"; +import { CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, WAIT_OPTS } from "./harness"; const getContext = createMetaEditE2EHarness("audit-gaps"); @@ -62,8 +62,8 @@ describe("MetaEdit audit gap coverage", () => { if (items.length) break; } const texts = items.map(el => ((el.querySelector(".suggestion-item-text") || el).textContent || "").trim()); - app.workspace.activeModal?.close?.(); - for (const el of Array.from(document.querySelectorAll(".suggestion-container, .suggestion-item"))) el.remove(); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); return texts.filter(t => t.startsWith("#dup")); })() `, diff --git a/tests/e2e/audit-modals.test.ts b/tests/e2e/audit-modals.test.ts index 3f2228a..c2e4940 100644 --- a/tests/e2e/audit-modals.test.ts +++ b/tests/e2e/audit-modals.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; +import { CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; const getContext = createMetaEditE2EHarness("audit-modals"); @@ -25,12 +25,7 @@ const HELPERS = ` }; const itemByName = (root, name) => Array.from(root.querySelectorAll(".setting-item")) .find((el) => el.querySelector(".setting-item-name")?.textContent.trim() === name); - const closeAll = async () => { - app.setting?.close?.(); - document.querySelectorAll(".modal-container .modal-close-button").forEach((b) => b.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - document.querySelectorAll(".modal-container, .suggestion-container, .prompt").forEach((el) => el.remove()); - await sleep(120); - }; + ${CLOSE_ALL_MODALS_JS} `; describe("MetaEdit modal + kanban flows", () => { @@ -41,7 +36,7 @@ describe("MetaEdit modal + kanban flows", () => { ` (async () => { ${HELPERS} - await closeAll(); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const prevEnabled = plugin.settings.AutoProperties.enabled; const prevProps = plugin.settings.AutoProperties.properties; @@ -75,7 +70,7 @@ describe("MetaEdit modal + kanban flows", () => { ` (async () => { ${HELPERS} - await closeAll(); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(notePath)}; let f = app.vault.getAbstractFileByPath(path); @@ -91,11 +86,9 @@ describe("MetaEdit modal + kanban flows", () => { try { const p = plugin.api.autoprop("mood"); const titleEl = await waitFor(".metaedit-ap-prompt-title"); - // Cancel by clicking the modal's close button (the X). - const modalEl = titleEl.closest(".modal-container") || document; - const closeBtn = modalEl.querySelector(".modal-close-button"); - if (closeBtn) closeBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); - else document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + // Cancel the way a user does: Escape through Obsidian's keymap + // scope (1.13 removed the .modal-close-button affordance). + document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", keyCode: 27, which: 27, bubbles: true })); const resolved = await p; await sleep(150); return { resolved, content: await app.vault.read(f), before }; @@ -118,7 +111,7 @@ describe("MetaEdit modal + kanban flows", () => { ` (async () => { ${HELPERS} - await closeAll(); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const root = await openTab(); const item = itemByName(root, "Progress Properties"); @@ -155,23 +148,33 @@ describe("MetaEdit modal + kanban flows", () => { expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - test("PROMPT-02: a date-typed YAML property opens a native date picker in the prompt", async () => { + // PROMPT-02: a date-typed YAML property opens Obsidian's native date widget. + // Since PR #168 a top-level YAML property is edited in the + // NativePropertyPrompt, so the date affordance is the native widget's + // input[type=date] - the legacy .metaEditPromptInput date picker no longer + // participates in this flow. + test("PROMPT-02: a date-typed YAML property opens a native date input in the edit prompt", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("date-prop.md"); - const result = await evalJsonAsync<{ inputType: string; hasDateClass: boolean }>( + const result = await evalJsonAsync<{ + inputType: string | null; + hasDateClass: boolean; + legacyPromptOpened: boolean; + modalsAfter: number; + }>( obsidian, ` (async () => { ${HELPERS} const itemText = (item) => ((item.querySelector(".suggestion-item-text") || item).textContent || "").trim(); - await closeAll(); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(notePath)}; let f = app.vault.getAbstractFileByPath(path); const body = "---\\ndue: 2026-01-01\\n---\\nbody\\n"; if (f) { await app.vault.modify(f, body); } else { f = await app.vault.create(path, body); } await sleep(300); - // Assign Obsidian's "date" property type so getDateInputType returns "date". + // Assign Obsidian's "date" property type so the native prompt mounts the date widget. app.metadataTypeManager.setType("due", "date"); await sleep(200); @@ -179,16 +182,25 @@ describe("MetaEdit modal + kanban flows", () => { const row = await waitFor(".suggestion-item", (i) => itemText(i).startsWith("due")); row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); row.dispatchEvent(new MouseEvent("click", { bubbles: true })); - const input = await waitFor(".metaEditPromptInput"); - const inputType = input.getAttribute("type"); - const hasDateClass = input.classList.contains("mod-date"); - await closeAll(); - return { inputType, hasDateClass }; + const host = await waitFor(".metaedit-native-property-host"); + const input = host.querySelector("input"); + const inputType = input?.getAttribute("type") ?? null; + const hasDateClass = input?.classList.contains("mod-date") ?? false; + const legacyPromptOpened = Boolean(document.querySelector(".metaEditPromptInput")); + await closeAllModals(); + return { + inputType, + hasDateClass, + legacyPromptOpened, + modalsAfter: document.querySelectorAll(".modal-container").length, + }; })() `, ); expect(result.inputType).toBe("date"); expect(result.hasDateClass).toBe(true); + expect(result.legacyPromptOpened).toBe(false); + expect(result.modalsAfter).toBe(0); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); @@ -200,7 +212,7 @@ describe("MetaEdit modal + kanban flows", () => { ` (async () => { ${HELPERS} - await closeAll(); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(boardPath)}; let f = app.vault.getAbstractFileByPath(path); diff --git a/tests/e2e/audit-settings.test.ts b/tests/e2e/audit-settings.test.ts index 05455c6..2ed65d1 100644 --- a/tests/e2e/audit-settings.test.ts +++ b/tests/e2e/audit-settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; +import { CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; const getContext = createMetaEditE2EHarness("audit-settings"); @@ -290,8 +290,8 @@ describe("MetaEdit settings tab", () => { opt.dispatchEvent(new MouseEvent("click", { bubbles: true })); } await sleep(300); - app.workspace.activeModal?.close?.(); - for (const el of Array.from(document.querySelectorAll(".suggestion-container, .suggestion-item, .prompt"))) el.remove(); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); return { suggestValues: captured ?? [], captured: captured !== null }; } finally { plugin.controller.createNewYamlPropertyFluid = orig; diff --git a/tests/e2e/audit-suggester.test.ts b/tests/e2e/audit-suggester.test.ts index e48970b..394d496 100644 --- a/tests/e2e/audit-suggester.test.ts +++ b/tests/e2e/audit-suggester.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; +import { CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; const getContext = createMetaEditE2EHarness("audit-suggester"); @@ -29,11 +29,7 @@ const HELPERS = ` input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true })); return input; }; - const closeModals = () => { - app.workspace.activeModal?.close?.(); - for (const b of Array.from(document.querySelectorAll(".modal-close-button"))) b.dispatchEvent(new MouseEvent("click", { bubbles: true })); - for (const el of Array.from(document.querySelectorAll(".suggestion-container, .suggestion-item, .prompt"))) el.remove(); - }; + ${CLOSE_ALL_MODALS_JS} `; describe("MetaEdit Edit Meta suggester flows", () => { @@ -45,7 +41,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { ` (async () => { ${HELPERS} - closeModals(); await sleep(150); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(notePath)}; let f = app.vault.getAbstractFileByPath(path); @@ -72,7 +68,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { await sleep(150); Array.from(modal.querySelectorAll("button")).find((b) => b.textContent.trim() === "Add").click(); await waitFor("body", () => (app.metadataCache.getFileCache(f)?.frontmatter ?? {}).freshKey === "freshValue"); - closeModals(); + await closeAllModals(); await sleep(100); return await app.vault.read(f); })() @@ -91,7 +87,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { ` (async () => { ${HELPERS} - closeModals(); await sleep(150); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(notePath)}; let f = app.vault.getAbstractFileByPath(path); @@ -118,7 +114,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { ` (async () => { ${HELPERS} - closeModals(); await sleep(150); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(notePath)}; let f = app.vault.getAbstractFileByPath(path); @@ -138,7 +134,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { await waitFor("body", () => !("deleteMe" in (app.metadataCache.getFileCache(f)?.frontmatter ?? {}))); await sleep(200); const modalOpen = !!document.querySelector(".suggestion-container .suggestion-item"); - closeModals(); + await closeAllModals(); return { content: await app.vault.read(f), modalOpen }; })() `, @@ -158,7 +154,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { ` (async () => { ${HELPERS} - closeModals(); await sleep(150); + await closeAllModals(); const plugin = app.plugins.plugins.${PLUGIN_ID}; const path = ${JSON.stringify(notePath)}; let f = app.vault.getAbstractFileByPath(path); @@ -177,7 +173,7 @@ describe("MetaEdit Edit Meta suggester flows", () => { // Transform deletes the YAML key, then appends the inline field. await waitFor("body", () => !("mover" in (app.metadataCache.getFileCache(f)?.frontmatter ?? {}))); await sleep(250); - closeModals(); + await closeAllModals(); await sleep(100); return await app.vault.read(f); })() diff --git a/tests/e2e/auto-properties.test.ts b/tests/e2e/auto-properties.test.ts index f6d96f1..2ff6e90 100644 --- a/tests/e2e/auto-properties.test.ts +++ b/tests/e2e/auto-properties.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, @@ -77,7 +78,8 @@ async function runAutoPropertyEdit( return { content, choices: saved ? saved.choices : undefined }; } finally { // Make sure no modal is left open to leak into the next test. - document.querySelectorAll(".modal-close-button").forEach((b) => b.click()); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); plugin.settings.AutoProperties = snapshot.auto; plugin.settings.EditMode.mode = snapshot.mode; await plugin.saveSettings(); diff --git a/tests/e2e/bulk-metadata.test.ts b/tests/e2e/bulk-metadata.test.ts index 44c3524..656ff67 100644 --- a/tests/e2e/bulk-metadata.test.ts +++ b/tests/e2e/bulk-metadata.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, WAIT_OPTS } from "./harness"; +import { CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, WAIT_OPTS } from "./harness"; const getContext = createMetaEditE2EHarness("bulk-metadata"); @@ -107,7 +107,8 @@ describe("MetaEdit bulk metadata edit", () => { return { content: await app.vault.read(file), conflictTitle }; } finally { app.metadataCache.getFileCache = originalGetFileCache; - document.querySelectorAll(".modal-close-button").forEach((button) => button.click()); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); } })() `, diff --git a/tests/e2e/fluid-create.test.ts b/tests/e2e/fluid-create.test.ts index 3f87c04..1a2f367 100644 --- a/tests/e2e/fluid-create.test.ts +++ b/tests/e2e/fluid-create.test.ts @@ -1,5 +1,5 @@ import {describe, expect, test} from "vitest"; -import {createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID} from "./harness"; +import {CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID} from "./harness"; const getContext = createMetaEditE2EHarness("fluid-create"); @@ -132,7 +132,8 @@ describe("MetaEdit fluid property creation", () => { out.coversCancel = dd ? intersects(dd.getBoundingClientRect(), cancelBtn.getBoundingClientRect()) : true; cancel(modal); await promise; - document.querySelectorAll(".suggestion-container").forEach(e => e.remove()); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); return out; })() `, diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index fcb3d49..917d89a 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -5,6 +5,56 @@ export const PLUGIN_ID = "metaedit"; // Reused by tests for sandbox content polling; also the harness reload interval. export const WAIT_OPTS = { timeoutMs: 15_000, intervalMs: 200 }; +/** + * In-app cleanup helper (interpolate into an eval body): closes every open + * modal/suggester the way a user does - Escape routed through Obsidian's + * keymap scope. Obsidian 1.13 removed the `.modal-close-button` DOM + * affordance, which silently turned class-targeted close clicks into no-ops; + * Escape is registered by `Modal`/`SuggestModal` themselves and survives DOM + * redesigns. Going through the real close path (never `el.remove()`) runs + * `onClose`, so pending `waitForClose` promises resolve and keymap scopes pop + * instead of leaking into the next test. + * + * One Escape closes one layer (an open suggest popover before its modal), so + * the loop presses per remaining layer, bounded. If Escape cannot drain the + * stack, `app.workspace.activeModal?.close()` is the lifecycle-aware fallback; + * anything still open after that throws with diagnostics rather than being + * hidden by DOM removal. + */ +export const CLOSE_ALL_MODALS_JS = String.raw` +async function closeAllModals() { + const settle = () => { + const {promise, resolve} = Promise.withResolvers(); + setTimeout(resolve, 60); + return promise; + }; + const openLayers = () => + document.querySelectorAll(".modal-container").length + + document.querySelectorAll(".suggestion-container").length; + const pressEscape = () => { + document.body.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", code: "Escape", keyCode: 27, which: 27, bubbles: true, + })); + }; + app.setting?.close?.(); + for (let round = 0; round < 10 && openLayers() > 0; round++) { + pressEscape(); + await settle(); + } + if (openLayers() > 0) { + app.workspace.activeModal?.close?.(); + await settle(); + } + if (openLayers() > 0) { + const leftovers = Array.from( + document.querySelectorAll(".modal-container, .suggestion-container"), + (el) => el.className, + ); + throw new Error("closeAllModals: still open after Escape + activeModal.close(): " + JSON.stringify(leftovers)); + } +} +`; + /** * Suite-scoped MetaEdit E2E harness built on obsidian-e2e's shared * `createPluginHarness`: one vault lock + sandbox + reload per file, per-test @@ -37,6 +87,14 @@ export const createMetaEditE2EHarness = createPluginHarness({ // provisioned dev vault symlinks all three artifacts. symlinkArtifacts: ["main.js", "manifest.json", "styles.css"], captureOnFailure: true, + // Failure-safe modal teardown: runs before EVERY per-test data restore, + // including after a failed test whose in-body cleanup never ran. Without + // this, a stuck prompt/suggester cascades modal-count-leak assertion + // failures into later tests and files (#184). + beforeDataRestore: (obsidian) => + obsidian.dev.evalJsonAsync( + `(async () => { ${CLOSE_ALL_MODALS_JS} await closeAllModals(); })()`, + ), }); /** @@ -51,3 +109,47 @@ export function evalJsonAsync( ): Promise { return obsidian.dev.evalJsonAsync(code); } + +/** + * Create (or replace) a vault file with `content` and wait for the metadata + * cache to index it - including its frontmatter when the content carries a + * `---` block - so a test can immediately parse/edit its properties. + */ +export async function writeLiveFile( + obsidian: ObsidianClient, + path: string, + content: string, +): Promise { + const needsFrontmatter = content.startsWith("---"); + await evalJsonAsync( + obsidian, + ` + (async () => { + const path = ${JSON.stringify(path)}; + const content = ${JSON.stringify(content)}; + const parts = path.split("/"); + let current = ""; + for (const part of parts.slice(0, -1)) { + current = current ? current + "/" + part : part; + if (!app.vault.getAbstractFileByPath(current)) { + try { + await app.vault.createFolder(current); + } catch (error) { + if (!String(error.message).includes("Folder already exists")) throw error; + } + } + } + const existing = app.vault.getAbstractFileByPath(path); + if (existing) await app.vault.delete(existing); + await app.vault.create(path, content); + for (let i = 0; i < 40; i++) { + const cache = app.metadataCache.getFileCache(app.vault.getAbstractFileByPath(path)); + if (cache && (!${needsFrontmatter} || cache.frontmatter)) break; + const {promise, resolve} = Promise.withResolvers(); + setTimeout(resolve, 50); + await promise; + } + })() + `, + ); +} diff --git a/tests/e2e/metaedit-runtime.test.ts b/tests/e2e/metaedit-runtime.test.ts index 918d211..17abbc9 100644 --- a/tests/e2e/metaedit-runtime.test.ts +++ b/tests/e2e/metaedit-runtime.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + CLOSE_ALL_MODALS_JS, createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, @@ -611,14 +612,8 @@ describe("MetaEdit runtime", () => { if (!nestedItem) throw new Error("Nested attributes.one row was not rendered."); const nestedButtonCount = nestedItem.querySelectorAll("button").length; - app.workspace.activeModal?.close?.(); - for (const button of Array.from(document.querySelectorAll(".modal-close-button"))) { - button.dispatchEvent(new MouseEvent("click", { bubbles: true })); - } - for (const element of Array.from(document.querySelectorAll(".suggestion-container, .suggestion-item"))) { - element.remove(); - } - await sleep(50); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); return { keys, @@ -648,10 +643,8 @@ describe("MetaEdit runtime", () => { throw new Error("Timed out waiting for " + selector); }; - for (const button of Array.from(document.querySelectorAll(".modal-close-button"))) { - button.dispatchEvent(new MouseEvent("click", { bubbles: true })); - } - await sleep(100); + ${CLOSE_ALL_MODALS_JS} + await closeAllModals(); const props = await plugin.controller.getPropertiesInFile(file); const property = props.find((prop) => prop.key === "attributes.one"); diff --git a/tests/e2e/multi-value.test.ts b/tests/e2e/multi-value.test.ts index 447db74..6e1c07c 100644 --- a/tests/e2e/multi-value.test.ts +++ b/tests/e2e/multi-value.test.ts @@ -1,15 +1,26 @@ import { describe, expect, test } from "vitest"; -import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID } from "./harness"; +import { createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, writeLiveFile } from "./harness"; +import { NATIVE_PROMPT_HELPERS_JS } from "./nativePromptHelpers"; const getContext = createMetaEditE2EHarness("multi-value"); // Live regressions for the multi-value / array editing cluster (#94, #51, #31, #36). -// These drive the real Obsidian write path (processFrontMatter / stringifyYaml) and, -// for the command flow, the real suggester + prompt UI. +// +// Since PR #168, `editMetaElement` routes eligible top-level YAML properties +// (Auto Properties are intercepted first) to the +// NativePropertyPrompt (Obsidian's own widgets), so the cluster's invariants - +// a list stays a native list, elements with commas or command-lookalike values +// survive, a scalar stays a scalar - are asserted through that prompt, driving +// the widget's real interactions (in-place pill edit, pill add, text edit). +// The legacy GenericSuggester + GenericPrompt multi-value editor still owns +// inline Dataview fields; the final four UI tests pin that surviving surface. +// (#184 note: the prompt/suggester UI renders fine in the headless instance - +// the earlier tests were simply waiting for a modal this flow no longer opens.) describe("MetaEdit multi-value editing", () => { - // #94: in the DEFAULT All Single mode, editing a YAML `tags` block list must keep - // it a native list - not collapse it to a comma-joined string. - test("edits a YAML tags list in All Single mode and keeps it a native list (#94)", async () => { + // #94: editing one element of a YAML `tags` block list must keep it a native + // list - not collapse it to a comma-joined string. All Single was the mode + // the original bug reproduced under; top-level YAML must ignore it entirely. + test("edits a YAML tags list element in the native prompt and keeps it a native list (#94)", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("tags-list.md"); await writeLiveFile( @@ -18,13 +29,16 @@ describe("MetaEdit multi-value editing", () => { "---\ntags:\n - state/inprogress\n - course-flash/writing-obsidian-plugins\n---\nbody\n", ); - const result = await driveListEdit(obsidian, notePath, { + const result = await driveNativeEdit(obsidian, notePath, { mode: "All Single", key: "tags", - selectText: "state/inprogress", - enterValue: "state/finished", + action: { kind: "editPill", from: "state/inprogress", to: "state/finished" }, }); + expect(result.pills).toEqual([ + "state/finished", + "course-flash/writing-obsidian-plugins", + ]); expect(result.content).toBe( "---\ntags:\n - state/finished\n - course-flash/writing-obsidian-plugins\n---\nbody\n", ); @@ -32,11 +46,12 @@ describe("MetaEdit multi-value editing", () => { "state/finished", "course-flash/writing-obsidian-plugins", ]); + expect(result.sawLegacySuggester).toBe(false); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - // #51: adding another tag to a `tags` list grows the native list. - test("adds a tag to a YAML tags list via Add to end (#51)", async () => { + // #51: adding another tag grows the native list. + test("adds a tag to a YAML tags list via the native prompt (#51)", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("tags-add.md"); await writeLiveFile( @@ -45,11 +60,10 @@ describe("MetaEdit multi-value editing", () => { "---\ntags:\n - cookingQ\n - chicken\n---\nbody\n", ); - const result = await driveListEdit(obsidian, notePath, { + const result = await driveNativeEdit(obsidian, notePath, { mode: "All Single", key: "tags", - selectText: "Add to end", - enterValue: "asia", + action: { kind: "addPill", value: "asia" }, }); expect(result.content).toBe( @@ -59,8 +73,8 @@ describe("MetaEdit multi-value editing", () => { expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - // Editing one element of a list whose elements contain commas must NOT shred the - // other elements - the element-preserving core fix. + // Editing one element of a list whose elements contain commas must NOT shred + // the other elements - the element-preserving core invariant. test("preserves list elements that contain commas", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("comma-list.md"); @@ -70,19 +84,20 @@ describe("MetaEdit multi-value editing", () => { "---\nauthors:\n - Smith, John\n - Doe, Jane\n---\nbody\n", ); - const result = await driveListEdit(obsidian, notePath, { + const result = await driveNativeEdit(obsidian, notePath, { mode: "All Single", key: "authors", - selectText: "Doe, Jane", - enterValue: "Roe, Jane", + type: "multitext", + action: { kind: "editPill", from: "Doe, Jane", to: "Roe, Jane" }, }); + expect(result.pills).toEqual(["Smith, John", "Roe, Jane"]); expect(result.readBack).toEqual(["Smith, John", "Roe, Jane"]); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - // A list element whose value merely contains "cmd" must be editable as a normal - // element - it must not be mistaken for an add/insert command and collapse the list. + // A list element whose value merely contains "cmd" must be editable as a + // normal element - never mistaken for an add/insert command. test("edits a list element whose value contains 'cmd' without collapsing the list", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("cmd-element.md"); @@ -92,90 +107,81 @@ describe("MetaEdit multi-value editing", () => { "---\nscripts:\n - cmd:build\n - test\n---\nbody\n", ); - const result = await driveListEdit(obsidian, notePath, { + const result = await driveNativeEdit(obsidian, notePath, { mode: "All Single", key: "scripts", - selectText: "cmd:build", - enterValue: "cmd:rebuild", + type: "multitext", + action: { kind: "editPill", from: "cmd:build", to: "cmd:rebuild" }, }); expect(result.readBack).toEqual(["cmd:rebuild", "test"]); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - // A YAML scalar in All Single mode still uses the single-line editor (no suggester), - // so the routing change does not regress ordinary scalar edits. - test("keeps a YAML scalar on the single-line editor in All Single mode", async () => { + test("edits a literal add-command sentinel as a normal list element", async () => { + const { obsidian, sandbox } = getContext(); + const notePath = sandbox.path("sentinel-value.md"); + await writeLiveFile( + obsidian, + notePath, + "---\nscripts:\n - cmd:addfirst\n - keep\n---\nbody\n", + ); + + const result = await driveNativeEdit(obsidian, notePath, { + mode: "All Single", + key: "scripts", + type: "multitext", + action: { kind: "editPill", from: "cmd:addfirst", to: "changed" }, + }); + + expect(result.content).toBe("---\nscripts:\n - changed\n - keep\n---\nbody\n"); + expect(result.readBack).toEqual(["changed", "keep"]); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); + + // A top-level YAML scalar gets the native text editor - never the legacy + // value suggester or the legacy prompt. + test("keeps a YAML scalar on the native text editor in All Single mode", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("scalar.md"); await writeLiveFile(obsidian, notePath, "---\nstatus: open\n---\nbody\n"); - const result = await evalJsonAsync<{ content: string; readBack: unknown; suggesterOpened: boolean }>( - obsidian, - ` - (async () => { - const plugin = app.plugins.plugins.${PLUGIN_ID}; - const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); - const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - const waitFor = async (selector) => { - const start = Date.now(); - while (Date.now() - start < 5000) { - const el = document.querySelector(selector); - if (el) return el; - await sleep(80); - } - throw new Error("Timed out waiting for " + selector); - }; - const originalMode = plugin.settings.EditMode.mode; - plugin.settings.EditMode.mode = "All Single"; - try { - const props = await plugin.controller.getPropertiesInFile(file); - const property = props.find((p) => p.key === "status"); - const editPromise = plugin.controller.editMetaElement(property, props, file); - const input = await waitFor(".metaEditPromptInput"); - const suggesterOpened = Boolean(document.querySelector(".suggestion-item")); - input.value = "closed"; - input.dispatchEvent(new Event("input", { bubbles: true })); - input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - await editPromise; - await sleep(300); - return { - content: await app.vault.read(file), - readBack: await plugin.api.getPropertyValue("status", file), - suggesterOpened, - }; - } finally { - plugin.settings.EditMode.mode = originalMode; - } - })() - `, - ); + const result = await driveNativeEdit(obsidian, notePath, { + mode: "All Single", + key: "status", + type: "text", + action: { kind: "setText", value: "closed" }, + }); - expect(result.suggesterOpened).toBe(false); + expect(result.sawLegacySuggester).toBe(false); + expect(result.sawLegacyPrompt).toBe(false); expect(result.content).toBe("---\nstatus: closed\n---\nbody\n"); expect(result.readBack).toBe("closed"); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); + // All Multi must not route a top-level YAML scalar into the legacy multi + // editor (which would flatten it through the suggester flow). test("keeps a YAML scalar scalar when edited through All Multi mode", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("scalar-allmulti.md"); await writeLiveFile(obsidian, notePath, "---\nstatus: open\n---\nbody\n"); - const result = await driveListEdit(obsidian, notePath, { + const result = await driveNativeEdit(obsidian, notePath, { mode: "All Multi", key: "status", - selectText: "open", - enterValue: "closed", + type: "text", + action: { kind: "setText", value: "closed" }, }); + expect(result.sawLegacySuggester).toBe(false); expect(result.content).toBe("---\nstatus: closed\n---\nbody\n"); expect(result.readBack).toBe("closed"); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - // #36: the public API update with an array writes a native YAML list (verifying the - // processFrontMatter write path that already fixed this stays fixed). + // #36: the public API update with an array writes a native YAML list + // (verifying the processFrontMatter write path stays fixed). test("api.update with an array writes a native YAML list (#36)", async () => { const { obsidian, sandbox } = getContext(); const notePath = sandbox.path("api-array.md"); @@ -188,7 +194,9 @@ describe("MetaEdit multi-value editing", () => { const plugin = app.plugins.plugins.${PLUGIN_ID}; const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); await plugin.api.update("foo", ["a", "c"], file); - await new Promise((r) => setTimeout(r, 300)); + const {promise, resolve} = Promise.withResolvers(); + setTimeout(resolve, 300); + await promise; return { content: await app.vault.read(file), readBack: await plugin.api.getPropertyValue("foo", file), @@ -202,31 +210,154 @@ describe("MetaEdit multi-value editing", () => { expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); - test("edits a literal add-command sentinel as a normal list element", async () => { + // The legacy GenericSuggester + GenericPrompt multi-value editor still owns + // inline Dataview fields: pick an element in the suggester, retype it in the + // prompt. This also pins that both legacy modals render headlessly (#184). + test("edits one element of an inline Dataview field through the legacy multi-value editor", async () => { const { obsidian, sandbox } = getContext(); - const notePath = sandbox.path("sentinel-value.md"); + const notePath = sandbox.path("dataview-edit.md"); + await writeLiveFile(obsidian, notePath, "authors:: alpha, beta\nbody\n"); + + const result = await driveLegacyListEdit(obsidian, notePath, { + mode: "All Multi", + key: "authors", + selectText: "beta", + enterValue: "gamma", + }); + + expect(result.content).toContain("authors:: alpha, gamma"); + expect(result.readBack).toBe("alpha, gamma"); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); + + test("adds an element to an inline Dataview field via Add to end", async () => { + const { obsidian, sandbox } = getContext(); + const notePath = sandbox.path("dataview-add.md"); + await writeLiveFile(obsidian, notePath, "authors:: alpha, beta\nbody\n"); + + const result = await driveLegacyListEdit(obsidian, notePath, { + mode: "All Multi", + key: "authors", + selectText: "Add to end", + enterValue: "gamma", + }); + + expect(result.content).toContain("authors:: alpha, beta, gamma"); + expect(result.readBack).toBe("alpha, beta, gamma"); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); + + // The add-command discrimination (VALUE_SELECTION_PREFIX vs the add + // sentinels) only runs in the legacy editor, so guard it where it can still + // regress: an element whose value contains "cmd" is a normal element. + test("edits an inline Dataview element whose value contains 'cmd' as a normal element", async () => { + const { obsidian, sandbox } = getContext(); + const notePath = sandbox.path("dataview-cmd.md"); + await writeLiveFile(obsidian, notePath, "scripts:: cmd:build, test\nbody\n"); + + const result = await driveLegacyListEdit(obsidian, notePath, { + mode: "All Multi", + key: "scripts", + selectText: "cmd:build", + enterValue: "cmd:rebuild", + }); + + expect(result.content).toContain("scripts:: cmd:rebuild, test"); + expect(result.readBack).toBe("cmd:rebuild, test"); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); + + // An element whose value literally equals an add-command sentinel token must + // still resolve to a "replace" edit, never to an add command. + test("edits an inline Dataview element equal to the add-command sentinel as a normal element", async () => { + const { obsidian, sandbox } = getContext(); + const notePath = sandbox.path("dataview-sentinel.md"); await writeLiveFile( obsidian, notePath, - "---\nscripts:\n - cmd:addfirst\n - keep\n---\nbody\n", + "scripts:: metaedit:multi-value:add-first, keep\nbody\n", ); - const result = await driveListEdit(obsidian, notePath, { - mode: "All Single", + const result = await driveLegacyListEdit(obsidian, notePath, { + mode: "All Multi", key: "scripts", - selectText: "cmd:addfirst", + selectText: "metaedit:multi-value:add-first", enterValue: "changed", }); - expect(result.content).toBe("---\nscripts:\n - changed\n - keep\n---\nbody\n"); - expect(result.readBack).toEqual(["changed", "keep"]); + expect(result.content).toContain("scripts:: changed, keep"); + expect(result.readBack).toBe("changed, keep"); expect(await obsidian.dev.runtimeErrors()).toEqual([]); }); }); -// Drive the command edit flow for a list property: open editMetaElement, click a -// suggestion by its text, then type a value into the prompt and submit. -async function driveListEdit( +type NativeEditAction = + | { kind: "editPill"; from: string; to: string } + | { kind: "addPill"; value: string } + | { kind: "setText"; value: string }; + +// Drive the NativePropertyPrompt for a top-level YAML property: open it via +// editMetaElement, apply one real widget interaction, Save, and report the +// resulting file plus whether any legacy modal surface appeared. +// +// `type` (omitted for reserved keys like `tags`, whose widget is fixed) pins +// the key's vault-wide property type (Obsidian's "Property type" menu) so the +// prompt mounts the intended widget in ANY vault: without it the widget +// follows vault-wide state (e.g. a dev vault where `authors` is typed text +// from unrelated notes would mount the text widget). +async function driveNativeEdit( + obsidian: Parameters[0], + notePath: string, + opts: { mode: string; key: string; type?: string; action: NativeEditAction }, +): Promise<{ + content: string; + readBack: unknown; + pills: string[] | null; + sawLegacySuggester: boolean; + sawLegacyPrompt: boolean; +}> { + return await evalJsonAsync( + obsidian, + ` + (async () => { + ${NATIVE_PROMPT_HELPERS_JS} + const plugin = app.plugins.plugins.${PLUGIN_ID}; + const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); + const action = ${JSON.stringify(opts.action)}; + const originalMode = plugin.settings.EditMode.mode; + plugin.settings.EditMode.mode = ${JSON.stringify(opts.mode)}; + if (${JSON.stringify(opts.type ?? null)}) { + app.metadataTypeManager.setType(${JSON.stringify(opts.key)}, ${JSON.stringify(opts.type)}); + await sleep(150); + } + try { + const {promise, host} = await openNative(file, ${JSON.stringify(opts.key)}); + const sawLegacySuggester = Boolean(document.querySelector(".suggestion-item")); + const sawLegacyPrompt = Boolean(document.querySelector(".metaEditPromptInput")); + let pills = null; + if (action.kind === "editPill") pills = await editPillByText(host, action.from, action.to); + else if (action.kind === "addPill") pills = await addPillInHost(host, action.value); + else setContenteditable(host, action.value); + await saveOpenModal(promise); + return { + content: await app.vault.read(file), + readBack: await plugin.api.getPropertyValue(${JSON.stringify(opts.key)}, file), + pills, + sawLegacySuggester, + sawLegacyPrompt, + }; + } finally { + plugin.settings.EditMode.mode = originalMode; + } + })() + `, + ); +} + +// Drive the legacy multi-value flow (inline Dataview fields): editMetaElement +// opens the GenericSuggester, click a suggestion by its text, then type a +// value into the GenericPrompt and submit. +async function driveLegacyListEdit( obsidian: Parameters[0], notePath: string, opts: { mode: string; key: string; selectText: string; enterValue: string }, @@ -235,18 +366,9 @@ async function driveListEdit( obsidian, ` (async () => { + ${NATIVE_PROMPT_HELPERS_JS} const plugin = app.plugins.plugins.${PLUGIN_ID}; const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); - const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - const waitFor = async (selector, predicate = () => true) => { - const start = Date.now(); - while (Date.now() - start < 5000) { - const el = Array.from(document.querySelectorAll(selector)).find(predicate); - if (el) return el; - await sleep(80); - } - throw new Error("Timed out waiting for " + selector); - }; const originalMode = plugin.settings.EditMode.mode; plugin.settings.EditMode.mode = ${JSON.stringify(opts.mode)}; try { @@ -281,40 +403,3 @@ async function driveListEdit( `, ); } - -async function writeLiveFile( - obsidian: Parameters[0], - path: string, - content: string, -): Promise { - await evalJsonAsync( - obsidian, - ` - (async () => { - const path = ${JSON.stringify(path)}; - const content = ${JSON.stringify(content)}; - const parts = path.split("/"); - let current = ""; - for (const part of parts.slice(0, -1)) { - current = current ? current + "/" + part : part; - if (!app.vault.getAbstractFileByPath(current)) { - try { - await app.vault.createFolder(current); - } catch (error) { - if (!String(error.message).includes("Folder already exists")) throw error; - } - } - } - const existing = app.vault.getAbstractFileByPath(path); - if (existing) await app.vault.delete(existing); - await app.vault.create(path, content); - // Wait for the metadata cache to populate the new file's frontmatter. - for (let i = 0; i < 40; i++) { - const cache = app.metadataCache.getFileCache(app.vault.getAbstractFileByPath(path)); - if (cache && cache.frontmatter) break; - await new Promise((r) => setTimeout(r, 50)); - } - })() - `, - ); -} diff --git a/tests/e2e/native-properties.test.ts b/tests/e2e/native-properties.test.ts index 7eda3d8..eef392b 100644 --- a/tests/e2e/native-properties.test.ts +++ b/tests/e2e/native-properties.test.ts @@ -1,5 +1,6 @@ import {describe, expect, test} from "vitest"; -import {createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID} from "./harness"; +import {createMetaEditE2EHarness, evalJsonAsync, PLUGIN_ID, writeLiveFile} from "./harness"; +import {NATIVE_PROMPT_HELPERS_JS} from "./nativePromptHelpers"; const getContext = createMetaEditE2EHarness("native-properties"); @@ -22,7 +23,7 @@ describe("MetaEdit native Obsidian property widgets", () => { obsidian, ` (async () => { - ${NATIVE_HELPERS} + ${NATIVE_PROMPT_HELPERS_JS} const plugin = app.plugins.plugins.${PLUGIN_ID}; const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); app.metadataTypeManager.setType("summary", "text"); @@ -90,7 +91,7 @@ describe("MetaEdit native Obsidian property widgets", () => { obsidian, ` (async () => { - ${NATIVE_HELPERS} + ${NATIVE_PROMPT_HELPERS_JS} const plugin = app.plugins.plugins.${PLUGIN_ID}; const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); @@ -171,7 +172,7 @@ describe("MetaEdit native Obsidian property widgets", () => { ` (async () => { try { - ${NATIVE_HELPERS} + ${NATIVE_PROMPT_HELPERS_JS} const plugin = app.plugins.plugins.${PLUGIN_ID}; const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); app.metadataTypeManager.setType("count", "number"); @@ -229,7 +230,7 @@ describe("MetaEdit native Obsidian property widgets", () => { obsidian, ` (async () => { - ${NATIVE_HELPERS} + ${NATIVE_PROMPT_HELPERS_JS} const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); app.metadataTypeManager.setType("summary", "text"); await sleep(200); @@ -284,227 +285,6 @@ describe("MetaEdit native Obsidian property widgets", () => { }); }); -const NATIVE_HELPERS = String.raw` -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -const waitFor = async (selector, predicate = () => true, timeout = 5000) => { - const start = Date.now(); - while (Date.now() - start < timeout) { - const el = Array.from(document.querySelectorAll(selector)).find(predicate); - if (el) return el; - await sleep(80); - } - throw new Error("Timed out waiting for " + selector); -}; - -const waitForCache = async (file, key) => { - const start = Date.now(); - while (Date.now() - start < 5000) { - const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter; - if (frontmatter && Object.prototype.hasOwnProperty.call(frontmatter, key)) return frontmatter[key]; - await sleep(80); - } - throw new Error("Timed out waiting for cache key " + key); -}; - -const waitForCacheValue = async (file, key, expected) => { - const start = Date.now(); - while (Date.now() - start < 5000) { - const value = app.metadataCache.getFileCache(file)?.frontmatter?.[key]; - if (value === expected) return value; - await sleep(80); - } - throw new Error("Timed out waiting for cache value " + key); -}; - -async function openNative(file, key) { - const plugin = app.plugins.plugins.${PLUGIN_ID}; - const props = await plugin.controller.getPropertiesInFile(file); - const property = props.find((prop) => prop.key === key); - if (!property) throw new Error("Property not found: " + key); - const promise = plugin.controller.editMetaElement(property, props, file); - const host = await waitFor(".metaedit-native-property-host"); - return {promise, host}; -} - -async function saveOpenModal(promise) { - const save = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Save"); - if (!save) throw new Error("Save button not found"); - save.click(); - const result = await Promise.race([ - promise.then(() => "resolved"), - new Promise((resolve) => setTimeout(() => resolve("timeout"), 5000)), - ]); - if (result !== "resolved") { - Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Cancel")?.click(); - throw new Error("Native property modal did not close after Save"); - } - await sleep(250); -} - -function setContenteditable(host, value) { - const input = host.querySelector("[contenteditable='true']"); - if (!input) throw new Error("contenteditable input not found"); - input.focus(); - document.execCommand("selectAll", false); - if (value === "") document.execCommand("delete", false); - else document.execCommand("insertText", false, value); - input.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: value})); - input.dispatchEvent(new Event("blur", {bubbles: true})); -} - -function setNativeInput(host, value) { - const input = host.querySelector("input"); - if (!input) throw new Error("input not found"); - input.focus(); - input.value = value; - input.dispatchEvent(new Event("input", {bubbles: true})); - input.dispatchEvent(new Event("change", {bubbles: true})); - input.dispatchEvent(new Event("blur", {bubbles: true})); -} - -async function editText(file, key, value) { - const {promise, host} = await openNative(file, key); - setContenteditable(host, value); - await saveOpenModal(promise); -} - -async function editInput(file, key, value) { - const {promise, host} = await openNative(file, key); - setNativeInput(host, value); - await saveOpenModal(promise); -} - -async function editCheckbox(file, key, checked) { - const {promise, host} = await openNative(file, key); - const input = host.querySelector("input[type='checkbox']"); - if (!input) throw new Error("checkbox input not found"); - input.focus(); - if (input.checked !== checked) input.click(); - else input.dispatchEvent(new Event("change", {bubbles: true})); - await saveOpenModal(promise); -} - -async function addPill(file, key, value) { - const {promise, host} = await openNative(file, key); - const input = host.querySelector(".multi-select-input,[contenteditable='true']"); - if (!input) throw new Error("multi-select input not found"); - input.focus(); - document.execCommand("insertText", false, value); - input.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true})); - input.dispatchEvent(new KeyboardEvent("keyup", {key: "Enter", bubbles: true})); - await sleep(200); - const pills = Array.from(host.querySelectorAll(".multi-select-pill-content")).map((pill) => pill.textContent); - await saveOpenModal(promise); - return pills; -} - -async function editTextWithWikilinkSuggestion(file, key, targetText) { - const {promise, host} = await openNative(file, key); - const input = host.querySelector("[contenteditable='true']"); - if (!input) throw new Error("text input not found"); - input.focus(); - document.execCommand("selectAll", false); - document.execCommand("insertText", false, "[["); - input.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: "[["})); - input.dispatchEvent(new KeyboardEvent("keyup", {key: "[", bubbles: true})); - const suggestion = await waitFor(".suggestion-item", (el) => (el.textContent || "").includes(targetText)); - const suggestionText = suggestion.textContent?.trim() ?? null; - suggestion.dispatchEvent(new MouseEvent("mousedown", {bubbles: true})); - suggestion.dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); - suggestion.dispatchEvent(new MouseEvent("click", {bubbles: true})); - await sleep(250); - await saveOpenModal(promise); - return suggestionText; -} - -async function addPillWithWikilinkSuggestion(file, key, targetText) { - const {promise, host} = await openNative(file, key); - const input = host.querySelector(".multi-select-input,[contenteditable='true']"); - if (!input) throw new Error("multi-select input not found"); - input.focus(); - document.execCommand("insertText", false, "[["); - input.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: "[["})); - input.dispatchEvent(new KeyboardEvent("keyup", {key: "[", bubbles: true})); - const suggestion = await waitFor(".suggestion-item", (el) => (el.textContent || "").includes(targetText)); - const suggestionText = suggestion.textContent?.trim() ?? null; - suggestion.dispatchEvent(new MouseEvent("mousedown", {bubbles: true})); - suggestion.dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); - suggestion.dispatchEvent(new MouseEvent("click", {bubbles: true})); - await sleep(250); - input.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true})); - input.dispatchEvent(new KeyboardEvent("keyup", {key: "Enter", bubbles: true})); - await sleep(200); - const pills = Array.from(host.querySelectorAll(".multi-select-pill-content")).map((pill) => pill.textContent); - await saveOpenModal(promise); - return {suggestionText, pills}; -} - -function beginCleanupMeasurement() { - const bodyBefore = document.body.children.length; - let adds = 0; - let removes = 0; - const originalAdd = document.addEventListener; - const originalRemove = document.removeEventListener; - document.addEventListener = function(...args) { - adds++; - return originalAdd.apply(this, args); - }; - document.removeEventListener = function(...args) { - removes++; - return originalRemove.apply(this, args); - }; - return { - finish() { - document.addEventListener = originalAdd; - document.removeEventListener = originalRemove; - return { - bodyDelta: document.body.children.length - bodyBefore, - documentListenerDelta: adds - removes, - modalCount: document.querySelectorAll(".modal-container").length, - suggestionCount: document.querySelectorAll(".suggestion-container").length, - }; - } - }; -} -`; - -async function writeLiveFile( - obsidian: Parameters[0], - path: string, - content: string, -): Promise { - await evalJsonAsync( - obsidian, - ` - (async () => { - const path = ${JSON.stringify(path)}; - const content = ${JSON.stringify(content)}; - const parts = path.split("/"); - let current = ""; - for (const part of parts.slice(0, -1)) { - current = current ? current + "/" + part : part; - if (!app.vault.getAbstractFileByPath(current)) { - try { - await app.vault.createFolder(current); - } catch (error) { - if (!String(error.message).includes("Folder already exists")) throw error; - } - } - } - const existing = app.vault.getAbstractFileByPath(path); - if (existing) await app.vault.delete(existing); - await app.vault.create(path, content); - for (let i = 0; i < 40; i++) { - const cache = app.metadataCache.getFileCache(app.vault.getAbstractFileByPath(path)); - if (cache) break; - await new Promise((resolve) => setTimeout(resolve, 50)); - } - })() - `, - ); -} - async function verifyWikilinkSuggestion( obsidian: Parameters[0], notePath: string, @@ -516,7 +296,7 @@ async function verifyWikilinkSuggestion( obsidian, ` (async () => { - ${NATIVE_HELPERS} + ${NATIVE_PROMPT_HELPERS_JS} const file = app.vault.getAbstractFileByPath(${JSON.stringify(notePath)}); app.metadataTypeManager.setType("summary", "text"); await sleep(600); diff --git a/tests/e2e/nativePromptHelpers.ts b/tests/e2e/nativePromptHelpers.ts new file mode 100644 index 0000000..b600432 --- /dev/null +++ b/tests/e2e/nativePromptHelpers.ts @@ -0,0 +1,252 @@ +import { PLUGIN_ID } from "./harness"; + +/** + * In-app drivers for the NativePropertyPrompt (the modal `editMetaElement` + * opens for eligible top-level YAML properties since PR #168): open the prompt, + * drive Obsidian's own property widgets (contenteditable text, native inputs, + * multi-select pills), and save/cancel through the real buttons. Interpolate + * into an eval body; shared by native-properties.test.ts and + * multi-value.test.ts so the two suites cannot drift apart. Real wall-clock + * polling is required here: the code runs inside a live external Obsidian + * process, which fake timers cannot drive. + */ +export const NATIVE_PROMPT_HELPERS_JS = String.raw` +const sleep = (ms) => { + const {promise, resolve} = Promise.withResolvers(); + setTimeout(resolve, ms); + return promise; +}; + +const waitFor = async (selector, predicate = () => true, timeout = 5000) => { + const start = Date.now(); + while (Date.now() - start < timeout) { + const el = Array.from(document.querySelectorAll(selector)).find(predicate); + if (el) return el; + await sleep(80); + } + throw new Error("Timed out waiting for " + selector); +}; + +const waitForCache = async (file, key) => { + const start = Date.now(); + while (Date.now() - start < 5000) { + const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter; + if (frontmatter && Object.prototype.hasOwnProperty.call(frontmatter, key)) return frontmatter[key]; + await sleep(80); + } + throw new Error("Timed out waiting for cache key " + key); +}; + +const waitForCacheValue = async (file, key, expected) => { + const start = Date.now(); + while (Date.now() - start < 5000) { + const value = app.metadataCache.getFileCache(file)?.frontmatter?.[key]; + if (value === expected) return value; + await sleep(80); + } + throw new Error("Timed out waiting for cache value " + key); +}; + +async function openNative(file, key) { + const plugin = app.plugins.plugins.${PLUGIN_ID}; + const props = await plugin.controller.getPropertiesInFile(file); + const property = props.find((prop) => prop.key === key); + if (!property) throw new Error("Property not found: " + key); + const promise = plugin.controller.editMetaElement(property, props, file); + const host = await waitFor(".metaedit-native-property-host"); + return {promise, host}; +} + +async function saveOpenModal(promise) { + const save = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Save"); + if (!save) throw new Error("Save button not found"); + save.click(); + const timeout = () => { + const {promise: timer, resolve} = Promise.withResolvers(); + setTimeout(() => resolve("timeout"), 5000); + return timer; + }; + const result = await Promise.race([promise.then(() => "resolved"), timeout()]); + if (result !== "resolved") { + Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === "Cancel")?.click(); + throw new Error("Native property modal did not close after Save"); + } + await sleep(250); +} + +function setContenteditable(host, value) { + const input = host.querySelector("[contenteditable='true']"); + if (!input) throw new Error("contenteditable input not found"); + input.focus(); + document.execCommand("selectAll", false); + if (value === "") document.execCommand("delete", false); + else document.execCommand("insertText", false, value); + input.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: value})); + input.dispatchEvent(new Event("blur", {bubbles: true})); +} + +function setNativeInput(host, value) { + const input = host.querySelector("input"); + if (!input) throw new Error("input not found"); + input.focus(); + input.value = value; + input.dispatchEvent(new Event("input", {bubbles: true})); + input.dispatchEvent(new Event("change", {bubbles: true})); + input.dispatchEvent(new Event("blur", {bubbles: true})); +} + +async function editText(file, key, value) { + const {promise, host} = await openNative(file, key); + setContenteditable(host, value); + await saveOpenModal(promise); +} + +async function editInput(file, key, value) { + const {promise, host} = await openNative(file, key); + setNativeInput(host, value); + await saveOpenModal(promise); +} + +async function editCheckbox(file, key, checked) { + const {promise, host} = await openNative(file, key); + const input = host.querySelector("input[type='checkbox']"); + if (!input) throw new Error("checkbox input not found"); + input.focus(); + if (input.checked !== checked) input.click(); + else input.dispatchEvent(new Event("change", {bubbles: true})); + await saveOpenModal(promise); +} + +function pillTexts(host) { + return Array.from(host.querySelectorAll(".multi-select-pill-content")).map((pill) => pill.textContent); +} + +async function addPill(file, key, value) { + const {promise, host} = await openNative(file, key); + const pills = await addPillInHost(host, value); + await saveOpenModal(promise); + return pills; +} + +// Typing into a multi-select opens a value/tag fuzzy-suggest popover whose +// scope owns Enter (it would commit the highlighted suggestion instead of the +// literal text). Escape closes just the popover - its scope sits above the +// modal's while open - so the following Enter commits what was typed. +async function dismissSuggestPopover(input) { + await sleep(150); + if (!document.querySelector(".suggestion-container")) return; + input.dispatchEvent(new KeyboardEvent("keydown", {key: "Escape", code: "Escape", keyCode: 27, which: 27, bubbles: true})); + await sleep(100); +} + +async function addPillInHost(host, value) { + const input = host.querySelector(".multi-select-input,[contenteditable='true']"); + if (!input) throw new Error("multi-select input not found"); + input.focus(); + document.execCommand("insertText", false, value); + await dismissSuggestPopover(input); + input.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true})); + input.dispatchEvent(new KeyboardEvent("keyup", {key: "Enter", bubbles: true})); + await sleep(200); + return pillTexts(host); +} + +// Edit an existing pill IN PLACE via Obsidian's real edit affordance: focus the +// pill, press Enter (the widget moves the pill's value into the multi-select +// input for editing), retype, press Enter to commit. Order is preserved, so +// callers can assert the exact resulting YAML list. +async function editPillByText(host, currentText, value) { + // The widget renders its pills asynchronously after the host mounts. + let pill = null; + const start = Date.now(); + while (!pill && Date.now() - start < 5000) { + pill = Array.from(host.querySelectorAll(".multi-select-pill")) + .find((el) => el.querySelector(".multi-select-pill-content")?.textContent === currentText) ?? null; + if (!pill) await sleep(80); + } + if (!pill) throw new Error("pill not found: " + currentText + " (have: " + JSON.stringify(pillTexts(host)) + "; host: " + host.outerHTML.slice(0, 400) + ")"); + pill.focus(); + pill.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true})); + await sleep(150); + const input = document.activeElement; + if (!input || input.getAttribute("contenteditable") !== "true") { + throw new Error("pill edit did not focus the editable multi-select input"); + } + document.execCommand("selectAll", false); + document.execCommand("insertText", false, value); + await dismissSuggestPopover(input); + input.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true})); + input.dispatchEvent(new KeyboardEvent("keyup", {key: "Enter", bubbles: true})); + await sleep(150); + return pillTexts(host); +} + +async function editTextWithWikilinkSuggestion(file, key, targetText) { + const {promise, host} = await openNative(file, key); + const input = host.querySelector("[contenteditable='true']"); + if (!input) throw new Error("text input not found"); + input.focus(); + document.execCommand("selectAll", false); + document.execCommand("insertText", false, "[["); + input.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: "[["})); + input.dispatchEvent(new KeyboardEvent("keyup", {key: "[", bubbles: true})); + const suggestion = await waitFor(".suggestion-item", (el) => (el.textContent || "").includes(targetText)); + const suggestionText = suggestion.textContent?.trim() ?? null; + suggestion.dispatchEvent(new MouseEvent("mousedown", {bubbles: true})); + suggestion.dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); + suggestion.dispatchEvent(new MouseEvent("click", {bubbles: true})); + await sleep(250); + await saveOpenModal(promise); + return suggestionText; +} + +async function addPillWithWikilinkSuggestion(file, key, targetText) { + const {promise, host} = await openNative(file, key); + const input = host.querySelector(".multi-select-input,[contenteditable='true']"); + if (!input) throw new Error("multi-select input not found"); + input.focus(); + document.execCommand("insertText", false, "[["); + input.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: "[["})); + input.dispatchEvent(new KeyboardEvent("keyup", {key: "[", bubbles: true})); + const suggestion = await waitFor(".suggestion-item", (el) => (el.textContent || "").includes(targetText)); + const suggestionText = suggestion.textContent?.trim() ?? null; + suggestion.dispatchEvent(new MouseEvent("mousedown", {bubbles: true})); + suggestion.dispatchEvent(new MouseEvent("mouseup", {bubbles: true})); + suggestion.dispatchEvent(new MouseEvent("click", {bubbles: true})); + await sleep(250); + input.dispatchEvent(new KeyboardEvent("keydown", {key: "Enter", bubbles: true})); + input.dispatchEvent(new KeyboardEvent("keyup", {key: "Enter", bubbles: true})); + await sleep(200); + const pills = pillTexts(host); + await saveOpenModal(promise); + return {suggestionText, pills}; +} + +function beginCleanupMeasurement() { + const bodyBefore = document.body.children.length; + let adds = 0; + let removes = 0; + const originalAdd = document.addEventListener; + const originalRemove = document.removeEventListener; + document.addEventListener = function(...args) { + adds++; + return originalAdd.apply(this, args); + }; + document.removeEventListener = function(...args) { + removes++; + return originalRemove.apply(this, args); + }; + return { + finish() { + document.addEventListener = originalAdd; + document.removeEventListener = originalRemove; + return { + bodyDelta: document.body.children.length - bodyBefore, + documentListenerDelta: adds - removes, + modalCount: document.querySelectorAll(".modal-container").length, + suggestionCount: document.querySelectorAll(".suggestion-container").length, + }; + } + }; +} +`;