vanilla 18.0

This commit is contained in:
Ernad Husremovic 2025-10-08 10:48:09 +02:00
parent 5454004ff9
commit d7f6d2725e
979 changed files with 428093 additions and 0 deletions

View 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);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,223 @@
/** @odoo-module */
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;
}
function setup() {
allowFixture = true;
if (shouldPrepareNextFixture) {
shouldPrepareNextFixture = false;
// Reset focus & selection
getActiveElement().blur();
getSelection().removeAllRanges();
}
}
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();
}
}
}

View 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();
}
}

View 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
);

File diff suppressed because it is too large Load diff

View 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;
}
}

View 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;
}
}
}

View 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();
}
}
}

View 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];
}
}

View 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";

View 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();

File diff suppressed because it is too large Load diff

View 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;
}

View 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))
);
}

View file

@ -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];
}
}
}
}

View 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 },
});

View 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);

View 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;
}

File diff suppressed because it is too large Load diff

View file

@ -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"));
}
}

View file

@ -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 }));
}
}

View file

@ -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("");
}
}

View file

@ -0,0 +1,710 @@
/** @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";
//-----------------------------------------------------------------------------
// 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 },
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 },
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;
}
}
};
}

View file

@ -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));
});
});
});

View file

@ -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);
});
});

View file

@ -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");
});
});

View file

@ -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();
});
});

View file

@ -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="&lt;p&gt;Iframe text content&lt;/p&gt;"></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="&lt;input &gt;"></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="&lt;p&gt;Iframe text content&lt;/p&gt;"></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

View file

@ -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"]);
});
});

View file

@ -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;

View file

@ -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`);
});
});

View file

@ -0,0 +1,95 @@
<!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/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>

View 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);

View file

@ -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$/, "");
}

View file

@ -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]);
});
});

View file

@ -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"]);
});
});

View file

@ -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([]);
});
});

View file

@ -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"]);
});
});

View file

@ -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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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