Initial commit: Core packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:45 +02:00
commit 12c29a983b
9512 changed files with 8379910 additions and 0 deletions

View file

@ -0,0 +1,123 @@
/** @odoo-module **/
// -----------------------------------------------------------------------------
// Cleanup
// -----------------------------------------------------------------------------
const cleanups = [];
/**
* Register a cleanup callback that will be executed whenever the current test
* is done.
*
* - the cleanups will be executed in reverse order
* - they will be executed even if the test fails/crashes
*
* @param {Function} callback
*/
export function registerCleanup(callback) {
cleanups.push(callback);
}
if (window.QUnit) {
QUnit.on("OdooAfterTestHook", (info) => {
if (QUnit.config.debug) {
return;
}
let cleanup;
// note that this calls the cleanup callbacks in reverse order!
while ((cleanup = cleanups.pop())) {
try {
cleanup(info);
} catch (error) {
console.error(error);
}
}
});
// -----------------------------------------------------------------------------
// Check leftovers
// -----------------------------------------------------------------------------
/**
* List of elements tolerated in the body after a test. The property "keep"
* prevents the element from being removed (typically: qunit suite elements).
*/
const validElements = [
// always in the body:
{ tagName: "DIV", attr: "id", value: "qunit", keep: true },
{ tagName: "DIV", attr: "id", value: "qunit-fixture", keep: true },
// shouldn't be in the body after a test but are tolerated:
{ tagName: "SCRIPT", attr: "id", value: "" },
{ tagName: "DIV", attr: "class", value: "o_notification_manager" },
{ tagName: "DIV", attr: "class", value: "tooltip fade bs-tooltip-auto" },
{
tagName: "DIV",
attr: "class",
value: "tooltip fade bs-tooltip-auto show",
},
{
tagName: "DIV",
attr: "class",
value: "tooltip tooltip-field-info fade bs-tooltip-auto",
},
{
tagName: "DIV",
attr: "class",
value: "tooltip tooltip-field-info fade bs-tooltip-auto show",
},
{ tagName: "SPAN", attr: "class", value: "select2-hidden-accessible" },
// Due to a Document Kanban bug (already present in 12.0)
{ tagName: "DIV", attr: "class", value: "ui-helper-hidden-accessible" },
{
tagName: "UL",
attr: "class",
value: "ui-menu ui-widget ui-widget-content ui-autocomplete ui-front",
},
{
tagName: "UL",
attr: "class",
value: "ui-menu ui-widget ui-widget-content ui-autocomplete dropdown-menu ui-front", // many2ones
},
];
/**
* After each test, we check that there is no leftover in the DOM.
*
* Note: this event is not QUnit standard, we added it for this specific use case.
* As a payload, an object with keys 'moduleName' and 'testName' is provided. It
* is used to indicate the test that left elements in the DOM, when it happens.
*/
QUnit.on("OdooAfterTestHook", function (info) {
if (QUnit.config.debug) {
return;
}
const failed = info.testReport.getStatus() === "failed";
const toRemove = [];
// check for leftover elements in the body
for (const bodyChild of document.body.children) {
const tolerated = validElements.find(
(e) => e.tagName === bodyChild.tagName && bodyChild.getAttribute(e.attr) === e.value
);
if (!failed && !tolerated) {
QUnit.pushFailure(
`Body still contains undesirable elements:\n${bodyChild.outerHTML}`
);
}
if (!tolerated || !tolerated.keep) {
toRemove.push(bodyChild);
}
}
// cleanup leftovers in #qunit-fixture
const qunitFixture = document.getElementById("qunit-fixture");
if (qunitFixture.children.length) {
toRemove.push(...qunitFixture.children);
}
// remove unwanted elements if not in debug
for (const el of toRemove) {
el.remove();
}
document.body.classList.remove("modal-open");
});
}

View file

@ -0,0 +1,37 @@
odoo.define("web.SessionOverrideForTests", (require) => {
// Override the Session.session_reload function
// The wowl test infrastructure does set a correct odoo global value before each test
// while the session is built only once for all tests.
// So if a test does a session_reload, it will merge the odoo global of that test
// into the session, and will alter every subsequent test of the suite.
// Obviously, we don't want that, ever.
const { session: sessionInfo } = require("@web/session");
const initialSessionInfo = Object.assign({}, sessionInfo);
const Session = require("web.Session");
const { patch } = require("@web/core/utils/patch");
patch(Session.prototype, "web.SessionTestPatch", {
async session_reload() {
for (const key in sessionInfo) {
delete sessionInfo[key];
}
for (const key in initialSessionInfo) {
sessionInfo[key] = initialSessionInfo[key];
}
return await this._super(...arguments);
},
});
});
odoo.define("web.test_legacy", async (require) => {
require("web.SessionOverrideForTests");
require("web.test_utils");
const session = require("web.session");
await session.is_bound; // await for templates from server
const FormView = require("web.FormView");
const ListView = require("web.ListView");
const viewRegistry = require("web.view_registry");
viewRegistry.add("legacy_form", FormView).add("legacy_list", ListView);
return { legacyProm: session.is_bound };
});

View file

