/** @odoo-module */ import { describe, expect, getFixture, test } from "@odoo/hoot"; import { click, formatXml, getActiveElement, getFocusableElements, getNextFocusableElement, getPreviousFocusableElement, isDisplayed, isEditable, isFocusable, isInDOM, isVisible, queryAll, queryAllRects, queryAllTexts, queryOne, queryRect, waitFor, waitForNone, } from "@odoo/hoot-dom"; import { animationFrame, mockTouch } from "@odoo/hoot-mock"; import { getParentFrame } from "@web/../lib/hoot-dom/helpers/dom"; import { mountForTest, parseUrl, waitForIframes } from "../local_helpers"; /** * @param {...string} queryAllSelectors */ const expectSelector = (...queryAllSelectors) => { /** * @param {string} nativeSelector */ const toEqualNodes = (nativeSelector, options) => { if (typeof nativeSelector !== "string") { throw new Error(`Invalid selector: ${nativeSelector}`); } let root = options?.root || getFixture(); if (typeof root === "string") { root = getFixture().querySelector(root); if (root.tagName === "IFRAME") { root = root.contentDocument; } } let nodes = nativeSelector ? [...root.querySelectorAll(nativeSelector)] : []; if (Number.isInteger(options?.index)) { nodes = [nodes.at(options.index)]; } const selector = queryAllSelectors.join(", "); const fnNodes = queryAll(selector); expect(fnNodes).toEqual(queryAll`${selector}`, { message: (pass, r) => [ queryAll, r`should return the same result from a tagged template literal`, ], }); expect(fnNodes).toEqual(nodes, { message: (pass, r) => [selector, r`should match`, nodes.length, r`nodes`], }); }; return { toEqualNodes }; }; /** * @param {Document} document * @param {HTMLElement} [root] * @returns {Promise} */ const makeIframe = (document, root) => new Promise((resolve) => { const iframe = document.createElement("iframe"); iframe.addEventListener("load", () => resolve(iframe)); iframe.srcdoc = ""; (root || document.body).appendChild(iframe); }); const FULL_HTML_TEMPLATE = /* html */ `

Title

List header

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo velit, tristique vitae neque a, faucibus mollis dui. Aliquam iaculis sodales mi id posuere. Proin malesuada bibendum pellentesque. Phasellus mattis at massa quis gravida. Morbi luctus interdum mi, quis dapibus augue. Vivamus condimentum nunc mi, vitae suscipit turpis dictum nec. Sed varius diam dui, eget ultricies ante dictum ac.

Form title
`; customElements.define( "hoot-test-shadow-root", class ShadowRoot extends HTMLElement { constructor() { super(); const shadow = this.attachShadow({ mode: "open" }); const p = document.createElement("p"); p.textContent = "Shadow content"; const input = document.createElement("input"); shadow.append(p, input); } } ); describe.tags("ui"); describe(parseUrl(import.meta.url), () => { test.todo("should crash", async () => { expect().toBeFalsy(); }); test("formatXml", () => { expect(formatXml("")).toBe(""); expect(formatXml("")).toBe(""); expect( formatXml(/* xml */ `
A
`) ).toBe(`
\n A\n
`); expect(formatXml(/* xml */ `
A
`)).toBe(`
\n A\n
`); // Inline expect( formatXml( /* xml */ `
A
`, { keepInlineTextNodes: true } ) ).toBe(`
\n A\n
`); expect(formatXml(/* xml */ `
A
`, { keepInlineTextNodes: true })).toBe( `
A
` ); }); test("getActiveElement", async () => { await mountForTest(/* xml */ ``); await waitForIframes(); expect(":iframe input").not.toBeFocused(); const input = queryOne(":iframe input"); await click(input); expect(":iframe input").toBeFocused(); expect(getActiveElement()).toBe(input); }); test("getActiveElement: shadow dom", async () => { await mountForTest(/* xml */ ``); expect("hoot-test-shadow-root:shadow input").not.toBeFocused(); const input = queryOne("hoot-test-shadow-root:shadow input"); await click(input); expect("hoot-test-shadow-root:shadow input").toBeFocused(); expect(getActiveElement()).toBe(input); }); test("getFocusableElements", async () => { await mountForTest(/* xml */ `
aaa
aaa `); expect(getFocusableElements().map((el) => el.className)).toEqual([ "button", "span", "input", "div", ]); expect(getFocusableElements({ tabbable: true }).map((el) => el.className)).toEqual([ "button", "input", "div", ]); }); test("getNextFocusableElement", async () => { await mountForTest(/* xml */ `
aaa
`); await click(".input"); expect(getNextFocusableElement()).toHaveClass("div"); }); test("getParentFrame", async () => { await mountForTest(/* xml */ `
`); const parent = await makeIframe(document, queryOne(".root")); const child = await makeIframe(parent.contentDocument); const content = child.contentDocument.createElement("div"); child.contentDocument.body.appendChild(content); expect(getParentFrame(content)).toBe(child); expect(getParentFrame(child)).toBe(parent); expect(getParentFrame(parent)).toBe(null); }); test("getPreviousFocusableElement", async () => { await mountForTest(/* xml */ `
aaa
`); await click(".input"); expect(getPreviousFocusableElement()).toHaveClass("button"); }); test("isEditable", async () => { expect(isEditable(document.createElement("input"))).toBe(true); expect(isEditable(document.createElement("textarea"))).toBe(true); expect(isEditable(document.createElement("select"))).toBe(false); const editableDiv = document.createElement("div"); expect(isEditable(editableDiv)).toBe(false); editableDiv.setAttribute("contenteditable", "true"); expect(isEditable(editableDiv)).toBe(false); // not supported }); test("isFocusable", async () => { await mountForTest(FULL_HTML_TEMPLATE); expect(isFocusable("input:first")).toBe(true); expect(isFocusable("li:first")).toBe(false); }); test("isInDom", async () => { await mountForTest(FULL_HTML_TEMPLATE); await waitForIframes(); expect(isInDOM(document)).toBe(true); expect(isInDOM(document.body)).toBe(true); expect(isInDOM(document.head)).toBe(true); expect(isInDOM(document.documentElement)).toBe(true); const form = queryOne`form`; expect(isInDOM(form)).toBe(true); form.remove(); expect(isInDOM(form)).toBe(false); const paragraph = queryOne`:iframe p`; expect(isInDOM(paragraph)).toBe(true); paragraph.remove(); expect(isInDOM(paragraph)).toBe(false); }); test("isDisplayed", async () => { await mountForTest(FULL_HTML_TEMPLATE); expect(isDisplayed(document)).toBe(true); expect(isDisplayed(document.body)).toBe(true); expect(isDisplayed(document.head)).toBe(true); expect(isDisplayed(document.documentElement)).toBe(true); expect(isDisplayed("form")).toBe(true); expect(isDisplayed(".hidden")).toBe(false); expect(isDisplayed("body")).toBe(false); // not available from fixture }); test("isVisible", async () => { await mountForTest(FULL_HTML_TEMPLATE + ""); expect(isVisible(document)).toBe(true); expect(isVisible(document.body)).toBe(true); expect(isVisible(document.head)).toBe(false); expect(isVisible(document.documentElement)).toBe(true); expect(isVisible("form")).toBe(true); expect(isVisible("hoot-test-shadow-root:shadow input")).toBe(true); expect(isVisible(".hidden")).toBe(false); expect(isVisible("body")).toBe(false); // not available from fixture }); test("matchMedia", async () => { // Invalid syntax expect(matchMedia("aaaa").matches).toBe(false); expect(matchMedia("display-mode: browser").matches).toBe(false); // Does not exist expect(matchMedia("(a)").matches).toBe(false); expect(matchMedia("(a: b)").matches).toBe(false); // Defaults expect(matchMedia("(display-mode:browser)").matches).toBe(true); expect(matchMedia("(display-mode: standalone)").matches).toBe(false); expect(matchMedia("not (display-mode: standalone)").matches).toBe(true); expect(matchMedia("(prefers-color-scheme :light)").matches).toBe(true); expect(matchMedia("(prefers-color-scheme : dark)").matches).toBe(false); expect(matchMedia("not (prefers-color-scheme: dark)").matches).toBe(true); expect(matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true); expect(matchMedia("(prefers-reduced-motion: no-preference)").matches).toBe(false); // Touch feature expect(window.matchMedia("(pointer: coarse)").matches).toBe(false); expect(window.ontouchstart).toBe(undefined); mockTouch(true); expect(window.matchMedia("(pointer: coarse)").matches).toBe(true); expect(window.ontouchstart).not.toBe(undefined); }); test("waitFor: already in fixture", async () => { await mountForTest(FULL_HTML_TEMPLATE); waitFor(".title").then((el) => { expect.step(el.className); return el; }); expect.verifySteps([]); await animationFrame(); expect.verifySteps(["title"]); }); test("waitFor: rejects", async () => { await expect(waitFor("never", { timeout: 1 })).rejects.toThrow( `Could not find elements matching "never" within 1 milliseconds` ); }); test("waitFor: add new element", async () => { const el1 = document.createElement("div"); el1.className = "new-element"; const el2 = document.createElement("div"); el2.className = "new-element"; const promise = waitFor(".new-element").then((el) => { expect.step(el.className); return el; }); await animationFrame(); expect.verifySteps([]); getFixture().append(el1, el2); await expect(promise).resolves.toBe(el1); expect.verifySteps(["new-element"]); }); test("waitForNone: DOM empty", async () => { waitForNone(".title").then(() => expect.step("none")); expect.verifySteps([]); await animationFrame(); expect.verifySteps(["none"]); }); test("waitForNone: rejects", async () => { await mountForTest(FULL_HTML_TEMPLATE); await expect(waitForNone(".title", { timeout: 1 })).rejects.toThrow(); }); test("waitForNone: delete elements", async () => { await mountForTest(FULL_HTML_TEMPLATE); waitForNone(".title").then(() => expect.step("none")); expect(".title").toHaveCount(3); for (const title of queryAll(".title")) { expect.verifySteps([]); title.remove(); await animationFrame(); } expect.verifySteps(["none"]); }); describe("query", () => { test("native selectors", async () => { await mountForTest(FULL_HTML_TEMPLATE); expect(queryAll()).toEqual([]); for (const selector of [ "main", `.${"title"}`, `${"ul"}${" "}${`${"li"}`}`, ".title", "ul > li", "form:has(.title:not(.haha)):not(.huhu) input[name='email']:enabled", "[colspan='1']", ]) { expectSelector(selector).toEqualNodes(selector); } }); test("custom pseudo-classes", async () => { await mountForTest(FULL_HTML_TEMPLATE); await waitForIframes(); // :first, :last, :only & :eq expectSelector(".title:first").toEqualNodes(".title", { index: 0 }); expectSelector(".title:last").toEqualNodes(".title", { index: -1 }); expectSelector(".title:eq(-1)").toEqualNodes(".title", { index: -1 }); expectSelector("main:only").toEqualNodes("main"); expectSelector(".title:only").toEqualNodes(""); expectSelector(".title:eq(1)").toEqualNodes(".title", { index: 1 }); expectSelector(".title:eq('1')").toEqualNodes(".title", { index: 1 }); expectSelector('.title:eq("1")').toEqualNodes(".title", { index: 1 }); // :contains (text) expectSelector("main > .text:contains(ipsum)").toEqualNodes("p"); expectSelector(".text:contains(/\\bL\\w+\\b\\sipsum/)").toEqualNodes("p"); expectSelector(".text:contains(item)").toEqualNodes("li"); // :contains (value) expectSelector("input:value(john)").toEqualNodes("[name=name],[name=email]"); expectSelector("input:value(john doe)").toEqualNodes("[name=name]"); expectSelector("input:value('John Doe (JOD)')").toEqualNodes("[name=name]"); expectSelector(`input:value("(JOD)")`).toEqualNodes("[name=name]"); expectSelector("input:value(johndoe)").toEqualNodes("[name=email]"); expectSelector("select:value(mr)").toEqualNodes("[name=title]"); expectSelector("select:value(unknown value)").toEqualNodes(""); // :selected expectSelector("option:selected").toEqualNodes( "select[name=title] option[value=mr],select[name=job] option:first-child" ); // :iframe expectSelector("iframe p:contains(iframe text content)").toEqualNodes(""); expectSelector("div:iframe p").toEqualNodes(""); expectSelector(":iframe p:contains(iframe text content)").toEqualNodes("p", { root: "iframe", }); }); test("advanced use cases", async () => { await mountForTest(FULL_HTML_TEMPLATE); // Comma-separated selectors expectSelector(":has(form:contains('Form title')),p:contains(ipsum)").toEqualNodes( "p,main" ); // :has & :not combinations with custom pseudo-classes expectSelector(`select:has(:contains(Employer))`).toEqualNodes("select[name=job]"); expectSelector(`select:not(:has(:contains(Employer)))`).toEqualNodes( "select[name=title]" ); expectSelector( `main:first-of-type:not(:has(:contains(This text does not exist))):contains('List header') > form:has([name="name"]):contains("Form title"):nth-child(6).overflow-auto:visible select[name=job] option:selected` ).toEqualNodes("select[name=job] option:first-child"); // :contains & commas expectSelector(`p:contains(velit,)`).toEqualNodes("p"); expectSelector(`p:contains('velit,')`).toEqualNodes("p"); expectSelector(`p:contains(", tristique")`).toEqualNodes("p"); expectSelector(`p:contains(/\\bvelit,/)`).toEqualNodes("p"); }); // Whatever, at this point I'm just copying failing selectors and creating // fake contexts accordingly as I'm fixing them. test("comma-separated long selector: no match", async () => { await mountForTest(/* xml */ `
idk
`); expectSelector( `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`, `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']` ).toEqualNodes(""); }); test("comma-separated long selector: match first", async () => { await mountForTest(/* xml */ `
`); expectSelector( `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`, `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']` ).toEqualNodes("we-toggler"); }); test("comma-separated long selector: match second", async () => { await mountForTest(/* xml */ `
idk
`); expectSelector( `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`, `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']` ).toEqualNodes("div[title]"); }); test("comma-separated :contains", async () => { await mountForTest(/* xml */ ` `); expectSelector( `.o_menu_sections .dropdown-item:contains('Products'), nav.o_burger_menu_content li[data-menu-xmlid='sale.menu_product_template_action']` ).toEqualNodes(".dropdown-item,li"); }); test(":contains with line return", async () => { await mountForTest(/* xml */ `
Matrix (PAV11, PAV22, PAV31)
PA4: PAV41
`); expectSelector( `span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")` ).toEqualNodes("span"); }); test(":has(...):first", async () => { await mountForTest(/* xml */ ` Conference for Architects TEST `); expectSelector( `a[href*="/event"]:contains("Conference for Architects TEST")` ).toEqualNodes("[target]"); expectSelector( `a[href*="/event"]:contains("Conference for Architects TEST"):first` ).toEqualNodes("[target]"); }); test(":eq", async () => { await mountForTest(/* xml */ `
  • a
  • b
  • c
`); expectSelector(`li:first:contains(a)`).toEqualNodes("li:nth-child(1)"); expectSelector(`li:contains(a):first`).toEqualNodes("li:nth-child(1)"); expectSelector(`li:first:contains(b)`).toEqualNodes(""); expectSelector(`li:contains(b):first`).toEqualNodes("li:nth-child(2)"); }); test(":empty", async () => { await mountForTest(/* xml */ ` `); expectSelector(`input:empty`).toEqualNodes(".empty"); expectSelector(`input:not(:empty)`).toEqualNodes(".value"); }); test("regular :contains", async () => { await mountForTest(/* xml */ ` `); expectSelector(`.website_links_click_chart .title:contains("1 clicks")`).toEqualNodes( ".title:nth-child(2)" ); }); test("other regular :contains", async () => { await mountForTest(/* xml */ ` `); expectSelector(`.ui-menu-item a:contains("Account Tax Group Partner")`).toEqualNodes( "ul li:first-child a" ); }); test(":iframe", async () => { await mountForTest(/* xml */ ` `); await waitForIframes(); expectSelector(`:iframe html`).toEqualNodes("html", { root: "iframe" }); expectSelector(`:iframe body`).toEqualNodes("body", { root: "iframe" }); expectSelector(`:iframe head`).toEqualNodes("head", { root: "iframe" }); }); test(":contains with brackets", async () => { await mountForTest(/* xml */ `
bbb [test_trigger] Mitchell Admin
`); expectSelector( `.o_content:has(.o_field_widget[name=messages]):has(td:contains(/^bbb$/)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin$/))` ).toEqualNodes(".o_content"); }); test(":eq in the middle of a selector", async () => { await mountForTest(/* xml */ `
`); expectSelector(`.oe_overlay.o_draggable:eq(2).oe_active`).toEqualNodes( "li:nth-child(3)" ); }); test("combinator +", async () => { await mountForTest(/* xml */ `
`); expectSelector( `form.js_attributes input:not(:checked) + label:contains(Steel - Test)` ).toEqualNodes("label"); }); test("multiple + combinators", async () => { await mountForTest(/* xml */ `

`); expectSelector(` .s_cover span.o_text_highlight:has( .o_text_highlight_item + br + .o_text_highlight_item ) `).toEqualNodes(".o_text_highlight"); }); test(":last", async () => { await mountForTest(/* xml */ `
`); expectSelector( `.o_field_widget[name=messages] .o_data_row td.o_list_record_remove button:visible:last` ).toEqualNodes(".o_data_row:last-child button"); }); test("select :contains & :value", async () => { await mountForTest(/* xml */ ` `); expectSelector(`.configurator_select:has(option:contains(Metal))`).toEqualNodes( "select" ); expectSelector(`.configurator_select:has(option:value(217))`).toEqualNodes("select"); expectSelector(`.configurator_select:has(option:value(218))`).toEqualNodes("select"); expectSelector(`.configurator_select:value(217)`).toEqualNodes("select"); expectSelector(`.configurator_select:value(218)`).toEqualNodes(""); expectSelector(`.configurator_select:value(Metal)`).toEqualNodes(""); }); test("invalid selectors", async () => { await mountForTest(FULL_HTML_TEMPLATE); expect(() => queryAll`[colspan=1]`).toThrow(); // missing quotes expect(() => queryAll`[href=/]`).toThrow(); // missing quotes expect( () => queryAll`_o_wblog_posts_loop:has(span:has(i.fa-calendar-o):has(a[href="/blog?search=a"])):has(span:has(i.fa-search):has(a[href^="/blog?date_begin"]))` ).toThrow(); // nested :has statements }); test("queryAllRects", async () => { await mountForTest(/* xml */ `
`); expect(queryAllRects("div")).toEqual( queryAll("div").map((el) => el.getBoundingClientRect()) ); expect(queryAllRects("div:first")).toEqual([new DOMRect({ width: 40, height: 60 })]); expect(queryAllRects("div:last")).toEqual([new DOMRect({ width: 20, height: 10 })]); }); test("queryAllTexts", async () => { await mountForTest(FULL_HTML_TEMPLATE); expect(queryAllTexts(".title")).toEqual(["Title", "List header", "Form title"]); expect(queryAllTexts("footer")).toEqual(["FooterBack to top"]); }); test("queryOne", async () => { await mountForTest(FULL_HTML_TEMPLATE); expect(queryOne(".title:first")).toBe(getFixture().querySelector("header .title")); expect(() => queryOne(".title")).toThrow(); expect(() => queryOne(".title", { exact: 2 })).toThrow(); }); test("queryRect", async () => { await mountForTest(/* xml */ `
`); expect(".rect").toHaveRect(".container"); // same rect as parent expect(".rect").toHaveRect({ width: 40, height: 60 }); expect(queryRect(".rect")).toEqual(queryOne(".rect").getBoundingClientRect()); expect(queryRect(".rect")).toEqual(new DOMRect({ width: 40, height: 60 })); }); test("queryRect with trimPadding", async () => { await mountForTest(/* xml */ `
`); expect("div").toHaveRect({ width: 50, height: 70 }); // with padding expect("div").toHaveRect({ width: 40, height: 60 }, { trimPadding: true }); }); test.skip("performance against jQuery", async () => { const jQuery = globalThis.$; const time = (fn) => { const start = performance.now(); fn(); return Number((performance.now() - start).toFixed(3)); }; const testCases = [ [ FULL_HTML_TEMPLATE, `main:first-of-type:not(:has(:contains(This text does not exist))):contains('List header') > form:has([name="name"]):contains("Form title"):nth-child(6).overflow-auto:visible select[name=job] option:selected`, ], [ /* html */ `
`, `.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`, ], ]; for (const [template, selector] of testCases) { const jQueryTimes = []; const queryAllTimes = []; for (let i = 0; i < 100; i++) { mountForTest(template); jQueryTimes.push(time(() => jQuery(selector))); queryAllTimes.push(time(() => queryAll(selector))); } const jQueryAvg = jQueryTimes.reduce((a, b) => a + b, 0) / jQueryTimes.length; const queryAllAvg = queryAllTimes.reduce((a, b) => a + b, 0) / queryAllTimes.length; expect(queryAllAvg).toBeLessThan(jQueryAvg * 1.25); // 25% margin } }); }); });