vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:09 +02:00
parent 5454004ff9
commit d7f6d2725e
979 changed files with 428093 additions and 0 deletions

View file

@ -0,0 +1,175 @@
/** @odoo-module */
import { on } from "@odoo/hoot-dom";
import { MockEventTarget } from "../hoot_utils";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Array: { isArray: $isArray },
Element,
Object: { assign: $assign, entries: $entries },
scroll: windowScroll,
scrollBy: windowScrollBy,
scrollTo: windowScrollTo,
} = globalThis;
const { animate, scroll, scrollBy, scrollIntoView, scrollTo } = Element.prototype;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
function forceInstantScroll(args) {
return !allowAnimations && args[0] && typeof args[0] === "object"
? [{ ...args[0], behavior: "instant" }, ...args.slice(1)]
: args;
}
const animationChangeBus = new MockEventTarget();
const animationChangeCleanups = [];
let allowAnimations = true;
let allowTransitions = false;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockAnimation extends MockEventTarget {
static publicListeners = ["cancel", "finish", "remove"];
currentTime = null;
effect = null;
finished = Promise.resolve(this);
id = "";
pending = false;
playState = "idle";
playbackRate = 1;
ready = Promise.resolve(this);
replaceState = "active";
startTime = null;
timeline = {
currentTime: this.currentTime,
duration: null,
};
cancel() {
this.dispatchEvent(new AnimationPlaybackEvent("cancel"));
}
commitStyles() {}
finish() {
this.dispatchEvent(new AnimationPlaybackEvent("finish"));
}
pause() {}
persist() {}
play() {
this.dispatchEvent(new AnimationPlaybackEvent("finish"));
}
reverse() {}
updatePlaybackRate() {}
}
export function cleanupAnimations() {
allowAnimations = true;
allowTransitions = false;
while (animationChangeCleanups.length) {
animationChangeCleanups.pop()();
}
}
/**
* Turns off all animations triggered programmatically (such as with `animate`),
* as well as smooth scrolls.
*
* @param {boolean} [enable=false]
*/
export function disableAnimations(enable = false) {
allowAnimations = enable;
}
/**
* Restores all suppressed "animation" and "transition" properties for the current
* test, as they are turned off by default.
*
* @param {boolean} [enable=true]
*/
export function enableTransitions(enable = true) {
allowTransitions = enable;
animationChangeBus.dispatchEvent(new CustomEvent("toggle-transitions"));
}
/** @type {Element["animate"]} */
export function mockedAnimate(...args) {
if (allowAnimations) {
return animate.call(this, ...args);
}
// Apply style properties immediatly
const keyframesList = $isArray(args[0]) ? args[0] : [args[0]];
const style = {};
for (const kf of keyframesList) {
for (const [key, value] of $entries(kf)) {
style[key] = $isArray(value) ? value.at(-1) : value;
}
}
$assign(this.style, style);
// Return mock animation
return new MockAnimation();
}
/** @type {Element["scroll"]} */
export function mockedScroll(...args) {
return scroll.call(this, ...forceInstantScroll(args));
}
/** @type {Element["scrollBy"]} */
export function mockedScrollBy(...args) {
return scrollBy.call(this, ...forceInstantScroll(args));
}
/** @type {Element["scrollIntoView"]} */
export function mockedScrollIntoView(...args) {
return scrollIntoView.call(this, ...forceInstantScroll(args));
}
/** @type {Element["scrollTo"]} */
export function mockedScrollTo(...args) {
return scrollTo.call(this, ...forceInstantScroll(args));
}
/** @type {typeof window["scroll"]} */
export function mockedWindowScroll(...args) {
return windowScroll.call(this, ...forceInstantScroll(args));
}
/** @type {typeof window["scrollBy"]} */
export function mockedWindowScrollBy(...args) {
return windowScrollBy.call(this, ...forceInstantScroll(args));
}
/** @type {typeof window["scrollTo"]} */
export function mockedWindowScrollTo(...args) {
return windowScrollTo.call(this, ...forceInstantScroll(args));
}
/**
* @param {(allowTransitions: boolean) => any} onChange
*/
export function subscribeToTransitionChange(onChange) {
onChange(allowTransitions);
animationChangeCleanups.push(
on(animationChangeBus, "toggle-transitions", () => onChange(allowTransitions))
);
}

