vanilla 19.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:49:46 +02:00
parent 991d2234ca
commit d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions

View file

@ -0,0 +1,185 @@
/** @odoo-module */
import { Component, useState, xml } from "@odoo/owl";
import { refresh, subscribeToURLParams } from "../core/url";
import { STORAGE, storageSet } from "../hoot_utils";
import { HootLink } from "./hoot_link";
/**
* @typedef {{
* }} HootButtonsProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
clearTimeout,
Object: { keys: $keys },
setTimeout,
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const DISABLE_TIMEOUT = 500;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootButtonsProps, import("../hoot").Environment>} */
export class HootButtons extends Component {
static components = { HootLink };
static props = {};
static template = xml`
<t t-set="isRunning" t-value="runnerState.status === 'running'" />
<t t-set="showAll" t-value="env.runner.hasRemovableFilter" />
<t t-set="showFailed" t-value="runnerState.failedIds.size" />
<t t-set="failedSuites" t-value="getFailedSuiteIds()" />
<div
class="${HootButtons.name} relative"
t-on-pointerenter="onPointerEnter"
t-on-pointerleave="onPointerLeave"
>
<div class="flex rounded gap-px overflow-hidden">
<button
type="button"
class="flex items-center bg-btn gap-2 px-2 py-1 transition-colors"
t-on-click.stop="onRunClick"
t-att-title="isRunning ? 'Stop (Esc)' : 'Run'"
t-att-disabled="state.disable"
>
<i t-attf-class="fa fa-{{ isRunning ? 'stop' : 'play' }}" />
<span t-esc="isRunning ? 'Stop' : 'Run'" />
</button>
<t t-if="showAll or showFailed">
<button
type="button"
class="bg-btn px-2 py-1 transition-colors animate-slide-left"
t-on-click.stop="onToggleClick"
>
<i class="fa fa-caret-down transition" t-att-class="{ 'rotate-180': state.open }" />
</button>
</t>
</div>
<t t-if="state.open">
<div
class="animate-slide-down w-fit absolute flex flex-col end-0 shadow rounded overflow-hidden shadow z-2"
>
<t t-if="showAll">
<HootLink class="'bg-btn p-2 whitespace-nowrap transition-colors'">
Run <strong>all</strong> tests
</HootLink>
</t>
<t t-if="showFailed">
<HootLink
ids="{ id: runnerState.failedIds }"
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
title="'Run failed tests'"
onClick="onRunFailedClick"
>
Run failed <strong>tests</strong>
</HootLink>
<HootLink
ids="{ id: failedSuites }"
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
title="'Run failed suites'"
onClick="onRunFailedClick"
>
Run failed <strong>suites</strong>
</HootLink>
</t>
</div>
</t>
</div>
`;
setup() {
const { runner } = this.env;
this.state = useState({
disable: false,
open: false,
});
this.runnerState = useState(runner.state);
this.disableTimeout = 0;
subscribeToURLParams(...$keys(runner.config));
}
getFailedSuiteIds() {
const { tests } = this.env.runner;
const suiteIds = [];
for (const id of this.runnerState.failedIds) {
const test = tests.get(id);
if (test && !suiteIds.includes(test.parent.id)) {
suiteIds.push(test.parent.id);
}
}
return suiteIds;
}
/**
* @param {PointerEvent} ev
*/
onPointerLeave(ev) {
if (ev.pointerType !== "mouse") {
return;
}
this.state.open = false;
}
/**
* @param {PointerEvent} ev
*/
onPointerEnter(ev) {
if (ev.pointerType !== "mouse") {
return;
}
if (!this.isRunning) {
this.state.open = true;
}
}
onRunClick() {
const { runner } = this.env;
switch (runner.state.status) {
case "done": {
refresh();
break;
}
case "ready": {
if (runner.config.manual) {
runner.manualStart();
} else {
refresh();
}
break;
}
case "running": {
runner.stop();
if (this.disableTimeout) {
clearTimeout(this.disableTimeout);
}
this.state.disable = true;
this.disableTimeout = setTimeout(
() => (this.state.disable = false),
DISABLE_TIMEOUT
);
break;
}
}
}
onRunFailedClick() {
storageSet(STORAGE.failed, []);
}
onToggleClick() {
this.state.open = !this.state.open;
}
}

View file

@ -0,0 +1,99 @@
/** @odoo-module */
import { reactive, useState } from "@odoo/owl";
import { getAllColors, getPreferredColorScheme } from "../../hoot-dom/hoot_dom_utils";
import { STORAGE, storageGet, storageSet } from "../hoot_utils";
/**
* @typedef {"dark" | "light"} ColorScheme
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { entries: $entries, keys: $keys },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/** @type {ColorScheme[]} */
const COLOR_SCHEMES = $keys(getAllColors()).filter((key) => key !== "default");
/** @type {ColorScheme} */
let defaultScheme = storageGet(STORAGE.scheme);
if (!COLOR_SCHEMES.includes(defaultScheme)) {
defaultScheme = getPreferredColorScheme();
storageSet(STORAGE.scheme, defaultScheme);
}
const colorChangedCallbacks = [
() => {
const { classList } = current.root;
classList.remove(...COLOR_SCHEMES);
classList.add(current.scheme);
},
];
const current = reactive(
{
/** @type {HTMLElement | null} */
root: null,
scheme: defaultScheme,
},
() => {
if (!current.root) {
return;
}
for (const callback of colorChangedCallbacks) {
callback(current.scheme);
}
}
);
current.root;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function generateStyleSheets() {
/** @type {Record<string, string>} */
const styles = {};
for (const [scheme, values] of $entries(getAllColors())) {
const content = [];
for (const [key, value] of $entries(values)) {
content.push(`--${key}:${value};`);
}
styles[scheme] = content.join("");
}
return styles;
}
export function getColorScheme() {
return current.scheme;
}
/**
* @param {(scheme: ColorScheme) => any} callback
*/
export function onColorSchemeChange(callback) {
colorChangedCallbacks.push(callback);
}
/**
* @param {HTMLElement | null} element
*/
export function setColorRoot(element) {
current.root = element;
}
export function toggleColorScheme() {
current.scheme = COLOR_SCHEMES.at(COLOR_SCHEMES.indexOf(current.scheme) - 1);
storageSet(STORAGE.scheme, current.scheme);
}
export function useColorScheme() {
return useState(current);
}

View file

@ -0,0 +1,381 @@
/** @odoo-module */
import { Component, useState, xml } from "@odoo/owl";
import { CONFIG_KEYS } from "../core/config";
import { LOG_LEVELS } from "../core/logger";
import { refresh } from "../core/url";
import { CASE_EVENT_TYPES, strictEqual } from "../hoot_utils";
import { generateSeed, internalRandom } from "../mock/math";
import { toggleColorScheme, useColorScheme } from "./hoot_colors";
import { HootCopyButton } from "./hoot_copy_button";
/**
* @typedef {"dark" | "light"} ColorScheme
*
* @typedef {{
* }} HootConfigMenuProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { entries: $entries, keys: $keys, values: $values },
} = globalThis;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootConfigMenuProps, import("../hoot").Environment>} */
export class HootConfigMenu extends Component {
static components = { HootCopyButton };
static props = {};
static template = xml`
<form class="contents" t-on-submit.prevent="refresh">
<h3 class="pb-1 border-b text-gray border-gray">Behavior</h3>
<t t-if="hasPresets()">
<div class="flex items-center gap-1">
<t t-set="hasCorrectViewPort" t-value="env.runner.checkPresetForViewPort()" />
<t t-set="highlightClass" t-value="hasCorrectViewPort ? 'text-primary' : 'text-amber'" />
<span class="me-auto">Preset</span>
<t t-foreach="env.runner.presets" t-as="presetKey" t-key="presetKey">
<t t-set="preset" t-value="env.runner.presets[presetKey]" />
<button
type="button"
class="border rounded transition-colors hover:bg-gray-300 dark:hover:bg-gray-700"
t-att-class="{ ['border-primary ' + highlightClass]: config.preset === presetKey }"
t-att-title="presetKey ? preset.label : 'No preset'"
t-on-click.stop="() => this.onPresetChange(presetKey)"
>
<i t-attf-class="fa w-5 h-5 {{ preset.icon or 'fa-ban' }}" />
</button>
</t>
</div>
</t>
<div
class="flex items-center gap-1"
title="Determines the order of the tests execution"
>
<span class="me-auto">Execution order</span>
<t t-foreach="executionOrders" t-as="order" t-key="order.value">
<button
type="button"
class="border rounded transition-colors hover:bg-gray-300 dark:hover:bg-gray-700"
t-att-class="{ 'text-primary border-primary': config.order === order.value }"
t-att-title="order.title"
t-on-click.stop="() => this.setExecutionOrder(order.value)"
>
<i class="fa w-5 h-5" t-att-class="{ [order.icon]: true }"/>
</button>
</t>
</div>
<t t-if="config.order === 'random'">
<small class="flex items-center p-1 pt-0 gap-1">
<span class="text-gray whitespace-nowrap ms-1">Seed:</span>
<input
type="text"
autofocus=""
class="w-full outline-none border-b border-primary px-1"
t-model.number="config.random"
/>
<button
type="button"
title="Generate new random seed"
t-on-click.stop="resetSeed"
>
<i class="fa fa-repeat" />
</button>
<HootCopyButton text="config.random.toString()" />
</small>
</t>
<label
class="flex items-center gap-3"
title="Sets test timeout value (in milliseconds)"
>
<span class="shrink-0">Test timeout</span>
<input
type="text"
class="outline-none border-b border-primary px-1 w-full"
t-model.number="config.timeout"
/>
</label>
<label
class="flex items-center gap-3"
title="Sets network delay (in milliseconds)"
>
<span class="shrink-0">Network delay</span>
<input
type="text"
class="outline-none border-b border-primary px-1 w-full"
t-model="config.networkDelay"
/>
</label>
<label
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title="Awaits user input before running the tests"
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
t-model="config.manual"
/>
<span>Run tests manually</span>
</label>
<label
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title="Re-run current tests and abort after a given amount of failed tests"
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
t-att-checked="config.bail"
t-on-change="onBailChange"
/>
<span>Bail</span>
</label>
<t t-if="config.bail">
<small class="flex items-center p-1 pt-0 gap-1">
<span class="text-gray whitespace-nowrap ms-1">Failed tests:</span>
<input
type="text"
autofocus=""
class="outline-none w-full border-b border-primary px-1"
t-model.number="config.bail"
/>
</small>
</t>
<label
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title="Controls the verbosity of the logs"
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
t-att-checked="config.loglevel"
t-on-change="onLogLevelChange"
/>
<span>Log level</span>
</label>
<t t-if="config.loglevel">
<small class="flex items-center p-1 pt-0 gap-1">
<span class="text-gray whitespace-nowrap ms-1">Level:</span>
<select
autofocus=""
class="outline-none w-full bg-base text-base border-b border-primary px-1"
t-model.number="config.loglevel"
>
<t t-foreach="LOG_LEVELS" t-as="level" t-key="level.value">
<option
t-att-value="level.value"
t-esc="level.label"
/>
</t>
</select>
</small>
</t>
<label
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title="Re-run current tests without catching any errors"
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
t-model="config.notrycatch"
/>
<span>No try/catch</span>
</label>
<!-- Display -->
<h3 class="mt-2 pb-1 border-b text-gray border-gray">Display</h3>
<div class="flex items-center gap-1">
<span class="me-auto">Events</span>
<t t-foreach="CASE_EVENT_TYPES" t-as="sType" t-key="sType">
<t t-set="isDisplayed" t-value="isEventDisplayed(sType)" />
<t t-set="eventColor" t-value="isDisplayed ? CASE_EVENT_TYPES[sType].color : 'gray'" />
<button
type="button"
t-attf-class="p-1 border-b-2 transition-color text-{{ eventColor }} border-{{ eventColor }}"
t-attf-title="{{ isDisplayed ? 'Hide' : 'Show' }} {{ sType }} events"
t-on-click.stop="(ev) => this.toggleEventType(ev, sType)"
>
<i class="fa" t-att-class="CASE_EVENT_TYPES[sType].icon" />
</button>
</t>
</div>
<button
type="button"
class="flex items-center gap-1"
t-on-click.stop="toggleSortResults"
>
<span class="me-auto">Sort by duration</span>
<span
class="flex items-center gap-1 transition-colors"
t-att-class="{ 'text-primary': uiState.sortResults }"
>
<t t-if="uiState.sortResults === 'asc'">
ascending
</t>
<t t-elif="uiState.sortResults === 'desc'">
descending
</t>
<t t-else="">
none
</t>
<i t-attf-class="fa fa-sort-numeric-{{ uiState.sortResults or 'desc' }}" />
</span>
</button>
<label
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title="Re-run current tests in headless mode (no UI)"
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
t-model="config.headless"
/>
<span>Headless</span>
</label>
<label
class="cursor-pointer flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title='Activates "incentives" to help you stay motivated'
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
t-model="config.fun"
/>
<span>Enable incentives</span>
</label>
<button
type="button"
class="flex items-center gap-1 hover:bg-gray-300 dark:hover:bg-gray-700"
title="Toggle the color scheme of the UI"
t-on-click.stop="toggleColorScheme"
>
<i t-attf-class="fa fa-{{ color.scheme === 'light' ? 'moon' : 'sun' }}-o w-4 h-4" />
Color scheme
</button>
<!-- Refresh button -->
<button
class="flex bg-btn justify-center rounded mt-1 p-1 transition-colors"
t-att-disabled="doesNotNeedRefresh()"
>
Apply and refresh
</button>
</form>
`;
CASE_EVENT_TYPES = CASE_EVENT_TYPES;
executionOrders = [
{ value: "fifo", title: "First in, first out", icon: "fa-sort-numeric-asc" },
{ value: "lifo", title: "Last in, first out", icon: "fa-sort-numeric-desc" },
{ value: "random", title: "Random", icon: "fa-random" },
];
LOG_LEVELS = $entries(LOG_LEVELS)
.filter(([, value]) => value)
.map(([label, value]) => ({ label, value }));
refresh = refresh;
toggleColorScheme = toggleColorScheme;
setup() {
const { runner, ui } = this.env;
this.color = useColorScheme();
this.config = useState(runner.config);
this.uiState = useState(ui);
}
doesNotNeedRefresh() {
return CONFIG_KEYS.every((key) =>
strictEqual(this.config[key], this.env.runner.initialConfig[key])
);
}
hasPresets() {
return $keys(this.env.runner.presets).filter(Boolean).length > 0;
}
/**
* @param {keyof CASE_EVENT_TYPES} sType
*/
isEventDisplayed(sType) {
return this.config.events & CASE_EVENT_TYPES[sType].value;
}
/**
* @param {Event & { currentTarget: HTMLInputElement }} ev
*/
onBailChange(ev) {
this.config.bail = ev.currentTarget.checked ? 1 : 0;
}
/**
* @param {Event & { currentTarget: HTMLInputElement }} ev
*/
onLogLevelChange(ev) {
this.config.loglevel = ev.currentTarget.checked ? LOG_LEVELS.suites : LOG_LEVELS.runner;
}
/**
* @param {string} presetId
*/
onPresetChange(presetId) {
this.config.preset = this.config.preset === presetId ? "" : presetId;
}
resetSeed() {
const newSeed = generateSeed();
this.config.random = newSeed;
internalRandom.seed = newSeed;
}
/**
* @param {"fifo" | "lifo" | "random"} order
*/
setExecutionOrder(order) {
this.config.order = order;
if (order === "random" && !this.config.random) {
this.resetSeed();
} else if (this.config.random) {
this.config.random = 0;
}
}
/**
* @param {PointerEvent} ev
* @param {import("../core/expect").CaseEventType} sType
*/
toggleEventType(ev, sType) {
const nType = CASE_EVENT_TYPES[sType].value;
if (this.config.events & nType) {
if (ev.altKey) {
this.config.events = 0;
} else {
this.config.events &= ~nType;
}
} else {
if (ev.altKey) {
// Aggregate all event types
this.config.events = $values(CASE_EVENT_TYPES).reduce((acc, t) => acc + t.value, 0);
} else {
this.config.events |= nType;
}
}
}
toggleSortResults() {
this.uiState.resultsPage = 0;
if (!this.uiState.sortResults) {
this.uiState.sortResults = "desc";
} else if (this.uiState.sortResults === "desc") {
this.uiState.sortResults = "asc";
} else {
this.uiState.sortResults = false;
}
}
}

