/** @odoo-module */ import { Component, useRef, useState, xml } from "@odoo/owl"; import { getActiveElement } from "@web/../lib/hoot-dom/helpers/dom"; import { isRegExpFilter, parseRegExp } from "@web/../lib/hoot-dom/hoot_dom_utils"; import { Suite } from "../core/suite"; import { Tag } from "../core/tag"; import { Test } from "../core/test"; import { EXCLUDE_PREFIX, refresh } from "../core/url"; import { INCLUDE_LEVEL, STORAGE, debounce, lookup, normalize, storageGet, storageSet, stringify, title, useWindowListener, } from "../hoot_utils"; import { HootTagButton } from "./hoot_tag_button"; /** * @typedef {{ * }} HootSearchProps * * @typedef {import("../core/config").SearchFilter} SearchFilter * * @typedef {import("../core/tag").Tag} Tag * * @typedef {import("../core/test").Test} Test */ //----------------------------------------------------------------------------- // Global //----------------------------------------------------------------------------- const { Math: { abs: $abs }, Object: { entries: $entries, values: $values }, } = globalThis; //----------------------------------------------------------------------------- // Internal //----------------------------------------------------------------------------- /** * @param {string} query */ const getPattern = (query) => { query = query.match(R_QUERY_CONTENT)[1]; return parseRegExp(normalize(query), { safe: true }); }; /** * /!\ Requires "job" and "category" to be in scope * * @param {string} tagName */ const templateIncludeWidget = (tagName) => /* xml */ ` <${tagName} class="flex items-center gap-1 cursor-pointer select-none" t-on-click.stop="() => this.toggleInclude(category, job.id)" >
/ `; const EMPTY_SUITE = new Suite(null, "…", []); const SECRET_SEQUENCE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65]; const R_QUERY_CONTENT = new RegExp(`^\\s*${EXCLUDE_PREFIX}?\\s*(.*)\\s*$`); const RESULT_LIMIT = 5; // Template parts, because 16 levels of indent is a bit much const TEMPLATE_FILTERS_AND_CATEGORIES = /* xml */ `
Start typing to show filters...

()

    ${templateIncludeWidget("li")}
    more items ...
`; const TEMPLATE_SEARCH_DASHBOARD = /* xml */ `

Recent searches

Available suites

    ${templateIncludeWidget("li")}

Available tags

    ${templateIncludeWidget("li")}
