/** @odoo-module **/ import { beforeEach, describe, expect, test } from "@odoo/hoot"; import { click, hover, leave, queryFirst, waitFor } from "@odoo/hoot-dom"; import { advanceTime, animationFrame, runAllTimers } from "@odoo/hoot-mock"; import { Component, useState, xml } from "@odoo/owl"; import { contains, getService, mountWithCleanup, onRpc, patchWithCleanup, } from "@web/../tests/web_test_helpers"; import { browser } from "@web/core/browser/browser"; import { Dialog } from "@web/core/dialog/dialog"; import { registry } from "@web/core/registry"; import { session } from "@web/session"; describe.current.tags("desktop"); class Counter extends Component { static props = ["*"]; static template = xml/*html*/ `
`; setup() { this.state = useState({ interval: 1, value: 0 }); } onIncrement() { this.state.value += this.state.interval; } } const tourRegistry = registry.category("web_tour.tours"); const tourConsumed = []; beforeEach(() => { patchWithCleanup(console, { error: () => {}, warn: () => {}, log: () => {}, dir: () => {}, }); onRpc("/web/dataset/call_kw/web_tour.tour/consume", async (request) => { const { params } = await request.json(); tourConsumed.push(params.args[0]); const nextTour = tourRegistry .getEntries() .filter(([tourName]) => !tourConsumed.includes(tourName)) .at(0); return (nextTour && { name: nextTour.at(0) }) || false; }); onRpc("/web/dataset/call_kw/res.users/switch_tour_enabled", async () => { return true; }); onRpc("/web/dataset/call_kw/web_tour.tour/get_tour_json_by_name", async () => { return { name: "tour1", steps: [ { trigger: "button.foo", run: "click" }, { trigger: "button.bar", run: "click" }, ], }; }); }); test("points to next step", async () => { tourRegistry.add("tour1", { steps: () => [ { trigger: "button.inc", run: "click", }, ], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml` `; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour1", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.inc").click(); expect(".o_tour_pointer").toHaveCount(0); expect("span.value").toHaveText("1"); }); test("next step with new anchor at same position", async () => { tourRegistry.add("tour1", { steps: () => [ { trigger: "button.foo", run: "click" }, { trigger: "button.bar", run: "click" }, ], }); class Dummy extends Component { static props = ["*"]; state = useState({ bool: true }); static template = xml/*html*/ ` `; } class Root extends Component { static props = ["*"]; static components = { Dummy }; static template = xml/*html*/ ` `; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour1", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); // check position of the pointer relative to the foo button let pointerRect = queryFirst(".o_tour_pointer").getBoundingClientRect(); let buttonRect = queryFirst("button.foo").getBoundingClientRect(); const leftValue1 = pointerRect.left - buttonRect.left; const bottomValue1 = pointerRect.bottom - buttonRect.bottom; expect(leftValue1).not.toBe(0); expect(bottomValue1).not.toBe(0); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); // check position of the pointer relative to the bar button pointerRect = queryFirst(".o_tour_pointer").getBoundingClientRect(); buttonRect = queryFirst("button.bar").getBoundingClientRect(); const leftValue2 = pointerRect.left - buttonRect.left; const bottomValue2 = pointerRect.bottom - buttonRect.bottom; expect(Math.round(bottomValue1)).toBe(Math.round(bottomValue2)); expect(leftValue1).toBe(leftValue2); await contains("button.bar").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("pointer is added on top of overlay's stack", async () => { registry.category("web_tour.tours").add("tour1", { steps: () => [ { trigger: ".modal .a", run: "click" }, { trigger: ".btn-primary", run: "click" }, ], }); class DummyDialog extends Component { static props = ["*"]; static components = { Dialog }; static template = xml` `; } class Root extends Component { static props = ["*"]; static components = {}; static template = xml``; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour1", { mode: "manual" }); getService("dialog").add(DummyDialog, {}); await advanceTime(100); expect(`.o-overlay-item`).toHaveCount(2); // the pointer should be after the dialog expect(".o-overlay-item:eq(0) .modal").toHaveCount(1); await advanceTime(100); expect(".o-overlay-item:eq(1) .o_tour_pointer").toHaveCount(1); await click(".modal .a"); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await click(".btn-primary"); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("registering test tour after service is started doesn't auto-start the tour", async () => { patchWithCleanup(session, { tour_enabled: true }); class Root extends Component { static components = { Counter }; static template = xml/*html*/ ` `; static props = ["*"]; } await mountWithCleanup(Root); expect(".o_tour_pointer").toHaveCount(0); registry.category("web_tour.tours").add("tour1", { steps: () => [ { content: "content", trigger: "button.inc", run: "click", }, ], }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("hovering to the anchor element should show the content and not when content empty", async () => { registry.category("web_tour.tours").add("la_vuelta", { steps: () => [ { content: "content", trigger: "button.inc", run: "click", }, { trigger: "button.inc", run: "click", }, ], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml/*html*/ ` `; } await mountWithCleanup(Root); await getService("tour_service").startTour("la_vuelta", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.inc").hover(); await animationFrame(); expect(".o_tour_pointer_content:not(.invisible)").toHaveCount(1); expect(".o_tour_pointer_content:not(.invisible)").toHaveText("content"); await contains(".other").hover(); await animationFrame(); expect(".o_tour_pointer_content.invisible").toHaveCount(1); await click("button.inc"); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.inc").hover(); await animationFrame(); expect(".o_tour_pointer_content.invisible").toHaveCount(1); await click("button.inc"); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("should show only 1 pointer at a time", async () => { registry.category("web_tour.tours").add("milan_sanremo", { steps: () => [ { trigger: ".interval input", run: "edit 5", }, ], }); registry.category("web_tour.tours").add("paris_roubaix", { steps: () => [ { trigger: "button.inc", run: "click", }, ], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml/*html*/ ` `; } await mountWithCleanup(Root); await getService("tour_service").startTour("paris_roubaix", { mode: "manual" }); await getService("tour_service").startTour("milan_sanremo", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains(".interval input").edit(5); expect(".o_tour_pointer").toHaveCount(1); await click("button.inc"); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("perform edit on next step", async () => { registry.category("web_tour.tours").add("giro_d_italia", { steps: () => [ { trigger: ".interval input", run: "edit 5", }, { trigger: "button.inc", run: "click", }, ], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml/*html*/ ` `; } await mountWithCleanup(Root); await getService("tour_service").startTour("giro_d_italia", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains(".interval input").edit(5); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.inc").click(); expect(".counter .value").toHaveText("5"); expect(".o_tour_pointer").toHaveCount(0); }); test("scrolling to next step should update the pointer's height", async (assert) => { patchWithCleanup(Element.prototype, { scrollIntoView(options) { super.scrollIntoView({ ...options, behavior: "instant" }); }, }); const content = "Click this pretty button to increment this magnificent counter !"; registry.category("web_tour.tours").add("tour_de_france", { steps: () => [ { trigger: "button.inc", content, run: "click", }, ], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml/*html*/ `
`; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour_de_france", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); expect(".o_tour_pointer").not.toHaveClass("o_open"); const firstOpenHeight = queryFirst(".o_tour_pointer").style.height; const firstOpenWidth = queryFirst(".o_tour_pointer").style.width; expect(firstOpenHeight).toBe("28px"); expect(firstOpenWidth).toBe("28px"); await contains("button.inc").hover(); expect(".o_tour_pointer").toHaveText(content); expect(".o_tour_pointer").toHaveClass("o_open"); await contains(".interval input").hover(); expect(".o_tour_pointer").not.toHaveClass("o_open"); await contains(".scrollable-parent").scroll({ top: 1000 }); await runAllTimers(); await animationFrame(); // awaits the intersection observer to update after the scroll // now the scroller pointer should be shown expect(".o_tour_pointer").toHaveCount(1); await contains(".o_tour_pointer").hover(); await animationFrame(); expect(".o_tour_pointer").toHaveText("Scroll up to reach the next step."); await contains(".o_tour_pointer").click(); await runAllTimers(); // awaits the intersection observer to update after the scroll await animationFrame(); // now the true step pointer should be shown again expect(".o_tour_pointer").toHaveCount(1); expect(".o_tour_pointer").not.toHaveClass("o_open"); await contains("button.inc").hover(); await animationFrame(); expect(".o_tour_pointer").toHaveClass("o_open"); expect(".o_tour_pointer").toHaveText(content); await contains(".interval input").hover(); const secondOpenHeight = queryFirst(".o_tour_pointer").style.height; const secondOpenWidth = queryFirst(".o_tour_pointer").style.width; expect(secondOpenHeight).toEqual(firstOpenHeight); expect(secondOpenWidth).toEqual(firstOpenWidth); await contains("button.inc").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("scroller pointer to reach next step", async () => { patchWithCleanup(Element.prototype, { scrollIntoView(options) { super.scrollIntoView({ ...options, behavior: "instant" }); }, }); registry.category("web_tour.tours").add("tour_des_flandres", { steps: () => [{ trigger: "button.inc", content: "Click to increment", run: "click" }], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml/*html*/ `
`; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour_des_flandres", { mode: "manual" }); await advanceTime(1000); await hover(".o_tour_pointer:empty"); await click(waitFor(".o_tour_pointer:contains(Scroll down to reach the next step.)")); await leave(); await advanceTime(1000); await hover(".o_tour_pointer:empty"); await waitFor(".o_tour_pointer:contains(Click to increment)"); expect(".counter .value").toHaveText("0"); await click("button.inc"); await animationFrame(); expect(".counter .value").toHaveText("1"); expect(".o_tour_pointer").toHaveCount(0); }); test("manual tour with inactive steps", async () => { registry.category("web_tour.tours").add("tour_de_wallonie", { steps: () => [ { isActive: ["auto"], trigger: ".interval input", run: "edit 5", }, { isActive: ["auto"], trigger: ".interval input", run: "edit 5", }, { isActive: ["manual"], trigger: ".interval input", run: "edit 5", }, { isActive: ["auto"], trigger: "button.inc", run: "click", }, { isActive: ["auto"], trigger: "button.inc", run: "click", }, { isActive: ["manual"], trigger: "button.inc", run: "click", }, { isActive: ["auto"], trigger: "button.inc", run: "click", }, ], }); class Root extends Component { static props = ["*"]; static components = { Counter }; static template = xml/*html*/ ` `; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour_de_wallonie", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains(".interval input").edit(5); expect(".o_tour_pointer").toHaveCount(1); await contains("button.inc").click(); expect(".o_tour_pointer").toHaveCount(0); expect(".counter .value").toHaveText("5"); await advanceTime(10000); }); test("manual tour with alternative trigger", async () => { patchWithCleanup(browser.console, { log: (s) => { !s.includes("═") ? expect.step(s) : ""; }, }); registry.category("web_tour.tours").add("tour_des_flandres_2", { steps: () => [ { trigger: ".button1, .button2", run: "click", }, { trigger: "body:not(:visible), .button4, .button3", run: "click", }, { trigger: ".interval1, .interval2, .button5", run: "click", }, { trigger: "button:contains(0, hello):enabled, button:contains(2, youpi)", run: "click", }, ], }); class Root extends Component { static components = {}; static template = xml/*html*/ `
`; static props = ["*"]; } await mountWithCleanup(Root); await getService("tour_service").startTour("tour_des_flandres_2", { mode: "manual" }); await contains(".button2").click(); await contains(".button3").click(); await contains(".button5").click(); await contains(".button2").click(); expect.verifySteps(["click", "click", "click", "click", "tour succeeded"]); }); test("Tour backward when the pointed element disappear", async () => { registry.category("web_tour.tours").add("tour1", { steps: () => [ { trigger: "button.foo", run: "click" }, { trigger: "button.bar", run: "click" }, ], }); class Dummy extends Component { static props = ["*"]; state = useState({ bool: true }); static components = {}; static template = xml` `; } await mountWithCleanup(Dummy); await getService("tour_service").startTour("tour1", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.fool").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.bar").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("Tour backward when the pointed element disappear and ignore warn step", async () => { patchWithCleanup(console, { warn: (msg) => expect.step(msg), }); registry.category("web_tour.tours").add("tour1", { steps: () => [ { trigger: "button.foo", run: "click" }, { trigger: "button.bar" }, { trigger: "button.bar", run: "click" }, ], }); class Dummy extends Component { static props = ["*"]; state = useState({ bool: true }); static components = {}; static template = xml` `; } await mountWithCleanup(Dummy); await getService("tour_service").startTour("tour1", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.fool").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.bar").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); expect.verifySteps(["Step 'button.bar' ignored.", "Step 'button.bar' ignored."]); }); test("Tour started by the URL", async () => { browser.location.href = `${browser.location.origin}?tour=tour1`; class Dummy extends Component { static props = ["*"]; state = useState({ bool: true }); static components = {}; static template = xml` `; } await mountWithCleanup(Dummy); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.bar").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); }); test("Log a warning if step ignored", async () => { patchWithCleanup(console, { warn: (msg) => expect.step(msg), }); registry.category("web_tour.tours").add("tour1", { steps: () => [ { trigger: "button.foo", run: "click" }, { trigger: "button.bar" }, { trigger: "button.bar", run: "click" }, ], }); class Dummy extends Component { static props = ["*"]; state = useState({ bool: true }); static components = {}; static template = xml` `; } await mountWithCleanup(Dummy); await getService("tour_service").startTour("tour1", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.foo").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); await contains("button.bar").click(); await animationFrame(); expect(".o_tour_pointer").toHaveCount(0); expect.verifySteps(["Step 'button.bar' ignored."]); }); test("check tooltip position", async () => { registry.category("web_tour.tours").add("tour_des_tooltip", { steps: () => [ { trigger: ".button0", tooltipPosition: "right", run: "click", }, { trigger: ".button1", tooltipPosition: "left", run: "click", }, { trigger: ".button2", tooltipPosition: "bottom", run: "click", }, { trigger: ".button3", tooltipPosition: "top", run: "click", }, ], }); class Root extends Component { static components = {}; static template = xml/*html*/ `
`; static props = ["*"]; } await mountWithCleanup(Root); let tooltip; await getService("tour_service").startTour("tour_des_tooltip", { mode: "manual" }); await animationFrame(); await advanceTime(100); tooltip = await waitFor(".o_tour_pointer"); const button0 = await waitFor(".button0"); expect(tooltip.getBoundingClientRect().left).toBeGreaterThan( button0.getBoundingClientRect().right ); await contains(".button0").click(); await animationFrame(); await advanceTime(100); tooltip = await waitFor(".o_tour_pointer"); const button1 = await waitFor(".button1"); expect(tooltip.getBoundingClientRect().right).toBeLessThan( button1.getBoundingClientRect().left ); await contains(".button1").click(); await animationFrame(); await advanceTime(100); tooltip = await waitFor(".o_tour_pointer"); const button2 = await waitFor(".button2"); expect(tooltip.getBoundingClientRect().top).toBeGreaterThan( button2.getBoundingClientRect().bottom ); await contains(".button2").click(); await animationFrame(); await advanceTime(100); tooltip = await waitFor(".o_tour_pointer"); const button3 = await waitFor(".button3"); expect(tooltip.getBoundingClientRect().bottom).toBeLessThan( button3.getBoundingClientRect().top ); await contains(".button3").click(); }); test("check rainbowManMessage", async () => { registry.category("web_tour.tours").add("rainbow_tour", { steps: () => [ { trigger: ".button0", run: "click", }, { trigger: ".button1", run: "click", }, { trigger: ".button2", run: "click", }, ], }); class Root extends Component { static components = {}; static template = xml/*html*/ `
`; static props = ["*"]; } await mountWithCleanup(Root); await getService("tour_service").startTour("rainbow_tour", { mode: "manual", rainbowManMessage: "Congratulations !", }); await contains(".button0").click(); await contains(".button1").click(); await contains(".button2").click(); const rainbowMan = await waitFor(".o_reward_rainbow_man"); expect(rainbowMan.getBoundingClientRect().width).toBe(400); expect(rainbowMan.getBoundingClientRect().height).toBe(400); expect(".o_reward_msg_content").toHaveText("Congratulations !"); }); test("check alternative trigger that appear after the initial trigger", async () => { registry.category("web_tour.tours").add("rainbow_tour", { steps: () => [ { trigger: ".button0, .button1", run: "click", }, ], }); class Root extends Component { static components = {}; static template = xml/*html*/ `
`; static props = ["*"]; } await mountWithCleanup(Root); getService("tour_service").startTour("rainbow_tour", { mode: "manual" }); await animationFrame(); expect(".o_tour_pointer").toHaveCount(1); const otherButton = document.createElement("button"); otherButton.classList.add("button1"); queryFirst(".add_button").appendChild(otherButton); await contains(".button1").click(); expect(".o_tour_pointer").toHaveCount(0); });