From c7cefee9cf94681c2fdd54c6045f1434e5b65205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Thu, 5 May 2022 11:57:08 +0000 Subject: [PATCH] [IMP] web: introduce macro system This commit introduce a macro system, to allow code to automate user actions. For now, this is useful for the knowledge application. But in general, this may be used by any code that want to interact "as a user" with the web client. For example, we may consider rewriting the tour system using macros. Sample macros: ```js const testMacro = { name: "test_macro", onStep: (el, step) => { console.log("macro step activated", el, step); }, timeout: 5000, steps: [ { trigger: '.dropdown-toggle[title="Home Menu"]', action: "click", }, { trigger: ".wrong-selector", action: "click", }, ], }; const testMacro2 = { name: "test_macro2", interval: 1000, steps: [ { // Open contact application trigger: '.dropdown-toggle[title="Home Menu"]', action: "click", }, { trigger: '.dropdown-item:contains("Contacts")', action: "click", }, { // click on create button trigger: () => document.querySelector('button[title="Create record"]'), action: "click", }, { action: () => console.log("we are here..."), }, { // input name trigger: 'div[name="name"] input', action: "text", value: "Aaron B.", }, { // save trigger: 'button[title="Save record"]', action: "click", }, ], }; /** * Infinite macro! This also illustrates that macros can be used to do non * linear sequence of steps. */ const testMacro3 = { name: "test_macro3", steps: [ { // Open contact application trigger: "button.o_form_button_cancel", action: "click", }, { action: () => activateMacro(testMacro3), }, ], }; const testMacro4 = { name: "test_macro4", onError: (error, step, index) => { console.log(`an error occured at step ${index}`); console.log(step, error); }, steps: [ { // Open contact application trigger: '.dropdown-toggle[title="Home Menu"]', action: "click", }, { action: () => { throw new Error("boom"); }, }, ], }; ``` closes odoo/odoo#88994 Related: odoo/enterprise#27010 Signed-off-by: Samuel Degueldre --- addons/web/static/src/core/macro.js | 202 +++++++++++++++++ addons/web/static/tests/core/macro_tests.js | 228 ++++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 addons/web/static/src/core/macro.js create mode 100644 addons/web/static/tests/core/macro_tests.js diff --git a/addons/web/static/src/core/macro.js b/addons/web/static/src/core/macro.js new file mode 100644 index 00000000000..3fd121ca403 --- /dev/null +++ b/addons/web/static/src/core/macro.js @@ -0,0 +1,202 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; + +// rough approximation of a visible element. not perfect (does not take into +// account opacity = 0 for example), but good enough for our purpose +const isVisible = (e) => e.offsetWidth > 0 || e.offsetHeight > 0; + +export const ACTION_HELPERS = { + click(el, _step) { + el.dispatchEvent(new MouseEvent("mouseover")); + el.dispatchEvent(new MouseEvent("mouseenter")); + el.dispatchEvent(new MouseEvent("mousedown")); + el.dispatchEvent(new MouseEvent("mouseup")); + el.click(); + el.dispatchEvent(new MouseEvent("mouseout")); + el.dispatchEvent(new MouseEvent("mouseleave")); + }, + text(el, step) { + // simulate an input (probably need to add keydown/keyup events) + this.click(el, step); + el.value = step.value; + el.dispatchEvent(new InputEvent("input", { bubbles: true })); + el.dispatchEvent(new InputEvent("change", { bubbles: true })); + }, +}; + +class TimeoutError extends Error {} + +class Macro { + constructor(descr) { + this.name = descr.name || "anonymous"; + this.timeoutDuration = descr.timeout || 0; + this.timeout = null; + this.currentIndex = 0; + this.interval = "interval" in descr ? Math.max(16, descr.interval) : 500; + this.isComplete = false; + this.steps = descr.steps; + this.onStep = descr.onStep || (() => {}); + this.onError = descr.onError; + this.onTimeout = descr.onTimeout; + this.setTimer(); + } + + advance() { + if (this.isComplete) { + return; + } + const step = this.steps[this.currentIndex]; + let trigger = step.trigger; + if (trigger) { + let el = null; + if (typeof trigger === "function") { + const result = this.safeCall(trigger); + if (result instanceof HTMLElement) { + el = result; + } + } + if (typeof trigger === "string") { + el = document.querySelector(trigger); + } + if (el && isVisible(el)) { + this.advanceStep(el, step); + } + } else { + // a step without a trigger is just an action + this.advanceStep(null, step); + } + } + + advanceStep(el, step) { + this.safeCall(this.onStep, el, step); + const action = step.action; + if (action in ACTION_HELPERS) { + ACTION_HELPERS[action](el, step); + } else if (typeof action === "function") { + this.safeCall(action, el); + } + this.currentIndex++; + if (this.currentIndex === this.steps.length) { + this.isComplete = true; + browser.clearTimeout(this.timeout); + } else { + this.setTimer(); + this.advance(); + } + } + + safeCall(fn, ...args) { + if (this.isComplete) { + return; + } + try { + return fn(...args); + } catch (e) { + this.handleError(e); + } + } + + setTimer() { + if (this.timeoutDuration) { + browser.clearTimeout(this.timeout); + this.timeout = browser.setTimeout(() => { + if (this.onTimeout) { + const index = this.currentIndex; + const step = this.steps[index]; + this.safeCall(this.onTimeout, step, index); + } else { + const error = new TimeoutError("Step timeout"); + this.handleError(error); + } + }, this.timeoutDuration); + } + } + + handleError(error) { + // mark the macro as complete, so it can be cleaned up from the + // engine + this.isComplete = true; + browser.clearTimeout(this.timeout); + if (this.onError) { + const index = this.currentIndex; + const step = this.steps[index]; + this.onError(error, step, index); + } else { + console.error(error); + } + } +} + +export class MacroEngine { + constructor(target = document.body) { + this.isRunning = false; + this.timeout = null; + this.target = target; + this.interval = Infinity; // nbr of ms before we check the dom to advance macros + this.macros = new Set(); + this.observer = new MutationObserver(this.delayedCheck.bind(this)); + } + + async activate(descr) { + // micro task tick to make sure we add the macro in a new call stack, + // so we are guaranteed that we are not iterating on the current macros + await Promise.resolve(); + const macro = new Macro(descr); + this.interval = Math.min(this.interval, macro.interval); + this.macros.add(macro); + this.start(); + } + + start() { + if (!this.isRunning) { + this.isRunning = true; + this.observer.observe(this.target, { + attributes: true, + childList: true, + subtree: true, + characterData: true, + }); + } + this.delayedCheck(); + } + + stop() { + if (this.isRunning) { + this.isRunning = false; + browser.clearTimeout(this.timeout); + this.timeout = null; + this.observer.disconnect(); + } + } + + delayedCheck() { + if (this.timeout) { + browser.clearTimeout(this.timeout); + } + this.timeout = browser.setTimeout(this.advanceMacros.bind(this), this.interval); + } + + advanceMacros() { + let toDelete = []; + for (let macro of this.macros) { + macro.advance(); + if (macro.isComplete) { + toDelete.push(macro); + } + } + if (toDelete.length) { + for (let macro of toDelete) { + this.macros.delete(macro); + } + // recompute current interval, because it may need to be increased + this.interval = Infinity; + for (let macro of this.macros) { + this.interval = Math.min(this.interval, macro.interval); + } + } + if (this.macros.size === 0) { + this.stop(); + } + } +} diff --git a/addons/web/static/tests/core/macro_tests.js b/addons/web/static/tests/core/macro_tests.js new file mode 100644 index 00000000000..b636d3cc297 --- /dev/null +++ b/addons/web/static/tests/core/macro_tests.js @@ -0,0 +1,228 @@ +/** @odoo-module **/ + +import { MacroEngine } from "@web/core/macro"; +import { getFixture, mockTimeout } from "../helpers/utils"; + +const { Component, xml, useState, mount } = owl; + +let target, engine, mock; + +QUnit.module( + "macros", + { + beforeEach() { + target = getFixture(); + engine = new MacroEngine(target); + mock = mockTimeout(); + }, + afterEach() { + if (engine.macros.size !== 0) { + throw new Error("Some macro is still running after a test"); + } + }, + }, + () => { + class TestComponent extends Component { + setup() { + this.state = useState({ value: 0 }); + } + } + TestComponent.template = xml` +
+ + + + + +
`; + + QUnit.test("simple use", async function (assert) { + await mount(TestComponent, target); + + const span = target.querySelector("span.value"); + assert.strictEqual(span.textContent, "0"); + + engine.activate({ + name: "test", + steps: [ + { + trigger: "button.inc", + action: "click", + }, + ], + }); + // default interval is 500 + await mock.advanceTime(300); + assert.strictEqual(span.textContent, "0"); + await mock.advanceTime(300); + assert.strictEqual(span.textContent, "1"); + }); + + QUnit.test("multiple steps", async function (assert) { + await mount(TestComponent, target); + + const span = target.querySelector("span.value"); + assert.strictEqual(span.textContent, "0"); + + engine.activate({ + name: "test", + steps: [ + { + trigger: "button.inc", + action: "click", + }, + { + trigger: () => { + return span.textContent === "1" ? span : null; + }, + }, + { + trigger: "button.inc", + action: "click", + }, + ], + }); + await mock.advanceTime(500); + assert.strictEqual(span.textContent, "1"); + await mock.advanceTime(500); + assert.strictEqual(span.textContent, "2"); + await mock.advanceTime(500); + assert.strictEqual(span.textContent, "2"); + }); + + QUnit.test("can use a function as action", async function (assert) { + await mount(TestComponent, target); + let flag = false; + engine.activate({ + name: "test", + steps: [ + { + trigger: "button.inc", + action: () => (flag = true), + }, + ], + }); + assert.strictEqual(flag, false); + await mock.advanceTime(600); + assert.strictEqual(flag, true); + }); + + QUnit.test("can input values", async function (assert) { + await mount(TestComponent, target); + const input = target.querySelector("input"); + + engine.activate({ + name: "test", + steps: [ + { + trigger: "div.counter input", + action: "text", + value: "aaron", + }, + ], + }); + assert.strictEqual(input.value, ""); + await mock.advanceTime(600); + assert.strictEqual(input.value, "aaron"); + }); + + QUnit.test("a step can have no trigger", async function (assert) { + await mount(TestComponent, target); + const input = target.querySelector("input"); + + engine.activate({ + name: "test", + steps: [ + { action: () => assert.step("1") }, + { action: () => assert.step("2") }, + { + trigger: "div.counter input", + action: "text", + value: "aaron", + }, + { action: () => assert.step("3") }, + ], + }); + assert.strictEqual(input.value, ""); + await mock.advanceTime(600); + assert.strictEqual(input.value, "aaron"); + assert.verifySteps(["1", "2", "3"]); + }); + + QUnit.test("onStep function is called at each step", async function (assert) { + await mount(TestComponent, target); + + const span = target.querySelector("span.value"); + assert.strictEqual(span.textContent, "0"); + + engine.activate({ + name: "test", + onStep: (el, step) => { + assert.step(step.info); + }, + steps: [ + { info: "1" }, + { + info: "2", + trigger: "button.inc", + action: "click", + }, + ], + }); + // default interval is 500 + await mock.advanceTime(600); + assert.strictEqual(span.textContent, "1"); + assert.verifySteps(["1", "2"]); + }); + + QUnit.test("trigger can be a function returning an htmlelement", async function (assert) { + await mount(TestComponent, target); + + const span = target.querySelector("span.value"); + assert.strictEqual(span.textContent, "0"); + + engine.activate({ + name: "test", + steps: [ + { + trigger: () => target.querySelector("button.inc"), + action: "click", + }, + ], + }); + // default interval is 500 + await mock.advanceTime(300); + assert.strictEqual(span.textContent, "0"); + await mock.advanceTime(300); + assert.strictEqual(span.textContent, "1"); + }); + + QUnit.test("macro does not click on invisible element", async function (assert) { + await mount(TestComponent, target); + + const span = target.querySelector("span.value"); + const button = target.querySelector("button.inc"); + assert.strictEqual(span.textContent, "0"); + + engine.activate({ + name: "test", + steps: [ + { + trigger: "button.inc", + action: "click", + }, + ], + }); + button.classList.add("d-none"); + + await mock.advanceTime(500); + assert.strictEqual(span.textContent, "0"); + await mock.advanceTime(500); + assert.strictEqual(span.textContent, "0"); + button.classList.remove("d-none"); + await mock.advanceTime(500); + + assert.strictEqual(span.textContent, "1"); + }); + } +);