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,114 @@
/** @odoo-module alias=@web/../tests/helpers/cleanup default=false */
// -----------------------------------------------------------------------------
// 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",
},
// 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,150 @@
/** @odoo-module alias=@web/../tests/helpers/mock_env default=false */
import { SERVICES_METADATA } from "@web/core/utils/hooks";
import { registry } from "@web/core/registry";
import { makeEnv, startServices } from "@web/env";
import { registerCleanup } from "./cleanup";
import { makeMockServer } from "./mock_server";
import { mocks } from "./mock_services";
import { patchWithCleanup } from "./utils";
import { Component } from "@odoo/owl";
import { startRouter } from "@web/core/browser/router";
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);
});
}
export const registryNamesToCloneWithCleanup = [
"actions",
"command_provider",
"command_setup",
"error_handlers",
"fields",
"fields",
"main_components",
"view_widgets",
"views",
];
export const utils = {
prepareRegistriesWithCleanup() {
// Clone registries
registryNamesToCloneWithCleanup.forEach((registryName) =>
cloneRegistryWithCleanup(registry.category(registryName))
);
// 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("main_components"));
clearRegistryWithCleanup(registry.category("services"));
clearServicesMetadataWithCleanup();
clearRegistryWithCleanup(registry.category("systray"));
clearRegistryWithCleanup(registry.category("user_menuitems"));
clearRegistryWithCleanup(registry.category("kanban_examples"));
clearRegistryWithCleanup(registry.category("__processed_archs__"));
// 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 function prepareRegistriesWithCleanup() {
return utils.prepareRegistriesWithCleanup();
}
/**
* @typedef {import("@web/env").OdooEnv} OdooEnv
*/
/**
* Create a test environment
*
* @param {*} config
* @returns {Promise<OdooEnv>}
*/
export async function makeTestEnv(config = {}) {
startRouter();
// 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);
}
let env = makeEnv();
await startServices(env);
Component.env = env;
if ("config" in config) {
env = Object.assign(Object.create(env), { config: config.config });
}
return env;
}
/**
* Create a test environment for dialog tests
*
* @param {*} config
* @returns {Promise<OdooEnv>}
*/
export async function makeDialogTestEnv(config = {}) {
const env = await makeTestEnv(config);
env.dialogData = {
isActive: true,
close() {},
};
return env;
}

View file

@ -0,0 +1,331 @@
/** @odoo-module alias=@web/../tests/helpers/mock_services default=false */
import { effectService } from "@web/core/effects/effect_service";
import { localization } from "@web/core/l10n/localization";
import { ConnectionAbortedError, rpcBus, rpc } from "@web/core/network/rpc";
import { ormService } from "@web/core/orm_service";
import { overlayService } from "@web/core/overlay/overlay_service";
import { uiService } from "@web/core/ui/ui_service";
import { user } from "@web/core/user";
import { patchWithCleanup } from "./utils";
// -----------------------------------------------------------------------------
// Mock Services
// -----------------------------------------------------------------------------
export const defaultLocalization = {
dateFormat: "MM/dd/yyyy",
timeFormat: "HH:mm:ss",
shortTimeFormat: "HH:mm",
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, { ...defaultLocalization, ...config });
patchWithCleanup(luxon.Settings, { defaultNumberingSystem: "latn" });
return {
name: "localization",
start: async (env) => {
return localization;
},
};
}
export function patchRPCWithCleanup(mockRPC = () => {}) {
let nextId = 1;
patchWithCleanup(rpc, {
_rpc: function (route, params = {}, settings = {}) {
let rejectFn;
const data = {
id: nextId++,
jsonrpc: "2.0",
method: "call",
params: params,
};
rpcBus.trigger("RPC:REQUEST", { data, url: route, settings });
const rpcProm = new Promise((resolve, reject) => {
rejectFn = reject;
Promise.resolve(mockRPC(...arguments))
.then((result) => {
rpcBus.trigger("RPC:RESPONSE", { data, settings, result });
resolve(result);
})
.catch((error) => {
rpcBus.trigger("RPC:RESPONSE", {
data,
settings,
error,
});
reject(error);
});
});
rpcProm.abort = (rejectError = true) => {
if (rejectError) {
rejectFn(new ConnectionAbortedError("XmlHttpRequestError abort"));
}
};
return rpcProm;
},
});
}
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 {
// Ignore
}
}
try {
await sendCb.call(this, data);
} catch {
listener = this._errorListener;
}
}
if (def) {
await def;
}
listener.call(this);
},
response: JSON.stringify(response || ""),
};
};
return MockXHR;
}
// -----------------------------------------------------------------------------
// Low level API mocking
// -----------------------------------------------------------------------------
export function makeMockFetch(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 mockRPC(route, params);
status = 200;
} catch {
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;
};
}
export const fakeCommandService = {
start() {
return {
add() {
return () => {};
},
getCommands() {
return [];
},
openPalette() {},
};
},
};
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 makeFakePwaService() {
return {
start() {
return {
canPromptToInstall: false,
isAvailable: false,
isScopedApp: false
}
}
}
}
export function patchUserContextWithCleanup(patch) {
const context = user.context;
patchWithCleanup(user, {
get context() {
return Object.assign({}, context, patch);
},
});
}
export function patchUserWithCleanup(patch) {
patchWithCleanup(user, patch);
}
export const fakeCompanyService = {
start() {
return {
allowedCompanies: {},
allowedCompaniesWithAncestors: {},
activeCompanyIds: [],
currentCompany: {},
setCompanies: () => {},
getCompany: () => {},
};
},
};
export function makeFakeBarcodeService() {
return {
start() {
return {
bus: {
async addEventListener() {},
async removeEventListener() {},
},
};
},
};
}
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);
},
};
},
};
}
function makeFakeActionService() {
return {
start() {
return {
doAction() {},
};
},
};
}
export const mocks = {
color_scheme: () => fakeColorSchemeService,
company: () => fakeCompanyService,
command: () => fakeCommandService,
effect: () => effectService, // BOI The real service ? Is this what we want ?
localization: makeFakeLocalizationService,
notification: makeFakeNotificationService,
title: () => fakeTitleService,
ui: () => uiService,
dialog: makeFakeDialogService,
orm: () => ormService,
action: makeFakeActionService,
overlay: () => overlayService,
};

