mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 00:12:04 +02:00
vanilla 19.0
This commit is contained in:
parent
991d2234ca
commit
d1963a3c3a
3066 changed files with 1651266 additions and 922560 deletions
255
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/config.js
Normal file
255
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/config.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { DEFAULT_EVENT_TYPES } from "../hoot_utils";
|
||||
import { generateSeed } from "../mock/math";
|
||||
|
||||
/**
|
||||
* @typedef {keyof typeof FILTER_SCHEMA} SearchFilter
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Number: { parseFloat: $parseFloat },
|
||||
Object: { entries: $entries, fromEntries: $fromEntries, keys: $keys },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @template {Record<string, any>} T
|
||||
* @param {T} schema
|
||||
* @returns {{ [key in keyof T]: ReturnType<T[key]["parse"]> }}
|
||||
*/
|
||||
function getSchemaDefaults(schema) {
|
||||
return $fromEntries($entries(schema).map(([key, value]) => [key, value.default]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {Record<string, any>} T
|
||||
* @param {T} schema
|
||||
* @returns {(keyof T)[]}
|
||||
*/
|
||||
function getSchemaKeys(schema) {
|
||||
return $keys(schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {(values: string[]) => T} parse
|
||||
* @returns {(valueIfEmpty: T) => (values: string[]) => T}
|
||||
*/
|
||||
function makeParser(parse) {
|
||||
return (valueIfEmpty) => (values) => values.length ? parse(values) : valueIfEmpty;
|
||||
}
|
||||
|
||||
const parseBoolean = makeParser(([value]) => value === "true");
|
||||
|
||||
const parseNumber = makeParser(([value]) => $parseFloat(value) || 0);
|
||||
|
||||
/** @type {ReturnType<typeof makeParser<"first-fail" | "failed" | false>>} */
|
||||
const parseShowDetail = makeParser(([value]) => (value === "false" ? false : value));
|
||||
|
||||
const parseString = makeParser(([value]) => value);
|
||||
|
||||
const parseStringArray = makeParser((values) => values);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export const CONFIG_SCHEMA = {
|
||||
/**
|
||||
* Amount of failed tests after which the test runner will be stopped.
|
||||
* A falsy value (including 0) means that the runner should never be aborted.
|
||||
* @default false
|
||||
*/
|
||||
bail: {
|
||||
default: 0,
|
||||
parse: parseNumber(1),
|
||||
},
|
||||
/**
|
||||
* Debug parameter used in Odoo.
|
||||
* It has no direct effect on the test runner, but is taken into account since
|
||||
* all URL parameters not explicitly defined in the schema are ignored.
|
||||
* @default ""
|
||||
*/
|
||||
debug: {
|
||||
default: "",
|
||||
parse: parseString("assets"),
|
||||
},
|
||||
/**
|
||||
* Same as the {@link FILTER_SCHEMA.test} filter, while also putting the test
|
||||
* runner in "debug" mode. See {@link Runner.debug} for more info.
|
||||
* @default false
|
||||
*/
|
||||
debugTest: {
|
||||
default: false,
|
||||
parse: parseBoolean(true),
|
||||
},
|
||||
/**
|
||||
* Determines the event types shown in test results.
|
||||
* @default assertion|error
|
||||
*/
|
||||
events: {
|
||||
default: DEFAULT_EVENT_TYPES,
|
||||
parse: parseNumber(0),
|
||||
},
|
||||
/**
|
||||
* Amount of frames rendered per second, used when mocking animation frames.
|
||||
* @default 60
|
||||
*/
|
||||
fps: {
|
||||
default: 60,
|
||||
parse: parseNumber(60),
|
||||
},
|
||||
/**
|
||||
* Lights up the mood.
|
||||
* @default false
|
||||
*/
|
||||
fun: {
|
||||
default: false,
|
||||
parse: parseBoolean(true),
|
||||
},
|
||||
/**
|
||||
* Whether to render the test runner user interface.
|
||||
* Note: this cannot be changed at runtime: the UI will not be un-rendered or
|
||||
* rendered if this parameter changes.
|
||||
* @default false
|
||||
*/
|
||||
headless: {
|
||||
default: false,
|
||||
parse: parseBoolean(true),
|
||||
},
|
||||
/**
|
||||
* Log level used by the test runner. The higher the level, the more logs will
|
||||
* be displayed.
|
||||
*/
|
||||
loglevel: {
|
||||
default: 0,
|
||||
parse: parseNumber(0),
|
||||
},
|
||||
/**
|
||||
* Whether the test runner must be manually started after page load (defaults
|
||||
* to starting automatically).
|
||||
* @default false
|
||||
*/
|
||||
manual: {
|
||||
default: false,
|
||||
parse: parseBoolean(true),
|
||||
},
|
||||
/**
|
||||
* Artifical delay introduced for each network call. It can be a fixed integer,
|
||||
* or an integer range (in the form "min-max") to generate a random delay between
|
||||
* "min" and "max".
|
||||
* @default 0
|
||||
*/
|
||||
networkDelay: {
|
||||
default: "0",
|
||||
parse: parseString("0"),
|
||||
},
|
||||
/**
|
||||
* Removes the safety of 'try .. catch' statements around each test's run function
|
||||
* to let errors bubble to the browser.
|
||||
* @default false
|
||||
*/
|
||||
notrycatch: {
|
||||
default: false,
|
||||
parse: parseBoolean(true),
|
||||
},
|
||||
/**
|
||||
* Determines the order of the tests execution.
|
||||
* - `"fifo"`: tests will be run sequentially as declared in the file system.
|
||||
* - `"lifo"`: tests will be run sequentially in the reverse order.
|
||||
* - `"random"`: shuffles tests and suites within their parent suite.
|
||||
* @default "fifo"
|
||||
*/
|
||||
order: {
|
||||
default: "fifo",
|
||||
parse: parseString(""),
|
||||
},
|
||||
/**
|
||||
* Environment in which the test runner is running. This parameter is used to
|
||||
* determine the default value of other parameters, namely:
|
||||
* - the user agent;
|
||||
* - touch support;
|
||||
* - size of the viewport.
|
||||
* @default "" no specific parameters are set
|
||||
*/
|
||||
preset: {
|
||||
default: "",
|
||||
parse: parseString(""),
|
||||
},
|
||||
/**
|
||||
* Determines the seed from which random numbers will be generated.
|
||||
* @default 0
|
||||
*/
|
||||
random: {
|
||||
default: 0,
|
||||
parse: parseString(generateSeed()),
|
||||
},
|
||||
/**
|
||||
* Determines how the failed tests must be unfolded in the UI:
|
||||
* - "first-fail": only the first failed test will be unfolded
|
||||
* - "failed": all failed tests will be unfolded
|
||||
* - false: all tests will remain folded
|
||||
* @default "first-fail"
|
||||
*/
|
||||
showdetail: {
|
||||
default: "first-fail",
|
||||
parse: parseShowDetail("failed"),
|
||||
},
|
||||
/**
|
||||
* Duration (in milliseconds) at the end of which a test will automatically fail.
|
||||
* @default 5_000
|
||||
*/
|
||||
timeout: {
|
||||
default: 5_000,
|
||||
parse: parseNumber(5_000),
|
||||
},
|
||||
};
|
||||
|
||||
export const FILTER_SCHEMA = {
|
||||
/**
|
||||
* Search string that will filter matching tests/suites, based on:
|
||||
* - their full name (including their parent suite(s))
|
||||
* - their tags
|
||||
* @default ""
|
||||
*/
|
||||
filter: {
|
||||
aliases: ["name"],
|
||||
default: "",
|
||||
parse: parseString(""),
|
||||
},
|
||||
/**
|
||||
* IDs of the suites OR tests to run exclusively. The ID of a job is generated
|
||||
* deterministically based on its full name.
|
||||
* @default []
|
||||
*/
|
||||
id: {
|
||||
aliases: ["ids"],
|
||||
default: [],
|
||||
parse: parseStringArray([]),
|
||||
},
|
||||
/**
|
||||
* Tag names of tests and suites to run exclusively (case insensitive).
|
||||
* @default []
|
||||
*/
|
||||
tag: {
|
||||
aliases: ["tags"],
|
||||
default: [],
|
||||
parse: parseStringArray([]),
|
||||
},
|
||||
};
|
||||
|
||||
/** @see {@link CONFIG_SCHEMA} */
|
||||
export const DEFAULT_CONFIG = getSchemaDefaults(CONFIG_SCHEMA);
|
||||
export const CONFIG_KEYS = getSchemaKeys(CONFIG_SCHEMA);
|
||||
|
||||
/** @see {@link FILTER_SCHEMA} */
|
||||
export const DEFAULT_FILTERS = getSchemaDefaults(FILTER_SCHEMA);
|
||||
export const FILTER_KEYS = getSchemaKeys(FILTER_SCHEMA);
|
||||
2437
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/expect.js
Normal file
2437
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/expect.js
Normal file
File diff suppressed because it is too large
Load diff
226
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/fixture.js
Normal file
226
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/fixture.js
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { animationFrame } from "@odoo/hoot-dom";
|
||||
import { App } from "@odoo/owl";
|
||||
import { getActiveElement, getCurrentDimensions } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import { setupEventActions } from "@web/../lib/hoot-dom/helpers/events";
|
||||
import { isInstanceOf } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import { HootError } from "../hoot_utils";
|
||||
import { subscribeToTransitionChange } from "../mock/animation";
|
||||
import { getViewPortHeight, getViewPortWidth } from "../mock/window";
|
||||
|
||||
/**
|
||||
* @typedef {Parameters<typeof import("@odoo/owl").mount>[2] & {
|
||||
* className: string | string[];
|
||||
* target?: import("@odoo/hoot-dom").Target;
|
||||
* }} MountOnFixtureOptions
|
||||
*
|
||||
* @typedef {{
|
||||
* component: import("@odoo/owl").ComponentConstructor;
|
||||
* props: unknown;
|
||||
* }} TestRootProps
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { customElements, document, getSelection, HTMLElement, Promise, WeakSet } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {HTMLIFrameElement} iframe
|
||||
*/
|
||||
function waitForIframe(iframe) {
|
||||
return new Promise((resolve) => iframe.addEventListener("load", resolve));
|
||||
}
|
||||
|
||||
const destroyed = new WeakSet();
|
||||
let allowFixture = false;
|
||||
/** @type {HootFixtureElement | null} */
|
||||
let currentFixture = null;
|
||||
let shouldPrepareNextFixture = true; // Prepare setup for first test
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {App | import("@odoo/owl").Component} target
|
||||
*/
|
||||
export function destroy(target) {
|
||||
const app = isInstanceOf(target, App) ? target : target.__owl__.app;
|
||||
if (destroyed.has(app)) {
|
||||
return;
|
||||
}
|
||||
destroyed.add(app);
|
||||
app.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./runner").Runner} runner
|
||||
*/
|
||||
export function makeFixtureManager(runner) {
|
||||
function cleanup() {
|
||||
allowFixture = false;
|
||||
|
||||
if (currentFixture) {
|
||||
shouldPrepareNextFixture = true;
|
||||
currentFixture.remove();
|
||||
currentFixture = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getFixture() {
|
||||
if (!allowFixture) {
|
||||
throw new HootError(`cannot access fixture outside of a test.`);
|
||||
}
|
||||
if (!currentFixture) {
|
||||
// Prepare fixture once to not force layouts/reflows
|
||||
currentFixture = document.createElement(HootFixtureElement.TAG_NAME);
|
||||
if (runner.debug || runner.headless) {
|
||||
currentFixture.show();
|
||||
}
|
||||
|
||||
const { width, height } = getCurrentDimensions();
|
||||
if (width !== getViewPortWidth()) {
|
||||
currentFixture.style.width = `${width}px`;
|
||||
}
|
||||
if (height !== getViewPortHeight()) {
|
||||
currentFixture.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
document.body.appendChild(currentFixture);
|
||||
}
|
||||
return currentFixture;
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
allowFixture = true;
|
||||
|
||||
if (shouldPrepareNextFixture) {
|
||||
shouldPrepareNextFixture = false;
|
||||
|
||||
// Reset focus & selection
|
||||
getActiveElement().blur();
|
||||
getSelection().removeAllRanges();
|
||||
// Wait for selectionchange events to expire before any actual testing.
|
||||
await animationFrame();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup,
|
||||
setup,
|
||||
get: getFixture,
|
||||
};
|
||||
}
|
||||
|
||||
export class HootFixtureElement extends HTMLElement {
|
||||
static CLASSES = {
|
||||
transitions: "allow-transitions",
|
||||
show: "show-fixture",
|
||||
};
|
||||
static TAG_NAME = "hoot-fixture";
|
||||
|
||||
static styleElement = document.createElement("style");
|
||||
|
||||
static {
|
||||
customElements.define(this.TAG_NAME, this);
|
||||
|
||||
this.styleElement.id = "hoot-fixture-style";
|
||||
this.styleElement.textContent = /* css */ `
|
||||
${this.TAG_NAME} {
|
||||
position: fixed !important;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
${this.TAG_NAME}.${this.CLASSES.show} {
|
||||
background-color: inherit;
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
${this.TAG_NAME}:not(.${this.CLASSES.transitions}) * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
get hasIframes() {
|
||||
return this._iframes.size > 0;
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_observer = new MutationObserver(this._onFixtureMutation.bind(this));
|
||||
/**
|
||||
* @private
|
||||
* @type {Map<HTMLIFrameElement, Promise<void>>}
|
||||
*/
|
||||
_iframes = new Map();
|
||||
|
||||
connectedCallback() {
|
||||
setupEventActions(this);
|
||||
subscribeToTransitionChange((allowTransitions) =>
|
||||
this.classList.toggle(this.constructor.CLASSES.transitions, allowTransitions)
|
||||
);
|
||||
|
||||
this._observer.observe(this, { childList: true, subtree: true });
|
||||
this._lookForIframes();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._iframes.clear();
|
||||
this._observer.disconnect();
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.classList.remove(this.constructor.CLASSES.show);
|
||||
}
|
||||
|
||||
async waitForIframes() {
|
||||
await Promise.all(this._iframes.values());
|
||||
}
|
||||
|
||||
show() {
|
||||
this.classList.add(this.constructor.CLASSES.show);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_lookForIframes() {
|
||||
const toRemove = new Set(this._iframes.keys());
|
||||
for (const iframe of this.getElementsByTagName("iframe")) {
|
||||
if (toRemove.delete(iframe)) {
|
||||
continue;
|
||||
}
|
||||
this._iframes.set(iframe, waitForIframe(iframe));
|
||||
setupEventActions(iframe.contentWindow);
|
||||
}
|
||||
for (const iframe of toRemove) {
|
||||
this._iframes.delete(iframe);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @type {MutationCallback}
|
||||
*/
|
||||
_onFixtureMutation(mutations) {
|
||||
if (mutations.some((mutation) => mutation.addedNodes)) {
|
||||
this._lookForIframes();
|
||||
}
|
||||
}
|
||||
}
|
||||
135
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/job.js
Normal file
135
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/job.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { generateHash, HootError, isOfType, normalize } from "../hoot_utils";
|
||||
import { applyTags } from "./tag";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* debug?: boolean;
|
||||
* multi?: number;
|
||||
* only?: boolean;
|
||||
* skip?: boolean;
|
||||
* timeout?: number;
|
||||
* todo?: boolean;
|
||||
* }} JobConfig
|
||||
*
|
||||
* @typedef {import("./tag").Tag} Tag
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { assign: $assign, entries: $entries },
|
||||
Symbol,
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {JobConfig} config
|
||||
*/
|
||||
function validateConfig(config) {
|
||||
for (const [key, value] of $entries(config)) {
|
||||
if (!isOfType(value, CONFIG_TAG_SCHEMA[key])) {
|
||||
throw new HootError(`invalid config tag: parameter "${key}" does not exist`, {
|
||||
level: "critical",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<keyof JobConfig, import("../hoot_utils").ArgumentType>} */
|
||||
const CONFIG_TAG_SCHEMA = {
|
||||
debug: "boolean",
|
||||
multi: "number",
|
||||
only: "boolean",
|
||||
skip: "boolean",
|
||||
timeout: "number",
|
||||
todo: "boolean",
|
||||
};
|
||||
|
||||
const S_MINIMIZED = Symbol("minimized");
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export class Job {
|
||||
/** @type {JobConfig} */
|
||||
config = {};
|
||||
/** @type {Job[]} */
|
||||
path = [this];
|
||||
runCount = 0;
|
||||
/** @type {Tag[]} */
|
||||
tags = [];
|
||||
|
||||
get isMinimized() {
|
||||
return S_MINIMIZED in this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./suite").Suite | null} parent
|
||||
* @param {string} name
|
||||
* @param {JobConfig & { tags?: Iterable<Tag> }} config
|
||||
*/
|
||||
constructor(parent, name, config) {
|
||||
this.parent = parent || null;
|
||||
this.name = name;
|
||||
|
||||
if (this.parent) {
|
||||
// Assigns parent path and config (ignoring multi)
|
||||
const parentConfig = {
|
||||
...this.parent.config,
|
||||
tags: this.parent.tags,
|
||||
};
|
||||
delete parentConfig.multi;
|
||||
this.configure(parentConfig);
|
||||
this.path.unshift(...this.parent.path);
|
||||
}
|
||||
|
||||
this.fullName = this.path.map((job) => job.name).join("/");
|
||||
this.id = generateHash(this.fullName);
|
||||
this.key = normalize(this.fullName);
|
||||
|
||||
this.configure(config);
|
||||
}
|
||||
|
||||
after() {
|
||||
for (const tag of this.tags) {
|
||||
tag.after?.(this);
|
||||
}
|
||||
}
|
||||
|
||||
before() {
|
||||
for (const tag of this.tags) {
|
||||
tag.before?.(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {JobConfig & { tags?: Iterable<Tag> }} config
|
||||
*/
|
||||
configure({ tags, ...config }) {
|
||||
// Assigns and validates job config
|
||||
$assign(this.config, config);
|
||||
validateConfig(this.config);
|
||||
|
||||
// Add tags
|
||||
applyTags(this, tags);
|
||||
}
|
||||
|
||||
minimize() {
|
||||
this[S_MINIMIZED] = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
willRunAgain() {
|
||||
return this.runCount < (this.config.multi || 0) || this.parent?.willRunAgain();
|
||||
}
|
||||
}
|
||||
377
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/logger.js
Normal file
377
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/logger.js
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { getColorHex } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { stringify } from "../hoot_utils";
|
||||
import { urlParams } from "./url";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
console: {
|
||||
debug: $debug,
|
||||
dir: $dir,
|
||||
error: $error,
|
||||
groupCollapsed: $groupCollapsed,
|
||||
groupEnd: $groupEnd,
|
||||
log: $log,
|
||||
table: $table,
|
||||
trace: $trace,
|
||||
warn: $warn,
|
||||
},
|
||||
Object: { entries: $entries, getOwnPropertyDescriptors: $getOwnPropertyDescriptors },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {any[]} args
|
||||
* @param {string} [prefix]
|
||||
* @param {string} [prefixColor]
|
||||
*/
|
||||
function styledArguments(args, prefix, prefixColor) {
|
||||
const fullPrefix = `%c[${prefix || DEFAULT_PREFIX[0]}]%c`;
|
||||
const styles = [`color:${prefixColor || DEFAULT_PREFIX[1]};font-weight:bold`, ""];
|
||||
const firstArg = args.shift() ?? "";
|
||||
if (typeof firstArg === "string") {
|
||||
args.unshift(`${fullPrefix} ${firstArg}`, ...styles);
|
||||
} else {
|
||||
args.unshift(fullPrefix, ...styles, firstArg);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any[]} args
|
||||
*/
|
||||
function unstyledArguments(args) {
|
||||
const prefix = `[${DEFAULT_PREFIX[0]}]`;
|
||||
const firstArg = args.shift() ?? "";
|
||||
if (typeof firstArg === "string") {
|
||||
args.unshift(`${prefix} ${firstArg}`);
|
||||
} else {
|
||||
args.unshift(prefix, firstArg);
|
||||
}
|
||||
return [args.join(" ")];
|
||||
}
|
||||
|
||||
class Logger {
|
||||
/** @private */
|
||||
issueLevel;
|
||||
/** @private */
|
||||
logLevel;
|
||||
|
||||
constructor(logLevel, issueLevel) {
|
||||
this.logLevel = logLevel;
|
||||
this.issueLevel = issueLevel;
|
||||
|
||||
// Pre-bind all methods for ease of use
|
||||
for (const [key, desc] of $entries($getOwnPropertyDescriptors(Logger.prototype))) {
|
||||
if (key !== "constructor" && typeof desc.value === "function") {
|
||||
this[key] = this[key].bind(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get global() {
|
||||
return new Logger(this.logLevel, ISSUE_LEVELS.global);
|
||||
}
|
||||
|
||||
// Standard console methods
|
||||
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
debug(...args) {
|
||||
$debug(...styledArguments(args));
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
error(...args) {
|
||||
switch (this.issueLevel) {
|
||||
case ISSUE_LEVELS.suppressed: {
|
||||
$groupCollapsed(...styledArguments(["suppressed"], ...ERROR_PREFIX));
|
||||
$trace(...args);
|
||||
$groupEnd();
|
||||
break;
|
||||
}
|
||||
case ISSUE_LEVELS.trace: {
|
||||
$trace(...styledArguments(args, ...ERROR_PREFIX));
|
||||
break;
|
||||
}
|
||||
case ISSUE_LEVELS.global: {
|
||||
$error(...styledArguments(args));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
$error(...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @param {any} arg
|
||||
* @param {() => any} callback
|
||||
*/
|
||||
group(title, callback) {
|
||||
$groupCollapsed(...styledArguments([title]));
|
||||
callback();
|
||||
$groupEnd();
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
table(...args) {
|
||||
$table(...args);
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
trace(...args) {
|
||||
$trace(...args);
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
warn(...args) {
|
||||
switch (this.issueLevel) {
|
||||
case ISSUE_LEVELS.suppressed: {
|
||||
$groupCollapsed(...styledArguments(["suppressed"], ...WARNING_PREFIX));
|
||||
$trace(...args);
|
||||
$groupEnd();
|
||||
break;
|
||||
}
|
||||
case ISSUE_LEVELS.global: {
|
||||
$warn(...styledArguments(args));
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
$warn(...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level-specific methods
|
||||
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
logDebug(...args) {
|
||||
if (!this.canLog("debug")) {
|
||||
return;
|
||||
}
|
||||
$debug(...styledArguments(args, ...DEBUG_PREFIX));
|
||||
}
|
||||
/**
|
||||
* @param {import("./suite").Suite} suite
|
||||
*/
|
||||
logSuite(suite) {
|
||||
if (!this.canLog("suites")) {
|
||||
return;
|
||||
}
|
||||
const args = [`${stringify(suite.fullName)} ended`];
|
||||
const withArgs = [];
|
||||
if (suite.reporting.passed) {
|
||||
withArgs.push("passed:", suite.reporting.passed, "/");
|
||||
}
|
||||
if (suite.reporting.failed) {
|
||||
withArgs.push("failed:", suite.reporting.failed, "/");
|
||||
}
|
||||
if (suite.reporting.skipped) {
|
||||
withArgs.push("skipped:", suite.reporting.skipped, "/");
|
||||
}
|
||||
if (withArgs.length) {
|
||||
args.push(
|
||||
`(${withArgs.shift()}`,
|
||||
...withArgs,
|
||||
"time:",
|
||||
suite.jobs.reduce((acc, job) => acc + (job.duration || 0), 0),
|
||||
"ms)"
|
||||
);
|
||||
}
|
||||
$log(...styledArguments(args));
|
||||
}
|
||||
/**
|
||||
* @param {import("./test").Test} test
|
||||
*/
|
||||
logTest(test) {
|
||||
if (!this.canLog("tests")) {
|
||||
return;
|
||||
}
|
||||
const { fullName, lastResults } = test;
|
||||
$log(
|
||||
...styledArguments([
|
||||
`Test ${stringify(fullName)} passed (assertions:`,
|
||||
lastResults.counts.assertion || 0,
|
||||
`/ time:`,
|
||||
lastResults.duration,
|
||||
`ms)`,
|
||||
])
|
||||
);
|
||||
}
|
||||
/**
|
||||
* @param {[label: string, color: string]} prefix
|
||||
* @param {...any} args
|
||||
*/
|
||||
logTestEvent(prefix, ...args) {
|
||||
$log(...styledArguments(args, ...prefix));
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
logRun(...args) {
|
||||
if (!this.canLog("runner")) {
|
||||
return;
|
||||
}
|
||||
$log(...styledArguments(args));
|
||||
}
|
||||
/**
|
||||
* @param {...any} args
|
||||
*/
|
||||
logGlobal(...args) {
|
||||
$dir(...unstyledArguments(args));
|
||||
}
|
||||
|
||||
// Other methods
|
||||
|
||||
/**
|
||||
* @param {keyof typeof LOG_LEVELS} level
|
||||
*/
|
||||
canLog(level) {
|
||||
return this.logLevel >= LOG_LEVELS[level];
|
||||
}
|
||||
/**
|
||||
* @param {keyof typeof ISSUE_LEVELS} level
|
||||
*/
|
||||
setIssueLevel(level) {
|
||||
const restoreIssueLevel = () => {
|
||||
this.issueLevel = previous;
|
||||
};
|
||||
const previous = this.issueLevel;
|
||||
this.issueLevel = ISSUE_LEVELS[level];
|
||||
return restoreIssueLevel;
|
||||
}
|
||||
/**
|
||||
* @param {keyof typeof LOG_LEVELS} level
|
||||
*/
|
||||
setLogLevel(level) {
|
||||
const restoreLogLevel = () => {
|
||||
this.logLevel = previous;
|
||||
};
|
||||
const previous = this.logLevel;
|
||||
this.logLevel = LOG_LEVELS[level];
|
||||
return restoreLogLevel;
|
||||
}
|
||||
}
|
||||
|
||||
const DEBUG_PREFIX = ["DEBUG", getColorHex("purple")];
|
||||
const DEFAULT_PREFIX = ["HOOT", getColorHex("primary")];
|
||||
const ERROR_PREFIX = ["ERROR", getColorHex("rose")];
|
||||
const WARNING_PREFIX = ["WARNING", getColorHex("amber")];
|
||||
let nextNetworkLogId = 1;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} prefix
|
||||
* @param {string} title
|
||||
*/
|
||||
export function makeNetworkLogger(prefix, title) {
|
||||
const id = nextNetworkLogId++;
|
||||
return {
|
||||
/**
|
||||
* Request logger: blue-ish.
|
||||
* @param {() => any} getData
|
||||
*/
|
||||
async logRequest(getData) {
|
||||
if (!logger.canLog("debug")) {
|
||||
return;
|
||||
}
|
||||
const color = `color: #66e`;
|
||||
const styles = [`${color}; font-weight: bold;`, color];
|
||||
$groupCollapsed(`-> %c${prefix}#${id}%c<${title}>`, ...styles, await getData());
|
||||
$trace("request trace");
|
||||
$groupEnd();
|
||||
},
|
||||
/**
|
||||
* Response logger: orange.
|
||||
* @param {() => any} getData
|
||||
*/
|
||||
async logResponse(getData) {
|
||||
if (!logger.canLog("debug")) {
|
||||
return;
|
||||
}
|
||||
const color = `color: #f80`;
|
||||
const styles = [`${color}; font-weight: bold;`, color];
|
||||
$log(`<- %c${prefix}#${id}%c<${title}>`, ...styles, await getData());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ISSUE_LEVELS = {
|
||||
/**
|
||||
* Suppressed:
|
||||
*
|
||||
* Condition:
|
||||
* - typically: in "todo" tests where issues should be ignored
|
||||
*
|
||||
* Effect:
|
||||
* - all errors and warnings are replaced by 'trace' calls
|
||||
*/
|
||||
suppressed: 0,
|
||||
/**
|
||||
* Trace:
|
||||
*
|
||||
* Condition:
|
||||
* - default level within a test run
|
||||
*
|
||||
* Effect:
|
||||
* - warnings are left as-is;
|
||||
* - errors are replaced by 'trace' calls, so that the actual console error
|
||||
* comes from the test runner with a summary of all failed reasons.
|
||||
*/
|
||||
trace: 1,
|
||||
/**
|
||||
* Global:
|
||||
*
|
||||
* Condition:
|
||||
* - errors which should be reported globally but not interrupt the run
|
||||
*
|
||||
* Effect:
|
||||
* - warnings are left as-is;
|
||||
* - errors are wrapped with a "HOOT" prefix, as to not stop the current test
|
||||
* run. Can typically be used to log test failed reasons.
|
||||
*/
|
||||
global: 2,
|
||||
/**
|
||||
* Critical:
|
||||
*
|
||||
* Condition:
|
||||
* - any error compromising the whole test run and should cancel or interrupt it
|
||||
* - default level outside of a test run (import errors, module root errors, etc.)
|
||||
*
|
||||
* Effect:
|
||||
* - warnings are left as-is;
|
||||
* - errors are left as-is, as to tell the server test to stop the current
|
||||
* (Python) test.
|
||||
*/
|
||||
critical: 3,
|
||||
};
|
||||
export const LOG_LEVELS = {
|
||||
runner: 0,
|
||||
suites: 1,
|
||||
tests: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
export const logger = new Logger(
|
||||
urlParams.loglevel ?? LOG_LEVELS.runner,
|
||||
ISSUE_LEVELS.critical // by default, all errors are "critical", i.e. should abort the whole run
|
||||
);
|
||||
1996
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/runner.js
Normal file
1996
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/runner.js
Normal file
File diff suppressed because it is too large
Load diff
127
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/suite.js
Normal file
127
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/suite.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { Callbacks, HootError, createReporting, stringify } from "../hoot_utils";
|
||||
import { Job } from "./job";
|
||||
|
||||
/**
|
||||
* @typedef {import("./tag").Tag} Tag
|
||||
*
|
||||
* @typedef {import("./test").Test} Test
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { freeze: $freeze },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
class MinimalCallbacks extends Callbacks {
|
||||
add() {}
|
||||
call() {}
|
||||
callSync() {}
|
||||
clear() {}
|
||||
}
|
||||
|
||||
const SHARED_CALLBACKS = new MinimalCallbacks();
|
||||
const SHARED_CURRENT_JOBS = $freeze([]);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Pick<Suite, "name" | "parent">} suite
|
||||
* @param {Error | string} message
|
||||
* @returns {HootError}
|
||||
*/
|
||||
export function suiteError({ name, parent }, message) {
|
||||
const parentString = parent ? ` (in parent suite ${stringify(parent.name)})` : "";
|
||||
const errorOptions = { level: "critical" };
|
||||
let errorMessage = `error while registering suite ${stringify(name)}${parentString}`;
|
||||
if (message instanceof Error) {
|
||||
errorOptions.cause = message;
|
||||
} else {
|
||||
errorMessage += `: ${message}`;
|
||||
}
|
||||
return new HootError(errorMessage, errorOptions);
|
||||
}
|
||||
|
||||
export class Suite extends Job {
|
||||
callbacks = new Callbacks();
|
||||
currentJobIndex = 0;
|
||||
/** @type {(Suite | Test)[]} */
|
||||
currentJobs = [];
|
||||
/** @type {(Suite | Test)[]} */
|
||||
jobs = [];
|
||||
reporting = createReporting();
|
||||
|
||||
totalSuiteCount = 0;
|
||||
totalTestCount = 0;
|
||||
|
||||
get weight() {
|
||||
return this.totalTestCount;
|
||||
}
|
||||
|
||||
addJob(job) {
|
||||
this.jobs.push(job);
|
||||
|
||||
if (job instanceof Suite) {
|
||||
this.increaseSuiteCount();
|
||||
} else {
|
||||
this.increaseTestCount();
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.parent?.reporting.add({ suites: +1 });
|
||||
this.minimize();
|
||||
}
|
||||
|
||||
minimize() {
|
||||
super.minimize();
|
||||
|
||||
this.callbacks.clear();
|
||||
|
||||
this.callbacks = SHARED_CALLBACKS;
|
||||
this.currentJobs = SHARED_CURRENT_JOBS;
|
||||
}
|
||||
|
||||
increaseSuiteCount() {
|
||||
this.totalSuiteCount++;
|
||||
this.parent?.increaseSuiteCount();
|
||||
}
|
||||
|
||||
increaseTestCount() {
|
||||
this.totalTestCount++;
|
||||
this.parent?.increaseTestCount();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentJobIndex = 0;
|
||||
|
||||
for (const job of this.jobs) {
|
||||
job.runCount = 0;
|
||||
|
||||
if (job instanceof Suite) {
|
||||
job.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Job[]} jobs
|
||||
*/
|
||||
setCurrentJobs(jobs) {
|
||||
if (this.isMinimized) {
|
||||
return;
|
||||
}
|
||||
this.currentJobs = jobs;
|
||||
this.currentJobIndex = 0;
|
||||
}
|
||||
}
|
||||
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/tag.js
Normal file
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/tag.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { HootError, levenshtein, normalize, stringify, stringToNumber } from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("./job").Job} Job
|
||||
* @typedef {import("./suite").Suite} Suite
|
||||
* @typedef {import("./suite").Test} Test
|
||||
*
|
||||
* @typedef {{
|
||||
* name: string;
|
||||
* exclude?: string[];
|
||||
* before?: (test: Test) => any;
|
||||
* after?: (test: Test) => any;
|
||||
* }} TagDefinition
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Math: { ceil: $ceil, max: $max },
|
||||
Object: { create: $create, keys: $keys },
|
||||
Set,
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Checks for similarity with other existing tag names.
|
||||
*
|
||||
* A tag name is considered similar to another if the following conditions are met:
|
||||
* - it doesn't include numbers (the number is likely meaningful enough to dissociate
|
||||
* it from other similar tags);
|
||||
* - the edit distance between the 2 is <= 10% of the length of the largest string
|
||||
*
|
||||
* @param {string} tagKey
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function checkTagSimilarity(tagKey, tagName) {
|
||||
if (R_UNIQUE_TAG.test(tagKey)) {
|
||||
return;
|
||||
}
|
||||
for (const key of $keys(existingTags)) {
|
||||
if (R_UNIQUE_TAG.test(key)) {
|
||||
continue;
|
||||
}
|
||||
const maxLength = $max(tagKey.length, key.length);
|
||||
const threshold = $ceil(SIMILARITY_PERCENTAGE * maxLength);
|
||||
const editDistance = levenshtein(key, tagKey);
|
||||
if (editDistance <= threshold) {
|
||||
similarities.push([existingTags[key], tagName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const R_UNIQUE_TAG = /\d/;
|
||||
const SIMILARITY_PERCENTAGE = 0.1;
|
||||
const TAG_COLORS = [
|
||||
["#f97316", "#ffedd5"], // orange
|
||||
["#eab308", "#fef9c3"], // yellow
|
||||
["#84cc16", "#ecfccb"], // lime
|
||||
["#10b981", "#d1fae5"], // emerald
|
||||
["#06b6d4", "#cffafe"], // cyan
|
||||
["#3b82f6", "#dbeafe"], // blue
|
||||
["#6366f1", "#e0e7ff"], // indigo
|
||||
["#d946ef", "#fae8ff"], // fuschia
|
||||
["#f43f5e", "#ffe4e6"], // rose
|
||||
];
|
||||
|
||||
/** @type {Record<string, Tag>} */
|
||||
const existingTags = $create(null);
|
||||
/** @type {[string, string][]} */
|
||||
const similarities = [];
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Job} job
|
||||
* @param {Iterable<Tag>} [tags]
|
||||
*/
|
||||
export function applyTags(job, tags) {
|
||||
if (!tags?.length) {
|
||||
return;
|
||||
}
|
||||
const existingKeys = new Set(job.tags.map((t) => t.key));
|
||||
for (const tag of tags) {
|
||||
if (existingKeys.has(tag.key)) {
|
||||
continue;
|
||||
}
|
||||
const excluded = tag.exclude?.filter((key) => existingKeys.has(key));
|
||||
if (excluded?.length) {
|
||||
throw new HootError(
|
||||
`cannot apply tag ${stringify(tag.name)} on test/suite ${stringify(
|
||||
job.name
|
||||
)} as it explicitly excludes tags ${excluded.map(stringify).join(" & ")}`,
|
||||
{ level: "global" }
|
||||
);
|
||||
}
|
||||
job.tags.push(tag);
|
||||
existingKeys.add(tag.key);
|
||||
tag.weight++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Globally defines specifications for a list of tags.
|
||||
* This is useful to add metadata or side-effects to a given tag, like an exclusion
|
||||
* to prevent specific tags to be added at the same time.
|
||||
*
|
||||
* @param {...TagDefinition} definitions
|
||||
* @example
|
||||
* defineTags({
|
||||
* name: "desktop",
|
||||
* exclude: ["mobile"],
|
||||
* });
|
||||
*/
|
||||
export function defineTags(...definitions) {
|
||||
return definitions.map((def) => {
|
||||
const tagKey = def.key || normalize(def.name.toLowerCase());
|
||||
if (existingTags[tagKey]) {
|
||||
throw new HootError(`duplicate definition for tag "${def.name}"`, {
|
||||
level: "global",
|
||||
});
|
||||
}
|
||||
checkTagSimilarity(tagKey, def.name);
|
||||
|
||||
existingTags[tagKey] = new Tag(tagKey, def);
|
||||
|
||||
return existingTags[tagKey];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} tagNames
|
||||
*/
|
||||
export function getTags(tagNames) {
|
||||
return tagNames.map((tagKey, i) => {
|
||||
const nKey = normalize(tagKey.toLowerCase());
|
||||
const tag = existingTags[nKey] || defineTags({ key: nKey, name: tagNames[i] })[0];
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
|
||||
export function getTagSimilarities() {
|
||||
return similarities;
|
||||
}
|
||||
|
||||
/**
|
||||
* ! SHOULD NOT BE EXPORTED OUTSIDE OF HOOT
|
||||
*
|
||||
* Used in Hoot internal tests to remove tags introduced within a test.
|
||||
*
|
||||
* @private
|
||||
* @param {Iterable<string>} tagKeys
|
||||
*/
|
||||
export function undefineTags(tagKeys) {
|
||||
for (const tagKey of tagKeys) {
|
||||
delete existingTags[tagKey];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Should **not** be instantiated outside of {@link defineTags}.
|
||||
* @see {@link defineTags}
|
||||
*/
|
||||
export class Tag {
|
||||
static DEBUG = "debug";
|
||||
static ONLY = "only";
|
||||
static SKIP = "skip";
|
||||
static TODO = "todo";
|
||||
|
||||
weight = 0;
|
||||
|
||||
get id() {
|
||||
return this.key;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key normalized tag name
|
||||
* @param {TagDefinition} definition
|
||||
*/
|
||||
constructor(key, { name, exclude, before, after }) {
|
||||
this.key = key;
|
||||
this.name = name;
|
||||
this.color = TAG_COLORS[stringToNumber(this.key) % TAG_COLORS.length];
|
||||
if (exclude) {
|
||||
this.exclude = exclude.map((id) => normalize(id.toLowerCase()));
|
||||
}
|
||||
if (before) {
|
||||
this.before = before;
|
||||
}
|
||||
if (after) {
|
||||
this.after = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
161
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/test.js
Normal file
161
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/test.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { markup, reactive } from "@odoo/owl";
|
||||
import { HootError, stringify } from "../hoot_utils";
|
||||
import { Job } from "./job";
|
||||
import { Tag } from "./tag";
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @typedef {T | PromiseLike<T>} MaybePromise
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { freeze: $freeze },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const SHARED_LOGS = $freeze({});
|
||||
const SHARED_RESULTS = $freeze([]);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Pick<Test, "name" | "parent">} test
|
||||
* @returns {HootError}
|
||||
*/
|
||||
export function testError({ name, parent }, ...message) {
|
||||
const parentString = parent ? ` (in suite ${stringify(parent.name)})` : "";
|
||||
return new HootError(
|
||||
`error while registering test ${stringify(name)}${parentString}: ${message.join("\n")}`,
|
||||
{ level: "critical" }
|
||||
);
|
||||
}
|
||||
|
||||
export class Test extends Job {
|
||||
static SKIPPED = 0;
|
||||
static PASSED = 1;
|
||||
static FAILED = 2;
|
||||
static ABORTED = 3;
|
||||
|
||||
formatted = false;
|
||||
logs = reactive({
|
||||
error: 0,
|
||||
warn: 0,
|
||||
});
|
||||
/** @type {import("./expect").CaseResult[]} */
|
||||
results = reactive([]);
|
||||
/** @type {() => MaybePromise<void> | null} */
|
||||
run = null;
|
||||
runFnString = "";
|
||||
status = Test.SKIPPED;
|
||||
|
||||
get code() {
|
||||
if (!this.formatted) {
|
||||
this.formatted = true;
|
||||
this.runFnString = this.formatFunctionSource(this.runFnString);
|
||||
if (window.Prism) {
|
||||
const highlighted = window.Prism.highlight(
|
||||
this.runFnString,
|
||||
Prism.languages.javascript,
|
||||
"javascript"
|
||||
);
|
||||
this.runFnString = markup(highlighted);
|
||||
}
|
||||
}
|
||||
return this.runFnString;
|
||||
}
|
||||
|
||||
get duration() {
|
||||
return this.results.reduce((acc, result) => acc + result.duration, 0);
|
||||
}
|
||||
|
||||
/** @returns {import("./expect").CaseResult | null} */
|
||||
get lastResults() {
|
||||
return this.results.at(-1);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.run = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} stringFn
|
||||
*/
|
||||
formatFunctionSource(stringFn) {
|
||||
let modifiers = "";
|
||||
let startingLine = 0;
|
||||
if (this.name) {
|
||||
for (const tag of this.tags) {
|
||||
if (this.parent.tags.includes(tag)) {
|
||||
continue;
|
||||
}
|
||||
switch (tag.key) {
|
||||
case Tag.TODO:
|
||||
case Tag.DEBUG:
|
||||
case Tag.SKIP:
|
||||
case Tag.ONLY: {
|
||||
modifiers += `.${tag.key}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startingLine++;
|
||||
stringFn = `test${modifiers}(${stringify(this.name)}, ${stringFn});`;
|
||||
}
|
||||
|
||||
const lines = stringFn.split("\n");
|
||||
|
||||
let toTrim = null;
|
||||
for (let i = startingLine; i < lines.length; i++) {
|
||||
if (!lines[i].trim()) {
|
||||
continue;
|
||||
}
|
||||
const [, whiteSpaces] = lines[i].match(/^(\s*)/);
|
||||
if (toTrim === null || whiteSpaces.length < toTrim) {
|
||||
toTrim = whiteSpaces.length;
|
||||
}
|
||||
}
|
||||
if (toTrim) {
|
||||
for (let i = startingLine; i < lines.length; i++) {
|
||||
lines[i] = lines[i].slice(toTrim);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
minimize() {
|
||||
super.minimize();
|
||||
|
||||
this.setRunFn(null);
|
||||
this.runFnString = "";
|
||||
this.logs = SHARED_LOGS;
|
||||
this.results = SHARED_RESULTS;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.run = this.run.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => MaybePromise<void>} fn
|
||||
*/
|
||||
setRunFn(fn) {
|
||||
this.run = fn ? async () => fn() : null;
|
||||
if (fn) {
|
||||
this.formatted = false;
|
||||
this.runFnString = fn.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/url.js
Normal file
202
odoo-bringout-oca-ocb-web/web/static/lib/hoot/core/url.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { onWillRender, reactive, useState } from "@odoo/owl";
|
||||
import { isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import { debounce, ensureArray, isNil } from "../hoot_utils";
|
||||
import { CONFIG_KEYS, CONFIG_SCHEMA, FILTER_KEYS, FILTER_SCHEMA } from "./config";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* debug?: boolean;
|
||||
* ignore?: boolean;
|
||||
* }} CreateUrlFromIdOptions
|
||||
*
|
||||
* @typedef {typeof import("./config").DEFAULT_CONFIG} DEFAULT_CONFIG
|
||||
*
|
||||
* @typedef {typeof import("./config").DEFAULT_FILTERS} DEFAULT_FILTERS
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
history,
|
||||
location,
|
||||
Object: { entries: $entries },
|
||||
Set,
|
||||
URIError,
|
||||
URL,
|
||||
URLSearchParams,
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const debouncedUpdateUrl = debounce(function updateUrl() {
|
||||
const url = createUrl({});
|
||||
url.search = "";
|
||||
for (const [key, value] of $entries(urlParams)) {
|
||||
if (isIterable(value)) {
|
||||
for (const val of value) {
|
||||
if (val) {
|
||||
url.searchParams.append(key, val);
|
||||
}
|
||||
}
|
||||
} else if (value) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const path = url.toString();
|
||||
history.replaceState({ path }, "", path);
|
||||
}, 20);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Partial<DEFAULT_CONFIG & DEFAULT_FILTERS>} params
|
||||
*/
|
||||
export function createUrl(params) {
|
||||
const url = new URL(location.href);
|
||||
for (const key in params) {
|
||||
url.searchParams.delete(key);
|
||||
if (!CONFIG_KEYS.includes(key) && !FILTER_KEYS.includes(key)) {
|
||||
throw new URIError(`unknown URL param key: "${key}"`);
|
||||
}
|
||||
if (isIterable(params[key])) {
|
||||
for (const value of params[key]) {
|
||||
url.searchParams.append(key, value);
|
||||
}
|
||||
} else if (!isNil(params[key])) {
|
||||
url.searchParams.set(key, params[key]);
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<keyof DEFAULT_FILTERS, string | Iterable<string>>} specs
|
||||
* @param {CreateUrlFromIdOptions} [options]
|
||||
*/
|
||||
export function createUrlFromId(specs, options) {
|
||||
const nextParams = {};
|
||||
for (const key of FILTER_KEYS) {
|
||||
nextParams[key] = new Set(ensureArray((options?.ignore && urlParams[key]) || []));
|
||||
}
|
||||
for (const [type, id] of $entries(specs)) {
|
||||
const ids = ensureArray(id);
|
||||
switch (type) {
|
||||
case "id": {
|
||||
if (options?.ignore) {
|
||||
for (const id of ids) {
|
||||
const exludedId = EXCLUDE_PREFIX + id;
|
||||
if (nextParams.id.has(exludedId) || urlParams.id?.includes(exludedId)) {
|
||||
nextParams.id.delete(exludedId);
|
||||
} else {
|
||||
nextParams.id.add(exludedId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const id of ids) {
|
||||
nextParams.id.add(id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tag": {
|
||||
if (options?.ignore) {
|
||||
for (const id of ids) {
|
||||
const exludedId = EXCLUDE_PREFIX + id;
|
||||
if (urlParams.tag?.includes(exludedId)) {
|
||||
nextParams.tag.delete(exludedId);
|
||||
} else {
|
||||
nextParams.tag.add(exludedId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const id of ids) {
|
||||
nextParams.tag.add(id);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in nextParams) {
|
||||
if (!nextParams[key].size) {
|
||||
nextParams[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
nextParams.debugTest = options?.debug ? true : null;
|
||||
|
||||
return createUrl(nextParams);
|
||||
}
|
||||
|
||||
export function refresh() {
|
||||
history.go();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<DEFAULT_CONFIG & DEFAULT_FILTERS>} params
|
||||
*/
|
||||
export function setParams(params) {
|
||||
for (const [key, value] of $entries(params)) {
|
||||
if (!CONFIG_KEYS.includes(key) && !FILTER_KEYS.includes(key)) {
|
||||
throw new URIError(`unknown URL param key: "${key}"`);
|
||||
}
|
||||
if (value) {
|
||||
urlParams[key] = isIterable(value) ? [...value] : value;
|
||||
} else {
|
||||
delete urlParams[key];
|
||||
}
|
||||
}
|
||||
|
||||
debouncedUpdateUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {...(keyof DEFAULT_CONFIG | keyof DEFAULT_FILTERS | "*")} keys
|
||||
*/
|
||||
export function subscribeToURLParams(...keys) {
|
||||
const state = useState(urlParams);
|
||||
if (keys.length) {
|
||||
const observedKeys = keys.includes("*") ? [...CONFIG_KEYS, ...FILTER_KEYS] : keys;
|
||||
onWillRender(() => observedKeys.forEach((key) => state[key]));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export const EXCLUDE_PREFIX = "-";
|
||||
|
||||
/** @type {Partial<DEFAULT_CONFIG & DEFAULT_FILTERS>} */
|
||||
export const urlParams = reactive({});
|
||||
|
||||
// Update URL params immediatly
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const searchKeys = new Set(searchParams.keys());
|
||||
for (const [configKey, { aliases, parse }] of $entries({
|
||||
...CONFIG_SCHEMA,
|
||||
...FILTER_SCHEMA,
|
||||
})) {
|
||||
const configKeys = [configKey, ...(aliases || [])];
|
||||
/** @type {string[]} */
|
||||
const values = [];
|
||||
let hasKey = false;
|
||||
for (const key of configKeys) {
|
||||
if (searchKeys.has(key)) {
|
||||
hasKey = true;
|
||||
values.push(...searchParams.getAll(key).filter(Boolean));
|
||||
}
|
||||
}
|
||||
if (hasKey) {
|
||||
urlParams[configKey] = parse(values);
|
||||
} else {
|
||||
delete urlParams[configKey];
|
||||
}
|
||||
}
|
||||
33
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot-mock.js
Normal file
33
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot-mock.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/** @odoo-module alias=@odoo/hoot-mock default=false */
|
||||
|
||||
/**
|
||||
* @typedef {import("./mock/network").ServerWebSocket} ServerWebSocket
|
||||
*/
|
||||
|
||||
export {
|
||||
advanceFrame,
|
||||
advanceTime,
|
||||
animationFrame,
|
||||
cancelAllTimers,
|
||||
Deferred,
|
||||
delay,
|
||||
freezeTime,
|
||||
microTick,
|
||||
runAllTimers,
|
||||
setFrameRate,
|
||||
tick,
|
||||
unfreezeTime,
|
||||
} from "@odoo/hoot-dom";
|
||||
export { disableAnimations, enableTransitions } from "./mock/animation";
|
||||
export { mockDate, mockLocale, mockTimeZone, onTimeZoneChange } from "./mock/date";
|
||||
export { makeSeededRandom } from "./mock/math";
|
||||
export { mockPermission, mockSendBeacon, mockUserAgent, mockVibrate } from "./mock/navigator";
|
||||
export { mockFetch, mockLocation, mockWebSocket, mockWorker } from "./mock/network";
|
||||
export { flushNotifications } from "./mock/notification";
|
||||
export {
|
||||
mockMatchMedia,
|
||||
mockTouch,
|
||||
watchAddedNodes,
|
||||
watchKeys,
|
||||
watchListeners,
|
||||
} from "./mock/window";
|
||||
119
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot.js
Normal file
119
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/** @odoo-module alias=@odoo/hoot default=false */
|
||||
|
||||
import { logger } from "./core/logger";
|
||||
import { Runner } from "./core/runner";
|
||||
import { urlParams } from "./core/url";
|
||||
import { makeRuntimeHook } from "./hoot_utils";
|
||||
import { setRunner } from "./main_runner";
|
||||
import { setupHootUI } from "./ui/setup_hoot_ui";
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* runner: Runner;
|
||||
* ui: import("./ui/setup_hoot_ui").UiState
|
||||
* }} Environment
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const runner = new Runner(urlParams);
|
||||
|
||||
setRunner(runner);
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {...unknown} values
|
||||
*/
|
||||
export function registerDebugInfo(...values) {
|
||||
logger.logDebug(...values);
|
||||
}
|
||||
|
||||
// Main test API
|
||||
export const describe = runner.describe;
|
||||
export const expect = runner.expect;
|
||||
export const test = runner.test;
|
||||
|
||||
// Hooks
|
||||
export const after = makeRuntimeHook("after");
|
||||
export const afterEach = makeRuntimeHook("afterEach");
|
||||
export const before = makeRuntimeHook("before");
|
||||
export const beforeEach = makeRuntimeHook("beforeEach");
|
||||
export const onError = makeRuntimeHook("onError");
|
||||
|
||||
// Fixture
|
||||
export const getFixture = runner.fixture.get;
|
||||
|
||||
// Other functions
|
||||
export const definePreset = runner.exportFn(runner.definePreset);
|
||||
export const dryRun = runner.exportFn(runner.dryRun);
|
||||
export const getCurrent = runner.exportFn(runner.getCurrent);
|
||||
export const start = runner.exportFn(runner.start);
|
||||
export const stop = runner.exportFn(runner.stop);
|
||||
|
||||
export { makeExpect } from "./core/expect";
|
||||
export { destroy } from "./core/fixture";
|
||||
export { defineTags } from "./core/tag";
|
||||
export { createJobScopedGetter } from "./hoot_utils";
|
||||
|
||||
// Constants
|
||||
export const globals = {
|
||||
AbortController: globalThis.AbortController,
|
||||
Array: globalThis.Array,
|
||||
Boolean: globalThis.Boolean,
|
||||
DataTransfer: globalThis.DataTransfer,
|
||||
Date: globalThis.Date,
|
||||
Document: globalThis.Document,
|
||||
Element: globalThis.Element,
|
||||
Error: globalThis.Error,
|
||||
ErrorEvent: globalThis.ErrorEvent,
|
||||
EventTarget: globalThis.EventTarget,
|
||||
Map: globalThis.Map,
|
||||
MutationObserver: globalThis.MutationObserver,
|
||||
Number: globalThis.Number,
|
||||
Object: globalThis.Object,
|
||||
ProgressEvent: globalThis.ProgressEvent,
|
||||
Promise: globalThis.Promise,
|
||||
PromiseRejectionEvent: globalThis.PromiseRejectionEvent,
|
||||
Proxy: globalThis.Proxy,
|
||||
RegExp: globalThis.RegExp,
|
||||
Request: globalThis.Request,
|
||||
Response: globalThis.Response,
|
||||
Set: globalThis.Set,
|
||||
SharedWorker: globalThis.SharedWorker,
|
||||
String: globalThis.String,
|
||||
TypeError: globalThis.TypeError,
|
||||
URIError: globalThis.URIError,
|
||||
URL: globalThis.URL,
|
||||
URLSearchParams: globalThis.URLSearchParams,
|
||||
WebSocket: globalThis.WebSocket,
|
||||
Window: globalThis.Window,
|
||||
Worker: globalThis.Worker,
|
||||
XMLHttpRequest: globalThis.XMLHttpRequest,
|
||||
cancelAnimationFrame: globalThis.cancelAnimationFrame,
|
||||
clearInterval: globalThis.clearInterval,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
console: globalThis.console,
|
||||
document: globalThis.document,
|
||||
fetch: globalThis.fetch,
|
||||
history: globalThis.history,
|
||||
JSON: globalThis.JSON,
|
||||
localStorage: globalThis.localStorage,
|
||||
location: globalThis.location,
|
||||
matchMedia: globalThis.matchMedia,
|
||||
Math: globalThis.Math,
|
||||
navigator: globalThis.navigator,
|
||||
ontouchstart: globalThis.ontouchstart,
|
||||
performance: globalThis.performance,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
sessionStorage: globalThis.sessionStorage,
|
||||
setInterval: globalThis.setInterval,
|
||||
setTimeout: globalThis.setTimeout,
|
||||
};
|
||||
export const __debug__ = runner;
|
||||
|
||||
export const isHootReady = setupHootUI();
|
||||
2014
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot_utils.js
Normal file
2014
odoo-bringout-oca-ocb-web/web/static/lib/hoot/hoot_utils.js
Normal file
File diff suppressed because it is too large
Load diff
20
odoo-bringout-oca-ocb-web/web/static/lib/hoot/main_runner.js
Normal file
20
odoo-bringout-oca-ocb-web/web/static/lib/hoot/main_runner.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/** @odoo-module */
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @type {import("./core/runner").Runner} */
|
||||
let runner;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function getRunner() {
|
||||
return runner;
|
||||
}
|
||||
|
||||
export function setRunner(mainRunner) {
|
||||
runner = mainRunner;
|
||||
}
|
||||
175
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/animation.js
Normal file
175
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/animation.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { on } from "@odoo/hoot-dom";
|
||||
import { MockEventTarget } from "../hoot_utils";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Array: { isArray: $isArray },
|
||||
Element,
|
||||
Object: { assign: $assign, entries: $entries },
|
||||
scroll: windowScroll,
|
||||
scrollBy: windowScrollBy,
|
||||
scrollTo: windowScrollTo,
|
||||
} = globalThis;
|
||||
|
||||
const { animate, scroll, scrollBy, scrollIntoView, scrollTo } = Element.prototype;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
function forceInstantScroll(args) {
|
||||
return !allowAnimations && args[0] && typeof args[0] === "object"
|
||||
? [{ ...args[0], behavior: "instant" }, ...args.slice(1)]
|
||||
: args;
|
||||
}
|
||||
|
||||
const animationChangeBus = new MockEventTarget();
|
||||
const animationChangeCleanups = [];
|
||||
|
||||
let allowAnimations = true;
|
||||
let allowTransitions = false;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export class MockAnimation extends MockEventTarget {
|
||||
static publicListeners = ["cancel", "finish", "remove"];
|
||||
|
||||
currentTime = null;
|
||||
effect = null;
|
||||
finished = Promise.resolve(this);
|
||||
id = "";
|
||||
pending = false;
|
||||
playState = "idle";
|
||||
playbackRate = 1;
|
||||
ready = Promise.resolve(this);
|
||||
replaceState = "active";
|
||||
startTime = null;
|
||||
timeline = {
|
||||
currentTime: this.currentTime,
|
||||
duration: null,
|
||||
};
|
||||
|
||||
cancel() {
|
||||
this.dispatchEvent(new AnimationPlaybackEvent("cancel"));
|
||||
}
|
||||
|
||||
commitStyles() {}
|
||||
|
||||
finish() {
|
||||
this.dispatchEvent(new AnimationPlaybackEvent("finish"));
|
||||
}
|
||||
|
||||
pause() {}
|
||||
|
||||
persist() {}
|
||||
|
||||
play() {
|
||||
this.dispatchEvent(new AnimationPlaybackEvent("finish"));
|
||||
}
|
||||
|
||||
reverse() {}
|
||||
|
||||
updatePlaybackRate() {}
|
||||
}
|
||||
|
||||
export function cleanupAnimations() {
|
||||
allowAnimations = true;
|
||||
allowTransitions = false;
|
||||
|
||||
while (animationChangeCleanups.length) {
|
||||
animationChangeCleanups.pop()();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns off all animations triggered programmatically (such as with `animate`),
|
||||
* as well as smooth scrolls.
|
||||
*
|
||||
* @param {boolean} [enable=false]
|
||||
*/
|
||||
export function disableAnimations(enable = false) {
|
||||
allowAnimations = enable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores all suppressed "animation" and "transition" properties for the current
|
||||
* test, as they are turned off by default.
|
||||
*
|
||||
* @param {boolean} [enable=true]
|
||||
*/
|
||||
export function enableTransitions(enable = true) {
|
||||
allowTransitions = enable;
|
||||
animationChangeBus.dispatchEvent(new CustomEvent("toggle-transitions"));
|
||||
}
|
||||
|
||||
/** @type {Element["animate"]} */
|
||||
export function mockedAnimate(...args) {
|
||||
if (allowAnimations) {
|
||||
return animate.call(this, ...args);
|
||||
}
|
||||
|
||||
// Apply style properties immediatly
|
||||
const keyframesList = $isArray(args[0]) ? args[0] : [args[0]];
|
||||
const style = {};
|
||||
for (const kf of keyframesList) {
|
||||
for (const [key, value] of $entries(kf)) {
|
||||
style[key] = $isArray(value) ? value.at(-1) : value;
|
||||
}
|
||||
}
|
||||
$assign(this.style, style);
|
||||
|
||||
// Return mock animation
|
||||
return new MockAnimation();
|
||||
}
|
||||
|
||||
/** @type {Element["scroll"]} */
|
||||
export function mockedScroll(...args) {
|
||||
return scroll.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/** @type {Element["scrollBy"]} */
|
||||
export function mockedScrollBy(...args) {
|
||||
return scrollBy.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/** @type {Element["scrollIntoView"]} */
|
||||
export function mockedScrollIntoView(...args) {
|
||||
return scrollIntoView.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/** @type {Element["scrollTo"]} */
|
||||
export function mockedScrollTo(...args) {
|
||||
return scrollTo.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/** @type {typeof window["scroll"]} */
|
||||
export function mockedWindowScroll(...args) {
|
||||
return windowScroll.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/** @type {typeof window["scrollBy"]} */
|
||||
export function mockedWindowScrollBy(...args) {
|
||||
return windowScrollBy.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/** @type {typeof window["scrollTo"]} */
|
||||
export function mockedWindowScrollTo(...args) {
|
||||
return windowScrollTo.call(this, ...forceInstantScroll(args));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {(allowTransitions: boolean) => any} onChange
|
||||
*/
|
||||
export function subscribeToTransitionChange(onChange) {
|
||||
onChange(allowTransitions);
|
||||
animationChangeCleanups.push(
|
||||
on(animationChangeBus, "toggle-transitions", () => onChange(allowTransitions))
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { MockEventTarget } from "../hoot_utils";
|
||||
import { logger } from "../core/logger";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
console,
|
||||
Object: { keys: $keys },
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const DISPATCHING_METHODS = ["error", "trace", "warn"];
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export class MockConsole extends MockEventTarget {
|
||||
static {
|
||||
for (const fnName of $keys(console)) {
|
||||
if (DISPATCHING_METHODS.includes(fnName)) {
|
||||
const fn = logger[fnName];
|
||||
this.prototype[fnName] = function (...args) {
|
||||
this.dispatchEvent(new CustomEvent(fnName, { detail: args }));
|
||||
return fn.apply(this, arguments);
|
||||
};
|
||||
} else {
|
||||
this.prototype[fnName] = console[fnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/crypto.js
Normal file
22
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/crypto.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/** @odoo-module */
|
||||
|
||||
export const mockCrypto = {
|
||||
subtle: {
|
||||
importKey: (_format, keyData, _algorithm, _extractable, _keyUsages) => {
|
||||
if (!keyData || keyData.length === 0) {
|
||||
throw Error(`KeyData is mandatory`);
|
||||
}
|
||||
return Promise.resolve("I'm a key");
|
||||
},
|
||||
encrypt: (_algorithm, _key, data) =>
|
||||
Promise.resolve(`encrypted data:${new TextDecoder().decode(data)}`),
|
||||
decrypt: (_algorithm, _key, data) =>
|
||||
Promise.resolve(new TextEncoder().encode(data.replace("encrypted data:", ""))),
|
||||
},
|
||||
getRandomValues: (typedArray) => {
|
||||
typedArray.forEach((_element, index) => {
|
||||
typedArray[index] = Math.round(Math.random() * 100);
|
||||
});
|
||||
return typedArray;
|
||||
},
|
||||
};
|
||||
253
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/date.js
Normal file
253
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/date.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { getTimeOffset, isTimeFrozen, resetTimeOffset } from "@web/../lib/hoot-dom/helpers/time";
|
||||
import { createMock, HootError, isNil } from "../hoot_utils";
|
||||
|
||||
/**
|
||||
* @typedef DateSpecs
|
||||
* @property {number} [year]
|
||||
* @property {number} [month] // 1-12
|
||||
* @property {number} [day] // 1-31
|
||||
* @property {number} [hour] // 0-23
|
||||
* @property {number} [minute] // 0-59
|
||||
* @property {number} [second] // 0-59
|
||||
* @property {number} [millisecond] // 0-999
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { Date, Intl } = globalThis;
|
||||
const { now: $now, UTC: $UTC } = Date;
|
||||
const { DateTimeFormat, Locale } = Intl;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Date} baseDate
|
||||
*/
|
||||
function computeTimeZoneOffset(baseDate) {
|
||||
const utcDate = new Date(baseDate.toLocaleString(DEFAULT_LOCALE, { timeZone: "UTC" }));
|
||||
const tzDate = new Date(baseDate.toLocaleString(DEFAULT_LOCALE, { timeZone: timeZoneName }));
|
||||
return (utcDate - tzDate) / 60000; // in minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} id
|
||||
*/
|
||||
function getDateParams() {
|
||||
return [...dateParams.slice(0, -1), dateParams.at(-1) + getTimeStampDiff() + getTimeOffset()];
|
||||
}
|
||||
|
||||
function getTimeStampDiff() {
|
||||
return isTimeFrozen() ? 0 : $now() - dateTimeStamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | DateSpecs} dateSpecs
|
||||
*/
|
||||
function parseDateParams(dateSpecs) {
|
||||
/** @type {DateSpecs} */
|
||||
const specs =
|
||||
(typeof dateSpecs === "string" ? dateSpecs.match(DATE_REGEX)?.groups : dateSpecs) || {};
|
||||
return [
|
||||
specs.year ?? DEFAULT_DATE[0],
|
||||
(specs.month ?? DEFAULT_DATE[1]) - 1,
|
||||
specs.day ?? DEFAULT_DATE[2],
|
||||
specs.hour ?? DEFAULT_DATE[3],
|
||||
specs.minute ?? DEFAULT_DATE[4],
|
||||
specs.second ?? DEFAULT_DATE[5],
|
||||
specs.millisecond ?? DEFAULT_DATE[6],
|
||||
].map(Number);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof dateParams} newDateParams
|
||||
*/
|
||||
function setDateParams(newDateParams) {
|
||||
dateParams = newDateParams;
|
||||
dateTimeStamp = $now();
|
||||
|
||||
resetTimeOffset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | number | null | undefined} tz
|
||||
*/
|
||||
function setTimeZone(tz) {
|
||||
if (typeof tz === "string") {
|
||||
if (!tz.includes("/")) {
|
||||
throw new HootError(`invalid time zone: must be in the format <Country/...Location>`);
|
||||
}
|
||||
|
||||
// Set TZ name
|
||||
timeZoneName = tz;
|
||||
// Set TZ offset based on name (must be computed for each date)
|
||||
timeZoneOffset = computeTimeZoneOffset;
|
||||
} else if (typeof tz === "number") {
|
||||
// Only set TZ offset
|
||||
timeZoneOffset = tz * -60;
|
||||
} else {
|
||||
// Reset both TZ name & offset
|
||||
timeZoneName = null;
|
||||
timeZoneOffset = null;
|
||||
}
|
||||
|
||||
for (const callback of timeZoneChangeCallbacks) {
|
||||
callback(tz ?? DEFAULT_TIMEZONE_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
class MockDateTimeFormat extends DateTimeFormat {
|
||||
constructor(locales, options) {
|
||||
super(locales, {
|
||||
...options,
|
||||
timeZone: options?.timeZone ?? timeZoneName ?? DEFAULT_TIMEZONE_NAME,
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {Intl.DateTimeFormat["format"]} */
|
||||
format(date) {
|
||||
return super.format(date || new MockDate());
|
||||
}
|
||||
|
||||
resolvedOptions() {
|
||||
return {
|
||||
...super.resolvedOptions(),
|
||||
timeZone: timeZoneName ?? DEFAULT_TIMEZONE_NAME,
|
||||
locale: locale ?? DEFAULT_LOCALE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const DATE_REGEX =
|
||||
/(?<year>\d{4})[/-](?<month>\d{2})[/-](?<day>\d{2})([\sT]+(?<hour>\d{2}):(?<minute>\d{2}):(?<second>\d{2})(\.(?<millisecond>\d{3}))?)?/;
|
||||
const DEFAULT_DATE = [2019, 2, 11, 9, 30, 0, 0];
|
||||
const DEFAULT_LOCALE = "en-US";
|
||||
const DEFAULT_TIMEZONE_NAME = "Europe/Brussels";
|
||||
const DEFAULT_TIMEZONE_OFFSET = -60;
|
||||
|
||||
/** @type {((tz: string | number) => any)[]} */
|
||||
const timeZoneChangeCallbacks = [];
|
||||
|
||||
let dateParams = DEFAULT_DATE;
|
||||
let dateTimeStamp = $now();
|
||||
/** @type {string | null} */
|
||||
let locale = null;
|
||||
/** @type {string | null} */
|
||||
let timeZoneName = null;
|
||||
/** @type {number | ((date: Date) => number) | null} */
|
||||
let timeZoneOffset = null;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function cleanupDate() {
|
||||
setDateParams(DEFAULT_DATE);
|
||||
locale = null;
|
||||
timeZoneName = null;
|
||||
timeZoneOffset = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks the current date and time, and also the time zone if any.
|
||||
*
|
||||
* Date can either be an object describing the date and time to mock, or a
|
||||
* string in SQL or ISO format (time and millisecond values can be omitted).
|
||||
* @see {@link mockTimeZone} for the time zone params.
|
||||
*
|
||||
* @param {string | DateSpecs} [date]
|
||||
* @param {string | number | null} [tz]
|
||||
* @example
|
||||
* mockDate("2023-12-25T20:45:00"); // 2023-12-25 20:45:00 UTC
|
||||
* @example
|
||||
* mockDate({ year: 2023, month: 12, day: 25, hour: 20, minute: 45 }); // same as above
|
||||
* @example
|
||||
* mockDate("2019-02-11 09:30:00.001", +2);
|
||||
*/
|
||||
export function mockDate(date, tz) {
|
||||
setDateParams(date ? parseDateParams(date) : DEFAULT_DATE);
|
||||
if (!isNil(tz)) {
|
||||
setTimeZone(tz);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks the current locale.
|
||||
*
|
||||
* If the time zone hasn't been mocked already, it will be assigned to the first
|
||||
* time zone available in the given locale (if any).
|
||||
*
|
||||
* @param {string} newLocale
|
||||
* @example
|
||||
* mockTimeZone("ja-JP"); // UTC + 9
|
||||
*/
|
||||
export function mockLocale(newLocale) {
|
||||
locale = newLocale;
|
||||
|
||||
if (!isNil(locale) && isNil(timeZoneName)) {
|
||||
// Set TZ from locale (if not mocked already)
|
||||
const firstAvailableTZ = new Locale(locale).timeZones?.[0];
|
||||
if (!isNil(firstAvailableTZ)) {
|
||||
setTimeZone(firstAvailableTZ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks the current time zone.
|
||||
*
|
||||
* Time zone can either be a time zone or an offset. Number offsets are expressed
|
||||
* in hours.
|
||||
*
|
||||
* @param {string | number | null} [tz]
|
||||
* @example
|
||||
* mockTimeZone(+10); // UTC + 10
|
||||
* @example
|
||||
* mockTimeZone("Europe/Brussels"); // UTC + 1 (or UTC + 2 in summer)
|
||||
* @example
|
||||
* mockTimeZone(null) // Resets to test default (+1)
|
||||
*/
|
||||
export function mockTimeZone(tz) {
|
||||
setTimeZone(tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes made on the time zone (mocked) value.
|
||||
*
|
||||
* @param {(tz: string | number) => any} callback
|
||||
*/
|
||||
export function onTimeZoneChange(callback) {
|
||||
timeZoneChangeCallbacks.push(callback);
|
||||
}
|
||||
|
||||
export class MockDate extends Date {
|
||||
constructor(...args) {
|
||||
if (args.length === 1) {
|
||||
super(args[0]);
|
||||
} else {
|
||||
const params = getDateParams();
|
||||
for (let i = 0; i < params.length; i++) {
|
||||
args[i] ??= params[i];
|
||||
}
|
||||
super($UTC(...args));
|
||||
}
|
||||
}
|
||||
|
||||
getTimezoneOffset() {
|
||||
const offset = timeZoneOffset ?? DEFAULT_TIMEZONE_OFFSET;
|
||||
return typeof offset === "function" ? offset(this) : offset;
|
||||
}
|
||||
|
||||
static now() {
|
||||
return new MockDate().getTime();
|
||||
}
|
||||
}
|
||||
|
||||
export const MockIntl = createMock(Intl, {
|
||||
DateTimeFormat: { value: MockDateTimeFormat },
|
||||
});
|
||||
91
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/math.js
Normal file
91
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/math.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { isNil, stringToNumber } from "../hoot_utils";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Math,
|
||||
Number: { isNaN: $isNaN, parseFloat: $parseFloat },
|
||||
Object: { defineProperties: $defineProperties },
|
||||
} = globalThis;
|
||||
const { floor: $floor, random: $random } = Math;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {unknown} [seed]
|
||||
*/
|
||||
function toValidSeed(seed) {
|
||||
if (isNil(seed)) {
|
||||
return generateSeed();
|
||||
}
|
||||
const nSeed = $parseFloat(seed);
|
||||
return $isNaN(nSeed) ? stringToNumber(nSeed) : nSeed;
|
||||
}
|
||||
|
||||
const DEFAULT_SEED = 1e16;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates a random 16-digit number.
|
||||
* This function uses the native (unpatched) {@link Math.random} method.
|
||||
*/
|
||||
export function generateSeed() {
|
||||
return $floor($random() * 1e16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a seeded random number generator equivalent to the native
|
||||
* {@link Math.random} method.
|
||||
*
|
||||
* It exposes a `seed` property that can be changed at any time to reset the
|
||||
* generator.
|
||||
*
|
||||
* @param {number} seed
|
||||
* @example
|
||||
* const randA = makeSeededRandom(1e16);
|
||||
* const randB = makeSeededRandom(1e16);
|
||||
* randA() === randB(); // true
|
||||
* @example
|
||||
* const random = makeSeededRandom(1e16);
|
||||
* random() === random(); // false
|
||||
*/
|
||||
export function makeSeededRandom(seed) {
|
||||
function random() {
|
||||
state ^= (state << 13) >>> 0;
|
||||
state ^= (state >>> 17) >>> 0;
|
||||
state ^= (state << 5) >>> 0;
|
||||
|
||||
return ((state >>> 0) & 0x7fffffff) / 0x7fffffff; // Normalize to [0, 1)
|
||||
}
|
||||
|
||||
let state = seed;
|
||||
|
||||
$defineProperties(random, {
|
||||
seed: {
|
||||
get() {
|
||||
return seed;
|
||||
},
|
||||
set(value) {
|
||||
seed = toValidSeed(value);
|
||||
state = seed;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return random;
|
||||
}
|
||||
|
||||
/**
|
||||
* `random` function used internally to not generate unwanted calls on global
|
||||
* `Math.random` function (and possibly having a different seed).
|
||||
*/
|
||||
export const internalRandom = makeSeededRandom(DEFAULT_SEED);
|
||||
328
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/navigator.js
Normal file
328
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/navigator.js
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { isInstanceOf } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { createMock, HootError, MIME_TYPE, MockEventTarget } from "../hoot_utils";
|
||||
import { getSyncValue, setSyncValue } from "./sync_values";
|
||||
|
||||
/**
|
||||
* @typedef {"android" | "ios" | "linux" | "mac" | "windows"} Platform
|
||||
*/
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Blob,
|
||||
ClipboardItem = class NonSecureClipboardItem {},
|
||||
navigator,
|
||||
Object: { assign: $assign },
|
||||
Set,
|
||||
TypeError,
|
||||
} = globalThis;
|
||||
const { userAgent: $userAgent } = navigator;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
function getBlobValue(value) {
|
||||
return isInstanceOf(value, Blob) ? value.text() : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the final synchronous value of several item types.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @param {string} type
|
||||
*/
|
||||
function getClipboardValue(value, type) {
|
||||
return getBlobValue(isInstanceOf(value, ClipboardItem) ? value.getType(type) : value);
|
||||
}
|
||||
|
||||
function getMockValues() {
|
||||
return {
|
||||
sendBeacon: throwNotImplemented("sendBeacon"),
|
||||
userAgent: makeUserAgent("linux"),
|
||||
/** @type {Navigator["vibrate"]} */
|
||||
vibrate: throwNotImplemented("vibrate"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<PermissionName, { name: string; state: PermissionState }>}
|
||||
*/
|
||||
function getPermissions() {
|
||||
return {
|
||||
"background-sync": {
|
||||
state: "granted", // should always be granted
|
||||
name: "background_sync",
|
||||
},
|
||||
"local-fonts": {
|
||||
state: "denied",
|
||||
name: "local_fonts",
|
||||
},
|
||||
"payment-handler": {
|
||||
state: "denied",
|
||||
name: "payment_handler",
|
||||
},
|
||||
"persistent-storage": {
|
||||
state: "denied",
|
||||
name: "durable_storage",
|
||||
},
|
||||
"screen-wake-lock": {
|
||||
state: "denied",
|
||||
name: "screen_wake_lock",
|
||||
},
|
||||
"storage-access": {
|
||||
state: "denied",
|
||||
name: "storage-access",
|
||||
},
|
||||
"window-management": {
|
||||
state: "denied",
|
||||
name: "window_placement",
|
||||
},
|
||||
accelerometer: {
|
||||
state: "denied",
|
||||
name: "sensors",
|
||||
},
|
||||
camera: {
|
||||
state: "denied",
|
||||
name: "video_capture",
|
||||
},
|
||||
geolocation: {
|
||||
state: "denied",
|
||||
name: "geolocation",
|
||||
},
|
||||
gyroscope: {
|
||||
state: "denied",
|
||||
name: "sensors",
|
||||
},
|
||||
magnetometer: {
|
||||
state: "denied",
|
||||
name: "sensors",
|
||||
},
|
||||
microphone: {
|
||||
state: "denied",
|
||||
name: "audio_capture",
|
||||
},
|
||||
midi: {
|
||||
state: "denied",
|
||||
name: "midi",
|
||||
},
|
||||
notifications: {
|
||||
state: "denied",
|
||||
name: "notifications",
|
||||
},
|
||||
push: {
|
||||
state: "denied",
|
||||
name: "push",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUserAgentBrowser() {
|
||||
if (/Firefox/i.test($userAgent)) {
|
||||
return "Gecko/20100101 Firefox/1000.0"; // Firefox
|
||||
}
|
||||
if (/Chrome/i.test($userAgent)) {
|
||||
return "AppleWebKit/1000.00 (KHTML, like Gecko) Chrome/1000.00 Safari/1000.00"; // Chrome
|
||||
}
|
||||
if (/Safari/i.test($userAgent)) {
|
||||
return "AppleWebKit/1000.00 (KHTML, like Gecko) Version/1000.00 Safari/1000.00"; // Safari
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Platform} platform
|
||||
*/
|
||||
function makeUserAgent(platform) {
|
||||
const userAgent = ["Mozilla/5.0"];
|
||||
switch (platform.toLowerCase()) {
|
||||
case "android": {
|
||||
userAgent.push("(Linux; Android 1000)");
|
||||
break;
|
||||
}
|
||||
case "ios": {
|
||||
userAgent.push("(iPhone; CPU iPhone OS 1000_0 like Mac OS X)");
|
||||
break;
|
||||
}
|
||||
case "linux": {
|
||||
userAgent.push("(X11; Linux x86_64)");
|
||||
break;
|
||||
}
|
||||
case "mac":
|
||||
case "macintosh": {
|
||||
userAgent.push("(Macintosh; Intel Mac OS X 10_15_7)");
|
||||
break;
|
||||
}
|
||||
case "win":
|
||||
case "windows": {
|
||||
userAgent.push("(Windows NT 10.0; Win64; x64)");
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
userAgent.push(platform);
|
||||
}
|
||||
}
|
||||
if (userAgentBrowser) {
|
||||
userAgent.push(userAgentBrowser);
|
||||
}
|
||||
return userAgent.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fnName
|
||||
*/
|
||||
function throwNotImplemented(fnName) {
|
||||
return function notImplemented() {
|
||||
throw new HootError(`unmocked navigator method: ${fnName}`);
|
||||
};
|
||||
}
|
||||
|
||||
/** @type {Set<MockPermissionStatus>} */
|
||||
const permissionStatuses = new Set();
|
||||
const userAgentBrowser = getUserAgentBrowser();
|
||||
const mockValues = getMockValues();
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export class MockClipboard {
|
||||
/** @type {unknown} */
|
||||
_value = null;
|
||||
|
||||
async read() {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
async readText() {
|
||||
return String(getClipboardValue(this._value, MIME_TYPE.text) ?? "");
|
||||
}
|
||||
|
||||
async write(value) {
|
||||
this._value = value;
|
||||
}
|
||||
|
||||
async writeText(value) {
|
||||
this._value = String(getClipboardValue(value, MIME_TYPE.text) ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
export class MockClipboardItem extends ClipboardItem {
|
||||
constructor(items) {
|
||||
super(items);
|
||||
|
||||
setSyncValue(this, items);
|
||||
}
|
||||
|
||||
// Added synchronous methods to enhance speed in tests
|
||||
|
||||
async getType(type) {
|
||||
return getSyncValue(this)[type];
|
||||
}
|
||||
}
|
||||
|
||||
export class MockPermissions {
|
||||
/**
|
||||
* @param {PermissionDescriptor} permissionDesc
|
||||
*/
|
||||
async query({ name }) {
|
||||
if (!(name in currentPermissions)) {
|
||||
throw new TypeError(
|
||||
`The provided value '${name}' is not a valid enum value of type PermissionName`
|
||||
);
|
||||
}
|
||||
return new MockPermissionStatus(name);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockPermissionStatus extends MockEventTarget {
|
||||
static publicListeners = ["change"];
|
||||
|
||||
/** @type {typeof currentPermissions[PermissionName]} */
|
||||
_permission;
|
||||
|
||||
/**
|
||||
* @param {PermissionName} name
|
||||
* @param {PermissionState} value
|
||||
*/
|
||||
constructor(name) {
|
||||
super(...arguments);
|
||||
|
||||
this._permission = currentPermissions[name];
|
||||
permissionStatuses.add(this);
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._permission.name;
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this._permission.state;
|
||||
}
|
||||
}
|
||||
|
||||
export const currentPermissions = getPermissions();
|
||||
|
||||
export const mockClipboard = new MockClipboard();
|
||||
|
||||
export const mockPermissions = new MockPermissions();
|
||||
|
||||
export const mockNavigator = createMock(navigator, {
|
||||
clipboard: { value: mockClipboard },
|
||||
maxTouchPoints: { get: () => (globalThis.ontouchstart === undefined ? 0 : 1) },
|
||||
permissions: { value: mockPermissions },
|
||||
sendBeacon: { get: () => mockValues.sendBeacon },
|
||||
serviceWorker: { get: () => undefined },
|
||||
userAgent: { get: () => mockValues.userAgent },
|
||||
vibrate: { get: () => mockValues.vibrate },
|
||||
});
|
||||
|
||||
export function cleanupNavigator() {
|
||||
permissionStatuses.clear();
|
||||
$assign(currentPermissions, getPermissions());
|
||||
$assign(mockValues, getMockValues());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PermissionName} name
|
||||
* @param {PermissionState} [value]
|
||||
*/
|
||||
export function mockPermission(name, value) {
|
||||
if (!(name in currentPermissions)) {
|
||||
throw new TypeError(
|
||||
`The provided value '${name}' is not a valid enum value of type PermissionName`
|
||||
);
|
||||
}
|
||||
|
||||
currentPermissions[name].state = value;
|
||||
|
||||
for (const permissionStatus of permissionStatuses) {
|
||||
if (permissionStatus.name === name) {
|
||||
permissionStatus.dispatchEvent(new Event("change"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Navigator["sendBeacon"]} callback
|
||||
*/
|
||||
export function mockSendBeacon(callback) {
|
||||
mockValues.sendBeacon = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Platform} platform
|
||||
*/
|
||||
export function mockUserAgent(platform = "linux") {
|
||||
mockValues.userAgent = makeUserAgent(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Navigator["vibrate"]} callback
|
||||
*/
|
||||
export function mockVibrate(callback) {
|
||||
mockValues.vibrate = callback;
|
||||
}
|
||||
1028
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/network.js
Normal file
1028
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/network.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,79 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { MockEventTarget } from "../hoot_utils";
|
||||
import { currentPermissions } from "./navigator";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { Event, Promise, Set } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/** @type {Set<MockNotification>} */
|
||||
const notifications = new Set();
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the list of notifications that have been created since the last call
|
||||
* to this function, consuming it in the process.
|
||||
*
|
||||
* @returns {MockNotification[]}
|
||||
*/
|
||||
export function flushNotifications() {
|
||||
const result = [...notifications];
|
||||
notifications.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
export class MockNotification extends MockEventTarget {
|
||||
static publicListeners = ["click", "close", "error", "show"];
|
||||
|
||||
/** @type {NotificationPermission} */
|
||||
static get permission() {
|
||||
return currentPermissions.notifications.state;
|
||||
}
|
||||
|
||||
/** @type {NotificationPermission} */
|
||||
get permission() {
|
||||
return this.constructor.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} title
|
||||
* @param {NotificationOptions} [options]
|
||||
*/
|
||||
constructor(title, options) {
|
||||
super(...arguments);
|
||||
|
||||
this.title = title;
|
||||
this.options = options;
|
||||
|
||||
if (this.permission === "granted") {
|
||||
notifications.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
static requestPermission() {
|
||||
return Promise.resolve(this.permission);
|
||||
}
|
||||
|
||||
click() {
|
||||
this.dispatchEvent(new Event("click"));
|
||||
}
|
||||
|
||||
close() {
|
||||
notifications.delete(this);
|
||||
this.dispatchEvent(new Event("close"));
|
||||
}
|
||||
|
||||
show() {
|
||||
this.dispatchEvent(new Event("show"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/** @odoo-module */
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
Object: { keys: $keys },
|
||||
StorageEvent,
|
||||
String,
|
||||
} = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export class MockStorage {
|
||||
get length() {
|
||||
return $keys(this).length;
|
||||
}
|
||||
|
||||
/** @type {typeof Storage.prototype.clear} */
|
||||
clear() {
|
||||
for (const key in this) {
|
||||
delete this[key];
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {typeof Storage.prototype.getItem} */
|
||||
getItem(key) {
|
||||
key = String(key);
|
||||
return this[key] ?? null;
|
||||
}
|
||||
|
||||
/** @type {typeof Storage.prototype.key} */
|
||||
key(index) {
|
||||
return $keys(this).at(index);
|
||||
}
|
||||
|
||||
/** @type {typeof Storage.prototype.removeItem} */
|
||||
removeItem(key) {
|
||||
key = String(key);
|
||||
delete this[key];
|
||||
window.dispatchEvent(new StorageEvent("storage", { key, newValue: null }));
|
||||
}
|
||||
|
||||
/** @type {typeof Storage.prototype.setItem} */
|
||||
setItem(key, value) {
|
||||
key = String(key);
|
||||
value = String(value);
|
||||
this[key] = value;
|
||||
window.dispatchEvent(new StorageEvent("storage", { key, newValue: value }));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/** @odoo-module */
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const { Blob, TextEncoder } = globalThis;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const syncValues = new WeakMap();
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
*/
|
||||
export function getSyncValue(object) {
|
||||
return syncValues.get(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} object
|
||||
* @param {any} value
|
||||
*/
|
||||
export function setSyncValue(object, value) {
|
||||
syncValues.set(object, value);
|
||||
}
|
||||
|
||||
export class MockBlob extends Blob {
|
||||
constructor(blobParts, options) {
|
||||
super(blobParts, options);
|
||||
|
||||
setSyncValue(this, blobParts);
|
||||
}
|
||||
|
||||
async arrayBuffer() {
|
||||
return new TextEncoder().encode(getSyncValue(this));
|
||||
}
|
||||
|
||||
async text() {
|
||||
return getSyncValue(this).join("");
|
||||
}
|
||||
}
|
||||
713
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/window.js
Normal file
713
odoo-bringout-oca-ocb-web/web/static/lib/hoot/mock/window.js
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { EventBus } from "@odoo/owl";
|
||||
import { getCurrentDimensions, getDocument, getWindow } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import {
|
||||
mockedCancelAnimationFrame,
|
||||
mockedClearInterval,
|
||||
mockedClearTimeout,
|
||||
mockedRequestAnimationFrame,
|
||||
mockedSetInterval,
|
||||
mockedSetTimeout,
|
||||
} from "@web/../lib/hoot-dom/helpers/time";
|
||||
import { interactor } from "../../hoot-dom/hoot_dom_utils";
|
||||
import { MockEventTarget, strictEqual, waitForDocument } from "../hoot_utils";
|
||||
import { getRunner } from "../main_runner";
|
||||
import {
|
||||
MockAnimation,
|
||||
mockedAnimate,
|
||||
mockedScroll,
|
||||
mockedScrollBy,
|
||||
mockedScrollIntoView,
|
||||
mockedScrollTo,
|
||||
mockedWindowScroll,
|
||||
mockedWindowScrollBy,
|
||||
mockedWindowScrollTo,
|
||||
} from "./animation";
|
||||
import { MockConsole } from "./console";
|
||||
import { MockDate, MockIntl } from "./date";
|
||||
import { MockClipboardItem, mockNavigator } from "./navigator";
|
||||
import {
|
||||
MockBroadcastChannel,
|
||||
MockMessageChannel,
|
||||
MockMessagePort,
|
||||
MockRequest,
|
||||
MockResponse,
|
||||
MockSharedWorker,
|
||||
MockURL,
|
||||
MockWebSocket,
|
||||
MockWorker,
|
||||
MockXMLHttpRequest,
|
||||
MockXMLHttpRequestUpload,
|
||||
mockCookie,
|
||||
mockHistory,
|
||||
mockLocation,
|
||||
mockedFetch,
|
||||
} from "./network";
|
||||
import { MockNotification } from "./notification";
|
||||
import { MockStorage } from "./storage";
|
||||
import { MockBlob } from "./sync_values";
|
||||
import { mockCrypto } from "./crypto";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Global
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
EventTarget,
|
||||
HTMLAnchorElement,
|
||||
MutationObserver,
|
||||
Number: { isNaN: $isNaN, parseFloat: $parseFloat },
|
||||
Object: {
|
||||
assign: $assign,
|
||||
defineProperties: $defineProperties,
|
||||
entries: $entries,
|
||||
getOwnPropertyDescriptor: $getOwnPropertyDescriptor,
|
||||
getPrototypeOf: $getPrototypeOf,
|
||||
keys: $keys,
|
||||
hasOwn: $hasOwn,
|
||||
},
|
||||
Reflect: { ownKeys: $ownKeys },
|
||||
Set,
|
||||
WeakMap,
|
||||
} = globalThis;
|
||||
|
||||
const { addEventListener, removeEventListener } = EventTarget.prototype;
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Internal
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {unknown} target
|
||||
* @param {Record<string, PropertyDescriptor>} descriptors
|
||||
*/
|
||||
function applyPropertyDescriptors(target, descriptors) {
|
||||
if (!originalDescriptors.has(target)) {
|
||||
originalDescriptors.set(target, {});
|
||||
}
|
||||
const targetDescriptors = originalDescriptors.get(target);
|
||||
const ownerDecriptors = new Map();
|
||||
for (const [property, rawDescriptor] of $entries(descriptors)) {
|
||||
const owner = findPropertyOwner(target, property);
|
||||
targetDescriptors[property] = $getOwnPropertyDescriptor(owner, property);
|
||||
const descriptor = { ...rawDescriptor };
|
||||
if ("value" in descriptor) {
|
||||
descriptor.writable = false;
|
||||
}
|
||||
if (!ownerDecriptors.has(owner)) {
|
||||
ownerDecriptors.set(owner, {});
|
||||
}
|
||||
const nextDescriptors = ownerDecriptors.get(owner);
|
||||
nextDescriptors[property] = descriptor;
|
||||
}
|
||||
for (const [owner, nextDescriptors] of ownerDecriptors) {
|
||||
$defineProperties(owner, nextDescriptors);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} [changedKeys]
|
||||
*/
|
||||
function callMediaQueryChanges(changedKeys) {
|
||||
for (const mediaQueryList of mediaQueryLists) {
|
||||
if (!changedKeys || changedKeys.some((key) => mediaQueryList.media.includes(key))) {
|
||||
const event = new MediaQueryListEvent("change", {
|
||||
matches: mediaQueryList.matches,
|
||||
media: mediaQueryList.media,
|
||||
});
|
||||
mediaQueryList.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T} target
|
||||
* @param {keyof T} property
|
||||
*/
|
||||
function findOriginalDescriptor(target, property) {
|
||||
if (originalDescriptors.has(target)) {
|
||||
const descriptors = originalDescriptors.get(target);
|
||||
if (descriptors && property in descriptors) {
|
||||
return descriptors[property];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} object
|
||||
* @param {string} property
|
||||
* @returns {unknown}
|
||||
*/
|
||||
function findPropertyOwner(object, property) {
|
||||
if ($hasOwn(object, property)) {
|
||||
return object;
|
||||
}
|
||||
const prototype = $getPrototypeOf(object);
|
||||
if (prototype) {
|
||||
return findPropertyOwner(prototype, property);
|
||||
}
|
||||
return object;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} object
|
||||
*/
|
||||
function getTouchDescriptors(object) {
|
||||
const descriptors = {};
|
||||
const toDelete = [];
|
||||
for (const eventName of TOUCH_EVENTS) {
|
||||
const fnName = `on${eventName}`;
|
||||
if (fnName in object) {
|
||||
const owner = findPropertyOwner(object, fnName);
|
||||
descriptors[fnName] = $getOwnPropertyDescriptor(owner, fnName);
|
||||
} else {
|
||||
toDelete.push(fnName);
|
||||
}
|
||||
}
|
||||
/** @type {({ descriptors?: Record<string, PropertyDescriptor>; toDelete?: string[]})} */
|
||||
const result = {};
|
||||
if ($keys(descriptors).length) {
|
||||
result.descriptors = descriptors;
|
||||
}
|
||||
if (toDelete.length) {
|
||||
result.toDelete = toDelete;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof globalThis} view
|
||||
*/
|
||||
function getTouchTargets(view) {
|
||||
return [view, view.Document.prototype];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof globalThis} view
|
||||
*/
|
||||
function getWatchedEventTargets(view) {
|
||||
return [
|
||||
view,
|
||||
view.document,
|
||||
// Permanent DOM elements
|
||||
view.HTMLDocument.prototype,
|
||||
view.HTMLBodyElement.prototype,
|
||||
view.HTMLHeadElement.prototype,
|
||||
view.HTMLHtmlElement.prototype,
|
||||
// Other event targets
|
||||
EventBus.prototype,
|
||||
MockEventTarget.prototype,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @returns {PropertyDescriptor}
|
||||
*/
|
||||
function makeEventDescriptor(type) {
|
||||
let callback = null;
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
return callback;
|
||||
},
|
||||
set(value) {
|
||||
if (callback === value) {
|
||||
return;
|
||||
}
|
||||
if (typeof callback === "function") {
|
||||
this.removeEventListener(type, callback);
|
||||
}
|
||||
callback = value;
|
||||
if (typeof callback === "function") {
|
||||
this.addEventListener(type, callback);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mediaQueryString
|
||||
*/
|
||||
function matchesQueryPart(mediaQueryString) {
|
||||
const [, key, value] = mediaQueryString.match(R_MEDIA_QUERY_PROPERTY) || [];
|
||||
let match = false;
|
||||
if (mockMediaValues[key]) {
|
||||
match = strictEqual(value, mockMediaValues[key]);
|
||||
} else if (key) {
|
||||
switch (key) {
|
||||
case "max-height": {
|
||||
match = getCurrentDimensions().height <= $parseFloat(value);
|
||||
break;
|
||||
}
|
||||
case "max-width": {
|
||||
match = getCurrentDimensions().width <= $parseFloat(value);
|
||||
break;
|
||||
}
|
||||
case "min-height": {
|
||||
match = getCurrentDimensions().height >= $parseFloat(value);
|
||||
break;
|
||||
}
|
||||
case "min-width": {
|
||||
match = getCurrentDimensions().width >= $parseFloat(value);
|
||||
break;
|
||||
}
|
||||
case "orientation": {
|
||||
const { width, height } = getCurrentDimensions();
|
||||
match = value === "landscape" ? width > height : width < height;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mediaQueryString.startsWith("not") ? !match : match;
|
||||
}
|
||||
|
||||
/** @type {addEventListener} */
|
||||
function mockedAddEventListener(...args) {
|
||||
const runner = getRunner();
|
||||
if (runner.dry || !runner.suiteStack.length) {
|
||||
// Ignore listeners during dry run or outside of a test suite
|
||||
return;
|
||||
}
|
||||
if (!R_OWL_SYNTHETIC_LISTENER.test(String(args[1]))) {
|
||||
// Ignore cleanup for Owl synthetic listeners
|
||||
runner.after(removeEventListener.bind(this, ...args));
|
||||
}
|
||||
return addEventListener.call(this, ...args);
|
||||
}
|
||||
|
||||
/** @type {Document["elementFromPoint"]} */
|
||||
function mockedElementFromPoint(...args) {
|
||||
return mockedElementsFromPoint.call(this, ...args)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocked version of {@link document.elementsFromPoint} to:
|
||||
* - remove "HOOT-..." elements from the result
|
||||
* - put the <body> & <html> elements at the end of the list, as they may be ordered
|
||||
* incorrectly due to the fixture being behind the body.
|
||||
* @type {Document["elementsFromPoint"]}
|
||||
*/
|
||||
function mockedElementsFromPoint(...args) {
|
||||
const { value: elementsFromPoint } = findOriginalDescriptor(this, "elementsFromPoint");
|
||||
const result = [];
|
||||
let hasDocumentElement = false;
|
||||
let hasBody = false;
|
||||
for (const element of elementsFromPoint.call(this, ...args)) {
|
||||
if (element.tagName.startsWith("HOOT")) {
|
||||
continue;
|
||||
}
|
||||
if (element === this.body) {
|
||||
hasBody = true;
|
||||
} else if (element === this.documentElement) {
|
||||
hasDocumentElement = true;
|
||||
} else {
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
if (hasBody) {
|
||||
result.push(this.body);
|
||||
}
|
||||
if (hasDocumentElement) {
|
||||
result.push(this.documentElement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mockedHref() {
|
||||
return this.hasAttribute("href") ? new MockURL(this.getAttribute("href")).href : "";
|
||||
}
|
||||
|
||||
/** @type {typeof matchMedia} */
|
||||
function mockedMatchMedia(mediaQueryString) {
|
||||
return new MockMediaQueryList(mediaQueryString);
|
||||
}
|
||||
|
||||
/** @type {typeof removeEventListener} */
|
||||
function mockedRemoveEventListener(...args) {
|
||||
if (getRunner().dry) {
|
||||
// Ignore listeners during dry run
|
||||
return;
|
||||
}
|
||||
return removeEventListener.call(this, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MutationRecord[]} mutations
|
||||
*/
|
||||
function observeAddedNodes(mutations) {
|
||||
const runner = getRunner();
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (runner.dry) {
|
||||
node.remove();
|
||||
} else {
|
||||
runner.after(node.remove.bind(node));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} ev
|
||||
*/
|
||||
function onAnchorHrefClick(ev) {
|
||||
if (ev.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
const href = ev.target.closest("a[href]")?.href;
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
|
||||
// Assign href to mock location instead of actual location
|
||||
mockLocation.href = href;
|
||||
|
||||
const [, hash] = href.split("#");
|
||||
if (hash) {
|
||||
// Scroll to the target element if the href is/has a hash
|
||||
getDocument().getElementById(hash)?.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
function onWindowResize() {
|
||||
callMediaQueryChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof globalThis} view
|
||||
*/
|
||||
function restoreTouch(view) {
|
||||
const touchObjects = getTouchTargets(view);
|
||||
for (let i = 0; i < touchObjects.length; i++) {
|
||||
const object = touchObjects[i];
|
||||
const { descriptors, toDelete } = originalTouchFunctions[i];
|
||||
if (descriptors) {
|
||||
$defineProperties(object, descriptors);
|
||||
}
|
||||
if (toDelete) {
|
||||
for (const fnName of toDelete) {
|
||||
delete object[fnName];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockMediaQueryList extends MockEventTarget {
|
||||
static publicListeners = ["change"];
|
||||
|
||||
get matches() {
|
||||
return this.media
|
||||
.split(R_COMMA)
|
||||
.some((orPart) => orPart.split(R_AND).every(matchesQueryPart));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mediaQueryString
|
||||
*/
|
||||
constructor(mediaQueryString) {
|
||||
super(...arguments);
|
||||
|
||||
this.media = mediaQueryString.trim().toLowerCase();
|
||||
|
||||
mediaQueryLists.add(this);
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_MEDIA_VALUES = {
|
||||
"display-mode": "browser",
|
||||
pointer: "fine",
|
||||
"prefers-color-scheme": "light",
|
||||
"prefers-reduced-motion": "reduce",
|
||||
};
|
||||
|
||||
const TOUCH_EVENTS = ["touchcancel", "touchend", "touchmove", "touchstart"];
|
||||
|
||||
const R_AND = /\s*\band\b\s*/;
|
||||
const R_COMMA = /\s*,\s*/;
|
||||
const R_MEDIA_QUERY_PROPERTY = /\(\s*([\w-]+)\s*:\s*(.+)\s*\)/;
|
||||
const R_OWL_SYNTHETIC_LISTENER = /\bnativeToSyntheticEvent\b/;
|
||||
|
||||
/** @type {WeakMap<unknown, Record<string, PropertyDescriptor>>} */
|
||||
const originalDescriptors = new WeakMap();
|
||||
const originalTouchFunctions = getTouchTargets(globalThis).map(getTouchDescriptors);
|
||||
|
||||
/** @type {Set<MockMediaQueryList>} */
|
||||
const mediaQueryLists = new Set();
|
||||
const mockConsole = new MockConsole();
|
||||
const mockLocalStorage = new MockStorage();
|
||||
const mockMediaValues = { ...DEFAULT_MEDIA_VALUES };
|
||||
const mockSessionStorage = new MockStorage();
|
||||
let mockTitle = "";
|
||||
|
||||
// Mock descriptors
|
||||
const ANCHOR_MOCK_DESCRIPTORS = {
|
||||
href: {
|
||||
...$getOwnPropertyDescriptor(HTMLAnchorElement.prototype, "href"),
|
||||
get: mockedHref,
|
||||
},
|
||||
};
|
||||
const DOCUMENT_MOCK_DESCRIPTORS = {
|
||||
cookie: {
|
||||
get: () => mockCookie.get(),
|
||||
set: (value) => mockCookie.set(value),
|
||||
},
|
||||
elementFromPoint: { value: mockedElementFromPoint },
|
||||
elementsFromPoint: { value: mockedElementsFromPoint },
|
||||
title: {
|
||||
get: () => mockTitle,
|
||||
set: (value) => (mockTitle = value),
|
||||
},
|
||||
};
|
||||
const ELEMENT_MOCK_DESCRIPTORS = {
|
||||
animate: { value: mockedAnimate },
|
||||
scroll: { value: mockedScroll },
|
||||
scrollBy: { value: mockedScrollBy },
|
||||
scrollIntoView: { value: mockedScrollIntoView },
|
||||
scrollTo: { value: mockedScrollTo },
|
||||
};
|
||||
const WINDOW_MOCK_DESCRIPTORS = {
|
||||
Animation: { value: MockAnimation },
|
||||
Blob: { value: MockBlob },
|
||||
BroadcastChannel: { value: MockBroadcastChannel },
|
||||
cancelAnimationFrame: { value: mockedCancelAnimationFrame, writable: false },
|
||||
clearInterval: { value: mockedClearInterval, writable: false },
|
||||
clearTimeout: { value: mockedClearTimeout, writable: false },
|
||||
ClipboardItem: { value: MockClipboardItem },
|
||||
console: { value: mockConsole, writable: false },
|
||||
crypto: { value: mockCrypto, writable: false },
|
||||
Date: { value: MockDate, writable: false },
|
||||
fetch: { value: interactor("server", mockedFetch).as("fetch"), writable: false },
|
||||
history: { value: mockHistory },
|
||||
innerHeight: { get: () => getCurrentDimensions().height },
|
||||
innerWidth: { get: () => getCurrentDimensions().width },
|
||||
isSecureContext: { value: true, writable: false },
|
||||
Intl: { value: MockIntl },
|
||||
localStorage: { value: mockLocalStorage, writable: false },
|
||||
matchMedia: { value: mockedMatchMedia },
|
||||
MessageChannel: { value: MockMessageChannel },
|
||||
MessagePort: { value: MockMessagePort },
|
||||
navigator: { value: mockNavigator },
|
||||
Notification: { value: MockNotification },
|
||||
outerHeight: { get: () => getCurrentDimensions().height },
|
||||
outerWidth: { get: () => getCurrentDimensions().width },
|
||||
Request: { value: MockRequest, writable: false },
|
||||
requestAnimationFrame: { value: mockedRequestAnimationFrame, writable: false },
|
||||
Response: { value: MockResponse, writable: false },
|
||||
scroll: { value: mockedWindowScroll },
|
||||
scrollBy: { value: mockedWindowScrollBy },
|
||||
scrollTo: { value: mockedWindowScrollTo },
|
||||
sessionStorage: { value: mockSessionStorage, writable: false },
|
||||
setInterval: { value: mockedSetInterval, writable: false },
|
||||
setTimeout: { value: mockedSetTimeout, writable: false },
|
||||
SharedWorker: { value: MockSharedWorker },
|
||||
URL: { value: MockURL },
|
||||
WebSocket: { value: MockWebSocket },
|
||||
Worker: { value: MockWorker },
|
||||
XMLHttpRequest: { value: MockXMLHttpRequest },
|
||||
XMLHttpRequestUpload: { value: MockXMLHttpRequestUpload },
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
export function cleanupWindow() {
|
||||
const view = getWindow();
|
||||
|
||||
// Storages
|
||||
mockLocalStorage.clear();
|
||||
mockSessionStorage.clear();
|
||||
|
||||
// Media
|
||||
mediaQueryLists.clear();
|
||||
$assign(mockMediaValues, DEFAULT_MEDIA_VALUES);
|
||||
|
||||
// Title
|
||||
mockTitle = "";
|
||||
|
||||
// Listeners
|
||||
view.removeEventListener("click", onAnchorHrefClick);
|
||||
view.removeEventListener("resize", onWindowResize);
|
||||
|
||||
// Head & body attributes
|
||||
const { head, body } = view.document;
|
||||
for (const { name } of head.attributes) {
|
||||
head.removeAttribute(name);
|
||||
}
|
||||
for (const { name } of body.attributes) {
|
||||
body.removeAttribute(name);
|
||||
}
|
||||
|
||||
// Touch
|
||||
restoreTouch(view);
|
||||
}
|
||||
|
||||
export function getTitle() {
|
||||
const doc = getDocument();
|
||||
const titleDescriptor = findOriginalDescriptor(doc, "title");
|
||||
if (titleDescriptor) {
|
||||
return titleDescriptor.get.call(doc);
|
||||
} else {
|
||||
return doc.title;
|
||||
}
|
||||
}
|
||||
|
||||
export function getViewPortHeight() {
|
||||
const view = getWindow();
|
||||
const heightDescriptor = findOriginalDescriptor(view, "innerHeight");
|
||||
if (heightDescriptor) {
|
||||
return heightDescriptor.get.call(view);
|
||||
} else {
|
||||
return view.innerHeight;
|
||||
}
|
||||
}
|
||||
|
||||
export function getViewPortWidth() {
|
||||
const view = getWindow();
|
||||
const titleDescriptor = findOriginalDescriptor(view, "innerWidth");
|
||||
if (titleDescriptor) {
|
||||
return titleDescriptor.get.call(view);
|
||||
} else {
|
||||
return view.innerWidth;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} name
|
||||
*/
|
||||
export function mockMatchMedia(values) {
|
||||
$assign(mockMediaValues, values);
|
||||
|
||||
callMediaQueryChanges($keys(values));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} setTouch
|
||||
*/
|
||||
export function mockTouch(setTouch) {
|
||||
const objects = getTouchTargets(getWindow());
|
||||
if (setTouch) {
|
||||
for (const object of objects) {
|
||||
const descriptors = {};
|
||||
for (const eventName of TOUCH_EVENTS) {
|
||||
const fnName = `on${eventName}`;
|
||||
if (!$hasOwn(object, fnName)) {
|
||||
descriptors[fnName] = makeEventDescriptor(eventName);
|
||||
}
|
||||
}
|
||||
$defineProperties(object, descriptors);
|
||||
}
|
||||
mockMatchMedia({ pointer: "coarse" });
|
||||
} else {
|
||||
for (const object of objects) {
|
||||
for (const eventName of TOUCH_EVENTS) {
|
||||
delete object[`on${eventName}`];
|
||||
}
|
||||
}
|
||||
mockMatchMedia({ pointer: "fine" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof globalThis} [view=getWindow()]
|
||||
*/
|
||||
export function patchWindow(view = getWindow()) {
|
||||
// Window (doesn't need to be ready)
|
||||
applyPropertyDescriptors(view, WINDOW_MOCK_DESCRIPTORS);
|
||||
|
||||
waitForDocument(view.document).then(() => {
|
||||
// Document
|
||||
applyPropertyDescriptors(view.document, DOCUMENT_MOCK_DESCRIPTORS);
|
||||
|
||||
// Element prototypes
|
||||
applyPropertyDescriptors(view.Element.prototype, ELEMENT_MOCK_DESCRIPTORS);
|
||||
applyPropertyDescriptors(view.HTMLAnchorElement.prototype, ANCHOR_MOCK_DESCRIPTORS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
*/
|
||||
export function setTitle(value) {
|
||||
const doc = getDocument();
|
||||
const titleDescriptor = findOriginalDescriptor(doc, "title");
|
||||
if (titleDescriptor) {
|
||||
titleDescriptor.set.call(doc, value);
|
||||
} else {
|
||||
doc.title = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupWindow() {
|
||||
const view = getWindow();
|
||||
|
||||
// Listeners
|
||||
view.addEventListener("click", onAnchorHrefClick);
|
||||
view.addEventListener("resize", onWindowResize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof globalThis} [view=getWindow()]
|
||||
*/
|
||||
export function watchAddedNodes(view = getWindow()) {
|
||||
const observer = new MutationObserver(observeAddedNodes);
|
||||
observer.observe(view.document.head, { childList: true });
|
||||
|
||||
return function unwatchAddedNodes() {
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {typeof globalThis} [view=getWindow()]
|
||||
*/
|
||||
export function watchListeners(view = getWindow()) {
|
||||
const targets = getWatchedEventTargets(view);
|
||||
for (const target of targets) {
|
||||
target.addEventListener = mockedAddEventListener;
|
||||
target.removeEventListener = mockedRemoveEventListener;
|
||||
}
|
||||
|
||||
return function unwatchAllListeners() {
|
||||
for (const target of targets) {
|
||||
target.addEventListener = addEventListener;
|
||||
target.removeEventListener = removeEventListener;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function checking that the given target does not contain any unexpected
|
||||
* key. The list of accepted keys is the initial list of keys of the target, along
|
||||
* with an optional `whiteList` argument.
|
||||
*
|
||||
* @template T
|
||||
* @param {T} target
|
||||
* @param {string[]} [whiteList]
|
||||
* @example
|
||||
* afterEach(watchKeys(window, ["odoo"]));
|
||||
*/
|
||||
export function watchKeys(target, whiteList) {
|
||||
const acceptedKeys = new Set([...$ownKeys(target), ...(whiteList || [])]);
|
||||
|
||||
return function checkKeys() {
|
||||
const keysDiff = $ownKeys(target).filter(
|
||||
(key) => $isNaN($parseFloat(key)) && !acceptedKeys.has(key)
|
||||
);
|
||||
for (const key of keysDiff) {
|
||||
const descriptor = $getOwnPropertyDescriptor(target, key);
|
||||
if (descriptor.configurable) {
|
||||
delete target[key];
|
||||
} else if (descriptor.writable) {
|
||||
target[key] = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,624 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, makeExpect, test } from "@odoo/hoot";
|
||||
import { check, manuallyDispatchProgrammaticEvent, tick, waitFor } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
import { Test } from "../../core/test";
|
||||
import { makeLabel } from "../../hoot_utils";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("makeExpect passing, without a test", () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
expect(() => customExpect(true).toBe(true)).toThrow(
|
||||
"cannot call `expect()` outside of a test"
|
||||
);
|
||||
|
||||
hooks.before();
|
||||
|
||||
customExpect({ key: true }).toEqual({ key: true });
|
||||
customExpect("oui").toBe("oui");
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(true);
|
||||
expect(results.events).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("makeExpect failing, without a test", () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
customExpect({ key: true }).toEqual({ key: true });
|
||||
customExpect("oui").toBe("non");
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(false);
|
||||
expect(results.events).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("makeExpect with a test", async () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
const customTest = new Test(null, "test", {});
|
||||
customTest.setRunFn(() => {
|
||||
customExpect({ key: true }).toEqual({ key: true });
|
||||
customExpect("oui").toBe("non");
|
||||
});
|
||||
|
||||
hooks.before(customTest);
|
||||
|
||||
await customTest.run();
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(customTest.lastResults).toBe(results);
|
||||
// Result is expected to have the same shape, no need for other assertions
|
||||
});
|
||||
|
||||
test("makeExpect with a test flagged with TODO", async () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
const customTest = new Test(null, "test", { todo: true });
|
||||
customTest.setRunFn(() => {
|
||||
customExpect(1).toBe(1);
|
||||
});
|
||||
|
||||
hooks.before(customTest);
|
||||
|
||||
await customTest.run();
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(false);
|
||||
expect(results.events[0].pass).toBe(true);
|
||||
});
|
||||
|
||||
test("makeExpect with no assertions & query events", async () => {
|
||||
await mountForTest(/* xml */ `<div>ABC</div>`);
|
||||
|
||||
const [, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
await waitFor("div:contains(ABC)");
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(true);
|
||||
expect(results.events).toHaveLength(1);
|
||||
expect(results.events[0].label).toBe("waitFor");
|
||||
});
|
||||
|
||||
test("makeExpect with no assertions & no query events", () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
expect(() => customExpect.assertions(0)).toThrow(
|
||||
"expected assertions count should be more than 1"
|
||||
);
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(false);
|
||||
expect(results.events).toHaveLength(1);
|
||||
expect(results.events[0].message).toEqual([
|
||||
"expected at least",
|
||||
["1", "integer"],
|
||||
"assertion or query event, but none were run",
|
||||
]);
|
||||
});
|
||||
|
||||
test("makeExpect with unconsumed matchers", () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
expect(() => customExpect(true, true)).toThrow("`expect()` only accepts a single argument");
|
||||
customExpect(1).toBe(1);
|
||||
customExpect(true);
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(false);
|
||||
expect(results.events).toHaveLength(2);
|
||||
expect(results.events[1].message.join(" ")).toBe(
|
||||
"called once without calling any matchers"
|
||||
);
|
||||
});
|
||||
|
||||
test("makeExpect with unverified steps", () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
customExpect.step("oui");
|
||||
customExpect.verifySteps(["oui"]);
|
||||
customExpect.step("non");
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(false);
|
||||
expect(results.events).toHaveLength(2); // 1 'verifySteps' + 1 'unverified steps'
|
||||
expect(results.events.at(-1).message).toEqual(["unverified steps"]);
|
||||
});
|
||||
|
||||
test("makeExpect retains current values", () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
const object = { a: 1 };
|
||||
customExpect(object).toEqual({ b: 2 });
|
||||
object.b = 2;
|
||||
|
||||
const testResult = hooks.after();
|
||||
|
||||
const [assertion] = testResult.events;
|
||||
expect(assertion.pass).toBe(false);
|
||||
expect(assertion.failedDetails[1][1]).toEqual({ a: 1 });
|
||||
expect(object).toEqual({ a: 1, b: 2 });
|
||||
});
|
||||
|
||||
test("'expect' results contain the correct informations", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<label style="color: #f00">
|
||||
Checkbox
|
||||
<input class="cb" type="checkbox" />
|
||||
</label>
|
||||
<input type="text" value="abc" />
|
||||
`);
|
||||
|
||||
await check("input[type=checkbox]");
|
||||
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
const matchers = [
|
||||
// Standard
|
||||
["toBe", 1, 1],
|
||||
["toBeCloseTo", 1, 1],
|
||||
["toBeEmpty", []],
|
||||
["toBeGreaterThan", 1, 0],
|
||||
["toBeInstanceOf", {}, Object],
|
||||
["toBeLessThan", 0, 1],
|
||||
["toBeOfType", 1, "integer"],
|
||||
["toBeWithin", 1, 0, 2],
|
||||
["toEqual", [], []],
|
||||
["toHaveLength", [], 0],
|
||||
["toInclude", [1], 1],
|
||||
["toMatch", "a", "a"],
|
||||
["toMatchObject", { a: 1, b: { l: [1, 2] } }, { b: { l: [1, 2] } }],
|
||||
[
|
||||
"toThrow",
|
||||
() => {
|
||||
throw new Error("");
|
||||
},
|
||||
],
|
||||
// DOM
|
||||
["toBeChecked", ".cb"],
|
||||
["toBeDisplayed", ".cb"],
|
||||
["toBeEnabled", ".cb"],
|
||||
["toBeFocused", ".cb"],
|
||||
["toBeVisible", ".cb"],
|
||||
["toHaveAttribute", ".cb", "type", "checkbox"],
|
||||
["toHaveClass", ".cb", "cb"],
|
||||
["toHaveCount", ".cb", 1],
|
||||
["toHaveInnerHTML", ".cb", ""],
|
||||
["toHaveOuterHTML", ".cb", `<input class="cb" type="checkbox" />`],
|
||||
["toHaveProperty", ".cb", "checked", true],
|
||||
["toHaveRect", "label", { x: 0 }],
|
||||
["toHaveStyle", "label", { color: "rgb(255, 0, 0)" }],
|
||||
["toHaveText", "label", "Checkbox"],
|
||||
["toHaveValue", "input[type=text]", "abc"],
|
||||
];
|
||||
|
||||
for (const [name, ...args] of matchers) {
|
||||
customExpect(args.shift())[name](...args);
|
||||
}
|
||||
|
||||
const testResult = hooks.after();
|
||||
|
||||
expect(testResult.pass).toBe(true);
|
||||
expect(testResult.events).toHaveLength(matchers.length);
|
||||
expect(testResult.events.map(({ label }) => label)).toEqual(matchers.map(([name]) => name));
|
||||
});
|
||||
|
||||
test("assertions are prevented after an error", async () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
|
||||
hooks.before();
|
||||
|
||||
await customExpect(Promise.resolve(1)).resolves.toBe(1);
|
||||
hooks.error(new Error("boom"));
|
||||
customExpect(2).toBe(2);
|
||||
customExpect(Promise.resolve(3)).resolves.toBe(3);
|
||||
await tick();
|
||||
|
||||
const results = hooks.after();
|
||||
|
||||
expect(results.pass).toBe(false);
|
||||
expect(results.events).toHaveLength(3); // toBe + error + unverified errors
|
||||
});
|
||||
|
||||
describe("standard matchers", () => {
|
||||
test("toBe", () => {
|
||||
// Boolean
|
||||
expect(true).toBe(true);
|
||||
expect(true).not.toBe(false);
|
||||
|
||||
// Floats
|
||||
expect(1.1).toBe(1.1);
|
||||
expect(0.1 + 0.2).not.toBe(0.3); // floating point errors
|
||||
|
||||
// Integers
|
||||
expect(+0).toBe(-0);
|
||||
expect(1 + 2).toBe(3);
|
||||
expect(1).not.toBe(-1);
|
||||
expect(NaN).toBe(NaN);
|
||||
|
||||
// Strings
|
||||
expect("abc").toBe("abc");
|
||||
expect(new String("abc")).not.toBe(new String("abc"));
|
||||
|
||||
// Other primitives
|
||||
expect(undefined).toBe(undefined);
|
||||
expect(undefined).not.toBe(null);
|
||||
|
||||
// Symbols
|
||||
const symbol = Symbol("symbol");
|
||||
expect(symbol).toBe(symbol);
|
||||
expect(symbol).not.toBe(Symbol("symbol"));
|
||||
expect(Symbol.for("symbol")).toBe(Symbol.for("symbol"));
|
||||
|
||||
// Objects
|
||||
const object = { x: 1 };
|
||||
expect(object).toBe(object);
|
||||
expect([]).not.toBe([]);
|
||||
expect(object).not.toBe({ x: 1 });
|
||||
|
||||
// Dates
|
||||
const date = new Date(0);
|
||||
expect(date).toBe(date);
|
||||
expect(new Date(0)).not.toBe(new Date(0));
|
||||
|
||||
// Nodes
|
||||
expect(new Image()).not.toBe(new Image());
|
||||
expect(document.createElement("div")).not.toBe(document.createElement("div"));
|
||||
});
|
||||
|
||||
test("toBeCloseTo", () => {
|
||||
expect(0.2 + 0.1).toBeCloseTo(0.3);
|
||||
expect(0.2 + 0.1).toBeCloseTo(0.3, { margin: Number.EPSILON });
|
||||
expect(0.2 + 0.1).not.toBeCloseTo(0.3, { margin: 1e-17 });
|
||||
|
||||
expect(3.51).toBeCloseTo(3);
|
||||
expect(3.51).toBeCloseTo(3.52, { margin: 2e-2 });
|
||||
expect(3.502).not.toBeCloseTo(3.503, { margin: 1e-3 });
|
||||
|
||||
expect(3).toBeCloseTo(4 - 1e-15);
|
||||
expect(3 + 1e-15).toBeCloseTo(4);
|
||||
expect(3).not.toBeCloseTo(4);
|
||||
});
|
||||
|
||||
test("toEqual", () => {
|
||||
// Boolean
|
||||
expect(true).toEqual(true);
|
||||
expect(true).not.toEqual(false);
|
||||
|
||||
// Floats
|
||||
expect(1.1).toEqual(1.1);
|
||||
expect(0.1 + 0.2).not.toEqual(0.3); // floating point errors
|
||||
|
||||
// Integers
|
||||
expect(+0).toEqual(-0);
|
||||
expect(1 + 2).toEqual(3);
|
||||
expect(1).not.toEqual(-1);
|
||||
expect(NaN).toEqual(NaN);
|
||||
|
||||
// Strings
|
||||
expect("abc").toEqual("abc");
|
||||
expect(new String("abc")).toEqual(new String("abc"));
|
||||
|
||||
// Other primitives
|
||||
expect(undefined).toEqual(undefined);
|
||||
expect(undefined).not.toEqual(null);
|
||||
|
||||
// Symbols
|
||||
const symbol = Symbol("symbol");
|
||||
expect(symbol).toEqual(symbol);
|
||||
expect(symbol).not.toEqual(Symbol("symbol"));
|
||||
expect(Symbol.for("symbol")).toEqual(Symbol.for("symbol"));
|
||||
|
||||
// Objects
|
||||
const object = { x: 1 };
|
||||
expect(object).toEqual(object);
|
||||
expect([]).toEqual([]);
|
||||
expect(object).toEqual({ x: 1 });
|
||||
|
||||
// Iterables
|
||||
expect(new Set([1, 4, 6])).toEqual(new Set([1, 4, 6]));
|
||||
expect(new Set([1, 4, 6])).not.toEqual([1, 4, 6]);
|
||||
expect(new Map([[{}, "abc"]])).toEqual(new Map([[{}, "abc"]]));
|
||||
|
||||
// Dates
|
||||
const date = new Date(0);
|
||||
expect(date).toEqual(date);
|
||||
expect(new Date(0)).toEqual(new Date(0));
|
||||
|
||||
// Nodes
|
||||
expect(new Image()).toEqual(new Image());
|
||||
expect(document.createElement("div")).toEqual(document.createElement("div"));
|
||||
expect(document.createElement("div")).not.toEqual(document.createElement("span"));
|
||||
});
|
||||
|
||||
test("toMatch", () => {
|
||||
class Exception extends Error {}
|
||||
|
||||
expect("aaaa").toMatch(/^a{4}$/);
|
||||
expect("aaaa").toMatch("aa");
|
||||
expect("aaaa").not.toMatch("aaaaa");
|
||||
|
||||
// Matcher from a class
|
||||
expect(new Exception("oui")).toMatch(Error);
|
||||
expect(new Exception("oui")).toMatch(Exception);
|
||||
expect(new Exception("oui")).toMatch(new Error("oui"));
|
||||
});
|
||||
|
||||
test("toMatchObject", () => {
|
||||
expect({
|
||||
bath: true,
|
||||
bedrooms: 4,
|
||||
kitchen: {
|
||||
amenities: ["oven", "stove", "washer"],
|
||||
area: 20,
|
||||
wallColor: "white",
|
||||
},
|
||||
}).toMatchObject({
|
||||
bath: true,
|
||||
kitchen: {
|
||||
amenities: ["oven", "stove", "washer"],
|
||||
wallColor: "white",
|
||||
},
|
||||
});
|
||||
expect([{ tralalero: "tralala" }, { foo: 1 }]).toMatchObject([
|
||||
{ tralalero: "tralala" },
|
||||
{ foo: 1 },
|
||||
]);
|
||||
expect([{ tralalero: "tralala" }, { foo: 1, lirili: "larila" }]).toMatchObject([
|
||||
{ tralalero: "tralala" },
|
||||
{ foo: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("toThrow", async () => {
|
||||
const asyncBoom = async () => {
|
||||
throw new Error("rejection");
|
||||
};
|
||||
|
||||
const boom = () => {
|
||||
throw new Error("error");
|
||||
};
|
||||
|
||||
expect(boom).toThrow();
|
||||
expect(boom).toThrow("error");
|
||||
expect(boom).toThrow(new Error("error"));
|
||||
|
||||
await expect(asyncBoom()).rejects.toThrow();
|
||||
await expect(asyncBoom()).rejects.toThrow("rejection");
|
||||
await expect(asyncBoom()).rejects.toThrow(new Error("rejection"));
|
||||
});
|
||||
|
||||
test("verifyErrors", async () => {
|
||||
expect.assertions(1);
|
||||
expect.errors(3);
|
||||
|
||||
const boom = (msg) => {
|
||||
throw new Error(msg);
|
||||
};
|
||||
|
||||
// Timeout
|
||||
setTimeout(() => boom("timeout"));
|
||||
// Promise
|
||||
queueMicrotask(() => boom("promise"));
|
||||
// Event
|
||||
manuallyDispatchProgrammaticEvent(window, "error", { message: "event" });
|
||||
|
||||
await tick();
|
||||
|
||||
expect.verifyErrors(["event", "promise", "timeout"]);
|
||||
});
|
||||
|
||||
test("verifySteps", () => {
|
||||
expect.assertions(4);
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
expect.step("abc");
|
||||
expect.step("def");
|
||||
expect.verifySteps(["abc", "def"]);
|
||||
|
||||
expect.step({ property: "foo" });
|
||||
expect.step("ghi");
|
||||
|
||||
expect.verifySteps([{ property: "foo" }, "ghi"]);
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DOM matchers", () => {
|
||||
test("toBeChecked", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input type="checkbox" />
|
||||
<input type="checkbox" checked="" />
|
||||
`);
|
||||
|
||||
expect("input:first").not.toBeChecked();
|
||||
expect("input:last").toBeChecked();
|
||||
});
|
||||
|
||||
test("toHaveAttribute", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input type="number" disabled="" />
|
||||
`);
|
||||
|
||||
expect("input").toHaveAttribute("disabled");
|
||||
expect("input").not.toHaveAttribute("readonly");
|
||||
expect("input").toHaveAttribute("type", "number");
|
||||
});
|
||||
|
||||
test("toHaveCount", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<ul>
|
||||
<li>milk</li>
|
||||
<li>eggs</li>
|
||||
<li>milk</li>
|
||||
</ul>
|
||||
`);
|
||||
|
||||
expect("iframe").toHaveCount(0);
|
||||
expect("iframe").not.toHaveCount();
|
||||
expect("ul").toHaveCount(1);
|
||||
expect("ul").toHaveCount();
|
||||
expect("li").toHaveCount(3);
|
||||
expect("li").toHaveCount();
|
||||
expect("li:contains(milk)").toHaveCount(2);
|
||||
});
|
||||
|
||||
test("toHaveProperty", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input type="search" readonly="" />
|
||||
`);
|
||||
|
||||
expect("input").toHaveProperty("type", "search");
|
||||
expect("input").not.toHaveProperty("readonly");
|
||||
expect("input").toHaveProperty("readOnly", true);
|
||||
});
|
||||
|
||||
test("toHaveText", async () => {
|
||||
class TextComponent extends Component {
|
||||
static props = {};
|
||||
static template = xml`
|
||||
<div class="with">With<t t-esc="nbsp" />nbsp</div>
|
||||
<div class="without">Without nbsp</div>
|
||||
`;
|
||||
|
||||
nbsp = "\u00a0";
|
||||
}
|
||||
|
||||
await mountForTest(TextComponent);
|
||||
|
||||
expect(".with").toHaveText("With nbsp");
|
||||
expect(".with").toHaveText("With\u00a0nbsp", { raw: true });
|
||||
expect(".with").not.toHaveText("With\u00a0nbsp");
|
||||
|
||||
expect(".without").toHaveText("Without nbsp");
|
||||
expect(".without").not.toHaveText("Without\u00a0nbsp");
|
||||
expect(".without").not.toHaveText("Without\u00a0nbsp", { raw: true });
|
||||
});
|
||||
|
||||
test("toHaveInnerHTML", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="parent">
|
||||
<p>
|
||||
abc<strong>def</strong>ghi
|
||||
<br />
|
||||
<input type="text" />
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(".parent").toHaveInnerHTML(/* xml */ `
|
||||
<p>abc<strong>def</strong>ghi<br><input type="text"></p>
|
||||
`);
|
||||
});
|
||||
|
||||
test("toHaveOuterHTML", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="parent">
|
||||
<p>
|
||||
abc<strong>def</strong>ghi
|
||||
<br />
|
||||
<input type="text" />
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(".parent").toHaveOuterHTML(/* xml */ `
|
||||
<div class="parent">
|
||||
<p>abc<strong>def</strong>ghi<br><input type="text"></p>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
test("toHaveStyle", async () => {
|
||||
const documentFontSize = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize
|
||||
);
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="div" style="width: 3rem; height: 26px" />
|
||||
`);
|
||||
|
||||
expect(".div").toHaveStyle({ width: `${3 * documentFontSize}px`, height: 26 });
|
||||
expect(".div").toHaveStyle({ display: "block" });
|
||||
expect(".div").toHaveStyle("border-top");
|
||||
expect(".div").not.toHaveStyle({ height: 50 });
|
||||
|
||||
expect(".div").toHaveStyle("height: 26px ; width : 3rem", { inline: true });
|
||||
expect(".div").not.toHaveStyle({ display: "block" }, { inline: true });
|
||||
expect(".div").not.toHaveStyle("border-top", { inline: true });
|
||||
});
|
||||
|
||||
test("no elements found messages", async () => {
|
||||
const [customExpect, hooks] = makeExpect({ headless: true });
|
||||
hooks.before();
|
||||
|
||||
await mountForTest(/* xml */ `
|
||||
<div />
|
||||
`);
|
||||
|
||||
const SELECTOR = "#brrbrrpatapim";
|
||||
const DOM_MATCHERS = [
|
||||
["toBeChecked"],
|
||||
["toBeDisplayed"],
|
||||
["toBeEnabled"],
|
||||
["toBeFocused"],
|
||||
["toBeVisible"],
|
||||
["toHaveAttribute", "attr"],
|
||||
["toHaveClass", "cls"],
|
||||
["toHaveInnerHTML", "<html></html>"],
|
||||
["toHaveOuterHTML", "<html></html>"],
|
||||
["toHaveProperty", "prop"],
|
||||
["toHaveRect", {}],
|
||||
["toHaveStyle", {}],
|
||||
["toHaveText", "abc"],
|
||||
["toHaveValue", "value"],
|
||||
];
|
||||
|
||||
for (const [matcher, arg] of DOM_MATCHERS) {
|
||||
customExpect(SELECTOR)[matcher](arg);
|
||||
}
|
||||
|
||||
const results = hooks.after();
|
||||
const assertions = results.getEvents("assertion");
|
||||
for (let i = 0; i < DOM_MATCHERS.length; i++) {
|
||||
const { label, message } = assertions[i];
|
||||
expect.step(label);
|
||||
expect(message).toEqual([
|
||||
"expected at least",
|
||||
makeLabel(1),
|
||||
"element and got",
|
||||
makeLabel(0),
|
||||
"elements matching",
|
||||
makeLabel(SELECTOR),
|
||||
]);
|
||||
}
|
||||
|
||||
expect.verifySteps(DOM_MATCHERS.map(([matcher]) => matcher));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, defineTags, describe, expect, test } from "@odoo/hoot";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
import { Runner } from "../../core/runner";
|
||||
import { Suite } from "../../core/suite";
|
||||
import { undefineTags } from "../../core/tag";
|
||||
|
||||
const makeTestRunner = () => {
|
||||
const runner = new Runner();
|
||||
after(() => undefineTags(runner.tags.keys()));
|
||||
return runner;
|
||||
};
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("can register suites", () => {
|
||||
const runner = makeTestRunner();
|
||||
runner.describe("a suite", () => {});
|
||||
runner.describe("another suite", () => {});
|
||||
|
||||
expect(runner.suites).toHaveLength(2);
|
||||
expect(runner.tests).toHaveLength(0);
|
||||
for (const suite of runner.suites.values()) {
|
||||
expect(suite).toMatch(Suite);
|
||||
}
|
||||
});
|
||||
|
||||
test("can register nested suites", () => {
|
||||
const runner = makeTestRunner();
|
||||
runner.describe(["a", "b", "c"], () => {});
|
||||
|
||||
expect([...runner.suites.values()].map((s) => s.name)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("can register tests", () => {
|
||||
const runner = makeTestRunner();
|
||||
runner.describe("suite 1", () => {
|
||||
runner.test("test 1", () => {});
|
||||
});
|
||||
runner.describe("suite 2", () => {
|
||||
runner.test("test 2", () => {});
|
||||
runner.test("test 3", () => {});
|
||||
});
|
||||
|
||||
expect(runner.suites).toHaveLength(2);
|
||||
expect(runner.tests).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("should not have duplicate suites", () => {
|
||||
const runner = makeTestRunner();
|
||||
runner.describe(["parent", "child a"], () => {});
|
||||
runner.describe(["parent", "child b"], () => {});
|
||||
|
||||
expect([...runner.suites.values()].map((suite) => suite.name)).toEqual([
|
||||
"parent",
|
||||
"child a",
|
||||
"child b",
|
||||
]);
|
||||
});
|
||||
|
||||
test("can refuse standalone tests", async () => {
|
||||
const runner = makeTestRunner();
|
||||
expect(() =>
|
||||
runner.test([], "standalone test", () => {
|
||||
expect(true).toBe(false);
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("can register test tags", async () => {
|
||||
const runner = makeTestRunner();
|
||||
runner.describe("suite", () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
// 10
|
||||
runner.test.tags(`Tag-${i}`);
|
||||
}
|
||||
|
||||
runner.test("tagged test", () => {});
|
||||
});
|
||||
|
||||
expect(runner.tags).toHaveLength(10);
|
||||
expect(runner.tests.values().next().value.tags).toHaveLength(10);
|
||||
});
|
||||
|
||||
test("can define exclusive test tags", async () => {
|
||||
expect.assertions(3);
|
||||
|
||||
defineTags(
|
||||
{
|
||||
name: "a",
|
||||
exclude: ["b"],
|
||||
},
|
||||
{
|
||||
name: "b",
|
||||
exclude: ["a"],
|
||||
}
|
||||
);
|
||||
|
||||
const runner = makeTestRunner();
|
||||
runner.describe("suite", () => {
|
||||
runner.test.tags("a");
|
||||
runner.test("first test", () => {});
|
||||
|
||||
runner.test.tags("b");
|
||||
runner.test("second test", () => {});
|
||||
|
||||
runner.test.tags("a", "b");
|
||||
expect(() => runner.test("third test", () => {})).toThrow(`cannot apply tag "b"`);
|
||||
|
||||
runner.test.tags("a", "c");
|
||||
runner.test("fourth test", () => {});
|
||||
});
|
||||
|
||||
expect(runner.tests).toHaveLength(3);
|
||||
expect(runner.tags).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
import { Suite } from "../../core/suite";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("should have a hashed id", () => {
|
||||
expect(new Suite(null, "a suite", []).id).toMatch(/^\w{8}$/);
|
||||
});
|
||||
|
||||
test("should describe its path in its name", () => {
|
||||
const a = new Suite(null, "a", []);
|
||||
const b = new Suite(a, "b", []);
|
||||
const c = new Suite(a, "c", []);
|
||||
const d = new Suite(b, "d", []);
|
||||
|
||||
expect(a.parent).toBe(null);
|
||||
expect(b.parent).toBe(a);
|
||||
expect(c.parent).toBe(a);
|
||||
expect(d.parent.parent).toBe(a);
|
||||
|
||||
expect(a.fullName).toBe("a");
|
||||
expect(b.fullName).toBe("a/b");
|
||||
expect(c.fullName).toBe("a/c");
|
||||
expect(d.fullName).toBe("a/b/d");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
import { Suite } from "../../core/suite";
|
||||
import { Test } from "../../core/test";
|
||||
|
||||
function disableHighlighting() {
|
||||
if (!window.Prism) {
|
||||
return () => {};
|
||||
}
|
||||
const { highlight } = window.Prism;
|
||||
window.Prism.highlight = (text) => text;
|
||||
|
||||
return function restoreHighlighting() {
|
||||
window.Prism.highlight = highlight;
|
||||
};
|
||||
}
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("should have a hashed id", () => {
|
||||
expect(new Test(null, "a test", {}).id).toMatch(/^\w{8}$/);
|
||||
});
|
||||
|
||||
test("should describe its path in its name", () => {
|
||||
const a = new Suite(null, "a", {});
|
||||
const b = new Suite(a, "b", {});
|
||||
const t1 = new Test(null, "t1", {});
|
||||
const t2 = new Test(a, "t2", {});
|
||||
const t3 = new Test(b, "t3", {});
|
||||
|
||||
expect(t1.fullName).toBe("t1");
|
||||
expect(t2.fullName).toBe("a/t2");
|
||||
expect(t3.fullName).toBe("a/b/t3");
|
||||
});
|
||||
|
||||
test("run is async and lazily formatted", () => {
|
||||
const restoreHighlighting = disableHighlighting();
|
||||
|
||||
const testName = "some test";
|
||||
const t = new Test(null, testName, {});
|
||||
const runFn = () => {
|
||||
// Synchronous
|
||||
expect(1).toBe(1);
|
||||
};
|
||||
|
||||
expect(t.run).toBe(null);
|
||||
expect(t.runFnString).toBe("");
|
||||
expect(t.formatted).toBe(false);
|
||||
|
||||
t.setRunFn(runFn);
|
||||
|
||||
expect(t.run()).toBeInstanceOf(Promise);
|
||||
expect(t.runFnString).toBe(runFn.toString());
|
||||
expect(t.formatted).toBe(false);
|
||||
|
||||
expect(String(t.code)).toBe(
|
||||
`
|
||||
test("${testName}", () => {
|
||||
// Synchronous
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
`.trim()
|
||||
);
|
||||
expect(t.formatted).toBe(true);
|
||||
|
||||
restoreHighlighting();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,922 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, getFixture, test } from "@odoo/hoot";
|
||||
import {
|
||||
animationFrame,
|
||||
click,
|
||||
formatXml,
|
||||
getActiveElement,
|
||||
getFocusableElements,
|
||||
getNextFocusableElement,
|
||||
getPreviousFocusableElement,
|
||||
isDisplayed,
|
||||
isEditable,
|
||||
isFocusable,
|
||||
isInDOM,
|
||||
isVisible,
|
||||
queryAll,
|
||||
queryAllRects,
|
||||
queryAllTexts,
|
||||
queryFirst,
|
||||
queryOne,
|
||||
queryRect,
|
||||
waitFor,
|
||||
waitForNone,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { mockTouch } from "@odoo/hoot-mock";
|
||||
import { getParentFrame } from "@web/../lib/hoot-dom/helpers/dom";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
const $ = queryFirst;
|
||||
const $1 = queryOne;
|
||||
const $$ = queryAll;
|
||||
|
||||
/**
|
||||
* @param {...string} queryAllSelectors
|
||||
*/
|
||||
const expectSelector = (...queryAllSelectors) => {
|
||||
/**
|
||||
* @param {string} nativeSelector
|
||||
*/
|
||||
const toEqualNodes = (nativeSelector, options) => {
|
||||
if (typeof nativeSelector !== "string") {
|
||||
throw new Error(`Invalid selector: ${nativeSelector}`);
|
||||
}
|
||||
let root = options?.root || getFixture();
|
||||
if (typeof root === "string") {
|
||||
root = getFixture().querySelector(root);
|
||||
if (root.tagName === "IFRAME") {
|
||||
root = root.contentDocument;
|
||||
}
|
||||
}
|
||||
let nodes = nativeSelector ? [...root.querySelectorAll(nativeSelector)] : [];
|
||||
if (Number.isInteger(options?.index)) {
|
||||
nodes = [nodes.at(options.index)];
|
||||
}
|
||||
|
||||
const selector = queryAllSelectors.join(", ");
|
||||
const fnNodes = $$(selector);
|
||||
expect(fnNodes).toEqual($$`${selector}`, {
|
||||
message: `should return the same result from a tagged template literal`,
|
||||
});
|
||||
expect(fnNodes).toEqual(nodes, {
|
||||
message: `should match ${nodes.length} nodes`,
|
||||
});
|
||||
};
|
||||
|
||||
return { toEqualNodes };
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Document} document
|
||||
* @param {HTMLElement} [root]
|
||||
* @returns {Promise<HTMLIFrameElement>}
|
||||
*/
|
||||
const makeIframe = (document, root) =>
|
||||
new Promise((resolve) => {
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.addEventListener("load", () => resolve(iframe));
|
||||
iframe.srcdoc = "<body></body>";
|
||||
(root || document.body).appendChild(iframe);
|
||||
});
|
||||
|
||||
const FULL_HTML_TEMPLATE = /* html */ `
|
||||
<header>
|
||||
<h1 class="title">Title</h1>
|
||||
</header>
|
||||
<main id="custom-html">
|
||||
<h5 class="title">List header</h5>
|
||||
<ul colspan="1" class="overflow-auto" style="max-height: 80px">
|
||||
<li class="text highlighted">First item</li>
|
||||
<li class="text">Second item</li>
|
||||
<li class="text">Last item</li>
|
||||
</ul>
|
||||
<p colspan="2" class="text">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur justo
|
||||
velit, tristique vitae neque a, faucibus mollis dui. Aliquam iaculis
|
||||
sodales mi id posuere. Proin malesuada bibendum pellentesque. Phasellus
|
||||
mattis at massa quis gravida. Morbi luctus interdum mi, quis dapibus
|
||||
augue. Vivamus condimentum nunc mi, vitae suscipit turpis dictum nec.
|
||||
Sed varius diam dui, eget ultricies ante dictum ac.
|
||||
</p>
|
||||
<div class="hidden" style="display: none;">Invisible section</div>
|
||||
<svg></svg>
|
||||
<form class="overflow-auto" style="max-width: 100px">
|
||||
<h5 class="title">Form title</h5>
|
||||
<input name="name" type="text" value="John Doe (JOD)" />
|
||||
<input name="email" type="email" value="johndoe@sample.com" />
|
||||
<select name="title" value="mr">
|
||||
<option>Select an option</option>
|
||||
<option value="mr" selected="selected">Mr.</option>
|
||||
<option value="mrs">Mrs.</option>
|
||||
</select>
|
||||
<select name="job">
|
||||
<option selected="selected">Select an option</option>
|
||||
<option value="employer">Employer</option>
|
||||
<option value="employee">Employee</option>
|
||||
</select>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="submit" disabled="disabled">Cancel</button>
|
||||
</form>
|
||||
<iframe srcdoc="<p>Iframe text content</p>"></iframe>
|
||||
</main>
|
||||
<footer>
|
||||
<em>Footer</em>
|
||||
<button type="button">Back to top</button>
|
||||
</footer>
|
||||
`;
|
||||
|
||||
customElements.define(
|
||||
"hoot-test-shadow-root",
|
||||
class ShadowRoot extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const shadow = this.attachShadow({ mode: "open" });
|
||||
|
||||
const p = document.createElement("p");
|
||||
p.textContent = "Shadow content";
|
||||
|
||||
const input = document.createElement("input");
|
||||
|
||||
shadow.append(p, input);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
describe.tags("ui");
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("formatXml", () => {
|
||||
expect(formatXml("")).toBe("");
|
||||
expect(formatXml("<input />")).toBe("<input/>");
|
||||
expect(
|
||||
formatXml(/* xml */ `
|
||||
<div>
|
||||
A
|
||||
</div>
|
||||
`)
|
||||
).toBe(`<div>\n A\n</div>`);
|
||||
expect(formatXml(/* xml */ `<div>A</div>`)).toBe(`<div>\n A\n</div>`);
|
||||
|
||||
// Inline
|
||||
expect(
|
||||
formatXml(
|
||||
/* xml */ `
|
||||
<div>
|
||||
A
|
||||
</div>
|
||||
`,
|
||||
{ keepInlineTextNodes: true }
|
||||
)
|
||||
).toBe(`<div>\n A\n</div>`);
|
||||
expect(formatXml(/* xml */ `<div>A</div>`, { keepInlineTextNodes: true })).toBe(
|
||||
`<div>A</div>`
|
||||
);
|
||||
});
|
||||
|
||||
test("getActiveElement", async () => {
|
||||
await mountForTest(/* xml */ `<iframe srcdoc="<input >"></iframe>`);
|
||||
|
||||
expect(":iframe input").not.toBeFocused();
|
||||
|
||||
const input = $1(":iframe input");
|
||||
await click(input);
|
||||
|
||||
expect(":iframe input").toBeFocused();
|
||||
expect(getActiveElement()).toBe(input);
|
||||
});
|
||||
|
||||
test("getActiveElement: shadow dom", async () => {
|
||||
await mountForTest(/* xml */ `<hoot-test-shadow-root />`);
|
||||
|
||||
expect("hoot-test-shadow-root:shadow input").not.toBeFocused();
|
||||
|
||||
const input = $1("hoot-test-shadow-root:shadow input");
|
||||
await click(input);
|
||||
|
||||
expect("hoot-test-shadow-root:shadow input").toBeFocused();
|
||||
expect(getActiveElement()).toBe(input);
|
||||
});
|
||||
|
||||
test("getFocusableElements", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input class="input" />
|
||||
<div class="div" tabindex="0">aaa</div>
|
||||
<span class="span" tabindex="-1">aaa</span>
|
||||
<button class="disabled-button" disabled="disabled">Disabled button</button>
|
||||
<button class="button" tabindex="1">Button</button>
|
||||
`);
|
||||
|
||||
expect(getFocusableElements().map((el) => el.className)).toEqual([
|
||||
"button",
|
||||
"span",
|
||||
"input",
|
||||
"div",
|
||||
]);
|
||||
|
||||
expect(getFocusableElements({ tabbable: true }).map((el) => el.className)).toEqual([
|
||||
"button",
|
||||
"input",
|
||||
"div",
|
||||
]);
|
||||
});
|
||||
|
||||
test("getNextFocusableElement", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input class="input" />
|
||||
<div class="div" tabindex="0">aaa</div>
|
||||
<button class="disabled-button" disabled="disabled">Disabled button</button>
|
||||
<button class="button" tabindex="1">Button</button>
|
||||
`);
|
||||
|
||||
await click(".input");
|
||||
|
||||
expect(getNextFocusableElement()).toHaveClass("div");
|
||||
});
|
||||
|
||||
test("getParentFrame", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="root"></div>
|
||||
`);
|
||||
|
||||
const parent = await makeIframe(document, $1(".root"));
|
||||
const child = await makeIframe(parent.contentDocument);
|
||||
|
||||
const content = child.contentDocument.createElement("div");
|
||||
child.contentDocument.body.appendChild(content);
|
||||
|
||||
expect(getParentFrame(content)).toBe(child);
|
||||
expect(getParentFrame(child)).toBe(parent);
|
||||
expect(getParentFrame(parent)).toBe(null);
|
||||
});
|
||||
|
||||
test("getPreviousFocusableElement", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input class="input" />
|
||||
<div class="div" tabindex="0">aaa</div>
|
||||
<button class="disabled-button" disabled="disabled">Disabled button</button>
|
||||
<button class="button" tabindex="1">Button</button>
|
||||
`);
|
||||
|
||||
await click(".input");
|
||||
|
||||
expect(getPreviousFocusableElement()).toHaveClass("button");
|
||||
});
|
||||
|
||||
test("isEditable", async () => {
|
||||
expect(isEditable(document.createElement("input"))).toBe(true);
|
||||
expect(isEditable(document.createElement("textarea"))).toBe(true);
|
||||
expect(isEditable(document.createElement("select"))).toBe(false);
|
||||
|
||||
const editableDiv = document.createElement("div");
|
||||
expect(isEditable(editableDiv)).toBe(false);
|
||||
editableDiv.setAttribute("contenteditable", "true");
|
||||
expect(isEditable(editableDiv)).toBe(false); // not supported
|
||||
});
|
||||
|
||||
test("isFocusable", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect(isFocusable("input:first")).toBe(true);
|
||||
expect(isFocusable("li:first")).toBe(false);
|
||||
});
|
||||
|
||||
test("isInDom", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect(isInDOM(document)).toBe(true);
|
||||
expect(isInDOM(document.body)).toBe(true);
|
||||
expect(isInDOM(document.head)).toBe(true);
|
||||
expect(isInDOM(document.documentElement)).toBe(true);
|
||||
|
||||
const form = $1`form`;
|
||||
expect(isInDOM(form)).toBe(true);
|
||||
|
||||
form.remove();
|
||||
|
||||
expect(isInDOM(form)).toBe(false);
|
||||
|
||||
const paragraph = $1`:iframe p`;
|
||||
expect(isInDOM(paragraph)).toBe(true);
|
||||
|
||||
paragraph.remove();
|
||||
|
||||
expect(isInDOM(paragraph)).toBe(false);
|
||||
});
|
||||
|
||||
test("isDisplayed", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect(isDisplayed(document)).toBe(true);
|
||||
expect(isDisplayed(document.body)).toBe(true);
|
||||
expect(isDisplayed(document.head)).toBe(true);
|
||||
expect(isDisplayed(document.documentElement)).toBe(true);
|
||||
expect(isDisplayed("form")).toBe(true);
|
||||
|
||||
expect(isDisplayed(".hidden")).toBe(false);
|
||||
expect(isDisplayed("body")).toBe(false); // not available from fixture
|
||||
});
|
||||
|
||||
test("isVisible", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE + "<hoot-test-shadow-root />");
|
||||
|
||||
expect(isVisible(document)).toBe(true);
|
||||
expect(isVisible(document.body)).toBe(true);
|
||||
expect(isVisible(document.head)).toBe(false);
|
||||
expect(isVisible(document.documentElement)).toBe(true);
|
||||
expect(isVisible("form")).toBe(true);
|
||||
expect(isVisible("hoot-test-shadow-root:shadow input")).toBe(true);
|
||||
|
||||
expect(isVisible(".hidden")).toBe(false);
|
||||
expect(isVisible("body")).toBe(false); // not available from fixture
|
||||
});
|
||||
|
||||
test("matchMedia", async () => {
|
||||
// Invalid syntax
|
||||
expect(matchMedia("aaaa").matches).toBe(false);
|
||||
expect(matchMedia("display-mode: browser").matches).toBe(false);
|
||||
|
||||
// Does not exist
|
||||
expect(matchMedia("(a)").matches).toBe(false);
|
||||
expect(matchMedia("(a: b)").matches).toBe(false);
|
||||
|
||||
// Defaults
|
||||
expect(matchMedia("(display-mode:browser)").matches).toBe(true);
|
||||
expect(matchMedia("(display-mode: standalone)").matches).toBe(false);
|
||||
expect(matchMedia("not (display-mode: standalone)").matches).toBe(true);
|
||||
expect(matchMedia("(prefers-color-scheme :light)").matches).toBe(true);
|
||||
expect(matchMedia("(prefers-color-scheme : dark)").matches).toBe(false);
|
||||
expect(matchMedia("not (prefers-color-scheme: dark)").matches).toBe(true);
|
||||
expect(matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true);
|
||||
expect(matchMedia("(prefers-reduced-motion: no-preference)").matches).toBe(false);
|
||||
|
||||
// Touch feature
|
||||
expect(window.matchMedia("(pointer: coarse)").matches).toBe(false);
|
||||
expect(window.ontouchstart).toBe(undefined);
|
||||
|
||||
mockTouch(true);
|
||||
|
||||
expect(window.matchMedia("(pointer: coarse)").matches).toBe(true);
|
||||
expect(window.ontouchstart).not.toBe(undefined);
|
||||
});
|
||||
|
||||
test("waitFor: already in fixture", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
waitFor(".title").then((el) => {
|
||||
expect.step(el.className);
|
||||
return el;
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["title"]);
|
||||
});
|
||||
|
||||
test("waitFor: rejects", async () => {
|
||||
await expect(waitFor("never", { timeout: 1 })).rejects.toThrow(
|
||||
`expected at least 1 element after 1ms and found 0 elements: 0 matching "never"`
|
||||
);
|
||||
});
|
||||
|
||||
test("waitFor: add new element", async () => {
|
||||
const el1 = document.createElement("div");
|
||||
el1.className = "new-element";
|
||||
|
||||
const el2 = document.createElement("div");
|
||||
el2.className = "new-element";
|
||||
|
||||
const promise = waitFor(".new-element").then((el) => {
|
||||
expect.step(el.className);
|
||||
return el;
|
||||
});
|
||||
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
getFixture().append(el1, el2);
|
||||
|
||||
await expect(promise).resolves.toBe(el1);
|
||||
|
||||
expect.verifySteps(["new-element"]);
|
||||
});
|
||||
|
||||
test("waitForNone: DOM empty", async () => {
|
||||
waitForNone(".title").then(() => expect.step("none"));
|
||||
expect.verifySteps([]);
|
||||
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["none"]);
|
||||
});
|
||||
|
||||
test("waitForNone: rejects", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
await expect(waitForNone(".title", { timeout: 1 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("waitForNone: delete elements", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
waitForNone(".title").then(() => expect.step("none"));
|
||||
expect(".title").toHaveCount(3);
|
||||
|
||||
for (const title of $$(".title")) {
|
||||
expect.verifySteps([]);
|
||||
|
||||
title.remove();
|
||||
|
||||
await animationFrame();
|
||||
}
|
||||
|
||||
expect.verifySteps(["none"]);
|
||||
});
|
||||
|
||||
describe("query", () => {
|
||||
test("native selectors", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect($$()).toEqual([]);
|
||||
for (const selector of [
|
||||
"main",
|
||||
`.${"title"}`,
|
||||
`${"ul"}${" "}${`${"li"}`}`,
|
||||
".title",
|
||||
"ul > li",
|
||||
"form:has(.title:not(.haha)):not(.huhu) input[name='email']:enabled",
|
||||
"[colspan='1']",
|
||||
]) {
|
||||
expectSelector(selector).toEqualNodes(selector);
|
||||
}
|
||||
});
|
||||
|
||||
test("custom pseudo-classes", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
// :first, :last, :only & :eq
|
||||
expectSelector(".title:first").toEqualNodes(".title", { index: 0 });
|
||||
expectSelector(".title:last").toEqualNodes(".title", { index: -1 });
|
||||
expectSelector(".title:eq(-1)").toEqualNodes(".title", { index: -1 });
|
||||
expectSelector("main:only").toEqualNodes("main");
|
||||
expectSelector(".title:only").toEqualNodes("");
|
||||
expectSelector(".title:eq(1)").toEqualNodes(".title", { index: 1 });
|
||||
expectSelector(".title:eq('1')").toEqualNodes(".title", { index: 1 });
|
||||
expectSelector('.title:eq("1")').toEqualNodes(".title", { index: 1 });
|
||||
|
||||
// :contains (text)
|
||||
expectSelector("main > .text:contains(ipsum)").toEqualNodes("p");
|
||||
expectSelector(".text:contains(/\\bL\\w+\\b\\sipsum/)").toEqualNodes("p");
|
||||
expectSelector(".text:contains(item)").toEqualNodes("li");
|
||||
|
||||
// :contains (value)
|
||||
expectSelector("input:value(john)").toEqualNodes("[name=name],[name=email]");
|
||||
expectSelector("input:value(john doe)").toEqualNodes("[name=name]");
|
||||
expectSelector("input:value('John Doe (JOD)')").toEqualNodes("[name=name]");
|
||||
expectSelector(`input:value("(JOD)")`).toEqualNodes("[name=name]");
|
||||
expectSelector("input:value(johndoe)").toEqualNodes("[name=email]");
|
||||
expectSelector("select:value(mr)").toEqualNodes("[name=title]");
|
||||
expectSelector("select:value(unknown value)").toEqualNodes("");
|
||||
|
||||
// :selected
|
||||
expectSelector("option:selected").toEqualNodes(
|
||||
"select[name=title] option[value=mr],select[name=job] option:first-child"
|
||||
);
|
||||
|
||||
// :iframe
|
||||
expectSelector("iframe p:contains(iframe text content)").toEqualNodes("");
|
||||
expectSelector("div:iframe p").toEqualNodes("");
|
||||
expectSelector(":iframe p:contains(iframe text content)").toEqualNodes("p", {
|
||||
root: "iframe",
|
||||
});
|
||||
});
|
||||
|
||||
test("advanced use cases", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
// Comma-separated selectors
|
||||
expectSelector(":has(form:contains('Form title')),p:contains(ipsum)").toEqualNodes(
|
||||
"p,main"
|
||||
);
|
||||
|
||||
// :has & :not combinations with custom pseudo-classes
|
||||
expectSelector(`select:has(:contains(Employer))`).toEqualNodes("select[name=job]");
|
||||
expectSelector(`select:not(:has(:contains(Employer)))`).toEqualNodes(
|
||||
"select[name=title]"
|
||||
);
|
||||
expectSelector(
|
||||
`main:first-of-type:not(:has(:contains(This text does not exist))):contains('List header') > form:has([name="name"]):contains("Form title"):nth-child(6).overflow-auto:visible select[name=job] option:selected`
|
||||
).toEqualNodes("select[name=job] option:first-child");
|
||||
|
||||
// :contains & commas
|
||||
expectSelector(`p:contains(velit,)`).toEqualNodes("p");
|
||||
expectSelector(`p:contains('velit,')`).toEqualNodes("p");
|
||||
expectSelector(`p:contains(", tristique")`).toEqualNodes("p");
|
||||
expectSelector(`p:contains(/\\bvelit,/)`).toEqualNodes("p");
|
||||
});
|
||||
|
||||
// Whatever, at this point I'm just copying failing selectors and creating
|
||||
// fake contexts accordingly as I'm fixing them.
|
||||
|
||||
test("comma-separated long selector: no match", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="o_we_customize_panel">
|
||||
<we-customizeblock-option class="snippet-option-ImageTools">
|
||||
<div class="o_we_so_color_palette o_we_widget_opened">
|
||||
idk
|
||||
</div>
|
||||
<we-select data-name="shape_img_opt">
|
||||
<we-toggler></we-toggler>
|
||||
</we-select>
|
||||
</we-customizeblock-option>
|
||||
</div>
|
||||
`);
|
||||
expectSelector(
|
||||
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
|
||||
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
|
||||
).toEqualNodes("");
|
||||
});
|
||||
|
||||
test("comma-separated long selector: match first", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="o_we_customize_panel">
|
||||
<we-customizeblock-option class="snippet-option-ImageTools">
|
||||
<we-select data-name="shape_img_opt">
|
||||
<we-toggler></we-toggler>
|
||||
</we-select>
|
||||
</we-customizeblock-option>
|
||||
</div>
|
||||
`);
|
||||
expectSelector(
|
||||
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
|
||||
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
|
||||
).toEqualNodes("we-toggler");
|
||||
});
|
||||
|
||||
test("comma-separated long selector: match second", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="o_we_customize_panel">
|
||||
<we-customizeblock-option class="snippet-option-ImageTools">
|
||||
<div title='we-select[data-name="shape_img_opt"] we-toggler'>
|
||||
idk
|
||||
</div>
|
||||
</we-customizeblock-option>
|
||||
</div>
|
||||
`);
|
||||
expectSelector(
|
||||
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] we-select[data-name="shape_img_opt"] we-toggler`,
|
||||
`.o_we_customize_panel:not(:has(.o_we_so_color_palette.o_we_widget_opened)) we-customizeblock-option[class='snippet-option-ImageTools'] [title='we-select[data-name="shape_img_opt"] we-toggler']`
|
||||
).toEqualNodes("div[title]");
|
||||
});
|
||||
|
||||
test("comma-separated :contains", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="o_menu_sections">
|
||||
<a class="dropdown-item">Products</a>
|
||||
</div>
|
||||
<nav class="o_burger_menu_content">
|
||||
<ul>
|
||||
<li data-menu-xmlid="sale.menu_product_template_action">
|
||||
Products
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
`);
|
||||
expectSelector(
|
||||
`.o_menu_sections .dropdown-item:contains('Products'), nav.o_burger_menu_content li[data-menu-xmlid='sale.menu_product_template_action']`
|
||||
).toEqualNodes(".dropdown-item,li");
|
||||
});
|
||||
|
||||
test(":contains with line return", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<span>
|
||||
<div>Matrix (PAV11, PAV22, PAV31)</div>
|
||||
<div>PA4: PAV41</div>
|
||||
</span>
|
||||
`);
|
||||
expectSelector(
|
||||
`span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")`
|
||||
).toEqualNodes("span");
|
||||
});
|
||||
|
||||
test(":has(...):first", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<a href="/web/event/1"></a>
|
||||
<a target="" href="/web/event/2">
|
||||
<span>Conference for Architects TEST</span>
|
||||
</a>
|
||||
`);
|
||||
|
||||
expectSelector(
|
||||
`a[href*="/event"]:contains("Conference for Architects TEST")`
|
||||
).toEqualNodes("[target]");
|
||||
expectSelector(
|
||||
`a[href*="/event"]:contains("Conference for Architects TEST"):first`
|
||||
).toEqualNodes("[target]");
|
||||
});
|
||||
|
||||
test(":eq", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<ul>
|
||||
<li>a</li>
|
||||
<li>b</li>
|
||||
<li>c</li>
|
||||
</ul>
|
||||
`);
|
||||
|
||||
expectSelector(`li:first:contains(a)`).toEqualNodes("li:nth-child(1)");
|
||||
expectSelector(`li:contains(a):first`).toEqualNodes("li:nth-child(1)");
|
||||
expectSelector(`li:first:contains(b)`).toEqualNodes("");
|
||||
expectSelector(`li:contains(b):first`).toEqualNodes("li:nth-child(2)");
|
||||
});
|
||||
|
||||
test(":empty", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<input class="empty" />
|
||||
<input class="value" value="value" />
|
||||
`);
|
||||
|
||||
expectSelector(`input:empty`).toEqualNodes(".empty");
|
||||
expectSelector(`input:not(:empty)`).toEqualNodes(".value");
|
||||
});
|
||||
|
||||
test("regular :contains", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="website_links_click_chart">
|
||||
<div class="title">
|
||||
0 clicks
|
||||
</div>
|
||||
<div class="title">
|
||||
1 clicks
|
||||
</div>
|
||||
<div class="title">
|
||||
2 clicks
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expectSelector(`.website_links_click_chart .title:contains("1 clicks")`).toEqualNodes(
|
||||
".title:nth-child(2)"
|
||||
);
|
||||
});
|
||||
|
||||
test("other regular :contains", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<ul
|
||||
class="o-autocomplete--dropdown-menu ui-widget show dropdown-menu ui-autocomplete"
|
||||
style="position: fixed; top: 283.75px; left: 168.938px"
|
||||
>
|
||||
<li class="o-autocomplete--dropdown-item ui-menu-item block">
|
||||
<a
|
||||
href="#"
|
||||
class="dropdown-item ui-menu-item-wrapper truncate ui-state-active"
|
||||
>Account Tax Group Partner</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
class="o-autocomplete--dropdown-item ui-menu-item block o_m2o_dropdown_option o_m2o_dropdown_option_search_more"
|
||||
>
|
||||
<a href="#" class="dropdown-item ui-menu-item-wrapper truncate"
|
||||
>Search More...</a
|
||||
>
|
||||
</li>
|
||||
<li
|
||||
class="o-autocomplete--dropdown-item ui-menu-item block o_m2o_dropdown_option o_m2o_dropdown_option_create_edit"
|
||||
>
|
||||
<a href="#" class="dropdown-item ui-menu-item-wrapper truncate"
|
||||
>Create and edit...</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
`);
|
||||
|
||||
expectSelector(`.ui-menu-item a:contains("Account Tax Group Partner")`).toEqualNodes(
|
||||
"ul li:first-child a"
|
||||
);
|
||||
});
|
||||
|
||||
test(":iframe", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<iframe srcdoc="<p>Iframe text content</p>"></iframe>
|
||||
`);
|
||||
|
||||
expectSelector(`:iframe html`).toEqualNodes("html", { root: "iframe" });
|
||||
expectSelector(`:iframe body`).toEqualNodes("body", { root: "iframe" });
|
||||
expectSelector(`:iframe head`).toEqualNodes("head", { root: "iframe" });
|
||||
});
|
||||
|
||||
test(":contains with brackets", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="o_content">
|
||||
<div class="o_field_widget" name="messages">
|
||||
<table class="o_list_view table table-sm table-hover table-striped o_list_view_ungrouped">
|
||||
<tbody>
|
||||
<tr class="o_data_row">
|
||||
<td class="o_list_record_selector">
|
||||
bbb
|
||||
</td>
|
||||
<td class="o_data_cell o_required_modifier">
|
||||
<span>
|
||||
[test_trigger] Mitchell Admin
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expectSelector(
|
||||
`.o_content:has(.o_field_widget[name=messages]):has(td:contains(/^bbb$/)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin$/))`
|
||||
).toEqualNodes(".o_content");
|
||||
});
|
||||
|
||||
test(":eq in the middle of a selector", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<ul>
|
||||
<li class="oe_overlay o_draggable"></li>
|
||||
<li class="oe_overlay o_draggable"></li>
|
||||
<li class="oe_overlay o_draggable oe_active"></li>
|
||||
<li class="oe_overlay o_draggable"></li>
|
||||
</ul>
|
||||
`);
|
||||
expectSelector(`.oe_overlay.o_draggable:eq(2).oe_active`).toEqualNodes(
|
||||
"li:nth-child(3)"
|
||||
);
|
||||
});
|
||||
|
||||
test("combinator +", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<form class="js_attributes">
|
||||
<input type="checkbox" />
|
||||
<label>Steel - Test</label>
|
||||
</form>
|
||||
`);
|
||||
|
||||
expectSelector(
|
||||
`form.js_attributes input:not(:checked) + label:contains(Steel - Test)`
|
||||
).toEqualNodes("label");
|
||||
});
|
||||
|
||||
test("multiple + combinators", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="s_cover">
|
||||
<span class="o_text_highlight">
|
||||
<span class="o_text_highlight_item">
|
||||
<span class="o_text_highlight_path_underline" />
|
||||
</span>
|
||||
<br />
|
||||
<span class="o_text_highlight_item">
|
||||
<span class="o_text_highlight_path_underline" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
expectSelector(`
|
||||
.s_cover span.o_text_highlight:has(
|
||||
.o_text_highlight_item
|
||||
+ br
|
||||
+ .o_text_highlight_item
|
||||
)
|
||||
`).toEqualNodes(".o_text_highlight");
|
||||
});
|
||||
|
||||
test(":last", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="o_field_widget" name="messages">
|
||||
<table class="o_list_view table table-sm table-hover table-striped o_list_view_ungrouped">
|
||||
<tbody>
|
||||
<tr class="o_data_row">
|
||||
<td class="o_list_record_remove">
|
||||
<button class="btn">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="o_data_row">
|
||||
<td class="o_list_record_remove">
|
||||
<button class="btn">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`);
|
||||
expectSelector(
|
||||
`.o_field_widget[name=messages] .o_data_row td.o_list_record_remove button:visible:last`
|
||||
).toEqualNodes(".o_data_row:last-child button");
|
||||
});
|
||||
|
||||
test("select :contains & :value", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<select class="configurator_select form-select form-select-lg">
|
||||
<option value="217" selected="">Metal</option>
|
||||
<option value="218">Wood</option>
|
||||
</select>
|
||||
`);
|
||||
expectSelector(`.configurator_select:has(option:contains(Metal))`).toEqualNodes(
|
||||
"select"
|
||||
);
|
||||
expectSelector(`.configurator_select:has(option:value(217))`).toEqualNodes("select");
|
||||
expectSelector(`.configurator_select:has(option:value(218))`).toEqualNodes("select");
|
||||
expectSelector(`.configurator_select:value(217)`).toEqualNodes("select");
|
||||
expectSelector(`.configurator_select:value(218)`).toEqualNodes("");
|
||||
expectSelector(`.configurator_select:value(Metal)`).toEqualNodes("");
|
||||
});
|
||||
|
||||
test("invalid selectors", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect(() => $$`[colspan=1]`).toThrow(); // missing quotes
|
||||
expect(() => $$`[href=/]`).toThrow(); // missing quotes
|
||||
expect(
|
||||
() =>
|
||||
$$`_o_wblog_posts_loop:has(span:has(i.fa-calendar-o):has(a[href="/blog?search=a"])):has(span:has(i.fa-search):has(a[href^="/blog?date_begin"]))`
|
||||
).toThrow(); // nested :has statements
|
||||
});
|
||||
|
||||
test("queryAllRects", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div style="width: 40px; height: 60px;" />
|
||||
<div style="width: 20px; height: 10px;" />
|
||||
`);
|
||||
|
||||
expect(queryAllRects("div")).toEqual($$("div").map((el) => el.getBoundingClientRect()));
|
||||
expect(queryAllRects("div:first")).toEqual([new DOMRect({ width: 40, height: 60 })]);
|
||||
expect(queryAllRects("div:last")).toEqual([new DOMRect({ width: 20, height: 10 })]);
|
||||
});
|
||||
|
||||
test("queryAllTexts", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect(queryAllTexts(".title")).toEqual(["Title", "List header", "Form title"]);
|
||||
expect(queryAllTexts("footer")).toEqual(["FooterBack to top"]);
|
||||
});
|
||||
|
||||
test("queryOne", async () => {
|
||||
await mountForTest(FULL_HTML_TEMPLATE);
|
||||
|
||||
expect($1(".title:first")).toBe(getFixture().querySelector("header .title"));
|
||||
|
||||
expect(() => $1(".title")).toThrow();
|
||||
expect(() => $1(".title", { exact: 2 })).toThrow();
|
||||
});
|
||||
|
||||
test("queryRect", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="container">
|
||||
<div class="rect" style="width: 40px; height: 60px;" />
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(".rect").toHaveRect(".container"); // same rect as parent
|
||||
expect(".rect").toHaveRect({ width: 40, height: 60 });
|
||||
expect(queryRect(".rect")).toEqual($1(".rect").getBoundingClientRect());
|
||||
expect(queryRect(".rect")).toEqual(new DOMRect({ width: 40, height: 60 }));
|
||||
});
|
||||
|
||||
test("queryRect with trimPadding", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div style="width: 50px; height: 70px; padding: 5px; margin: 6px" />
|
||||
`);
|
||||
|
||||
expect("div").toHaveRect({ width: 50, height: 70 }); // with padding
|
||||
expect("div").toHaveRect({ width: 40, height: 60 }, { trimPadding: true });
|
||||
});
|
||||
|
||||
test("not found messages", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="tralalero">
|
||||
Tralala
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(() => $("invalid:pseudo-selector")).toThrow();
|
||||
// Perform in-between valid query with custom pseudo selectors
|
||||
expect($`.modal:visible:contains('Tung Tung Tung Sahur')`).toBe(null);
|
||||
|
||||
// queryOne error messages
|
||||
expect(() => $1()).toThrow(`found 0 elements instead of 1`);
|
||||
expect(() => $$([], { exact: 18 })).toThrow(`found 0 elements instead of 18`);
|
||||
expect(() => $1("")).toThrow(`found 0 elements instead of 1: 0 matching ""`);
|
||||
expect(() => $$(".tralalero", { exact: -20 })).toThrow(
|
||||
`found 1 element instead of -20: 1 matching ".tralalero"`
|
||||
);
|
||||
expect(() => $1`.tralalero:contains(Tralala):visible:scrollable:first`).toThrow(
|
||||
`found 0 elements instead of 1: 0 matching ".tralalero:contains(Tralala):visible:scrollable:first" (1 element with text "Tralala" > 1 visible element > 0 scrollable elements)`
|
||||
);
|
||||
expect(() =>
|
||||
$1(".tralalero", {
|
||||
contains: "Tralala",
|
||||
visible: true,
|
||||
scrollable: true,
|
||||
first: true,
|
||||
})
|
||||
).toThrow(
|
||||
`found 0 elements instead of 1: 1 matching ".tralalero", including 1 element with text "Tralala", including 1 visible element, including 0 scrollable elements`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,132 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import {
|
||||
Deferred,
|
||||
advanceTime,
|
||||
animationFrame,
|
||||
microTick,
|
||||
runAllTimers,
|
||||
tick,
|
||||
waitUntil,
|
||||
} from "@odoo/hoot-dom";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
// timeout of 1 second to ensure all timeouts are actually mocked
|
||||
describe.timeout(1_000);
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("advanceTime", async () => {
|
||||
expect.assertions(8);
|
||||
|
||||
await advanceTime(5_000);
|
||||
|
||||
const timeoutId = window.setTimeout(() => expect.step("timeout"), "2000");
|
||||
const intervalId = window.setInterval(() => expect.step("interval"), 3_000);
|
||||
const animationHandle = window.requestAnimationFrame((delta) => {
|
||||
expect(delta).toBeGreaterThan(5_000);
|
||||
expect.step("animation");
|
||||
});
|
||||
|
||||
expect(timeoutId).toBeGreaterThan(0);
|
||||
expect(intervalId).toBeGreaterThan(0);
|
||||
expect(animationHandle).toBeGreaterThan(0);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await advanceTime(10_000); // 10 seconds
|
||||
|
||||
expect.verifySteps(["animation", "timeout", "interval", "interval", "interval"]);
|
||||
|
||||
await advanceTime(10_000);
|
||||
|
||||
expect.verifySteps(["interval", "interval", "interval"]);
|
||||
|
||||
window.clearInterval(intervalId);
|
||||
|
||||
await advanceTime(10_000);
|
||||
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
|
||||
test("Deferred", async () => {
|
||||
const def = new Deferred();
|
||||
|
||||
def.then(() => expect.step("resolved"));
|
||||
|
||||
expect.step("before");
|
||||
|
||||
def.resolve(14);
|
||||
|
||||
expect.step("after");
|
||||
|
||||
await expect(def).resolves.toBe(14);
|
||||
|
||||
expect.verifySteps(["before", "after", "resolved"]);
|
||||
});
|
||||
|
||||
test("tick", async () => {
|
||||
let count = 0;
|
||||
|
||||
const nextTickPromise = tick().then(() => ++count);
|
||||
|
||||
expect(count).toBe(0);
|
||||
|
||||
await expect(nextTickPromise).resolves.toBe(1);
|
||||
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("runAllTimers", async () => {
|
||||
expect.assertions(4);
|
||||
|
||||
window.setTimeout(() => expect.step("timeout"), 1e6);
|
||||
window.requestAnimationFrame((delta) => {
|
||||
expect(delta).toBeGreaterThan(1);
|
||||
expect.step("animation");
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
const ms = await runAllTimers();
|
||||
|
||||
expect(ms).toBeCloseTo(1e6, { margin: 10 });
|
||||
expect.verifySteps(["animation", "timeout"]);
|
||||
});
|
||||
|
||||
test("waitUntil: already true", async () => {
|
||||
const promise = waitUntil(() => "some value").then((value) => {
|
||||
expect.step("resolved");
|
||||
return value;
|
||||
});
|
||||
|
||||
expect.verifySteps([]);
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
|
||||
await microTick();
|
||||
|
||||
expect.verifySteps(["resolved"]);
|
||||
await expect(promise).resolves.toBe("some value");
|
||||
});
|
||||
|
||||
test("waitUntil: rejects", async () => {
|
||||
await expect(waitUntil(() => false, { timeout: 0 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("waitUntil: lazy", async () => {
|
||||
let returnValue = "";
|
||||
const promise = waitUntil(() => returnValue).then((v) => expect.step(v));
|
||||
|
||||
expect.verifySteps([]);
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
|
||||
await animationFrame();
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
returnValue = "test";
|
||||
|
||||
await animationFrame();
|
||||
|
||||
expect.verifySteps(["test"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
const _owl = window.owl;
|
||||
delete window.owl;
|
||||
|
||||
export const App = _owl.App;
|
||||
export const Component = _owl.Component;
|
||||
export const EventBus = _owl.EventBus;
|
||||
export const OwlError = _owl.OwlError;
|
||||
export const __info__ = _owl.__info__;
|
||||
export const blockDom = _owl.blockDom;
|
||||
export const loadFile = _owl.loadFile;
|
||||
export const markRaw = _owl.markRaw;
|
||||
export const markup = _owl.markup;
|
||||
export const mount = _owl.mount;
|
||||
export const onError = _owl.onError;
|
||||
export const onMounted = _owl.onMounted;
|
||||
export const onPatched = _owl.onPatched;
|
||||
export const onRendered = _owl.onRendered;
|
||||
export const onWillDestroy = _owl.onWillDestroy;
|
||||
export const onWillPatch = _owl.onWillPatch;
|
||||
export const onWillRender = _owl.onWillRender;
|
||||
export const onWillStart = _owl.onWillStart;
|
||||
export const onWillUnmount = _owl.onWillUnmount;
|
||||
export const onWillUpdateProps = _owl.onWillUpdateProps;
|
||||
export const reactive = _owl.reactive;
|
||||
export const status = _owl.status;
|
||||
export const toRaw = _owl.toRaw;
|
||||
export const useChildSubEnv = _owl.useChildSubEnv;
|
||||
export const useComponent = _owl.useComponent;
|
||||
export const useEffect = _owl.useEffect;
|
||||
export const useEnv = _owl.useEnv;
|
||||
export const useExternalListener = _owl.useExternalListener;
|
||||
export const useRef = _owl.useRef;
|
||||
export const useState = _owl.useState;
|
||||
export const useSubEnv = _owl.useSubEnv;
|
||||
export const validate = _owl.validate;
|
||||
export const validateType = _owl.validateType;
|
||||
export const whenReady = _owl.whenReady;
|
||||
export const xml = _owl.xml;
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { queryOne } from "@odoo/hoot-dom";
|
||||
import { isInstanceOf, isIterable } from "@web/../lib/hoot-dom/hoot_dom_utils";
|
||||
import {
|
||||
deepEqual,
|
||||
formatHumanReadable,
|
||||
formatTechnical,
|
||||
generateHash,
|
||||
levenshtein,
|
||||
lookup,
|
||||
match,
|
||||
parseQuery,
|
||||
title,
|
||||
toExplicitString,
|
||||
} from "../hoot_utils";
|
||||
import { mountForTest, parseUrl } from "./local_helpers";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("deepEqual", () => {
|
||||
const recursive = {};
|
||||
recursive.self = recursive;
|
||||
|
||||
const TRUTHY_CASES = [
|
||||
[true, true],
|
||||
[false, false],
|
||||
[null, null],
|
||||
[recursive, recursive],
|
||||
[new Date(0), new Date(0)],
|
||||
[
|
||||
{ b: 2, a: 1 },
|
||||
{ a: 1, b: 2 },
|
||||
],
|
||||
[{ o: { a: [{ b: 1 }] } }, { o: { a: [{ b: 1 }] } }],
|
||||
[Symbol.for("a"), Symbol.for("a")],
|
||||
[document.createElement("div"), document.createElement("div")],
|
||||
[
|
||||
[1, 2, 3],
|
||||
[1, 2, 3],
|
||||
],
|
||||
];
|
||||
const FALSY_CASES = [
|
||||
[true, false],
|
||||
[null, undefined],
|
||||
[recursive, { ...recursive, a: 1 }],
|
||||
[
|
||||
[1, 2, 3],
|
||||
[3, 1, 2],
|
||||
],
|
||||
[new Date(0), new Date(1_000)],
|
||||
[{ a: new Date(0) }, { a: 0 }],
|
||||
[document.createElement("a"), document.createElement("div")],
|
||||
[{ [Symbol("a")]: 1 }, { [Symbol("a")]: 1 }],
|
||||
];
|
||||
const TRUTHY_IF_UNORDERED_CASES = [
|
||||
[
|
||||
[1, "2", 3],
|
||||
["2", 3, 1],
|
||||
],
|
||||
[
|
||||
[1, { a: [4, 2] }, "3"],
|
||||
[{ a: [2, 4] }, "3", 1],
|
||||
],
|
||||
[
|
||||
new Set([
|
||||
"abc",
|
||||
new Map([
|
||||
["b", 2],
|
||||
["a", 1],
|
||||
]),
|
||||
]),
|
||||
new Set([
|
||||
new Map([
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
]),
|
||||
"abc",
|
||||
]),
|
||||
],
|
||||
];
|
||||
|
||||
expect.assertions(
|
||||
TRUTHY_CASES.length + FALSY_CASES.length + TRUTHY_IF_UNORDERED_CASES.length * 2
|
||||
);
|
||||
|
||||
for (const [a, b] of TRUTHY_CASES) {
|
||||
expect(deepEqual(a, b)).toBe(true, {
|
||||
message: [a, `==`, b],
|
||||
});
|
||||
}
|
||||
for (const [a, b] of FALSY_CASES) {
|
||||
expect(deepEqual(a, b)).toBe(false, {
|
||||
message: [a, `!=`, b],
|
||||
});
|
||||
}
|
||||
for (const [a, b] of TRUTHY_IF_UNORDERED_CASES) {
|
||||
expect(deepEqual(a, b)).toBe(false, {
|
||||
message: [a, `!=`, b],
|
||||
});
|
||||
expect(deepEqual(a, b, { ignoreOrder: true })).toBe(true, {
|
||||
message: [a, `==`, b, `(unordered))`],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("formatHumanReadable", () => {
|
||||
// Strings
|
||||
expect(formatHumanReadable("abc")).toBe(`"abc"`);
|
||||
expect(formatHumanReadable("a".repeat(300))).toBe(`"${"a".repeat(80)}…"`);
|
||||
expect(formatHumanReadable(`with "double quotes"`)).toBe(`'with "double quotes"'`);
|
||||
expect(formatHumanReadable(`with "double quotes" and 'single quote'`)).toBe(
|
||||
`\`with "double quotes" and 'single quote'\``
|
||||
);
|
||||
// Numbers
|
||||
expect(formatHumanReadable(1)).toBe(`1`);
|
||||
// Other primitives
|
||||
expect(formatHumanReadable(true)).toBe(`true`);
|
||||
expect(formatHumanReadable(null)).toBe(`null`);
|
||||
// Functions & classes
|
||||
expect(formatHumanReadable(async function oui() {})).toBe(`async function oui() { … }`);
|
||||
expect(formatHumanReadable(class Oui {})).toBe(`class Oui { … }`);
|
||||
// Iterators
|
||||
expect(formatHumanReadable([1, 2, 3])).toBe(`[1, 2, 3]`);
|
||||
expect(formatHumanReadable(new Set([1, 2, 3]))).toBe(`Set [1, 2, 3]`);
|
||||
expect(
|
||||
formatHumanReadable(
|
||||
new Map([
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
])
|
||||
)
|
||||
).toBe(`Map [["a", 1], ["b", 2]]`);
|
||||
// Objects
|
||||
expect(formatHumanReadable(/ab(c)d/gi)).toBe(`/ab(c)d/gi`);
|
||||
expect(formatHumanReadable(new Date("1997-01-09T12:30:00.000Z"))).toBe(
|
||||
`1997-01-09T12:30:00.000Z`
|
||||
);
|
||||
expect(formatHumanReadable({})).toBe(`{ }`);
|
||||
expect(formatHumanReadable({ a: { b: 1 } })).toBe(`{ a: { b: 1 } }`);
|
||||
expect(
|
||||
formatHumanReadable(
|
||||
new Proxy(
|
||||
{
|
||||
allowed: true,
|
||||
get forbidden() {
|
||||
throw new Error("Cannot access!");
|
||||
},
|
||||
},
|
||||
{}
|
||||
)
|
||||
)
|
||||
).toBe(`{ allowed: true }`);
|
||||
expect(formatHumanReadable(window)).toBe(`Window { }`);
|
||||
// Nodes
|
||||
expect(formatHumanReadable(document.createElement("div"))).toBe("<div>");
|
||||
expect(formatHumanReadable(document.createTextNode("some text"))).toBe("#text");
|
||||
expect(formatHumanReadable(document)).toBe("#document");
|
||||
});
|
||||
|
||||
test("formatTechnical", () => {
|
||||
expect(
|
||||
formatTechnical({
|
||||
b: 2,
|
||||
[Symbol("s")]: "value",
|
||||
a: true,
|
||||
})
|
||||
).toBe(
|
||||
`{
|
||||
a: true,
|
||||
b: 2,
|
||||
Symbol(s): "value",
|
||||
}`.trim()
|
||||
);
|
||||
|
||||
expect(formatTechnical(["a", "b"])).toBe(
|
||||
`[
|
||||
"a",
|
||||
"b",
|
||||
]`.trim()
|
||||
);
|
||||
|
||||
class List extends Array {}
|
||||
|
||||
expect(formatTechnical(new List("a", "b"))).toBe(
|
||||
`List [
|
||||
"a",
|
||||
"b",
|
||||
]`.trim()
|
||||
);
|
||||
|
||||
function toArguments() {
|
||||
return arguments;
|
||||
}
|
||||
|
||||
expect(formatTechnical(toArguments("a", "b"))).toBe(
|
||||
`Arguments [
|
||||
"a",
|
||||
"b",
|
||||
]`.trim()
|
||||
);
|
||||
});
|
||||
|
||||
test("generateHash", () => {
|
||||
expect(generateHash("abc")).toHaveLength(8);
|
||||
expect(generateHash("abcdef")).toHaveLength(8);
|
||||
expect(generateHash("abc")).toBe(generateHash("abc"));
|
||||
|
||||
expect(generateHash("abc")).not.toBe(generateHash("def"));
|
||||
});
|
||||
|
||||
test("isInstanceOf", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<iframe srcdoc="" />
|
||||
`);
|
||||
|
||||
expect(() => isInstanceOf()).toThrow(TypeError);
|
||||
expect(() => isInstanceOf("a")).toThrow(TypeError);
|
||||
|
||||
expect(isInstanceOf(null, null)).toBe(false);
|
||||
expect(isInstanceOf(undefined, undefined)).toBe(false);
|
||||
expect(isInstanceOf("", String)).toBe(false);
|
||||
expect(isInstanceOf(24, Number)).toBe(false);
|
||||
expect(isInstanceOf(true, Boolean)).toBe(false);
|
||||
|
||||
class List extends Array {}
|
||||
|
||||
class A {}
|
||||
class B extends A {}
|
||||
|
||||
expect(isInstanceOf([], Array)).toBe(true);
|
||||
expect(isInstanceOf(new List(), Array)).toBe(true);
|
||||
expect(isInstanceOf(new B(), B)).toBe(true);
|
||||
expect(isInstanceOf(new B(), A)).toBe(true);
|
||||
expect(isInstanceOf(new Error("error"), Error)).toBe(true);
|
||||
expect(isInstanceOf(/a/, RegExp, Date)).toBe(true);
|
||||
expect(isInstanceOf(new Date(), RegExp, Date)).toBe(true);
|
||||
|
||||
const { contentDocument, contentWindow } = queryOne("iframe");
|
||||
|
||||
expect(isInstanceOf(queryOne("iframe"), HTMLIFrameElement)).toBe(true);
|
||||
expect(contentWindow instanceof Window).toBe(false);
|
||||
expect(isInstanceOf(contentWindow, Window)).toBe(true);
|
||||
expect(contentDocument.body instanceof HTMLBodyElement).toBe(false);
|
||||
expect(isInstanceOf(contentDocument.body, HTMLBodyElement)).toBe(true);
|
||||
});
|
||||
|
||||
test("isIterable", () => {
|
||||
expect(isIterable([1, 2, 3])).toBe(true);
|
||||
expect(isIterable(new Set([1, 2, 3]))).toBe(true);
|
||||
|
||||
expect(isIterable(null)).toBe(false);
|
||||
expect(isIterable("abc")).toBe(false);
|
||||
expect(isIterable({})).toBe(false);
|
||||
});
|
||||
|
||||
test("levenshtein", () => {
|
||||
expect(levenshtein("abc", "abc")).toBe(0);
|
||||
expect(levenshtein("abc", "àbc ")).toBe(2);
|
||||
expect(levenshtein("abc", "def")).toBe(3);
|
||||
expect(levenshtein("abc", "adc")).toBe(1);
|
||||
});
|
||||
|
||||
test("parseQuery & lookup", () => {
|
||||
/**
|
||||
* @param {string} query
|
||||
* @param {string[]} itemsList
|
||||
* @param {string} [property]
|
||||
*/
|
||||
const expectQuery = (query, itemsList, property = "key") => {
|
||||
const keyedItems = itemsList.map((item) => ({ [property]: item }));
|
||||
const result = lookup(parseQuery(query), keyedItems);
|
||||
return {
|
||||
/**
|
||||
* @param {string[]} expected
|
||||
*/
|
||||
toEqual: (expected) =>
|
||||
expect(result).toEqual(
|
||||
expected.map((item) => ({ [property]: item })),
|
||||
{ message: `query ${query} should match ${expected}` }
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const list = [
|
||||
"Frodo",
|
||||
"Sam",
|
||||
"Merry",
|
||||
"Pippin",
|
||||
"Frodo Sam",
|
||||
"Merry Pippin",
|
||||
"Frodo Sam Merry Pippin",
|
||||
];
|
||||
|
||||
// Error handling
|
||||
expect(() => parseQuery()).toThrow();
|
||||
expect(() => lookup()).toThrow();
|
||||
expect(() => lookup("a", [{ key: "a" }])).toThrow();
|
||||
expect(() => lookup(parseQuery("a"))).toThrow();
|
||||
|
||||
// Empty query and/or empty lists
|
||||
expectQuery("", []).toEqual([]);
|
||||
expectQuery("", ["bababa", "baaab", "cccbccb"]).toEqual(["bababa", "baaab", "cccbccb"]);
|
||||
expectQuery("aaa", []).toEqual([]);
|
||||
|
||||
// Regex
|
||||
expectQuery(`/.b$/`, ["bababa", "baaab", "cccbccB"]).toEqual(["baaab"]);
|
||||
expectQuery(`/.b$/i`, ["bababa", "baaab", "cccbccB"]).toEqual(["baaab", "cccbccB"]);
|
||||
|
||||
// Exact match
|
||||
expectQuery(`"aaa"`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab"]);
|
||||
expectQuery(`"sam"`, list).toEqual([]);
|
||||
expectQuery(`"Sam"`, list).toEqual(["Sam", "Frodo Sam", "Frodo Sam Merry Pippin"]);
|
||||
expectQuery(`"Sam" "Frodo"`, list).toEqual(["Frodo Sam", "Frodo Sam Merry Pippin"]);
|
||||
expectQuery(`"Frodo Sam"`, list).toEqual(["Frodo Sam", "Frodo Sam Merry Pippin"]);
|
||||
expectQuery(`"FrodoSam"`, list).toEqual([]);
|
||||
expectQuery(`"Frodo Sam"`, list).toEqual([]);
|
||||
expectQuery(`"Sam" -"Frodo"`, list).toEqual(["Sam"]);
|
||||
|
||||
// Partial (fuzzy) match
|
||||
expectQuery(`aaa`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab", "bababa"]);
|
||||
expectQuery(`aaa -bbb`, ["bababa", "baaab", "cccbccb"]).toEqual(["baaab"]);
|
||||
expectQuery(`-aaa`, ["bababa", "baaab", "cccbccb"]).toEqual(["cccbccb"]);
|
||||
expectQuery(`frosapip`, list).toEqual(["Frodo Sam Merry Pippin"]);
|
||||
expectQuery(`-s fro`, list).toEqual(["Frodo"]);
|
||||
expectQuery(` FR SAPI `, list).toEqual(["Frodo Sam Merry Pippin"]);
|
||||
|
||||
// Mixed queries
|
||||
expectQuery(`"Sam" fro pip`, list).toEqual(["Frodo Sam Merry Pippin"]);
|
||||
expectQuery(`fro"Sam"pip`, list).toEqual(["Frodo Sam Merry Pippin"]);
|
||||
expectQuery(`-"Frodo" s`, list).toEqual(["Sam"]);
|
||||
expectQuery(`"Merry" -p`, list).toEqual(["Merry"]);
|
||||
expectQuery(`"rry" -s`, list).toEqual(["Merry", "Merry Pippin"]);
|
||||
});
|
||||
|
||||
test("match", () => {
|
||||
expect(match("abc", /^abcd?/)).toBe(true);
|
||||
expect(match(new Error("error message"), "message")).toBe(true);
|
||||
});
|
||||
|
||||
test("title", () => {
|
||||
expect(title("abcDef")).toBe("AbcDef");
|
||||
});
|
||||
|
||||
test("toExplicitString", () => {
|
||||
expect(toExplicitString("\n")).toBe(`\\n`);
|
||||
expect(toExplicitString("\t")).toBe(`\\t`);
|
||||
|
||||
expect(toExplicitString(" \n")).toBe(` \n`);
|
||||
expect(toExplicitString("\t ")).toBe(`\t `);
|
||||
|
||||
expect(toExplicitString("Abc\u200BDef")).toBe(`Abc\\u200bDef`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HOOT internal tests</title>
|
||||
|
||||
<!-- Source map -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@odoo/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
|
||||
"@odoo/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
|
||||
"@odoo/hoot": "/web/static/lib/hoot/hoot.js",
|
||||
"@odoo/owl": "/web/static/lib/hoot/tests/hoot-owl-module.js",
|
||||
"@web/../lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
|
||||
"@web/../lib/hoot-dom/helpers/events": "/web/static/lib/hoot-dom/helpers/events.js",
|
||||
"@web/../lib/hoot-dom/helpers/time": "/web/static/lib/hoot-dom/helpers/time.js",
|
||||
"@web/../lib/hoot-dom/hoot_dom_utils": "/web/static/lib/hoot-dom/hoot_dom_utils.js",
|
||||
"/web/static/lib/hoot-dom/helpers/dom": "/web/static/lib/hoot-dom/helpers/dom.js",
|
||||
"/web/static/lib/hoot-dom/helpers/events": "/web/static/lib/hoot-dom/helpers/events.js",
|
||||
"/web/static/lib/hoot-dom/helpers/time": "/web/static/lib/hoot-dom/helpers/time.js",
|
||||
"/web/static/lib/hoot-dom/hoot_dom_utils": "/web/static/lib/hoot-dom/hoot_dom_utils.js",
|
||||
"/web/static/lib/hoot-dom/hoot-dom": "/web/static/lib/hoot-dom/hoot-dom.js",
|
||||
"/web/static/lib/hoot/core/cleanup": "/web/static/lib/hoot/core/cleanup.js",
|
||||
"/web/static/lib/hoot/core/config": "/web/static/lib/hoot/core/config.js",
|
||||
"/web/static/lib/hoot/core/expect": "/web/static/lib/hoot/core/expect.js",
|
||||
"/web/static/lib/hoot/core/fixture": "/web/static/lib/hoot/core/fixture.js",
|
||||
"/web/static/lib/hoot/core/job": "/web/static/lib/hoot/core/job.js",
|
||||
"/web/static/lib/hoot/core/logger": "/web/static/lib/hoot/core/logger.js",
|
||||
"/web/static/lib/hoot/core/runner": "/web/static/lib/hoot/core/runner.js",
|
||||
"/web/static/lib/hoot/core/suite": "/web/static/lib/hoot/core/suite.js",
|
||||
"/web/static/lib/hoot/core/tag": "/web/static/lib/hoot/core/tag.js",
|
||||
"/web/static/lib/hoot/core/test": "/web/static/lib/hoot/core/test.js",
|
||||
"/web/static/lib/hoot/core/url": "/web/static/lib/hoot/core/url.js",
|
||||
"/web/static/lib/hoot/hoot_utils": "/web/static/lib/hoot/hoot_utils.js",
|
||||
"/web/static/lib/hoot/hoot-mock": "/web/static/lib/hoot/hoot-mock.js",
|
||||
"/web/static/lib/hoot/hoot": "/web/static/lib/hoot/hoot.js",
|
||||
"/web/static/lib/hoot/lib/diff_match_patch": "/web/static/lib/hoot/lib/diff_match_patch.js",
|
||||
"/web/static/lib/hoot/main_runner": "/web/static/lib/hoot/main_runner.js",
|
||||
"/web/static/lib/hoot/mock/animation": "/web/static/lib/hoot/mock/animation.js",
|
||||
"/web/static/lib/hoot/mock/console": "/web/static/lib/hoot/mock/console.js",
|
||||
"/web/static/lib/hoot/mock/crypto": "/web/static/lib/hoot/mock/crypto.js",
|
||||
"/web/static/lib/hoot/mock/date": "/web/static/lib/hoot/mock/date.js",
|
||||
"/web/static/lib/hoot/mock/math": "/web/static/lib/hoot/mock/math.js",
|
||||
"/web/static/lib/hoot/mock/navigator": "/web/static/lib/hoot/mock/navigator.js",
|
||||
"/web/static/lib/hoot/mock/network": "/web/static/lib/hoot/mock/network.js",
|
||||
"/web/static/lib/hoot/mock/notification": "/web/static/lib/hoot/mock/notification.js",
|
||||
"/web/static/lib/hoot/mock/storage": "/web/static/lib/hoot/mock/storage.js",
|
||||
"/web/static/lib/hoot/mock/sync_values": "/web/static/lib/hoot/mock/sync_values.js",
|
||||
"/web/static/lib/hoot/mock/window": "/web/static/lib/hoot/mock/window.js",
|
||||
"/web/static/lib/hoot/tests/local_helpers": "/web/static/lib/hoot/tests/local_helpers.js",
|
||||
"/web/static/lib/hoot/ui/hoot_buttons": "/web/static/lib/hoot/ui/hoot_buttons.js",
|
||||
"/web/static/lib/hoot/ui/hoot_colors": "/web/static/lib/hoot/ui/hoot_colors.js",
|
||||
"/web/static/lib/hoot/ui/hoot_config_menu": "/web/static/lib/hoot/ui/hoot_config_menu.js",
|
||||
"/web/static/lib/hoot/ui/hoot_copy_button": "/web/static/lib/hoot/ui/hoot_copy_button.js",
|
||||
"/web/static/lib/hoot/ui/hoot_debug_toolbar": "/web/static/lib/hoot/ui/hoot_debug_toolbar.js",
|
||||
"/web/static/lib/hoot/ui/hoot_dropdown": "/web/static/lib/hoot/ui/hoot_dropdown.js",
|
||||
"/web/static/lib/hoot/ui/hoot_job_buttons": "/web/static/lib/hoot/ui/hoot_job_buttons.js",
|
||||
"/web/static/lib/hoot/ui/hoot_link": "/web/static/lib/hoot/ui/hoot_link.js",
|
||||
"/web/static/lib/hoot/ui/hoot_log_counters": "/web/static/lib/hoot/ui/hoot_log_counters.js",
|
||||
"/web/static/lib/hoot/ui/hoot_main": "/web/static/lib/hoot/ui/hoot_main.js",
|
||||
"/web/static/lib/hoot/ui/hoot_reporting": "/web/static/lib/hoot/ui/hoot_reporting.js",
|
||||
"/web/static/lib/hoot/ui/hoot_search": "/web/static/lib/hoot/ui/hoot_search.js",
|
||||
"/web/static/lib/hoot/ui/hoot_side_bar": "/web/static/lib/hoot/ui/hoot_side_bar.js",
|
||||
"/web/static/lib/hoot/ui/hoot_status_panel": "/web/static/lib/hoot/ui/hoot_status_panel.js",
|
||||
"/web/static/lib/hoot/ui/hoot_tag_button": "/web/static/lib/hoot/ui/hoot_tag_button.js",
|
||||
"/web/static/lib/hoot/ui/hoot_technical_value": "/web/static/lib/hoot/ui/hoot_technical_value.js",
|
||||
"/web/static/lib/hoot/ui/hoot_test_path": "/web/static/lib/hoot/ui/hoot_test_path.js",
|
||||
"/web/static/lib/hoot/ui/hoot_test_result": "/web/static/lib/hoot/ui/hoot_test_result.js",
|
||||
"/web/static/lib/hoot/ui/setup_hoot_ui": "/web/static/lib/hoot/ui/setup_hoot_ui.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Test assets -->
|
||||
<script src="/web/static/lib/owl/owl.js"></script>
|
||||
<script src="../hoot.js" type="module" defer></script>
|
||||
<link rel="stylesheet" href="/web/static/lib/hoot/ui/hoot_style.css" />
|
||||
<link rel="stylesheet" href="/web/static/src/libs/fontawesome/css/font-awesome.css" />
|
||||
|
||||
<!-- Test suites -->
|
||||
<script src="./index.js" type="module" defer></script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
17
odoo-bringout-oca-ocb-web/web/static/lib/hoot/tests/index.js
Normal file
17
odoo-bringout-oca-ocb-web/web/static/lib/hoot/tests/index.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { isHootReady, start } from "@odoo/hoot";
|
||||
|
||||
import "./core/expect.test.js";
|
||||
import "./core/runner.test.js";
|
||||
import "./core/suite.test.js";
|
||||
import "./core/test.test.js";
|
||||
import "./hoot-dom/dom.test.js";
|
||||
import "./hoot-dom/events.test.js";
|
||||
import "./hoot-dom/time.test.js";
|
||||
import "./hoot_utils.test.js";
|
||||
import "./mock/navigator.test.js";
|
||||
import "./mock/network.test.js";
|
||||
import "./mock/window.test.js";
|
||||
import "./ui/hoot_technical_value.test.js";
|
||||
import "./ui/hoot_test_result.test.js";
|
||||
|
||||
isHootReady.then(start);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, destroy, getFixture } from "@odoo/hoot";
|
||||
import { App, Component, xml } from "@odoo/owl";
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// Exports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {import("@odoo/owl").ComponentConstructor} ComponentClass
|
||||
* @param {ConstructorParameters<typeof App>[1]} [config]
|
||||
*/
|
||||
export async function mountForTest(ComponentClass, config) {
|
||||
if (typeof ComponentClass === "string") {
|
||||
ComponentClass = class extends Component {
|
||||
static name = "anonymous component";
|
||||
static props = {};
|
||||
static template = xml`${ComponentClass}`;
|
||||
};
|
||||
}
|
||||
|
||||
const app = new App(ComponentClass, {
|
||||
name: "TEST",
|
||||
test: true,
|
||||
warnIfNoStaticProps: true,
|
||||
...config,
|
||||
});
|
||||
const fixture = getFixture();
|
||||
|
||||
after(() => destroy(app));
|
||||
|
||||
fixture.style.backgroundColor = "#fff";
|
||||
await app.mount(fixture);
|
||||
if (fixture.hasIframes) {
|
||||
await fixture.waitForIframes();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
export function parseUrl(url) {
|
||||
return url.replace(/^.*hoot\/tests/, "@hoot").replace(/(\.test)?\.js$/, "");
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { mockSendBeacon, mockTouch, mockVibrate } from "@odoo/hoot-mock";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
/**
|
||||
* @param {Promise<any>} promise
|
||||
*/
|
||||
const ensureResolvesImmediatly = (promise) =>
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise((resolve, reject) => reject("failed to resolve in a single micro tick")),
|
||||
]);
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
describe("clipboard", () => {
|
||||
test.tags("secure");
|
||||
test("read/write calls are resolved immediatly", async () => {
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"text/plain": new Blob(["some text"], { type: "text/plain" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
const items = await ensureResolvesImmediatly(navigator.clipboard.read());
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toBeInstanceOf(ClipboardItem);
|
||||
|
||||
const blob = await ensureResolvesImmediatly(items[0].getType("text/plain"));
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
|
||||
const value = await ensureResolvesImmediatly(blob.text());
|
||||
|
||||
expect(value).toBe("some text");
|
||||
});
|
||||
});
|
||||
|
||||
test("maxTouchPoints", () => {
|
||||
mockTouch(false);
|
||||
|
||||
expect(navigator.maxTouchPoints).toBe(0);
|
||||
|
||||
mockTouch(true);
|
||||
|
||||
expect(navigator.maxTouchPoints).toBe(1);
|
||||
});
|
||||
|
||||
test("sendBeacon", () => {
|
||||
expect(() => navigator.sendBeacon("/route", new Blob([]))).toThrow(/sendBeacon/);
|
||||
|
||||
mockSendBeacon(expect.step);
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
navigator.sendBeacon("/route", new Blob([]));
|
||||
|
||||
expect.verifySteps(["/route"]);
|
||||
});
|
||||
|
||||
test("vibrate", () => {
|
||||
expect(() => navigator.vibrate(100)).toThrow(/vibrate/);
|
||||
|
||||
mockVibrate(expect.step);
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
navigator.vibrate(100);
|
||||
|
||||
expect.verifySteps([100]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, test } from "@odoo/hoot";
|
||||
import { mockFetch } from "@odoo/hoot-mock";
|
||||
import { parseUrl } from "../local_helpers";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("setup network values", async () => {
|
||||
expect(document.cookie).toBe("");
|
||||
|
||||
document.cookie = "cids=4";
|
||||
document.title = "kek";
|
||||
|
||||
expect(document.cookie).toBe("cids=4");
|
||||
expect(document.title).toBe("kek");
|
||||
});
|
||||
|
||||
test("values are reset between test", async () => {
|
||||
expect(document.cookie).toBe("");
|
||||
expect(document.title).toBe("");
|
||||
});
|
||||
|
||||
test("fetch should not mock internal URLs", async () => {
|
||||
mockFetch(expect.step);
|
||||
|
||||
await fetch("http://some.url");
|
||||
await fetch("/odoo");
|
||||
await fetch(URL.createObjectURL(new Blob([""])));
|
||||
|
||||
expect.verifySteps(["http://some.url", "/odoo"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { queryOne } from "@odoo/hoot-dom";
|
||||
import { EventBus } from "@odoo/owl";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
import { watchListeners } from "@odoo/hoot-mock";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
class TestBus extends EventBus {
|
||||
addEventListener(type) {
|
||||
expect.step(`addEventListener:${type}`);
|
||||
return super.addEventListener(...arguments);
|
||||
}
|
||||
|
||||
removeEventListener() {
|
||||
throw new Error("Cannot remove event listeners");
|
||||
}
|
||||
}
|
||||
|
||||
let testBus;
|
||||
|
||||
test("elementFromPoint and elementsFromPoint should be mocked", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<div class="oui" style="position: absolute; left: 10px; top: 10px; width: 250px; height: 250px;">
|
||||
Oui
|
||||
</div>
|
||||
`);
|
||||
|
||||
expect(".oui").toHaveRect({
|
||||
x: 10,
|
||||
y: 10,
|
||||
width: 250,
|
||||
height: 250,
|
||||
});
|
||||
|
||||
const div = queryOne(".oui");
|
||||
expect(document.elementFromPoint(11, 11)).toBe(div);
|
||||
expect(document.elementsFromPoint(11, 11)).toEqual([
|
||||
div,
|
||||
document.body,
|
||||
document.documentElement,
|
||||
]);
|
||||
|
||||
expect(document.elementFromPoint(9, 9)).toBe(document.body);
|
||||
expect(document.elementsFromPoint(9, 9)).toEqual([document.body, document.documentElement]);
|
||||
});
|
||||
|
||||
// ! WARNING: the following 2 tests need to be run sequentially to work, as they
|
||||
// ! attempt to test the in-between-tests event listeners cleanup.
|
||||
test("event listeners are properly removed: setup", async () => {
|
||||
const callback = () => expect.step("callback");
|
||||
|
||||
testBus = new TestBus();
|
||||
|
||||
expect.verifySteps([]);
|
||||
|
||||
after(watchListeners());
|
||||
|
||||
testBus.addEventListener("some-event", callback);
|
||||
testBus.trigger("some-event");
|
||||
|
||||
expect.verifySteps(["addEventListener:some-event", "callback"]);
|
||||
});
|
||||
test("event listeners are properly removed: check", async () => {
|
||||
testBus.trigger("some-event");
|
||||
|
||||
expect.verifySteps([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { after, describe, expect, test } from "@odoo/hoot";
|
||||
import { animationFrame, click, Deferred } from "@odoo/hoot-dom";
|
||||
import { Component, reactive, useState, xml } from "@odoo/owl";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
import { logger } from "../../core/logger";
|
||||
import { HootTechnicalValue } from "../../ui/hoot_technical_value";
|
||||
|
||||
const mountTechnicalValue = async (defaultValue) => {
|
||||
const updateValue = async (value) => {
|
||||
state.value = value;
|
||||
await animationFrame();
|
||||
};
|
||||
|
||||
const state = reactive({ value: defaultValue });
|
||||
|
||||
class TechnicalValueParent extends Component {
|
||||
static components = { HootTechnicalValue };
|
||||
static props = {};
|
||||
static template = xml`<HootTechnicalValue value="state.value" />`;
|
||||
|
||||
setup() {
|
||||
this.state = useState(state);
|
||||
}
|
||||
}
|
||||
|
||||
await mountForTest(TechnicalValueParent);
|
||||
|
||||
return updateValue;
|
||||
};
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("technical value with primitive values", async () => {
|
||||
const updateValue = await mountTechnicalValue("oui");
|
||||
expect(".hoot-string").toHaveText(`"oui"`);
|
||||
|
||||
await updateValue(`"stringified"`);
|
||||
expect(".hoot-string").toHaveText(`'"stringified"'`);
|
||||
|
||||
await updateValue(3);
|
||||
expect(".hoot-integer").toHaveText(`3`);
|
||||
|
||||
await updateValue(undefined);
|
||||
expect(".hoot-undefined").toHaveText(`undefined`);
|
||||
|
||||
await updateValue(null);
|
||||
expect(".hoot-null").toHaveText(`null`);
|
||||
});
|
||||
|
||||
test("technical value with objects", async () => {
|
||||
const logDebug = logger.debug;
|
||||
logger.debug = expect.step;
|
||||
after(() => (logger.debug = logDebug));
|
||||
|
||||
const updateValue = await mountTechnicalValue({});
|
||||
expect(".hoot-technical").toHaveText(`Object(0)`);
|
||||
|
||||
await updateValue([1, 2, "3"]);
|
||||
|
||||
expect(".hoot-technical").toHaveText(`Array(3)`);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".hoot-object");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical").toHaveText(`Array(3)[\n1\n,\n2\n,\n"3"\n,\n]`);
|
||||
expect.verifySteps([[1, 2, "3"]]);
|
||||
|
||||
await updateValue({ a: true });
|
||||
expect(".hoot-technical").toHaveText(`Object(1)`);
|
||||
|
||||
await click(".hoot-object");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical").toHaveText(`Object(1){\na\n:\ntrue\n,\n}`);
|
||||
|
||||
await updateValue({
|
||||
a: true,
|
||||
sub: {
|
||||
key: "oui",
|
||||
},
|
||||
});
|
||||
expect(".hoot-technical").toHaveText(`Object(2)`);
|
||||
|
||||
await click(".hoot-object:first");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical:first").toHaveText(
|
||||
`Object(2){\na\n:\ntrue\n,\nsub\n:\nObject(1)\n}`
|
||||
);
|
||||
expect.verifySteps([]);
|
||||
|
||||
await click(".hoot-object:last");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-technical:first").toHaveText(
|
||||
`Object(2){\na\n:\ntrue\n,\nsub\n:\nObject(1){\nkey\n:\n"oui"\n,\n}\n}`
|
||||
);
|
||||
expect.verifySteps([{ key: "oui" }]);
|
||||
});
|
||||
|
||||
test("technical value with special cases", async () => {
|
||||
const updateValue = await mountTechnicalValue(new Date(0));
|
||||
expect(".hoot-technical").toHaveText(`1970-01-01T00:00:00.000Z`);
|
||||
|
||||
await updateValue(/ab[c]/gi);
|
||||
expect(".hoot-technical").toHaveText(`/ab[c]/gi`);
|
||||
|
||||
const def = new Deferred(() => {});
|
||||
await updateValue(def);
|
||||
expect(".hoot-technical").toHaveText(`Deferred<\npending\n>`);
|
||||
|
||||
def.resolve("oui");
|
||||
await animationFrame();
|
||||
expect(".hoot-technical").toHaveText(`Deferred<\nfulfilled\n:\n"oui"\n>`);
|
||||
});
|
||||
|
||||
test("evaluation of unsafe value does not crash", async () => {
|
||||
const logDebug = logger.debug;
|
||||
logger.debug = () => expect.step("debug");
|
||||
after(() => (logger.debug = logDebug));
|
||||
|
||||
class UnsafeString extends String {
|
||||
toString() {
|
||||
return this.valueOf();
|
||||
}
|
||||
valueOf() {
|
||||
throw new Error("UNSAFE");
|
||||
}
|
||||
}
|
||||
|
||||
await mountTechnicalValue(new UnsafeString("some value"));
|
||||
await click(".hoot-object");
|
||||
|
||||
expect(".hoot-object").toHaveText("UnsafeString(0)", {
|
||||
message: "size is 0 because it couldn't be evaluated",
|
||||
});
|
||||
|
||||
expect.verifySteps(["debug"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import { describe, expect, makeExpect, test } from "@odoo/hoot";
|
||||
import { mountForTest, parseUrl } from "../local_helpers";
|
||||
|
||||
import { animationFrame, click } from "@odoo/hoot-dom";
|
||||
import { Component, xml } from "@odoo/owl";
|
||||
import { Runner } from "../../core/runner";
|
||||
import { Test } from "../../core/test";
|
||||
import { HootTestResult } from "../../ui/hoot_test_result";
|
||||
import { makeUiState } from "../../ui/setup_hoot_ui";
|
||||
|
||||
/**
|
||||
* @param {(mockExpect: typeof expect) => any} callback
|
||||
*/
|
||||
const mountTestResults = async (testFn, props) => {
|
||||
const runner = new Runner();
|
||||
const ui = makeUiState();
|
||||
const mockTest = new Test(null, "test", {});
|
||||
const [mockExpect, { after, before }] = makeExpect({});
|
||||
|
||||
class Parent extends Component {
|
||||
static components = { HootTestResult };
|
||||
static props = { test: Test, open: [Boolean, { value: "always" }] };
|
||||
static template = xml`
|
||||
<HootTestResult test="props.test" open="props.open">
|
||||
Toggle
|
||||
</HootTestResult>
|
||||
`;
|
||||
|
||||
mockTest = mockTest;
|
||||
}
|
||||
|
||||
before(mockTest);
|
||||
testFn(mockExpect);
|
||||
after(runner);
|
||||
|
||||
await mountForTest(Parent, {
|
||||
env: { runner, ui },
|
||||
props: {
|
||||
test: mockTest,
|
||||
open: "always",
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
return mockTest;
|
||||
};
|
||||
|
||||
const CLS_PASS = "text-emerald";
|
||||
const CLS_FAIL = "text-rose";
|
||||
|
||||
describe(parseUrl(import.meta.url), () => {
|
||||
test("test results: toBe and basic interactions", async () => {
|
||||
const mockTest = await mountTestResults(
|
||||
(mockExpect) => {
|
||||
mockExpect(true).toBe(true);
|
||||
mockExpect(true).toBe(false);
|
||||
},
|
||||
{ open: false }
|
||||
);
|
||||
|
||||
expect(".HootTestResult button:only").toHaveText("Toggle");
|
||||
expect(".hoot-result-detail").not.toHaveCount();
|
||||
expect(mockTest.lastResults.pass).toBe(false);
|
||||
|
||||
await click(".HootTestResult button");
|
||||
await animationFrame();
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/received value is strictly equal to true/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
|
||||
/expected values to be strictly equal/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
test("test results: toEqual", async () => {
|
||||
await mountTestResults((mockExpect) => {
|
||||
mockExpect([1, 2, { a: true }]).toEqual([1, 2, { a: true }]);
|
||||
mockExpect([1, { a: false }, 3]).toEqual([1, { a: true }, 3]);
|
||||
});
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/received value is deeply equal to \[1, 2, { a: true }\]/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
|
||||
/expected values to be deeply equal/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("test results: toHaveCount", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<span class="text" >abc</span>
|
||||
<span class="text" >bcd</span>
|
||||
`);
|
||||
await mountTestResults((mockExpect) => {
|
||||
mockExpect(".text").toHaveCount(2);
|
||||
mockExpect(".text").toHaveCount(1);
|
||||
});
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/found 2 elements matching ".text"/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}`).toHaveText(
|
||||
/found 2 elements matching ".text"/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(`.hoot-info .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("multiple test results: toHaveText", async () => {
|
||||
await mountForTest(/* xml */ `
|
||||
<span class="text" >abc</span>
|
||||
<span class="text" >bcd</span>
|
||||
`);
|
||||
await mountTestResults((mockExpect) => {
|
||||
mockExpect(".text:first").toHaveText("abc");
|
||||
mockExpect(".text").toHaveText("abc");
|
||||
mockExpect(".text").not.toHaveText("abc");
|
||||
});
|
||||
|
||||
expect(".hoot-result-detail").toHaveCount(1);
|
||||
|
||||
// First assertion: pass
|
||||
expect(`.hoot-result-detail > .${CLS_PASS}`).toHaveText(
|
||||
/1 element matching ".text:first" has text "abc"/,
|
||||
{ inline: true }
|
||||
);
|
||||
|
||||
// Second assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}:eq(0)`).toHaveText(
|
||||
/expected 2 elements matching ".text" to have the given text/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(".hoot-info:eq(0) .hoot-html").toHaveCount(2);
|
||||
expect(".hoot-info:eq(0) .hoot-html").toHaveText("<span.text/>");
|
||||
expect(`.hoot-info:eq(0) .${CLS_PASS}:contains(Received)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(0) .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(0) .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
|
||||
// Third assertion: fail
|
||||
expect(`.hoot-result-detail > .${CLS_FAIL}:eq(1)`).toHaveText(
|
||||
/expected 2 elements matching ".text" not to have the given text/,
|
||||
{ inline: true }
|
||||
);
|
||||
expect(".hoot-info:eq(1) .hoot-html").toHaveCount(2);
|
||||
expect(".hoot-info:eq(1) .hoot-html").toHaveText("<span.text/>");
|
||||
expect(`.hoot-info:eq(1) .${CLS_PASS}:contains(Received)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(1) .${CLS_PASS}:contains(Expected)`).toHaveCount(1);
|
||||
expect(`.hoot-info:eq(1) .${CLS_FAIL}:contains(Received)`).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
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