19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:58 +01:00
parent 20e6dadd87
commit 4b94f0abc5
205 changed files with 24700 additions and 14614 deletions

View file

@ -0,0 +1,235 @@
/** @odoo-module **/
import { afterEach, beforeEach, describe, expect, test } from "@odoo/hoot";
import { advanceTime, animationFrame, queryFirst } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import { mountWithCleanup, patchWithCleanup } from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { Macro } from "@web/core/macro";
import { registry } from "@web/core/registry";
describe.current.tags("desktop");
const mainErrorMessage = (trigger) =>
`Error: Potential non deterministic behavior found in 300ms for trigger ${trigger}.`;
let macro;
async function waitForMacro() {
for (let i = 0; i < 50; i++) {
await animationFrame();
await advanceTime(265);
if (macro.isComplete) {
return;
}
}
if (!macro.isComplete) {
throw new Error(`Macro is not complete`);
}
}
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<button class="button0">Button 0</button>
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
</div>
</t>
`;
static props = ["*"];
}
registry.category("web_tour.tours").add("tour_to_check_undeterminisms", {
steps: () => [
{
trigger: ".button0",
run: "click",
},
{
trigger: ".button1",
run: "click",
},
{
trigger: ".container",
},
{
trigger: ".button2",
run: "click",
},
],
});
beforeEach(async () => {
patchWithCleanup(Macro.prototype, {
start() {
super.start(...arguments);
macro = this;
},
});
patchWithCleanup(browser.console, {
log: (s) => expect.step(`log: ${s}`),
error: (s) => {
s = s.replace(/\n +at.*/g, ""); // strip stack trace
expect.step(`error: ${s}`);
},
warn: () => {},
dir: () => {},
});
await mountWithCleanup(Root);
await odoo.startTour("tour_to_check_undeterminisms", {
mode: "auto",
delayToCheckUndeterminisms: 300,
});
});
afterEach(() => {
macro.stop();
});
test("element is no longer visible", async () => {
macro.onStep = ({ index }) => {
if (index == 2) {
setTimeout(() => {
queryFirst(".container").classList.add("d-none");
}, 400);
}
};
await waitForMacro();
const expectedError = `Initial element is no longer visible`;
expect.verifySteps([
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
${mainErrorMessage(".button1")}
${expectedError}`,
]);
});
test("change text", async () => {
macro.onStep = ({ index }) => {
if (index == 2) {
setTimeout(() => {
queryFirst(".button1").textContent = "Text has changed :)";
}, 400);
}
};
await waitForMacro();
expect.verifySteps([
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
${mainErrorMessage(".button1")}
Initial element has changed:
{
"node": "<button class=\\"button1\\">Text has changed :)</button>",
"modifiedText": [
{
"before": "Button 1",
"after": "Text has changed :)"
}
]
}`,
]);
});
test("change attributes", async () => {
macro.onStep = ({ index }) => {
if (index == 2) {
setTimeout(() => {
const button1 = queryFirst(".button1");
button1.classList.add("brol");
button1.classList.remove("button1");
button1.setAttribute("data-value", "42");
}, 400);
}
};
await waitForMacro();
const expectedError = `{
"node": "<button class=\\"brol\\" data-value=\\"42\\">Button 1</button>",
"modifiedAttributes": [
{
"attributeName": "class",
"before": "button1",
"after": "brol"
},
{
"attributeName": "data-value",
"before": null,
"after": "42"
}
]
}`;
expect.verifySteps([
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
${mainErrorMessage(".button1")}
Initial element has changed:
${expectedError}`,
]);
});
test("add child node", async () => {
macro.onStep = ({ index }) => {
if (index == 4) {
setTimeout(() => {
const addElement = document.createElement("div");
addElement.classList.add("brol");
addElement.textContent = "Hello world !";
queryFirst(".container").appendChild(addElement);
}, 400);
}
};
await waitForMacro();
const expectedError = `{
"node": "<div class=\\"container\\"><button class=\\"button0\\">Button 0</button><button class=\\"button1\\">Button 1</button><button class=\\"button2\\">Button 2</button><div class=\\"brol\\">Hello world !</div></div>",
"modifiedText": [
{
"before": "Button 0Button 1Button 2",
"after": "Button 0Button 1Button 2Hello world !"
}
],
"addedNodes": [
{
"newNode": "<div class=\\"brol\\">Hello world !</div>"
}
]
}`;
expect.verifySteps([
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
"log: [3/4] Tour tour_to_check_undeterminisms → Step .container",
`error: FAILED: [3/4] Tour tour_to_check_undeterminisms → Step .container.
${mainErrorMessage(".container")}
Initial element has changed:
${expectedError}`,
]);
});
test.skip("snapshot is the same but has mutated", async () => {
macro.onStep = async ({ index }) => {
if (index === 2) {
setTimeout(() => {
const button1 = queryFirst(".button1");
button1.setAttribute("data-value", "42");
button1.classList.add("brol");
button1.removeAttribute("data-value");
button1.classList.remove("brol");
}, 400);
}
};
await waitForMacro();
const expectedError = `Initial element has mutated 4 times:
[
"attribute: data-value",
"attribute: class"
]`;
expect.verifySteps([
"log: [1/4] Tour tour_to_check_undeterminisms → Step .button0",
"log: [2/4] Tour tour_to_check_undeterminisms → Step .button1",
`error: FAILED: [2/4] Tour tour_to_check_undeterminisms → Step .button1.
${mainErrorMessage(".button1")}
${expectedError}`,
]);
});