View file

@ -0,0 +1,48 @@
/** @odoo-module */
import { Component, useState, xml } from "@odoo/owl";
import { copy, hasClipboard } from "../hoot_utils";
/**
* @typedef {{
* altText?: string;
* text: string;
* }} HootCopyButtonProps
*/
/** @extends {Component<HootCopyButtonProps, import("../hoot").Environment>} */
export class HootCopyButton extends Component {
static props = {
altText: { type: String, optional: true },
text: String,
};
static template = xml`
<t t-if="hasClipboard()">
<button
type="button"
class="text-gray-400 hover:text-gray-500"
t-att-class="{ 'text-emerald': state.copied }"
title="copy to clipboard"
t-on-click.stop="onClick"
>
<i class="fa fa-clipboard" />
</button>
</t>
`;
hasClipboard = hasClipboard;
setup() {
this.state = useState({ copied: false });
}
/**
* @param {PointerEvent} ev
*/
async onClick(ev) {
const text = ev.altKey && this.props.altText ? this.props.altText : this.props.text;
await copy(text);
this.state.copied = true;
}
}

View file

@ -0,0 +1,330 @@
/** @odoo-module */
import { Component, onWillRender, useEffect, useRef, useState, xml } from "@odoo/owl";
import { Test } from "../core/test";
import { refresh } from "../core/url";
import { formatTime, throttle } from "../hoot_utils";
import { HootConfigMenu } from "./hoot_config_menu";
import { HootTestPath } from "./hoot_test_path";
import { HootTestResult } from "./hoot_test_result";
const {
HTMLElement,
innerHeight,
innerWidth,
Math: { max: $max, min: $min },
Object: { assign: $assign },
} = globalThis;
const addWindowListener = window.addEventListener.bind(window);
const removeWindowListener = window.removeEventListener.bind(window);
const { addEventListener, removeEventListener } = HTMLElement.prototype;
/**
* @param {string} containerRefName
* @param {string} handleRefName
* @param {() => any} allowDrag
*/
function useMovable(containerRefName, handleRefName, allowDrag) {
function computeEffectDependencies() {
return [(currentContainer = containerRef.el), (currentHandle = handleRef.el)];
}
/**
* @param {PointerEvent} ev
*/
function drag(ev) {
if (!currentContainer || !isDragging) {
return;
}
ev.preventDefault();
const x = $max($min(maxX, ev.clientX - offsetX), 0);
const y = $max($min(maxY, ev.clientY - offsetY), 0);
$assign(currentContainer.style, {
left: `${x}px`,
top: `${y}px`,
});
}
/**
* @param {PointerEvent} [ev]
*/
function dragEnd(ev) {
if (!currentContainer || !isDragging) {
return;
}
isDragging = false;
ev?.preventDefault();
removeWindowListener("pointermove", throttledDrag);
removeWindowListener("pointerup", dragEnd);
}
/**
* @param {PointerEvent} ev
*/
function dragStart(ev) {
if (!currentContainer || !allowDrag()) {
return;
}
if (isDragging) {
dragEnd(ev);
} else {
ev.preventDefault();
}
isDragging = true;
addWindowListener("pointermove", throttledDrag);
addWindowListener("pointerup", dragEnd);
addWindowListener("keydown", dragEnd);
const { x, y, width, height } = currentContainer.getBoundingClientRect();
$assign(currentContainer.style, {
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
});
offsetX = ev.clientX - x;
offsetY = ev.clientY - y;
maxX = innerWidth - width;
maxY = innerHeight - height;
}
function effectCleanup() {
if (currentHandle) {
removeEventListener.call(currentHandle, "pointerdown", dragStart);
}
}
function onEffect() {
if (currentHandle) {
addEventListener.call(currentHandle, "pointerdown", dragStart);
}
return effectCleanup;
}
function resetPosition() {
currentContainer?.removeAttribute("style");
dragEnd();
}
const throttledDrag = throttle(drag);
const containerRef = useRef(containerRefName);
const handleRef = useRef(handleRefName);
/** @type {HTMLElement | null} */
let currentContainer = null;
/** @type {HTMLElement | null} */
let currentHandle = null;
let isDragging = false;
let maxX = 0;
let maxY = 0;
let offsetX = 0;
let offsetY = 0;
useEffect(onEffect, computeEffectDependencies);
return {
resetPosition,
};
}
/**
* @typedef {import("../core/expect").Assertion} Assertion
*
* @typedef {{
* test: Test;
* }} HootDebugToolBarProps
*
* @typedef {import("../core/expect").CaseResult} CaseResult
*/
/** @extends {Component<HootDebugToolBarProps, import("../hoot").Environment>} */
export class HootDebugToolBar extends Component {
static components = { HootConfigMenu, HootTestPath, HootTestResult };
static props = {
test: Test,
};
static template = xml`
<div
class="${HootDebugToolBar.name} absolute start-0 bottom-0 max-w-full max-h-full flex p-4 z-4"
t-att-class="{ 'w-full': state.open }"
t-ref="root"
>
<div class="flex flex-col w-full overflow-hidden rounded shadow bg-gray-200 dark:bg-gray-800">
<div class="flex items-center gap-2 px-2">
<i
class="fa fa-bug text-cyan p-2"
t-att-class="{ 'cursor-move': !state.open }"
t-ref="handle"
/>
<div class="flex gap-px rounded my-1 overflow-hidden min-w-fit">
<button
class="bg-btn px-2 py-1"
title="Exit debug mode (Ctrl + Esc)"
t-on-click.stop="exitDebugMode"
>
<i class="fa fa-sign-out" />
</button>
<t t-if="done">
<button
class="bg-btn px-2 py-1 animate-slide-left"
title="Restart test (F5)"
t-on-click.stop="refresh"
>
<i class="fa fa-refresh" />
</button>
</t>
</div>
<button
class="flex flex-1 items-center gap-1 truncate"
t-on-click.stop="toggleOpen"
title="Click to toggle details"
>
status:
<strong
t-attf-class="text-{{ info.className }}"
t-esc="info.status"
/>
<span class="hidden sm:flex items-center gap-1">
<span class="text-gray">-</span>
assertions:
<span class="contents text-emerald">
<strong t-esc="info.passed" />
passed
</span>
<t t-if="info.failed">
<span class="text-gray">/</span>
<span class="contents text-rose">
<strong t-esc="info.failed" />
failed
</span>
</t>
</span>
<span class="text-gray">-</span>
time:
<span
class="text-primary"
t-esc="formatTime(props.test.lastResults?.duration, 'ms')"
/>
</button>
<button class="p-2" t-on-click="toggleConfig">
<i class="fa fa-cog" />
</button>
</div>
<t t-if="state.open">
<div class="flex flex-col w-full sm:flex-row overflow-auto">
<HootTestResult open="'always'" test="props.test" t-key="done">
<HootTestPath canCopy="true" full="true" test="props.test" />
</HootTestResult>
<t t-if="state.configOpen">
<div class="flex flex-col gap-1 p-3 overflow-y-auto">
<HootConfigMenu />
</div>
</t>
</div>
</t>
</div>
</div>
`;
formatTime = formatTime;
refresh = refresh;
get done() {
return Boolean(this.runnerState.done.size); // subscribe to test being added as done
}
setup() {
this.runnerState = useState(this.env.runner.state);
this.state = useState({
configOpen: false,
open: false,
});
onWillRender(this.onWillRender.bind(this));
this.movable = useMovable("root", "handle", this.allowDrag.bind(this));
}
allowDrag() {
return !this.state.open;
}
exitDebugMode() {
const { runner } = this.env;
runner.config.debugTest = false;
runner.stop();
}
getInfo() {
const [status, className] = this.getStatus();
const [assertPassed, assertFailed] = this.groupAssertions(
this.props.test.lastResults?.getEvents("assertion")
);
return {
className,
status,
passed: assertPassed,
failed: assertFailed,
};
}
getStatus() {
if (this.props.test.lastResults) {
switch (this.props.test.status) {
case Test.PASSED:
return ["passed", "emerald"];
case Test.FAILED:
return ["failed", "rose"];
case Test.ABORTED:
return ["aborted", "amber"];
}
}
return ["running", "cyan"];
}
/**
* @param {Assertion[]} [assertions]
*/
groupAssertions(assertions) {
let passed = 0;
let failed = 0;
for (const assertion of assertions || []) {
if (assertion.pass) {
passed++;
} else {
failed++;
}
}
return [passed, failed];
}
onWillRender() {
this.info = this.getInfo();
}
toggleConfig() {
this.state.configOpen = !this.state.open || !this.state.configOpen;
if (this.state.configOpen && !this.state.open) {
this.state.open = true;
this.movable.resetPosition();
}
}
toggleOpen() {
this.state.open = !this.state.open;
if (this.state.open) {
this.movable.resetPosition();
}
}
}

View file

@ -0,0 +1,88 @@
/** @odoo-module */
import { Component, useRef, useState, xml } from "@odoo/owl";
import { useAutofocus, useHootKey, useWindowListener } from "../hoot_utils";
/**
* @typedef {{
* buttonClassName?: string:
* className?: string:
* slots: Record<string, any>;
* }} HootDropdownProps
*/
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootDropdownProps, import("../hoot").Environment>} */
export class HootDropdown extends Component {
static template = xml`
<div class="${HootDropdown.name} relative" t-att-class="props.className" t-ref="root">
<button
t-ref="toggler"
class="flex rounded p-2 transition-colors"
t-att-class="props.buttonClassName"
>
<t t-slot="toggler" open="state.open" />
</button>
<t t-if="state.open">
<div
class="
hoot-dropdown absolute animate-slide-down
flex flex-col end-0 p-3 gap-2
bg-base text-base mt-1 shadow rounded z-2"
>
<button class="fixed end-2 top-2 p-1 text-rose sm:hidden" t-on-click="() => state.open = false">
<i class="fa fa-times w-5 h-5" />
</button>
<t t-slot="menu" open="state.open" />
</div>
</t>
</div>
`;
static props = {
buttonClassName: { type: String, optional: true },
className: { type: String, optional: true },
slots: {
type: Object,
shape: {
toggler: Object,
menu: Object,
},
},
};
setup() {
this.rootRef = useRef("root");
this.togglerRef = useRef("toggler");
this.state = useState({
open: false,
});
useAutofocus(this.rootRef);
useHootKey(["Escape"], this.close);
useWindowListener(
"click",
(ev) => {
const path = ev.composedPath();
if (!path.includes(this.rootRef.el)) {
this.state.open = false;
} else if (path.includes(this.togglerRef.el)) {
this.state.open = !this.state.open;
}
},
{ capture: true }
);
}
/**
* @param {KeyboardEvent} ev
*/
close(ev) {
if (this.state.open) {
ev.preventDefault();
this.state.open = false;
}
}
}

View file

@ -0,0 +1,58 @@
/** @odoo-module */
import { Component, xml } from "@odoo/owl";
import { Job } from "../core/job";
import { Test } from "../core/test";
import { HootLink } from "./hoot_link";
/**
* @typedef {{
* hidden?: boolean;
* job: Job;
* }} HootJobButtonsProps
*/
/** @extends {Component<HootJobButtonsProps, import("../hoot").Environment>} */
export class HootJobButtons extends Component {
static components = { HootLink };
static props = {
hidden: { type: Boolean, optional: true },
job: Job,
};
static template = xml`
<t t-set="type" t-value="getType()" />
<div class="${HootJobButtons.name} items-center gap-1" t-att-class="props.hidden ? 'hidden' : 'flex'">
<HootLink
ids="{ id: props.job.id }"
class="'hoot-btn-link border border-primary text-emerald rounded transition-colors'"
title="'Run this ' + type + ' only'"
>
<i class="fa fa-play w-5 h-5" />
</HootLink>
<t t-if="type === 'test'">
<HootLink
ids="{ id: props.job.id }"
options="{ debug: true }"
class="'hoot-btn-link border border-primary text-emerald rounded transition-colors'"
title="'Run this ' + type + ' only in debug mode'"
>
<i class="fa fa-bug w-5 h-5" />
</HootLink>
</t>
<HootLink
ids="{ id: props.job.id }"
options="{ ignore: true }"
class="'hoot-btn-link border border-primary text-rose rounded transition-colors'"
title="'Ignore ' + type"
>
<i class="fa fa-ban w-5 h-5" />
</HootLink>
</div>
`;
getType() {
return this.props.job instanceof Test ? "test" : "suite";
}
}