View file

@ -0,0 +1,107 @@
/** @odoo-module alias=@web/../tests/helpers/mount_in_fixture default=false**/
import { App, Component, xml } from "@odoo/owl";
import { registerCleanup } from "@web/../tests/helpers/cleanup";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { mocks } from "@web/../tests/helpers/mock_services";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { registry } from "@web/core/registry";
import { getTemplate } from "@web/core/templates";
class TestComponent extends Component {
static props = {
components: { type: Array },
};
static template = xml`
<t t-foreach="props.components" t-as="comp" t-key="comp.component.name">
<t t-component="comp.component" t-props="comp.props"/>
</t>
`;
/**
* Returns the instance of the first component.
* @returns {Component}
*/
get defaultComponent() {
return this.__owl__.bdom.children[0].child.component;
}
}
function getApp(env, props) {
const appConfig = {
env,
getTemplate,
test: true,
props: props,
};
if (env.services && "localization" in env.services) {
appConfig.translateFn = env._t;
}
const app = new App(TestComponent, appConfig);
registerCleanup(() => app.destroy());
return app;
}
/**
* @typedef {Object} Config
* @property {Object} env
* @property {Object} props
* @property {string[]} templates
*/
/**
* This functions will mount the given component and
* will add a MainComponentsContainer if the overlay
* service is loaded.
*
* @template T
* @param {new (...args: any[]) => T} Comp
* @param {HTMLElement} target
* @param {Config} config
* @returns {Promise<T>} Instance of Comp
*/
export async function mountInFixture(Comp, target, config = {}) {
const serviceRegistry = registry.category("services");
let env = config.env || {};
const isEnvInitialized = env && env.services;
function isServiceRegistered(serviceName) {
return isEnvInitialized
? serviceName in env.services
: serviceRegistry.contains(serviceName);
}
async function addService(serviceName, service) {
if (isServiceRegistered(serviceName)) {
return;
}
service = typeof service === "function" ? service() : service;
if (isEnvInitialized) {
env.services[serviceName] = await service.start(env);
} else {
serviceRegistry.add(serviceName, service);
}
}
const components = [{ component: Comp, props: config.props || {} }];
if (isServiceRegistered("overlay")) {
await addService("localization", mocks.localization);
components.push({ component: MainComponentsContainer, props: {} });
}
if (!isEnvInitialized) {
env = await makeTestEnv(env);
}
const app = getApp(env, { components });
if (config.templates) {
app.addTemplates(config.templates);
}
const testComp = await app.mount(target);
return testComp.defaultComponent;
}

File diff suppressed because it is too large Load diff