View file

@ -1,67 +0,0 @@
/** @odoo-module **/
import { disableTours } from "@web_tour/debug/debug_manager";
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
import { ormService } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { uiService } from "@web/core/ui/ui_service";
import { click, getFixture, mount } from "@web/../tests/helpers/utils";
import { makeTestEnv } from "@web/../tests/helpers/mock_env";
import { makeFakeLocalizationService, fakeCommandService } from "@web/../tests/helpers/mock_services";
import { DebugMenuParent } from "@web/../tests/core/debug/debug_manager_tests";
const debugRegistry = registry.category("debug");
let target;
QUnit.module("Tours", (hooks) => {
QUnit.module("DebugManager");
hooks.beforeEach(async () => {
target = getFixture();
registry
.category("services")
.add("hotkey", hotkeyService)
.add("ui", uiService)
.add("orm", ormService)
.add("localization", makeFakeLocalizationService())
.add("command", fakeCommandService);
});
QUnit.test("can disable tours", async (assert) => {
debugRegistry.category("default").add("disableTours", disableTours);
const fakeTourService = {
start(env) {
return {
getActiveTours() {
return [{ name: 'a' }, { name: 'b' }];
}
}
},
};
registry.category("services").add("tour", fakeTourService);
const mockRPC = async (route, args) => {
if (args.method === "check_access_rights") {
return Promise.resolve(true);
}
if (args.method === "consume") {
assert.step("consume");
assert.deepEqual(args.args[0], ['a', 'b']);
return Promise.resolve(true);
}
};
const env = await makeTestEnv({ mockRPC });
await mount(DebugMenuParent, target, { env });
await click(target.querySelector("button.dropdown-toggle"));
assert.containsOnce(target, ".dropdown-item");
await click(target.querySelector(".dropdown-item"));
assert.verifySteps(["consume"]);
});
});

View file