@ -0,0 +1,65 @@
/** @odoo-module */
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { patch, unpatch } from "@web/core/utils/patch";
import { makeLegacyDialogMappingService } from "@web/legacy/utils";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import core from "web.core";
import makeTestEnvironment from "web.test_env";
import { registerCleanup } from "./cleanup";
import { makeTestEnv } from "./mock_env";
import { patchWithCleanup } from "./utils";
import * as FavoriteMenu from "web.FavoriteMenu";
import * as CustomFavoriteItem from "web.CustomFavoriteItem";
const serviceRegistry = registry.category("services");
export async function makeLegacyDialogMappingTestEnv() {
const coreBusListeners = [];
patch(core.bus, "legacy.core.bus.listeners", {
on(eventName, thisArg, callback) {
this._super(...arguments);
coreBusListeners.push({ eventName, callback });
},
});
const legacyEnv = makeTestEnvironment({ bus: core.bus });
serviceRegistry.add("ui", uiService);
serviceRegistry.add("hotkey", hotkeyService);
serviceRegistry.add("legacy_dialog_mapping", makeLegacyDialogMappingService(legacyEnv));
const env = await makeTestEnv();
legacyEnv.services.hotkey = env.services.hotkey;
legacyEnv.services.ui = env.services.ui;
registerCleanup(() => {
for (const listener of coreBusListeners) {
core.bus.off(listener.eventName, listener.callback);
}
unpatch(core.bus, "legacy.core.bus.listeners");
});
return {
legacyEnv,
env,
};
}
function clearLegacyRegistryWithCleanup(r) {
const patch = {
// To improve? Initial data in registry is ignored.
map: Object.create(null),
// Preserve onAdd listeners
listeners: [...r.listeners],
_scoreMapping: Object.create(null),
_sortedKeys: null,
};
patchWithCleanup(r, patch);
}
export function prepareLegacyRegistriesWithCleanup() {
// Clear FavoriteMenu registry and add the "Save favorite" item.
clearLegacyRegistryWithCleanup(FavoriteMenu.registry);
FavoriteMenu.registry.add("favorite-generator-menu", CustomFavoriteItem, 0);
}

View file

