Odoo18-Base/addons/web/static/tests/legacy/helpers/mock_server.js

2735 lines
108 KiB
JavaScript
Raw Permalink Normal View History

2025-01-06 10:57:38 +07:00
/** @odoo-module alias=@web/../tests/helpers/mock_server default=false */
import { assets } from "@web/core/assets";
import { browser } from "@web/core/browser/browser";
import { Domain } from "@web/core/domain";
import {
deserializeDate,
deserializeDateTime,
parseDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { intersection, unique } from "@web/core/utils/arrays";
import { deepCopy, pick } from "@web/core/utils/objects";
import { patchRPCWithCleanup, makeMockFetch } from "./mock_services";
import { patchWithCleanup } from "./utils";
import { makeErrorFromResponse } from "@web/core/network/rpc";
import { registerCleanup } from "./cleanup";
const domParser = new DOMParser();
const xmlSerializer = new XMLSerializer();
const regex_field_agg = /(\w+)(?::(\w+)(?:\((\w+)\))?)?/;
// valid SQL aggregation functions
const VALID_AGGREGATE_FUNCTIONS = [
"array_agg",
"count",
"count_distinct",
"bool_and",
"bool_or",
"max",
"min",
"avg",
"sum",
];
let logId = 1;
const DEFAULT_FIELD_VALUES = {
char: false,
many2one: false,
one2many: [],
many2many: [],
monetary: 0,
binary: false,
integer: 0,
float: 0,
boolean: false,
date: false,
datetime: false,
html: false,
text: false,
selection: false,
reference: false,
properties: [],
json: false,
};
const READ_GROUP_NUMBER_GRANULARITY = [
"year_number",
"quarter_number",
"month_number",
"iso_week_number",
"day_of_year",
"day_of_month",
"day_of_week",
"hour_number",
"minute_number",
"second_number",
];
// -----------------------------------------------------------------------------
// Utils
// -----------------------------------------------------------------------------
/**
* @param {string} prefix
* @param {string} title
*/
function makeLogger(prefix, title) {
const log = (bullet, colorValue, ...data) => {
const color = `color: ${colorValue}`;
const styles = [[color, "font-weight: bold"].join(";")];
let msg = `${bullet} %c[${prefix}:${id}]`;
if (title) {
msg += `%c ${title}`;
styles.push(color);
}
console.log(msg, ...styles, ...data);
};
/**
* Request logger: color is blue-ish.
* @param {...any} data
*/
const request = (...data) => {
hasCalledRequest = true;
log("->", "#66e", ...data);
};
/**
* Response logger: color is orange.
* @param {...any} data
*/
const response = (...data) => {
if (!hasCalledRequest) {
console.warn(`Response logged before request.`);
}
log("<-", "#f80", ...data);
hasCalledRequest = false;
};
const id = logId++;
let hasCalledRequest = false;
return { request, response };
}
export function makeServerError({
code,
context,
description,
message,
subType,
errorName,
type,
} = {}) {
return makeErrorFromResponse({
code: code || 200,
message: message || "Odoo Server Error",
data: {
name: errorName || `odoo.exceptions.${type || "UserError"}`,
debug: "traceback",
arguments: [],
context: context || {},
subType,
message: description,
},
});
}
/**
* @param {Element} tree
* @param {(node: Element) => any} cb
*/
function traverseElementTree(tree, cb) {
if (cb(tree)) {
Array.from(tree.children).forEach((c) => traverseElementTree(c, cb));
}
}
// -----------------------------------------------------------------------------
// MockServer
// -----------------------------------------------------------------------------
export class MockServer {
active = true;
constructor(data, options = {}) {
this.init(data, options);
}
init(data, options) {
this.models = data.models || {};
this.actions = data.actions || {};
this.menus = data.menus || null;
this.archs = data.views || {};
this.debug = options.debug || false;
Object.entries(this.models).forEach(([modelName, model]) => {
model.fields = {
id: { string: "ID", type: "integer" },
display_name: { string: "Display Name", type: "char" },
name: { string: "Name", type: "char", default: "name" },
write_date: { string: "Last Modified on", type: "datetime" },
...model.fields,
};
for (const fieldName in model.fields) {
model.fields[fieldName].name = fieldName;
}
});
Object.entries(this.models).forEach(([modelName, model]) => {
model.records = model.records || [];
for (var i = 0; i < model.records.length; i++) {
const values = model.records[i];
// add potentially missing id
const id = values.id === undefined ? this.getUnusedID(modelName) : values.id;
// create a clean object, initial values are passed to write
model.records[i] = { id };
// ensure initial data goes through proper conversion (x2m, ...)
this.applyDefaults(model, values);
this.writeRecord(modelName, values, id, { ensureIntegrity: false });
}
model.onchanges = model.onchanges || {};
model.methods = model.methods || {};
});
// fill relational fields' inverse.
for (const modelName in this.models) {
const records = this.models[modelName].records;
if (!Array.isArray(this.models[modelName].records)) {
continue;
}
records.forEach((record) => this.updateComodelRelationalFields(modelName, record));
}
}
/**
* Simulate a complete RPC call. This is the main method for this class.
*
* This method also log incoming and outgoing data, and stringify/parse data
* to simulate a barrier between the server and the client. It also simulate
* server errors.
*/
async performRPC(route, args) {
const logger = makeLogger("RPC", route);
args = JSON.parse(JSON.stringify(args));
if (this.debug) {
logger.request(args);
}
const result = await this._performRPC(route, args);
// try {
// const result = await this._performRPC(route, args);
// } catch {
// const message = result && result.message;
// const event = result && result.event;
// const errorString = JSON.stringify(message || false);
// console.warn(
// "%c[RPC] response (error) " + route,
// "color: orange; font-weight: bold;",
// JSON.parse(errorString)
// );
// return Promise.reject({ message: errorString, event });
// }
const actualResult = JSON.parse(JSON.stringify(result !== undefined ? result : false));
if (this.debug) {
logger.response(actualResult);
}
return actualResult;
// TODO?
// var abort = def.abort || def.reject;
// if (abort) {
// abort = abort.bind(def);
// } else {
// abort = function () {
// throw new Error("Can't abort this request");
// };
// }
// def.abort = abort;
}
_getViewFields(modelName, viewType, models) {
if (["kanban", "list", "form"].includes(viewType)) {
for (const fieldNames of Object.values(models)) {
fieldNames.add("id");
fieldNames.add("write_date");
}
} else if (viewType === "search") {
models[modelName] = Object.keys(this.models[modelName].fields);
} else if (viewType === "graph") {
for (const [fieldName, field] of Object.entries(this.models[modelName].fields)) {
if (["integer", "float"].includes(field.type)) {
models[modelName].add(fieldName);
}
}
} else if (viewType === "pivot") {
for (const [fieldName, field] of Object.entries(this.models[modelName].fields)) {
if (
[
"many2one",
"many2many",
"char",
"boolean",
"selection",
"date",
"datetime",
].includes(field.type)
) {
models[modelName].add(fieldName);
}
}
}
return models;
}
getView(modelName, args, kwargs) {
if (!(modelName in this.models)) {
throw new Error(`Model ${modelName} was not defined in mock server data`);
}
// find the arch
let [viewId, viewType] = args;
if (!viewId) {
const contextKey = viewType + "_view_ref";
if (contextKey in kwargs.context) {
viewId = kwargs.context[contextKey];
}
}
const key = [modelName, viewId, viewType].join(",");
let arch = this.archs[key];
if (!arch) {
const genericViewKey = Object.keys(this.archs).find((fullKey) => {
const [_model, , _viewType] = fullKey.split(",");
return _model === modelName && _viewType === viewType;
});
if (genericViewKey) {
arch = this.archs[genericViewKey];
viewId = parseInt(genericViewKey.split(",")[1], 10) || false;
}
}
if (!arch) {
throw new Error("No arch found for key " + key);
}
// generate a field_view_get result
const fields = Object.assign({}, this.models[modelName].fields);
for (const fieldName in fields) {
fields[fieldName].name = fieldName;
}
// var viewOptions = params.viewOptions || {};
const view = this._getView({
arch,
modelName,
fields,
context: kwargs.context || {},
models: this.models,
});
if (kwargs.options.toolbar) {
view.toolbar = this.models[modelName].toolbar || {};
}
if (viewId !== undefined) {
view.id = viewId;
}
return view;
}
_getView(params) {
const processedNodes = params.processedNodes || [];
const { arch, context, modelName } = params;
const level = params.level || 0;
const editable = params.editable || true;
const fields = deepCopy(params.fields);
function isNodeProcessed(node) {
return processedNodes.findIndex((n) => n.isSameNode(node)) > -1;
}
const onchanges = params.models[modelName].onchanges || {};
const fieldNodes = {};
const groupbyNodes = {};
const relatedModels = { [modelName]: new Set() };
let doc;
if (typeof arch === "string") {
doc = domParser.parseFromString(arch, "text/xml").documentElement;
} else {
doc = arch;
}
const editableView = editable && this._editableNode(doc, modelName);
const onchangeAbleView = this._onchangeAbleView(doc);
const inFormView = doc.tagName === "form";
traverseElementTree(doc, (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return false;
}
["required", "readonly", "invisible", "column_invisible"].forEach((attr) => {
const value = node.getAttribute(attr);
if (value === "1" || value === "true") {
node.setAttribute(attr, "True");
}
});
const isField = node.tagName === "field";
const isGroupby = node.tagName === "groupby";
if (isField) {
const fieldName = node.getAttribute("name");
fieldNodes[fieldName] = {
node,
isInvisible: node.getAttribute("invisible") === "True",
isEditable: editableView && this._editableNode(node, modelName),
};
const field = fields[fieldName];
if (!field) {
throw new Error("Field " + fieldName + " does not exist");
}
} else if (isGroupby && !isNodeProcessed(node)) {
const groupbyName = node.getAttribute("name");
fieldNodes[groupbyName] = { node };
groupbyNodes[groupbyName] = node;
}
if (isGroupby && !isNodeProcessed(node)) {
return false;
}
return !isField;
});
Object.keys(fieldNodes).forEach((field) => relatedModels[modelName].add(field));
let relModel, relFields;
Object.entries(fieldNodes).forEach(([name, { node, isInvisible, isEditable }]) => {
const field = fields[name];
if (isEditable && (field.type === "many2one" || field.type === "many2many")) {
const canCreate = node.getAttribute("can_create");
node.setAttribute("can_create", canCreate || "true");
const canWrite = node.getAttribute("can_write");
node.setAttribute("can_write", canWrite || "true");
}
if (field.type === "one2many" || field.type === "many2many") {
relModel = field.relation;
// inline subviews: in forms if field is visible and has no widget (1st level only)
if (inFormView && level === 0 && !node.getAttribute("widget") && !isInvisible) {
const inlineViewTypes = Array.from(node.children).map((c) => c.tagName);
const missingViewtypes = [];
const mode = node.getAttribute("mode") || "kanban,list";
if (!intersection(inlineViewTypes, mode.split(",")).length) {
// TODO: use a kanban view by default in mobile
missingViewtypes.push((node.getAttribute("mode") || "list").split(",")[0]);
}
for (let type of missingViewtypes) {
let key = `${field.relation},false,${type}`;
if (!this.archs[key]) {
const regexp = new RegExp(`${field.relation},[a-z._0-9]+,${type}`);
key = Object.keys(this.archs).find((k) => regexp.test(k));
}
// in a lot of tests, we don't need the form view, so it doesn't even exist
const arch = this.archs[key] || (type === "form" ? "<form/>" : null);
if (!arch) {
throw new Error(`Can't find ${type} view to inline`);
}
node.appendChild(
domParser.parseFromString(arch, "text/xml").documentElement
);
}
}
Array.from(node.children).forEach((childNode) => {
if (childNode.tagName) {
relFields = Object.assign({}, params.models[relModel].fields);
// this is hackhish, but _getView modifies the subview document in place
const { models } = this._getView({
models: params.models,
arch: childNode,
modelName: relModel,
fields: relFields,
context,
processedNodes,
level: level + 1,
editable: editableView,
});
Object.entries(models).forEach(([modelName, fields]) => {
relatedModels[modelName] = relatedModels[modelName] || new Set();
fields.forEach((field) => relatedModels[modelName].add(field));
});
}
});
}
// add onchanges
if (onchangeAbleView && name in onchanges) {
node.setAttribute("on_change", "1");
}
});
Object.entries(groupbyNodes).forEach(([name, node]) => {
const field = fields[name];
if (field.type !== "many2one") {
throw new Error("groupby can only target many2one");
}
field.views = {};
relModel = field.relation;
relFields = Object.assign({}, params.models[relModel].fields);
processedNodes.push(node);
// postprocess simulation
const { models } = this._getView({
models: params.models,
arch: node,
modelName: relModel,
fields: relFields,
context,
processedNodes,
editable: false,
});
Object.entries(models).forEach(([modelName, fields]) => {
relatedModels[modelName] = relatedModels[modelName] || new Set();
fields.forEach((field) => relatedModels[modelName].add(field));
});
});
const processedArch = xmlSerializer.serializeToString(doc);
const fieldsInView = {};
Object.entries(fields).forEach(([fname, field]) => {
if (fname in fieldNodes) {
fieldsInView[fname] = field;
}
});
const viewType = doc.tagName;
return {
arch: processedArch,
model: modelName,
type: viewType,
models: this._getViewFields(modelName, viewType, relatedModels),
};
}
_editableNode(node, modelName) {
switch (node.tagName) {
case "form":
return true;
case "list":
return node.getAttribute("editable") || node.getAttribute("multi_edit");
case "field": {
const fname = node.getAttribute("name");
const field = this.models[modelName].fields[fname];
return (
!field.readonly &&
node.getAttribute("readonly") !== "True" &&
node.getAttribute("readonly") !== "1"
);
}
default:
return false;
}
}
_onchangeAbleView(node) {
if (node.tagName === "form") {
return true;
} else if (node.tagName === "list") {
return true;
} else if (node.tagName === "kanban") {
return true;
}
}
/**
* Converts an Object representing a record to actual return Object of the
* python `onchange` method.
* Specifically, it reads `display_name` on many2one's and transforms raw id
* list in orm command lists for x2many's.
* For x2m fields that add or update records (ORM commands 0 and 1), it is
* recursive.
*
* @param {string} model: the model's name
* @param {Object} values: an object representing a record
* @returns {Object}
*/
convertToOnChange(modelName, values, specification) {
Object.entries(values).forEach(([fname, val]) => {
const field = this.models[modelName].fields[fname];
if (field.type === "many2one" && typeof val === "number") {
values[fname] = this.mockWebRead(field.relation, [[val]], {
specification: specification[fname].fields || {},
})[0];
} else if (field.type === "one2many" || field.type === "many2many") {
val.forEach((cmd) => {
switch (cmd[0]) {
case 0: // CREATE
case 1: // UPDATE
cmd[2] = this.convertToOnChange(
field.relation,
cmd[2],
specification[fname].fields || {}
);
break;
case 4: // LINK_TO
cmd[2] = this.mockWebRead(field.relation, [[cmd[1]]], {
specification: specification[fname].fields || {},
})[0];
}
});
} else if (field.type === "reference") {
if (val) {
const [model, i] = val.split(",");
const id = parseInt(i, 10);
const result = this.mockWebRead(model, [[id]], {
specification: specification[fname].fields || {},
});
values[fname] = {
...result[0],
id: { id, model },
};
}
}
});
return values;
}
async _performRPC(route, args) {
// Check if there is an handler in the mockRegistry: either specific for this model
// (with key 'model/method'), or global (with key 'method')
// This allows to mock routes/methods defined outside web.
const methodName = args.method || route;
const mockFunction =
registry.category("mock_server").get(`${args.model}/${methodName}`, null) ||
registry.category("mock_server").get(methodName, null);
if (mockFunction) {
return mockFunction.call(this, route, args);
}
switch (route) {
case "/web/webclient/load_menus":
return this.mockLoadMenus();
case "/web/action/load":
return this.mockLoadAction(args);
case "/web/action/load_breadcrumbs":
return this.mockLoadBreadcrumbs(args);
case "/web/dataset/resequence":
return this.mockResequence(args);
}
if (
route.indexOf("/web/image") >= 0 ||
[".png", ".jpg"].includes(route.substr(route.length - 4))
) {
return;
}
switch (args.method) {
case "render_public_asset": {
return true;
}
case "action_archive":
return this.mockWrite(args.model, [args.args[0], { active: false }]);
case "action_unarchive":
return this.mockWrite(args.model, [args.args[0], { active: true }]);
case "copy":
return this.mockCopy(args.model, args.args, args.kwargs);
case "create":
return this.mockCreate(args.model, args.args[0], args.kwargs);
case "fields_get":
return this.mockFieldsGet(args.model, args.fields);
case "get_views":
return this.mockGetViews(args.model, args.kwargs);
case "name_create":
return this.mockNameCreate(args.model, args.args[0], args.kwargs);
case "name_search":
return this.mockNameSearch(args.model, args.args, args.kwargs);
case "onchange":
return this.mockOnchange(args.model, args.args, args.kwargs);
case "read":
return this.mockRead(args.model, args.args);
case "search":
return this.mockSearch(args.model, args.args, args.kwargs);
case "search_count":
return this.mockSearchCount(args.model, args.args, args.kwargs);
case "search_panel_select_range":
return this.mockSearchPanelSelectRange(args.model, args.args, args.kwargs);
case "search_panel_select_multi_range":
return this.mockSearchPanelSelectMultiRange(args.model, args.args, args.kwargs);
case "search_read":
return this.mockSearchRead(args.model, args.args, args.kwargs);
case "unlink":
return this.mockUnlink(args.model, args.args);
case "web_read":
return this.mockWebRead(args.model, args.args, args.kwargs);
case "web_save":
return this.mockWebSave(args.model, args.args, args.kwargs);
case "read_group":
return this.mockReadGroup(args.model, args.kwargs);
case "web_read_group":
return this.mockWebReadGroup(args.model, args.kwargs);
case "web_search_read":
return this.mockWebSearchReadUnity(args.model, args.args, args.kwargs);
case "read_progress_bar":
return this.mockReadProgressBar(args.model, args.kwargs);
case "write":
return this.mockWrite(args.model, args.args);
}
const model = this.models[args.model];
const method = model && model.methods[args.method];
if (method) {
return method(args.model, args.args, args.kwargs);
}
throw new Error(`Unimplemented route: ${route}`);
}
/**
* Simulate a 'copy' operation, so we simply try to duplicate a record in
* memory
*
* @private
* @param {string} modelName
* @param {number[]} ids array of ids of some valid records
* @param {Object} [kwargs={}]
* @param {Object} [kwargs.default] default data
* @returns {number[]} the IDs of the duplicated record
*/
mockCopy(modelName, [ids], kwargs = {}) {
const model = this.models[modelName];
const newIDs = [];
for (const id of ids) {
const newID = this.getUnusedID(modelName);
const originalRecord = model.records.find((record) => record.id === id);
const duplicatedRecord = { ...originalRecord, ...kwargs.default, id: newID };
duplicatedRecord.display_name = `${originalRecord.display_name} (copy)`;
model.records.push(duplicatedRecord);
newIDs.push(newID);
}
return newIDs;
}
mockCreate(modelName, valsList, kwargs = {}) {
let returnArrayOfIds = true;
if (!Array.isArray(valsList)) {
valsList = [valsList];
returnArrayOfIds = false;
}
const model = this.models[modelName];
const ids = [];
for (const values of valsList) {
if ("id" in values) {
throw new Error("Cannot create a record with a predefinite id");
}
const id = this.getUnusedID(modelName);
ids.push(id);
const record = { id };
model.records.push(record);
this.applyDefaults(model, values, kwargs.context);
this.writeRecord(modelName, values, id);
this.updateComodelRelationalFields(modelName, record);
}
return returnArrayOfIds ? ids : ids[0];
}
/**
* @param {string} modelName
* @param {array[]} args a list with a list of fields in the first position
* @param {Object} [kwargs={}]
* @param {Object} [kwargs.context] the context to eventually read default
* values
* @returns {Object}
*/
mockDefaultGet(modelName, args, kwargs = {}) {
const fields = args[0];
const model = this.models[modelName];
const result = {};
for (const fieldName of fields) {
if (fieldName === "id") {
continue;
}
const field = model.fields[fieldName];
const key = "default_" + fieldName;
if (kwargs.context && key in kwargs.context) {
if (field.type === "one2many" || field.type === "many2many") {
const ids = kwargs.context[key] || [];
result[fieldName] = ids.map((id) => [4, id]);
} else {
result[fieldName] = kwargs.context[key];
}
continue;
}
if ("default" in field) {
result[fieldName] = field.default;
continue;
} else {
if (!(field.type in DEFAULT_FIELD_VALUES)) {
throw new Error(`Missing default value for type '${field.type}'`);
}
result[fieldName] = DEFAULT_FIELD_VALUES[field.type];
}
}
for (const fieldName in result) {
const field = model.fields[fieldName];
if (field.type === "many2one" && result[fieldName]) {
const recordExists = this.models[field.relation].records.some(
(r) => r.id === result[fieldName]
);
if (!recordExists) {
delete result[fieldName];
}
}
}
return result;
}
mockFieldsGet(modelName, fieldNames) {
let fields = this.models[modelName].fields;
if (fieldNames) {
fields = pick(this.models[modelName].fields, ...fieldNames);
}
return fields;
}
mockLoadAction({ action_id } = {}) {
const action =
this.actions[action_id] ||
Object.values(this.actions).find((action) => action.xml_id === action_id) ||
Object.values(this.actions).find((a) => a.path === action_id);
if (!action) {
throw makeServerError({
errorName: "odoo.addons.web.controllers.action.MissingActionError",
message: `The action ${JSON.stringify(action_id)} does not exist`,
});
}
return action;
}
mockLoadBreadcrumbs({ actions }) {
return actions.map(({ action: action_id, model, resId }) => {
if (action_id) {
const act = this.mockLoadAction({ action_id });
if (resId) {
return {
display_name: this.mockRead(act.res_model, [[resId], ["display_name"]])[0]
.display_name,
};
}
return { display_name: act.name };
} else if (model) {
if (resId) {
return { display_name: this.models[model].records[resId].display_name };
}
throw new Error("Actions with a model should also have a resId");
}
throw new Error("Actions should have either an action (id or path) or a model");
});
}
mockLoadMenus() {
let menus = this.menus;
if (!menus) {
menus = {
root: { id: "root", children: [1], name: "root", appID: "root" },
1: { id: 1, children: [], name: "App0", appID: 1 },
};
}
return menus;
}
mockGetViews(modelName, kwargs) {
const views = {};
const models = {};
// Determine all the models/fields used in the views
// modelFields = {modelName: Set([...fieldNames])}
const modelFields = {};
kwargs.views.forEach(([viewId, viewType]) => {
views[viewType] = this.getView(modelName, [viewId, viewType], kwargs);
Object.entries(views[viewType].models).forEach(([modelName, fields]) => {
modelFields[modelName] = modelFields[modelName] || new Set();
fields.forEach((field) => modelFields[modelName].add(field));
});
delete views[viewType].models;
});
// For each model, fetch the information of the fields used in the views only
Object.entries(modelFields).forEach(([modelName, fields]) => {
models[modelName] = { fields: this.mockFieldsGet(modelName, [...fields]) };
});
if (kwargs.options.load_filters && "search" in views) {
views["search"].filters = this.models[modelName].filters || [];
}
return { models, views };
}
/**
* Simulate a 'name_create' operation
*
* @private
* @param {string} modelName
* @param {string} name
* @param {object} kwargs
* @param {object} [kwargs.context]
* @returns {Array} a couple [id, name]
*/
mockNameCreate(modelName, name, kwargs) {
const values = {
name: name,
display_name: name,
};
const [id] = this.mockCreate(modelName, [values], kwargs);
return [id, name];
}
/**
* Simulate a 'name_search' operation.
*
* @param {string} model
* @param {Array} args
* @param {string} args[0]
* @param {Array} args[1], search domain
* @param {Object} kwargs
* @param {number} [kwargs.limit=100] server-side default limit
* @returns {Array[]} a list of [id, display_name]
*/
mockNameSearch(model, args, kwargs) {
const str = args && typeof args[0] === "string" ? args[0] : kwargs.name;
const limit = kwargs.limit || 100;
const domain = (args && args[1]) || kwargs.args || [];
const { records } = this.models[model];
const result = [];
for (const r of records) {
const isInDomain = this.evaluateDomain(domain, r);
if (isInDomain && (!str.length || (r.display_name && r.display_name.includes(str)))) {
result.push([r.id, r.display_name]);
}
}
return result.slice(0, limit);
}
mockOnchange(modelName, args, kwargs) {
const resId = args[0][0];
const changes = args[1];
const specification = args[3];
let fields = args[2] ? (Array.isArray(args[2]) ? args[2] : [args[2]]) : [];
const onchanges = this.models[modelName].onchanges || {};
const firstOnChange = !fields.length;
const fieldsFromView = new Set(Object.keys(specification));
let serverValues = {};
const onchangeValues = {};
for (const fieldName in changes) {
if (!(fieldName in this.models[modelName].fields)) {
throw makeServerError({
type: "ValidationError",
message: `Field ${fieldName} does not exist`,
});
}
}
if (resId) {
serverValues = this.mockRead(modelName, [args[0], [...fieldsFromView]], kwargs)[0];
} else if (firstOnChange) {
// It is the new semantics: no field in arguments means we are in
// a default_get + onchange situation
fields = [...fieldsFromView];
fields
.filter((fName) => !Object.keys(serverValues).includes(fName) && fName !== "id")
.forEach((fName) => {
onchangeValues[fName] = false;
});
const defaultValues = this.mockDefaultGet(modelName, [[...fieldsFromView]], kwargs);
for (const fieldName in defaultValues) {
const fieldType = this.models[modelName].fields[fieldName].type;
if (["one2many", "many2many"].includes(fieldType)) {
const subSpec = specification[fieldName];
for (const command of defaultValues[fieldName]) {
if (command[0] === 0 || command[0] === 1) {
command[2] = pick(command[2], ...Object.keys(subSpec.fields));
}
}
}
}
Object.assign(onchangeValues, defaultValues);
}
fields.forEach((field) => {
if (field in onchanges) {
const target = Object.assign({}, serverValues, onchangeValues, changes);
const handler = {
set(_, key, val) {
if (target[key] !== val) {
onchangeValues[key] = val;
target[key] = val;
}
return true;
},
};
onchanges[field](new Proxy(target, handler));
}
});
for (const fieldName in onchangeValues) {
if (!fieldsFromView.has(fieldName)) {
delete onchangeValues[fieldName];
}
}
return {
value: this.convertToOnChange(modelName, onchangeValues, specification),
};
}
mockRead(modelName, args) {
const model = this.models[modelName];
let fields;
if (args[1] && args[1].length) {
fields = [...new Set(args[1].concat(["id"]))];
} else {
fields = Object.keys(model.fields);
}
const ids = Array.isArray(args[0]) ? args[0] : [args[0]];
const records = [];
// Mapping of model records used in the current mockRead call.
const modelMap = {
[modelName]: {},
};
for (const record of model.records) {
modelMap[modelName][record.id] = record;
}
for (const fieldName of fields) {
const field = model.fields[fieldName];
if (!field) {
continue; // the field doesn't exist on the model, so skip it
}
const { relation, type } = field;
if (type === "many2one" && !modelMap[relation]) {
modelMap[relation] = {};
for (const record of this.models[relation].records) {
modelMap[relation][record.id] = record;
}
}
}
for (const id of ids) {
if (!id) {
throw new Error(
"mock read: falsy value given as id, would result in an access error in actual server !"
);
}
const record = modelMap[modelName][id];
if (!record) {
continue;
}
const result = { id: record.id };
for (const fieldName of fields) {
const field = model.fields[fieldName];
if (!field) {
continue; // the field doesn't exist on the model, so skip it
}
if (["float", "integer", "monetary"].includes(field.type)) {
// read should return 0 for unset numeric fields
result[fieldName] = record[fieldName] || 0;
} else if (field.type === "many2one") {
const relRecord = modelMap[field.relation][record[fieldName]];
if (relRecord) {
result[fieldName] = [record[fieldName], relRecord.display_name];
} else {
result[fieldName] = false;
}
} else if (field.type === "one2many" || field.type === "many2many") {
result[fieldName] = record[fieldName] || [];
} else {
result[fieldName] = record[fieldName] !== undefined ? record[fieldName] : false;
}
}
records.push(result);
}
return records;
}
mockReadGroup(modelName, kwargs) {
if (!("lazy" in kwargs)) {
kwargs.lazy = true;
}
const fields = this.models[modelName].fields;
const records = this.getRecords(modelName, kwargs.domain);
let groupBy = [];
if (kwargs.groupby.length) {
groupBy = kwargs.lazy ? [kwargs.groupby[0]] : kwargs.groupby;
}
const groupByFieldNames = groupBy.map((groupByField) => {
return groupByField.split(":")[0];
});
const aggregatedFields = [];
// if no fields have been given, the server picks all stored fields
if (kwargs.fields.length === 0) {
for (const fieldName in fields) {
if (groupByFieldNames.includes(fieldName)) {
continue;
}
aggregatedFields.push({ fieldName, name: fieldName });
}
} else {
kwargs.fields.forEach((fspec) => {
const [, name, func, fname] = fspec.match(regex_field_agg);
const fieldName = func ? fname || name : name;
if (func && !VALID_AGGREGATE_FUNCTIONS.includes(func)) {
throw new Error(`Invalid aggregation function ${func}.`);
}
if (!fields[fieldName]) {
return;
}
if (!fields[fieldName].aggregator && !func) {
return;
}
if (groupByFieldNames.includes(fieldName)) {
// grouped fields are not aggregated
return;
}
if (
["many2one", "reference"].includes(fields[fieldName].type) &&
!["count_distinct", "array_agg"].includes(func)
) {
return;
}
aggregatedFields.push({ fieldName, func, name });
});
}
function aggregateFields(group, records) {
for (const { fieldName, func, name } of aggregatedFields) {
switch (fields[fieldName].type) {
case "integer":
case "float": {
if (func === "array_agg") {
group[name] = records.map((r) => r[fieldName]);
} else {
if (!records.length) {
group[name] = false;
} else {
group[name] = 0;
for (const r of records) {
group[name] += r[fieldName];
}
}
}
break;
}
case "many2one":
case "reference": {
const ids = records.map((r) => r[fieldName]);
if (func === "array_agg") {
group[name] = ids.map((id) => (id ? id : null));
} else {
const uniqueIds = [...new Set(ids)].filter((id) => id);
group[name] = uniqueIds.length;
}
break;
}
case "boolean": {
if (func === "array_agg") {
group[name] = records.map((r) => r[fieldName]);
} else if (func === "bool_or") {
group[name] = records.some((r) => Boolean(r[fieldName]));
} else if (func === "bool_and") {
group[name] = records.every((r) => Boolean(r[fieldName]));
}
break;
}
}
}
}
function formatValue(groupByField, val) {
if (val === false || val === undefined) {
return false;
}
const [fieldName, aggregateFunction = "month"] = groupByField.split(":");
const { type } = fields[fieldName];
if (type === "date") {
const date = deserializeDate(val);
switch (aggregateFunction) {
case "day":
return date.toFormat("yyyy-MM-dd");
case "day_of_week":
return date.weekday;
case "day_of_month":
return date.day;
case "day_of_year":
return date.ordinal;
case "week":
return `W${date.toFormat("WW kkkk")}`;
case "iso_week_number":
return date.weekNumber;
case "month_number":
return date.month;
case "quarter":
return `Q${date.toFormat("q yyyy")}`;
case "quarter_number":
return date.quarter;
case "year":
return date.toFormat("yyyy");
case "year_number":
return date.year;
default:
return date.toFormat("MMMM yyyy");
}
} else if (type === "datetime") {
const date = deserializeDateTime(val);
switch (aggregateFunction) {
// The year is added to the format because is needed to correctly compute the
// domain and the range (startDate and endDate).
case "second_number":
return date.second;
case "minute_number":
return date.minute;
case "hour":
return date.toFormat("HH:00 dd MMM yyyy");
case "hour_number":
return date.hour;
case "day":
return date.toFormat("yyyy-MM-dd");
case "day_of_week":
return date.weekday;
case "day_of_month":
return date.day;
case "day_of_year":
return date.ordinal;
case "week":
return `W${date.toFormat("WW kkkk")}`;
case "iso_week_number":
return date.weekNumber;
case "month_number":
return date.month;
case "quarter":
return `Q${date.toFormat("q yyyy")}`;
case "quarter_number":
return date.quarter;
case "year":
return date.toFormat("yyyy");
case "year_number":
return date.year;
default:
return date.toFormat("MMMM yyyy");
}
} else if (Array.isArray(val)) {
if (val.length === 0) {
return false;
}
return type === "many2many" ? val : val[0];
} else {
return val;
}
}
if (!groupBy.length) {
const group = { __count: records.length, __domain: kwargs.domain };
aggregateFields(group, records);
return [group];
}
const groups = {};
for (const r of records) {
let recordGroupValues = [];
for (const gbField of groupBy) {
const [fieldName] = gbField.split(":");
let value = formatValue(gbField, r[fieldName]);
if (!Array.isArray(value)) {
value = [value];
}
recordGroupValues = value.reduce((acc, val) => {
const newGroup = {};
newGroup[gbField] = val;
if (recordGroupValues.length === 0) {
acc.push(newGroup);
} else {
for (const groupValue of recordGroupValues) {
acc.push({ ...groupValue, ...newGroup });
}
}
return acc;
}, []);
}
for (const groupValue of recordGroupValues) {
const valueKey = JSON.stringify(groupValue);
groups[valueKey] = groups[valueKey] || [];
groups[valueKey].push(r);
}
}
let readGroupResult = [];
for (const [groupId, groupRecords] of Object.entries(groups)) {
const group = {
...JSON.parse(groupId),
__domain: kwargs.domain || [],
__range: {},
};
for (const gbField of groupBy) {
if (!(gbField in group)) {
group[gbField] = false;
continue;
}
const [fieldName, dateRange] = gbField.split(":");
const value = Number.isInteger(group[gbField])
? group[gbField]
: group[gbField] || false;
const { relation, type } = fields[fieldName];
if (["many2one", "many2many"].includes(type) && !Array.isArray(value)) {
const relatedRecord = this.models[relation].records.find(
({ id }) => id === value
);
if (relatedRecord) {
group[gbField] = [value, relatedRecord.display_name];
} else {
group[gbField] = false;
}
}
if (["date", "datetime"].includes(type)) {
if (value) {
if (!READ_GROUP_NUMBER_GRANULARITY.includes(dateRange)) {
let startDate, endDate;
switch (dateRange) {
case "hour": {
startDate = parseDateTime(value, {
format: "HH:00 dd MMM yyyy",
});
endDate = startDate.plus({ hours: 1 });
// Remove the year from the result value of the group. It was needed
// to compute the startDate and endDate.
group[gbField] = startDate.toFormat("HH:00 dd MMM");
break;
}
case "day": {
startDate = parseDateTime(value, { format: "yyyy-MM-dd" });
endDate = startDate.plus({ days: 1 });
break;
}
case "week": {
startDate = parseDateTime(value, { format: "WW kkkk" });
endDate = startDate.plus({ weeks: 1 });
break;
}
case "quarter": {
startDate = parseDateTime(value, { format: "q yyyy" });
endDate = startDate.plus({ quarters: 1 });
break;
}
case "year": {
startDate = parseDateTime(value, { format: "y" });
endDate = startDate.plus({ years: 1 });
break;
}
case "month":
default: {
startDate = parseDateTime(value, { format: "MMMM yyyy" });
endDate = startDate.plus({ months: 1 });
break;
}
}
const serialize = type === "date" ? serializeDate : serializeDateTime;
const from = serialize(startDate);
const to = serialize(endDate);
group.__range[gbField] = { from, to };
group.__domain = [
[fieldName, ">=", from],
[fieldName, "<", to],
].concat(group.__domain);
} else {
group.__domain = [
[`${fieldName}.${dateRange}`, "=", parseInt(value, 10)],
...group.__domain,
];
}
} else {
group.__range[gbField] = false;
group.__domain = [[fieldName, "=", value]].concat(group.__domain);
}
} else {
group.__domain = [[fieldName, "=", value]].concat(group.__domain);
}
}
if (Object.keys(group.__range || {}).length === 0) {
delete group.__range;
}
// compute count key to match dumb server logic...
let countKey;
if (kwargs.lazy && groupBy.length >= 1) {
countKey = groupBy[0].split(":")[0] + "_count";
} else {
countKey = "__count";
}
group[countKey] = groupRecords.length;
aggregateFields(group, groupRecords);
readGroupResult.push(group);
}
// Order by
this.sortByField(readGroupResult, modelName, kwargs.orderby || groupByFieldNames.join(","));
// Limit
if (kwargs.limit) {
const offset = kwargs.offset || 0;
readGroupResult = readGroupResult.slice(offset, kwargs.limit + offset);
}
return readGroupResult;
}
/**
* @param {string} modelName
* @param {[number | number[]]} args
* @returns {true} currently, always returns true
*/
mockUnlink(modelName, [ids]) {
ids = Array.isArray(ids) ? ids : [ids];
this.models[modelName].records = this.models[modelName].records.filter(
(record) => !ids.includes(record.id)
);
// update value of relationnal fields pointing to the deleted records
for (const { fields, records } of Object.values(this.models)) {
for (const [fieldName, field] of Object.entries(fields)) {
if (field.relation === modelName) {
for (const record of records) {
if (Array.isArray(record[fieldName])) {
record[fieldName] = record[fieldName].filter((id) => !ids.includes(id));
} else if (ids.includes(record[fieldName])) {
record[fieldName] = false;
}
}
}
}
}
return true;
}
mockWebReadGroup(modelName, kwargs) {
const groups = this.mockReadGroup(modelName, kwargs);
const allGroups = this.mockReadGroup(modelName, {
domain: kwargs.domain,
fields: ["display_name"],
groupby: kwargs.groupby,
lazy: kwargs.lazy,
});
return {
groups: groups,
length: allGroups.length,
};
}
mockReadProgressBar(modelName, kwargs) {
const { domain, group_by: groupBy, progress_bar: progressBar } = kwargs;
const groups = this.mockReadGroup(modelName, { domain, fields: [], groupby: [groupBy] });
// Find group by field
const data = {};
for (const group of groups) {
const records = this.getRecords(modelName, group.__domain || []);
let groupByValue = group[groupBy]; // always technical value here
if (Array.isArray(groupByValue)) {
groupByValue = groupByValue[0];
}
// special case for bool values: rpc call response with capitalized strings
if (!(groupByValue in data)) {
if (groupByValue === true) {
groupByValue = "True";
} else if (groupByValue === false) {
groupByValue = "False";
}
}
if (!(groupByValue in data)) {
data[groupByValue] = {};
for (const key in progressBar.colors) {
data[groupByValue][key] = 0;
}
}
for (const record of records) {
const fieldValue = record[progressBar.field];
if (fieldValue in data[groupByValue]) {
data[groupByValue][fieldValue]++;
}
}
}
return data;
}
/**
* Simulates a call to the server '_search_panel_field_image' method.
*
* @private
* @param {string} model
* @param {string} fieldName
* @param {Object} kwargs
* @see mockSearchPanelDomainImage()
*/
mockSearchPanelFieldImage(model, fieldName, kwargs) {
const enableCounters = kwargs.enable_counters;
const onlyCounters = kwargs.only_counters;
const extraDomain = kwargs.extra_domain || [];
const normalizedExtra = new Domain(extraDomain).toList();
const noExtra = JSON.stringify(normalizedExtra) === "[]";
const modelDomain = kwargs.model_domain || [];
const countDomain = new Domain([...modelDomain, ...extraDomain]).toList();
const limit = kwargs.limit;
const setLimit = kwargs.set_limit;
if (onlyCounters) {
return this.mockSearchPanelDomainImage(model, fieldName, countDomain, true);
}
const modelDomainImage = this.mockSearchPanelDomainImage(
model,
fieldName,
modelDomain,
enableCounters && noExtra,
setLimit && limit
);
if (enableCounters && !noExtra) {
const countDomainImage = this.mockSearchPanelDomainImage(
model,
fieldName,
countDomain,
true
);
for (const [id, values] of modelDomainImage.entries()) {
const element = countDomainImage.get(id);
values.__count = element ? element.__count : 0;
}
}
return modelDomainImage;
}
/**
* Simulates a call to the server '_search_panel_domain_image' method.
*
* @private
* @param {string} model
* @param {Array[]} domain
* @param {string} fieldName
* @param {boolean} setCount
* @returns {Map}
*/
mockSearchPanelDomainImage(model, fieldName, domain, setCount = false, limit = false) {
const field = this.models[model].fields[fieldName];
let groupIdName;
if (field.type === "many2one") {
groupIdName = (value) => value || [false, undefined];
// mockReadGroup does not take care of the condition [fieldName, '!=', false]
// in the domain defined below !!!
} else if (field.type === "selection") {
const selection = {};
for (const [value, label] of this.models[model].fields[fieldName].selection) {
selection[value] = label;
}
groupIdName = (value) => [value, selection[value]];
}
domain = new Domain([...domain, [fieldName, "!=", false]]).toList();
const groups = this.mockReadGroup(model, {
domain,
fields: [fieldName],
groupby: [fieldName],
limit,
});
const domainImage = new Map();
for (const group of groups) {
const [id, display_name] = groupIdName(group[fieldName]);
const values = { id, display_name };
if (setCount) {
values.__count = group[fieldName + "_count"];
}
domainImage.set(id, values);
}
return domainImage;
}
/**
* Simulates a call to the server '_search_panel_global_counters' method.
*
* @private
* @param {Map} valuesRange
* @param {(string|boolean)} parentName 'parent_id' or false
*/
mockSearchPanelGlobalCounters(valuesRange, parentName) {
const localCounters = [...valuesRange.keys()].map((id) => valuesRange.get(id).__count);
for (let [id, values] of valuesRange.entries()) {
const count = localCounters[id];
if (count) {
let parent_id = values[parentName];
while (parent_id) {
values = valuesRange.get(parent_id);
values.__count += count;
parent_id = values[parentName];
}
}
}
}
/**
* Simulates a call to the server '_search_panel_sanitized_parent_hierarchy' method.
*
* @private
* @param {Object[]} records
* @param {(string|boolean)} parentName 'parent_id' or false
* @param {number[]} ids
* @returns {Object[]}
*/
mockSearchPanelSanitizedParentHierarchy(records, parentName, ids) {
const getParentId = (record) => record[parentName] && record[parentName][0];
const allowedRecords = {};
for (const record of records) {
allowedRecords[record.id] = record;
}
const recordsToKeep = {};
for (const id of ids) {
const ancestorChain = {};
let recordId = id;
let chainIsFullyIncluded = true;
while (chainIsFullyIncluded && recordId) {
const knownStatus = recordsToKeep[recordId];
if (knownStatus !== undefined) {
chainIsFullyIncluded = knownStatus;
break;
}
const record = allowedRecords[recordId];
if (record) {
ancestorChain[recordId] = record;
recordId = getParentId(record);
} else {
chainIsFullyIncluded = false;
}
}
for (const id in ancestorChain) {
recordsToKeep[id] = chainIsFullyIncluded;
}
}
return records.filter((rec) => recordsToKeep[rec.id]);
}
/**
* Simulates a call to the server 'search_panel_selection_range' method.
*
* @private
* @param {string} model
* @param {string} fieldName
* @param {Object} kwargs
* @returns {Object[]}
*/
mockSearchPanelSelectionRange(model, fieldName, kwargs) {
const enableCounters = kwargs.enable_counters;
const expand = kwargs.expand;
let domainImage;
if (enableCounters || !expand) {
const newKwargs = Object.assign({}, kwargs, {
only_counters: expand,
});
domainImage = this.mockSearchPanelFieldImage(model, fieldName, newKwargs);
}
if (!expand) {
return [...domainImage.values()];
}
const selection = this.models[model].fields[fieldName].selection;
const selectionRange = [];
for (const [value, label] of selection) {
const values = {
id: value,
display_name: label,
};
if (enableCounters) {
values.__count = domainImage.get(value) ? domainImage.get(value).__count : 0;
}
selectionRange.push(values);
}
return selectionRange;
}
/**
* Simulates a call to the server 'search_panel_select_range' method.
*
* @private
* @param {string} model
* @param {string[]} args
* @param {string} args[fieldName]
* @param {Object} [kwargs={}]
* @param {Array[]} [kwargs.category_domain] domain generated by categories
* (this parameter is used in _search_panel_range)
* @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
* (this parameter is used in _search_panel_range)
* @param {boolean} [kwargs.enable_counters] whether to count records by value
* @param {Array[]} [kwargs.filter_domain] domain generated by filters
* @param {number} [kwargs.limit] maximal number of values to fetch
* @param {Array[]} [kwargs.search_domain] base domain of search (this parameter
* is used in _search_panel_range)
* @returns {Object}
*/
mockSearchPanelSelectRange(model, [fieldName], kwargs) {
const field = this.models[model].fields[fieldName];
const supportedTypes = ["many2one", "selection"];
if (!supportedTypes.includes(field.type)) {
throw new Error(
`Only types ${supportedTypes} are supported for category (found type ${field.type})`
);
}
const modelDomain = kwargs.search_domain || [];
const extraDomain = new Domain([
...(kwargs.category_domain || []),
...(kwargs.filter_domain || []),
]).toList();
if (field.type === "selection") {
const newKwargs = Object.assign({}, kwargs, {
model_domain: modelDomain,
extra_domain: extraDomain,
});
kwargs.model_domain = modelDomain;
return {
parent_field: false,
values: this.mockSearchPanelSelectionRange(model, fieldName, newKwargs),
};
}
const fieldNames = ["display_name"];
let hierarchize = "hierarchize" in kwargs ? kwargs.hierarchize : true;
let getParentId;
let parentName = false;
if (hierarchize && this.models[field.relation].fields.parent_id) {
parentName = "parent_id"; // in tests, parent field is always 'parent_id'
fieldNames.push(parentName);
getParentId = (record) => record.parent_id && record.parent_id[0];
} else {
hierarchize = false;
}
let comodelDomain = kwargs.comodel_domain || [];
const enableCounters = kwargs.enable_counters;
const expand = kwargs.expand;
const limit = kwargs.limit;
let domainImage;
if (enableCounters || !expand) {
const newKwargs = Object.assign({}, kwargs, {
model_domain: modelDomain,
extra_domain: extraDomain,
only_counters: expand,
set_limit: limit && !(expand || hierarchize || comodelDomain),
});
domainImage = this.mockSearchPanelFieldImage(model, fieldName, newKwargs);
}
if (!expand && !hierarchize && !comodelDomain.length) {
if (limit && domainImage.size === limit) {
return { error_msg: "Too many items to display." };
}
return {
parent_field: parentName,
values: [...domainImage.values()],
};
}
let imageElementIds;
if (!expand) {
imageElementIds = [...domainImage.keys()].map(Number);
let condition;
if (hierarchize) {
const records = this.models[field.relation].records;
const ancestorIds = new Set();
for (const id of imageElementIds) {
let recordId = id;
let record;
while (recordId) {
ancestorIds.add(recordId);
record = records.find((rec) => rec.id === recordId);
recordId = record[parentName];
}
}
condition = ["id", "in", [...new Set(ancestorIds)]];
} else {
condition = ["id", "in", imageElementIds];
}
comodelDomain = new Domain([...comodelDomain, condition]).toList();
}
let comodelRecords = this.mockSearchRead(field.relation, [comodelDomain, fieldNames], {
limit,
});
if (hierarchize) {
const ids = expand ? comodelRecords.map((rec) => rec.id) : imageElementIds;
comodelRecords = this.mockSearchPanelSanitizedParentHierarchy(
comodelRecords,
parentName,
ids
);
}
if (limit && comodelRecords.length === limit) {
return { error_msg: "Too many items to display." };
}
// A map is used to keep the initial order.
const fieldRange = new Map();
for (const record of comodelRecords) {
const values = {
id: record.id,
display_name: record.display_name,
};
if (hierarchize) {
values[parentName] = getParentId(record);
}
if (enableCounters) {
values.__count = domainImage.get(record.id)
? domainImage.get(record.id).__count
: 0;
}
fieldRange.set(record.id, values);
}
if (hierarchize && enableCounters) {
this.mockSearchPanelGlobalCounters(fieldRange, parentName);
}
return {
parent_field: parentName,
values: [...fieldRange.values()],
};
}
/**
* Simulates a call to the server 'search_panel_select_multi_range' method.
*
* @private
* @param {string} model
* @param {string[]} args
* @param {string} args[fieldName]
* @param {Object} [kwargs={}]
* @param {Array[]} [kwargs.category_domain] domain generated by categories
* @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
* (this parameter is used in _search_panel_range)
* @param {boolean} [kwargs.enable_counters] whether to count records by value
* @param {Array[]} [kwargs.filter_domain] domain generated by filters
* @param {string} [kwargs.group_by] extra field to read on comodel, to group
* comodel records
* @param {Array[]} [kwargs.group_domain] dict, one domain for each activated
* group for the group_by (if any). Those domains are used to fech accurate
* counters for values in each group
* @param {number} [kwargs.limit] maximal number of values to fetch
* @param {Array[]} [kwargs.search_domain] base domain of search
* @returns {Object}
*/
mockSearchPanelSelectMultiRange(model, [fieldName], kwargs) {
const field = this.models[model].fields[fieldName];
const supportedTypes = ["many2one", "many2many", "selection"];
if (!supportedTypes.includes(field.type)) {
throw new Error(
`Only types ${supportedTypes} are supported for filter (found type ${field.type})`
);
}
let modelDomain = kwargs.search_domain || [];
let extraDomain = new Domain([
...(kwargs.category_domain || []),
...(kwargs.filter_domain || []),
]).toList();
if (field.type === "selection") {
const newKwargs = Object.assign({}, kwargs, {
model_domain: modelDomain,
extra_domain: extraDomain,
});
return {
values: this.mockSearchPanelSelectionRange(model, fieldName, newKwargs),
};
}
const fieldNames = ["display_name"];
const groupBy = kwargs.group_by;
let groupIdName;
if (groupBy) {
const groupByField = this.models[field.relation].fields[groupBy];
fieldNames.push(groupBy);
if (groupByField.type === "many2one") {
groupIdName = (value) => value || [false, "Not set"];
} else if (groupByField.type === "selection") {
const groupBySelection = Object.assign(
{},
this.models[field.relation].fields[groupBy].selection
);
groupBySelection[false] = "Not Set";
groupIdName = (value) => [value, groupBySelection[value]];
} else {
groupIdName = (value) => (value ? [value, value] : [false, "Not set"]);
}
}
let comodelDomain = kwargs.comodel_domain || [];
const enableCounters = kwargs.enable_counters;
const expand = kwargs.expand;
const limit = kwargs.limit;
if (field.type === "many2many") {
const comodelRecords = this.mockSearchRead(
field.relation,
[comodelDomain, fieldNames],
{
limit,
}
);
if (expand && limit && comodelRecords.length === limit) {
return { error_msg: "Too many items to display." };
}
const groupDomain = kwargs.group_domain;
const fieldRange = [];
for (const record of comodelRecords) {
const values = {
id: record.id,
display_name: record.display_name,
};
let groupId;
if (groupBy) {
const [gId, gName] = groupIdName(record[groupBy]);
values.group_id = groupId = gId;
values.group_name = gName;
}
let count;
let inImage;
if (enableCounters || !expand) {
const searchDomain = new Domain([
...modelDomain,
[fieldName, "in", record.id],
]).toList();
let localExtraDomain = extraDomain;
if (groupBy && groupDomain) {
localExtraDomain = new Domain([
...localExtraDomain,
...(groupDomain[JSON.stringify(groupId)] || []),
]).toList();
}
const searchCountDomain = new Domain([
...searchDomain,
...localExtraDomain,
]).toList();
if (enableCounters) {
count = this.mockSearchCount(model, [searchCountDomain]);
}
if (!expand) {
if (enableCounters && JSON.stringify(localExtraDomain) === "[]") {
inImage = count;
} else {
inImage = this.mockSearch(model, [searchDomain], { limit: 1 }).length;
}
}
}
if (expand || inImage) {
if (enableCounters) {
values.__count = count;
}
fieldRange.push(values);
}
}
if (!expand && limit && fieldRange.length === limit) {
return { error_msg: "Too many items to display." };
}
return { values: fieldRange };
}
if (field.type === "many2one") {
let domainImage;
if (enableCounters || !expand) {
extraDomain = new Domain([...extraDomain, ...(kwargs.group_domain || [])]).toList();
modelDomain = new Domain([...modelDomain, ...(kwargs.group_domain || [])]).toList();
const newKwargs = Object.assign({}, kwargs, {
model_domain: modelDomain,
extra_domain: extraDomain,
only_counters: expand,
set_limit: limit && !(expand || groupBy || comodelDomain),
});
domainImage = this.mockSearchPanelFieldImage(model, fieldName, newKwargs);
}
if (!expand && !groupBy && !comodelDomain.length) {
if (limit && domainImage.size === limit) {
return { error_msg: "Too many items to display." };
}
return { values: [...domainImage.values()] };
}
if (!expand) {
const imageElementIds = [...domainImage.keys()].map(Number);
comodelDomain = new Domain([
...comodelDomain,
["id", "in", imageElementIds],
]).toList();
}
const comodelRecords = this.mockSearchRead(
field.relation,
[comodelDomain, fieldNames],
{
limit,
}
);
if (limit && comodelRecords.length === limit) {
return { error_msg: "Too many items to display." };
}
const fieldRange = [];
for (const record of comodelRecords) {
const values = {
id: record.id,
display_name: record.display_name,
};
if (groupBy) {
const [groupId, groupName] = groupIdName(record[groupBy]);
values.group_id = groupId;
values.group_name = groupName;
}
if (enableCounters) {
values.__count = domainImage.get(record.id)
? domainImage.get(record.id).__count
: 0;
}
fieldRange.push(values);
}
return { values: fieldRange };
}
}
mockSearch(modelName, args, kwargs) {
const result = this.mockSearchController({
model: modelName,
domain: kwargs.domain || args[0],
fields: kwargs.fields || args[1],
offset: kwargs.offset || args[2],
limit: kwargs.limit || args[3],
sort: kwargs.order || args[4],
context: kwargs.context,
});
return result.records.map((r) => r.id);
}
/**
* Simulate a 'search_count' operation
*
* @private
* @param {string} model
* @param {Array} args
* @param {object} kwargs
* @param {object} [kwargs.context]
* @returns {number}
*/
mockSearchCount(model, args, kwargs = {}) {
return this.getRecords(model, args[0], kwargs.context).length;
}
mockSearchRead(modelName, args, kwargs) {
const { fieldNames, records } = this.mockSearchController({
model: modelName,
domain: kwargs.domain || args[0],
fields: kwargs.fields || args[1],
offset: kwargs.offset || args[2],
limit: kwargs.limit || args[3],
sort: kwargs.order || args[4],
context: kwargs.context,
});
return this.mockRead(modelName, [records.map((r) => r.id), fieldNames]);
}
mockWebSave(modelName, args, kwargs) {
const ids = args[0];
if (ids.length === 0) {
args[0] = this.mockCreate(modelName, args[1], kwargs);
} else {
this.mockWrite(modelName, args);
}
if (kwargs.next_id) {
args[0] = kwargs.next_id;
}
return this.mockWebRead(modelName, args, kwargs);
}
mockWebRead(modelName, args, kwargs) {
const ids = args[0];
let fieldNames = Object.keys(kwargs.specification);
if (!fieldNames.length) {
fieldNames = ["id"];
}
const records = this.mockRead(modelName, [ids, fieldNames], {
context: kwargs.context,
});
this._unityReadRecords(modelName, kwargs.specification, records);
return records;
}
mockWebSearchReadUnity(modelName, args, kwargs) {
let _fieldNames = Object.keys(kwargs.specification);
if (!_fieldNames.length) {
_fieldNames = ["id"];
}
const { fieldNames, length, records } = this.mockSearchController({
model: modelName,
fields: _fieldNames,
domain: kwargs.domain,
offset: kwargs.offset,
limit: kwargs.limit,
sort: kwargs.order,
context: kwargs.context,
});
const result = {
length,
records: this.mockRead(modelName, [records.map((r) => r.id), fieldNames]),
};
const countLimit = kwargs.count_limit || args[5];
if (countLimit) {
result.length = Math.min(result.length, countLimit);
}
this._unityReadRecords(modelName, kwargs.specification, result.records);
return result;
}
mockSearchController(params) {
const model = this.models[params.model];
let fieldNames = params.fields;
const offset = params.offset || 0;
if (!fieldNames || !fieldNames.length) {
fieldNames = Object.keys(model.fields);
}
fieldNames = [...new Set(fieldNames.concat(["id"]))];
const { context } = params;
const active_test = context && "active_test" in context ? context.active_test : true;
let records = this.getRecords(
params.model,
params.domain || [],
Object.assign({}, params.context, { active_test })
);
this.sortByField(records, params.model, params.sort);
const nbRecords = records.length;
records = records.slice(offset, params.limit ? offset + params.limit : nbRecords);
return {
fieldNames,
length: nbRecords,
records,
};
}
mockResequence(args) {
const offset = args.offset ? Number(args.offset) : 0;
const field = args.field || "sequence";
if (!(field in this.models[args.model].fields)) {
return false;
}
for (const index in args.ids) {
const record = this.models[args.model].records.find((r) => r.id === args.ids[index]);
record[field] = Number(index) + offset;
}
return true;
}
mockWrite(modelName, args) {
args[0].forEach((id) => {
const [originalRecord] = this.mockSearchRead(modelName, [[["id", "=", id]]], {});
this.writeRecord(modelName, args[1], id);
const updatedRecord = this.models[modelName].records.find((record) => record.id === id);
this.updateComodelRelationalFields(modelName, updatedRecord, originalRecord);
});
return true;
}
//////////////////////////////////////////////////////////////////////////////
// Private
//////////////////////////////////////////////////////////////////////////////
/**
* Fill all inverse fields of the relational fields present in the record
* to be created/updated.
*
* @param {string} modelName
* @param {Object} record record that have been created/updated.
* @param {Object|undefined} originalRecord record before update.
*/
updateComodelRelationalFields(modelName, record, originalRecord) {
for (const fname in record) {
const field = this.models[modelName].fields[fname];
const comodelName = field.relation || record[field.model_name_ref_fname];
const inverseFieldName =
field.inverse_fname_by_model_name && field.inverse_fname_by_model_name[comodelName];
if (!inverseFieldName) {
// field has no inverse, skip it.
continue;
}
const relatedRecordIds = Array.isArray(record[fname]) ? record[fname] : [record[fname]];
const comodel_inverse_field = this.models[comodelName].fields[inverseFieldName];
// we only want to set a value for comodel inverse field if the model field has a value.
if (record[fname]) {
for (const relatedRecordId of relatedRecordIds) {
let inverseFieldNewValue = record.id;
const relatedRecord = this.models[comodelName].records.find(
(record) => record.id === relatedRecordId
);
const relatedFieldValue = relatedRecord && relatedRecord[inverseFieldName];
if (
relatedFieldValue === undefined ||
relatedFieldValue === record.id ||
(field.type !== "one2many" && relatedFieldValue.includes(record.id))
) {
// related record does not exist or the related value is already up to date.
continue;
}
if (Array.isArray(relatedFieldValue)) {
inverseFieldNewValue = [...relatedFieldValue, record.id];
}
const data = { [inverseFieldName]: inverseFieldNewValue };
if (comodel_inverse_field.type === "many2one_reference") {
data[comodel_inverse_field.model_name_ref_fname] = modelName;
}
this.writeRecord(comodelName, data, relatedRecordId);
}
} else if (field.type === "many2one_reference") {
// we need to clean the many2one_field as well.
const model_many2one_field =
comodel_inverse_field.inverse_fname_by_model_name[modelName];
this.writeRecord(modelName, { [model_many2one_field]: false }, record.id);
}
// it's an update, get the records that were originally referenced but are not
// anymore and update their relational fields.
if (originalRecord) {
const originalRecordIds = Array.isArray(originalRecord[fname])
? originalRecord[fname]
: [originalRecord[fname]];
// search read returns [id, name], let's ensure the removedRecordIds are integers.
const removedRecordIds = originalRecordIds.filter(
(recordId) => Number.isInteger(recordId) && !relatedRecordIds.includes(recordId)
);
for (const removedRecordId of removedRecordIds) {
const removedRecord = this.models[comodelName].records.find(
(record) => record.id === removedRecordId
);
if (!removedRecord) {
continue;
}
let inverseFieldNewValue = false;
if (Array.isArray(removedRecord[inverseFieldName])) {
inverseFieldNewValue = removedRecord[inverseFieldName].filter(
(id) => id !== record.id
);
}
this.writeRecord(
comodelName,
{
[inverseFieldName]: inverseFieldNewValue.length
? inverseFieldNewValue
: false,
},
removedRecordId
);
}
}
}
}
evaluateDomain(domain, record) {
return new Domain(domain).contains(record);
}
/**
* Returns the field by which a given model must be ordered.
* It is either:
* - the field matching 'fieldNameSpec' (if any, else an error is thrown).
* - if no field spec is given : the 'sequence' field (if any), or the 'id' field.
*
* @param {string} modelName
* @param {string} [fieldNameSpec]
* @returns {Object}
*/
getOrderByField(modelName, fieldNameSpec) {
const { fields } = this.models[modelName];
const fieldName = fieldNameSpec?.split(":")[0] || ("sequence" in fields ? "sequence" : "id");
if (!(fieldName in fields)) {
throw new Error(
`Mock: cannot sort records of model "${modelName}" by field "${fieldName}": field not found`
);
}
return fields[fieldName];
}
/**
* Extract a sorting value for date/datetime fields from read_group __range
* The start of the range for the shortest granularity is taken since it is
* the most specific for a given group.
*
* @param {Object} record
* @param {string} fieldName
* @returns {string|false}
*/
getDateSortingValue(record, fieldName) {
// extract every range start related to fieldName
const values = [];
for (const groupedBy of Object.keys(record.__range)) {
if (groupedBy.startsWith(fieldName)) {
values.push(record.__range[groupedBy].from);
}
}
// return false or the latest range start (related to the shortest
// granularity (i.e. day, week, ...))
return !values.length || values.includes(false)
? false
: values.reduce((max, value) => {
return value > max ? value : max;
});
}
/**
* Extract a sorting value for date/datetime fields from read_group when the
* date is groupby by a date number (month_number, year_number, ...)
*
* @param {Object} group
* @param {string} fieldName
* @returns {number | false}
*/
getDateNumberSortingValue = (group, fieldName) => {
let max = -1;
let value = false;
for (const groupedBy in group) {
if (groupedBy.startsWith(fieldName)) {
const [, granularity] = groupedBy.split(":");
const index = READ_GROUP_NUMBER_GRANULARITY.indexOf(granularity);
if (index !== -1 && index > max) {
max = index;
value = group[groupedBy];
}
}
}
return value;
};
/**
* Get all records from a model matching a domain. The only difficulty is
* that if we have an 'active' field, we implicitely add active = true in
* the domain.
*/
getRecords(modelName, domain, { active_test = true } = {}) {
if (!Array.isArray(domain)) {
throw new Error("MockServer._getRecords: given domain has to be an array.");
}
const model = this.models[modelName];
// add ['active', '=', true] to the domain if 'active' is not yet present in domain
if (active_test && "active" in model.fields) {
const activeInDomain = domain.some((subDomain) => subDomain[0] === "active");
if (!activeInDomain) {
domain = domain.concat([["active", "=", true]]);
}
}
let records = model.records;
if (domain.length) {
domain = domain.map((criterion) => {
// 'child_of' operator isn't supported by domain.js, so we replace
// in by the 'in' operator (with the ids of children)
if (criterion[1] === "child_of") {
let oldLength = 0;
const childIDs = [criterion[2]];
while (childIDs.length > oldLength) {
oldLength = childIDs.length;
records.forEach((r) => {
if (childIDs.indexOf(r.parent_id) >= 0) {
childIDs.push(r.id);
}
});
}
criterion = [criterion[0], "in", childIDs];
} else if (criterion[1] === "parent_of") {
// 'parent_of' operator is not supported by domain.js, so we replace
// in by the 'in' operator (with the ids of parent and its ancestors)
const childID = criterion[2];
const parentIDs = [];
const recordPerID = {};
for (const record of records) {
recordPerID[record.id] = record;
}
let record = recordPerID[childID];
while (record) {
parentIDs.push(record.id);
record = record.parent_id && recordPerID[record.parent_id];
}
criterion = [criterion[0], "in", parentIDs];
}
// In case of many2many field, if domain operator is '=' generally change it to 'in' operator
const field = model.fields[criterion[0]] || {};
if (field.type === "many2many" && criterion[1] === "=") {
if (criterion[2] === false) {
// if undefined value asked, domain.js require equality with empty array
criterion = [criterion[0], "=", []];
} else {
criterion = [criterion[0], "in", [criterion[2]]];
}
}
return criterion;
});
records = records.filter((record) => this.evaluateDomain(domain, record));
}
return records;
}
/**
* Sorts the given list of records *IN PLACE* by the given field name. The
* 'orderby' field name and sorting direction are determined by the optional
* `orderBy` param, else the default orderBy field is applied (with "ASC").
* @see {getOrderByField}
*
* @param {Object[]} records
* @param {string} modelName
* @param {string} [orderBy="id ASC"]
* @returns {Object[]}
*/
sortByField(records, modelName, orderBy = "") {
const orderBys = orderBy.split(",");
const [fieldNameSpec, order] = orderBys.pop().split(" ");
const field = this.getOrderByField(modelName, fieldNameSpec);
// Prepares a values map if needed to easily retrieve the ordering
// factor associated to a certain id or value.
let valuesMap;
switch (field.type) {
case "many2many":
case "many2one": {
const coRecords = this.models[field.relation].records;
const coField = this.getOrderByField(field.relation);
if (field.type === "many2many") {
// M2m use the joined list of comodel field values
// -> they need to be sorted
this.sortByField(coRecords, field.relation);
}
valuesMap = new Map(coRecords.map((r) => [r.id, r[coField.name]]));
break;
}
case "selection": {
// Selection order is determined by the index of each value
valuesMap = new Map(field.selection.map((v, i) => [v[0], i]));
break;
}
}
// Actual sorting
const sortedRecords = records.sort((r1, r2) => {
let v1 = r1[field.name];
let v2 = r2[field.name];
switch (field.type) {
case "many2one": {
if (v1) {
v1 = valuesMap.get(v1[0]);
}
if (v2) {
v2 = valuesMap.get(v2[0]);
}
break;
}
case "many2many": {
// Co-records have already been sorted -> comparing the joined
// list of each of them will yield the proper result.
if (v1) {
v1 = v1.map((id) => valuesMap.get(id)).join("");
}
if (v2) {
v2 = v2.map((id) => valuesMap.get(id)).join("");
}
break;
}
case "date":
case "datetime": {
if (r1.__range && r2.__range) {
v1 = this.getDateSortingValue(r1, field.name);
v2 = this.getDateSortingValue(r2, field.name);
} else {
v1 = this.getDateNumberSortingValue(r1, field.name);
v2 = this.getDateNumberSortingValue(r2, field.name);
}
break;
}
case "selection": {
v1 = valuesMap.get(v1);
v2 = valuesMap.get(v2);
break;
}
}
const result = v1 > v2 ? 1 : v1 < v2 ? -1 : 0;
return order === "DESC" ? -result : result;
});
// Goes to the next level of orderBy (if any)
if (orderBys.length) {
return this.sortByField(sortedRecords, modelName, orderBys.join(","));
}
return sortedRecords;
}
writeRecord(modelName, values, id, { ensureIntegrity = true } = {}) {
const model = this.models[modelName];
const record = model.records.find((r) => r.id === id);
for (const fieldName in values) {
const field = model.fields[fieldName];
let value = values[fieldName];
if (!field) {
throw Error(
`Mock: Can't write value "${JSON.stringify(
value
)}" on field "${fieldName}" on record "${model},${id}" (field is undefined)`
);
}
if (["one2many", "many2many"].includes(field.type)) {
let ids = record[fieldName] ? record[fieldName].slice() : [];
// if a field has been modified, its value must always be sent to the server for onchange and write.
// take into account that the value can be a empty list of commands.
if (Array.isArray(value) && value.length) {
if (
value.reduce((hasOnlyInt, val) => hasOnlyInt && Number.isInteger(val), true)
) {
// fallback to command 6 when given a simple list of ids
value = [[6, 0, value]];
}
} else if (value === false) {
// delete all command
value = [[5]];
}
// interpret commands
for (const command of value || []) {
if (command[0] === 0) {
// CREATE
const inverseData = command[2]; // write in place instead of copy, because some tests rely on the object given being updated
const inverseFieldName =
field.inverse_fname_by_model_name &&
field.inverse_fname_by_model_name[field.relation];
if (inverseFieldName) {
inverseData[inverseFieldName] = id;
}
const [newId] = this.mockCreate(field.relation, [inverseData]);
ids.push(newId);
} else if (command[0] === 1) {
// UPDATE
this.mockWrite(field.relation, [[command[1]], command[2]]);
} else if (command[0] === 2 || command[0] === 3) {
// DELETE or FORGET
ids.splice(ids.indexOf(command[1]), 1);
} else if (command[0] === 4) {
// LINK_TO
if (!ids.includes(command[1])) {
ids.push(command[1]);
}
} else if (command[0] === 5) {
// DELETE ALL
ids = [];
} else if (command[0] === 6) {
// REPLACE WITH
// copy array to avoid leak by reference (eg. of default data)
ids = [...command[2]];
} else {
throw Error(
`Command "${JSON.stringify(
value
)}" not supported by the MockServer on field "${fieldName}" on record "${model},${id}"`
);
}
}
record[fieldName] = ids;
} else if (field.type === "many2one") {
if (value) {
const relRecord = this.models[field.relation].records.find(
(r) => r.id === value
);
if (!relRecord && ensureIntegrity) {
throw Error(
`Wrong id "${JSON.stringify(
value
)}" for a many2one on field "${fieldName}" on record "${model},${id}"`
);
}
record[fieldName] = value;
} else {
record[fieldName] = false;
}
} else {
record[fieldName] = value;
}
}
}
getUnusedID(modelName) {
const model = this.models[modelName];
return (
model.records.reduce((max, record) => {
if (!Number.isInteger(record.id)) {
return max;
}
return Math.max(record.id, max);
}, 0) + 1
);
}
applyDefaults(model, record, context = {}) {
record.display_name = record.display_name || record.name;
for (const fieldName in model.fields) {
if (fieldName === "id") {
continue;
}
if (!(fieldName in record)) {
if (`default_${fieldName}` in context) {
record[fieldName] = context[`default_${fieldName}`];
} else if ("default" in model.fields[fieldName]) {
const def = model.fields[fieldName].default;
record[fieldName] = typeof def === "function" ? def.call(this) : def;
} else if (["one2many", "many2many"].includes(model.fields[fieldName].type)) {
record[fieldName] = [];
} else {
record[fieldName] = false;
}
}
}
}
_unityReadRecords(modelName, spec, records) {
for (const fieldName in spec) {
const field = this.models[modelName].fields[fieldName];
const relatedFields = spec[fieldName].fields;
switch (field.type) {
case "reference": {
for (const record of records) {
if (!record[fieldName]) {
continue;
}
const [model, i] = record[fieldName].split(",");
const id = parseInt(i, 10);
record[fieldName] = {};
if (relatedFields && Object.keys(relatedFields).length) {
const result = this.mockWebRead(model, [[id]], {
specification: relatedFields,
context: spec[fieldName].context,
});
record[fieldName] = result[0];
}
record[fieldName].id = { id, model };
}
break;
}
case "many2one_reference": {
for (const record of records) {
const id = record[fieldName];
if (!id) {
record[fieldName] = 0;
continue;
}
if (!relatedFields) {
continue;
}
// the field isn't necessarily in the view, so get the model by looking
// in "db"
const dbRecord = this.models[modelName].records.find(
(r) => r.id === record.id
);
const model = dbRecord[field.model_field];
record[fieldName] = {};
if (relatedFields && Object.keys(relatedFields).length) {
const result = this.mockWebRead(model, [[id]], {
specification: relatedFields,
context: spec[fieldName].context,
});
record[fieldName] = result[0];
}
}
break;
}
case "one2many":
case "many2many": {
if (relatedFields && Object.keys(relatedFields).length) {
const ids = unique(records.map((r) => r[fieldName]).flat());
const result = this.mockWebRead(field.relation, [ids], {
specification: relatedFields,
context: spec[fieldName].context,
});
const allRelRecords = {};
for (const relRecord of result) {
allRelRecords[relRecord.id] = relRecord;
}
const { limit, order } = spec[fieldName];
for (const record of records) {
const relResIds = record[fieldName];
let relRecords = relResIds.map((resId) => allRelRecords[resId]);
if (order) {
relRecords = this.sortByField(relRecords, field.relation, order);
}
if (limit) {
relRecords = relRecords.map((r, i) => {
return i < limit ? r : { id: r.id };
});
}
record[fieldName] = relRecords;
}
}
break;
}
case "many2one": {
for (const record of records) {
if (record[fieldName] !== false) {
if (!relatedFields) {
record[fieldName] = record[fieldName][0];
} else {
record[fieldName] = this.mockWebRead(
field.relation,
[record[fieldName][0]],
{
specification: relatedFields,
context: spec[fieldName].context,
}
)[0];
}
}
}
}
}
}
}
}
// -----------------------------------------------------------------------------
// MockServer deployment helper
// -----------------------------------------------------------------------------
// instance of `MockServer` linked to the current test.
let mockServer;
QUnit.testStart(() => (mockServer = undefined));
export async function makeMockServer(serverData, mockRPC) {
serverData = serverData || {};
if (!mockServer) {
mockServer = new MockServer(serverData, {
debug: QUnit.config.debug,
});
} else {
Object.assign(mockServer.archs, serverData.views);
Object.assign(mockServer.actions, serverData.actions);
}
const _mockRPC = async (route, args = {}) => {
let res;
if (args.method !== "POST") {
// simulates that we serialized the call to be passed in a real request
args = JSON.parse(JSON.stringify(args));
}
if (!mockServer.active) {
// End of test => all RPCs are blocking
return new Promise(() => {});
}
if (mockRPC) {
res = await mockRPC(route, args, mockServer.performRPC.bind(mockServer));
}
if (res === undefined) {
res = await mockServer.performRPC(route, args);
}
return res;
};
patchWithCleanup(browser, {
fetch: makeMockFetch(_mockRPC),
});
patchRPCWithCleanup(_mockRPC);
if (mockRPC) {
const { loadJS, loadCSS } = assets;
patchWithCleanup(assets, {
async loadJS(resource) {
if (resource === "/web/static/lib/stacktracejs/stacktrace.js") {
// Bypass `mockRPC` for the stracktrace.js lib to avoid infinite loop if there
// is an error inside the `mockRPC` call.
return loadJS(resource);
}
let res = await mockRPC(resource, {});
if (res === undefined) {
res = await loadJS(resource);
} else {
makeLogger("ASSETS", "fetch (mock) JS resource").request(resource);
}
return res;
},
async loadCSS(resource) {
let res = await mockRPC(resource, {});
if (res === undefined) {
res = await loadCSS(resource);
} else {
makeLogger("ASSETS", "fetch (mock) CSS resource").request(resource);
}
return res;
},
});
}
// Replace RPC service
registerCleanup(() => (mockServer.active = false));
return mockServer;
}