@ -0,0 +1,629 @@
/** @odoo-module **/
import { afterEach, beforeEach, describe, expect, test } from "@odoo/hoot";
import { advanceTime, animationFrame, queryFirst } from "@odoo/hoot-dom";
import { Component, xml } from "@odoo/owl";
import {
getService,
makeMockEnv,
mountWithCleanup,
patchWithCleanup,
} from "@web/../tests/web_test_helpers";
import { browser } from "@web/core/browser/browser";
import { Dialog } from "@web/core/dialog/dialog";
import { Macro } from "@web/core/macro";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
describe.current.tags("desktop");
const tourRegistry = registry.category("web_tour.tours");
let macro;
async function waitForMacro() {
for (let i = 0; i < 50; i++) {
await animationFrame();
await advanceTime(265);
if (macro.isComplete) {
return;
}
}
if (!macro.isComplete) {
throw new Error(`Macro is not complete`);
}
}
beforeEach(() => {
patchWithCleanup(Macro.prototype, {
start() {
super.start(...arguments);
macro = this;
},
});
patchWithCleanup(console, {
error: () => {},
warn: () => {},
log: () => {},
dir: () => {},
});
});
afterEach(() => {
macro.stop();
});
test("Step Tour validity", async () => {
patchWithCleanup(console, {
error: (msg) => expect.step(msg),
});
const steps = [
{
Belgium: true,
wins: "of course",
EURO2024: true,
trigger: "button.foo",
},
{
my_title: "EURO2024",
trigger: "button.bar",
doku: "Lukaku 10",
},
{
trigger: "button.bar",
run: ["Enjoy euro 2024"],
},
{
trigger: "button.bar",
run() {},
},
];
tourRegistry.add("tour1", {
steps: () => steps,
});
await makeMockEnv({});
const waited_error1 = `Error in schema for TourStep ${JSON.stringify(
steps[0],
null,
4
)}\nInvalid object: unknown key 'Belgium', unknown key 'wins', unknown key 'EURO2024'`;
const waited_error2 = `Error in schema for TourStep ${JSON.stringify(
steps[1],
null,
4
)}\nInvalid object: unknown key 'my_title', unknown key 'doku'`;
const waited_error3 = `Error in schema for TourStep ${JSON.stringify(
steps[2],
null,
4
)}\nInvalid object: 'run' is not a string or function or boolean`;
await getService("tour_service").startTour("tour1");
await animationFrame();
expect.verifySteps([waited_error1, waited_error2, waited_error3]);
});
test("a tour with invalid step trigger", async () => {
patchWithCleanup(browser.console, {
groupCollapsed: (s) => expect.step(`log: ${s}`),
log: (s) => expect.step(`log: ${s}`),
warn: (s) => {},
error: (s) => expect.step(`error: ${s}`),
});
tourRegistry.add("tour_invalid_trigger", {
steps: () => [
{
trigger: ".button0:contins(brol)",
run: "click",
},
{
trigger: ".button1:has(machin)",
run: "click",
},
],
});
await odoo.startTour("tour_invalid_trigger", { mode: "auto" }); // Use odoo to run tour from registry because this is a test tour
await waitForMacro();
const expectedSteps = [
"log: [1/2] Tour tour_invalid_trigger → Step .button0:contins(brol)",
`error: FAILED: [1/2] Tour tour_invalid_trigger → Step .button0:contins(brol).
ERROR during find trigger:
Failed to execute 'querySelectorAll' on 'Element': '.button0:contins(brol)' is not a valid selector.`,
];
expect.verifySteps(expectedSteps);
});
test("a failing tour logs the step that failed in run", async () => {
patchWithCleanup(browser.console, {
groupCollapsed: (s) => expect.step(`log: ${s}`),
log: (s) => expect.step(`log: ${s}`),
warn: (s) => {},
error: (s) => {
s = s.replace(/\n +at.*/g, ""); // strip stack trace
expect.step(`error: ${s}`);
},
});
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<button class="button0">Button 0</button>
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
tourRegistry.add("tour2", {
steps: () => [
{
trigger: ".button0",
run: "click",
},
{
trigger: ".button1",
run() {
const el = queryFirst(".wrong_selector");
el.click();
},
},
],
});
await odoo.startTour("tour2", { mode: "auto" }); // Use odoo to run tour from registry because this is a test tour
await waitForMacro();
const expectedError = [
"log: [1/2] Tour tour2 → Step .button0",
`log: [2/2] Tour tour2 → Step .button1`,
[
"error: FAILED: [2/2] Tour tour2 → Step .button1.",
`TypeError: Cannot read properties of null (reading 'click')`,
].join("\n"),
];
expect.verifySteps(expectedError);
});
test("a failing tour with disabled element", async () => {
patchWithCleanup(browser.console, {
log: (s) => {},
warn: (s) => {},
error: (s) => expect.step(`error: ${s}`),
});
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<button class="button0">Button 0</button>
<button class="button1" disabled="">Button 1</button>
<button class="button2">Button 2</button>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
tourRegistry.add("tour3", {
timeout: 500,
steps: () => [
{
trigger: ".button0",
run: "click",
},
{
trigger: ".button1",
run: "click",
},
{
trigger: ".button2",
run: "click",
},
],
});
await odoo.startTour("tour3", { mode: "auto" });
await waitForMacro();
const expectedError = [
`error: FAILED: [2/3] Tour tour3 → Step .button1.
Element has been found.
BUT: Element is not enabled. TIP: You can use :enable to wait the element is enabled before doing action on it.
TIMEOUT step failed to complete within 500 ms.`,
];
expect.verifySteps(expectedError);
});
test("a failing tour logs the step that failed", async () => {
patchWithCleanup(browser.console, {
dir: (s) => expect.step(`runbot: ${s.replace(/[\s-]*/g, "")}`),
groupCollapsed: (s) => expect.step(`log: ${s}`),
log: (s) => expect.step(`log: ${s}`),
warn: (s) => expect.step(`warn: ${s.replace(/[\s-]*/gi, "")}`),
error: (s) => expect.step(`error: ${s}`),
});
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<button class="button0">Button 0</button>
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
<button class="button3">Button 3</button>
<button class="button4">Button 4</button>
<button class="button5">Button 5</button>
<button class="button6">Button 6</button>
<button class="button7">Button 7</button>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
tourRegistry.add("tour1", {
steps: () => [
{
content: "content",
trigger: ".button0",
run: "click",
},
{
content: "content",
trigger: ".button1",
run: "click",
},
{
content: "content",
trigger: ".button2",
run: "click",
},
{
content: "content",
trigger: ".button3",
run: "click",
},
{
content: "content",
trigger: ".wrong_selector",
timeout: 111,
run: "click",
},
{
content: "content",
trigger: ".button4",
run: "click",
},
{
content: "content",
trigger: ".button5",
run: "click",
},
{
content: "content",
trigger: ".button6",
run: "click",
},
{
content: "content",
trigger: ".button7",
run: "click",
},
],
});
await odoo.startTour("tour1", { mode: "auto" });
await waitForMacro();
expect.verifySteps([
"log: [1/9] Tour tour1 → Step content (trigger: .button0)",
"log: [2/9] Tour tour1 → Step content (trigger: .button1)",
"log: [3/9] Tour tour1 → Step content (trigger: .button2)",
"log: [4/9] Tour tour1 → Step content (trigger: .button3)",
"log: [5/9] Tour tour1 → Step content (trigger: .wrong_selector)",
`error: FAILED: [5/9] Tour tour1 → Step content (trigger: .wrong_selector).
Element (.wrong_selector) has not been found.
TIMEOUT step failed to complete within 111 ms.`,
`runbot: {"content":"content","trigger":".button1","run":"click"},{"content":"content","trigger":".button2","run":"click"},{"content":"content","trigger":".button3","run":"click"},FAILED:[5/9]Tourtour1→Stepcontent(trigger:.wrong_selector){"content":"content","trigger":".wrong_selector","run":"click","timeout":111},{"content":"content","trigger":".button4","run":"click"},{"content":"content","trigger":".button5","run":"click"},{"content":"content","trigger":".button6","run":"click"},`,
]);
});
test("check tour with inactive steps", async () => {
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<button class="button0">Button 0</button>
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
</div>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
registry.category("web_tour.tours").add("pipu_tour", {
steps: () => [
{
isActive: [".container:not(:has(.this_selector_is_not_here))"],
trigger: ".button0",
run() {
expect.step("this action 1 has not been skipped");
},
},
{
isActive: [".container:not(:has(.button0))"],
trigger: ".button1",
run() {
expect.step("this action 2 has been skipped");
},
},
{
isActive: [".container:not(:has(.this_selector_is_not_here))"],
trigger: ".button2",
run() {
expect.step("this action 3 has not been skipped");
},
},
],
});
await odoo.startTour("pipu_tour", { mode: "auto" });
await waitForMacro();
expect.verifySteps([
"this action 1 has not been skipped",
"this action 3 has not been skipped",
]);
});
test("automatic tour with invisible element", async () => {
patchWithCleanup(browser.console, {
warn: (s) => {},
error: (s) => expect.step(`error: ${s}`),
});
await makeMockEnv();
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<button class="button0">Button 0</button>
<button class="button1" style="display:none;">Button 1</button>
<button class="button2">Button 2</button>
</div>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
registry.category("web_tour.tours").add("tour_de_wallonie", {
timeout: 777,
steps: () => [
{
trigger: ".button0",
run: "click",
},
{
trigger: ".button1",
run: "click",
},
{
trigger: ".button2",
run: "click",
},
],
});
await odoo.startTour("tour_de_wallonie", { mode: "auto" });
await waitForMacro();
expect.verifySteps([
`error: FAILED: [2/3] Tour tour_de_wallonie → Step .button1.
Element has been found.
BUT: Element is not visible. TIP: You can use :not(:visible) to force the search for an invisible element.
TIMEOUT step failed to complete within 777 ms.`,
]);
});
test("automatic tour with invisible element but use :not(:visible))", async () => {
patchWithCleanup(browser.console, {
log: (s) => {
s.includes("tour succeeded") ? expect.step(`succeeded`) : false;
},
warn: (s) => {},
error: (s) => expect.step(`error: ${s}`),
});
await makeMockEnv();
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<button class="button0">Button 0</button>
<button class="button1" style="display:none;">Button 1</button>
<button class="button2">Button 2</button>
</div>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
registry.category("web_tour.tours").add("tour_de_wallonie", {
steps: () => [
{
trigger: ".button0",
run: "click",
},
{
trigger: ".button1:not(:visible)",
run: "click",
},
{
trigger: ".button2",
run: "click",
},
],
});
await odoo.startTour("tour_de_wallonie", { mode: "auto" });
await waitForMacro();
expect.verifySteps(["succeeded"]);
});
test("automatic tour with alternative trigger", async () => {
let suppressLog = false;
patchWithCleanup(browser.console, {
groupCollapsed: (s) => {
expect.step("on step");
suppressLog = true;
},
groupEnd: () => {
suppressLog = false;
},
log: (s) => {
if (suppressLog) {
return;
}
if (s.toLowerCase().includes("tour tour_des_flandres succeeded")) {
expect.step("succeeded");
} else if (s !== "tour succeeded") {
expect.step("on step");
}
},
});
registry.category("web_tour.tours").add("tour_des_flandres", {
steps: () => [
{
trigger: ".interval, .button1",
},
{
trigger: ".interval, .button3",
},
{
trigger: ".interval1, .interval2, .button4",
},
{
trigger: ".button5",
},
],
});
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<button class="button0">Button 0</button>
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
<button class="button3">Button 3</button>
<button class="button4">Button 4</button>
<button class="button5">Button 5</button>
</div>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
await odoo.startTour("tour_des_flandres", { mode: "auto" });
await waitForMacro();
expect.verifySteps(["on step", "on step", "on step", "on step", "succeeded"]);
});
test("check not possible to click below modal", async () => {
patchWithCleanup(console, {
warn: () => {},
error: (s) => expect.step(`error: ${s}`),
log: (s) => expect.step(`log: ${s}`),
dir: () => {},
});
class DummyDialog extends Component {
static props = ["*"];
static components = { Dialog };
static template = xml`
<Dialog>
<button class="a">A</button>
<button class="b">B</button>
</Dialog>
`;
}
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<div class="p-3"><button class="button0" t-on-click="openDialog">Button 0</button></div>
<div class="p-3"><button class="button1">Button 1</button></div>
<div class="p-3"><button class="button2">Button 2</button></div>
<div class="p-3"><button class="button3">Button 3</button></div>
</div>
</t>
`;
static props = ["*"];
setup() {
this.dialogService = useService("dialog");
}
openDialog() {
this.dialogService.add(DummyDialog);
}
}
await mountWithCleanup(Root);
registry.category("web_tour.tours").add("tour_check_modal", {
timeout: 888,
steps: () => [
{
trigger: ".button0",
run: "click",
},
{
trigger: ".button1",
run: "click",
},
],
});
await odoo.startTour("tour_check_modal", { mode: "auto" });
await waitForMacro();
expect.verifySteps([
"log: [1/2] Tour tour_check_modal → Step .button0",
"log: [2/2] Tour tour_check_modal → Step .button1",
`error: FAILED: [2/2] Tour tour_check_modal → Step .button1.
Element has been found.
BUT: It is not allowed to do action on an element that's below a modal.
TIMEOUT step failed to complete within 888 ms.`,
]);
});
test("a tour where hoot trigger failed", async () => {
patchWithCleanup(browser.console, {
error: (s) => expect.step(`error: ${s}`),
});
class Root extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<button class="button0">Button 0</button>
<button class="button1">Button 1</button>
<button class="button2">Button 2</button>
</t>
`;
static props = ["*"];
}
await mountWithCleanup(Root);
tourRegistry.add("tour_hoot_failed", {
steps: () => [
{
content: "content",
trigger: ".button0",
run: "click",
},
{
content: "content",
trigger: ".button1:brol(:machin)",
run: "click",
},
],
});
await odoo.startTour("tour_hoot_failed", { mode: "auto" });
await waitForMacro();
expect.verifySteps([
`error: FAILED: [2/2] Tour tour_hoot_failed → Step content (trigger: .button1:brol(:machin)).
ERROR during find trigger:
Failed to execute 'querySelectorAll' on 'Element': '.button1:brol(:machin)' is not a valid selector.`,
]);
});

