diff --git a/apps/web/src/lib/restore-test-global.mjs b/apps/web/src/lib/restore-test-global.mjs index 7f145639..fdf1251b 100644 --- a/apps/web/src/lib/restore-test-global.mjs +++ b/apps/web/src/lib/restore-test-global.mjs @@ -1,12 +1,28 @@ 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. +// Linux Bun can be left with a plain window object, or with a linkedom +// document whose view is not installed as `window`. TipTap reads +// `window.addEventListener` and appends CSS to `document.head`. +export const ensureTestDocumentHead = () => { + const doc = globalThis.document; + if (!doc || typeof doc.createElement !== "function" || typeof doc.getElementsByTagName !== "function") return; + if (doc.getElementsByTagName("head")[0]) return; + const head = doc.createElement("head"); + const html = doc.documentElement; + if (html && typeof html.insertBefore === "function") { + html.insertBefore(head, html.firstChild ?? null); + return; + } + if (typeof doc.appendChild === "function") doc.appendChild(head); +}; + export const ensureTestWindowDom = () => { + const view = globalThis.document?.defaultView; + if (view && typeof globalThis.window?.addEventListener !== "function") { + globalThis.window = view; + } + ensureTestDocumentHead(); const current = globalThis.window; if (!current || typeof current !== "object") return; if (typeof current.addEventListener !== "function") current.addEventListener = noop; diff --git a/apps/web/src/lib/restore-test-global.test.mjs b/apps/web/src/lib/restore-test-global.test.mjs index 7e8f64cf..0bc6d8ea 100644 --- a/apps/web/src/lib/restore-test-global.test.mjs +++ b/apps/web/src/lib/restore-test-global.test.mjs @@ -1,9 +1,8 @@ import { expect, test } from "bun:test"; -import { ensureTestWindowDom, restoreTestGlobal } from "./restore-test-global.mjs"; +import { ensureTestDocumentHead, ensureTestWindowDom, restoreTestGlobal } from "./restore-test-global.mjs"; test("restoreTestGlobal keeps a window object when nothing was there before", () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); - delete globalThis.window; const stub = { localStorage: {} }; globalThis.window = stub; restoreTestGlobal("window", undefined, stub); @@ -26,6 +25,50 @@ test("restoreTestGlobal leaves a global that another file replaced", () => { else delete globalThis.document; }); +test("ensureTestDocumentHead inserts a head when the document has none", () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "document"); + const heads = []; + const html = { + firstChild: null, + insertBefore(node) { + heads.push(node); + }, + }; + globalThis.document = { + documentElement: html, + createElement: (name) => ({ nodeName: name, appendChild() {} }), + getElementsByTagName: (name) => (name === "head" ? heads : []), + }; + ensureTestDocumentHead(); + expect(heads).toHaveLength(1); + ensureTestDocumentHead(); + expect(heads).toHaveLength(1); + if (descriptor) Object.defineProperty(globalThis, "document", descriptor); + else delete globalThis.document; +}); + +test("ensureTestWindowDom uses the document view when the leftover window cannot listen", () => { + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + const documentDescriptor = Object.getOwnPropertyDescriptor(globalThis, "document"); + const view = { + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.document = { + defaultView: view, + documentElement: { insertBefore() {} }, + createElement: (name) => ({ nodeName: name }), + getElementsByTagName: () => [{}], + }; + globalThis.window = { edgeeverDesktop: { isAvailable: true } }; + ensureTestWindowDom(); + expect(globalThis.window).toBe(view); + if (windowDescriptor) Object.defineProperty(globalThis, "window", windowDescriptor); + else delete globalThis.window; + if (documentDescriptor) Object.defineProperty(globalThis, "document", documentDescriptor); + else delete globalThis.document; +}); + test("ensureTestWindowDom fills event methods on a leftover window", () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); const stub = { edgeeverDesktop: { isAvailable: true } }; diff --git a/apps/web/src/lib/wechat-copy.test.mjs b/apps/web/src/lib/wechat-copy.test.mjs index e4ec1297..5638838d 100644 --- a/apps/web/src/lib/wechat-copy.test.mjs +++ b/apps/web/src/lib/wechat-copy.test.mjs @@ -1,19 +1,39 @@ -import { beforeAll, describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { parseHTML } from "../../../../node_modules/.bun/linkedom@0.18.13/node_modules/linkedom/esm/index.js"; -const window = parseHTML(""); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); +const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); +const originalNode = Object.getOwnPropertyDescriptor(globalThis, "Node"); +const originalHTMLElement = Object.getOwnPropertyDescriptor(globalThis, "HTMLElement"); +const originalGetComputedStyle = Object.getOwnPropertyDescriptor(globalThis, "getComputedStyle"); + +const linkedomWindow = parseHTML(""); try { - Object.defineProperty(window.document, "compatMode", { value: "CSS1Compat" }); + Object.defineProperty(linkedomWindow.document, "compatMode", { value: "CSS1Compat" }); } catch { // linkedom may already expose a read-only quirks flag. KaTeX still renders. } -globalThis.document = window.document; -globalThis.Node = window.Node; -globalThis.HTMLElement = window.HTMLElement; -globalThis.getComputedStyle = typeof window.getComputedStyle === "function" - ? window.getComputedStyle.bind(window) +globalThis.window = linkedomWindow; +globalThis.document = linkedomWindow.document; +globalThis.Node = linkedomWindow.Node; +globalThis.HTMLElement = linkedomWindow.HTMLElement; +globalThis.getComputedStyle = typeof linkedomWindow.getComputedStyle === "function" + ? linkedomWindow.getComputedStyle.bind(linkedomWindow) : () => ({ getPropertyValue: () => "" }); +const restoreGlobal = (name, descriptor) => { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else delete globalThis[name]; +}; + +afterAll(() => { + restoreGlobal("window", originalWindow); + restoreGlobal("document", originalDocument); + restoreGlobal("Node", originalNode); + restoreGlobal("HTMLElement", originalHTMLElement); + restoreGlobal("getComputedStyle", originalGetComputedStyle); +}); + const css = (element) => (element?.style.cssText ?? "").replace(/:\s+/g, ":"); let preparePublishArticle;