View file

@ -0,0 +1,116 @@
/** @odoo-module */
import { Component, useState, xml } from "@odoo/owl";
import { FILTER_SCHEMA } from "../core/config";
import { createUrlFromId } from "../core/url";
import { ensureArray, INCLUDE_LEVEL } from "../hoot_utils";
/**
* @typedef {{
* class?: string;
* ids?: Record<import("../core/config").SearchFilter, string[]>;
* onClick?: (event: PointerEvent) => any;
* options?: import("../core/url").CreateUrlFromIdOptions;
* slots: { default: any };
* style?: string;
* target?: string;
* title?: string;
* }} HootLinkProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { entries: $entries },
} = globalThis;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Link component which computes its href lazily (i.e. on focus or pointerenter).
*
* @extends {Component<HootLinkProps, import("../hoot").Environment>}
*/
export class HootLink extends Component {
static template = xml`
<a
t-att-class="props.class"
t-att-href="state.href"
t-att-target="props.target"
t-att-title="props.title"
t-att-style="props.style"
t-on-click.stop="onClick"
t-on-focus="updateHref"
t-on-pointerenter="updateHref"
>
<t t-slot="default" />
</a>
`;
static props = {
class: { type: String, optional: true },
ids: {
type: Object,
values: [String, { type: Array, element: String }],
optional: true,
},
options: {
type: Object,
shape: {
debug: { type: Boolean, optional: true },
ignore: { type: Boolean, optional: true },
},
optional: true,
},
slots: {
type: Object,
shape: {
default: { type: Object, optional: true },
},
},
style: { type: String, optional: true },
target: { type: String, optional: true },
title: { type: String, optional: true },
};
setup() {
this.state = useState({ href: "#" });
}
/**
* @param {PointerEvent} ev
*/
onClick(ev) {
const { ids, options } = this.props;
if (ids && ev.altKey) {
const { includeSpecs } = this.env.runner.state;
let appliedFilter = false;
for (const [type, idOrIds] of $entries(ids)) {
if (!(type in FILTER_SCHEMA)) {
continue;
}
const targetValue = options?.ignore ? -INCLUDE_LEVEL.url : +INCLUDE_LEVEL.url;
for (const id of ensureArray(idOrIds)) {
const finalValue = includeSpecs[type][id] === targetValue ? 0 : targetValue;
this.env.runner.include(type, id, finalValue);
appliedFilter = true;
}
}
if (appliedFilter) {
ev.preventDefault();
}
} else {
this.props.onClick?.(ev);
}
}
updateHref() {
const { ids, options } = this.props;
const simplifiedIds = this.env.runner.simplifyUrlIds(ids);
this.state.href = createUrlFromId(simplifiedIds, options);
}
}

View file

@ -0,0 +1,43 @@
/** @odoo-module */
import { Component, xml } from "@odoo/owl";
/**
* @typedef {{
* logs: { error: number, warn: number };
* }} HootLogCountersProps
*/
/** @extends {Component<HootLogCountersProps, import("../hoot").Environment>} */
export class HootLogCounters extends Component {
static components = {};
static props = {
logs: {
type: Object,
shape: {
error: Number,
warn: Number,
},
},
};
static template = xml`
<t t-if="props.logs.error">
<span
class="flex items-center gap-1 text-rose"
t-attf-title="{{ props.logs.error }} error log(s) (check the console)"
>
<i class="fa fa-times-circle" />
<strong t-esc="props.logs.error" />
</span>
</t>
<t t-if="props.logs.warn">
<span
class="flex items-center gap-1 text-amber"
t-attf-title="{{ props.logs.warn }} warning log(s) (check the console)"
>
<i class="fa fa-exclamation-triangle" />
<strong t-esc="props.logs.warn" />
</span>
</t>
`;
}

View file

@ -0,0 +1,194 @@
/** @odoo-module */
import { Component, useState, xml } from "@odoo/owl";
import { createUrl, refresh } from "../core/url";
import { callHootKey, useHootKey, useWindowListener } from "../hoot_utils";
import { HootButtons } from "./hoot_buttons";
import { HootConfigMenu } from "./hoot_config_menu";
import { HootDebugToolBar } from "./hoot_debug_toolbar";
import { HootDropdown } from "./hoot_dropdown";
import { HootReporting } from "./hoot_reporting";
import { HootSearch } from "./hoot_search";
import { HootSideBar } from "./hoot_side_bar";
import { HootStatusPanel } from "./hoot_status_panel";
/**
* @typedef {{
* }} HootMainProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { setTimeout } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
// Indenpendant from Hoot style classes since it is not loaded in headless
const HEADLESS_CONTAINER_STYLE = [
"position: absolute",
"bottom: 0",
"inset-inline-start: 50%",
"transform: translateX(-50%)",
"display: flex",
"z-index: 4",
"margin-bottom: 1rem",
"padding-left: 1rem",
"padding-right: 1rem",
"padding-top: 0.5rem",
"padding-bottom: 0.5rem",
"gap: 0.5rem",
"white-space: nowrap",
"border-radius: 9999px",
"box-shadow: 2px 1px 5px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)",
"background-color: #e2e8f0",
].join(";");
const HEADLESS_LINK_STYLE = ["color: #714b67", "text-decoration: underline"].join(";");
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootMainProps, import("../hoot").Environment>} */
export class HootMain extends Component {
static components = {
HootButtons,
HootConfigMenu,
HootDebugToolBar,
HootDropdown,
HootReporting,
HootSearch,
HootSideBar,
HootStatusPanel,
};
static props = {};
static template = xml`
<t t-if="env.runner.headless">
<div style="${HEADLESS_CONTAINER_STYLE}">
Running in headless mode
<a style="${HEADLESS_LINK_STYLE}" t-att-href="createUrl({ headless: null })">
Run with UI
</a>
</div>
</t>
<t t-else="">
<main
class="${HootMain.name} flex flex-col w-full h-full bg-base relative"
t-att-class="{ 'hoot-animations': env.runner.config.fun }"
>
<header class="flex flex-col bg-gray-200 dark:bg-gray-800">
<nav class="hoot-controls py-1 px-2">
<h1
class="hoot-logo m-0 select-none"
title="Hierarchically Organized Odoo Tests"
>
<strong class="flex">HOOT</strong>
</h1>
<HootButtons />
<HootSearch />
<HootDropdown buttonClassName="'bg-btn'">
<t t-set-slot="toggler" t-slot-scope="dropdownState">
<i class="fa fa-cog transition" t-att-class="{ 'rotate-90': dropdownState.open }" />
</t>
<t t-set-slot="menu">
<HootConfigMenu />
</t>
</HootDropdown>
</nav>
</header>
<HootStatusPanel />
<div class="flex h-full overflow-y-auto">
<HootSideBar />
<HootReporting />
</div>
</main>
<t t-if="state.debugTest">
<HootDebugToolBar test="state.debugTest" />
</t>
</t>
`;
createUrl = createUrl;
escapeKeyPresses = 0;
setup() {
const { runner } = this.env;
this.state = useState({
debugTest: null,
});
runner.beforeAll(() => {
if (!runner.debug) {
return;
}
if (runner.debug === true) {
this.state.debugTest = runner.state.tests[0];
} else {
this.state.debugTest = runner.debug;
}
});
runner.afterAll(() => {
this.state.debugTest = null;
});
useWindowListener("resize", (ev) => this.onWindowResize(ev));
useWindowListener("keydown", callHootKey, { capture: true });
useHootKey(["Enter"], this.manualStart);
useHootKey(["Escape"], this.abort);
if (!runner.config.headless) {
useHootKey(["Alt", "d"], this.toggleDebug);
}
}
/**
* @param {KeyboardEvent} ev
*/
abort(ev) {
const { runner } = this.env;
this.escapeKeyPresses++;
setTimeout(() => this.escapeKeyPresses--, 500);
if (runner.state.status === "running" && this.escapeKeyPresses >= 2) {
ev.preventDefault();
runner.stop();
}
}
/**
* @param {KeyboardEvent} ev
*/
manualStart(ev) {
const { runner } = this.env;
if (runner.state.status !== "ready") {
return;
}
ev.preventDefault();
if (runner.config.manual) {
runner.manualStart();
} else {
refresh();
}
}
onWindowResize() {
this.env.runner.checkPresetForViewPort();
}
/**
* @param {KeyboardEvent} ev
*/
toggleDebug(ev) {
ev.preventDefault();
const { runner } = this.env;
runner.config.debugTest = !runner.config.debugTest;
}
}

View file

@ -0,0 +1,375 @@
/** @odoo-module */
import { Component, onWillRender, useState, xml } from "@odoo/owl";
import { Test } from "../core/test";
import { formatTime, parseQuery } from "../hoot_utils";
import { HootJobButtons } from "./hoot_job_buttons";
import { HootLogCounters } from "./hoot_log_counters";
import { HootTestPath } from "./hoot_test_path";
import { HootTestResult } from "./hoot_test_result";
/**
* @typedef {import("../core/test").Test} Test
*
* @typedef {{
* }} HootReportingProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Boolean } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {keyof import("../core/runner").Runner["state"]} varName
* @param {string} colorClassName
*/
const issueTemplate = (varName, colorClassName) => /* xml */ `
<t t-foreach="runnerState['${varName}']" t-as="key" t-key="key">
<t t-set="issue" t-value="runnerState['${varName}'][key]" />
<div
class="flex flex-col justify-center px-3 py-2 gap-2 border-gray border-b text-${colorClassName} bg-${colorClassName}-900"
t-att-title="issue.message"
>
<h3 class="flex items-center gap-1 whitespace-nowrap">
<span class="min-w-3 min-h-3 rounded-full bg-${colorClassName}" />
Global <t t-esc="issue.name" />
<span t-if="issue.count > 1">
(x<t t-esc="issue.count" />)
</span>:
<small class="ms-auto text-gray whitespace-nowrap italic font-normal">
stack trace available in the console
</small>
</h3>
<ul>
<t t-foreach="issue.message.split('\\n')" t-as="messagePart" t-key="messagePart_index">
<li class="truncate" t-esc="messagePart" />
</t>
</ul>
</div>
</t>`;
/**
* @param {Test} a
* @param {Test} b
*/
function sortByDurationAscending(a, b) {
return a.duration - b.duration;
}
/**
* @param {Test} a
* @param {Test} b
*/
function sortByDurationDescending(a, b) {
return b.duration - a.duration;
}
const COLORS = {
failed: "text-rose",
passed: "text-emerald",
skipped: "text-cyan",
todo: "text-purple",
};
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootReportingProps, import("../hoot").Environment>} */
export class HootReporting extends Component {
static components = {
HootLogCounters,
HootJobButtons,
HootTestPath,
HootTestResult,
};
static props = {};
static template = xml`
<div class="${HootReporting.name} flex-1 overflow-y-auto">
<!-- Errors -->
${issueTemplate("globalErrors", "rose")}
<!-- Warnings -->
${issueTemplate("globalWarnings", "amber")}
<!-- Test results -->
<t t-set="resultStart" t-value="uiState.resultsPage * uiState.resultsPerPage" />
<t t-foreach="filteredResults.slice(resultStart, resultStart + uiState.resultsPerPage)" t-as="result" t-key="result.id">
<HootTestResult
open="state.openTests.includes(result.test.id)"
test="result.test"
>
<div class="flex items-center gap-2 overflow-hidden">
<HootTestPath canCopy="true" showStatus="true" test="result.test" />
<HootLogCounters logs="result.test.logs" />
</div>
<div class="flex items-center ms-1 gap-2">
<small
class="whitespace-nowrap"
t-attf-class="text-{{ result.test.config.skip ? 'skip' : 'gray' }}"
>
<t t-if="result.test.config.skip">
skipped
</t>
<t t-else="">
<t t-if="result.test.status === Test.ABORTED">
aborted after
</t>
<t t-esc="formatTime(result.test.duration, 'ms')" />
</t>
</small>
<HootJobButtons job="result.test" />
</div>
</HootTestResult>
</t>
<!-- "No test" panel -->
<t t-if="!filteredResults.length">
<div class="flex items-center justify-center h-full">
<t t-set="message" t-value="getEmptyMessage()" />
<t t-if="message">
<em class="p-5 rounded bg-gray-200 dark:bg-gray-800 whitespace-nowrap text-gray">
No
<span
t-if="message.statusFilter"
t-att-class="message.statusFilterClassName"
t-esc="message.statusFilter"
/>
tests found
<t t-if="message.filter">
matching
<strong class="text-primary" t-esc="message.filter" />
</t>
<t t-if="message.selectedSuiteName">
in suite
<strong class="text-primary" t-esc="message.selectedSuiteName" />
</t>.
</em>
</t>
<t t-else="">
<div class="flex flex-col gap-3 p-5 rounded bg-gray-200 dark:bg-gray-800">
<h3 class="border-b border-gray pb-1">
<strong class="text-primary" t-esc="runnerReporting.tests" />
/
<span class="text-primary" t-esc="runnerState.tests.length" />
tests completed
</h3>
<ul class="flex flex-col gap-2">
<t t-if="runnerReporting.passed">
<li class="flex gap-1">
<button
class="flex items-center gap-1 text-emerald"
t-on-click.stop="() => this.filterResults('passed')"
>
<i class="fa fa-check-circle" />
<strong t-esc="runnerReporting.passed" />
</button>
tests passed
</li>
</t>
<t t-if="runnerReporting.failed">
<li class="flex gap-1">
<button
class="flex items-center gap-1 text-rose"
t-on-click.stop="() => this.filterResults('failed')"
>
<i class="fa fa-times-circle" />
<strong t-esc="runnerReporting.failed" />
</button>
tests failed
</li>
</t>
<t t-if="runnerReporting.skipped">
<li class="flex gap-1">
<button
class="flex items-center gap-1 text-cyan"
t-on-click.stop="() => this.filterResults('skipped')"
>
<i class="fa fa-pause-circle" />
<strong t-esc="runnerReporting.skipped" />
</button>
tests skipped
</li>
</t>
<t t-if="runnerReporting.todo">
<li class="flex gap-1">
<button
class="flex items-center gap-1 text-purple"
t-on-click.stop="() => this.filterResults('todo')"
>
<i class="fa fa-exclamation-circle" />
<strong t-esc="runnerReporting.todo" />
</button>
tests to do
</li>
</t>
</ul>
</div>
</t>
</div>
</t>
</div>
`;
Test = Test;
formatTime = formatTime;
setup() {
const { runner, ui } = this.env;
this.config = useState(runner.config);
this.runnerReporting = useState(runner.reporting);
this.runnerState = useState(runner.state);
this.state = useState({
/** @type {string[]} */
openGroups: [],
/** @type {string[]} */
openTests: [],
});
this.uiState = useState(ui);
const { showdetail } = this.config;
let didShowDetail = false;
runner.afterPostTest((test) => {
if (
showdetail &&
!(showdetail === "first-fail" && didShowDetail) &&
[Test.FAILED, Test.ABORTED].includes(test.status)
) {
didShowDetail = true;
this.state.openTests.push(test.id);
}
});
onWillRender(() => {
this.filteredResults = this.computeFilteredResults();
this.uiState.totalResults = this.filteredResults.length;
});
}
computeFilteredResults() {
const { selectedSuiteId, sortResults, statusFilter } = this.uiState;
const queryFilter = this.getQueryFilter();
const results = [];
for (const test of this.runnerState.done) {
let matchFilter = false;
switch (statusFilter) {
case "failed": {
matchFilter = !test.config.skip && test.results.some((r) => !r.pass);
break;
}
case "passed": {
matchFilter =
!test.config.todo && !test.config.skip && test.results.some((r) => r.pass);
break;
}
case "skipped": {
matchFilter = test.config.skip;
break;
}
case "todo": {
matchFilter = test.config.todo;
break;
}
default: {
matchFilter = Boolean(selectedSuiteId) || test.results.some((r) => !r.pass);
break;
}
}
if (matchFilter && selectedSuiteId) {
matchFilter = test.path.some((suite) => suite.id === selectedSuiteId);
}
if (matchFilter && queryFilter) {
matchFilter = queryFilter(test.key);
}
if (!matchFilter) {
continue;
}
results.push({
duration: test.lastResults?.duration,
status: test.status,
id: `test#${test.id}`,
test: test,
});
}
if (!sortResults) {
return results;
}
return results.sort(
sortResults === "asc" ? sortByDurationAscending : sortByDurationDescending
);
}
/**
* @param {typeof this.uiState.statusFilter} status
*/
filterResults(status) {
this.uiState.resultsPage = 0;
if (this.uiState.statusFilter === status) {
this.uiState.statusFilter = null;
} else {
this.uiState.statusFilter = status;
}
}
getEmptyMessage() {
const { selectedSuiteId, statusFilter } = this.uiState;
if (!statusFilter && !selectedSuiteId) {
return null;
}
return {
statusFilter,
statusFilterClassName: COLORS[statusFilter],
filter: this.config.filter,
selectedSuiteName: selectedSuiteId && this.env.runner.suites.get(selectedSuiteId).name,
};
}
getQueryFilter() {
const parsedQuery = parseQuery(this.config.filter || "");
if (!parsedQuery.length) {
return null;
}
return (key) =>
parsedQuery.every((qp) => {
const pass = qp.matchValue(key);
return qp.exclude ? !pass : pass;
});
}
/**
* @param {PointerEvent} ev
* @param {string} id
*/
toggleGroup(ev, id) {
const index = this.state.openGroups.indexOf(id);
if (ev.altKey) {
if (index in this.state.openGroups) {
this.state.openGroups = [];
} else {
this.state.openGroups = this.filteredResults
.filter((r) => r.suite)
.map((r) => r.suite.id);
}
} else {
if (index in this.state.openGroups) {
this.state.openGroups.splice(index, 1);
} else {
this.state.openGroups.push(id);
}
}
}
}