View file

@ -0,0 +1,39 @@
/** @odoo-module */
import { MockEventTarget } from "../hoot_utils";
import { logger } from "../core/logger";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
console,
Object: { keys: $keys },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const DISPATCHING_METHODS = ["error", "trace", "warn"];
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockConsole extends MockEventTarget {
static {
for (const fnName of $keys(console)) {
if (DISPATCHING_METHODS.includes(fnName)) {
const fn = logger[fnName];
this.prototype[fnName] = function (...args) {
this.dispatchEvent(new CustomEvent(fnName, { detail: args }));
return fn.apply(this, arguments);
};
} else {
this.prototype[fnName] = console[fnName];
}
}
}
}

View file

@ -0,0 +1,253 @@
/** @odoo-module */
import { getTimeOffset, isTimeFrozen, resetTimeOffset } from "@web/../lib/hoot-dom/helpers/time";
import { createMock, HootError, isNil } from "../hoot_utils";
/**
* @typedef DateSpecs
* @property {number} [year]
* @property {number} [month] // 1-12
* @property {number} [day] // 1-31
* @property {number} [hour] // 0-23
* @property {number} [minute] // 0-59
* @property {number} [second] // 0-59
* @property {number} [millisecond] // 0-999
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Date, Intl } = globalThis;
const { now: $now, UTC: $UTC } = Date;
const { DateTimeFormat, Locale } = Intl;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {Date} baseDate
*/
function computeTimeZoneOffset(baseDate) {
const utcDate = new Date(baseDate.toLocaleString(DEFAULT_LOCALE, { timeZone: "UTC" }));
const tzDate = new Date(baseDate.toLocaleString(DEFAULT_LOCALE, { timeZone: timeZoneName }));
return (utcDate - tzDate) / 60000; // in minutes
}
/**
* @param {number} id
*/
function getDateParams() {
return [...dateParams.slice(0, -1), dateParams.at(-1) + getTimeStampDiff() + getTimeOffset()];
}
function getTimeStampDiff() {
return isTimeFrozen() ? 0 : $now() - dateTimeStamp;
}
/**
* @param {string | DateSpecs} dateSpecs
*/
function parseDateParams(dateSpecs) {
/** @type {DateSpecs} */
const specs =
(typeof dateSpecs === "string" ? dateSpecs.match(DATE_REGEX)?.groups : dateSpecs) || {};
return [
specs.year ?? DEFAULT_DATE[0],
(specs.month ?? DEFAULT_DATE[1]) - 1,
specs.day ?? DEFAULT_DATE[2],
specs.hour ?? DEFAULT_DATE[3],
specs.minute ?? DEFAULT_DATE[4],
specs.second ?? DEFAULT_DATE[5],
specs.millisecond ?? DEFAULT_DATE[6],
].map(Number);
}
/**
* @param {typeof dateParams} newDateParams
*/
function setDateParams(newDateParams) {
dateParams = newDateParams;
dateTimeStamp = $now();
resetTimeOffset();
}
/**
* @param {string | number | null | undefined} tz
*/
function setTimeZone(tz) {
if (typeof tz === "string") {
if (!tz.includes("/")) {
throw new HootError(`invalid time zone: must be in the format <Country/...Location>`);
}
// Set TZ name
timeZoneName = tz;
// Set TZ offset based on name (must be computed for each date)
timeZoneOffset = computeTimeZoneOffset;
} else if (typeof tz === "number") {
// Only set TZ offset
timeZoneOffset = tz * -60;
} else {
// Reset both TZ name & offset
timeZoneName = null;
timeZoneOffset = null;
}
for (const callback of timeZoneChangeCallbacks) {
callback(tz ?? DEFAULT_TIMEZONE_NAME);
}
}
class MockDateTimeFormat extends DateTimeFormat {
constructor(locales, options) {
super(locales, {
...options,
timeZone: options?.timeZone ?? timeZoneName ?? DEFAULT_TIMEZONE_NAME,
});
}
/** @type {Intl.DateTimeFormat["format"]} */
format(date) {
return super.format(date || new MockDate());
}
resolvedOptions() {
return {
...super.resolvedOptions(),
timeZone: timeZoneName ?? DEFAULT_TIMEZONE_NAME,
locale: locale ?? DEFAULT_LOCALE,
};
}
}
const DATE_REGEX =
/(?<year>\d{4})[/-](?<month>\d{2})[/-](?<day>\d{2})([\sT]+(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(\.(?<millisecond>\d{3}))?)?/;
const DEFAULT_DATE = [2019, 2, 11, 9, 30, 0, 0];
const DEFAULT_LOCALE = "en-US";
const DEFAULT_TIMEZONE_NAME = "Europe/Brussels";
const DEFAULT_TIMEZONE_OFFSET = -60;
/** @type {((tz: string | number) => any)[]} */
const timeZoneChangeCallbacks = [];
let dateParams = DEFAULT_DATE;
let dateTimeStamp = $now();
/** @type {string | null} */
let locale = null;
/** @type {string | null} */
let timeZoneName = null;
/** @type {number | ((date: Date) => number) | null} */
let timeZoneOffset = null;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function cleanupDate() {
setDateParams(DEFAULT_DATE);
locale = null;
timeZoneName = null;
timeZoneOffset = null;
}
/**
* Mocks the current date and time, and also the time zone if any.
*
* Date can either be an object describing the date and time to mock, or a
* string in SQL or ISO format (time and millisecond values can be omitted).
* @see {@link mockTimeZone} for the time zone params.
*
* @param {string | DateSpecs} [date]
* @param {string | number | null} [tz]
* @example
* mockDate("2023-12-25T20:45:00"); // 2023-12-25 20:45:00 UTC
* @example
* mockDate({ year: 2023, month: 12, day: 25, hour: 20, minute: 45 }); // same as above
* @example
* mockDate("2019-02-11 09:30:00.001", +2);
*/
export function mockDate(date, tz) {
setDateParams(date ? parseDateParams(date) : DEFAULT_DATE);
if (!isNil(tz)) {
setTimeZone(tz);
}
}
/**
* Mocks the current locale.
*
* If the time zone hasn't been mocked already, it will be assigned to the first
* time zone available in the given locale (if any).
*
* @param {string} newLocale
* @example
* mockTimeZone("ja-JP"); // UTC + 9
*/
export function mockLocale(newLocale) {
locale = newLocale;
if (!isNil(locale) && isNil(timeZoneName)) {
// Set TZ from locale (if not mocked already)
const firstAvailableTZ = new Locale(locale).timeZones?.[0];
if (!isNil(firstAvailableTZ)) {
setTimeZone(firstAvailableTZ);
}
}
}
/**
* Mocks the current time zone.
*
* Time zone can either be a time zone or an offset. Number offsets are expressed
* in hours.
*
* @param {string | number | null} [tz]
* @example
* mockTimeZone(+10); // UTC + 10
* @example
* mockTimeZone("Europe/Brussels"); // UTC + 1 (or UTC + 2 in summer)
* @example
* mockTimeZone(null) // Resets to test default (+1)
*/
export function mockTimeZone(tz) {
setTimeZone(tz);
}
/**
* Subscribe to changes made on the time zone (mocked) value.
*
* @param {(tz: string | number) => any} callback
*/
export function onTimeZoneChange(callback) {
timeZoneChangeCallbacks.push(callback);
}
export class MockDate extends Date {
constructor(...args) {
if (args.length === 1) {
super(args[0]);
} else {
const params = getDateParams();
for (let i = 0; i < params.length; i++) {
args[i] ??= params[i];
}
super($UTC(...args));
}
}
getTimezoneOffset() {
const offset = timeZoneOffset ?? DEFAULT_TIMEZONE_OFFSET;
return typeof offset === "function" ? offset(this) : offset;
}
static now() {
return new MockDate().getTime();
}
}
export const MockIntl = createMock(Intl, {
DateTimeFormat: { value: MockDateTimeFormat },
});

View file

@ -0,0 +1,91 @@
/** @odoo-module */
import { isNil, stringToNumber } from "../hoot_utils";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Math,
Number: { isNaN: $isNaN, parseFloat: $parseFloat },
Object: { defineProperties: $defineProperties },
} = globalThis;
const { floor: $floor, random: $random } = Math;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {unknown} [seed]
*/
function toValidSeed(seed) {
if (isNil(seed)) {
return generateSeed();
}
const nSeed = $parseFloat(seed);
return $isNaN(nSeed) ? stringToNumber(nSeed) : nSeed;
}
const DEFAULT_SEED = 1e16;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Generates a random 16-digit number.
* This function uses the native (unpatched) {@link Math.random} method.
*/
export function generateSeed() {
return $floor($random() * 1e16);
}
/**
* Returns a seeded random number generator equivalent to the native
* {@link Math.random} method.
*
* It exposes a `seed` property that can be changed at any time to reset the
* generator.
*
* @param {number} seed
* @example
* const randA = makeSeededRandom(1e16);
* const randB = makeSeededRandom(1e16);
* randA() === randB(); // true
* @example
* const random = makeSeededRandom(1e16);
* random() === random(); // false
*/
export function makeSeededRandom(seed) {
function random() {
state ^= (state << 13) >>> 0;
state ^= (state >>> 17) >>> 0;
state ^= (state << 5) >>> 0;
return ((state >>> 0) & 0x7fffffff) / 0x7fffffff; // Normalize to [0, 1)
}
let state = seed;
$defineProperties(random, {
seed: {
get() {
return seed;
},
set(value) {
seed = toValidSeed(value);
state = seed;
},
},
});
return random;
}
/**
* `random` function used internally to not generate unwanted calls on global
* `Math.random` function (and possibly having a different seed).
*/
export const internalRandom = makeSeededRandom(DEFAULT_SEED);

