/** @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)"
>
/
${tagName}>
`;
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 */ `
Available suites
${templateIncludeWidget("li")}
Available tags
${templateIncludeWidget("li")}
`;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component} */
export class HootSearch extends Component {
static components = { HootTagButton };
static props = {};
static template = xml`
`;
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!");
}
}
}