summaryrefslogtreecommitdiff
path: root/addons/web/static/tests/helpers/test_utils_create.js
blob: 908ac97aa624972dc21223f113d67fd3ce42b2c2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
odoo.define('web.test_utils_create', function (require) {
    "use strict";

    /**
     * Create Test Utils
     *
     * This module defines various utility functions to help creating mock widgets
     *
     * Note that all methods defined in this module are exported in the main
     * testUtils file.
     */

    const ActionManager = require('web.ActionManager');
    const ActionMenus = require('web.ActionMenus');
    const concurrency = require('web.concurrency');
    const config = require('web.config');
    const ControlPanel = require('web.ControlPanel');
    const customHooks = require('web.custom_hooks');
    const DebugManager = require('web.DebugManager.Backend');
    const dom = require('web.dom');
    const makeTestEnvironment = require('web.test_env');
    const ActionModel = require('web/static/src/js/views/action_model.js');
    const Registry = require('web.Registry');
    const testUtilsMock = require('web.test_utils_mock');
    const Widget = require('web.Widget');

    const { Component } = owl;
    const { useRef, useState } = owl.hooks;
    const { xml } = owl.tags;

    /**
     * Create and return an instance of ActionManager with all rpcs going through a
     * mock method using the data, actions and archs objects as sources.
     *
     * @param {Object} [params={}]
     * @param {Object} [params.actions] the actions given to the mock server
     * @param {Object} [params.archs] this archs given to the mock server
     * @param {Object} [params.data] the business data given to the mock server
     * @param {function} [params.mockRPC]
     * @returns {Promise<ActionManager>}
     */
    async function createActionManager(params = {}) {
        const target = prepareTarget(params.debug);

        const widget = new Widget();
        // when 'document' addon is installed, the sidebar does a 'search_read' on
        // model 'ir_attachment' each time a record is open, so we monkey-patch
        // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly,
        // whether or not 'document' is installed
        const mockRPC = params.mockRPC;
        Object.assign(params, {
            async mockRPC(route, args) {
                if (args.model === 'ir.attachment') {
                    return [];
                }
                if (mockRPC) {
                    return mockRPC.apply(this, arguments);
                }
                return this._super(...arguments);
            },
        });
        const mockServer = await testUtilsMock.addMockEnvironment(widget, Object.assign({ debounce: false }, params));
        await widget.prependTo(target);
        widget.el.classList.add('o_web_client');
        if (config.device.isMobile) {
            widget.el.classList.add('o_touch_device');
        }

        params.server = mockServer;

        const userContext = params.context && params.context.user_context || {};
        const actionManager = new ActionManager(widget, userContext);

        // Override the ActionMenus registry unless told otherwise.
        let actionMenusRegistry = ActionMenus.registry;
        if (params.actionMenusRegistry !== true) {
            ActionMenus.registry = new Registry();
        }

        const originalDestroy = ActionManager.prototype.destroy;
        actionManager.destroy = function () {
            actionManager.destroy = originalDestroy;
            widget.destroy();
            if (params.actionMenusRegistry !== true) {
                ActionMenus.registry = actionMenusRegistry;
            }
        };
        const fragment = document.createDocumentFragment();
        await actionManager.appendTo(fragment);
        dom.append(widget.el, fragment, {
            callbacks: [{ widget: actionManager }],
            in_DOM: true,
        });
        return actionManager;
    }

    /**
     * Similar as createView, but specific for calendar views. Some calendar
     * tests need to trigger positional clicks on the DOM produced by fullcalendar.
     * Those tests must use this helper with option positionalClicks set to true.
     * This will move the rendered calendar to the body (required to do positional
     * clicks), and wait for a setTimeout(0) before returning, because fullcalendar
     * makes the calendar scroll to 6:00 in a setTimeout(0), which might have an
     * impact according to where we want to trigger positional clicks.
     *
     * @param {Object} params @see createView
     * @param {Object} [options]
     * @param {boolean} [options.positionalClicks=false]
     * @returns {Promise<CalendarController>}
     */
    async function createCalendarView(params, options) {
        const calendar = await createView(params);
        if (!options || !options.positionalClicks) {
            return calendar;
        }
        const viewElements = [...document.getElementById('qunit-fixture').children];
        // prepend reset the scrollTop to zero so we restore it manually
        let fcScroller = document.querySelector('.fc-scroller');
        const scrollPosition = fcScroller.scrollTop;
        viewElements.forEach(el => document.body.prepend(el));
        fcScroller = document.querySelector('.fc-scroller');
        fcScroller.scrollTop = scrollPosition;

        const destroy = calendar.destroy;
        calendar.destroy = () => {
            viewElements.forEach(el => el.remove());
            destroy();
        };
        await concurrency.delay(0);
        return calendar;
    }

    /**
     * Create a simple component environment with a basic Parent component, an
     * extensible env and a mocked server. The returned value is the instance of
     * the given constructor.
     * @param {class} constructor Component class to instantiate
     * @param {Object} [params = {}]
     * @param {boolean} [params.debug]
     * @param {Object} [params.env]
     * @param {Object} [params.intercepts] object in which the keys represent the
     *      intercepted event names and the values are their callbacks.
     * @param {Object} [params.props]
     * @returns {Promise<Component>} instance of `constructor`
     */
    async function createComponent(constructor, params = {}) {
        if (!constructor) {
            throw new Error(`Missing argument "constructor".`);
        }
        if (!(constructor.prototype instanceof Component)) {
            throw new Error(`Argument "constructor" must be an Owl Component.`);
        }
        const cleanUp = await testUtilsMock.addMockEnvironmentOwl(Component, params);
        class Parent extends Component {
            constructor() {
                super(...arguments);
                this.Component = constructor;
                this.state = useState(params.props || {});
                this.component = useRef('component');
                for (const eventName in params.intercepts || {}) {
                    customHooks.useListener(eventName, params.intercepts[eventName]);
                }
            }
        }
        Parent.template = xml`<t t-component="Component" t-props="state" t-ref="component"/>`;
        const parent = new Parent();
        await parent.mount(prepareTarget(params.debug), { position: 'first-child' });
        const child = parent.component.comp;
        const originalDestroy = child.destroy;
        child.destroy = function () {
            child.destroy = originalDestroy;
            cleanUp();
            parent.destroy();
        };
        return child;
    }

    /**
     * Create a Control Panel instance, with an extensible environment and
     * its related Control Panel Model. Event interception is done through
     * params['get-controller-query-params'] and params.search, for the two
     * available event handlers respectively.
     * @param {Object} [params={}]
     * @param {Object} [params.cpProps]
     * @param {Object} [params.cpModelConfig]
     * @param {boolean} [params.debug]
     * @param {Object} [params.env]
     * @returns {Object} useful control panel testing elements:
     *  - controlPanel: the control panel instance
     *  - el: the control panel HTML element
     *  - helpers: a suite of bound helpers (see above functions for all
     *    available helpers)
     */
    async function createControlPanel(params = {}) {
        const debug = params.debug || false;
        const env = makeTestEnvironment(params.env || {});
        const props = Object.assign({
            action: {},
            fields: {},
        }, params.cpProps);
        const globalConfig = Object.assign({
            context: {},
            domain: [],
        }, params.cpModelConfig);

        if (globalConfig.arch && globalConfig.fields) {
            const model = "__mockmodel__";
            const serverParams = {
                model,
                data: { [model]: { fields: globalConfig.fields, records: [] } },
            };
            const mockServer = await testUtilsMock.addMockEnvironment(
                new Widget(),
                serverParams,
            );
            const { arch, fields } = testUtilsMock.fieldsViewGet(mockServer, {
                arch: globalConfig.arch,
                fields: globalConfig.fields,
                model,
                viewOptions: { context: globalConfig.context },
            });
            Object.assign(globalConfig, { arch, fields });
        }

        globalConfig.env = env;
        const archs = (globalConfig.arch && { search: globalConfig.arch, }) || {};
        const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
        const extensions = {
            ControlPanel: { archNodes: controlPanelInfo.children, },
        };

        class Parent extends Component {
            constructor() {
                super();
                this.searchModel = new ActionModel(extensions, globalConfig);
                this.state = useState(props);
                this.controlPanel = useRef("controlPanel");
            }
            async willStart() {
                await this.searchModel.load();
            }
            mounted() {
                if (params['get-controller-query-params']) {
                    this.searchModel.on('get-controller-query-params', this,
                        params['get-controller-query-params']);
                }
                if (params.search) {
                    this.searchModel.on('search', this, params.search);
                }
            }
        }
        Parent.components = { ControlPanel };
        Parent.env = env;
        Parent.template = xml`
            <ControlPanel
                t-ref="controlPanel"
                t-props="state"
                searchModel="searchModel"
            />`;

        const parent = new Parent();
        await parent.mount(prepareTarget(debug), { position: 'first-child' });

        const controlPanel = parent.controlPanel.comp;
        const destroy = controlPanel.destroy;
        controlPanel.destroy = function () {
            controlPanel.destroy = destroy;
            parent.destroy();
        };
        controlPanel.getQuery = () => parent.searchModel.get('query');

        return controlPanel;
    }

    /**
     * Create and return an instance of DebugManager with all rpcs going through a
     * mock method, assuming that the user has access rights, and is an admin.
     *
     * @param {Object} [params={}]
     * @returns {Promise<DebugManager>}
     */
    async function createDebugManager(params = {}) {
        const mockRPC = params.mockRPC;
        Object.assign(params, {
            async mockRPC(route, args) {
                if (args.method === 'check_access_rights') {
                    return true;
                }
                if (args.method === 'xmlid_to_res_id') {
                    return true;
                }
                if (mockRPC) {
                    return mockRPC.apply(this, arguments);
                }
                return this._super(...arguments);
            },
            session: {
                async user_has_group(group) {
                    if (group === 'base.group_no_one') {
                        return true;
                    }
                    return this._super(...arguments);
                },
            },
        });
        const debugManager = new DebugManager();
        await testUtilsMock.addMockEnvironment(debugManager, params);
        return debugManager;
    }

    /**
     * Create a model from given parameters.
     *
     * @param {Object} params This object will be given to addMockEnvironment, so
     *   any parameters from that method applies
     * @param {Class} params.Model the model class to use
     * @returns {Model}
     */
    async function createModel(params) {
        const widget = new Widget();

        const model = new params.Model(widget, params);

        await testUtilsMock.addMockEnvironment(widget, params);

        // override the model's 'destroy' so that it calls 'destroy' on the widget
        // instead, as the widget is the parent of the model and the mockServer.
        model.destroy = function () {
            // remove the override to properly destroy the model when it will be
            // called the second time (by its parent)
            delete model.destroy;
            widget.destroy();
        };

        return model;
    }

    /**
     * Create a widget parent from given parameters.
     *
     * @param {Object} params This object will be given to addMockEnvironment, so
     *   any parameters from that method applies
     * @returns {Promise<Widget>}
     */
    async function createParent(params) {
        const widget = new Widget();
        await testUtilsMock.addMockEnvironment(widget, params);
        return widget;
    }

    /**
     * Create a view from various parameters.  Here, a view means a javascript
     * instance of an AbstractView class, such as a form view, a list view or a
     * kanban view.
     *
     * It returns the instance of the view, properly created, with all rpcs going
     * through a mock method using the data object as source, and already loaded/
     * started.
     *
     * @param {Object} params
     * @param {string} params.arch the xml (arch) of the view to be instantiated
     * @param {any[]} [params.domain] the initial domain for the view
     * @param {Object} [params.context] the initial context for the view
     * @param {string[]} [params.groupBy] the initial groupBy for the view
     * @param {Object[]} [params.favoriteFilters] the favorite filters one would like to have at initialization
     * @param {integer} [params.fieldDebounce=0] the debounce value to use for the
     *   duration of the test.
     * @param {AbstractView} params.View the class that will be instantiated
     * @param {string} params.model a model name, will be given to the view
     * @param {Object} params.intercepts an object with event names as key, and
     *   callback as value.  Each key,value will be used to intercept the event.
     *   Note that this is particularly useful if you want to intercept events going
     *   up in the init process of the view, because there are no other way to do it
     *   after this method returns
     * @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true.
     *    Default is false.
     * @returns {Promise<AbstractController>} the instance of the view
     */
    async function createView(params) {
        const target = prepareTarget(params.debug);
        const widget = new Widget();
        // reproduce the DOM environment of views
        const webClient = Object.assign(document.createElement('div'), {
            className: 'o_web_client',
        });
        const actionManager = Object.assign(document.createElement('div'), {
            className: 'o_action_manager',
        });
        target.prepend(webClient);
        webClient.append(actionManager);

        // add mock environment: mock server, session, fieldviewget, ...
        const mockServer = await testUtilsMock.addMockEnvironment(widget, params);
        const viewInfo = testUtilsMock.fieldsViewGet(mockServer, params);

        params.server = mockServer;

        // create the view
        const View = params.View;
        const modelName = params.model || 'foo';
        const defaultAction = {
            res_model: modelName,
            context: {},
        };
        const viewOptions = Object.assign({
            action: Object.assign(defaultAction, params.action),
            view: viewInfo,
            modelName: modelName,
            ids: 'res_id' in params ? [params.res_id] : undefined,
            currentId: 'res_id' in params ? params.res_id : undefined,
            domain: params.domain || [],
            context: params.context || {},
            hasActionMenus: false,
        }, params.viewOptions);
        // patch the View to handle the groupBy given in params, as we can't give it
        // in init (unlike the domain and context which can be set in the action)
        testUtilsMock.patch(View, {
            _updateMVCParams() {
                this._super(...arguments);
                this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || [];
                testUtilsMock.unpatch(View);
            },
        });
        if ('hasSelectors' in params) {
            viewOptions.hasSelectors = params.hasSelectors;
        }

        let view;
        if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') {
            // TODO: probably needs to create an helper just for that
            view = new params.View({ viewInfo, modelName });
        } else {
            viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.fieldsViewGet(mockServer, {
                arch: params.archs && params.archs[params.model + ',false,search'] || '<search/>',
                fields: viewInfo.fields,
                model: params.model,
            }), { favoriteFilters: params.favoriteFilters });

            view = new params.View(viewInfo, viewOptions);
        }

        if (params.interceptsPropagate) {
            for (const name in params.interceptsPropagate) {
                testUtilsMock.intercept(widget, name, params.interceptsPropagate[name], true);
            }
        }

        // Override the ActionMenus registry unless told otherwise.
        let actionMenusRegistry = ActionMenus.registry;
        if (params.actionMenusRegistry !== true) {
            ActionMenus.registry = new Registry();
        }

        const viewController = await view.getController(widget);
        // override the view's 'destroy' so that it calls 'destroy' on the widget
        // instead, as the widget is the parent of the view and the mockServer.
        viewController.__destroy = viewController.destroy;
        viewController.destroy = function () {
            // remove the override to properly destroy the viewController and its children
            // when it will be called the second time (by its parent)
            delete viewController.destroy;
            widget.destroy();
            webClient.remove();
            if (params.actionMenusRegistry !== true) {
                ActionMenus.registry = actionMenusRegistry;
            }
        };

        // render the viewController in a fragment as they must be able to render correctly
        // without being in the DOM
        const fragment = document.createDocumentFragment();
        await viewController.appendTo(fragment);
        dom.prepend(actionManager, fragment, {
            callbacks: [{ widget: viewController }],
            in_DOM: true,
        });

        if (!params.doNotDisableAHref) {
            [...viewController.el.getElementsByTagName('A')].forEach(elem => {
                elem.addEventListener('click', ev => {
                    ev.preventDefault();
                });
            });
        }
        return viewController;
    }

    /**
     * Get the target (fixture or body) of the document and adds event listeners
     * to intercept custom or DOM events.
     *
     * @param {boolean} [debug=false] if true, the widget will be appended in
     *      the DOM. Also, RPCs and uncaught OdooEvent will be logged
     * @returns {HTMLElement}
     */
    function prepareTarget(debug = false) {
        document.body.classList.toggle('debug', debug);
        return debug ? document.body : document.getElementById('qunit-fixture');
    }

    return {
        createActionManager,
        createCalendarView,
        createComponent,
        createControlPanel,
        createDebugManager,
        createModel,
        createParent,
        createView,
        prepareTarget,
    };
});