View file

@ -0,0 +1,328 @@
/** @odoo-module */
import { isInstanceOf } from "../../hoot-dom/hoot_dom_utils";
import { createMock, HootError, MIME_TYPE, MockEventTarget } from "../hoot_utils";
import { getSyncValue, setSyncValue } from "./sync_values";
/**
* @typedef {"android" | "ios" | "linux" | "mac" | "windows"} Platform
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Blob,
ClipboardItem = class NonSecureClipboardItem {},
navigator,
Object: { assign: $assign },
Set,
TypeError,
} = globalThis;
const { userAgent: $userAgent } = navigator;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
function getBlobValue(value) {
return isInstanceOf(value, Blob) ? value.text() : value;
}
/**
* Returns the final synchronous value of several item types.
*
* @param {unknown} value
* @param {string} type
*/
function getClipboardValue(value, type) {
return getBlobValue(isInstanceOf(value, ClipboardItem) ? value.getType(type) : value);
}
function getMockValues() {
return {
sendBeacon: throwNotImplemented("sendBeacon"),
userAgent: makeUserAgent("linux"),
/** @type {Navigator["vibrate"]} */
vibrate: throwNotImplemented("vibrate"),
};
}
/**
* @returns {Record<PermissionName, { name: string; state: PermissionState }>}
*/
function getPermissions() {
return {
"background-sync": {
state: "granted", // should always be granted
name: "background_sync",
},
"local-fonts": {
state: "denied",
name: "local_fonts",
},
"payment-handler": {
state: "denied",
name: "payment_handler",
},
"persistent-storage": {
state: "denied",
name: "durable_storage",
},
"screen-wake-lock": {
state: "denied",
name: "screen_wake_lock",
},
"storage-access": {
state: "denied",
name: "storage-access",
},
"window-management": {
state: "denied",
name: "window_placement",
},
accelerometer: {
state: "denied",
name: "sensors",
},
camera: {
state: "denied",
name: "video_capture",
},
geolocation: {
state: "denied",
name: "geolocation",
},
gyroscope: {
state: "denied",
name: "sensors",
},
magnetometer: {
state: "denied",
name: "sensors",
},
microphone: {
state: "denied",
name: "audio_capture",
},
midi: {
state: "denied",
name: "midi",
},
notifications: {
state: "denied",
name: "notifications",
},
push: {
state: "denied",
name: "push",
},
};
}
function getUserAgentBrowser() {
if (/Firefox/i.test($userAgent)) {
return "Gecko/20100101 Firefox/1000.0"; // Firefox
}
if (/Chrome/i.test($userAgent)) {
return "AppleWebKit/1000.00 (KHTML, like Gecko) Chrome/1000.00 Safari/1000.00"; // Chrome
}
if (/Safari/i.test($userAgent)) {
return "AppleWebKit/1000.00 (KHTML, like Gecko) Version/1000.00 Safari/1000.00"; // Safari
}
}
/**
* @param {Platform} platform
*/
function makeUserAgent(platform) {
const userAgent = ["Mozilla/5.0"];
switch (platform.toLowerCase()) {
case "android": {
userAgent.push("(Linux; Android 1000)");
break;
}
case "ios": {
userAgent.push("(iPhone; CPU iPhone OS 1000_0 like Mac OS X)");
break;
}
case "linux": {
userAgent.push("(X11; Linux x86_64)");
break;
}
case "mac":
case "macintosh": {
userAgent.push("(Macintosh; Intel Mac OS X 10_15_7)");
break;
}
case "win":
case "windows": {
userAgent.push("(Windows NT 10.0; Win64; x64)");
break;
}
default: {
userAgent.push(platform);
}
}
if (userAgentBrowser) {
userAgent.push(userAgentBrowser);
}
return userAgent.join(" ");
}
/**
* @param {string} fnName
*/
function throwNotImplemented(fnName) {
return function notImplemented() {
throw new HootError(`unmocked navigator method: ${fnName}`);
};
}
/** @type {Set<MockPermissionStatus>} */
const permissionStatuses = new Set();
const userAgentBrowser = getUserAgentBrowser();
const mockValues = getMockValues();
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockClipboard {
/** @type {unknown} */
_value = null;
async read() {
return this._value;
}
async readText() {
return String(getClipboardValue(this._value, MIME_TYPE.text) ?? "");
}
async write(value) {
this._value = value;
}
async writeText(value) {
this._value = String(getClipboardValue(value, MIME_TYPE.text) ?? "");
}
}
export class MockClipboardItem extends ClipboardItem {
constructor(items) {
super(items);
setSyncValue(this, items);
}
// Added synchronous methods to enhance speed in tests
async getType(type) {
return getSyncValue(this)[type];
}
}
export class MockPermissions {
/**
* @param {PermissionDescriptor} permissionDesc
*/
async query({ name }) {
if (!(name in currentPermissions)) {
throw new TypeError(
`The provided value '${name}' is not a valid enum value of type PermissionName`
);
}
return new MockPermissionStatus(name);
}
}
export class MockPermissionStatus extends MockEventTarget {
static publicListeners = ["change"];
/** @type {typeof currentPermissions[PermissionName]} */
_permission;
/**
* @param {PermissionName} name
* @param {PermissionState} value
*/
constructor(name) {
super(...arguments);
this._permission = currentPermissions[name];
permissionStatuses.add(this);
}
get name() {
return this._permission.name;
}
get state() {
return this._permission.state;
}
}
export const currentPermissions = getPermissions();
export const mockClipboard = new MockClipboard();
export const mockPermissions = new MockPermissions();
export const mockNavigator = createMock(navigator, {
clipboard: { value: mockClipboard },
maxTouchPoints: { get: () => (globalThis.ontouchstart === undefined ? 0 : 1) },
permissions: { value: mockPermissions },
sendBeacon: { get: () => mockValues.sendBeacon },
serviceWorker: { get: () => undefined },
userAgent: { get: () => mockValues.userAgent },
vibrate: { get: () => mockValues.vibrate },
});
export function cleanupNavigator() {
permissionStatuses.clear();
$assign(currentPermissions, getPermissions());
$assign(mockValues, getMockValues());
}
/**
* @param {PermissionName} name
* @param {PermissionState} [value]
*/
export function mockPermission(name, value) {
if (!(name in currentPermissions)) {
throw new TypeError(
`The provided value '${name}' is not a valid enum value of type PermissionName`
);
}
currentPermissions[name].state = value;
for (const permissionStatus of permissionStatuses) {
if (permissionStatus.name === name) {
permissionStatus.dispatchEvent(new Event("change"));
}
}
}
/**
* @param {Navigator["sendBeacon"]} callback
*/
export function mockSendBeacon(callback) {
mockValues.sendBeacon = callback;
}
/**
* @param {Platform} platform
*/
export function mockUserAgent(platform = "linux") {
mockValues.userAgent = makeUserAgent(platform);
}
/**
* @param {Navigator["vibrate"]} callback
*/
export function mockVibrate(callback) {
mockValues.vibrate = callback;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,79 @@
/** @odoo-module */
import { MockEventTarget } from "../hoot_utils";
import { currentPermissions } from "./navigator";
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Event, Promise, Set } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/** @type {Set<MockNotification>} */
const notifications = new Set();
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Returns the list of notifications that have been created since the last call
* to this function, consuming it in the process.
*
* @returns {MockNotification[]}
*/
export function flushNotifications() {
const result = [...notifications];
notifications.clear();
return result;
}
export class MockNotification extends MockEventTarget {
static publicListeners = ["click", "close", "error", "show"];
/** @type {NotificationPermission} */
static get permission() {
return currentPermissions.notifications.state;
}
/** @type {NotificationPermission} */
get permission() {
return this.constructor.permission;
}
/**
* @param {string} title
* @param {NotificationOptions} [options]
*/
constructor(title, options) {
super(...arguments);
this.title = title;
this.options = options;
if (this.permission === "granted") {
notifications.push(this);
}
}
static requestPermission() {
return Promise.resolve(this.permission);
}
click() {
this.dispatchEvent(new Event("click"));
}
close() {
notifications.delete(this);
this.dispatchEvent(new Event("close"));
}
show() {
this.dispatchEvent(new Event("show"));
}
}

