summaryrefslogtreecommitdiff
path: root/addons/web
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web')
-rw-r--r--addons/web/static/src/js/core/patch_mixin.js80
-rw-r--r--addons/web/static/src/js/core/utils.js7
-rw-r--r--addons/web/static/tests/core/patch_mixin_tests.js994
-rw-r--r--addons/web/views/webclient_templates.xml2
4 files changed, 1082 insertions, 1 deletions
diff --git a/addons/web/static/src/js/core/patch_mixin.js b/addons/web/static/src/js/core/patch_mixin.js
new file mode 100644
index 00000000000..99f06f56588
--- /dev/null
+++ b/addons/web/static/src/js/core/patch_mixin.js
@@ -0,0 +1,80 @@
+odoo.define("web.patchMixin", function () {
+ "use strict";
+
+ /**
+ * This module defines and exports the 'patchMixin' function. This function
+ * returns a 'monkey-patchable' version of the ES6 Class given in arguments.
+ *
+ * const patchMixin = require('web.patchMixin');
+ * class MyClass {
+ * print() {
+ * console.log('MyClass');
+ * }
+ * }
+ * const MyPatchedClass = patchMixin(MyClass);
+ *
+ *
+ * A patchable class has a 'patch' function, allowing to define a patch:
+ *
+ * MyPatchedClass.patch("module_name.key", T =>
+ * class extends T {
+ * print() {
+ * console.log('MyPatchedClass');
+ * super.print();
+ * }
+ * }
+ * );
+ *
+ * const myPatchedClass = new MyPatchedClass();
+ * myPatchedClass.print(); // displays "MyPatchedClass" and "MyClass"
+ *
+ *
+ * The 'unpatch' function can be used to remove a patch, given its key:
+ *
+ * MyPatchedClass.unpatch("module_name.key");
+ */
+ function patchMixin(OriginalClass) {
+ let unpatchList = [];
+ class PatchableClass extends OriginalClass {}
+
+ PatchableClass.patch = function (name, patch) {
+ if (unpatchList.find(x => x.name === name)) {
+ throw new Error(`Class ${OriginalClass.name} already has a patch ${name}`);
+ }
+ if (!Object.prototype.hasOwnProperty.call(this, 'patch')) {
+ throw new Error(`Class ${this.name} is not patchable`);
+ }
+ const SubClass = patch(Object.getPrototypeOf(this));
+ unpatchList.push({
+ name: name,
+ elem: this,
+ prototype: this.prototype,
+ origProto: Object.getPrototypeOf(this),
+ origPrototype: Object.getPrototypeOf(this.prototype),
+ patch: patch,
+ });
+ Object.setPrototypeOf(this, SubClass);
+ Object.setPrototypeOf(this.prototype, SubClass.prototype);
+ };
+
+ PatchableClass.unpatch = function (name) {
+ if (!unpatchList.find(x => x.name === name)) {
+ throw new Error(`Class ${OriginalClass.name} does not have any patch ${name}`);
+ }
+ const toUnpatch = unpatchList.reverse();
+ unpatchList = [];
+ for (let unpatch of toUnpatch) {
+ Object.setPrototypeOf(unpatch.elem, unpatch.origProto);
+ Object.setPrototypeOf(unpatch.prototype, unpatch.origPrototype);
+ }
+ for (let u of toUnpatch.reverse()) {
+ if (u.name !== name) {
+ PatchableClass.patch(u.name, u.patch);
+ }
+ }
+ };
+ return PatchableClass;
+ }
+
+ return patchMixin;
+});
diff --git a/addons/web/static/src/js/core/utils.js b/addons/web/static/src/js/core/utils.js
index f1d4b00c6a9..e8924747616 100644
--- a/addons/web/static/src/js/core/utils.js
+++ b/addons/web/static/src/js/core/utils.js
@@ -527,7 +527,12 @@ var utils = {
/**
* Patch a class and return a function that remove the patch
* when called.
- *
+ *
+ * This function is the last resort solution for monkey-patching an
+ * ES6 Class, for people that do not control the code defining the Class
+ * to patch (e.g. partners), and when that Class isn't patchable already
+ * (i.e. when it doesn't have a 'patch' function, defined by the 'web.PatchMixin').
+ *
* @param {Class} C Class to patch
* @param {string} patchName
* @param {Object} patch
diff --git a/addons/web/static/tests/core/patch_mixin_tests.js b/addons/web/static/tests/core/patch_mixin_tests.js
new file mode 100644
index 00000000000..7329a9607d4
--- /dev/null
+++ b/addons/web/static/tests/core/patch_mixin_tests.js
@@ -0,0 +1,994 @@
+odoo.define("web.patchMixin_tests", function (require) {
+"use strict";
+
+const patchMixin = require('web.patchMixin');
+
+QUnit.module('core', {}, function () {
+
+ QUnit.module('patchMixin', {}, function () {
+
+ QUnit.test('basic use', function (assert) {
+ assert.expect(4);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ const a = new A();
+ a.f();
+
+ assert.ok(a instanceof A);
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.f',
+ ]);
+ });
+
+ QUnit.test('simple patch', function (assert) {
+ assert.expect(5);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch.constructor',
+ 'A.f',
+ 'patch.f',
+ ]);
+ });
+
+ QUnit.test('two patches on same base class', function (assert) {
+ assert.expect(7);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch1', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch1.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch1.f');
+ }
+ }
+ );
+
+ A.patch('patch2', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch2.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch2.f');
+ }
+ }
+ );
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch1.constructor',
+ 'patch2.constructor',
+ 'A.f',
+ 'patch1.f',
+ 'patch2.f',
+ ]);
+ });
+
+ QUnit.test('two patches with same name on same base class', function (assert) {
+ assert.expect(1);
+
+ const A = patchMixin(class {});
+
+ A.patch('patch', T => class extends T {});
+
+ // keys should be unique
+ assert.throws(() => {
+ A.patch('patch', T => class extends T {});
+ });
+ });
+
+ QUnit.test('unpatch', function (assert) {
+ assert.expect(8);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch.constructor',
+ 'A.f',
+ 'patch.f',
+ ]);
+
+ A.unpatch('patch');
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.f',
+ ]);
+ });
+
+ QUnit.test('unpatch 2', function (assert) {
+ assert.expect(12);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch1', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch1.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch1.f');
+ }
+ }
+ );
+
+ A.patch('patch2', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch2.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch2.f');
+ }
+ }
+ );
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch1.constructor',
+ 'patch2.constructor',
+ 'A.f',
+ 'patch1.f',
+ 'patch2.f',
+ ]);
+
+ A.unpatch('patch1');
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch2.constructor',
+ 'A.f',
+ 'patch2.f',
+ ]);
+ });
+
+ QUnit.test('unpatch inexistent', function (assert) {
+ assert.expect(1);
+
+ const A = patchMixin(class {});
+ A.patch('patch', T => class extends T {});
+
+ A.unpatch('patch');
+ assert.throws(() => {
+ A.unpatch('inexistent-patch');
+ });
+ });
+
+ QUnit.test('patch for specialization', function (assert) {
+ assert.expect(1);
+
+ let args = [];
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ args = ['A', ...arguments];
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super('patch', ...arguments);
+ }
+ }
+ );
+
+ new A('instantiation');
+
+ assert.deepEqual(args, ['A', 'patch', 'instantiation']);
+ });
+
+ QUnit.test('instance fields', function (assert) {
+ assert.expect(1);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ this.x = ['A'];
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ this.x.push('patch');
+ }
+ }
+ );
+
+ const a = new A();
+ assert.deepEqual(a.x, ['A', 'patch']);
+ });
+
+ QUnit.test('call instance method defined in patch', function (assert) {
+ assert.expect(3);
+
+ const A = patchMixin(
+ class {}
+ );
+
+ assert.notOk((new A()).f);
+
+ A.patch('patch', T =>
+ class extends T {
+ f() {
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ (new A()).f();
+ assert.verifySteps(['patch.f']);
+ });
+
+ QUnit.test('class methods', function (assert) {
+ assert.expect(7);
+
+ const A = patchMixin(
+ class {
+ static f() {
+ assert.step('A');
+ }
+ }
+ );
+
+ A.f();
+ assert.verifySteps(['A']);
+
+ A.patch('patch', T =>
+ class extends T {
+ static f() {
+ super.f();
+ assert.step('patch');
+ }
+ }
+ );
+
+ A.f();
+ assert.verifySteps(['A', 'patch']);
+
+ A.unpatch('patch');
+
+ A.f();
+ assert.verifySteps(['A']);
+ });
+
+ QUnit.test('class fields', function (assert) {
+ assert.expect(4);
+
+ class A {}
+ A.foo = ['A'];
+ A.bar = 'A';
+
+ const PatchableA = patchMixin(A);
+
+ PatchableA.patch('patch', T => {
+ class Patch extends T {}
+
+ Patch.foo = [...T.foo, 'patched A'];
+ Patch.bar = 'patched A';
+
+ return Patch;
+ });
+
+ assert.deepEqual(PatchableA.foo, ['A', 'patched A']);
+ assert.strictEqual(PatchableA.bar, 'patched A');
+
+ PatchableA.unpatch('patch');
+
+ assert.deepEqual(PatchableA.foo, ['A']);
+ assert.strictEqual(PatchableA.bar, 'A');
+ });
+
+ QUnit.test('lazy patch', function (assert) {
+ assert.expect(4);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ const a = new A();
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ // will not be called
+ assert.step('patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ a.f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.f',
+ 'patch.f',
+ ]);
+ });
+
+
+ QUnit.module('inheritance');
+
+ QUnit.test('inheriting a patchable class', function (assert) {
+ assert.expect(8);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ class B extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.f',
+ ]);
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'B.constructor',
+ 'A.f',
+ 'B.f',
+ ]);
+ });
+
+ QUnit.test('inheriting a patchable class that has patch', function (assert) {
+ assert.expect(12);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ class B extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch.constructor',
+ 'A.f',
+ 'patch.f',
+ ]);
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'patch.constructor',
+ 'B.constructor',
+ 'A.f',
+ 'patch.f',
+ 'B.f',
+ ]);
+ });
+
+ QUnit.test('patch inherited patchable class', function (assert) {
+ assert.expect(10);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ const B = patchMixin(
+ class extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+ );
+
+ B.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.f',
+ ]);
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'B.constructor',
+ 'patch.constructor',
+ 'A.f',
+ 'B.f',
+ 'patch.f',
+ ]);
+ });
+
+ QUnit.test('patch inherited patched class', function (assert) {
+ assert.expect(14);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('A.patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('A.patch.f');
+ }
+ }
+ );
+
+ /**
+ * /!\ WARNING /!\
+ *
+ * If you want to patch class B, make it patchable
+ * otherwise it will patch class A!
+ */
+ const B = patchMixin(
+ class extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+ );
+
+ B.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('B.patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.patch.f');
+ }
+ }
+ );
+
+ const a = new A();
+ a.f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.patch.constructor',
+ 'A.f',
+ 'A.patch.f',
+ ]);
+
+ const b = new B();
+ b.f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.patch.constructor',
+ 'B.constructor',
+ 'B.patch.constructor',
+ 'A.f',
+ 'A.patch.f',
+ 'B.f',
+ 'B.patch.f',
+ ]);
+ });
+
+ QUnit.test('unpatch inherited patched class', function (assert) {
+ assert.expect(15);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('A.patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('A.patch.f');
+ }
+ }
+ );
+
+ const B = patchMixin(
+ class extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+ );
+
+ B.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('B.patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.patch.f');
+ }
+ }
+ );
+
+ A.unpatch('patch');
+
+ (new A()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.f',
+ ]);
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'B.constructor',
+ 'B.patch.constructor',
+ 'A.f',
+ 'B.f',
+ 'B.patch.f',
+ ]);
+
+ B.unpatch('patch');
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'B.constructor',
+ 'A.f',
+ 'B.f',
+ ]);
+ });
+
+ QUnit.test('unpatch inherited patched class 2', function (assert) {
+ assert.expect(12);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('A.patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('A.patch.f');
+ }
+ }
+ );
+
+ const B = patchMixin(
+ class extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+ );
+
+ B.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ assert.step('B.patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.patch.f');
+ }
+ }
+ );
+
+ B.unpatch('patch');
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'A.patch.constructor',
+ 'B.constructor',
+ 'A.f',
+ 'A.patch.f',
+ 'B.f',
+ ]);
+
+ A.unpatch('patch');
+
+ (new B()).f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'B.constructor',
+ 'A.f',
+ 'B.f',
+ ]);
+ });
+
+ QUnit.test('class methods', function (assert) {
+ assert.expect(12);
+
+ const A = patchMixin(
+ class {
+ static f() {
+ assert.step('A');
+ }
+ }
+ );
+
+ const B = patchMixin(
+ class extends A {
+ static f() {
+ super.f();
+ assert.step('B');
+ }
+ }
+ );
+
+ A.patch('patch', T =>
+ class extends T {
+ static f() {
+ super.f();
+ assert.step('A.patch');
+ }
+ }
+ );
+
+ B.patch('patch', T =>
+ class extends T {
+ static f() {
+ super.f();
+ assert.step('B.patch');
+ }
+ }
+ );
+
+ B.f();
+ assert.verifySteps(['A', 'A.patch', 'B', 'B.patch']);
+
+ A.unpatch('patch');
+
+ B.f();
+ assert.verifySteps(['A', 'B', 'B.patch']);
+
+ B.unpatch('patch');
+
+ B.f();
+ assert.verifySteps(['A', 'B']);
+ });
+
+ QUnit.test('class fields', function (assert) {
+ assert.expect(3);
+
+ class A {}
+ A.foo = ['A'];
+ A.bar = 'A';
+
+ const PatchableA = patchMixin(A);
+
+ class B extends PatchableA {}
+ // /!\ This is not dynamic
+ // so if A.foo is patched after this assignment
+ // B.foo won't have the patches of A.foo
+ B.foo = [...PatchableA.foo, 'B'];
+ B.bar = 'B';
+
+ const PatchableB = patchMixin(B);
+
+ PatchableA.patch('patch', T => {
+ class Patch extends T {}
+
+ Patch.foo = [...T.foo, 'patched A'];
+ Patch.bar = 'patched A';
+
+ return Patch;
+ });
+
+ PatchableB.patch('patch', T => {
+ class Patch extends T {}
+
+ Patch.foo = [...T.foo, 'patched B'];
+ Patch.bar = 'patched B';
+
+ return Patch;
+ });
+
+ assert.deepEqual(PatchableB.foo, [ 'A', /* 'patched A', */ 'B', 'patched B' ]);
+ assert.deepEqual(PatchableA.foo, [ 'A', 'patched A' ]);
+ assert.strictEqual(PatchableB.bar, 'patched B');
+ });
+
+ QUnit.test('inheritance and lazy patch', function (assert) {
+ assert.expect(6);
+
+ const A = patchMixin(
+ class {
+ constructor() {
+ assert.step('A.constructor');
+ }
+ f() {
+ assert.step('A.f');
+ }
+ }
+ );
+
+ class B extends A {
+ constructor() {
+ super();
+ assert.step('B.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('B.f');
+ }
+ }
+
+ const b = new B();
+
+ A.patch('patch', T =>
+ class extends T {
+ constructor() {
+ super();
+ // will not be called
+ assert.step('patch.constructor');
+ }
+ f() {
+ super.f();
+ assert.step('patch.f');
+ }
+ }
+ );
+
+ b.f();
+
+ assert.verifySteps([
+ 'A.constructor',
+ 'B.constructor',
+ 'A.f',
+ 'patch.f',
+ 'B.f',
+ ]);
+ });
+
+ QUnit.test('patch not patchable class that inherits patchable class', function (assert) {
+ assert.expect(1);
+
+ const A = patchMixin(class {});
+ class B extends A {}
+
+ // class B is not patchable
+ assert.throws(() => {
+ B.patch('patch', T => class extends T {});
+ });
+ });
+ });
+});
+});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 05059f15d55..275a1ccd59f 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -144,6 +144,7 @@
<script type="text/javascript" src="/web/static/src/js/core/dom.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/local_storage.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/mixins.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/core/patch_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/qweb.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/ram_storage.js"></script>
<script type="text/javascript" src="/web/static/src/js/core/registry.js"></script>
@@ -721,6 +722,7 @@
<script type="text/javascript" src="/web/static/tests/core/data_comparison_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/math_utils_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/mixins_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/core/patch_mixin_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/time_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/concurrency_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/util_tests.js"></script>