File diff suppressed because it is too large Load diff

View file

@ -1,249 +0,0 @@
odoo.define('web_tour.tour_manager_tests', async function (require) {
"use strict";
const core = require("web.core");
const KanbanView = require('web.KanbanView');
const TourManager = require('web_tour.TourManager');
const testUtils = require('web.test_utils');
const createView = testUtils.createView;
/**
* Create a widget and a TourManager instance with a list of given Tour objects.
* @see `TourManager.register()` for more details on the Tours registry system.
* @param {Object} params aside from the parameters defined below, passed
* to {@see addMockEnvironment}.
* @param {string[]} [params.consumed_tours]
* @param {boolean} [params.debug] also passed along
* @param {boolean} [params.disabled]
* @param {string} params.template inner HTML content of the widget
* @param {Object[]} params.tours { {string} name, {Object} option, {Object[]} steps }
*/
async function createTourManager({ consumed_tours, disabled, template, tours, ...params }) {
const parent = await testUtils.createParent(params);
const tourManager = new TourManager(parent, consumed_tours, disabled);
tourManager.running_step_delay = 0;
for (const { name, options, steps } of tours) {
tourManager.register(name, options, steps);
}
const _destroy = tourManager.destroy;
tourManager.destroy = function () {
tourManager.destroy = _destroy;
parent.destroy();
};
await parent.prependTo(testUtils.prepareTarget(params.debug));
parent.el.innerHTML = template;
await tourManager._register_all(true);
// Wait for possible tooltips to be loaded and appended.
await testUtils.nextTick();
return tourManager;
}
QUnit.module("Tours", function () {
QUnit.module("Tour manager");
QUnit.test("Tours sequence", async function (assert) {
assert.expect(2);
const tourManager = await createTourManager({
template: `
<button class="btn anchor">Anchor</button>`,
tours: [
{ name: "Tour 1", options: { sequence: 10 }, steps: [{ trigger: '.anchor' }] },
{ name: "Tour 2", options: {}, steps: [{ trigger: '.anchor' }] },
{ name: "Tour 3", options: { sequence: 5 }, steps: [{ trigger: '.anchor', content: "Oui" }] },
],
// Use this test in "debug" mode because the tips need to be in
// the viewport to be able to test their normal content
// (otherwise, the tips would indicate to the users that they
// have to scroll).
debug: true,
});
assert.containsOnce(document.body, '.o_tooltip:visible');
assert.strictEqual($('.o_tooltip_content:visible').text(), "Oui",
"content should be that of the third tour");
tourManager.destroy();
});
QUnit.test("Displays a rainbow man by default at the end of tours", async function (assert) {
assert.expect(3);
function onShowEffect(params) {
assert.deepEqual(params, {
fadeout: "medium",
message: owl.markup("<strong><b>Good job!</b> You went through all steps of this tour.</strong>"),
type: "rainbow_man"
});
}
core.bus.on("show-effect", null, onShowEffect);
const tourManager = await createTourManager({
data: { 'web_tour.tour': { fields: {}, consume() {} } },
template: `<button class="btn anchor">Anchor</button>`,
tours: [{
name: "Some tour",
options: {},
steps: [{ trigger: '.anchor', content: "anchor" }],
}],
// Use this test in "debug" mode because the tips need to be in
// the viewport to be able to test their normal content
// (otherwise, the tips would indicate to the users that they
// have to scroll).
debug: true,
});
assert.containsOnce(document.body, '.o_tooltip');
await testUtils.dom.click($('.anchor'));
assert.containsNone(document.body, '.o_tooltip');
tourManager.destroy();
core.bus.off("show-effect", onShowEffect);
});
QUnit.test("Click on invisible tip consumes it", async function (assert) {
assert.expect(5);
const tourManager = await createTourManager({
data: { 'web_tour.tour': { fields: {}, consume() {} } },
template: `
<button class="btn anchor1">Anchor</button>
<button class="btn anchor2">Anchor</button>
`,
tours: [{
name: "Tour 1",
options: { rainbowMan: false, sequence: 10 },
steps: [{ trigger: '.anchor1', content: "1" }],
}, {
name: "Tour 2",
options: { rainbowMan: false, sequence: 5 },
steps: [{ trigger: '.anchor2', content: "2" }],
}],
// Use this test in "debug" mode because the tips need to be in
// the viewport to be able to test their normal content
// (otherwise, the tips would indicate to the users that they
// have to scroll).
debug: true,
});
assert.containsN(document.body, '.o_tooltip', 2);
assert.strictEqual($('.o_tooltip_content:visible').text(), "2");
await testUtils.dom.click($('.anchor1'));
assert.containsOnce(document.body, '.o_tooltip');
assert.strictEqual($('.o_tooltip_content:visible').text(), "2");
await testUtils.dom.click($('.anchor2'));
assert.containsNone(document.body, '.o_tooltip');
tourManager.destroy();
});
QUnit.test("Step anchor replaced", async function (assert) {
assert.expect(3);
const tourManager = await createTourManager({
observe: true,
template: `<input class="anchor"/>`,
tours: [{
name: "Tour",
options: { rainbowMan: false },
steps: [{ trigger: "input.anchor" }],
}],
});
assert.containsOnce(document.body, '.o_tooltip:visible');
const $anchor = $(".anchor");
const $parent = $anchor.parent();
$parent.empty();
$parent.append($anchor);
// Simulates the observer picking up the mutation and triggering an update
tourManager.update();
await testUtils.nextTick();
assert.containsOnce(document.body, '.o_tooltip:visible');
await testUtils.fields.editInput($('.anchor'), "AAA");
assert.containsNone(document.body, '.o_tooltip:visible');
tourManager.destroy();
});
QUnit.test("kanban quick create VS tour tooltips", async function (assert) {
assert.expect(3);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: {
partner: {
fields: {
foo: {string: "Foo", type: "char"},
bar: {string: "Bar", type: "boolean"},
},
records: [
{id: 1, bar: true, foo: "yop"},
]
}
},
arch: `<kanban>
<field name="bar"/>
<templates><t t-name="kanban-box">
<div><field name="foo"/></div>
</t></templates>
</kanban>`,
groupBy: ['bar'],
});
// click to add an element
await testUtils.dom.click(kanban.$('.o_kanban_header .o_kanban_quick_add i').first());
assert.containsOnce(kanban, '.o_kanban_quick_create',
"should have open the quick create widget");
// create tour manager targeting the kanban quick create in its steps
const tourManager = await createTourManager({
observe: true,
template: kanban.$el.html(),
tours: [{
name: "Tour",
options: { rainbowMan: false },
steps: [{ trigger: "input[name='display_name']" }],
}],
});
assert.containsOnce(document.body, '.o_tooltip:visible');
await testUtils.dom.click($('.o_tooltip:visible'));
assert.containsOnce(kanban, '.o_kanban_quick_create',
"the quick create should not have been destroyed when tooltip is clicked");
kanban.destroy();
tourManager.destroy();
});
QUnit.test("Automatic tour disabling", async function (assert) {
assert.expect(2);
const options = {
template: `<button class="btn anchor">Anchor</button>`,
tours: [{ name: "Tour", options: {}, steps: [{ trigger: '.anchor' }] }],
};
const enabledTM = await createTourManager({ disabled: false, ...options });
assert.containsOnce(document.body, '.o_tooltip:visible');
enabledTM.destroy();
const disabledTM = await createTourManager({ disabled: true, ...options });
assert.containsNone(document.body, '.o_tooltip:visible');
disabledTM.destroy();
});
});
});