View file

@ -0,0 +1,54 @@
/** @odoo-module */
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { keys: $keys },
StorageEvent,
String,
} = globalThis;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export class MockStorage {
get length() {
return $keys(this).length;
}
/** @type {typeof Storage.prototype.clear} */
clear() {
for (const key in this) {
delete this[key];
}
}
/** @type {typeof Storage.prototype.getItem} */
getItem(key) {
key = String(key);
return this[key] ?? null;
}
/** @type {typeof Storage.prototype.key} */
key(index) {
return $keys(this).at(index);
}
/** @type {typeof Storage.prototype.removeItem} */
removeItem(key) {
key = String(key);
delete this[key];
window.dispatchEvent(new StorageEvent("storage", { key, newValue: null }));
}
/** @type {typeof Storage.prototype.setItem} */
setItem(key, value) {
key = String(key);
value = String(value);
this[key] = value;
window.dispatchEvent(new StorageEvent("storage", { key, newValue: value }));
}
}

View file

@ -0,0 +1,48 @@
/** @odoo-module */
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Blob, TextEncoder } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const syncValues = new WeakMap();
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @param {any} object
*/
export function getSyncValue(object) {
return syncValues.get(object);
}
/**
* @param {any} object
* @param {any} value
*/
export function setSyncValue(object, value) {
syncValues.set(object, value);
}
export class MockBlob extends Blob {
constructor(blobParts, options) {
super(blobParts, options);
setSyncValue(this, blobParts);
}
async arrayBuffer() {
return new TextEncoder().encode(getSyncValue(this));
}
async text() {
return getSyncValue(this).join("");
}
}

View file

@ -0,0 +1,710 @@
/** @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;
}
}
};
}