UNPKG

vitest-environment-ui5

Version:
215 lines 7.24 kB
import { URL } from "node:url"; import { performance } from "node:perf_hooks"; import process from "node:process"; import { MessageChannel } from "node:worker_threads"; import { JSDOM, VirtualConsole } from "jsdom"; import { populateGlobal } from "vitest/environments"; import { getSafeTimers } from "@vitest/utils"; const UI5_BOOTSTRAP_ID = "sap-ui-bootstrap"; // UI5 script tag ID let UI5_TIMEOUT = 100; // UI5 load timeout in ms /** * Await for next tick */ function waitNextTick() { return new Promise((resolve) => { getSafeTimers().setTimeout(resolve, 25); }); } /** * Catch jsdom window errors */ function catchWindowErrors(window) { let userErrorListenerCount = 0; /** * Throw UnhandlerError for window error events */ function throwUnhandlerError(e) { if (userErrorListenerCount === 0 && e.error != null) { process.emit("uncaughtException", e.error); } } const addEventListener = window.addEventListener.bind(window); const removeEventListener = window.removeEventListener.bind(window); window.addEventListener("error", throwUnhandlerError); window.addEventListener = function (...args) { if (args[0] === "error") { userErrorListenerCount++; } return addEventListener.apply(this, args); }; window.removeEventListener = function (...args) { if (args[0] === "error" && userErrorListenerCount) { userErrorListenerCount--; } return removeEventListener.apply(this, args); }; return function clearErrorHandlers() { window.removeEventListener("error", throwUnhandlerError); }; } /** * Get jsdom configuration to build JSDOM from HTML file containing UI5 bootstrap configuration */ function getConfiguration() { const vConsole = new VirtualConsole(); vConsole.sendTo(console); return { resources: "usable", runScripts: "dangerously", pretendToBeVisual: true, virtualConsole: vConsole, beforeParse: (jsdomWindow) => { // @ts-expect-error: Add performance.timing for old UI5 versions jsdomWindow.performance.timing = { fetchStart: Date.now(), navigationStart: Date.now(), }; jsdomWindow.MessageChannel = MessageChannel; /* userAgent: window.navigator.userAgent, userAgentData: window.navigator.userAgentData, platform: window.navigator.platform */ // Patch window.matchMedia because it doesn't exist in JSDOM Object.defineProperty(jsdomWindow, "matchMedia", { writable: true, configurable: true, value: (query) => ({ matches: false, media: query, onchange: null, addEventListener: () => { }, removeEventListener: () => { }, dispatchEvent: () => { }, }), }); }, }; } /** * Build JSDOM from a local HTML file */ function buildFromFile(ui5) { const options = { ...getConfiguration(), referrer: "https://ui5.sap.com/", }; return JSDOM.fromFile(ui5.path, options); } /** * Build JSDOM from an URL */ function buildFromUrl(ui5) { return JSDOM.fromURL(ui5.path, getConfiguration()); } /** * Add load and error events to UI5 bootstrap script tag to handle its status */ function ui5BootstrapListener(window) { return new Promise((resolve, reject) => { const ui5Script = window.document.getElementById(UI5_BOOTSTRAP_ID); if (ui5Script) { ui5Script.addEventListener("load", () => { resolve(); }); ui5Script.addEventListener("error", () => { reject(new Error(`Error loading ${UI5_BOOTSTRAP_ID}!`)); }); } else { reject(new Error(`Script tag ${UI5_BOOTSTRAP_ID} not found!`)); } }); } /** * Add load and error events to UI5 bootstrap script tag to handle its status */ async function ui5CoreLibraryListener(window, startTime) { await waitNextTick(); return new Promise((resolve, reject) => { const elapsedTime = performance.now() - startTime; if (elapsedTime > UI5_TIMEOUT) { reject(new Error(`UI5 load timeout: ${UI5_TIMEOUT}ms!`)); } else if (window.sap?.ui?.require) { window.sap?.ui?.require(["sap/ui/core/Core"], resolve); } else { ui5CoreLibraryListener(window, startTime).then(resolve).catch(reject); } }); } /** * Await UI5 to be loaded: onInit event */ function ui5Ready(window) { return new Promise((resolve, reject) => { window.sap?.ui?.require(["sap/ui/core/Core"], (Core) => { if (Core) { Core.ready ? Core.ready(resolve) : Core.attachInit(resolve); // eslint-disable-line @typescript-eslint/no-unused-expressions } else { reject(new Error("UI5 core not loaded!")); } }); }); } /** * Returns whether the path is a valid URL or not */ function isValidUrl(path) { try { return !!new URL(path); } catch (err) { // eslint-disable-next-line no-console console.error("Invalid URL:", path, ". Error:", err?.message); return false; } } /** * Vitest environment for UI5 */ export default { name: "ui5", transformMode: "web", async setup(global, { ui5 = {} }) { UI5_TIMEOUT = ui5.timeout ?? UI5_TIMEOUT; const isUrl = isValidUrl(ui5.path); let dom; let clearWindowErrors; try { if (!ui5?.path) { throw new Error("The path to the HTML file/page containing the UI5 bootstrap setup must be set!"); } dom = isUrl ? await buildFromUrl(ui5) : await buildFromFile(ui5); clearWindowErrors = catchWindowErrors(dom.window); await ui5BootstrapListener(dom.window); await ui5CoreLibraryListener(dom.window, performance.now()); await ui5Ready(dom.window); } catch (err) { console.error(err); // eslint-disable-line no-console return Promise.reject(err); } const hrefFile = dom.window.location.href; if (!isUrl) { // Workaround to avoid > SecurityError: localStorage is not available for opaque origins dom.reconfigure({ url: "http://localhost/" }); } const { keys, originals } = populateGlobal(global, dom.window, { bindFunctions: true, }); if (!isUrl) { // Comeback to original settings dom.reconfigure({ url: hrefFile }); } return { teardown(global) { clearWindowErrors(); dom.window.close(); keys.forEach((key) => delete global[key]); // eslint-disable-line @typescript-eslint/no-dynamic-delete originals.forEach((v, k) => (global[k] = v)); }, }; }, }; //# sourceMappingURL=index.js.map