vitest-environment-ui5
Version:
Vitest testing environment for UI5
215 lines • 7.24 kB
JavaScript
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