diff --git a/apps/web/src/components/editor/NoteLinkSuggestion.test.mjs b/apps/web/src/components/editor/NoteLinkSuggestion.test.mjs index f93f9d2a..85090ff9 100644 --- a/apps/web/src/components/editor/NoteLinkSuggestion.test.mjs +++ b/apps/web/src/components/editor/NoteLinkSuggestion.test.mjs @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; +import { ensureTestWindowDom } from "../../lib/restore-test-global.mjs"; import { filterNoteLinkSuggestions, insertSuggestedNoteLink, @@ -31,6 +32,7 @@ describe("note link suggestion", () => { }); test("replaces the @ query with the existing internal-note link format", () => { + ensureTestWindowDom(); const editor = new Editor({ extensions: [StarterKit], content: { @@ -64,6 +66,7 @@ describe("note link suggestion", () => { }); test("uses the untitled fallback for an empty title", () => { + ensureTestWindowDom(); const editor = new Editor({ extensions: [StarterKit], content: { diff --git a/apps/web/src/components/editor/attachment-editor-range.test.mjs b/apps/web/src/components/editor/attachment-editor-range.test.mjs index d434b6ae..19f14caf 100644 --- a/apps/web/src/components/editor/attachment-editor-range.test.mjs +++ b/apps/web/src/components/editor/attachment-editor-range.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { Editor } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import { FileAttachment, PdfAttachment } from "@edgeever/shared"; +import { ensureTestWindowDom } from "../../lib/restore-test-global.mjs"; import { findAttachmentRange, removeAttachmentAt, renameAttachmentAt } from "./attachment-editor-range.ts"; const videoUrl = "edgeever-resource://resource/res_video"; @@ -35,10 +36,13 @@ const pdfNode = (url, filename) => ({ const paragraph = (...content) => ({ type: "paragraph", content }); -const makeEditor = (content) => new Editor({ - extensions: [StarterKit, FileAttachment, PdfAttachment], - content: { type: "doc", content }, -}); +const makeEditor = (content) => { + ensureTestWindowDom(); + return new Editor({ + extensions: [StarterKit, FileAttachment, PdfAttachment], + content: { type: "doc", content }, + }); +}; describe("attachment editor range", () => { test("removes a video file-attachment node whose hover URL is the API blob path", () => { diff --git a/apps/web/src/components/editor/math-formula.test.mjs b/apps/web/src/components/editor/math-formula.test.mjs index 10e9951a..316b1b12 100644 --- a/apps/web/src/components/editor/math-formula.test.mjs +++ b/apps/web/src/components/editor/math-formula.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { Editor } from "@tiptap/core"; import { createEdgeEverDocumentExtensions } from "@edgeever/shared"; import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; +import { ensureTestWindowDom } from "../../lib/restore-test-global.mjs"; import { applyMathFormula, deleteMathFormula, @@ -9,12 +10,15 @@ import { selectedTextAsLatex, } from "./math-formula.ts"; -const createEditor = (content) => new Editor({ - extensions: createEdgeEverDocumentExtensions({ - mathematics: createEdgeEverMathematics(), - }), - content, -}); +const createEditor = (content) => { + ensureTestWindowDom(); + return new Editor({ + extensions: createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + }), + content, + }); +}; describe("math formula commands", () => { test("inserts inline math at the caret", () => { diff --git a/apps/web/src/components/pdf/pdf-document-source.test.mjs b/apps/web/src/components/pdf/pdf-document-source.test.mjs index 20e49f47..793ce2f9 100644 --- a/apps/web/src/components/pdf/pdf-document-source.test.mjs +++ b/apps/web/src/components/pdf/pdf-document-source.test.mjs @@ -20,9 +20,16 @@ describe("PDF document sources", () => { }); test("recovers leaked desktop resource URLs in the web runtime", async () => { - await expect(loadPdfDocumentSource("edgeever-resource://resource/res_web", undefined)).resolves.toEqual({ - url: "/api/v1/resources/res_web/blob", - }); + const current = globalThis.window; + const previousBridge = current?.edgeeverDesktop; + if (current) current.edgeeverDesktop = undefined; + try { + await expect(loadPdfDocumentSource("edgeever-resource://resource/res_web")).resolves.toEqual({ + url: "/api/v1/resources/res_web/blob", + }); + } finally { + if (current) current.edgeeverDesktop = previousBridge; + } }); test("loads synced desktop resources through the native bridge", async () => { diff --git a/apps/web/src/lib/restore-test-global.mjs b/apps/web/src/lib/restore-test-global.mjs index 53b5416f..7f145639 100644 --- a/apps/web/src/lib/restore-test-global.mjs +++ b/apps/web/src/lib/restore-test-global.mjs @@ -1,4 +1,20 @@ const restoreUnconditionally = Symbol("restore-unconditionally"); +const noop = () => {}; + +// Linux Bun exposes `document` without a `window` binding. Test files then +// install plain objects. ProseMirror and TipTap read `window.MutationObserver` +// and `window.addEventListener` from that object, so a leftover stub needs +// those methods. Do not create a window when none is installed: assigning one +// is unnecessary on hosts whose editor already resolves `window` elsewhere. +export const ensureTestWindowDom = () => { + const current = globalThis.window; + if (!current || typeof current !== "object") return; + if (typeof current.addEventListener !== "function") current.addEventListener = noop; + if (typeof current.removeEventListener !== "function") current.removeEventListener = noop; + if (typeof current.getComputedStyle !== "function") current.getComputedStyle = () => ({ listStyle: "" }); + if (typeof current.setTimeout !== "function") current.setTimeout = globalThis.setTimeout.bind(globalThis); + if (typeof current.clearTimeout !== "function") current.clearTimeout = globalThis.clearTimeout.bind(globalThis); +}; export const restoreTestGlobal = (key, original, installed = restoreUnconditionally) => { if (installed !== restoreUnconditionally && globalThis[key] !== installed) return; @@ -6,10 +22,12 @@ export const restoreTestGlobal = (key, original, installed = restoreUnconditiona globalThis[key] = original; return; } - // Linux Bun provides document without a window binding. Assigning - // undefined shadows it, and deleting it makes ProseMirror throw - // `window is not defined` while reading MutationObserver. Keep the - // stub object when there was no previous window. - if (key === "window") return; + // Assigning undefined shadows the host binding, and deleting window makes + // ProseMirror throw `window is not defined`. Keep the stub and make it + // safe for a later editor test. + if (key === "window") { + ensureTestWindowDom(); + return; + } delete globalThis[key]; }; diff --git a/apps/web/src/lib/restore-test-global.test.mjs b/apps/web/src/lib/restore-test-global.test.mjs index 97cfb39f..7e8f64cf 100644 --- a/apps/web/src/lib/restore-test-global.test.mjs +++ b/apps/web/src/lib/restore-test-global.test.mjs @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { restoreTestGlobal } from "./restore-test-global.mjs"; +import { ensureTestWindowDom, restoreTestGlobal } from "./restore-test-global.mjs"; test("restoreTestGlobal keeps a window object when nothing was there before", () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); @@ -8,6 +8,8 @@ test("restoreTestGlobal keeps a window object when nothing was there before", () globalThis.window = stub; restoreTestGlobal("window", undefined, stub); expect(globalThis.window).toBe(stub); + expect(typeof globalThis.window.addEventListener).toBe("function"); + expect(typeof globalThis.window.removeEventListener).toBe("function"); if (descriptor) Object.defineProperty(globalThis, "window", descriptor); else delete globalThis.window; }); @@ -23,3 +25,16 @@ test("restoreTestGlobal leaves a global that another file replaced", () => { if (descriptor) Object.defineProperty(globalThis, "document", descriptor); else delete globalThis.document; }); + +test("ensureTestWindowDom fills event methods on a leftover window", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + const stub = { edgeeverDesktop: { isAvailable: true } }; + globalThis.window = stub; + ensureTestWindowDom(); + expect(typeof stub.addEventListener).toBe("function"); + expect(typeof stub.getComputedStyle).toBe("function"); + expect(stub.edgeeverDesktop.isAvailable).toBe(true); + ensureTestWindowDom(); + if (descriptor) Object.defineProperty(globalThis, "window", descriptor); + else delete globalThis.window; +});