@ -0,0 +1,136 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { makeEnv, startServices } from "@web/env";
import FormController from "web.FormController";
import { SERVICES_METADATA } from "../../src/env";
import { registerCleanup } from "./cleanup";
import { makeMockServer } from "./mock_server";
import { mocks } from "./mock_services";
import { patchWithCleanup } from "./utils";
function prepareRegistry(registry, keepContent = false) {
const _addEventListener = registry.addEventListener.bind(registry);
const _removeEventListener = registry.removeEventListener.bind(registry);
const patch = {
content: keepContent ? { ...registry.content } : {},
elements: null,
entries: null,
subRegistries: {},
addEventListener(type, callback) {
_addEventListener(type, callback);
registerCleanup(() => {
_removeEventListener(type, callback);
});
},
};
patchWithCleanup(registry, patch);
}
export function clearRegistryWithCleanup(registry) {
prepareRegistry(registry);
}
function cloneRegistryWithCleanup(registry) {
prepareRegistry(registry, true);
}
export function clearServicesMetadataWithCleanup() {
const servicesMetadata = Object.assign({}, SERVICES_METADATA);
for (const key of Object.keys(SERVICES_METADATA)) {
delete SERVICES_METADATA[key];
}
registerCleanup(() => {
for (const key of Object.keys(SERVICES_METADATA)) {
delete SERVICES_METADATA[key];
}
Object.assign(SERVICES_METADATA, servicesMetadata);
});
}
function prepareRegistriesWithCleanup() {
// Clone registries
cloneRegistryWithCleanup(registry.category("actions"));
cloneRegistryWithCleanup(registry.category("views"));
cloneRegistryWithCleanup(registry.category("error_handlers"));
cloneRegistryWithCleanup(registry.category("command_provider"));
cloneRegistryWithCleanup(registry.category("command_setup"));
cloneRegistryWithCleanup(registry.category("view_widgets"));
cloneRegistryWithCleanup(registry.category("fields"));
cloneRegistryWithCleanup(registry.category("wowlToLegacyServiceMappers"));
cloneRegistryWithCleanup(registry.category("main_components"));
cloneRegistryWithCleanup(registry.category("fields"));
// Clear registries
clearRegistryWithCleanup(registry.category("command_categories"));
clearRegistryWithCleanup(registry.category("debug"));
clearRegistryWithCleanup(registry.category("error_dialogs"));
clearRegistryWithCleanup(registry.category("favoriteMenu"));
clearRegistryWithCleanup(registry.category("ir.actions.report handlers"));
clearRegistryWithCleanup(registry.category("wowlToLegacyServiceMappers"));
clearRegistryWithCleanup(registry.category("services"));
clearServicesMetadataWithCleanup();
clearRegistryWithCleanup(registry.category("systray"));
clearRegistryWithCleanup(registry.category("user_menuitems"));
clearRegistryWithCleanup(registry.category("kanban_examples"));
clearRegistryWithCleanup(registry.category("__processed_archs__"));
clearRegistryWithCleanup(registry.category("action_menus"));
// fun fact: at least one registry is missing... this shows that we need a
// better design for the way we clear these registries...
}
// This is exported in a utils object to allow for patching
export const utils = {
prepareRegistriesWithCleanup,
};
/**
* @typedef {import("@web/env").OdooEnv} OdooEnv
*/
/**
* Create a test environment
*
* @param {*} config
* @returns {Promise<OdooEnv>}
*/
export async function makeTestEnv(config = {}) {
// add all missing dependencies if necessary
const serviceRegistry = registry.category("services");
const servicesToProcess = serviceRegistry.getAll();
while (servicesToProcess.length) {
const service = servicesToProcess.pop();
if (service.dependencies) {
for (const depName of service.dependencies) {
if (depName in mocks && !serviceRegistry.contains(depName)) {
const dep = mocks[depName]();
serviceRegistry.add(depName, dep);
servicesToProcess.push(dep);
}
}
}
}
if (config.serverData || config.mockRPC || config.activateMockServer) {
await makeMockServer(config.serverData, config.mockRPC);
}
// remove the multi-click delay for the quick edit in form views
// todo: move this elsewhere (setup?)
const initialQuickEditDelay = FormController.prototype.multiClickTime;
FormController.prototype.multiClickTime = 0;
registerCleanup(() => {
FormController.prototype.multiClickTime = initialQuickEditDelay;
});
let env = makeEnv();
await startServices(env);
owl.Component.env = env;
if ("config" in config) {
env = Object.assign(Object.create(env), { config: config.config });
}
return env;
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,343 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { routerService } from "@web/core/browser/router_service";
import { localization } from "@web/core/l10n/localization";
import { _t } from "@web/core/l10n/translation";
import { rpcService } from "@web/core/network/rpc_service";
import { userService } from "@web/core/user_service";
import { effectService } from "@web/core/effects/effect_service";
import { objectToUrlEncodedString } from "@web/core/utils/urls";
import { registerCleanup } from "./cleanup";
import { patchWithCleanup } from "./utils";
import { uiService } from "@web/core/ui/ui_service";
import { ConnectionAbortedError } from "../../src/core/network/rpc_service";
import { Component, status } from "@odoo/owl";
// -----------------------------------------------------------------------------
// Mock Services
// -----------------------------------------------------------------------------
export const defaultLocalization = {
dateFormat: "MM/dd/yyyy",
timeFormat: "HH:mm:ss",
dateTimeFormat: "MM/dd/yyyy HH:mm:ss",
decimalPoint: ".",
direction: "ltr",
grouping: [],
multiLang: false,
thousandsSep: ",",
weekStart: 7,
};
/**
* @param {Partial<typeof defaultLocalization>} [config]
*/
export function makeFakeLocalizationService(config = {}) {
patchWithCleanup(localization, Object.assign({}, defaultLocalization, config));
return {
name: "localization",
start: async (env) => {
env._t = _t;
},
};
}
function buildMockRPC(mockRPC) {
return async function (...args) {
if (this instanceof Component && status(this) === "destroyed") {
return new Promise(() => {});
}
if (mockRPC) {
return mockRPC(...args);
}
};
}
export function makeFakeRPCService(mockRPC) {
return {
name: "rpc",
start() {
const rpcService = buildMockRPC(mockRPC);
return function () {
let rejectFn;
const rpcProm = new Promise((resolve, reject) => {
rejectFn = reject;
rpcService(...arguments)
.then(resolve)
.catch(reject);
});
rpcProm.abort = (rejectError = true) => {
if (rejectError) {
rejectFn(new ConnectionAbortedError("XmlHttpRequestError abort"));
}
};
return rpcProm;
};
},
specializeForComponent: rpcService.specializeForComponent,
};
}
export function makeMockXHR(response, sendCb, def) {
const MockXHR = function () {
return {
_loadListener: null,
url: "",
addEventListener(type, listener) {
if (type === "load") {
this._loadListener = listener;
} else if (type === "error") {
this._errorListener = listener;
}
},
set onload(listener) {
this._loadListener = listener;
},
set onerror(listener) {
this._errorListener = listener;
},
open(method, url) {
this.url = url;
},
getResponseHeader() {},
setRequestHeader() {},
async send(data) {
let listener = this._loadListener;
if (sendCb) {
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch (_e) {
// Ignore
}
}
try {
await sendCb.call(this, data);
} catch (_e) {
listener = this._errorListener;
}
}
if (def) {
await def;
}
listener.call(this);
},
response: JSON.stringify(response || ""),
};
};
return MockXHR;
}
// -----------------------------------------------------------------------------
// Low level API mocking
// -----------------------------------------------------------------------------
export function makeMockFetch(mockRPC) {
const _rpc = buildMockRPC(mockRPC);
return async (input, params) => {
let route = typeof input === "string" ? input : input.url;
if (route.includes("load_menus")) {
const routeArray = route.split("/");
params = {
hash: routeArray.pop(),
};
route = routeArray.join("/");
}
let res;
let status;
try {
res = await _rpc(route, params);
status = 200;
} catch (_e) {
res = { error: _e.message };
status = 500;
}
const blob = new Blob([JSON.stringify(res || {})], { type: "application/json" });
const response = new Response(blob, { status });
// Mock some functions of the Response API to make them almost synchronous (micro-tick level)
// as their native implementation is async (tick level), which can lead to undeterministic
// errors as it breaks the hypothesis that calling nextTick after fetching data is enough
// to see the result rendered in the DOM.
response.json = () => Promise.resolve(JSON.parse(JSON.stringify(res || {})));
response.text = () => Promise.resolve(String(res || {}));
response.blob = () => Promise.resolve(blob);
return response;
};
}
/**
* @param {Object} [params={}]
* @param {Object} [params.onRedirect] hook on the "redirect" method
* @returns {typeof routerService}
*/
export function makeFakeRouterService(params = {}) {
return {
start({ bus }) {
const router = routerService.start(...arguments);
bus.addEventListener("test:hashchange", (ev) => {
const hash = ev.detail;
browser.location.hash = objectToUrlEncodedString(hash);
});
registerCleanup(router.cancelPushes);
patchWithCleanup(router, {
async redirect() {
await this._super(...arguments);
if (params.onRedirect) {
params.onRedirect(...arguments);
}
},
});
return router;
},
};
}
export const fakeCommandService = {
start() {
return {
add() {
return () => {};
},
getCommands() {
return [];
},
openPalette() {},
};
},
};
export const fakeCookieService = {
start() {
const cookie = {};
return {
get current() {
return cookie;
},
setCookie(key, value) {
if (value !== undefined) {
cookie[key] = value;
}
},
deleteCookie(key) {
delete cookie[key];
},
};
},
};
export const fakeTitleService = {
start() {
let current = {};
return {
get current() {
return JSON.stringify(current);
},
getParts() {
return current;
},
setParts(parts) {
current = Object.assign({}, current, parts);
},
};
},
};
export const fakeColorSchemeService = {
start() {
return {
switchToColorScheme() {},
};
},
};
export function makeFakeNotificationService(mock) {
return {
start() {
function add() {
if (mock) {
return mock(...arguments);
}
}
return {
add,
};
},
};
}
export function makeFakeDialogService(addDialog, closeAllDialog) {
return {
start() {
return {
add: addDialog || (() => () => {}),
closeAll: closeAllDialog || (() => () => {}),
};
},
};
}
export function makeFakeUserService(hasGroup = () => false) {
return {
...userService,
start() {
const fakeUserService = userService.start(...arguments);
fakeUserService.hasGroup = hasGroup;
return fakeUserService;
},
};
}
export const fakeCompanyService = {
start() {
return {
availableCompanies: {},
allowedCompanyIds: [],
currentCompany: {},
setCompanies: () => {},
};
},
};
export function makeFakeHTTPService(getResponse, postResponse) {
getResponse =
getResponse ||
((route, readMethod) => {
return readMethod === "json" ? {} : "";
});
postResponse =
postResponse ||
((route, params, readMethod) => {
return readMethod === "json" ? {} : "";
});
return {
start() {
return {
async get(...args) {
return getResponse(...args);
},
async post(...args) {
return postResponse(...args);
},
};
},
};
}
export const mocks = {
color_scheme: () => fakeColorSchemeService,
company: () => fakeCompanyService,
command: () => fakeCommandService,
cookie: () => fakeCookieService,
effect: () => effectService, // BOI The real service ? Is this what we want ?
localization: makeFakeLocalizationService,
notification: makeFakeNotificationService,
router: makeFakeRouterService,
rpc: makeFakeRPCService,
title: () => fakeTitleService,
ui: () => uiService,
user: () => userService,
dialog: makeFakeDialogService,
};

