[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 <sad@odoo.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
<div class="counter">
|
||||
<button class="inc" t-on-click="() => this.state.value++">increment</button>
|
||||
<button class="dec" t-on-click="() => this.state.value--">decrement</button>
|
||||
<button class="double" t-on-click="() => this.state.value = 2*this.state.value">double</button>
|
||||
<span class="value"><t t-esc="state.value"/></span>
|
||||
<input />
|
||||
</div>`;
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user