533 lines
18 KiB
JavaScript
533 lines
18 KiB
JavaScript
/**
|
|
* The purpose of this test is to click on every installed App and then open each
|
|
* view. On each view, click on each filter.
|
|
*/
|
|
|
|
import { App, reactive } from "@odoo/owl";
|
|
import { browser } from "@web/core/browser/browser";
|
|
import { rpcBus } from "@web/core/network/rpc";
|
|
import { getPopoverForTarget } from "@web/core/popover/popover";
|
|
|
|
export const SUCCESS_SIGNAL = "clickbot test succeeded";
|
|
|
|
const MOUSE_EVENTS = ["mouseover", "mouseenter", "mousedown", "mouseup", "click"];
|
|
const BLACKLISTED_MENUS = [
|
|
"base.menu_theme_store", // Open a new tab
|
|
"base.menu_third_party", // Open a new tab
|
|
"event.menu_event_registration_desk", // there's no way to come back from this menu (tablet mode)
|
|
"hr_attendance.menu_hr_attendance_kiosk_no_user_mode", // same here (tablet mode)
|
|
"mrp_workorder.menu_mrp_workorder_root", // same here (tablet mode)
|
|
"account.menu_action_account_bank_journal_form", // Modal in an iFrame
|
|
"pos_preparation_display.menu_point_kitchen_display_root", // conditional menu that may leads to frontend
|
|
];
|
|
// If you change this selector, adapt Studio test "Studio icon matches the clickbot selector"
|
|
const STUDIO_SYSTRAY_ICON_SELECTOR = ".o_web_studio_navbar_item:not(.o_disabled) i";
|
|
|
|
let isEnterprise;
|
|
let state;
|
|
let calledRPC;
|
|
let errorRPC;
|
|
let actionCount;
|
|
let env;
|
|
let apps;
|
|
|
|
/**
|
|
* Hook on specific activities of the webclient to detect when to move forward.
|
|
* This should be done only once.
|
|
*/
|
|
function setup(light, currentState) {
|
|
env = odoo.__WOWL_DEBUG__.root.env;
|
|
const stopButton = document.createElement("button");
|
|
stopButton.setAttribute("id", "stop-clickbot");
|
|
stopButton.classList.add("btn", "btn-danger");
|
|
stopButton.textContent = "Stop ClickAll!";
|
|
stopButton.onclick = function () {
|
|
browser.localStorage.removeItem("running.clickbot");
|
|
location.reload();
|
|
};
|
|
document.body.appendChild(stopButton);
|
|
|
|
env.bus.addEventListener("ACTION_MANAGER:UI-UPDATED", uiUpdate);
|
|
rpcBus.addEventListener("RPC:REQUEST", onRPCRequest);
|
|
rpcBus.addEventListener("RPC:RESPONSE", onRPCResponse);
|
|
isEnterprise = odoo.info && odoo.info.isEnterprise;
|
|
|
|
state = reactive(
|
|
currentState || {
|
|
light,
|
|
studioCount: 0,
|
|
testedApps: [],
|
|
testedMenus: [],
|
|
testedFilters: 0,
|
|
testedModals: 0,
|
|
appIndex: 0,
|
|
menuIndex: 0,
|
|
subMenuIndex: 0,
|
|
},
|
|
() => browser.localStorage.setItem("running.clickbot", JSON.stringify(state))
|
|
);
|
|
browser.localStorage.setItem("running.clickbot", JSON.stringify(state));
|
|
|
|
actionCount = 0;
|
|
calledRPC = {};
|
|
apps = null;
|
|
errorRPC = undefined;
|
|
}
|
|
|
|
function onRPCRequest({ detail }) {
|
|
calledRPC[detail.data.id] = detail.url;
|
|
}
|
|
|
|
function onRPCResponse({ detail }) {
|
|
delete calledRPC[detail.data.id];
|
|
if (detail.error) {
|
|
errorRPC = { ...detail };
|
|
}
|
|
}
|
|
|
|
function uiUpdate() {
|
|
actionCount++;
|
|
}
|
|
|
|
function cleanup() {
|
|
browser.localStorage.removeItem("running.clickbot");
|
|
env.bus.removeEventListener("ACTION_MANAGER:UI-UPDATED", uiUpdate);
|
|
rpcBus.removeEventListener("RPC:REQUEST", onRPCRequest);
|
|
rpcBus.removeEventListener("RPC:RESPONSE", onRPCResponse);
|
|
const stopButton = document.getElementById("stop-clickbot");
|
|
stopButton.remove();
|
|
}
|
|
|
|
/**
|
|
* Returns a promise that resolves after the next animation frame.
|
|
*
|
|
* @returns {Promise}
|
|
*/
|
|
async function waitForNextAnimationFrame() {
|
|
await new Promise(browser.setTimeout);
|
|
await new Promise((r) => requestAnimationFrame(r));
|
|
}
|
|
|
|
/**
|
|
* Simulate all of the mouse events triggered during a click action.
|
|
*
|
|
* @param {EventTarget} target the element on which to perform the click
|
|
* @param {string} elDescription description of the item
|
|
* @returns {Promise} resolved after next animation frame
|
|
*/
|
|
async function triggerClick(target, elDescription) {
|
|
if (target) {
|
|
if (elDescription) {
|
|
browser.console.log(`Clicking on: ${elDescription}`);
|
|
}
|
|
} else {
|
|
throw new Error(`No element "${elDescription}" found.`);
|
|
}
|
|
MOUSE_EVENTS.forEach((type) => {
|
|
const event = new MouseEvent(type, { bubbles: true, cancelable: true, view: window });
|
|
target.dispatchEvent(event);
|
|
});
|
|
await waitForNextAnimationFrame();
|
|
}
|
|
|
|
/**
|
|
* Wait a certain amount of time for a condition to occur
|
|
*
|
|
* @param {function} stopCondition a function that returns a boolean
|
|
* @returns {Promise} that is rejected if the timeout is exceeded
|
|
*/
|
|
async function waitForCondition(stopCondition) {
|
|
const interval = 25;
|
|
const initialTime = 30000;
|
|
let timeLimit = initialTime;
|
|
|
|
function hasPendingRPC() {
|
|
return Object.keys(calledRPC).length > 0;
|
|
}
|
|
function hasScheduledTask() {
|
|
let size = 0;
|
|
for (const app of App.apps) {
|
|
size += app.scheduler.tasks.size;
|
|
}
|
|
return size > 0;
|
|
}
|
|
function errorDialog() {
|
|
if (document.querySelector(".o_error_dialog")) {
|
|
if (errorRPC) {
|
|
browser.console.error(
|
|
"A RPC in error was detected, maybe it's related to the error dialog : " +
|
|
JSON.stringify(errorRPC)
|
|
);
|
|
}
|
|
throw new Error(
|
|
"Error dialog detected" + document.querySelector(".o_error_dialog").innerHTML
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
while (errorDialog() || !stopCondition() || hasPendingRPC() || hasScheduledTask()) {
|
|
if (timeLimit <= 0) {
|
|
let msg = `Timeout, the clicked element took more than ${
|
|
initialTime / 1000
|
|
} seconds to load\n`;
|
|
msg += `Waiting for:\n`;
|
|
if (Object.keys(calledRPC).length > 0) {
|
|
msg += ` * ${Object.values(calledRPC).join(", ")} RPC\n`;
|
|
}
|
|
let scheduleTasks = "";
|
|
for (const app of App.apps) {
|
|
for (const task of app.scheduler.tasks) {
|
|
scheduleTasks += task.node.name + ",";
|
|
}
|
|
}
|
|
if (scheduleTasks.length > 0) {
|
|
msg += ` * ${scheduleTasks} scheduled tasks\n`;
|
|
}
|
|
if (!stopCondition()) {
|
|
msg += ` * stopCondition: ${stopCondition.toString()}`;
|
|
}
|
|
throw new Error(msg);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
timeLimit -= interval;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make sure the home menu is open (enterprise only)
|
|
*/
|
|
async function ensureHomeMenu() {
|
|
const homeMenu = document.querySelector("div.o_home_menu");
|
|
if (!homeMenu) {
|
|
let menuToggle = document.querySelector("nav.o_main_navbar > a.o_menu_toggle");
|
|
if (!menuToggle) {
|
|
// In the Barcode application, there is no navbar. So you have to click
|
|
// on the o_stock_barcode_home_menu button which is the equivalent
|
|
// of the o_menu_toggle button in the navbar.
|
|
menuToggle = document.querySelector(".o_stock_barcode_home_menu");
|
|
}
|
|
await triggerClick(menuToggle, "home menu toggle button");
|
|
await waitForCondition(() => document.querySelector("div.o_home_menu"));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make sure the apps menu is open (community only)
|
|
*/
|
|
async function ensureAppsMenu() {
|
|
const apps = document.querySelectorAll(".o-dropdown--menu .o_app");
|
|
if (!apps || !apps.length) {
|
|
const toggler = document.querySelector(".o_navbar_apps_menu .dropdown-toggle");
|
|
await triggerClick(toggler, "apps menu toggle button");
|
|
await waitForCondition(() => document.querySelector(".o-dropdown--menu .o_app"));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the next menu to test, and update the internal counters.
|
|
*
|
|
* @returns {DomElement}
|
|
*/
|
|
async function getNextMenu() {
|
|
const menuToggles = document.querySelectorAll(
|
|
".o_menu_sections > .dropdown-toggle, .o_menu_sections > .dropdown-item"
|
|
);
|
|
if (state.menuIndex === menuToggles.length) {
|
|
state.menuIndex = 0;
|
|
return; // all menus done
|
|
}
|
|
let menuToggle = menuToggles[state.menuIndex];
|
|
if (menuToggle.classList.contains("dropdown-toggle")) {
|
|
// the current menu is a dropdown toggler -> open it and pick a menu inside the dropdown
|
|
let dropdownMenu = getPopoverForTarget(menuToggle);
|
|
if (!dropdownMenu) {
|
|
await triggerClick(menuToggle, "menu toggler");
|
|
dropdownMenu = getPopoverForTarget(menuToggle);
|
|
}
|
|
if (!dropdownMenu) {
|
|
state.menuIndex = 0; // empty More menu has no dropdown (FIXME?)
|
|
return;
|
|
}
|
|
const items = dropdownMenu.querySelectorAll(".dropdown-item");
|
|
menuToggle = items[state.subMenuIndex];
|
|
if (state.subMenuIndex === items.length - 1) {
|
|
// this is the last item, so go to the next menu
|
|
state.menuIndex++;
|
|
state.subMenuIndex = 0;
|
|
} else {
|
|
// this isn't the last item, so increment the index inside this dropdown
|
|
state.subMenuIndex++;
|
|
}
|
|
} else {
|
|
// the current menu isn't a dropdown, so go to the next menu
|
|
state.menuIndex++;
|
|
}
|
|
return menuToggle;
|
|
}
|
|
|
|
/**
|
|
* Return the next app to test, and update the internal counter.
|
|
*
|
|
* @returns {DomElement}
|
|
*/
|
|
async function getNextApp() {
|
|
if (!apps || !apps.length) {
|
|
if (isEnterprise) {
|
|
await ensureHomeMenu();
|
|
apps = document.querySelectorAll(".o_apps .o_app");
|
|
} else {
|
|
await ensureAppsMenu();
|
|
apps = document.querySelectorAll(".o-dropdown--menu .o_app");
|
|
}
|
|
}
|
|
const appName = apps[state.appIndex]?.dataset?.menuXmlid;
|
|
state.appIndex++;
|
|
return appName;
|
|
}
|
|
|
|
/**
|
|
* Test Studio
|
|
* Click on the Studio systray item to enter Studio, and simply leave it once loaded.
|
|
*/
|
|
async function testStudio() {
|
|
const studioIcon = document.querySelector(STUDIO_SYSTRAY_ICON_SELECTOR);
|
|
if (!studioIcon) {
|
|
return;
|
|
}
|
|
// Open the filter menu dropdown
|
|
await triggerClick(studioIcon, "entering studio");
|
|
await waitForCondition(() => document.querySelector(".o_in_studio"));
|
|
await triggerClick(document.querySelector(".o_web_studio_leave"), "leaving studio");
|
|
await waitForCondition(() =>
|
|
document.querySelector(".o_main_navbar:not(.o_studio_navbar) .o_menu_toggle")
|
|
);
|
|
state.studioCount++;
|
|
}
|
|
|
|
/**
|
|
* Test filters
|
|
* Click on each filter in the control pannel
|
|
*/
|
|
async function testFilters() {
|
|
if (state.light === true) {
|
|
return;
|
|
}
|
|
const searchBarMenu = document.querySelector(
|
|
".o_control_panel .dropdown-toggle.o_searchview_dropdown_toggler"
|
|
);
|
|
if (!searchBarMenu) {
|
|
return;
|
|
}
|
|
// Open the search bar menu dropdown
|
|
await triggerClick(searchBarMenu);
|
|
const filterMenuButton = document.querySelector(".o_dropdown_container.o_filter_menu");
|
|
// Is there a filter menu in the search bar
|
|
if (!filterMenuButton) {
|
|
return;
|
|
}
|
|
|
|
// Avoid the "Custom Filter" menu item (it don't have the class .o_menu_item)
|
|
const simpleFilterSel = ".o_filter_menu > .dropdown-item.o_menu_item:not(.o_add_custom_filter)";
|
|
const dateFilterSel = ".o_filter_menu > .o_accordion";
|
|
const filterMenuItems = document.querySelectorAll(`${simpleFilterSel},${dateFilterSel}`);
|
|
browser.console.log(`Testing ${filterMenuItems.length} filters`);
|
|
state.testedFilters += filterMenuItems.length;
|
|
for (const filter of filterMenuItems) {
|
|
// Date filters
|
|
if (filter.classList.contains("o_accordion")) {
|
|
// If a fitler has options, it will simply unfold and show all options.
|
|
await triggerClick(
|
|
filter.querySelector(".o_accordion_toggle"),
|
|
`filter "${filter.innerText.trim()}"`
|
|
);
|
|
|
|
// If a fitler has options, it will simply unfold and show all options.
|
|
// We then click on the first one.
|
|
const firstOption = filter.querySelector(
|
|
".o_accordion > .o_accordion_values > .dropdown-item"
|
|
);
|
|
if (firstOption) {
|
|
await triggerClick(firstOption, `filter option "${firstOption.innerText.trim()}"`);
|
|
await waitForCondition(() => true);
|
|
}
|
|
} else {
|
|
await triggerClick(filter, `filter "${filter.innerText.trim()}"`);
|
|
await waitForCondition(() => true);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Orchestrate the test of views
|
|
* This function finds the buttons that permit to switch views and orchestrate
|
|
* the click on each of them
|
|
* @returns {Promise}
|
|
*/
|
|
async function testViews() {
|
|
if (state.light === true) {
|
|
return;
|
|
}
|
|
const switchButtons = document.querySelectorAll(
|
|
"nav.o_cp_switch_buttons > button.o_switch_view:not(.active):not(.o_map)"
|
|
);
|
|
for (const switchButton of switchButtons) {
|
|
// Only way to get the viewType from the switchButton
|
|
const viewType = [...switchButton.classList]
|
|
.find((cls) => cls !== "o_switch_view" && cls.startsWith("o_"))
|
|
.slice(2);
|
|
browser.console.log(`Testing view switch: ${viewType}`);
|
|
// timeout to avoid click debounce
|
|
browser.setTimeout(function () {
|
|
const target = document.querySelector(
|
|
`nav.o_cp_switch_buttons > button.o_switch_view.o_${viewType}`
|
|
);
|
|
if (target) {
|
|
triggerClick(target, `${viewType} view switcher`);
|
|
}
|
|
}, 250);
|
|
await waitForCondition(() => {
|
|
return document.querySelector(`.o_switch_view.o_${viewType}.active`) !== null;
|
|
});
|
|
await testStudio();
|
|
await testFilters();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test a menu item by:
|
|
* 1 - clikcing on the menuItem
|
|
* 2 - Orchestrate the view switch
|
|
*
|
|
* @param {DomElement} element: the menu item
|
|
* @returns {Promise}
|
|
*/
|
|
async function testMenuItem(element) {
|
|
const menu = element.dataset.menuXmlid;
|
|
const menuDescription = element.innerText.trim() + " " + menu;
|
|
if (BLACKLISTED_MENUS.includes(menu)) {
|
|
browser.console.log(`Skipping blacklisted menu ${menuDescription}`);
|
|
return Promise.resolve(); // Skip black listed menus
|
|
}
|
|
browser.console.log(`Testing menu ${menuDescription}`);
|
|
state.testedMenus.push(menu);
|
|
const startActionCount = actionCount;
|
|
await triggerClick(element, `menu item "${element.innerText.trim()}"`);
|
|
try {
|
|
let isModal = false;
|
|
await waitForCondition(() => {
|
|
if (document.querySelector(".o_dialog:not(.o_error_dialog)")) {
|
|
isModal = true;
|
|
browser.console.log(`Modal detected: ${menuDescription}`);
|
|
state.testedModals++;
|
|
return true;
|
|
} else {
|
|
return startActionCount !== actionCount;
|
|
}
|
|
});
|
|
if (isModal) {
|
|
await triggerClick(
|
|
document.querySelector(".o_dialog header > .btn-close"),
|
|
"modal close button"
|
|
);
|
|
} else {
|
|
await testStudio();
|
|
await testFilters();
|
|
await testViews();
|
|
}
|
|
} catch (err) {
|
|
browser.console.error(`Error while testing ${menuDescription}`);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test an "App" menu item by orchestrating the following actions:
|
|
* 1 - clicking on its menuItem
|
|
* 2 - clicking on each view
|
|
* 3 - clicking on each menu
|
|
* 3.1 - clicking on each view
|
|
* @returns {Promise}
|
|
*/
|
|
async function testApp() {
|
|
let element;
|
|
|
|
if (!state.testedApps.includes(state.app)) {
|
|
if (isEnterprise) {
|
|
await ensureHomeMenu();
|
|
element = document.querySelector(`a.o_app.o_menuitem[data-menu-xmlid="${state.app}"]`);
|
|
} else {
|
|
await ensureAppsMenu();
|
|
element = document.querySelector(
|
|
`.o-dropdown--menu .dropdown-item[data-menu-xmlid="${state.app}"]`
|
|
);
|
|
}
|
|
if (!element) {
|
|
throw new Error(`No app found for xmlid ${state.app}`);
|
|
}
|
|
browser.console.log(`Testing app menu: ${state.app}`);
|
|
state.testedApps.push(state.app);
|
|
await testMenuItem(element);
|
|
} else {
|
|
browser.console.log(`already tested app ${state.app}`);
|
|
}
|
|
|
|
if (state.light === true) {
|
|
return;
|
|
}
|
|
state.menuIndex = 0;
|
|
state.subMenuIndex = 0;
|
|
let menu = await getNextMenu();
|
|
while (menu) {
|
|
await testMenuItem(menu);
|
|
menu = await getNextMenu();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main function that starts orchestration of tests
|
|
*/
|
|
async function _clickEverywhere(xmlId, light, currentState) {
|
|
setup(light, currentState);
|
|
console.log("Starting ClickEverywhere test");
|
|
console.log(`Odoo flavor: ${isEnterprise ? "Enterprise" : "Community"}`);
|
|
const startTime = performance.now();
|
|
try {
|
|
if (xmlId) {
|
|
state.app = xmlId;
|
|
await testApp();
|
|
} else {
|
|
if (state.app) {
|
|
// This is needed to test the last app after a reload
|
|
await testApp();
|
|
}
|
|
while ((state.app = await getNextApp())) {
|
|
await testApp();
|
|
}
|
|
}
|
|
|
|
console.log(`Test took ${(performance.now() - startTime) / 1000} seconds`);
|
|
browser.console.log(`Successfully tested ${state.testedApps.length} apps`);
|
|
browser.console.log(
|
|
`Successfully tested ${state.testedMenus.length - state.testedApps.length} menus`
|
|
);
|
|
browser.console.log(`Successfully tested ${state.testedModals} modals`);
|
|
browser.console.log(`Successfully tested ${state.testedFilters} filters`);
|
|
if (state.studioCount > 0) {
|
|
browser.console.log(`Successfully tested ${state.studioCount} views in Studio`);
|
|
}
|
|
browser.console.log(SUCCESS_SIGNAL);
|
|
} catch (err) {
|
|
console.log(`Test took ${(performance.now() - startTime) / 1000} seconds`);
|
|
browser.console.error(err || "test failed");
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
}
|
|
|
|
function clickEverywhere(xmlId, light = false, currentState) {
|
|
browser.setTimeout(_clickEverywhere, 1000, xmlId, light, currentState);
|
|
}
|
|
|
|
window.clickEverywhere = clickEverywhere;
|