`; //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** @extends {Component} */ export class HootSearch extends Component { static components = { HootTagButton }; static props = {}; static template = xml`
${TEMPLATE_SEARCH_DASHBOARD} ${TEMPLATE_FILTERS_AND_CATEGORIES}
`; categories = ["suite", "test", "tag"]; useTextFilter = false; refresh = refresh; title = title; get useRegExp() { return isRegExpFilter(this.state.query.trim()); } get wrappedQuery() { const query = this.state.query.trim(); return this.useRegExp ? query : stringify(query); } updateSuggestions = debounce(() => { this.state.empty = this.isEmpty(); this.state.categories = this.findSuggestions(); this.state.showDropdown = true; }, 16); setup() { const { runner } = this.env; runner.beforeAll(() => { this.state.categories = this.findSuggestions(); this.state.empty = this.isEmpty(); }); runner.afterAll(() => this.focusSearchInput()); this.rootRef = useRef("root"); this.searchInputRef = useRef("search-input"); this.config = useState(runner.config); const query = this.config.filter || ""; this.state = useState({ categories: { /** @type {Suite[]} */ suite: [], /** @type {Tag[]} */ tag: [], /** @type {Test[]} */ test: [], }, disabled: false, empty: !query.trim(), query, showDropdown: false, }); this.runnerState = useState(runner.state); useWindowListener( "click", (ev) => { if (this.runnerState.status !== "running") { const shouldOpen = ev.composedPath().includes(this.rootRef.el); if (shouldOpen && !this.state.showDropdown) { this.updateSuggestions(); } this.state.showDropdown = shouldOpen; } }, { capture: true } ); } /** * @param {string} query * @param {Map} items * @param {SearchFilter} category */ filterItems(query, items, category) { const checked = this.runnerState.includeSpecs[category]; const result = []; const remaining = []; let checkedCount = 0; for (const item of items.values()) { const value = $abs(checked[item.id]); if (value === INCLUDE_LEVEL.url) { result.push(item); checkedCount++; } else { remaining.push(item); } } const matching = lookup(query, remaining); result.push(...matching.slice(0, RESULT_LIMIT)); return [result, checkedCount, matching.length - RESULT_LIMIT]; } findSuggestions() { const { suites, tags, tests } = this.env.runner; const pattern = getPattern(this.state.query); return { suite: this.filterItems(pattern, suites, "suite"), tag: this.filterItems(pattern, tags, "tag"), test: this.filterItems(pattern, tests, "test"), }; } focusSearchInput() { this.searchInputRef.el?.focus(); } getCategoryCounts() { const { includeSpecs } = this.runnerState; const counts = []; for (const category of this.categories) { let include = 0; let exclude = 0; for (const value of $values(includeSpecs[category])) { switch (value) { case +INCLUDE_LEVEL.url: case +INCLUDE_LEVEL.tag: { include++; break; } case -INCLUDE_LEVEL.url: case -INCLUDE_LEVEL.tag: { exclude++; break; } } } if (include + exclude) { counts.push({ category, tip: `Remove all ${category}`, include, exclude }); } } return counts; } getHasIncludeValue() { return $values(this.runnerState.includeSpecs).some((values) => $values(values).some((value) => value > 0) ); } getLatestSearches() { return storageGet(STORAGE.searches) || []; } /** * * @param {(Suite | Test)[]} path */ getShortPath(path) { if (path.length <= 3) { return path.slice(0, -1); } else { return [path.at(0), EMPTY_SUITE, path.at(-2)]; } } /** * @param {Iterable} items */ getTop(items) { return [...items].sort((a, b) => b.weight - a.weight).slice(0, 5); } isEmpty() { return !( this.state.query.trim() || $values(this.runnerState.includeSpecs).some((values) => $values(values).some((value) => $abs(value) === INCLUDE_LEVEL.url) ) ); } /** * @param {number} value */ isReadonly(value) { return $abs(value) > INCLUDE_LEVEL.url; } /** * @param {unknown} item */ isTag(item) { return item instanceof Tag; } /** * @param {SearchFilter} categoryId * @param {string} id * @param {"exclude" | "include"} value */ onIncludeChange(categoryId, id, value) { if (value === "include" || value === "exclude") { this.setInclude( categoryId, id, value === "include" ? +INCLUDE_LEVEL.url : -INCLUDE_LEVEL.url ); } else { this.setInclude(categoryId, id, 0); } } /** * @param {KeyboardEvent} ev */ onKeyDown(ev) { /** * @param {number} inc */ const navigate = (inc) => { ev.preventDefault(); const elements = [ this.searchInputRef.el, ...this.rootRef.el.querySelectorAll("input[type=radio]:checked:enabled"), ]; let nextIndex = elements.indexOf(getActiveElement(document)) + inc; if (nextIndex >= elements.length) { nextIndex = 0; } else if (nextIndex < -1) { nextIndex = -1; } elements.at(nextIndex).focus(); }; switch (ev.key) { case "Escape": { if (this.state.showDropdown) { ev.preventDefault(); this.state.showDropdown = false; } return; } case "ArrowDown": { return navigate(+1); } case "ArrowUp": { return navigate(-1); } } } /** * @param {KeyboardEvent} ev */ onRegExpKeyDown(ev) { switch (ev.key) { case "Enter": case " ": { ev.preventDefault(); this.toggleRegExp(); break; } } } onSearchInputChange() { if (!this.state.query) { return; } const latestSearches = this.getLatestSearches(); latestSearches.unshift(this.state.query); storageSet(STORAGE.searches, [...new Set(latestSearches)].slice(0, 5)); } /** * @param {InputEvent & { currentTarget: HTMLInputElement }} ev */ onSearchInputInput(ev) { this.state.query = ev.currentTarget.value; this.env.ui.resultsPage = 0; this.updateFilterParam(); this.updateSuggestions(); } /** * @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev */ onSearchInputKeyDown(ev) { switch (ev.key) { case "Backspace": { if (ev.currentTarget.selectionStart === 0 && ev.currentTarget.selectionEnd === 0) { this.uncheckLastCategory(); } break; } case "r": { if (ev.altKey) { this.toggleRegExp(); } break; } } if (this.config.fun) { this.verifySecretSequenceStep(ev); } } /** * @param {SearchFilter} categoryId * @param {string} id * @param {number} [value] */ setInclude(categoryId, id, value) { this.env.runner.include(categoryId, id, value); } /** * @param {string} query */ setQuery(query) { this.state.query = query; this.updateFilterParam(); this.updateSuggestions(); this.focusSearchInput(); } toggleDebug() { this.config.debugTest = !this.config.debugTest; } toggleRegExp() { const query = this.state.query.trim(); if (this.useRegExp) { this.state.query = query.slice(1, -1); } else { this.state.query = `/${query}/`; } this.updateFilterParam(); this.updateSuggestions(); } uncheckLastCategory() { for (const category of [...this.categories].reverse()) { let foundItemToUncheck = false; for (const [key, value] of $entries(this.runnerState.includeSpecs[category])) { if (this.isReadonly(value)) { continue; } foundItemToUncheck = true; this.setInclude(category, key, 0); } if (foundItemToUncheck) { return true; } } return false; } updateFilterParam() { this.useTextFilter = true; this.config.filter = this.state.query.trim(); this.config.suite = []; this.config.tag = []; this.config.test = []; } /** * @param {SearchFilter} categoryId * @param {string} id */ toggleInclude(categoryId, id) { const currentValue = this.runnerState.includeSpecs[categoryId][id]; if (this.isReadonly(currentValue)) { return; // readonly } if (currentValue > 0) { this.setInclude(categoryId, id, -INCLUDE_LEVEL.url); } else if (currentValue < 0) { this.setInclude(categoryId, id, 0); } else { this.setInclude(categoryId, id, +INCLUDE_LEVEL.url); } } /** * @param {KeyboardEvent} ev */ verifySecretSequenceStep(ev) { this.secretSequence ||= 0; if (ev.keyCode === SECRET_SEQUENCE[this.secretSequence]) { ev.stopPropagation(); ev.preventDefault(); this.secretSequence++; } else { this.secretSequence = 0; return; } if (this.secretSequence === SECRET_SEQUENCE.length) { this.secretSequence = 0; const { runner } = this.env; runner.stop(); runner.reporting.passed += runner.reporting.failed; runner.reporting.passed += runner.reporting.todo; runner.reporting.failed = 0; runner.reporting.todo = 0; for (const [, suite] of runner.suites) { suite.reporting.passed += suite.reporting.failed; suite.reporting.passed += suite.reporting.todo; suite.reporting.failed = 0; suite.reporting.todo = 0; } for (const [, test] of runner.tests) { test.config.todo = false; test.status = Test.PASSED; for (const result of test.results) { result.pass = true; result.currentErrors = []; for (const assertion of result.getEvents("assertion")) { assertion.pass = true; } } } this.__owl__.app.root.render(true); console.warn("Secret sequence activated: all tests pass!"); } } }