mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 16:52:02 +02:00
vanilla 19.0
This commit is contained in:
parent
991d2234ca
commit
d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions
185
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_buttons.js
Normal file
185
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_buttons.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
116
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_link.js
Normal file
116
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_link.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
`;
|
||||
}
|
||||
194
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_main.js
Normal file
194
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_main.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
890
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_search.js
Normal file
890
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_search.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
1686
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_style.css
Normal file
1686
odoo-bringout-oca-ocb-web/web/static/lib/hoot/ui/hoot_style.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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">‍</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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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><<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>/></t>
|
||||
</button>
|
||||
</t>
|
||||
<t t-elif="value === S_ANY or value === S_NONE">
|
||||
<span class="italic">
|
||||
<<t t-esc="symbolValue(value)" />>
|
||||
</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">
|
||||
<
|
||||
<span class="text-gray" t-esc="state.promiseState[0]" />
|
||||
<t t-if="state.promiseState[0] !== 'pending'">
|
||||
: <HootTechnicalValue value="state.promiseState[1]" />
|
||||
</t>
|
||||
>
|
||||
</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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue