odoo18/addons/web/static/lib/hoot/mock/window.js

711 lines
20 KiB
JavaScript

/** @odoo-module */
import { EventBus } from "@odoo/owl";
import { getCurrentDimensions, getDocument, getWindow } from "@web/../lib/hoot-dom/helpers/dom";
import {
mockedCancelAnimationFrame,
mockedClearInterval,
mockedClearTimeout,
mockedRequestAnimationFrame,
mockedSetInterval,
mockedSetTimeout,
} from "@web/../lib/hoot-dom/helpers/time";
import { interactor } from "../../hoot-dom/hoot_dom_utils";
import { MockEventTarget, strictEqual, waitForDocument } from "../hoot_utils";
import { getRunner } from "../main_runner";
import {
MockAnimation,
mockedAnimate,
mockedScroll,
mockedScrollBy,
mockedScrollIntoView,
mockedScrollTo,
mockedWindowScroll,
mockedWindowScrollBy,
mockedWindowScrollTo,
} from "./animation";
import { MockConsole } from "./console";
import { MockDate, MockIntl } from "./date";
import { MockClipboardItem, mockNavigator } from "./navigator";
import {
MockBroadcastChannel,
MockMessageChannel,
MockMessagePort,
MockRequest,
MockResponse,
MockSharedWorker,
MockURL,
MockWebSocket,
MockWorker,
MockXMLHttpRequest,
MockXMLHttpRequestUpload,
mockCookie,
mockHistory,
mockLocation,
mockedFetch,
} from "./network";
import { MockNotification } from "./notification";
import { MockStorage } from "./storage";
import { MockBlob } from "./sync_values";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
EventTarget,
HTMLAnchorElement,
MutationObserver,
Number: { isNaN: $isNaN, parseFloat: $parseFloat },
Object: {
assign: $assign,
defineProperties: $defineProperties,
entries: $entries,
getOwnPropertyDescriptor: $getOwnPropertyDescriptor,
getPrototypeOf: $getPrototypeOf,
keys: $keys,
hasOwn: $hasOwn,
},
Reflect: { ownKeys: $ownKeys },
Set,
WeakMap,
} = globalThis;
const { addEventListener, removeEventListener } = EventTarget.prototype;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {unknown} target
* @param {Record<string, PropertyDescriptor>} descriptors
*/
function applyPropertyDescriptors(target, descriptors) {
if (!originalDescriptors.has(target)) {
originalDescriptors.set(target, {});
}
const targetDescriptors = originalDescriptors.get(target);
const ownerDecriptors = new Map();
for (const [property, rawDescriptor] of $entries(descriptors)) {
const owner = findPropertyOwner(target, property);
targetDescriptors[property] = $getOwnPropertyDescriptor(owner, property);
const descriptor = { ...rawDescriptor };
if ("value" in descriptor) {
descriptor.writable = false;
}
if (!ownerDecriptors.has(owner)) {
ownerDecriptors.set(owner, {});
}
const nextDescriptors = ownerDecriptors.get(owner);
nextDescriptors[property] = descriptor;
}
for (const [owner, nextDescriptors] of ownerDecriptors) {
$defineProperties(owner, nextDescriptors);
}
}
/**
* @param {string[]} [changedKeys]
*/
function callMediaQueryChanges(changedKeys) {
for (const mediaQueryList of mediaQueryLists) {
if (!changedKeys || changedKeys.some((key) => mediaQueryList.media.includes(key))) {
const event = new MediaQueryListEvent("change", {
matches: mediaQueryList.matches,
media: mediaQueryList.media,
});
mediaQueryList.dispatchEvent(event);
}
}
}
/**
* @template T
* @param {T} target
* @param {keyof T} property
*/
function findOriginalDescriptor(target, property) {
if (originalDescriptors.has(target)) {
const descriptors = originalDescriptors.get(target);
if (descriptors && property in descriptors) {
return descriptors[property];
}
}
return null;
}
/**
* @param {unknown} object
* @param {string} property
* @returns {unknown}
*/
function findPropertyOwner(object, property) {
if ($hasOwn(object, property)) {
return object;
}
const prototype = $getPrototypeOf(object);
if (prototype) {
return findPropertyOwner(prototype, property);
}
return object;
}
/**
* @param {unknown} object
*/
function getTouchDescriptors(object) {
const descriptors = {};
const toDelete = [];
for (const eventName of TOUCH_EVENTS) {
const fnName = `on${eventName}`;
if (fnName in object) {
const owner = findPropertyOwner(object, fnName);
descriptors[fnName] = $getOwnPropertyDescriptor(owner, fnName);
} else {
toDelete.push(fnName);
}
}
/** @type {({ descriptors?: Record<string, PropertyDescriptor>; toDelete?: string[]})} */
const result = {};
if ($keys(descriptors).length) {
result.descriptors = descriptors;
}
if (toDelete.length) {
result.toDelete = toDelete;
}
return result;
}
/**
* @param {typeof globalThis} view
*/
function getTouchTargets(view) {
return [view, view.Document.prototype];
}
/**
* @param {typeof globalThis} view
*/
function getWatchedEventTargets(view) {
return [
view,
view.document,
// Permanent DOM elements
view.HTMLDocument.prototype,
view.HTMLBodyElement.prototype,
view.HTMLHeadElement.prototype,
view.HTMLHtmlElement.prototype,
// Other event targets
EventBus.prototype,
MockEventTarget.prototype,
];
}
/**
* @param {string} type
* @returns {PropertyDescriptor}
*/
function makeEventDescriptor(type) {
let callback = null;
return {
enumerable: true,
configurable: true,
get() {
return callback;
},
set(value) {
if (callback === value) {
return;
}
if (typeof callback === "function") {
this.removeEventListener(type, callback);
}
callback = value;
if (typeof callback === "function") {
this.addEventListener(type, callback);
}
},
};
}
/**
* @param {string} mediaQueryString
*/
function matchesQueryPart(mediaQueryString) {
const [, key, value] = mediaQueryString.match(R_MEDIA_QUERY_PROPERTY) || [];
let match = false;
if (mockMediaValues[key]) {
match = strictEqual(value, mockMediaValues[key]);
} else if (key) {
switch (key) {
case "max-height": {
match = getCurrentDimensions().height <= $parseFloat(value);
break;
}
case "max-width": {
match = getCurrentDimensions().width <= $parseFloat(value);
break;
}
case "min-height": {
match = getCurrentDimensions().height >= $parseFloat(value);
break;
}
case "min-width": {
match = getCurrentDimensions().width >= $parseFloat(value);
break;
}
case "orientation": {
const { width, height } = getCurrentDimensions();
match = value === "landscape" ? width > height : width < height;
break;
}
}
}
return mediaQueryString.startsWith("not") ? !match : match;
}
/** @type {addEventListener} */
function mockedAddEventListener(...args) {
const runner = getRunner();
if (runner.dry || !runner.suiteStack.length) {
// Ignore listeners during dry run or outside of a test suite
return;
}
if (!R_OWL_SYNTHETIC_LISTENER.test(String(args[1]))) {
// Ignore cleanup for Owl synthetic listeners
runner.after(removeEventListener.bind(this, ...args));
}
return addEventListener.call(this, ...args);
}
/** @type {Document["elementFromPoint"]} */
function mockedElementFromPoint(...args) {
return mockedElementsFromPoint.call(this, ...args)[0];
}
/**
* Mocked version of {@link document.elementsFromPoint} to:
* - remove "HOOT-..." elements from the result
* - put the <body> & <html> elements at the end of the list, as they may be ordered
* incorrectly due to the fixture being behind the body.
* @type {Document["elementsFromPoint"]}
*/
function mockedElementsFromPoint(...args) {
const { value: elementsFromPoint } = findOriginalDescriptor(this, "elementsFromPoint");
const result = [];
let hasDocumentElement = false;
let hasBody = false;
for (const element of elementsFromPoint.call(this, ...args)) {
if (element.tagName.startsWith("HOOT")) {
continue;
}
if (element === this.body) {
hasBody = true;
} else if (element === this.documentElement) {
hasDocumentElement = true;
} else {
result.push(element);
}
}
if (hasBody) {
result.push(this.body);
}
if (hasDocumentElement) {
result.push(this.documentElement);
}
return result;
}
function mockedHref() {
return this.hasAttribute("href") ? new MockURL(this.getAttribute("href")).href : "";
}
/** @type {typeof matchMedia} */
function mockedMatchMedia(mediaQueryString) {
return new MockMediaQueryList(mediaQueryString);
}
/** @type {typeof removeEventListener} */
function mockedRemoveEventListener(...args) {
if (getRunner().dry) {
// Ignore listeners during dry run
return;
}
return removeEventListener.call(this, ...args);
}
/**
* @param {MutationRecord[]} mutations
*/
function observeAddedNodes(mutations) {
const runner = getRunner();
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (runner.dry) {
node.remove();
} else {
runner.after(node.remove.bind(node));
}
}
}
}
/**
* @param {PointerEvent} ev
*/
function onAnchorHrefClick(ev) {
if (ev.defaultPrevented) {
return;
}
const href = ev.target.closest("a[href]")?.href;
if (!href) {
return;
}
ev.preventDefault();
// Assign href to mock location instead of actual location
mockLocation.href = href;
const [, hash] = href.split("#");
if (hash) {
// Scroll to the target element if the href is/has a hash
getDocument().getElementById(hash)?.scrollIntoView();
}
}
function onWindowResize() {
callMediaQueryChanges();
}
/**
* @param {typeof globalThis} view
*/
function restoreTouch(view) {
const touchObjects = getTouchTargets(view);
for (let i = 0; i < touchObjects.length; i++) {
const object = touchObjects[i];
const { descriptors, toDelete } = originalTouchFunctions[i];
if (descriptors) {
$defineProperties(object, descriptors);
}
if (toDelete) {
for (const fnName of toDelete) {
delete object[fnName];
}
}
}
}
class MockMediaQueryList extends MockEventTarget {
static publicListeners = ["change"];
get matches() {
return this.media
.split(R_COMMA)
.some((orPart) => orPart.split(R_AND).every(matchesQueryPart));
}
/**
* @param {string} mediaQueryString
*/
constructor(mediaQueryString) {
super(...arguments);
this.media = mediaQueryString.trim().toLowerCase();
mediaQueryLists.add(this);
}
}
const DEFAULT_MEDIA_VALUES = {
"display-mode": "browser",
pointer: "fine",
"prefers-color-scheme": "light",
"prefers-reduced-motion": "reduce",
};
const TOUCH_EVENTS = ["touchcancel", "touchend", "touchmove", "touchstart"];
const R_AND = /\s*\band\b\s*/;
const R_COMMA = /\s*,\s*/;
const R_MEDIA_QUERY_PROPERTY = /\(\s*([\w-]+)\s*:\s*(.+)\s*\)/;
const R_OWL_SYNTHETIC_LISTENER = /\bnativeToSyntheticEvent\b/;
/** @type {WeakMap<unknown, Record<string, PropertyDescriptor>>} */
const originalDescriptors = new WeakMap();
const originalTouchFunctions = getTouchTargets(globalThis).map(getTouchDescriptors);
/** @type {Set<MockMediaQueryList>} */
const mediaQueryLists = new Set();
const mockConsole = new MockConsole();
const mockLocalStorage = new MockStorage();
const mockMediaValues = { ...DEFAULT_MEDIA_VALUES };
const mockSessionStorage = new MockStorage();
let mockTitle = "";
// Mock descriptors
const ANCHOR_MOCK_DESCRIPTORS = {
href: {
...$getOwnPropertyDescriptor(HTMLAnchorElement.prototype, "href"),
get: mockedHref,
},
};
const DOCUMENT_MOCK_DESCRIPTORS = {
cookie: {
get: () => mockCookie.get(),
set: (value) => mockCookie.set(value),
},
elementFromPoint: { value: mockedElementFromPoint },
elementsFromPoint: { value: mockedElementsFromPoint },
title: {
get: () => mockTitle,
set: (value) => (mockTitle = value),
},
};
const ELEMENT_MOCK_DESCRIPTORS = {
animate: { value: mockedAnimate },
scroll: { value: mockedScroll },
scrollBy: { value: mockedScrollBy },
scrollIntoView: { value: mockedScrollIntoView },
scrollTo: { value: mockedScrollTo },
};
const WINDOW_MOCK_DESCRIPTORS = {
Animation: { value: MockAnimation },
Blob: { value: MockBlob },
BroadcastChannel: { value: MockBroadcastChannel },
cancelAnimationFrame: { value: mockedCancelAnimationFrame, writable: false },
clearInterval: { value: mockedClearInterval, writable: false },
clearTimeout: { value: mockedClearTimeout, writable: false },
ClipboardItem: { value: MockClipboardItem },
console: { value: mockConsole, writable: false },
Date: { value: MockDate, writable: false },
fetch: { value: interactor("server", mockedFetch).as("fetch"), writable: false },
history: { value: mockHistory },
innerHeight: { get: () => getCurrentDimensions().height },
innerWidth: { get: () => getCurrentDimensions().width },
Intl: { value: MockIntl },
localStorage: { value: mockLocalStorage, writable: false },
matchMedia: { value: mockedMatchMedia },
MessageChannel: { value: MockMessageChannel },
MessagePort: { value: MockMessagePort },
navigator: { value: mockNavigator },
Notification: { value: MockNotification },
outerHeight: { get: () => getCurrentDimensions().height },
outerWidth: { get: () => getCurrentDimensions().width },
Request: { value: MockRequest, writable: false },
requestAnimationFrame: { value: mockedRequestAnimationFrame, writable: false },
Response: { value: MockResponse, writable: false },
scroll: { value: mockedWindowScroll },
scrollBy: { value: mockedWindowScrollBy },
scrollTo: { value: mockedWindowScrollTo },
sessionStorage: { value: mockSessionStorage, writable: false },
setInterval: { value: mockedSetInterval, writable: false },
setTimeout: { value: mockedSetTimeout, writable: false },
SharedWorker: { value: MockSharedWorker },
URL: { value: MockURL },
WebSocket: { value: MockWebSocket },
Worker: { value: MockWorker },
XMLHttpRequest: { value: MockXMLHttpRequest },
XMLHttpRequestUpload: { value: MockXMLHttpRequestUpload },
};
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function cleanupWindow() {
const view = getWindow();
// Storages
mockLocalStorage.clear();
mockSessionStorage.clear();
// Media
mediaQueryLists.clear();
$assign(mockMediaValues, DEFAULT_MEDIA_VALUES);
// Title
mockTitle = "";
// Listeners
view.removeEventListener("click", onAnchorHrefClick);
view.removeEventListener("resize", onWindowResize);
// Head & body attributes
const { head, body } = view.document;
for (const { name } of head.attributes) {
head.removeAttribute(name);
}
for (const { name } of body.attributes) {
body.removeAttribute(name);
}
// Touch
restoreTouch(view);
}
export function getTitle() {
const doc = getDocument();
const titleDescriptor = findOriginalDescriptor(doc, "title");
if (titleDescriptor) {
return titleDescriptor.get.call(doc);
} else {
return doc.title;
}
}
export function getViewPortHeight() {
const view = getWindow();
const heightDescriptor = findOriginalDescriptor(view, "innerHeight");
if (heightDescriptor) {
return heightDescriptor.get.call(view);
} else {
return view.innerHeight;
}
}
export function getViewPortWidth() {
const view = getWindow();
const titleDescriptor = findOriginalDescriptor(view, "innerWidth");
if (titleDescriptor) {
return titleDescriptor.get.call(view);
} else {
return view.innerWidth;
}
}
/**
* @param {Record<string, string>} name
*/
export function mockMatchMedia(values) {
$assign(mockMediaValues, values);
callMediaQueryChanges($keys(values));
}
/**
* @param {boolean} setTouch
*/
export function mockTouch(setTouch) {
const objects = getTouchTargets(getWindow());
if (setTouch) {
for (const object of objects) {
const descriptors = {};
for (const eventName of TOUCH_EVENTS) {
const fnName = `on${eventName}`;
if (!$hasOwn(object, fnName)) {
descriptors[fnName] = makeEventDescriptor(eventName);
}
}
$defineProperties(object, descriptors);
}
mockMatchMedia({ pointer: "coarse" });
} else {
for (const object of objects) {
for (const eventName of TOUCH_EVENTS) {
delete object[`on${eventName}`];
}
}
mockMatchMedia({ pointer: "fine" });
}
}
/**
* @param {typeof globalThis} [view=getWindow()]
*/
export function patchWindow(view = getWindow()) {
// Window (doesn't need to be ready)
applyPropertyDescriptors(view, WINDOW_MOCK_DESCRIPTORS);
waitForDocument(view.document).then(() => {
// Document
applyPropertyDescriptors(view.document, DOCUMENT_MOCK_DESCRIPTORS);
// Element prototypes
applyPropertyDescriptors(view.Element.prototype, ELEMENT_MOCK_DESCRIPTORS);
applyPropertyDescriptors(view.HTMLAnchorElement.prototype, ANCHOR_MOCK_DESCRIPTORS);
});
}
/**
* @param {string} value
*/
export function setTitle(value) {
const doc = getDocument();
const titleDescriptor = findOriginalDescriptor(doc, "title");
if (titleDescriptor) {
titleDescriptor.set.call(doc, value);
} else {
doc.title = value;
}
}
export function setupWindow() {
const view = getWindow();
// Listeners
view.addEventListener("click", onAnchorHrefClick);
view.addEventListener("resize", onWindowResize);
}
/**
* @param {typeof globalThis} [view=getWindow()]
*/
export function watchAddedNodes(view = getWindow()) {
const observer = new MutationObserver(observeAddedNodes);
observer.observe(view.document.head, { childList: true });
return function unwatchAddedNodes() {
observer.disconnect();
};
}
/**
* @param {typeof globalThis} [view=getWindow()]
*/
export function watchListeners(view = getWindow()) {
const targets = getWatchedEventTargets(view);
for (const target of targets) {
target.addEventListener = mockedAddEventListener;
target.removeEventListener = mockedRemoveEventListener;
}
return function unwatchAllListeners() {
for (const target of targets) {
target.addEventListener = addEventListener;
target.removeEventListener = removeEventListener;
}
};
}
/**
* Returns a function checking that the given target does not contain any unexpected
* key. The list of accepted keys is the initial list of keys of the target, along
* with an optional `whiteList` argument.
*
* @template T
* @param {T} target
* @param {string[]} [whiteList]
* @example
* afterEach(watchKeys(window, ["odoo"]));
*/
export function watchKeys(target, whiteList) {
const acceptedKeys = new Set([...$ownKeys(target), ...(whiteList || [])]);
return function checkKeys() {
const keysDiff = $ownKeys(target).filter(
(key) => $isNaN($parseFloat(key)) && !acceptedKeys.has(key)
);
for (const key of keysDiff) {
const descriptor = $getOwnPropertyDescriptor(target, key);
if (descriptor.configurable) {
delete target[key];
} else if (descriptor.writable) {
target[key] = undefined;
}
}
};
}