View file

@ -0,0 +1,909 @@
/** @odoo-module **/
import { browser } from "@web/core/browser/browser";
import { isMacOS } from "@web/core/browser/feature_detection";
import { download } from "@web/core/network/download";
import { Deferred } from "@web/core/utils/concurrency";
import { patch, unpatch } from "@web/core/utils/patch";
import { isVisible } from "@web/core/utils/ui";
import { registerCleanup } from "./cleanup";
import { templates } from "@web/core/assets";
import { App, onMounted, onPatched, useComponent } from "@odoo/owl";
/**
* Patch the native Date object
*
* Note that it will be automatically unpatched at the end of the test
*
* @param {number} [year]
* @param {number} [month]
* @param {number} [day]
* @param {number} [hours]
* @param {number} [minutes]
* @param {number} [seconds]
*/
export function patchDate(year, month, day, hours, minutes, seconds) {
var RealDate = window.Date;
var actualDate = new RealDate();
// By default, RealDate uses the browser offset, so we must replace it with the offset fixed in luxon.
var fakeDate = new RealDate(year, month, day, hours, minutes, seconds);
if (!(luxon.Settings.defaultZone instanceof luxon.FixedOffsetZone)) {
throw new Error("luxon.Settings.defaultZone must be a FixedOffsetZone");
}
const browserOffset = -fakeDate.getTimezoneOffset();
const patchedOffset = luxon.Settings.defaultZone.offset();
const offsetDiff = patchedOffset - browserOffset;
const correctedMinutes = fakeDate.getMinutes() - offsetDiff;
fakeDate.setMinutes(correctedMinutes);
var timeInterval = actualDate.getTime() - fakeDate.getTime();
// eslint-disable-next-line no-global-assign
window.Date = (function (NativeDate) {
function Date(Y, M, D, h, m, s, ms) {
var length = arguments.length;
let date;
if (arguments.length > 0) {
date =
length == 1 && String(Y) === Y // isString(Y)
? // We explicitly pass it through parse:
new NativeDate(Date.parse(Y))
: // We have to manually make calls depending on argument
// length here
length >= 7
? new NativeDate(Y, M, D, h, m, s, ms)
: length >= 6
? new NativeDate(Y, M, D, h, m, s)
: length >= 5
? new NativeDate(Y, M, D, h, m)
: length >= 4
? new NativeDate(Y, M, D, h)
: length >= 3
? new NativeDate(Y, M, D)
: length >= 2
? new NativeDate(Y, M)
: length >= 1
? new NativeDate(Y)
: new NativeDate();
// Prevent mixups with unfixed Date object
date.constructor = Date;
return date;
} else {
date = new NativeDate();
var time = date.getTime();
time -= timeInterval;
date.setTime(time);
return date;
}
}
// Copy any custom methods a 3rd party library may have added
for (var key in NativeDate) {
Date[key] = NativeDate[key];
}
// Copy "native" methods explicitly; they may be non-enumerable
// exception: 'now' uses fake date as reference
Date.now = function () {
var date = new NativeDate();
var time = date.getTime();
time -= timeInterval;
return time;
};
Date.UTC = NativeDate.UTC;
Date.prototype = NativeDate.prototype;
Date.prototype.constructor = Date;
// Upgrade Date.parse to handle simplified ISO 8601 strings
Date.parse = NativeDate.parse;
return Date;
})(Date);
registerCleanup(() => {
window.Date = RealDate;
});
}
/**
* Applies a fixed time zone to luxon based on an offset to the UTC time zone.
*
* @param {number} offset the number of minutes ahead or behind the UTC time zone
* +120 => UTC+2
* -120 => UTC-2
*/
export function patchTimeZone(offset) {
const originalZone = luxon.Settings.defaultZone;
luxon.Settings.defaultZone = new luxon.FixedOffsetZone.instance(offset);
registerCleanup(() => {
luxon.Settings.defaultZone = originalZone;
});
}
let nextId = 1;
/**
*
* @param {Object} obj object to patch
* @param {Object} patchValue the actual patch description
* @param {{pure?: boolean}} [options]
*/
export function patchWithCleanup(obj, patchValue, options) {
const patchName = `__test_patch_${nextId++}__`;
patch(obj, patchName, patchValue, options);
registerCleanup(() => {
unpatch(obj, patchName);
});
}
/**
* @returns {HTMLElement}
*/
export function getFixture() {
if (!window.QUnit) {
return document;
}
if (QUnit.config.debug) {
return document.body;
} else {
return document.querySelector("#qunit-fixture");
}
}
export async function nextTick() {
await new Promise((resolve) => window.requestAnimationFrame(resolve));
await new Promise((resolve) => setTimeout(resolve));
}
export function makeDeferred() {
return new Deferred();
}
export function findElement(el, selector) {
let target = el;
if (selector) {
const els = el.querySelectorAll(selector);
if (els.length === 0) {
throw new Error(`No element found (selector: ${selector})`);
}
if (els.length > 1) {
throw new Error(`Found ${els.length} elements, instead of 1 (selector: ${selector})`);
}
target = els[0];
}
return target;
}
function keyboardEventBubble(args) {
return Object.assign({}, args, {
bubbles: true,
keyCode: args.which,
cancelable: true,
});
}
function mouseEventMapping(args) {
return {
clientX: args ? args.pageX : undefined,
clientY: args ? args.pageY : undefined,
...args,
bubbles: true,
cancelable: true,
view: window,
};
}
function mouseEventNoBubble(args) {
return {
clientX: args ? args.pageX : undefined,
clientY: args ? args.pageY : undefined,
...args,
bubbles: false,
cancelable: false,
view: window,
};
}
function touchEventMapping(args) {
return {
...args,
cancelable: true,
bubbles: true,
composed: true,
view: window,
rotation: 0.0,
zoom: 1.0,
touches: args.touches ? [...args.touches.map((e) => new Touch(e))] : undefined,
};
}
function touchEventCancelMapping(args) {
return {
...touchEventMapping(args),
cancelable: false,
};
}
function noBubble(args) {
return Object.assign({}, args, { bubbles: false });
}
function onlyBubble(args) {
return Object.assign({}, args, { bubbles: true });
}
// TriggerEvent constructor/args processor mapping
const EVENT_TYPES = {
auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
click: { constructor: MouseEvent, processParameters: mouseEventMapping },
contextmenu: {
constructor: MouseEvent,
processParameters: mouseEventMapping,
},
dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping },
mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping },
mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping },
mouseenter: {
constructor: MouseEvent,
processParameters: mouseEventNoBubble,
},
mouseleave: {
constructor: MouseEvent,
processParameters: mouseEventNoBubble,
},
mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping },
mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping },
focus: { constructor: FocusEvent, processParameters: noBubble },
focusin: { constructor: FocusEvent, processParameters: onlyBubble },
blur: { constructor: FocusEvent, processParameters: noBubble },
cut: { constructor: ClipboardEvent, processParameters: onlyBubble },
copy: { constructor: ClipboardEvent, processParameters: onlyBubble },
paste: { constructor: ClipboardEvent, processParameters: onlyBubble },
keydown: {
constructor: KeyboardEvent,
processParameters: keyboardEventBubble,
},
keypress: {
constructor: KeyboardEvent,
processParameters: keyboardEventBubble,
},
keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
drag: { constructor: DragEvent, processParameters: onlyBubble },
dragend: { constructor: DragEvent, processParameters: onlyBubble },
dragenter: { constructor: DragEvent, processParameters: onlyBubble },
dragstart: { constructor: DragEvent, processParameters: onlyBubble },
dragleave: { constructor: DragEvent, processParameters: onlyBubble },
dragover: { constructor: DragEvent, processParameters: onlyBubble },
drop: { constructor: DragEvent, processParameters: onlyBubble },
input: { constructor: InputEvent, processParameters: onlyBubble },
compositionstart: {
constructor: CompositionEvent,
processParameters: onlyBubble,
},
compositionend: {
constructor: CompositionEvent,
processParameters: onlyBubble,
},
};
if (typeof TouchEvent === "function") {
Object.assign(EVENT_TYPES, {
touchstart: {
constructor: TouchEvent,
processParameters: touchEventMapping,
},
touchend: { constructor: TouchEvent, processParameters: touchEventMapping },
touchmove: {
constructor: TouchEvent,
processParameters: touchEventMapping,
},
touchcancel: {
constructor: TouchEvent,
processParameters: touchEventCancelMapping,
},
});
}
function _makeEvent(eventType, eventAttrs) {
let event;
if (eventType in EVENT_TYPES) {
const { constructor, processParameters } = EVENT_TYPES[eventType];
event = new constructor(eventType, processParameters(eventAttrs));
} else {
event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true }));
}
return event;
}
export function triggerEvent(el, selector, eventType, eventAttrs = {}, options = {}) {
const event = _makeEvent(eventType, eventAttrs);
const target = findElement(el, selector);
if (!target) {
throw new Error(`Can't find a target to trigger ${eventType} event`);
}
if (!options.skipVisibilityCheck) {
if (!isVisible(target)) {
throw new Error(`Called triggerEvent ${eventType} on invisible target`);
}
}
target.dispatchEvent(event);
if (!options.fast) {
return nextTick().then(() => event);
}
return event;
}
export async function triggerEvents(el, querySelector, events, options) {
for (let e = 0; e < events.length; e++) {
if (Array.isArray(events[e])) {
triggerEvent(el, querySelector, events[e][0], events[e][1], options);
} else {
triggerEvent(el, querySelector, events[e], {}, options);
}
}
await nextTick();
}
/**
* Triggers a scroll event on the given target
*
* If the target cannot be scrolled or an axis has reached
* the end of the scrollable area, the event can be transmitted
* to its nearest parent until it can be triggered
*
* @param {HTMLElement} target target of the scroll event
* @param {Object} coordinates
* @param {Number} coordinates[left] coordinates to scroll horizontally
* @param {Number} coordinates[top] coordinates to scroll vertically
* @param {Boolean} canPropagate states if the scroll can propagate to a scrollable parent
*/
export async function triggerScroll(
target,
coordinates = { left: null, top: null },
canPropagate = true
) {
const isScrollable =
(target.scrollHeight > target.clientHeight && target.clientHeight > 0) ||
(target.scrollWidth > target.clientWidth && target.clientWidth > 0);
if (!isScrollable && !canPropagate) {
return;
}
if (isScrollable) {
const canScrollFrom = {
left:
coordinates.left > target.scrollLeft
? target.scrollLeft + target.clientWidth < target.scrollWidth
: target.scrollLeft > 0,
top:
coordinates.top > target.scrollTop
? target.scrollTop + target.clientHeight < target.scrollHeight
: target.scrollTop > 0,
};
const scrollCoordinates = {};
Object.entries(coordinates).forEach(([key, value]) => {
if (value !== null && canScrollFrom[key]) {
scrollCoordinates[key] = value;
delete coordinates[key];
}
});
target.scrollTo(scrollCoordinates);
target.dispatchEvent(new UIEvent("scroll"));
await nextTick();
if (!canPropagate || !Object.entries(coordinates).length) {
return;
}
}
target.parentElement
? triggerScroll(target.parentElement, coordinates)
: window.dispatchEvent(new UIEvent("scroll"));
await nextTick();
}
export function click(el, selector, skipVisibilityCheck = false) {
return triggerEvent(
el,
selector,
"click",
{ bubbles: true, cancelable: true },
{ skipVisibilityCheck }
);
}
export function clickCreate(htmlElement) {
if (htmlElement.querySelectorAll(".o_form_button_create").length) {
return click(htmlElement, ".o_form_button_create");
} else if (htmlElement.querySelectorAll(".o_list_button_create").length) {
return click(htmlElement, ".o_list_button_create");
} else {
throw new Error("No edit button found to be clicked.");
}
}
export function clickEdit(htmlElement) {
if (htmlElement.querySelectorAll(".o_list_button_edit").length) {
return click(htmlElement, ".o_list_button_edit");
} else {
throw new Error("No edit button found to be clicked.");
}
}
export async function clickSave(htmlElement) {
if (htmlElement.querySelectorAll(".o_form_status_indicator").length) {
await mouseEnter(htmlElement, ".o_form_status_indicator");
}
if (htmlElement.querySelectorAll(".o_form_button_save").length) {
return click(htmlElement, ".o_form_button_save");
} else if (htmlElement.querySelectorAll(".o_list_button_save").length) {
return click(htmlElement, ".o_list_button_save");
} else {
throw new Error("No save button found to be clicked.");
}
}
export async function clickDiscard(htmlElement) {
if (htmlElement.querySelectorAll(".o_form_status_indicator").length) {
await mouseEnter(htmlElement, ".o_form_status_indicator");
}
if (htmlElement.querySelectorAll(".o_form_button_cancel").length) {
return click(htmlElement, ".o_form_button_cancel");
} else if (htmlElement.querySelectorAll(".o_list_button_discard").length) {
return click(htmlElement, ".o_list_button_discard");
} else {
throw new Error("No discard button found to be clicked.");
}
}
/**
* Triggers a mouseenter event on the given target. If no
* coordinates are given, the event is located by default
* in the middle of the target to simplify the test process
*
* @param {HTMLElement} el
* @param {string} selector
* @param {Object} coordinates position of the mouseenter event
*/
export async function mouseEnter(el, selector, coordinates) {
const target = el.querySelector(selector) || el;
const atPos = coordinates || {
clientX: target.getBoundingClientRect().left + target.getBoundingClientRect().width / 2,
clientY: target.getBoundingClientRect().top + target.getBoundingClientRect().height / 2,
};
return triggerEvent(target, null, "mouseenter", atPos);
}
export async function editInput(el, selector, value) {
const input = findElement(el, selector);
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement)) {
throw new Error("Only 'input' and 'textarea' elements can be edited with 'editInput'.");
}
if (
!["text", "textarea", "email", "search", "color", "number", "file", "tel"].includes(
input.type
)
) {
throw new Error(`Type "${input.type}" not supported by 'editInput'.`);
}
const eventOpts = {};
if (input.type === "file") {
const files = Array.isArray(value) ? value : [value];
const dataTransfer = new DataTransfer();
for (const file of files) {
if (!(file instanceof File)) {
throw new Error(`File input value should be one or several File objects.`);
}
dataTransfer.items.add(file);
}
input.files = dataTransfer.files;
eventOpts.skipVisibilityCheck = true;
} else {
input.value = value;
}
await triggerEvents(input, null, ["input", "change"], eventOpts);
if (input.type === "file") {
// Need to wait for the file to be loaded by the input
await nextTick();
await nextTick();
}
}
export function editSelect(el, selector, value) {
const select = findElement(el, selector);
if (select.tagName !== "SELECT") {
throw new Error("Only select tag can be edited with selectInput.");
}
select.value = value;
return triggerEvent(select, null, "change");
}
/**
* Triggers an hotkey properly disregarding the operating system.
*
* @param {string} hotkey
* @param {boolean} addOverlayModParts
* @param {KeyboardEventInit} eventAttrs
* @returns {{ keydownEvent: KeyboardEvent, keyupEvent: KeyboardEvent }}
*/
export function triggerHotkey(hotkey, addOverlayModParts = false, eventAttrs = {}) {
eventAttrs.key = hotkey.split("+").pop();
if (/shift/i.test(hotkey)) {
eventAttrs.shiftKey = true;
}
if (/control/i.test(hotkey)) {
if (isMacOS()) {
eventAttrs.metaKey = true;
} else {
eventAttrs.ctrlKey = true;
}
}
if (/alt/i.test(hotkey) || addOverlayModParts) {
if (isMacOS()) {
eventAttrs.ctrlKey = true;
} else {
eventAttrs.altKey = true;
}
}
if (!("bubbles" in eventAttrs)) {
eventAttrs.bubbles = true;
}
const keydownEvent = new KeyboardEvent("keydown", eventAttrs);
const keyupEvent = new KeyboardEvent("keyup", eventAttrs);
document.activeElement.dispatchEvent(keydownEvent);
document.activeElement.dispatchEvent(keyupEvent);
return { keydownEvent, keyupEvent };
}
export async function legacyExtraNextTick() {
return nextTick();
}
export function mockDownload(cb) {
patchWithCleanup(download, { _download: cb });
}
export const hushConsole = Object.create(null);
for (const propName of Object.keys(window.console)) {
hushConsole[propName] = () => {};
}
export function mockSendBeacon(mock) {
patchWithCleanup(navigator, {
sendBeacon: (url, blob) => {
return mock(url, blob) !== false;
},
});
}
export function mockTimeout() {
const timeouts = new Map();
let currentTime = 0;
let id = 1;
patchWithCleanup(browser, {
setTimeout(fn, delay = 0) {
timeouts.set(id, { fn, scheduledFor: delay + currentTime, id });
return id++;
},
clearTimeout(id) {
timeouts.delete(id);
},
});
return {
execRegisteredTimeouts() {
for (const { fn } of timeouts.values()) {
fn();
}
timeouts.clear();
},
async advanceTime(duration) {
// wait here so all microtasktick scheduled in this frame can be
// executed and possibly register their own timeout
await nextTick();
currentTime += duration;
for (const { fn, scheduledFor, id } of timeouts.values()) {
if (scheduledFor <= currentTime) {
fn();
timeouts.delete(id);
}
}
// wait here to make sure owl can update the UI
await nextTick();
},
};
}
export function mockAnimationFrame() {
const callbacks = new Map();
let id = 1;
patchWithCleanup(browser, {
requestAnimationFrame(fn) {
callbacks.set(id, fn);
return id++;
},
cancelAnimationFrame(id) {
callbacks.delete(id);
},
});
return function execRegisteredCallbacks() {
for (const fn of callbacks.values()) {
fn();
}
callbacks.clear();
};
}
export async function mount(Comp, target, config = {}) {
let { props, env } = config;
env = env || {};
const configuration = {
env,
templates,
test: true,
props,
};
if (env.services && "localization" in env.services) {
configuration.translateFn = env._t;
}
const app = new App(Comp, configuration);
registerCleanup(() => app.destroy());
return app.mount(target);
}
export function destroy(comp) {
comp.__owl__.app.destroy();
}
export function findChildren(comp, predicate = (e) => e) {
const queue = [];
[].unshift.apply(queue, Object.values(comp.__owl__.children));
while (queue.length > 0) {
const curNode = queue.pop();
if (predicate(curNode)) {
return curNode;
}
[].unshift.apply(queue, Object.values(curNode.component.__owl__.children));
}
}
// partial replacement of t-ref on component
export function useChild() {
const node = useComponent().__owl__;
const setChild = () => {
const componentNode = Object.values(node.children)[0];
node.component.child = componentNode.component;
};
onMounted(setChild);
onPatched(setChild);
}
const lifeCycleHooks = [
"onError",
"onMounted",
"onPatched",
"onRendered",
"onWillDestroy",
"onWillPatch",
"onWillRender",
"onWillStart",
"onWillUnmount",
"onWillUpdateProps",
];
export function useLogLifeCycle(logFn, name = "") {
const component = owl.useComponent();
let loggedName = `${component.constructor.name}`;
if (name) {
loggedName = `${component.constructor.name} ${name}`;
}
for (const hook of lifeCycleHooks) {
owl[hook](() => {
logFn(`${hook} ${loggedName}`);
});
}
}
/**
* Returns the list of nodes containing n2 (included) that do not contain n1.
*
* @param {Node} n1
* @param {Node} n2
* @returns {Node[]}
*/
function getDifferentParents(n1, n2) {
const parents = [n2];
while (parents[0].parentNode) {
const parent = parents[0].parentNode;
if (parent.contains(n1)) {
break;
}
parents.unshift(parent);
}
return parents;
}
/**
* Helper performing a drag and drop sequence.
*
* - the 'fromSelector' is used to determine the element on which the drag will
* start;
* - the 'toSelector' will determine the element on which the first one will be
* dropped.
*
* The first element will be dragged by its center, and will be dropped on the
* bottom-right inner pixel of the target element. This behavior covers both
* cases of appending the first element to the end of a list (toSelector =
* target list) or moving it at the position of another element, effectively
* placing the first element before the second (toSelector = other element).
*
* A position can be given to drop the first element above, below, or on the
* side of the second (default is inside, as specified above).
*
* Note that only the last event is awaited, since all the others are
* considered to be synchronous.
*
* @param {Element|string} from
* @param {Element|string} to
* @param {string} [position] "top" | "bottom" | "left" | "right"
* @returns {Promise<void>}
*/
export async function dragAndDrop(from, to, position) {
const dropFunction = drag(from, to, position);
await dropFunction();
}
/**
* Helper performing a drag.
*
* - the 'from' selector is used to determine the element on which the drag will
* start;
* - the 'to' selector will determine the element on which the dragged element will be
* moved.
*
* Returns a drop function
* @param {Element|string} from
* @param {Element|string} to
* @param {string} [position] "top" | "bottom" | "left" | "right"
* @returns {function: Promise<void>}
*/
export function drag(from, to, position) {
const fixture = getFixture();
from = from instanceof Element ? from : fixture.querySelector(from);
to = to instanceof Element ? to : fixture.querySelector(to);
// Mouse down on main target
const fromRect = from.getBoundingClientRect();
const toRect = to.getBoundingClientRect();
triggerEvent(from, null, "mousedown", {
clientX: fromRect.x + fromRect.width / 2,
clientY: fromRect.y + fromRect.height / 2,
});
// Find target position
const toPos = {
clientX: toRect.x + toRect.width / 2,
clientY: toRect.y + toRect.height / 2,
};
if (position && typeof position === "object") {
// x and y coordinates start from the element's initial coordinates
toPos.clientX += position.x || 0;
toPos.clientY += position.y || 0;
} else {
switch (position) {
case "top": {
toPos.clientY = toRect.y - 1;
break;
}
case "bottom": {
toPos.clientY = toRect.y + toRect.height + 1;
break;
}
case "left": {
toPos.clientX = toRect.x - 1;
break;
}
case "right": {
toPos.clientX = toRect.x + toRect.width + 1;
break;
}
}
}
// Move, enter and drop the element on the target
triggerEvent(window, null, "mousemove", toPos);
// "mouseenter" is fired on every parent of `to` that do not contain
// `from` (typically: different parent lists).
for (const target of getDifferentParents(from, to)) {
triggerEvent(target, null, "mouseenter", toPos);
}
return function () {
return drop(from, toPos);
};
}
function drop(from, toPos) {
return triggerEvent(from, null, "mouseup", toPos);
}
export async function clickDropdown(target, fieldName) {
const dropdownInput = target.querySelector(`[name='${fieldName}'] .dropdown input`);
dropdownInput.focus();
await nextTick();
await click(dropdownInput);
}
export async function clickOpenedDropdownItem(target, fieldName, itemContent) {
const dropdowns = target.querySelectorAll(`[name='${fieldName}'] .dropdown .dropdown-menu`);
if (dropdowns.length === 0) {
throw new Error(`No dropdown found for field ${fieldName}`);
} else if (dropdowns.length > 1) {
throw new Error(`Found ${dropdowns.length} dropdowns for field ${fieldName}`);
}
const dropdownItems = dropdowns[0].querySelectorAll("li");
const indexToClick = Array.from(dropdownItems)
.map((html) => html.textContent)
.indexOf(itemContent);
if (indexToClick === -1) {
throw new Error(`The element '${itemContent}' does not exist in the dropdown`);
}
await click(dropdownItems[indexToClick], null, "click");
}
export async function selectDropdownItem(target, fieldName, itemContent) {
await clickDropdown(target, fieldName);
await clickOpenedDropdownItem(target, fieldName, itemContent);
}
export function getNodesTextContent(nodes) {
return Array.from(nodes).map((n) => n.textContent);
}
/**
* Click to open the dropdown on a many2one
*/
export async function clickOpenM2ODropdown(el, fieldName, selector) {
const m2oSelector = `${selector || ""} .o_field_many2one[name=${fieldName}] input`;
const matches = el.querySelectorAll(m2oSelector);
if (matches.length !== 1) {
throw new Error(
`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`
);
}
await click(matches[0]);
return matches[0];
}
/**
* Click on the active (highlighted) selection in a m2o dropdown.
*/
// TO FIX
export async function clickM2OHighlightedItem(el, fieldName, selector) {
const m2oSelector = `${selector || ""} .o_field_many2one[name=${fieldName}] input`;
// const $dropdown = $(m2oSelector).autocomplete('widget');
const matches = el.querySelectorAll(m2oSelector);
if (matches.length !== 1) {
throw new Error(
`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`
);
}
// clicking on an li (no matter which one), will select the focussed one
return click(matches[0].parentElement.querySelector("li"));
}
// X2Many
export async function addRow(target, selector) {
await click(target.querySelector(`${selector ? selector : ""} .o_field_x2many_list_row_add a`));
}
export async function removeRow(target, index) {
await click(target.querySelectorAll(".o_list_record_remove")[index]);
}