View file

@ -0,0 +1,890 @@
/** @odoo-module */
import { Component, onPatched, onWillPatch, useRef, useState, xml } from "@odoo/owl";
import { getActiveElement } from "@web/../lib/hoot-dom/helpers/dom";
import { R_REGEX, REGEX_MARKER } from "@web/../lib/hoot-dom/hoot_dom_utils";
import { Suite } from "../core/suite";
import { Tag } from "../core/tag";
import { Test } from "../core/test";
import { refresh } from "../core/url";
import {
debounce,
EXACT_MARKER,
INCLUDE_LEVEL,
lookup,
parseQuery,
R_QUERY_EXACT,
STORAGE,
storageGet,
storageSet,
stringify,
title,
useHootKey,
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
*/
function addExact(query) {
return EXACT_MARKER + query + EXACT_MARKER;
}
/**
* @param {string} query
*/
function addRegExp(query) {
return REGEX_MARKER + query + REGEX_MARKER;
}
/**
* @param {"suite" | "tag" | "test"} category
*/
function categoryToType(category) {
return category === "tag" ? category : "id";
}
/**
* @param {string} query
*/
function removeExact(query) {
return query.replaceAll(EXACT_MARKER, "");
}
/**
* @param {string} query
*/
function removeRegExp(query) {
return query.slice(1, -1);
}
/**
* /!\ Requires "job" and "category" to be in scope
*
* @param {string} tagName
*/
const templateIncludeWidget = (tagName) => /* xml */ `
<t t-set="type" t-value="category === 'tag' ? category : 'id'" />
<t t-set="includeStatus" t-value="runnerState.includeSpecs[type][job.id] or 0" />
<t t-set="readonly" t-value="isReadonly(includeStatus)" />
<${tagName}
class="flex items-center gap-1 cursor-pointer select-none"
t-on-click.stop="() => this.toggleInclude(type, job.id)"
>
<div
class="hoot-include-widget h-5 p-px flex items-center relative border rounded-full"
t-att-class="{
'border-gray': readonly,
'border-primary': !readonly,
'opacity-50': readonly,
}"
t-att-title="readonly and 'Cannot change because it depends on a tag modifier in the code'"
t-on-pointerup="focusSearchInput"
t-on-change="(ev) => this.onIncludeChange(type, job.id, ev.target.value)"
>
<input
type="radio"
class="w-4 h-4 cursor-pointer appearance-none"
t-att-title="!readonly and 'Exclude'"
t-att-disabled="readonly"
t-att-name="job.id" value="exclude"
t-att-checked="includeStatus lt 0"
/>
<input
type="radio"
class="w-4 h-4 cursor-pointer appearance-none"
t-att-disabled="readonly"
t-att-name="job.id" value="null"
t-att-checked="!includeStatus"
/>
<input
type="radio"
class="w-4 h-4 cursor-pointer appearance-none"
t-att-title="!readonly and 'Include'"
t-att-disabled="readonly"
t-att-name="job.id" value="include"
t-att-checked="includeStatus gt 0"
/>
</div>
<t t-if="isTag(job)">
<HootTagButton tag="job" inert="true" />
</t>
<t t-else="">
<span
class="flex items-center font-bold whitespace-nowrap overflow-hidden"
t-att-title="job.fullName"
>
<t t-foreach="getShortPath(job.path)" t-as="suite" t-key="suite.id">
<span class="text-gray px-1" t-esc="suite.name" />
<span class="font-normal">/</span>
</t>
<t t-set="isSet" t-value="job.id in runnerState.includeSpecs.id" />
<span
class="truncate px-1"
t-att-class="{
'font-extrabold': isSet,
'text-emerald': includeStatus gt 0,
'text-rose': includeStatus lt 0,
'text-gray': !isSet and hasIncludeValue,
'text-primary': !isSet and !hasIncludeValue,
'italic': hasIncludeValue ? includeStatus lte 0 : includeStatus lt 0,
}"
t-esc="job.name"
/>
</span>
</t>
</${tagName}>
`;
/**
*
* @param {ReturnType<typeof useRef<HTMLInputElement>>} ref
*/
function useKeepSelection(ref) {
/**
* @param {number} nextOffset
*/
function keepSelection(nextOffset) {
offset = nextOffset || 0;
}
let offset = null;
let start = 0;
let end = 0;
onWillPatch(() => {
if (offset === null || !ref.el) {
return;
}
start = ref.el.selectionStart;
end = ref.el.selectionEnd;
});
onPatched(() => {
if (offset === null || !ref.el) {
return;
}
ref.el.selectionStart = start + offset;
ref.el.selectionEnd = end + offset;
offset = null;
});
return keepSelection;
}
const EMPTY_SUITE = new Suite(null, "…", []);
const SECRET_SEQUENCE = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
const RESULT_LIMIT = 5;
// Template parts, because 16 levels of indent is a bit much
const TEMPLATE_FILTERS_AND_CATEGORIES = /* xml */ `
<div class="flex mb-2">
<t t-if="trimmedQuery">
<button
class="flex items-center gap-1"
type="submit"
title="Run this filter"
t-on-pointerdown="updateFilterParam"
>
<h4 class="text-primary m-0">
Filter using
<t t-if="hasRegExpFilter()">
regular expression
</t>
<t t-else="">
text
</t>
</h4>
<t t-esc="wrappedQuery()" />
</button>
</t>
<t t-else="">
<em class="text-gray ms-1">
Start typing to show filters...
</em>
</t>
</div>
<t t-foreach="categories" t-as="category" t-key="category">
<t t-set="jobs" t-value="state.categories[category][0]" />
<t t-set="remainingCount" t-value="state.categories[category][1]" />
<t t-if="jobs?.length">
<div class="flex flex-col mb-2 max-h-48 overflow-hidden">
<h4
class="text-primary font-bold flex items-center mb-2"
t-esc="title(category)"
/>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="jobs" t-as="job" t-key="job.id">
${templateIncludeWidget("li")}
</t>
<t t-if="remainingCount > 0">
<div class="italic">
<t t-esc="remainingCount" /> more items ...
</div>
</t>
</ul>
</div>
</t>
</t>
`;
const TEMPLATE_SEARCH_DASHBOARD = /* xml */ `
<div class="flex flex-col gap-4 sm:grid sm:grid-cols-3 sm:gap-0">
<div class="flex flex-col sm:px-4">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
Recent searches
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="getLatestSearches()" t-as="text" t-key="text_index">
<li>
<button
class="w-full px-2 hover:bg-gray-300 dark:hover:bg-gray-700"
type="button"
t-on-click.stop="() => this.setQuery(text)"
t-esc="text"
/>
</li>
</t>
</ul>
</div>
<div class="flex flex-col sm:px-4 border-gray sm:border-x">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
Available suites
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="getTop(env.runner.rootSuites)" t-as="job" t-key="job.id">
<t t-set="category" t-value="'suite'" />
${templateIncludeWidget("li")}
</t>
</ul>
</div>
<div class="flex flex-col sm:px-4">
<h4 class="text-primary font-bold flex items-center mb-2">
<span class="w-full">
Available tags
</span>
</h4>
<ul class="flex flex-col overflow-y-auto gap-1">
<t t-foreach="getTop(env.runner.tags.values())" t-as="job" t-key="job.id">
<t t-set="category" t-value="'tag'" />
${templateIncludeWidget("li")}
</t>
</ul>
</div>
</div>
`;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootSearchProps, import("../hoot").Environment>} */
export class HootSearch extends Component {
static components = { HootTagButton };
static props = {};
static template = xml`
<t t-set="hasIncludeValue" t-value="getHasIncludeValue()" />
<t t-set="isRunning" t-value="runnerState.status === 'running'" />
<search class="${HootSearch.name} flex-1" t-ref="root" t-on-keydown="onKeyDown">
<form class="relative" t-on-submit.prevent="refresh">
<div class="hoot-search-bar flex border rounded items-center bg-base px-1 gap-1 w-full transition-colors">
<t t-foreach="getCategoryCounts()" t-as="count" t-key="count.category">
<button
type="button"
class="flex border border-primary rounded"
t-att-title="count.tip"
>
<span class="bg-btn px-1 transition-colors" t-esc="count.category" />
<span class="mx-1 flex gap-1">
<t t-if="count.include.length">
<span class="text-emerald" t-esc="count.include.length" />
</t>
<t t-if="count.exclude.length">
<span class="text-rose" t-esc="count.exclude.length" />
</t>
</span>
</button>
</t>
<input
type="search"
class="w-full rounded p-1 outline-none"
autofocus="autofocus"
placeholder="Filter suites, tests or tags"
t-ref="search-input"
t-att-class="{ 'text-gray': !config.filter }"
t-att-disabled="isRunning"
t-att-value="state.query"
t-on-change="onSearchInputChange"
t-on-input="onSearchInputInput"
t-on-keydown="onSearchInputKeyDown"
/>
<label
class="hoot-search-icon cursor-pointer p-1"
title="Use exact match (Alt + X)"
tabindex="0"
t-on-keydown="onExactKeyDown"
>
<input
type="checkbox"
class="hidden"
t-att-checked="hasExactFilter()"
t-att-disabled="isRunning"
t-on-change="toggleExact"
/>
<i class="fa fa-quote-right text-gray transition-colors" />
</label>
<label
class="hoot-search-icon cursor-pointer p-1"
title="Use regular expression (Alt + R)"
tabindex="0"
t-on-keydown="onRegExpKeyDown"
>
<input
type="checkbox"
class="hidden"
t-att-checked="hasRegExpFilter()"
t-att-disabled="isRunning"
t-on-change="toggleRegExp"
/>
<i class="fa fa-asterisk text-gray transition-colors" />
</label>
<label
class="hoot-search-icon cursor-pointer p-1"
title="Debug mode (Alt + D)"
t-on-keydown="onDebugKeyDown"
>
<input
type="checkbox"
class="hidden"
t-att-checked="config.debugTest"
t-att-disabled="isRunning"
t-on-change="toggleDebug"
/>
<i class="fa fa-bug text-gray transition-colors" />
</label>
</div>
<t t-if="state.showDropdown">
<div class="hoot-dropdown-lg flex flex-col animate-slide-down bg-base text-base absolute mt-1 p-3 shadow rounded z-2">
<t t-if="state.empty">
${TEMPLATE_SEARCH_DASHBOARD}
</t>
<t t-else="">
${TEMPLATE_FILTERS_AND_CATEGORIES}
</t>
</div>
</t>
</form>
</search>
`;
categories = ["suite", "test", "tag"];
debouncedUpdateSuggestions = debounce(this.updateSuggestions.bind(this), 16);
refresh = refresh;
title = title;
get trimmedQuery() {
return this.state.query.trim();
}
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);
useHootKey(["Alt", "r"], this.toggleRegExp);
useHootKey(["Alt", "x"], this.toggleExact);
useHootKey(["Escape"], this.closeDropdown);
useWindowListener(
"click",
(ev) => {
if (this.runnerState.status !== "running") {
const shouldOpen = ev.composedPath().includes(this.rootRef.el);
if (shouldOpen && !this.state.showDropdown) {
this.debouncedUpdateSuggestions();
}
this.state.showDropdown = shouldOpen;
}
},
{ capture: true }
);
this.keepSelection = useKeepSelection(this.searchInputRef);
}
/**
* @param {KeyboardEvent} ev
*/
closeDropdown(ev) {
if (!this.state.showDropdown) {
return;
}
ev.preventDefault();
this.state.showDropdown = false;
}
/**
* @param {string} parsedQuery
* @param {Map<string, Suite | Tag | Test>} items
* @param {SearchFilter} category
*/
filterItems(parsedQuery, items, category) {
const checked = this.runnerState.includeSpecs[category];
const result = [];
const remaining = [];
for (const item of items.values()) {
const value = $abs(checked[item.id]);
if (value === INCLUDE_LEVEL.url) {
result.push(item);
} else {
remaining.push(item);
}
}
const matching = lookup(parsedQuery, remaining);
result.push(...matching.slice(0, RESULT_LIMIT));
return [result, matching.length - RESULT_LIMIT];
}
findSuggestions() {
const { suites, tags, tests } = this.env.runner;
const parsedQuery = parseQuery(this.trimmedQuery);
return {
suite: this.filterItems(parsedQuery, suites, "id"),
tag: this.filterItems(parsedQuery, tags, "tag"),
test: this.filterItems(parsedQuery, tests, "id"),
};
}
focusSearchInput() {
this.searchInputRef.el?.focus();
}
getCategoryCounts() {
const { includeSpecs } = this.runnerState;
const { suites, tests } = this.env.runner;
const counts = [];
for (const category of this.categories) {
const include = [];
const exclude = [];
for (const [id, value] of $entries(includeSpecs[categoryToType(category)])) {
if (
(category === "suite" && !suites.has(id)) ||
(category === "test" && !tests.has(id))
) {
continue;
}
switch (value) {
case +INCLUDE_LEVEL.url:
case +INCLUDE_LEVEL.tag: {
include.push(id);
break;
}
case -INCLUDE_LEVEL.url:
case -INCLUDE_LEVEL.tag: {
exclude.push(id);
break;
}
}
}
if (include.length || exclude.length) {
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<Suite | Tag>} items
*/
getTop(items) {
return [...items].sort((a, b) => b.weight - a.weight).slice(0, 5);
}
hasExactFilter(query = this.trimmedQuery) {
R_QUERY_EXACT.lastIndex = 0;
return R_QUERY_EXACT.test(query);
}
hasRegExpFilter(query = this.trimmedQuery) {
return R_REGEX.test(query);
}
isEmpty() {
return !(
this.trimmedQuery ||
$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 {number} inc
*/
navigate(inc) {
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();
}
/**
* @param {KeyboardEvent} ev
*/
onExactKeyDown(ev) {
switch (ev.key) {
case "Enter":
case " ": {
this.toggleExact(ev);
break;
}
}
}
/**
* @param {SearchFilter} type
* @param {string} id
* @param {"exclude" | "include"} value
*/
onIncludeChange(type, id, value) {
if (value === "include" || value === "exclude") {
this.setInclude(
type,
id,
value === "include" ? +INCLUDE_LEVEL.url : -INCLUDE_LEVEL.url
);
} else {
this.setInclude(type, id, 0);
}
}
/**
* @param {KeyboardEvent} ev
*/
onKeyDown(ev) {
switch (ev.key) {
case "ArrowDown": {
ev.preventDefault();
return this.navigate(+1);
}
case "ArrowUp": {
ev.preventDefault();
return this.navigate(-1);
}
case "Enter": {
return refresh();
}
}
}
/**
* @param {KeyboardEvent} ev
*/
onRegExpKeyDown(ev) {
switch (ev.key) {
case "Enter":
case " ": {
this.toggleRegExp(ev);
break;
}
}
}
onSearchInputChange() {
if (!this.trimmedQuery) {
return;
}
const latestSearches = this.getLatestSearches();
latestSearches.unshift(this.trimmedQuery);
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.debouncedUpdateSuggestions();
}
/**
* @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev
*/
onSearchInputKeyDown(ev) {
switch (ev.key) {
case "Backspace": {
if (ev.currentTarget.selectionStart === 0 && ev.currentTarget.selectionEnd === 0) {
this.uncheckLastCategory();
}
break;
}
}
if (this.config.fun) {
this.verifySecretSequenceStep(ev);
}
}
/**
* @param {SearchFilter} type
* @param {string} id
* @param {number} [value]
*/
setInclude(type, id, value) {
this.config.filter = "";
this.env.runner.include(type, id, value);
}
/**
* @param {string} query
*/
setQuery(query) {
this.state.query = query;
this.updateFilterParam();
this.updateSuggestions();
this.focusSearchInput();
}
toggleDebug() {
this.config.debugTest = !this.config.debugTest;
}
/**
* @param {Event} ev
*/
toggleExact(ev) {
ev.preventDefault();
const currentQuery = this.trimmedQuery;
let query = currentQuery;
if (this.hasRegExpFilter(query)) {
query = removeRegExp(query);
}
if (this.hasExactFilter(query)) {
query = removeExact(query);
} else {
query = addExact(query);
}
this.keepSelection((query.length - currentQuery.length) / 2);
this.setQuery(query);
}
/**
* @param {SearchFilter} type
* @param {string} id
*/
toggleInclude(type, id) {
const currentValue = this.runnerState.includeSpecs[type][id];
if (this.isReadonly(currentValue)) {
return; // readonly
}
if (currentValue > 0) {
this.setInclude(type, id, -INCLUDE_LEVEL.url);
} else if (currentValue < 0) {
this.setInclude(type, id, 0);
} else {
this.setInclude(type, id, +INCLUDE_LEVEL.url);
}
}
/**
* @param {Event} ev
*/
toggleRegExp(ev) {
ev.preventDefault();
const currentQuery = this.trimmedQuery;
let query = currentQuery;
if (this.hasExactFilter(query)) {
query = removeExact(query);
}
if (this.hasRegExpFilter(query)) {
query = removeRegExp(query);
} else {
query = addRegExp(query);
}
this.keepSelection((query.length - currentQuery.length) / 2);
this.setQuery(query);
}
uncheckLastCategory() {
for (const count of this.getCategoryCounts().reverse()) {
const type = categoryToType(count.category);
const includeSpecs = this.runnerState.includeSpecs[type];
for (const id of [...count.exclude, ...count.include]) {
const value = includeSpecs[id];
if (this.isReadonly(value)) {
continue;
}
this.setInclude(type, id, 0);
return true;
}
}
return false;
}
updateFilterParam() {
this.config.filter = this.trimmedQuery;
}
updateSuggestions() {
this.state.empty = this.isEmpty();
this.state.categories = this.findSuggestions();
this.state.showDropdown = true;
}
/**
* @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!");
}
}
wrappedQuery(query = this.trimmedQuery) {
return this.hasRegExpFilter(query) ? query : stringify(query);
}
}

View file

@ -0,0 +1,458 @@
/** @odoo-module */
import { Component, onWillRender, useEffect, useRef, useState, xml } from "@odoo/owl";
import { Suite } from "../core/suite";
import { createUrlFromId } from "../core/url";
import { lookup, parseQuery } from "../hoot_utils";
import { HootJobButtons } from "./hoot_job_buttons";
/**
* @typedef {{
* multi?: number;
* name: string;
* hasSuites: boolean;
* reporting: import("../hoot_utils").Reporting;
* selected: boolean;
* unfolded: boolean;
* }} HootSideBarSuiteProps
*
* @typedef {{
* reporting: import("../hoot_utils").Reporting;
* statusFilter: import("./setup_hoot_ui").StatusFilter | null;
* }} HootSideBarCounterProps
*
* @typedef {{
* }} HootSideBarProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const { Boolean, location: actualLocation, Object, String } = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
const SUITE_CLASSNAME = "hoot-sidebar-suite";
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @extends {Component<HootSideBarSuiteProps, import("../hoot").Environment>}
*/
export class HootSideBarSuite extends Component {
static props = {
multi: { type: Number, optional: true },
name: String,
hasSuites: Boolean,
reporting: Object,
selected: Boolean,
unfolded: Boolean,
};
static template = xml`
<t t-if="props.hasSuites">
<i
class="fa fa-chevron-right text-xs transition"
t-att-class="{
'rotate-90': props.unfolded,
'opacity-25': !props.reporting.failed and !props.reporting.tests
}"
/>
</t>
<span t-ref="root" t-att-class="getClassName()" t-esc="props.name" />
<t t-if="props.multi">
<strong class="text-amber whitespace-nowrap me-1">
x<t t-esc="props.multi" />
</strong>
</t>
`;
setup() {
const rootRef = useRef("root");
let wasSelected = false;
useEffect(
(selected) => {
if (selected && !wasSelected) {
rootRef.el.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
wasSelected = selected;
},
() => [this.props.selected]
);
}
getClassName() {
const { reporting, selected } = this.props;
let className = "truncate transition";
if (reporting.failed) {
className += " text-rose";
} else if (!reporting.tests) {
className += " opacity-25";
}
if (selected) {
className += " font-bold";
}
return className;
}
}
/** @extends {Component<HootSideBarCounterProps, import("../hoot").Environment>} */
export class HootSideBarCounter extends Component {
static props = {
reporting: Object,
statusFilter: [String, { value: null }],
};
static template = xml`
<t t-set="info" t-value="getCounterInfo()" />
<span
t-attf-class="${HootSideBarCounter.name} {{ info[1] ? info[0] : 'text-gray' }} {{ info[1] ? 'font-bold' : '' }}"
t-esc="info[1]"
/>
`;
getCounterInfo() {
const { reporting, statusFilter } = this.props;
switch (statusFilter) {
case "failed":
return ["text-rose", reporting.failed];
case "passed":
return ["text-emerald", reporting.passed];
case "skipped":
return ["text-cyan", reporting.skipped];
case "todo":
return ["text-purple", reporting.todo];
default:
return ["text-primary", reporting.tests];
}
}
}
/**
* @extends {Component<HootSideBarProps, import("../hoot").Environment>}
*/
export class HootSideBar extends Component {
static components = { HootJobButtons, HootSideBarSuite, HootSideBarCounter };
static props = {};
static template = xml`
<div
class="${HootSideBar.name} flex-col w-64 h-full resize-x shadow bg-gray-200 dark:bg-gray-800 z-1 hidden md:flex"
t-on-click.stop="onClick"
>
<form class="flex p-2 items-center gap-1">
<div class="hoot-search-bar border rounded bg-base w-full">
<input
class="w-full rounded px-2 py-1 outline-none"
type="search"
placeholder="Search suites"
t-ref="search-input"
t-model="state.filter"
t-on-keydown="onSearchInputKeydown"
/>
</div>
<t t-if="env.runner.hasFilter">
<button
type="button"
class="text-primary p-1 transition-colors"
t-att-title="state.hideEmpty ? 'Show all suites' : 'Hide other suites'"
t-on-click.stop="toggleHideEmpty"
>
<i t-attf-class="fa fa-{{ state.hideEmpty ? 'eye' : 'eye-slash' }}" />
</button>
</t>
<t t-set="expanded" t-value="unfoldedIds.size === env.runner.suites.size" />
<button
type="button"
class="text-primary p-1 transition-colors"
t-attf-title="{{ expanded ? 'Collapse' : 'Expand' }} all"
t-on-click.stop="() => this.toggleExpand(expanded)"
>
<i t-attf-class="fa fa-{{ expanded ? 'compress' : 'expand' }}" />
</button>
</form>
<ul class="overflow-x-hidden overflow-y-auto" t-ref="suites-list">
<t t-foreach="filteredSuites" t-as="suite" t-key="suite.id">
<li class="flex items-center h-7 animate-slide-down">
<button
class="${SUITE_CLASSNAME} flex items-center w-full h-full gap-1 px-2 overflow-hidden hover:bg-gray-300 dark:hover:bg-gray-700"
t-att-class="{ 'bg-gray-300 dark:bg-gray-700': uiState.selectedSuiteId === suite.id }"
t-attf-style="margin-left: {{ (suite.path.length - 1) + 'rem' }};"
t-attf-title="{{ suite.fullName }}\n- {{ suite.totalTestCount }} tests\n- {{ suite.totalSuiteCount }} suites"
t-on-click.stop="(ev) => this.toggleItem(suite)"
t-on-keydown="(ev) => this.onSuiteKeydown(ev, suite)"
>
<div class="flex items-center truncate gap-1 flex-1">
<HootSideBarSuite
multi="suite.config.multi"
name="suite.name"
hasSuites="hasSuites(suite)"
reporting="suite.reporting"
selected="uiState.selectedSuiteId === suite.id"
unfolded="unfoldedIds.has(suite.id)"
/>
<span class="text-gray">
(<t t-esc="suite.totalTestCount" />)
</span>
</div>
<HootJobButtons hidden="true" job="suite" />
<t t-if="env.runner.state.suites.includes(suite)">
<HootSideBarCounter
reporting="suite.reporting"
statusFilter="uiState.statusFilter"
/>
</t>
</button>
</li>
</t>
</ul>
</div>
`;
filteredSuites = [];
runningSuites = new Set();
unfoldedIds = new Set();
setup() {
const { runner, ui } = this.env;
this.searchInputRef = useRef("search-input");
this.suitesListRef = useRef("suites-list");
this.uiState = useState(ui);
this.state = useState({
filter: "",
hideEmpty: false,
suites: [],
/** @type {Set<string>} */
unfoldedIds: new Set(),
});
runner.beforeAll(() => {
const singleRootSuite = runner.rootSuites.filter((suite) => suite.currentJobs.length);
if (singleRootSuite.length === 1) {
// Unfolds only root suite containing jobs
this.unfoldAndSelect(singleRootSuite[0]);
} else {
// As the runner might have registered suites after the initial render,
// with those suites not being read by this component yet, it will
// not have subscribed and re-rendered automatically.
// This here allows the opportunity to read all suites one last time
// before starting the run.
this.render();
}
});
onWillRender(() => {
[this.filteredSuites, this.unfoldedIds] = this.getFilteredVisibleSuites();
});
}
/**
* Filters
*/
getFilteredVisibleSuites() {
const { runner } = this.env;
const { hideEmpty } = this.state;
const allSuites = runner.suites.values();
let allowedIds;
let unfoldedIds;
let rootSuites;
// Filtering suites
const parsedQuery = parseQuery(this.state.filter);
if (parsedQuery.length) {
allowedIds = new Set();
unfoldedIds = new Set(this.state.unfoldedIds);
rootSuites = new Set();
for (const matchingSuite of lookup(parsedQuery, allSuites, "name")) {
for (const suite of matchingSuite.path) {
allowedIds.add(suite.id);
unfoldedIds.add(suite.id);
if (!suite.parent) {
rootSuites.add(suite);
}
}
}
} else {
unfoldedIds = this.state.unfoldedIds;
rootSuites = runner.rootSuites;
}
// Computing unfolded suites
/**
* @param {Suite} suite
*/
function addSuite(suite) {
if (
!(suite instanceof Suite) || // Not a suite
(allowedIds && !allowedIds.has(suite.id)) || // Not "allowed" (by parent)
(hideEmpty && !(suite.reporting.tests || suite.currentJobs.length)) // Filtered because empty
) {
return;
}
unfoldedSuites.push(suite);
if (!unfoldedIds.has(suite.id)) {
return;
}
for (const child of suite.jobs) {
addSuite(child);
}
}
const unfoldedSuites = [];
for (const suite of rootSuites) {
addSuite(suite);
}
return [unfoldedSuites, unfoldedIds];
}
getSuiteElements() {
return this.suitesListRef.el
? [...this.suitesListRef.el.getElementsByClassName(SUITE_CLASSNAME)]
: [];
}
/**
* @param {import("../core/job").Job} job
*/
hasSuites(job) {
return job.jobs.some((subJob) => subJob instanceof Suite);
}
onClick() {
// Unselect suite when clicking outside of a suite & in the side bar
this.uiState.selectedSuiteId = null;
this.uiState.resultsPage = 0;
}
/**
* @param {KeyboardEvent & { currentTarget: HTMLInputElement }} ev
*/
onSearchInputKeydown(ev) {
switch (ev.key) {
case "ArrowDown": {
if (ev.currentTarget.selectionEnd === ev.currentTarget.value.length) {
const suiteElements = this.getSuiteElements();
suiteElements[0]?.focus();
}
}
}
}
/**
* @param {KeyboardEvent & { currentTarget: HTMLButtonElement }} ev
* @param {Suite} suite
*/
onSuiteKeydown(ev, suite) {
const { currentTarget, key } = ev;
switch (key) {
case "ArrowDown": {
return this.selectElementAt(currentTarget, +1);
}
case "ArrowLeft": {
if (this.state.unfoldedIds.has(suite.id)) {
return this.toggleItem(suite, false);
} else {
return this.selectElementAt(currentTarget, -1);
}
}
case "ArrowRight": {
if (this.state.unfoldedIds.has(suite.id)) {
return this.selectElementAt(currentTarget, +1);
} else {
return this.toggleItem(suite, true);
}
}
case "ArrowUp": {
return this.selectElementAt(currentTarget, -1);
}
case "Enter": {
ev.preventDefault();
actualLocation.href = createUrlFromId({ id: suite.id });
}
}
}
/**
* @param {HTMLElement} target
* @param {number} delta
*/
selectElementAt(target, delta) {
const suiteElements = this.getSuiteElements();
const nextIndex = suiteElements.indexOf(target) + delta;
if (nextIndex < 0) {
this.searchInputRef.el?.focus();
} else if (nextIndex >= suiteElements.length) {
suiteElements[0].focus();
} else {
suiteElements[nextIndex].focus();
}
}
/**
* @param {boolean} expanded
*/
toggleExpand(expanded) {
if (expanded) {
this.state.unfoldedIds.clear();
} else {
for (const { id } of this.env.runner.suites.values()) {
this.state.unfoldedIds.add(id);
}
}
}
toggleHideEmpty() {
this.state.hideEmpty = !this.state.hideEmpty;
}
/**
* @param {Suite} suite
* @param {boolean} [forceAdd]
*/
toggleItem(suite, forceAdd) {
if (this.uiState.selectedSuiteId !== suite.id) {
this.uiState.selectedSuiteId = suite.id;
this.uiState.resultsPage = 0;
if (this.state.unfoldedIds.has(suite.id)) {
return;
}
}
if (forceAdd ?? !this.state.unfoldedIds.has(suite.id)) {
this.unfoldAndSelect(suite);
} else {
this.state.unfoldedIds.delete(suite.id);
}
}
/**
* @param {Suite} suite
*/
unfoldAndSelect(suite) {
this.state.unfoldedIds.add(suite.id);
while (suite.currentJobs.length === 1) {
suite = suite.currentJobs[0];
if (!(suite instanceof Suite)) {
break;
}
this.state.unfoldedIds.add(suite.id);
this.uiState.selectedSuiteId = suite.id;
this.uiState.resultsPage = 0;
}
}
}

View file

@ -0,0 +1,366 @@
/** @odoo-module */
import { Component, onWillRender, useEffect, useRef, useState, xml } from "@odoo/owl";
import { getColorHex } from "../../hoot-dom/hoot_dom_utils";
import { Test } from "../core/test";
import { formatTime } from "../hoot_utils";
import { getTitle, setTitle } from "../mock/window";
import { onColorSchemeChange } from "./hoot_colors";
import { HootTestPath } from "./hoot_test_path";
/**
* @typedef {import("../core/runner").Runner} Runner
*
* @typedef {{
* }} HootStatusPanelProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { values: $values },
Math: { ceil: $ceil, floor: $floor, max: $max, min: $min, random: $random },
clearInterval,
document,
performance,
setInterval,
} = globalThis;
/** @type {Performance["now"]} */
const $now = performance.now.bind(performance);
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {HTMLCanvasElement | null} canvas
*/
function setupCanvas(canvas) {
if (!canvas) {
return;
}
[canvas.width, canvas.height] = [canvas.clientWidth, canvas.clientHeight];
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
}
/**
* @param {number} min
* @param {number} max
*/
function randInt(min, max) {
return $floor($random() * (max - min + 1)) + min;
}
/**
* @param {string} content
*/
function spawnIncentive(content) {
const incentive = document.createElement("div");
const params = [
`--_content: '${content}'`,
`--_fly-duration: ${randInt(2000, 3000)}`,
`--_size: ${randInt(32, 48)}`,
`--_wiggle-duration: ${randInt(800, 2000)}`,
`--_wiggle-range: ${randInt(5, 30)}`,
`--_x: ${randInt(0, 100)}`,
`--_y: ${randInt(100, 150)}`,
];
incentive.setAttribute("class", `incentive fixed`);
incentive.setAttribute("style", params.join(";"));
/** @param {AnimationEvent} ev */
function onEnd(ev) {
return ev.animationName === "animation-incentive-travel" && incentive.remove();
}
incentive.addEventListener("animationend", onEnd);
incentive.addEventListener("animationcancel", onEnd);
document.querySelector("hoot-container").shadowRoot.appendChild(incentive);
}
/**
* @param {boolean} failed
*/
function updateTitle(failed) {
const toAdd = failed ? TITLE_PREFIX.fail : TITLE_PREFIX.pass;
let title = getTitle();
if (title.startsWith(toAdd)) {
return;
}
for (const prefix of $values(TITLE_PREFIX)) {
if (title.startsWith(prefix)) {
title = title.slice(prefix.length);
break;
}
}
setTitle(`${toAdd} ${title}`);
}
const TIMER_PRECISION = 100; // in ms
const TITLE_PREFIX = {
fail: "✖",
pass: "✔",
};
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<HootStatusPanelProps, import("../hoot").Environment>} */
export class HootStatusPanel extends Component {
static components = { HootTestPath };
static props = {};
static template = xml`
<div class="${HootStatusPanel.name} flex items-center justify-between gap-3 px-3 py-1 bg-gray-300 dark:bg-gray-700" t-att-class="state.className">
<div class="flex items-center gap-2 overflow-hidden">
<t t-if="runnerState.status === 'ready'">
Ready
</t>
<t t-elif="runnerState.status === 'running'">
<i t-if="state.debug" class="text-cyan fa fa-bug" title="Debugging" />
<div
t-else=""
class="animate-spin shrink-0 grow-0 w-4 h-4 border-2 border-emerald border-t-transparent rounded-full"
role="status"
title="Running"
/>
<strong class="text-primary" t-esc="env.runner.totalTime" />
</t>
<t t-else="">
<span class="hidden md:block">
<strong class="text-primary" t-esc="runnerReporting.tests" />
tests completed
(total time: <strong class="text-primary" t-esc="env.runner.totalTime" />
<t t-if="env.runner.aborted">, run aborted by user</t>)
</span>
<span class="md:hidden flex items-center gap-1">
<i class="fa fa-clock-o" />
<strong class="text-primary" t-esc="env.runner.totalTime" />
</span>
</t>
<t t-if="runnerState.currentTest">
<HootTestPath test="runnerState.currentTest" />
</t>
<t t-if="state.timer">
<span class="text-cyan" t-esc="formatTime(state.timer, 's')" />
</t>
</div>
<div class="flex items-center gap-1">
<t t-if="runnerReporting.passed">
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'passed' ? 'emerald' : 'gray'" />
<button
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
t-on-click.stop="() => this.filterResults('passed')"
t-attf-title="Show {{ runnerReporting.passed }} passed tests"
>
<i class="fa fa-check-circle" />
<t t-esc="runnerReporting.passed" />
</button>
</t>
<t t-if="runnerReporting.failed">
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'failed' ? 'rose' : 'gray'" />
<button
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
t-on-click.stop="() => this.filterResults('failed')"
t-attf-title="Show {{ runnerReporting.failed }} failed tests"
>
<i class="fa fa-times-circle" />
<t t-esc="runnerReporting.failed" />
</button>
</t>
<t t-if="runnerReporting.skipped">
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'skipped' ? 'cyan' : 'gray'" />
<button
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
t-on-click.stop="() => this.filterResults('skipped')"
t-attf-title="Show {{ runnerReporting.skipped }} skipped tests"
>
<i class="fa fa-pause-circle" />
<t t-esc="runnerReporting.skipped" />
</button>
</t>
<t t-if="runnerReporting.todo">
<t t-set="color" t-value="!uiState.statusFilter or uiState.statusFilter === 'todo' ? 'purple' : 'gray'" />
<button
t-attf-class="text-{{ color }} transition-colors flex items-center gap-1 p-1 font-bold"
t-on-click.stop="() => this.filterResults('todo')"
t-attf-title="Show {{ runnerReporting.todo }} tests to do"
>
<i class="fa fa-exclamation-circle" />
<t t-esc="runnerReporting.todo" />
</button>
</t>
<t t-if="uiState.totalResults gt uiState.resultsPerPage">
<t t-set="lastPage" t-value="getLastPage()" />
<div class="flex gap-1 animate-slide-left">
<button
class="px-1 transition-color"
title="Previous page"
t-att-disabled="uiState.resultsPage === 0"
t-on-click.stop="previousPage"
>
<i class="fa fa-chevron-left" />
</button>
<strong class="text-primary" t-esc="uiState.resultsPage + 1" />
<span class="text-gray">/</span>
<t t-esc="lastPage + 1" />
<button
class="px-1 transition-color"
title="Next page"
t-att-disabled="uiState.resultsPage === lastPage"
t-on-click.stop="nextPage"
>
<i class="fa fa-chevron-right" />
</button>
</div>
</t>
</div>
</div>
<canvas t-ref="progress-canvas" class="flex h-1 w-full" />
`;
currentTestStart;
formatTime = formatTime;
intervalId = 0;
setup() {
const { runner, ui } = this.env;
this.canvasRef = useRef("progress-canvas");
this.runnerReporting = useState(runner.reporting);
this.runnerState = useState(runner.state);
this.state = useState({
className: "",
timer: null,
});
this.uiState = useState(ui);
this.progressBarIndex = 0;
runner.beforeAll(this.globalSetup.bind(this));
runner.afterAll(this.globalCleanup.bind(this));
if (!runner.headless) {
runner.beforeEach(this.startTimer.bind(this));
runner.afterPostTest(this.stopTimer.bind(this));
}
useEffect(setupCanvas, () => [this.canvasRef.el]);
onColorSchemeChange(this.onColorSchemeChange.bind(this));
onWillRender(this.updateProgressBar.bind(this));
}
/**
* @param {typeof this.uiState.statusFilter} status
*/
filterResults(status) {
this.uiState.resultsPage = 0;
if (this.uiState.statusFilter === status) {
this.uiState.statusFilter = null;
} else {
this.uiState.statusFilter = status;
}
}
getLastPage() {
const { resultsPerPage, totalResults } = this.uiState;
return $max($floor((totalResults - 1) / resultsPerPage), 0);
}
/**
* @param {Runner} runner
*/
globalCleanup(runner) {
if (!runner.headless) {
this.stopTimer();
}
updateTitle(this.runnerReporting.failed > 0);
if (runner.config.fun) {
for (let i = 0; i < this.runnerReporting.failed; i++) {
spawnIncentive("😭");
}
for (let i = 0; i < this.runnerReporting.passed; i++) {
spawnIncentive("🦉");
}
}
}
/**
* @param {Runner} runner
*/
globalSetup(runner) {
this.state.debug = runner.debug;
}
nextPage() {
this.uiState.resultsPage = $min(this.uiState.resultsPage + 1, this.getLastPage());
}
onColorSchemeChange() {
this.progressBarIndex = 0;
this.updateProgressBar();
}
previousPage() {
this.uiState.resultsPage = $max(this.uiState.resultsPage - 1, 0);
}
startTimer() {
this.stopTimer();
this.currentTestStart = $now();
this.intervalId = setInterval(() => {
this.state.timer =
$floor(($now() - this.currentTestStart) / TIMER_PRECISION) * TIMER_PRECISION;
}, TIMER_PRECISION);
}
stopTimer() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = 0;
}
this.state.timer = 0;
}
updateProgressBar() {
const canvas = this.canvasRef.el;
if (!canvas) {
return;
}
const ctx = canvas.getContext("2d");
const { width, height } = canvas;
const { done, tests } = this.runnerState;
const doneList = [...done];
const cellSize = width / tests.length;
const minSize = $ceil(cellSize);
while (this.progressBarIndex < done.size) {
const test = doneList[this.progressBarIndex];
const x = $floor(this.progressBarIndex * cellSize);
switch (test.status) {
case Test.ABORTED:
ctx.fillStyle = getColorHex("amber");
break;
case Test.FAILED:
ctx.fillStyle = getColorHex("rose");
break;
case Test.PASSED:
ctx.fillStyle = test.config.todo
? getColorHex("purple")
: getColorHex("emerald");
break;
case Test.SKIPPED:
ctx.fillStyle = getColorHex("cyan");
break;
}
ctx.fillRect(x, 0, minSize, height);
this.progressBarIndex++;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,53 @@
/** @odoo-module */
import { Component, xml } from "@odoo/owl";
import { Tag } from "../core/tag";
import { HootLink } from "./hoot_link";
/**
* @typedef {{
* inert?: boolean;
* tag: Tag;
* }} HootTagButtonProps
*/
/** @extends {Component<HootTagButtonProps, import("../hoot").Environment>} */
export class HootTagButton extends Component {
static components = { HootLink };
static props = {
inert: { type: Boolean, optional: true },
tag: Tag,
};
static template = xml`
<t t-if="props.inert">
<span
class="rounded-full px-2"
t-att-style="style"
t-att-title="title"
>
<small class="text-xs font-bold" t-esc="props.tag.name" />
</span>
</t>
<t t-else="">
<HootLink
ids="{ tag: props.tag.name }"
class="'rounded-full px-2'"
style="style"
title="title"
>
<small class="text-xs font-bold hidden md:inline" t-esc="props.tag.name" />
<span class="md:hidden">&#8205;</span>
</HootLink>
</t>
`;
get style() {
return `background-color: ${this.props.tag.color[0]}; color: ${this.props.tag.color[1]};`;
}
get title() {
return `Tag ${this.props.tag.name}`;
}
}

View file

@ -0,0 +1,267 @@
/** @odoo-module */
import {
Component,
onWillRender,
onWillUpdateProps,
xml as owlXml,
toRaw,
useState,
} from "@odoo/owl";
import { isNode, toSelector } from "@web/../lib/hoot-dom/helpers/dom";
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
import { logger } from "../core/logger";
import {
getTypeOf,
isSafe,
Markup,
S_ANY,
S_NONE,
stringify,
toExplicitString,
} from "../hoot_utils";
/**
* @typedef {{
* value?: any;
* }} TechnicalValueProps
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Object: { keys: $keys },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* Compacted version of {@link owlXml} removing all whitespace between tags.
*
* @type {typeof String.raw}
*/
function xml(template, ...substitutions) {
return owlXml({
raw: String.raw(template, ...substitutions)
.replace(/>\s+/g, ">")
.replace(/\s+</g, "<"),
});
}
const INVARIABLE_OBJECTS = [Promise, RegExp];
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @extends {Component<TechnicalValueProps, import("../hoot").Environment>} */
export class HootTechnicalValue extends Component {
static components = { HootTechnicalValue };
static props = {
value: { optional: true },
};
static template = xml`
<t t-if="isMarkup">
<t t-if="value.type === 'technical'">
<pre class="hoot-technical" t-att-class="value.className">
<t t-foreach="value.content" t-as="subValue" t-key="subValue_index">
<HootTechnicalValue value="subValue" />
</t>
</pre>
</t>
<t t-else="">
<t t-if="value.tagName === 't'" t-esc="value.content" />
<t t-else="" t-tag="value.tagName" t-att-class="value.className" t-esc="value.content" />
</t>
</t>
<t t-elif="isNode(value)">
<t t-set="elParts" t-value="toSelector(value, { object: true })" />
<button
class="hoot-html"
t-on-click.stop="log"
>
<t>&lt;<t t-esc="elParts.tag" /></t>
<t t-if="elParts.id">
<span class="hoot-html-id" t-esc="elParts.id" />
</t>
<t t-if="elParts.class">
<span class="hoot-html-class" t-esc="elParts.class" />
</t>
<t>/&gt;</t>
</button>
</t>
<t t-elif="value === S_ANY or value === S_NONE">
<span class="italic">
&lt;<t t-esc="symbolValue(value)" />&gt;
</span>
</t>
<t t-elif="typeof value === 'symbol'">
<span>
Symbol(<span class="hoot-string" t-esc="stringify(symbolValue(value))" />)
</span>
</t>
<t t-elif="value and typeof value === 'object'">
<t t-set="labelSize" t-value="getLabelAndSize()" />
<pre class="hoot-technical">
<button
class="hoot-object inline-flex items-center gap-1 me-1"
t-on-click.stop="onClick"
>
<t t-if="labelSize[1] > 0">
<i
class="fa fa-caret-right"
t-att-class="{ 'rotate-90': state.open }"
/>
</t>
<t t-esc="labelSize[0]" />
<t t-if="state.promiseState">
&lt;
<span class="text-gray" t-esc="state.promiseState[0]" />
<t t-if="state.promiseState[0] !== 'pending'">
: <HootTechnicalValue value="state.promiseState[1]" />
</t>
&gt;
</t>
<t t-elif="labelSize[1] !== null">
(<t t-esc="labelSize[1]" />)
</t>
</button>
<t t-if="state.open and labelSize[1] > 0">
<t t-if="isIterable(value)">
<t>[</t>
<ul class="ps-4">
<t t-foreach="value" t-as="subValue" t-key="subValue_index">
<li class="flex">
<HootTechnicalValue value="subValue" />
<t t-esc="displayComma(subValue)" />
</li>
</t>
</ul>
<t>]</t>
</t>
<t t-else="">
<t>{</t>
<ul class="ps-4">
<t t-foreach="value" t-as="key" t-key="key">
<li class="flex">
<span class="hoot-key" t-esc="key" />
<span class="me-1">:</span>
<HootTechnicalValue value="value[key]" />
<t t-esc="displayComma(value[key])" />
</li>
</t>
</ul>
<t>}</t>
</t>
</t>
</pre>
</t>
<t t-else="">
<span t-attf-class="hoot-{{ getTypeOf(value) }}">
<t t-esc="typeof value === 'string' ? stringify(explicitValue) : explicitValue" />
</span>
</t>
`;
getTypeOf = getTypeOf;
isIterable = isIterable;
isNode = isNode;
stringify = stringify;
toSelector = toSelector;
S_ANY = S_ANY;
S_NONE = S_NONE;
get explicitValue() {
return toExplicitString(this.value);
}
setup() {
this.logged = false;
this.state = useState({
open: false,
promiseState: null,
});
this.wrapPromiseValue(this.props.value);
onWillRender(() => {
this.isMarkup = Markup.isMarkup(this.props.value);
this.value = toRaw(this.props.value);
this.isSafe = isSafe(this.value);
});
onWillUpdateProps((nextProps) => {
this.state.open = false;
this.wrapPromiseValue(nextProps.value);
});
}
onClick() {
this.log(this.value);
this.state.open = !this.state.open;
}
getLabelAndSize() {
if (isInstanceOf(this.value, Date)) {
return [this.value.toISOString(), null];
}
if (isInstanceOf(this.value, RegExp)) {
return [String(this.value), null];
}
return [this.value.constructor.name, this.getSize()];
}
getSize() {
for (const Class of INVARIABLE_OBJECTS) {
if (isInstanceOf(this.value, Class)) {
return null;
}
}
if (!this.isSafe) {
return 0;
}
const values = isIterable(this.value) ? [...this.value] : $keys(this.value);
return values.length;
}
displayComma(value) {
return value && typeof value === "object" ? "" : ",";
}
log() {
if (this.logged) {
return;
}
this.logged = true;
logger.debug(this.value);
}
/**
* @param {Symbol} symbol
*/
symbolValue(symbol) {
return symbol.toString().slice(7, -1);
}
wrapPromiseValue(promise) {
if (!isInstanceOf(promise, Promise)) {
return;
}
this.state.promiseState = ["pending", null];
Promise.resolve(promise).then(
(value) => {
this.state.promiseState = ["fulfilled", value];
return value;
},
(reason) => {
this.state.promiseState = ["rejected", reason];
throw reason;
}
);
}
}

View file

@ -0,0 +1,159 @@
/** @odoo-module */
import { Component, useState, xml } from "@odoo/owl";
import { Test } from "../core/test";
import { HootCopyButton } from "./hoot_copy_button";
import { HootLink } from "./hoot_link";
import { HootTagButton } from "./hoot_tag_button";
/**
* @typedef {{
* canCopy?: boolean;
* full?: boolean;
* inert?: boolean;
* showStatus?: boolean;
* test: Test;
* }} HootTestPathProps
*/
/** @extends {Component<HootTestPathProps, import("../hoot").Environment>} */
export class HootTestPath extends Component {
static components = { HootCopyButton, HootLink, HootTagButton };
static props = {
canCopy: { type: Boolean, optional: true },
full: { type: Boolean, optional: true },
inert: { type: Boolean, optional: true },
showStatus: { type: Boolean, optional: true },
test: Test,
};
static template = xml`
<t t-set="statusInfo" t-value="getStatusInfo()" />
<div class="flex items-center gap-1 whitespace-nowrap overflow-hidden">
<t t-if="props.showStatus">
<span
t-attf-class="inline-flex min-w-3 min-h-3 rounded-full bg-{{ statusInfo.className }}"
t-att-title="statusInfo.text"
/>
</t>
<span class="flex items-center overflow-hidden">
<t t-if="uiState.selectedSuiteId and !props.full">
<span class="text-gray font-bold p-1 select-none hidden md:inline">...</span>
<span class="select-none hidden md:inline">/</span>
</t>
<t t-foreach="getTestPath()" t-as="suite" t-key="suite.id">
<t t-if="props.inert">
<span
class="text-gray whitespace-nowrap font-bold p-1 hidden md:inline transition-colors"
t-esc="suite.name"
/>
</t>
<t t-else="">
<HootLink
ids="{ id: suite.id }"
class="'text-gray hover:text-primary hover:underline whitespace-nowrap font-bold p-1 hidden md:inline transition-colors'"
title="'Run ' + suite.fullName"
t-esc="suite.name"
/>
<t t-if="suite.config.multi">
<strong class="text-amber whitespace-nowrap me-1">
x<t t-esc="suite.config.multi" />
</strong>
</t>
</t>
<span class="select-none hidden md:inline" t-att-class="{ 'text-cyan': suite.config.skip }">/</span>
</t>
<span
class="text-primary truncate font-bold p-1"
t-att-class="{ 'text-cyan': props.test.config.skip }"
t-att-title="props.test.name"
t-esc="props.test.name"
/>
<t t-if="props.canCopy">
<HootCopyButton text="props.test.name" altText="props.test.id" />
</t>
<t t-if="results.length > 1">
<strong class="text-amber whitespace-nowrap mx-1">
x<t t-esc="results.length" />
</strong>
</t>
</span>
<t t-if="props.test.tags.length">
<ul class="flex items-center gap-1">
<t t-foreach="props.test.tags.slice(0, 5)" t-as="tag" t-key="tag.name">
<li class="flex">
<HootTagButton tag="tag" />
</li>
</t>
</ul>
</t>
</div>
`;
setup() {
this.results = useState(this.props.test.results);
this.uiState = useState(this.env.ui);
}
getStatusInfo() {
switch (this.props.test.status) {
case Test.ABORTED: {
return { className: "amber", text: "aborted" };
}
case Test.FAILED: {
if (this.props.test.config.todo) {
return { className: "purple", text: "todo" };
} else {
return { className: "rose", text: "failed" };
}
}
case Test.PASSED: {
if (this.props.test.config.todo) {
return { className: "purple", text: "todo" };
} else {
return { className: "emerald", text: "passed" };
}
}
default: {
return { className: "cyan", text: "skipped" };
}
}
}
/**
* @param {import("../core/suite").Suite} suite
*/
getSuiteInfo(suite) {
let suites = 0;
let tests = 0;
let assertions = 0;
for (const job of suite.jobs) {
if (job instanceof Test) {
tests++;
assertions += job.lastResults?.counts.assertion || 0;
} else {
suites++;
}
}
return {
id: suite.id,
name: suite.name,
parent: suite.parent?.name || null,
suites,
tests,
assertions,
};
}
getTestPath() {
const { selectedSuiteId } = this.uiState;
const { test } = this.props;
const path = test.path.slice(0, -1);
if (this.props.full || !selectedSuiteId) {
return path;
}
const index = path.findIndex((suite) => suite.id === selectedSuiteId) + 1;
return path.slice(index);
}
}

View file

@ -0,0 +1,410 @@
/** @odoo-module */
import { Component, onWillRender, useState, xml } from "@odoo/owl";
import { isFirefox } from "../../hoot-dom/hoot_dom_utils";
import { Tag } from "../core/tag";
import { Test } from "../core/test";
import { subscribeToURLParams } from "../core/url";
import {
CASE_EVENT_TYPES,
formatHumanReadable,
formatTime,
getTypeOf,
isLabel,
Markup,
ordinal,
} from "../hoot_utils";
import { HootCopyButton } from "./hoot_copy_button";
import { HootLink } from "./hoot_link";
import { HootTechnicalValue } from "./hoot_technical_value";
/**
* @typedef {import("../core/expect").CaseEvent} CaseEvent
* @typedef {import("../core/expect").CaseEventType} CaseEventType
* @typedef {import("../core/expect").CaseResult} CaseResult
* @typedef {import("./setup_hoot_ui").StatusFilter} StatusFilter
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Boolean,
Object: { entries: $entries, fromEntries: $fromEntries },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {[number, CaseEvent][]} indexedResults
* @param {number} events
*/
function filterEvents(indexedResults, events) {
/** @type {Record<number, CaseEvent[]>} */
const filteredEvents = {};
for (const [i, result] of indexedResults) {
filteredEvents[i] = result.getEvents(events);
}
return filteredEvents;
}
/**
* @param {CaseEvent[]} results
* @param {StatusFilter} statusFilter
*/
function filterResults(results, statusFilter) {
const ordinalResults = [];
const hasFailed = results.some((r) => !r.pass);
const shouldPass = statusFilter === "passed";
for (let i = 0; i < results.length; i++) {
if (!hasFailed || results[i].pass === shouldPass) {
ordinalResults.push([i + 1, results[i]]);
}
}
return ordinalResults;
}
/**
* @param {string} label
* @param {string} owner
*/
function stackTemplate(label, owner) {
// Defined with string concat because line returns are taken into account in <pre> tags.
const preContent =
/* xml */ `<t t-foreach="parseStack(${owner}.stack)" t-as="part" t-key="part_index">` +
/* xml */ `<t t-if="typeof part === 'string'" t-esc="part" />` +
/* xml */ `<span t-else="" t-att-class="part.className" t-esc="part.value" />` +
/* xml */ `</t>`;
return /* xml */ `
<t t-if="${owner}?.stack">
<div class="flex col-span-2 gap-x-2 px-2 mt-1">
<span class="text-rose">
${label}:
</span>
<pre class="hoot-technical m-0">${preContent}</pre>
</div>
</t>
`;
}
const ERROR_TEMPLATE = /* xml */ `
<div class="text-rose flex items-center gap-1 px-2 truncate">
<i class="fa fa-exclamation" />
<strong t-esc="event.label" />
<span class="flex truncate" t-esc="event.message.join(' ')" />
</div>
<t t-set="timestamp" t-value="formatTime(event.ts - (result.ts || 0), 'ms')" />
<small class="text-gray flex items-center" t-att-title="timestamp">
<t t-esc="'@' + timestamp" />
</small>
${stackTemplate("Source", "event")}
${stackTemplate("Cause", "event.cause")}
`;
const EVENT_TEMPLATE = /* xml */ `
<div
t-attf-class="text-{{ eventColor }} flex items-center gap-1 px-2 truncate"
>
<t t-if="sType === 'assertion'">
<t t-esc="event.number + '.'" />
</t>
<t t-else="">
<i class="fa" t-att-class="eventIcon" />
</t>
<!-- TODO: add documentation links once they exist -->
<a href="#" class="hover:text-primary flex gap-1 items-center" t-att-class="{ 'text-cyan': sType === 'assertion' }">
<t t-if="event.flags">
<i t-if="event.hasFlag('rejects')" class="fa fa-times" />
<i t-elif="event.hasFlag('resolves')" class="fa fa-arrow-right" />
<i t-if="event.hasFlag('not')" class="fa fa-exclamation" />
</t>
<strong t-esc="event.label" />
</a>
<span class="flex gap-1 truncate items-center">
<t t-foreach="event.message" t-as="part" t-key="part_index">
<t t-if="isLabel(part)">
<t t-if="!part[1]">
<span t-esc="part[0]" />
</t>
<t t-elif="part[1].endsWith('[]')">
<strong class="hoot-array">
<t>[</t>
<span t-attf-class="hoot-{{ part[1].slice(0, -2) }}" t-esc="part[0].slice(1, -1)" />
<t>]</t>
</strong>
</t>
<t t-elif="part[1] === 'icon'">
<i t-att-class="part[0]" />
</t>
<t t-else="">
<strong t-attf-class="hoot-{{ part[1] }}">
<t t-if="part[1] === 'url'">
<a
class="underline"
t-att-href="part[0]"
t-esc="part[0]"
target="_blank"
/>
</t>
<t t-else="">
<t t-esc="part[0]" />
</t>
</strong>
</t>
</t>
<t t-else="">
<span t-esc="part" />
</t>
</t>
</span>
</div>
<t t-set="timestamp" t-value="formatTime(event.ts - (result.ts || 0), 'ms')" />
<small class="flex items-center text-gray" t-att-title="timestamp">
<t t-esc="'@' + timestamp" />
</small>
<t t-if="event.additionalMessage">
<div class="flex items-center ms-4 px-2 gap-1 col-span-2">
<em class="text-blue truncate" t-esc="event.additionalMessage" />
<HootCopyButton text="event.additionalMessage" />
</div>
</t>
<t t-if="!event.pass">
<t t-if="event.failedDetails">
<div class="hoot-info grid col-span-2 gap-x-2 px-2">
<t t-foreach="event.failedDetails" t-as="details" t-key="details_index">
<t t-if="isMarkup(details, 'group')">
<div class="col-span-2 flex gap-2 ps-2 mt-1" t-att-class="details.className">
<t t-esc="details.groupIndex" />.
<HootTechnicalValue value="details.content" />
</div>
</t>
<t t-else="">
<HootTechnicalValue value="details[0]" />
<HootTechnicalValue value="details[1]" />
</t>
</t>
</div>
</t>
${stackTemplate("Source", "event")}
</t>
`;
const CASE_EVENT_TYPES_INVERSE = $fromEntries(
$entries(CASE_EVENT_TYPES).map(([k, v]) => [v.value, k])
);
const R_STACK_LINE_START = isFirefox()
? /^\s*(?<prefix>@)(?<rest>.*)/i
: /^\s*(?<prefix>at)(?<rest>.*)/i;
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* @typedef {{
* open?: boolean | "always";
* slots: any;
* test: Test;
* }} TestResultProps
*/
/** @extends {Component<TestResultProps, import("../hoot").Environment>} */
export class HootTestResult extends Component {
static components = { HootCopyButton, HootLink, HootTechnicalValue };
static props = {
open: [{ type: Boolean }, { value: "always" }],
slots: {
type: Object,
shape: {
default: Object,
},
},
test: Test,
};
static template = xml`
<div
class="${HootTestResult.name}
flex flex-col w-full border-b overflow-hidden
border-gray-300 dark:border-gray-600"
t-att-class="getClassName()"
>
<button
type="button"
class="px-3 flex items-center justify-between"
t-on-click.stop="toggleDetails"
>
<t t-slot="default" />
</button>
<t t-if="state.showDetails and !props.test.config.skip">
<t t-foreach="filteredResults" t-as="indexedResult" t-key="indexedResult[0]">
<t t-set="index" t-value="indexedResult[0]" />
<t t-set="result" t-value="indexedResult[1]" />
<t t-if="results.length > 1">
<div class="flex justify-between mx-2 my-1">
<span t-attf-class="text-{{ result.pass ? 'emerald' : 'rose' }}">
<t t-esc="ordinal(index)" /> run:
</span>
<t t-set="timestamp" t-value="formatTime(result.duration, 'ms')" />
<small class="text-gray flex items-center" t-att-title="timestamp">
<t t-esc="timestamp" />
</small>
</div>
</t>
<div class="hoot-result-detail grid gap-1 rounded overflow-x-auto p-1 mx-2 animate-slide-down">
<t t-if="!filteredEvents[index].length">
<em class="text-gray px-2 py-1">No test event to show</em>
</t>
<t t-foreach="filteredEvents[index]" t-as="event" t-key="event_index">
<t t-set="sType" t-value="getTypeName(event.type)" />
<t t-set="eventIcon" t-value="CASE_EVENT_TYPES[sType].icon" />
<t t-set="eventColor" t-value="
'pass' in event ?
(event.pass ? 'emerald' : 'rose') :
CASE_EVENT_TYPES[sType].color"
/>
<t t-if="sType === 'error'">
${ERROR_TEMPLATE}
</t>
<t t-else="">
${EVENT_TEMPLATE}
</t>
</t>
</div>
</t>
<div class="flex flex-col overflow-y-hidden">
<nav class="flex items-center gap-2 p-2 text-gray">
<button
type="button"
class="flex items-center px-1 gap-1 text-sm hover:text-primary"
t-on-click.stop="toggleCode"
>
<t t-if="state.showCode">
Hide source code
</t>
<t t-else="">
Show source code
</t>
</button>
</nav>
<t t-if="state.showCode">
<div class="m-2 mt-0 rounded animate-slide-down overflow-auto">
<pre
class="language-javascript"
style="margin: 0"
><code class="language-javascript" t-out="props.test.code" /></pre>
</div>
</t>
</div>
</t>
</div>
`;
CASE_EVENT_TYPES = CASE_EVENT_TYPES;
Tag = Tag;
formatHumanReadable = formatHumanReadable;
formatTime = formatTime;
getTypeOf = getTypeOf;
isLabel = isLabel;
isMarkup = Markup.isMarkup;
ordinal = ordinal;
/** @type {ReturnType<typeof filterEvents>} */
filteredEvents;
/** @type {[number, CaseEvent][]} */
filteredResults;
setup() {
subscribeToURLParams("*");
const { runner, ui } = this.env;
this.config = useState(runner.config);
this.logs = useState(this.props.test.logs);
this.results = useState(this.props.test.results);
this.state = useState({
showCode: false,
showDetails: Boolean(this.props.open),
});
this.uiState = useState(ui);
onWillRender(this.onWillRender.bind(this));
}
getClassName() {
if (this.logs.error) {
return "bg-rose-900";
}
switch (this.props.test.status) {
case Test.ABORTED: {
return "bg-amber-900";
}
case Test.FAILED: {
if (this.props.test.config.todo) {
return "bg-purple-900";
} else {
return "bg-rose-900";
}
}
case Test.PASSED: {
if (this.logs.warn) {
return "bg-amber-900";
} else if (this.props.test.config.todo) {
return "bg-purple-900";
} else {
return "bg-emerald-900";
}
}
default: {
return "bg-cyan-900";
}
}
}
/**
* @param {number} nType
*/
getTypeName(nType) {
return CASE_EVENT_TYPES_INVERSE[nType];
}
onWillRender() {
this.filteredResults = filterResults(this.results, this.uiState.statusFilter);
this.filteredEvents = filterEvents(this.filteredResults, this.config.events);
}
/**
* @param {string} stack
*/
parseStack(stack) {
const result = [];
for (const line of stack.split("\n")) {
const match = line.match(R_STACK_LINE_START);
if (match) {
result.push(
{ className: "text-rose", value: match.groups.prefix },
match.groups.rest + "\n"
);
} else {
result.push(line + "\n");
}
}
return result;
}
toggleCode() {
this.state.showCode = !this.state.showCode;
}
toggleDetails() {
if (this.props.open === "always") {
return;
}
this.state.showDetails = !this.state.showDetails;
}
}

View file

@ -0,0 +1,186 @@
/** @odoo-module */
import { mount, reactive } from "@odoo/owl";
import { HootFixtureElement } from "../core/fixture";
import { waitForDocument } from "../hoot_utils";
import { getRunner } from "../main_runner";
import { patchWindow } from "../mock/window";
import {
generateStyleSheets,
getColorScheme,
onColorSchemeChange,
setColorRoot,
} from "./hoot_colors";
import { HootMain } from "./hoot_main";
/**
* @typedef {"failed" | "passed" | "skipped" | "todo"} StatusFilter
*
* @typedef {ReturnType<typeof makeUiState>} UiState
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
customElements,
document,
fetch,
HTMLElement,
Object: { entries: $entries },
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {string} href
*/
function createLinkElement(href) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
return link;
}
/**
* @param {string} content
*/
function createStyleElement(content) {
const style = document.createElement("style");
style.innerText = content;
return style;
}
function getPrismStyleUrl() {
const theme = getColorScheme() === "dark" ? "okaida" : "default";
return `/web/static/lib/prismjs/themes/${theme}.css`;
}
function loadAsset(tagName, attributes) {
return new Promise((resolve, reject) => {
const el = document.createElement(tagName);
Object.assign(el, attributes);
el.addEventListener("load", resolve);
el.addEventListener("error", reject);
document.head.appendChild(el);
});
}
async function loadBundle(bundle) {
const bundleResponse = await fetch(`/web/bundle/${bundle}`);
const result = await bundleResponse.json();
const promises = [];
for (const { src, type } of result) {
if (src && type === "link") {
loadAsset("link", {
rel: "stylesheet",
href: src,
});
} else if (src && type === "script") {
promises.push(
loadAsset("script", {
src,
type: "text/javascript",
})
);
}
}
await Promise.all(promises);
}
class HootContainer extends HTMLElement {
constructor() {
super(...arguments);
this.attachShadow({ mode: "open" });
}
connectedCallback() {
setColorRoot(this);
}
disconnectedCallback() {
setColorRoot(null);
}
}
customElements.define("hoot-container", HootContainer);
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export function makeUiState() {
return reactive({
resultsPage: 0,
resultsPerPage: 40,
/** @type {string | null} */
selectedSuiteId: null,
/** @type {"asc" | "desc" | false} */
sortResults: false,
/** @type {StatusFilter | null} */
statusFilter: null,
totalResults: 0,
});
}
/**
* Appends the main Hoot UI components in a container, which itself will be appended
* on the current document body.
*
* @returns {Promise<void>}
*/
export async function setupHootUI() {
// - Patch window before code from other modules is executed
patchWindow();
const runner = getRunner();
const container = document.createElement("hoot-container");
container.style.display = "contents";
await waitForDocument(document);
document.head.appendChild(HootFixtureElement.styleElement);
document.body.appendChild(container);
const promises = [
// Mount main container
mount(HootMain, container.shadowRoot, {
env: {
runner,
ui: makeUiState(),
},
name: "HOOT",
}),
];
if (!runner.headless) {
// In non-headless: also wait for lazy-loaded libs (Highlight & DiffMatchPatch)
promises.push(loadBundle("web.assets_unit_tests_setup_ui"));
let colorStyleContent = "";
for (const [className, content] of $entries(generateStyleSheets())) {
const selector = className === "default" ? ":host" : `:host(.${className})`;
colorStyleContent += `${selector}{${content}}`;
}
const prismStyleLink = createLinkElement(getPrismStyleUrl());
onColorSchemeChange(() => {
prismStyleLink.href = getPrismStyleUrl();
});
container.shadowRoot.append(
createStyleElement(colorStyleContent),
createLinkElement("/web/static/src/libs/fontawesome/css/font-awesome.css"),
prismStyleLink,
// Hoot-specific style is loaded last to take priority over other stylesheets
createLinkElement("/web/static/lib/hoot/ui/hoot_style.css")
);
}
await Promise.all(promises);
}