Odoo18-Base/addons/web/static/tests/_framework/module_set.hoot.js
2025-01-06 10:57:38 +07:00

605 lines
18 KiB
JavaScript

// ! WARNING: this module cannot depend on modules not ending with ".hoot" (except libs) !
import { describe, dryRun, globals, start, stop } from "@odoo/hoot";
import { Deferred, microTick } from "@odoo/hoot-dom";
import { watchKeys, watchListeners } from "@odoo/hoot-mock";
import { whenReady } from "@odoo/owl";
import { mockBrowserFactory } from "./mock_browser.hoot";
import { mockCurrencyFactory } from "./mock_currency.hoot";
import { TEST_SUFFIX } from "./mock_module_loader";
import { mockSessionFactory } from "./mock_session.hoot";
import { makeTemplateFactory } from "./mock_templates.hoot";
import { mockUserFactory } from "./mock_user.hoot";
/**
* @typedef {{
* addonsKey: string;
* filter?: (path: string) => boolean;
* moduleNames: string[];
* }} ModuleSet
*
* @typedef {{
* addons?: Iterable<string>;
* }} ModuleSetParams
*/
const { fetch: realFetch } = globals;
const { define, loader } = odoo;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {string[]} entryPoints
* @param {Set<string>} additionalAddons
*/
const defineModuleSet = async (entryPoints, additionalAddons) => {
/** @type {ModuleSet} */
const moduleSet = {};
if (additionalAddons.has("*")) {
// Use all addons
moduleSet.addonsKey = "*";
moduleSet.moduleNames = sortedModuleNames.filter((name) => !name.endsWith(TEST_SUFFIX));
} else {
// Use subset of addons
for (const entryPoint of entryPoints) {
additionalAddons.add(getAddonName(entryPoint));
}
const addons = await fetchDependencies(additionalAddons);
if (addons.has("spreadsheet")) {
/**
* spreadsheet addons defines a module that does not starts with `@spreadsheet` but `@odoo` (`@odoo/o-spreadsheet)
* To ensure that this module is loaded, we have to include `odoo` in the dependencies
*/
addons.add("odoo");
}
const filter = (path) => addons.has(getAddonName(path));
// Module names are cached for each configuration of addons
const joinedAddons = [...addons].sort().join(",");
if (!moduleNamesCache.has(joinedAddons)) {
moduleNamesCache.set(
joinedAddons,
sortedModuleNames.filter((name) => !name.endsWith(TEST_SUFFIX) && filter(name))
);
}
moduleSet.addonsKey = joinedAddons;
moduleSet.filter = filter;
moduleSet.moduleNames = moduleNamesCache.get(joinedAddons);
}
return moduleSet;
};
/**
* @param {string[]} entryPoints
*/
const describeDrySuite = async (entryPoints) => {
const moduleSet = await defineModuleSet(entryPoints, new Set(["*"]));
const moduleSetLoader = new ModuleSetLoader(moduleSet);
moduleSetLoader.setup();
for (const entryPoint of entryPoints) {
// Run test factory
describe(getSuitePath(entryPoint), () => {
// Load entry point module
const fullModuleName = entryPoint + TEST_SUFFIX;
const module = moduleSetLoader.startModule(fullModuleName);
// Check exports (shouldn't have any)
const exports = Object.keys(module || {});
if (exports.length) {
throw new Error(
`Test files cannot have exports, found the following exported member(s) in module ${fullModuleName}:${exports
.map((name) => `\n - ${name}`)
.join("")}`
);
}
});
}
moduleSetLoader.cleanup();
};
/**
* @param {Set<string>} addons
*/
const fetchDependencies = async (addons) => {
// Fetch missing dependencies
const addonsToFetch = [];
for (const addon of addons) {
if (!dependencyCache[addon] && !DEFAULT_ADDONS.includes(addon)) {
addonsToFetch.push(addon);
dependencyCache[addon] = new Deferred();
}
}
if (addonsToFetch.length) {
if (!dependencyBatch.length) {
dependencyBatchPromise = Deferred.resolve().then(() => {
const module_names = [...new Set(dependencyBatch)];
dependencyBatch = [];
return orm("ir.module.module.dependency", "all_dependencies", [], { module_names });
});
}
dependencyBatch.push(...addonsToFetch);
dependencyBatchPromise.then((allDependencies) => {
for (const [moduleName, dependencyNames] of Object.entries(allDependencies)) {
dependencyCache[moduleName] ||= new Deferred();
dependencyCache[moduleName].resolve();
dependencies[moduleName] = dependencyNames.filter(
(dep) => !DEFAULT_ADDONS.includes(dep)
);
}
resolveAddonDependencies(dependencies);
});
}
await Promise.all([...addons].map((addon) => dependencyCache[addon]));
return getDependencies(addons);
};
/**
* @param {string} name
*/
const findMockFactory = (name) => {
if (MODULE_MOCKS_BY_NAME.has(name)) {
return MODULE_MOCKS_BY_NAME.get(name);
}
for (const [key, factory] of MODULE_MOCKS_BY_REGEX) {
if (key instanceof RegExp && key.test(name)) {
return factory;
}
}
return null;
};
/**
* @param {string} name
*/
const getAddonName = (name) => name.match(R_PATH_ADDON)?.[1];
/**
* @param {Iterable<string>} addons
*/
const getDependencies = (addons) => {
const result = new Set(DEFAULT_ADDONS);
for (const addon of addons) {
if (DEFAULT_ADDONS.includes(addon)) {
continue;
}
result.add(addon);
for (const dep of dependencies[addon]) {
result.add(dep);
}
}
return result;
};
/**
* @param {string} name
*/
const getSuitePath = (name) => name.replace("../tests/", "");
/**
* Keeps the original definition of a factory.
*
* @param {string} name
*/
const makeFixedFactory = (name) => {
return () => {
if (!loader.modules.has(name)) {
loader.startModule(name);
}
return loader.modules.get(name);
};
};
/**
* Toned-down version of the RPC + ORM features since this file cannot depend on
* them.
*
* @param {string} model
* @param {string} method
* @param {any[]} args
* @param {Record<string, any>} kwargs
*/
const orm = async (model, method, args, kwargs) => {
const response = await realFetch(`/web/dataset/call_kw/${model}/${method}`, {
body: JSON.stringify({
id: nextRpcId++,
jsonrpc: "2.0",
method: "call",
params: { args, kwargs, method, model },
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const { error, result } = await response.json();
if (error) {
throw error;
}
return result;
};
/**
* @template {Record<string, string[]>} T
* @param {T} dependencies
*/
const resolveAddonDependencies = (dependencies) => {
const findJob = () =>
Object.entries(remaining).find(([, deps]) => deps.every((dep) => dep in solved));
const remaining = { ...dependencies };
/** @type {T} */
const solved = {};
let entry;
while ((entry = findJob())) {
const [name, deps] = entry;
solved[name] = [...new Set(deps.flatMap((dep) => [dep, ...solved[dep]]))];
delete remaining[name];
}
const remainingKeys = Object.keys(remaining);
if (remainingKeys.length) {
throw new Error(`Unresolved dependencies: ${remainingKeys.join(", ")}`);
}
Object.assign(dependencies, solved);
};
const runTests = async () => {
// Find dependency issues
const errors = loader.findErrors(loader.factories.keys());
delete errors.unloaded; // Only a few modules have been loaded yet => irrelevant
if (Object.keys(errors).length) {
return loader.reportErrors(errors);
}
// Sort modules to accelerate loading time
/** @type {Record<string, Deferred>} */
const defs = {};
/** @type {string[]} */
const testModuleNames = [];
for (const [name, { deps }] of loader.factories) {
// Register test module
if (name.endsWith(TEST_SUFFIX)) {
const baseName = name.slice(0, -TEST_SUFFIX.length);
testModuleNames.push(baseName);
}
// Register module dependencies
const [modDef, ...depDefs] = [name, ...deps].map((dep) => (defs[dep] ||= new Deferred()));
Promise.all(depDefs).then(() => {
sortedModuleNames.push(name);
modDef.resolve();
});
}
await Promise.all(Object.values(defs));
// Dry run
const [{ suites }] = await Promise.all([
dryRun(() => describeDrySuite(testModuleNames)),
whenReady(),
]);
// Run all test files
const filteredSuitePaths = new Set(suites.map((s) => s.fullName));
let currentAddonsKey = "";
for (const moduleName of testModuleNames) {
const suitePath = getSuitePath(moduleName);
if (!filteredSuitePaths.has(suitePath)) {
continue;
}
const moduleSet = await defineModuleSet([moduleName], new Set());
const moduleSetLoader = new ModuleSetLoader(moduleSet);
if (currentAddonsKey !== moduleSet.addonsKey) {
if (moduleSetLoader.modules.has(TEMPLATE_MODULE_NAME)) {
// If templates module is available: set URL filter to filter out
// static templates and cleanup current processed templates.
const templateModule = moduleSetLoader.modules.get(TEMPLATE_MODULE_NAME);
templateModule.setUrlFilters(moduleSet.filter ? [moduleSet.filter] : []);
templateModule.clearProcessedTemplates();
}
currentAddonsKey = moduleSet.addonsKey;
}
const suite = describe(suitePath, () => {
moduleSetLoader.setup();
moduleSetLoader.startModule(moduleName + TEST_SUFFIX);
});
// Run recently added tests
const running = await start(suite);
moduleSetLoader.cleanup();
__gcAndLogMemory(suite.fullName, suite.reporting.tests);
if (!running) {
break;
}
}
await stop();
__gcAndLogMemory("tests done");
};
/**
* This method tries to manually run the garbage collector (if exposed) and logs
* the current heap size (if available). It is meant to be called right after a
* suite's module set has been fully executed.
*
* This is used for debugging memory leaks, or if the containing process running
* unit tests doesn't know how much available memory it actually has.
*
* To enable this feature, the containing process must simply use the `--expose-gc`
* flag.
*
* @param {string} label
* @param {number} [testCount]
*/
const __gcAndLogMemory = (label, testCount) => {
if (typeof window.gc !== "function") {
return;
}
// Cleanup last retained textarea
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
textarea.value = "aaa";
textarea.focus();
textarea.remove();
// Run garbage collection
window.gc();
// Log memory usage
const logs = [
`[MEMINFO] ${label} (after GC)`,
"- used:",
window.performance.memory.usedJSHeapSize,
"- total:",
window.performance.memory.totalJSHeapSize,
"- limit:",
window.performance.memory.jsHeapSizeLimit,
];
if (Number.isInteger(testCount)) {
logs.push("- tests:", testCount);
}
console.log(...logs);
};
/** @extends {OdooModuleLoader} */
class ModuleSetLoader extends loader.constructor {
cleanups = [];
/**
* @param {ModuleSet} moduleSet
*/
constructor(moduleSet) {
super();
this.factories = new Map(loader.factories);
this.modules = new Map(loader.modules);
this.moduleSet = moduleSet;
odoo.define = this.define.bind(this);
odoo.loader = this;
}
/**
* @override
* @type {typeof loader["addJob"]}
*/
addJob(name) {
if (this.canAddModule(name)) {
super.addJob(...arguments);
}
}
/**
* @param {string} name
*/
canAddModule(name) {
const { filter } = this.moduleSet;
return !filter || filter(name) || R_DEFAULT_MODULE.test(name);
}
cleanup() {
odoo.define = define;
odoo.loader = loader;
while (this.cleanups.length) {
this.cleanups.pop()();
}
}
/**
* @override
* @type {typeof loader["define"]}
*/
define(name, deps, factory) {
if (!loader.factories.has(name)) {
// Lazy-loaded modules are added to the main loader for next ModuleSetLoader
// instances.
loader.define(name, deps, factory, true);
// We assume that lazy-loaded modules are not required by any other
// module.
sortedModuleNames.push(name);
moduleNamesCache.clear();
}
return super.define(...arguments);
}
setup() {
this.cleanups.push(
watchKeys(window.odoo),
watchKeys(window, ALLOWED_GLOBAL_KEYS),
watchListeners()
);
// Load module set modules (without entry point)
for (const name of this.moduleSet.moduleNames) {
const mockFactory = findMockFactory(name);
if (mockFactory) {
// Use mock
this.factories.set(name, {
deps: [],
fn: mockFactory(name, this.factories.get(name)),
});
}
if (!this.modules.has(name)) {
// Run (or re-run) module factory
this.startModule(name);
}
}
}
/**
* @override
* @type {typeof loader["startModule"]}
*/
startModule(name) {
if (this.canAddModule(name)) {
return super.startModule(...arguments);
}
this.jobs.delete(name);
return null;
}
}
const ALLOWED_GLOBAL_KEYS = [
"ace", // Ace editor
"Chart", // Chart.js
"FullCalendar", // Full Calendar
"L", // Leaflet
"lamejs", // LameJS
"luxon", // Luxon
"odoo",
"owl",
];
const CSRF_TOKEN = odoo.csrf_token;
const DEFAULT_ADDONS = ["base", "web"];
const MODULE_MOCKS_BY_NAME = new Map([
// Fixed modules
["@web/core/template_inheritance", makeFixedFactory],
// Other mocks
["@web/core/browser/browser", mockBrowserFactory],
["@web/core/currency", mockCurrencyFactory],
["@web/core/templates", makeTemplateFactory],
["@web/core/user", mockUserFactory],
["@web/session", mockSessionFactory],
]);
const MODULE_MOCKS_BY_REGEX = new Map([
// Fixed modules
[/\.bundle\.xml$/, makeFixedFactory],
]);
const R_DEFAULT_MODULE = /^@odoo\/(owl|hoot)/;
const R_PATH_ADDON = /^[@/]?(\w+)/;
const TEMPLATE_MODULE_NAME = "@web/core/templates";
/** @type {Record<string, string[]} */
const dependencies = {};
/** @type {Record<string, Deferred} */
const dependencyCache = {};
/** @type {Record<string, Promise<Response>} */
const globalFetchCache = Object.create(null);
/** @type {Set<string>} */
const modelsToFetch = new Set();
/** @type {Map<string, string[]>} */
const moduleNamesCache = new Map();
/** @type {Map<string, Record<string, any>>} */
const serverModelCache = new Map();
/** @type {string[]} */
const sortedModuleNames = [];
/** @type {string[]} */
let dependencyBatch = [];
/** @type {Promise<Record<string, string[]>> | null} */
let dependencyBatchPromise = null;
let nextRpcId = 1e9;
// Invoke tests after the module loader finished loading.
microTick().then(runTests);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function clearServerModelCache() {
serverModelCache.clear();
}
/**
* @param {Iterable<string>} modelNames
*/
export async function fetchModelDefinitions(modelNames) {
// Fetch missing definitions
const namesList = [...modelsToFetch];
if (namesList.length) {
const formData = new FormData();
formData.set("csrf_token", CSRF_TOKEN);
formData.set("model_names", JSON.stringify(namesList));
const response = await realFetch("/web/model/get_definitions", {
body: formData,
method: "POST",
});
if (!response.ok) {
const [s, some, does] =
namesList.length === 1 ? ["", "this", "does"] : ["s", "some or all of these", "do"];
const message = `Could not fetch definition${s} for server model${s} "${namesList.join(
`", "`
)}": ${some} model${s} ${does} not exist`;
throw new Error(message);
}
const modelDefs = await response.json();
for (const [modelName, modelDef] of Object.entries(modelDefs)) {
serverModelCache.set(modelName, modelDef);
modelsToFetch.delete(modelName);
}
}
return [...modelNames].map((modelName) => [modelName, serverModelCache.get(modelName)]);
}
/**
* @param {string | URL} input
* @param {RequestInit} [init]
*/
export function globalCachedFetch(input, init) {
if (init?.method && init.method.toLowerCase() !== "get") {
throw new Error(`cannot use a global cached fetch with HTTP method "${init.method}"`);
}
const key = String(input);
if (!(key in globalFetchCache)) {
globalFetchCache[key] = realFetch(input, init).catch((reason) => {
delete globalFetchCache[key];
throw reason;
});
}
return globalFetchCache[key];
}
/**
* @param {string} modelName
*/
export function registerModelToFetch(modelName) {
if (!serverModelCache.has(modelName)) {
modelsToFetch.add(modelName);
}
}