View file

@ -0,0 +1,465 @@
import { beforeEach, describe, expect, test } from "@odoo/hoot";
import { click, edit, keyDown, keyUp, press, queryOne } from "@odoo/hoot-dom";
import { animationFrame, runAllTimers } from "@odoo/hoot-mock";
import {
contains,
defineWebModels,
mountWithCleanup,
onRpc,
patchWithCleanup,
serverState,
} from "@web/../tests/web_test_helpers";
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
import { browser } from "@web/core/browser/browser";
import { TourRecorder } from "@web_tour/js/tour_recorder/tour_recorder";
import {
TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY,
tourRecorderState,
} from "@web_tour/js/tour_recorder/tour_recorder_state";
import { Component, xml } from "@odoo/owl";
import { useAutofocus } from "@web/core/utils/hooks";
import { WebClient } from "@web/webclient/webclient";
describe.current.tags("desktop");
let tourRecorder;
beforeEach(async () => {
serverState.debug = "1";
browser.localStorage.setItem(TOUR_RECORDER_ACTIVE_LOCAL_STORAGE_KEY, "1");
patchWithCleanup(TourRecorder.prototype, {
setup() {
tourRecorder = this;
return super.setup(...arguments);
},
});
defineWebModels();
await mountWithCleanup(WebClient);
});
const checkTourSteps = (expected) => {
expect(tourRecorder.state.steps.map((s) => s.trigger)).toEqual(expected);
};
test("Click on element with unique odoo class", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<div class="o_child_1 click"></div>
<div class="o_child_2"></div>
<div class="o_child_3"></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
checkTourSteps([".o_child_1"]);
await click(".o_child_2");
await animationFrame();
checkTourSteps([".o_child_1", ".o_child_2"]);
});
test("Click on element with no unique odoo class", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<div class="o_child_1 click"></div>
<div class="o_child_1"></div>
<div class="o_child_1"></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
checkTourSteps([".o_child_1:nth-child(1)"]);
});
test("Find the nearest odoo class", async () => {
await mountWithCleanup(`<a class="click"></a>`, { noMainContainer: true });
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
checkTourSteps([".o_web_client > a"]);
});
test("Click on elements with 'data-menu-xmlid' attribute", async () => {
await mountWithCleanup(
`
<div>
<div></div>
<div data-menu-xmlid="my_menu_1" class="click_1"></div>
<div data-menu-xmlid="my_menu_2" class="click_2 o_div"></div>
<div></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click_1");
await click(".click_2");
await animationFrame();
checkTourSteps([
".o_web_client div[data-menu-xmlid='my_menu_1']",
".o_div[data-menu-xmlid='my_menu_2']",
]);
});
test("Click on elements with 'name' attribute", async () => {
await mountWithCleanup(
`
<div>
<div></div>
<div name="sale_id" class="click_1"></div>
<div name="partner_id" class="click_2 o_div"></div>
<div></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click_1");
await click(".click_2");
await animationFrame();
checkTourSteps([".o_web_client div[name='sale_id']", ".o_div[name='partner_id']"]);
});
test("Click on element that have a link or button has parent", async () => {
await mountWithCleanup(
`
<div>
<button class="o_button"><i class="click_1">icon</i></button>
<a class="o_link"><span class="click_2">This is my link</span></a>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click_1");
await click(".click_2");
await animationFrame();
checkTourSteps([".o_button", ".o_link"]);
});
test("Click on element with path that can be reduced", async () => {
await mountWithCleanup(
`
<div class=".o_parent">
<div name="field_name">
<div class="o_div_2">
<div class="o_div_3 click"></div>
</div>
</div>
<div name="field_partner_id">
<div class="o_div_2">
<div class="o_div_3"></div>
</div>
</div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
checkTourSteps(["div[name='field_name'] .o_div_3"]);
});
test("Click on input", async () => {
await mountWithCleanup(
`
<div class=".o_parent">
<input type="text" class="click o_input"/>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
expect(".o_button_record").toHaveText("Record (recording keyboard)");
checkTourSteps([".o_input"]);
});
test("Click on tag that is inside a contenteditable", async () => {
await mountWithCleanup(
`
<div class=".o_parent">
<div class="o_editor" contenteditable="true">
<p class="click oe-hint oe-command-temporary-hint" placeholder="My placeholder..."></p>
</div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
expect(".o_button_record").toHaveText("Record (recording keyboard)");
checkTourSteps([".o_editor[contenteditable='true']"]);
});
test("Remove step during recording", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<div class="o_child click"></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
checkTourSteps([".o_child"]);
await click(".o_button_steps");
await animationFrame();
contains(".o_button_delete_step").click();
await click(".o_button_steps");
await animationFrame();
checkTourSteps([]);
});
test("Edit input", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<input type="text" class="click o_input"/>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
expect(".o_button_record").toHaveText("Record (recording keyboard)");
expect(".click").toBeFocused();
await edit("Bismillah");
await animationFrame();
checkTourSteps([".o_input"]);
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["edit Bismillah"]);
});
test("Save custom tour", async () => {
onRpc("web_tour.tour", "create", ({ args }) => {
const tour = args[0][0];
expect.step(tour.name);
expect.step(tour.url);
expect.step(tour.step_ids.length);
return true;
});
await mountWithCleanup(
`
<div class="o_parent">
<div class="click"></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
checkTourSteps([".o_parent > div"]);
await click(".o_button_save");
await animationFrame();
await contains("input[name='name']").click();
await edit("tour_name");
await animationFrame();
await click(".o_button_save_confirm");
await runAllTimers(); // Wait that the save notification disappear
expect.verifySteps(["tour_name", "/", 1]);
});
test("Drag and drop", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<div>
<div class="o_drag">drag me</div>
</div>
<div class="o_drop"></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await contains(".o_drag").dragAndDrop(".o_drop");
await animationFrame();
checkTourSteps([".o_drag"]);
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["drag_and_drop .o_drop"]);
});
test("Edit contenteditable", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<div class="o_editor click" contenteditable="true" style="width: 50px; height: 50px">
</div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await click(".click");
await animationFrame();
expect(".o_editor").toBeFocused();
expect(".o_button_record").toHaveText("Record (recording keyboard)");
await keyDown("B");
await animationFrame();
queryOne(".o_editor").appendChild(document.createTextNode("Bismillah"));
await keyUp("B");
await animationFrame();
checkTourSteps([".o_editor[contenteditable='true']"]);
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["editor Bismillah"]);
});
test("Selecting item in autocomplete field through Enter", async () => {
class Dummy extends Component {
static components = { AutoComplete };
static template = xml`<AutoComplete id="'autocomplete'" value="'World'" sources="sources"/>`;
static props = ["*"];
sources = [
{
options: [
{ label: "World", onSelect() {} },
{ label: "Hello", onSelect() {} },
],
},
];
}
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await mountWithCleanup(Dummy);
await click("#autocomplete");
await animationFrame();
await press("Enter");
checkTourSteps([
".o-autocomplete--input",
".o-autocomplete--dropdown-item > a:contains('World'), .fa-circle-o-notch",
]);
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["click", "click"]);
});
test("Edit input after autofocus", async () => {
class Dummy extends Component {
static components = {};
static template = xml/*html*/ `
<t>
<div class="container">
<input type="text" class="o_input" t-ref="input"/>
</div>
</t>
`;
static props = ["*"];
setup() {
useAutofocus({ refName: "input" });
}
}
expect(".o_tour_recorder").toHaveCount(1);
await click(".o_button_record");
await animationFrame();
await mountWithCleanup(Dummy);
expect(".o_input").toBeFocused();
expect(".o_button_record").toHaveText("Record");
await edit("Bismillah");
await animationFrame();
expect(".o_button_record").toHaveText("Record (recording keyboard)");
checkTourSteps([".o_input"]);
expect(tourRecorder.state.steps.map((s) => s.run)).toEqual(["edit Bismillah"]);
});
test("Check Tour Recorder State", async () => {
await mountWithCleanup(
`
<div class="o_parent">
<div class="o_child_1 click"></div>
<div class="o_child_2"></div>
<div class="o_child_3"></div>
</div>
`,
{ noMainContainer: true }
);
expect(".o_tour_recorder").toHaveCount(1);
expect(tourRecorderState.isRecording()).toBe("0");
await click(".o_button_record");
await animationFrame();
expect(tourRecorderState.isRecording()).toBe("1");
expect(tourRecorderState.getCurrentTourRecorder()).toEqual([]);
await click(".click");
await animationFrame();
checkTourSteps([".o_child_1"]);
await click(".o_child_2");
await animationFrame();
checkTourSteps([".o_child_1", ".o_child_2"]);
expect(tourRecorderState.getCurrentTourRecorder()).toEqual([
{ trigger: ".o_child_1", run: "click" },
{ trigger: ".o_child_2", run: "click" },
]);
await click(".o_button_record");
await animationFrame();
await click(".o_tour_recorder_close_button");
await animationFrame();
expect(tourRecorderState.getCurrentTourRecorder()).toEqual([]);
expect(tourRecorderState.isRecording()).toBe("0");
});