19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -45,6 +45,7 @@ import {
S_NONE,
strictEqual,
} from "../hoot_utils";
import { mockFetch } from "../mock/network";
import { logger } from "./logger";
import { Test } from "./test";
@ -134,6 +135,7 @@ const {
Array: { isArray: $isArray },
clearTimeout,
Error,
Intl: { ListFormat },
Math: { abs: $abs, floor: $floor },
Object: { assign: $assign, create: $create, entries: $entries, keys: $keys },
parseFloat,
@ -177,7 +179,7 @@ function detailsFromValues(...args) {
* @param {...unknown} args
*/
function detailsFromValuesWithDiff(...args) {
return [...detailsFromValues(...args), Markup.diff(...args)];
return detailsFromValues(...args).concat([Markup.diff(...args)]);
}
/**
@ -446,6 +448,7 @@ export function makeExpect(params) {
if (currentResult.currentSteps.length) {
currentResult.registerEvent("assertion", {
label: "step",
docLabel: "expect.step",
pass: false,
failedDetails: detailsFromEntries([["Steps:", currentResult.currentSteps]]),
reportMessage: [r`unverified steps`],
@ -456,6 +459,7 @@ export function makeExpect(params) {
if (!(assertionCount + queryCount)) {
currentResult.registerEvent("assertion", {
label: "assertions",
docLabel: "expect.assertions",
pass: false,
reportMessage: [
r`expected at least`,
@ -469,6 +473,7 @@ export function makeExpect(params) {
) {
currentResult.registerEvent("assertion", {
label: "assertions",
docLabel: "expect.assertions",
pass: false,
reportMessage: [
r`expected`,
@ -484,6 +489,7 @@ export function makeExpect(params) {
if (currentResult.currentErrors.length) {
currentResult.registerEvent("assertion", {
label: "errors",
docLabel: "expect.errors",
pass: false,
reportMessage: [currentResult.currentErrors.length, r`unverified error(s)`],
});
@ -493,6 +499,7 @@ export function makeExpect(params) {
if (currentResult.expectedErrors && currentResult.expectedErrors !== errorCount) {
currentResult.registerEvent("assertion", {
label: "errors",
docLabel: "expect.errors",
pass: false,
reportMessage: [
r`expected`,
@ -539,6 +546,7 @@ export function makeExpect(params) {
/** @type {import("../hoot_utils").Reporting} */
const report = {
assertions: assertionCount,
duration: test.lastResults?.duration || 0,
tests: 1,
};
if (!currentResult.pass) {
@ -562,6 +570,12 @@ export function makeExpect(params) {
}
/**
* Expects the current test to have the `expected` amount of assertions. This
* number cannot be less than 1.
*
* Note that it is generally preferred to use `expect.step` and `expect.verifySteps`
* instead as it is more reliable and allows to test more extensively.
*
* @param {number} expected
*/
function assertions(expected) {
@ -605,10 +619,10 @@ export function makeExpect(params) {
return false;
}
const { errors, options } = resolver;
const actualErrors = currentResult.currentErrors;
const { currentErrors } = currentResult;
const pass =
actualErrors.length === errors.length &&
actualErrors.every(
currentErrors.length === errors.length &&
currentErrors.every(
(error, i) =>
match(error, errors[i]) || (error.cause && match(error.cause, errors[i]))
);
@ -623,12 +637,13 @@ export function makeExpect(params) {
: "expected the following errors";
const assertion = {
label: "verifyErrors",
docLabel: "expect.verifyErrors",
message: options?.message,
pass,
reportMessage,
};
if (!pass) {
const fActual = actualErrors.map(formatError);
const fActual = currentErrors.map(formatError);
const fExpected = errors.map(formatError);
assertion.failedDetails = detailsFromValuesWithDiff(fExpected, fActual);
assertion.stack = getStack(1);
@ -662,6 +677,7 @@ export function makeExpect(params) {
: "expected the following steps";
const assertion = {
label: "verifySteps",
docLabel: "expect.verifySteps",
message: options?.message,
pass,
reportMessage,
@ -677,6 +693,11 @@ export function makeExpect(params) {
}
/**
* Expects the current test to have the `expected` amount of errors.
*
* This also means that from the moment this function is called, the test will
* accept that amount of errors before being considered as failed.
*
* @param {number} expected
*/
function errors(expected) {
@ -718,6 +739,9 @@ export function makeExpect(params) {
}
/**
* Registers a step for the current test, that can be consumed by `expect.verifySteps`.
* Unconsumed steps will fail the test.
*
* @param {unknown} value
*/
function step(value) {
@ -748,6 +772,11 @@ export function makeExpect(params) {
throw scopeError("expect.verifyErrors");
}
ensureArguments(arguments, "any[]", ["object", null]);
if (errors.length > currentResult.expectedErrors) {
throw new HootError(
`cannot call \`expect.verifyErrors()\` without calling \`expect.errors()\` beforehand`
);
}
return checkErrors({ errors, options }, true);
}
@ -761,6 +790,8 @@ export function makeExpect(params) {
* @param {VerifierOptions} [options]
* @returns {boolean}
* @example
* expect.step("web_read_group");
* expect.step([1, 2]);
* expect.verifySteps(["web_read_group", "web_search_read"]);
*/
function verifySteps(steps, options) {
@ -988,6 +1019,9 @@ export class CaseResult {
this.counts[type]++;
switch (type) {
case "assertion": {
if (value && this.headless) {
delete value.docLabel; // Only required in UI
}
caseEvent = new Assertion(this.counts.assertion, value);
this.pass &&= caseEvent.pass;
break;
@ -2032,13 +2066,15 @@ export class Matcher {
* - contain file objects matching the given `files` list.
*
* @param {ReturnType<typeof getNodeValue>} [value]
* @param {ExpectOptions} [options]
* @param {ExpectOptions & { raw?: boolean }} [options]
* @example
* expect("input[type=email]").toHaveValue("john@doe.com");
* expect("input[name=age]").toHaveValue(29);
* @example
* expect("input[type=file]").toHaveValue(new File(["foo"], "foo.txt"));
* @example
* expect("select[multiple]").toHaveValue(["foo", "bar"]);
* @example
* expect("input[name=age]").toHaveValue("29", { raw: true });
*/
toHaveValue(value, options) {
this._ensureArguments(arguments, [
@ -2055,7 +2091,7 @@ export class Matcher {
return this._resolve(() => ({
name: "toHaveValue",
acceptedType: ["string", "node", "node[]"],
mapElements: (el) => getNodeValue(el),
mapElements: (el) => getNodeValue(el, options?.raw),
predicate: (elValue, el) => {
if (isCheckable(el)) {
throw new HootError(
@ -2201,10 +2237,17 @@ export class Matcher {
const types = ensureArray(acceptedType);
if (!types.some((type) => isOfType(this._received, type))) {
const joinedTypes =
types.length > 1
? new ListFormat("en-GB", {
type: "disjunction",
style: "long",
}).format(types)
: types[0];
throw new TypeError(
`expected received value to be of type ${listJoin(types, ",", "or").join(
" "
)}, got ${formatHumanReadable(this._received)}`
`expected received value to be of type ${joinedTypes}, got ${formatHumanReadable(
this._received
)}`
);
}
@ -2300,11 +2343,14 @@ export class CaseEvent {
export class Assertion extends CaseEvent {
/** @type {string | null | undefined} */
additionalMessage;
/** @type {string | undefined} */
docLabel;
type = CASE_EVENT_TYPES.assertion.value;
/**
* @param {number} number
* @param {Partial<Assertion & {
* docLabel?: string;
* message: AssertionMessage,
* reportMessage: AssertionReportMessage,
* }>} values
@ -2312,6 +2358,7 @@ export class Assertion extends CaseEvent {
constructor(number, values) {
super();
this.docLabel = values.docLabel;
this.label = values.label;
this.flags = values.flags || 0;
this.pass = values.pass || false;
@ -2388,11 +2435,16 @@ export class DOMCaseEvent extends CaseEvent {
* @param {InteractionType} type
* @param {InteractionDetails} details
*/
constructor(type, [name, args, returnValue]) {
constructor(type, [name, alias, args, returnValue]) {
super();
this.type = CASE_EVENT_TYPES[type].value;
this.label = name;
this.label = alias || name;
if (type === "server") {
this.docLabel = mockFetch.name;
} else {
this.docLabel = name;
}
for (let i = 0; i < args.length; i++) {
if (args[i] !== undefined && (i === 0 || typeof args[i] !== "object")) {
this.message.push(makeLabelOrString(args[i]));
@ -2419,12 +2471,20 @@ export class CaseError extends CaseEvent {
this.message = error.message.split(R_WHITE_SPACE);
/** @type {string} */
this.stack = error.stack;
// Ensures that the stack contains the error name & message.
// This can happen when setting the 'message' after creating the error.
const errorNameAndMessage = String(error);
if (!this.stack.startsWith(errorNameAndMessage)) {
this.stack = errorNameAndMessage + this.stack.slice(error.name.length);
}
}
}
export class Step extends CaseEvent {
type = CASE_EVENT_TYPES.step.value;
label = "step";
docLabel = "expect.step";
/**
* @param {any} value

View file

@ -25,7 +25,8 @@ import { getViewPortHeight, getViewPortWidth } from "../mock/window";
// Global
//-----------------------------------------------------------------------------
const { customElements, document, getSelection, HTMLElement, Promise, WeakSet } = globalThis;
const { customElements, document, getSelection, HTMLElement, MutationObserver, Promise, WeakSet } =
globalThis;
//-----------------------------------------------------------------------------
// Internal

View file

@ -1,7 +1,7 @@
/** @odoo-module */
import { getColorHex } from "../../hoot-dom/hoot_dom_utils";
import { stringify } from "../hoot_utils";
import { isNil, stringify } from "../hoot_utils";
import { urlParams } from "./url";
//-----------------------------------------------------------------------------
@ -123,13 +123,13 @@ class Logger {
$groupEnd();
}
/**
* @param {...any} args
* @param {...any} args
*/
table(...args) {
$table(...args);
}
/**
* @param {...any} args
* @param {...any} args
*/
trace(...args) {
$trace(...args);
@ -190,7 +190,7 @@ class Logger {
`(${withArgs.shift()}`,
...withArgs,
"time:",
suite.jobs.reduce((acc, job) => acc + (job.duration || 0), 0),
suite.reporting.duration,
"ms)"
);
}
@ -203,16 +203,7 @@ class Logger {
if (!this.canLog("tests")) {
return;
}
const { fullName, lastResults } = test;
$log(
...styledArguments([
`Test ${stringify(fullName)} passed (assertions:`,
lastResults.counts.assertion || 0,
`/ time:`,
lastResults.duration,
`ms)`,
])
);
$log(...styledArguments([`Running test ${stringify(test.fullName)}`]));
}
/**
* @param {[label: string, color: string]} prefix
@ -285,32 +276,56 @@ let nextNetworkLogId = 1;
*/
export function makeNetworkLogger(prefix, title) {
const id = nextNetworkLogId++;
const slicedTitle =
title.length > 128 ? title.slice(0, 128) + " (click to show full input)" : title;
return {
/**
* Request logger: blue-ish.
* @param {() => any} getData
* Request logger: blue lotus.
* @param {() => any[]} getData
*/
async logRequest(getData) {
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");
const color = `color: #6960ec`;
const args = [`${color}; font-weight: bold;`, color];
const [dataHeader, ...otherData] = getData();
if (!isNil(dataHeader)) {
args.push(dataHeader);
}
$groupCollapsed(`-> %c${prefix}#${id}%c ${slicedTitle}`, ...args);
if (slicedTitle !== title) {
$log(title);
}
for (const data of otherData) {
$log(data);
}
$trace("Request trace:");
$groupEnd();
},
/**
* Response logger: orange.
* @param {() => any} getData
* Response logger: dark orange.
* @param {() => any[]} getData
*/
async logResponse(getData) {
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());
const color = `color: #ff8c00`;
const args = [`${color}; font-weight: bold;`, color];
const [dataHeader, ...otherData] = getData();
if (!isNil(dataHeader)) {
args.push(dataHeader);
}
$groupCollapsed(`<- %c${prefix}#${id}%c ${slicedTitle}`, ...args);
if (slicedTitle !== title) {
$log(title);
}
for (const data of otherData) {
$log(data);
}
$trace("Response trace:");
$groupEnd();
},
};
}

View file

@ -31,7 +31,7 @@ import {
import { cleanupAnimations } from "../mock/animation";
import { cleanupDate } from "../mock/date";
import { internalRandom } from "../mock/math";
import { cleanupNavigator, mockUserAgent } from "../mock/navigator";
import { cleanupNavigator } from "../mock/navigator";
import { cleanupNetwork, throttleNetwork } from "../mock/network";
import {
cleanupWindow,
@ -50,8 +50,16 @@ import { Test, testError } from "./test";
import { EXCLUDE_PREFIX, createUrlFromId, setParams } from "./url";
// Import all helpers for debug mode
import * as hootDom from "@odoo/hoot-dom";
import * as hootMock from "@odoo/hoot-mock";
import * as _hootDom from "@odoo/hoot-dom";
import * as _animation from "../mock/animation";
import * as _date from "../mock/date";
import * as _math from "../mock/math";
import * as _navigator from "../mock/navigator";
import * as _network from "../mock/network";
import * as _notification from "../mock/notification";
import * as _window from "../mock/window";
const { isPrevented, mockPreventDefault } = _window;
/**
* @typedef {{
@ -115,7 +123,7 @@ const {
Number: { parseFloat: $parseFloat },
Object: {
assign: $assign,
defineProperties: $defineProperties,
defineProperty: $defineProperty,
entries: $entries,
freeze: $freeze,
fromEntries: $fromEntries,
@ -163,8 +171,11 @@ function formatIncludes(values) {
*/
function formatAssertions(assertions) {
const lines = [];
for (const { failedDetails, label, message, number } of assertions) {
for (const { additionalMessage, failedDetails, label, message, number } of assertions) {
const formattedMessage = message.map((part) => (isLabel(part) ? part[0] : String(part)));
if (additionalMessage) {
formattedMessage.push(`(${additionalMessage})`);
}
lines.push(`\n${number}. [${label}] ${formattedMessage.join(" ")}`);
if (failedDetails) {
for (const detail of failedDetails) {
@ -191,15 +202,6 @@ function formatAssertions(assertions) {
return lines;
}
/**
* @param {Event} ev
*/
function safePrevent(ev) {
if (ev.cancelable) {
ev.preventDefault();
}
}
/**
* @template T
* @param {T[]} array
@ -832,6 +834,7 @@ export class Runner {
}
manualStart() {
this._canStartDef ||= Promise.withResolvers();
this._canStartDef.resolve(true);
}
@ -981,6 +984,8 @@ export class Runner {
continue;
}
logger.logTest(test);
// Suppress console errors and warnings if test is in "todo" mode
// (and not in debug).
const restoreConsole = handleConsoleIssues(test, !this.debug);
@ -1048,14 +1053,7 @@ export class Runner {
// Log test errors and increment counters
this.expectHooks.after(this);
if (lastResults.pass) {
logger.logTest(test);
if (this.state.failedIds.has(test.id)) {
this.state.failedIds.delete(test.id);
storageSet(STORAGE.failed, [...this.state.failedIds]);
}
} else {
if (!lastResults.pass) {
this._failed++;
const failReasons = [];
@ -1088,6 +1086,9 @@ export class Runner {
this.state.failedIds.add(test.id);
storageSet(STORAGE.failed, [...this.state.failedIds]);
}
} else if (this.state.failedIds.has(test.id)) {
this.state.failedIds.delete(test.id);
storageSet(STORAGE.failed, [...this.state.failedIds]);
}
await this._callbacks.call("after-post-test", test, handleError);
@ -1151,6 +1152,17 @@ export class Runner {
await this._callbacks.call("after-all", this, logger.error);
if (this.headless) {
// Log root suite results in headless
const restoreLogLevel = logger.setLogLevel("suites");
for (const suite of this.suites.values()) {
if (!suite.parent) {
logger.logSuite(suite);
}
}
restoreLogLevel();
}
const { passed, failed, assertions } = this.reporting;
if (failed > 0) {
const errorMessage = ["Some tests failed: see above for details"];
@ -1317,17 +1329,16 @@ export class Runner {
/** @type {Configurators} */
const configurators = { ...configuratorGetters, ...configuratorMethods };
const properties = {};
for (const [key, getter] of $entries(configuratorGetters)) {
properties[key] = { get: getter };
$defineProperty(configurableFn, key, { get: getter });
}
for (const [key, getter] of $entries(configuratorMethods)) {
properties[key] = { value: getter };
$defineProperty(configurableFn, key, { value: getter });
}
/** @type {{ tags: Tag[], [key: string]: any }} */
let currentConfig = { tags: [] };
return $defineProperties(configurableFn, properties);
return configurableFn;
}
/**
@ -1395,7 +1406,7 @@ export class Runner {
}
/**
* @param {...string} tagNames
* @param {...string} tagNames
*/
const addTagsToCurrent = (...tagNames) => {
const current = getCurrent();
@ -1425,7 +1436,6 @@ export class Runner {
* @param {boolean} [canEraseParent]
*/
_erase(job, canEraseParent = false) {
job.minimize();
if (job instanceof Suite) {
if (!job.reporting.failed) {
this.suites.delete(job.id);
@ -1435,6 +1445,7 @@ export class Runner {
this.tests.delete(job.id);
}
}
job.minimize();
if (canEraseParent && job.parent) {
const jobIndex = job.parent.jobs.indexOf(job);
if (jobIndex >= 0) {
@ -1694,9 +1705,6 @@ export class Runner {
if (preset.tags?.length) {
this._include(this.state.includeSpecs.tag, preset.tags, INCLUDE_LEVEL.preset);
}
if (preset.platform) {
mockUserAgent(preset.platform);
}
if (typeof preset.touch === "boolean") {
this.beforeEach(() => mockTouch(preset.touch));
}
@ -1731,7 +1739,7 @@ export class Runner {
this._populateState = false;
if (!this.state.tests.length) {
throw new HootError(`no tests to run`, { level: "critical" });
logger.logGlobal(`no tests to run`);
}
// Reduce non-included suites & tests info to a miminum
@ -1778,7 +1786,7 @@ export class Runner {
const error = ensureError(ev);
if (handledErrors.has(error)) {
// Already handled
return safePrevent(ev);
return ev.preventDefault();
}
handledErrors.add(error);
@ -1786,27 +1794,32 @@ export class Runner {
ev = new ErrorEvent("error", { error });
}
mockPreventDefault(ev);
if (error.message.includes(RESIZE_OBSERVER_MESSAGE)) {
// Stop event
ev.stopImmediatePropagation();
if (ev.bubbles) {
ev.stopPropagation();
}
return safePrevent(ev);
return ev.preventDefault();
}
if (this.state.currentTest && !(error instanceof HootError)) {
if (this.state.currentTest) {
// Handle the error in the current test
const handled = this._handleErrorInTest(ev, error);
if (handled) {
return safePrevent(ev);
if (!(error instanceof HootError)) {
ev.preventDefault();
}
return;
}
} else {
this._handleGlobalError(ev, error);
}
// Prevent error event
safePrevent(ev);
ev.preventDefault();
// Log error
if (error.level) {
@ -1826,7 +1839,7 @@ export class Runner {
_handleErrorInTest(ev, error) {
for (const callbackRegistry of this._getCallbackChain(this.state.currentTest)) {
callbackRegistry.callSync("error", ev, logger.error);
if (ev.defaultPrevented) {
if (isPrevented(ev)) {
// Prevented in tests
return true;
}
@ -1875,7 +1888,7 @@ export class Runner {
async _setupStart() {
this._startTime = $now();
if (this.config.manual) {
this._canStartDef = Promise.withResolvers();
this._canStartDef ||= Promise.withResolvers();
}
// Config log
@ -1903,10 +1916,21 @@ export class Runner {
this.config.debugTest = false;
this.debug = false;
} else {
const nameSpace = exposeHelpers(hootDom, hootMock, {
destroy,
getFixture: this.fixture.get,
});
const nameSpace = exposeHelpers(
_hootDom,
_animation,
_date,
_math,
_navigator,
_network,
_notification,
_window,
{
__debug__: this,
destroy,
getFixture: this.fixture.get,
}
);
logger.setLogLevel("debug");
logger.logDebug(
`Debug mode is active: Hoot helpers available from \`window.${nameSpace}\``

View file

@ -157,7 +157,7 @@ export function getTagSimilarities() {
* Used in Hoot internal tests to remove tags introduced within a test.
*
* @private
* @param {Iterable<string>} tagKeys
* @param {Iterable<string>} tagKeys
*/
export function undefineTags(tagKeys) {
for (const tagKey of tagKeys) {

View file

@ -1,33 +1,84 @@
/** @odoo-module alias=@odoo/hoot-mock default=false */
/**
* @typedef {import("./mock/network").ServerWebSocket} ServerWebSocket
*/
import * as _hootDom from "@odoo/hoot-dom";
import * as _animation from "./mock/animation";
import * as _date from "./mock/date";
import * as _math from "./mock/math";
import * as _navigator from "./mock/navigator";
import * as _network from "./mock/network";
import * as _notification from "./mock/notification";
import * as _window from "./mock/window";
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";
/** @deprecated use `import { advanceFrame } from "@odoo/hoot";` */
export const advanceFrame = _hootDom.advanceFrame;
/** @deprecated use `import { advanceTime } from "@odoo/hoot";` */
export const advanceTime = _hootDom.advanceTime;
/** @deprecated use `import { animationFrame } from "@odoo/hoot";` */
export const animationFrame = _hootDom.animationFrame;
/** @deprecated use `import { cancelAllTimers } from "@odoo/hoot";` */
export const cancelAllTimers = _hootDom.cancelAllTimers;
/** @deprecated use `import { Deferred } from "@odoo/hoot";` */
export const Deferred = _hootDom.Deferred;
/** @deprecated use `import { delay } from "@odoo/hoot";` */
export const delay = _hootDom.delay;
/** @deprecated use `import { freezeTime } from "@odoo/hoot";` */
export const freezeTime = _hootDom.freezeTime;
/** @deprecated use `import { microTick } from "@odoo/hoot";` */
export const microTick = _hootDom.microTick;
/** @deprecated use `import { runAllTimers } from "@odoo/hoot";` */
export const runAllTimers = _hootDom.runAllTimers;
/** @deprecated use `import { setFrameRate } from "@odoo/hoot";` */
export const setFrameRate = _hootDom.setFrameRate;
/** @deprecated use `import { tick } from "@odoo/hoot";` */
export const tick = _hootDom.tick;
/** @deprecated use `import { unfreezeTime } from "@odoo/hoot";` */
export const unfreezeTime = _hootDom.unfreezeTime;
/** @deprecated use `import { disableAnimations } from "@odoo/hoot";` */
export const disableAnimations = _animation.disableAnimations;
/** @deprecated use `import { enableTransitions } from "@odoo/hoot";` */
export const enableTransitions = _animation.enableTransitions;
/** @deprecated use `import { mockDate } from "@odoo/hoot";` */
export const mockDate = _date.mockDate;
/** @deprecated use `import { mockLocale } from "@odoo/hoot";` */
export const mockLocale = _date.mockLocale;
/** @deprecated use `import { mockTimeZone } from "@odoo/hoot";` */
export const mockTimeZone = _date.mockTimeZone;
/** @deprecated use `import { onTimeZoneChange } from "@odoo/hoot";` */
export const onTimeZoneChange = _date.onTimeZoneChange;
/** @deprecated use `import { makeSeededRandom } from "@odoo/hoot";` */
export const makeSeededRandom = _math.makeSeededRandom;
/** @deprecated use `import { mockPermission } from "@odoo/hoot";` */
export const mockPermission = _navigator.mockPermission;
/** @deprecated use `import { mockSendBeacon } from "@odoo/hoot";` */
export const mockSendBeacon = _navigator.mockSendBeacon;
/** @deprecated use `import { mockUserAgent } from "@odoo/hoot";` */
export const mockUserAgent = _navigator.mockUserAgent;
/** @deprecated use `import { mockVibrate } from "@odoo/hoot";` */
export const mockVibrate = _navigator.mockVibrate;
/** @deprecated use `import { mockFetch } from "@odoo/hoot";` */
export const mockFetch = _network.mockFetch;
/** @deprecated use `import { mockLocation } from "@odoo/hoot";` */
export const mockLocation = _network.mockLocation;
/** @deprecated use `import { mockWebSocket } from "@odoo/hoot";` */
export const mockWebSocket = _network.mockWebSocket;
/** @deprecated use `import { mockWorker } from "@odoo/hoot";` */
export const mockWorker = _network.mockWorker;
/** @deprecated use `import { flushNotifications } from "@odoo/hoot";` */
export const flushNotifications = _notification.flushNotifications;
/** @deprecated use `import { mockMatchMedia } from "@odoo/hoot";` */
export const mockMatchMedia = _window.mockMatchMedia;
/** @deprecated use `import { mockTouch } from "@odoo/hoot";` */
export const mockTouch = _window.mockTouch;
/** @deprecated use `import { watchAddedNodes } from "@odoo/hoot";` */
export const watchAddedNodes = _window.watchAddedNodes;
/** @deprecated use `import { watchKeys } from "@odoo/hoot";` */
export const watchKeys = _window.watchKeys;
/** @deprecated use `import { watchListeners } from "@odoo/hoot";` */
export const watchListeners = _window.watchListeners;

View file

@ -3,11 +3,29 @@
import { logger } from "./core/logger";
import { Runner } from "./core/runner";
import { urlParams } from "./core/url";
import { makeRuntimeHook } from "./hoot_utils";
import { copyAndBind, makeRuntimeHook } from "./hoot_utils";
import { setRunner } from "./main_runner";
import { setupHootUI } from "./ui/setup_hoot_ui";
/**
* @typedef {import("../hoot-dom/helpers/dom").Dimensions} Dimensions
* @typedef {import("../hoot-dom/helpers/dom").FormatXmlOptions} FormatXmlOptions
* @typedef {import("../hoot-dom/helpers/dom").Position} Position
* @typedef {import("../hoot-dom/helpers/dom").QueryOptions} QueryOptions
* @typedef {import("../hoot-dom/helpers/dom").QueryRectOptions} QueryRectOptions
* @typedef {import("../hoot-dom/helpers/dom").QueryTextOptions} QueryTextOptions
* @typedef {import("../hoot-dom/helpers/dom").Target} Target
*
* @typedef {import("../hoot-dom/helpers/events").DragHelpers} DragHelpers
* @typedef {import("../hoot-dom/helpers/events").DragOptions} DragOptions
* @typedef {import("../hoot-dom/helpers/events").EventType} EventType
* @typedef {import("../hoot-dom/helpers/events").FillOptions} FillOptions
* @typedef {import("../hoot-dom/helpers/events").InputValue} InputValue
* @typedef {import("../hoot-dom/helpers/events").KeyStrokes} KeyStrokes
* @typedef {import("../hoot-dom/helpers/events").PointerOptions} PointerOptions
*
* @typedef {import("./mock/network").ServerWebSocket} ServerWebSocket
*
* @typedef {{
* runner: Runner;
* ui: import("./ui/setup_hoot_ui").UiState
@ -26,19 +44,12 @@ 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
// Test hooks
export const after = makeRuntimeHook("after");
export const afterEach = makeRuntimeHook("afterEach");
export const before = makeRuntimeHook("before");
@ -48,7 +59,7 @@ export const onError = makeRuntimeHook("onError");
// Fixture
export const getFixture = runner.fixture.get;
// Other functions
// Other test runner functions
export const definePreset = runner.exportFn(runner.definePreset);
export const dryRun = runner.exportFn(runner.dryRun);
export const getCurrent = runner.exportFn(runner.getCurrent);
@ -61,59 +72,102 @@ 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 globals = copyAndBind(globalThis);
export const isHootReady = setupHootUI();
// Mock
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, withFetch } from "./mock/network";
export { flushNotifications } from "./mock/notification";
export {
mockMatchMedia,
mockTouch,
watchAddedNodes,
watchKeys,
watchListeners,
} from "./mock/window";
// HOOT-DOM
export {
advanceFrame,
advanceTime,
animationFrame,
cancelAllTimers,
check,
clear,
click,
dblclick,
Deferred,
delay,
drag,
edit,
fill,
formatXml,
freezeTime,
getActiveElement,
getFocusableElements,
getNextFocusableElement,
getParentFrame,
getPreviousFocusableElement,
hover,
isDisplayed,
isEditable,
isFocusable,
isInDOM,
isInViewPort,
isScrollable,
isVisible,
keyDown,
keyUp,
leave,
manuallyDispatchProgrammaticEvent,
matches,
microTick,
middleClick,
on,
pointerDown,
pointerUp,
press,
queryAll,
queryAllAttributes,
queryAllProperties,
queryAllRects,
queryAllTexts,
queryAllValues,
queryAny,
queryAttribute,
queryFirst,
queryOne,
queryRect,
queryText,
queryValue,
resize,
rightClick,
runAllTimers,
scroll,
select,
setFrameRate,
setInputFiles,
setInputRange,
tick,
uncheck,
unfreezeTime,
unload,
waitFor,
waitForNone,
waitUntil,
} from "@odoo/hoot-dom";
// Debug
export { exposeHelpers } from "../hoot-dom/hoot_dom_utils";
export const __debug__ = runner;
export const isHootReady = setupHootUI();
/**
* @param {...unknown} values
*/
export function registerDebugInfo(...values) {
logger.logDebug(...values);
}

View file

@ -6,6 +6,7 @@ import { isNode } from "@web/../lib/hoot-dom/helpers/dom";
import {
isInstanceOf,
isIterable,
isPromise,
parseRegExp,
R_WHITE_SPACE,
toSelector,
@ -114,6 +115,7 @@ const {
TypeError,
URL,
URLSearchParams,
WeakMap,
WeakSet,
window,
} = globalThis;
@ -165,10 +167,21 @@ function getGenericSerializer(value) {
}
function makeObjectCache() {
const cache = new Set();
const cache = new WeakSet();
return {
add: (...values) => values.forEach((value) => cache.add(value)),
has: (...values) => values.every((value) => cache.has(value)),
add: (...values) => {
for (const value of values) {
cache.add(value);
}
},
has: (...values) => {
for (const value of values) {
if (!cache.has(value)) {
return false;
}
}
return true;
},
};
}
@ -178,11 +191,7 @@ function makeObjectCache() {
* @returns {T}
*/
function resolve(value) {
if (typeof value === "function") {
return value();
} else {
return value;
}
return typeof value === "function" ? value() : value;
}
/**
@ -205,6 +214,51 @@ function truncate(value, length = MAX_HUMAN_READABLE_SIZE) {
return strValue.length <= length ? strValue : strValue.slice(0, length) + ELLIPSIS;
}
/**
* @template T
* @param {T} value
* @param {ReturnType<makeObjectCache>} cache
* @returns {T}
*/
function _deepCopy(value, cache) {
if (!value) {
return value;
}
if (typeof value === "function") {
if (value.name) {
return `<function ${value.name}>`;
} else {
return "<anonymous function>";
}
}
if (typeof value === "object" && !Markup.isMarkup(value)) {
if (isInstanceOf(value, String, Number, Boolean)) {
return value;
}
if (isNode(value)) {
// Nodes
return value.cloneNode(true);
} else if (isInstanceOf(value, Date, RegExp)) {
// Dates & regular expressions
return new (getConstructor(value))(value);
} else if (isIterable(value)) {
const isArray = $isArray(value);
const valueArray = isArray ? value : [...value];
// Iterables
const values = valueArray.map((item) => _deepCopy(item, cache));
return $isArray(value) ? values : new (getConstructor(value))(values);
} else {
// Other objects
if (cache.has(value)) {
return S_CIRCULAR;
}
cache.add(value);
return $fromEntries($ownKeys(value).map((key) => [key, _deepCopy(value[key], cache)]));
}
}
return value;
}
/**
* @param {unknown} a
* @param {unknown} b
@ -521,6 +575,8 @@ class QueryPartialString extends QueryString {
compareFn = getFuzzyScore;
}
const EMPTY_CONSTRUCTOR = { name: null };
/** @type {Map<Function, (value: unknown) => string>} */
const GENERIC_SERIALIZERS = new Map([
[BigInt, (v) => v.valueOf()],
@ -551,10 +607,12 @@ const R_NAMED_FUNCTION = /^\s*(async\s+)?function/;
const R_INVISIBLE_CHARACTERS = /[\u00a0\u200b-\u200d\ufeff]/g;
const R_OBJECT = /^\[object ([\w-]+)\]$/;
const labelObjects = new WeakSet();
const objectConstructors = new Map();
/** @type {(KeyboardEventInit & { callback: (ev: KeyboardEvent) => any })[]} */
const hootKeys = [];
const labelObjects = new WeakSet();
const objectConstructors = new Map();
/** @type {WeakMap<unknown, unknown>} */
const syncValues = new WeakMap();
const windowTarget = {
addEventListener: window.addEventListener.bind(window),
removeEventListener: window.removeEventListener.bind(window),
@ -597,6 +655,22 @@ export function callHootKey(ev) {
}
}
/**
* @template T
* @param {T} object
* @returns {T}
*/
export function copyAndBind(object) {
const copy = {};
for (const [key, desc] of $entries($getOwnPropertyDescriptors(object))) {
if (key !== "constructor" && typeof desc.value === "function") {
desc.value = desc.value.bind(object);
}
$defineProperty(copy, key, desc);
}
return copy;
}
/**
* @template {(previous: any, ...args: any[]) => any} T
* @param {T} instanceGetter
@ -666,6 +740,7 @@ export function createReporting(parentReporting) {
const reporting = reactive({
assertions: 0,
duration: 0,
failed: 0,
passed: 0,
skipped: 0,
@ -714,45 +789,6 @@ export function createMock(target, descriptors) {
return mock;
}
/**
* @template T
* @param {T} value
* @returns {T}
*/
export function deepCopy(value) {
if (!value) {
return value;
}
if (typeof value === "function") {
if (value.name) {
return `<function ${value.name}>`;
} else {
return "<anonymous function>";
}
}
if (typeof value === "object" && !Markup.isMarkup(value)) {
if (isInstanceOf(value, String, Number, Boolean)) {
return value;
}
if (isNode(value)) {
// Nodes
return value.cloneNode(true);
} else if (isInstanceOf(value, Date, RegExp)) {
// Dates & regular expressions
return new (getConstructor(value))(value);
} else if (isIterable(value)) {
// Iterables
const values = [...value].map(deepCopy);
return $isArray(value) ? values : new (getConstructor(value))(values);
} else {
// Other objects
return $fromEntries($ownKeys(value).map((key) => [key, deepCopy(value[key])]));
}
}
return value;
}
/**
* @template {(...args: any[]) => any} T
* @param {T} fn
@ -801,6 +837,15 @@ export function debounce(fn, delay) {
}[name];
}
/**
* @template T
* @param {T} value
* @returns {T}
*/
export function deepCopy(value) {
return _deepCopy(value, makeObjectCache());
}
/**
* @param {unknown} a
* @param {unknown} b
@ -955,7 +1000,7 @@ export function generateHash(...strings) {
export function getConstructor(value) {
const { constructor } = value;
if (constructor !== Object) {
return constructor || { name: null };
return constructor || EMPTY_CONSTRUCTOR;
}
const str = value.toString();
const match = str.match(R_OBJECT);
@ -1021,6 +1066,30 @@ export function getFuzzyScore(pattern, string) {
return score;
}
/**
* Returns the value associated to the given object.
* If 'toStringValue' is set, the result will concatenate any inner object that
* also has an associated sync value. This is typically useful for nested Blobs.
*
* @param {unknown} object
* @param {boolean} toStringValue
*/
export function getSyncValue(object, toStringValue) {
const result = syncValues.get(object);
if (!toStringValue) {
return result;
}
let textResult = "";
if (isIterable(result)) {
for (const part of result) {
textResult += syncValues.has(part) ? getSyncValue(part, toStringValue) : String(part);
}
} else {
textResult += String(result);
}
return textResult;
}
/**
* @param {unknown} value
* @returns {ArgumentType}
@ -1389,6 +1458,14 @@ export async function paste() {
}
}
/**
* @param {unknown} object
* @param {unknown} value
*/
export function setSyncValue(object, value) {
syncValues.set(object, value);
}
/**
* @param {string} key
*/
@ -1591,7 +1668,7 @@ export class Callbacks {
* @param {boolean} [once]
*/
add(type, callback, once) {
if (isInstanceOf(callback, Promise)) {
if (isPromise(callback)) {
const promiseValue = callback;
callback = function waitForPromise() {
return Promise.resolve(promiseValue).then(resolve);
@ -1994,9 +2071,12 @@ export const INCLUDE_LEVEL = {
};
export const MIME_TYPE = {
formData: "multipart/form-data",
blob: "application/octet-stream",
html: "text/html",
json: "application/json",
text: "text/plain",
xml: "text/xml",
};
export const STORAGE = {
@ -2006,6 +2086,7 @@ export const STORAGE = {
};
export const S_ANY = Symbol("any value");
export const S_CIRCULAR = Symbol("circular object");
export const S_NONE = Symbol("no value");
export const R_QUERY_EXACT = new RegExp(

View file

@ -11,6 +11,15 @@ let runner;
// Exports
//-----------------------------------------------------------------------------
/**
* @param {string} funcName
*/
export function ensureTest(funcName) {
if (!runner?.getCurrent().test) {
throw new Error(`Cannot call '${funcName}' from outside a test`);
}
}
export function getRunner() {
return runner;
}

View file

@ -2,6 +2,7 @@
import { on } from "@odoo/hoot-dom";
import { MockEventTarget } from "../hoot_utils";
import { ensureTest } from "../main_runner";
//-----------------------------------------------------------------------------
// Global
@ -95,6 +96,7 @@ export function cleanupAnimations() {
* @param {boolean} [enable=false]
*/
export function disableAnimations(enable = false) {
ensureTest("disableAnimations");
allowAnimations = enable;
}
@ -105,6 +107,7 @@ export function disableAnimations(enable = false) {
* @param {boolean} [enable=true]
*/
export function enableTransitions(enable = true) {
ensureTest("enableTransitions");
allowTransitions = enable;
animationChangeBus.dispatchEvent(new CustomEvent("toggle-transitions"));
}

View file

@ -1,22 +1,71 @@
/** @odoo-module */
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
Math: { random: $random, floor: $floor },
TextEncoder,
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/** @type {SubtleCrypto["decrypt"]} */
function decrypt(algorithm, key, data) {
return Promise.resolve($encode(data.replace("encrypted data:", "")));
}
/** @type {SubtleCrypto["encrypt"]} */
function encrypt(algorithm, key, data) {
return Promise.resolve(`encrypted data:${$decode(data)}`);
}
/** @type {Crypto["getRandomValues"]} */
function getRandomValues(array) {
for (let i = 0; i < array.length; i++) {
array[i] = $floor($random() * 256);
}
return array;
}
/** @type {SubtleCrypto["importKey"]} */
function importKey(format, keyData, algorithm, extractable, keyUsages) {
if (arguments.length < 5) {
throw new TypeError(
`Failed to execute 'importKey' on 'SubtleCrypto': 5 arguments required, but only ${arguments.length} present.`
);
}
if (!keyData || keyData.length === 0) {
throw new TypeError(
`Failed to execute 'importKey' on 'SubtleCrypto': The provided value cannot be converted to a sequence.`
);
}
const key = Symbol([algorithm, String(extractable), "secret", ...keyUsages].join("/"));
return Promise.resolve(key);
}
function randomUUID() {
return [4, 2, 2, 2, 6]
.map((length) => getRandomValues(new Uint8Array(length)).toHex())
.join("-");
}
const $encode = TextEncoder.prototype.encode.bind(new TextEncoder());
const $decode = TextDecoder.prototype.decode.bind(new TextDecoder("utf-8"));
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
export const mockCrypto = {
getRandomValues,
randomUUID,
subtle: {
importKey: (_format, keyData, _algorithm, _extractable, _keyUsages) => {
if (!keyData || keyData.length === 0) {
throw Error(`KeyData is mandatory`);
}
return Promise.resolve("I'm a key");
},
encrypt: (_algorithm, _key, data) =>
Promise.resolve(`encrypted data:${new TextDecoder().decode(data)}`),
decrypt: (_algorithm, _key, data) =>
Promise.resolve(new TextEncoder().encode(data.replace("encrypted data:", ""))),
},
getRandomValues: (typedArray) => {
typedArray.forEach((_element, index) => {
typedArray[index] = Math.round(Math.random() * 100);
});
return typedArray;
decrypt,
encrypt,
importKey,
},
};

View file

@ -2,6 +2,7 @@
import { getTimeOffset, isTimeFrozen, resetTimeOffset } from "@web/../lib/hoot-dom/helpers/time";
import { createMock, HootError, isNil } from "../hoot_utils";
import { ensureTest } from "../main_runner";
/**
* @typedef DateSpecs
@ -161,7 +162,7 @@ export function cleanupDate() {
* @see {@link mockTimeZone} for the time zone params.
*
* @param {string | DateSpecs} [date]
* @param {string | number | null} [tz]
* @param {string | number | null} [tz]
* @example
* mockDate("2023-12-25T20:45:00"); // 2023-12-25 20:45:00 UTC
* @example
@ -170,6 +171,7 @@ export function cleanupDate() {
* mockDate("2019-02-11 09:30:00.001", +2);
*/
export function mockDate(date, tz) {
ensureTest("mockDate");
setDateParams(date ? parseDateParams(date) : DEFAULT_DATE);
if (!isNil(tz)) {
setTimeZone(tz);
@ -187,6 +189,7 @@ export function mockDate(date, tz) {
* mockTimeZone("ja-JP"); // UTC + 9
*/
export function mockLocale(newLocale) {
ensureTest("mockLocale");
locale = newLocale;
if (!isNil(locale) && isNil(timeZoneName)) {
@ -213,6 +216,7 @@ export function mockLocale(newLocale) {
* mockTimeZone(null) // Resets to test default (+1)
*/
export function mockTimeZone(tz) {
ensureTest("mockTimeZone");
setTimeZone(tz);
}

View file

@ -1,8 +1,15 @@
/** @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";
import {
createMock,
getSyncValue,
HootError,
MIME_TYPE,
MockEventTarget,
setSyncValue,
} from "../hoot_utils";
import { ensureTest } from "../main_runner";
/**
* @typedef {"android" | "ios" | "linux" | "mac" | "windows"} Platform
@ -220,7 +227,7 @@ export class MockClipboardItem extends ClipboardItem {
// Added synchronous methods to enhance speed in tests
async getType(type) {
return getSyncValue(this)[type];
return getSyncValue(this, false)[type];
}
}
@ -264,18 +271,137 @@ export class MockPermissionStatus extends MockEventTarget {
}
}
export class MockServiceWorker extends MockEventTarget {
static publicListeners = ["error", "message"];
/** @type {ServiceWorkerState} */
state = "activated";
/**
* @param {string} scriptURL
*/
constructor(scriptURL) {
super(...arguments);
/** @type {string} */
this.scriptURL = scriptURL;
}
/**
* @param {any} _message
*/
postMessage(_message) {}
}
export class MockServiceWorkerContainer extends MockEventTarget {
static publicListeners = ["controllerchange", "message", "messageerror"];
/**
* @private
*/
_readyResolved = false;
/**
* @private
* @type {Map<string, MockServiceWorkerRegistration>}
*/
_registrations = new Map();
/** @type {MockServiceWorker | null} */
controller = null;
get ready() {
return this._readyPromise;
}
constructor() {
super(...arguments);
const { promise, resolve } = Promise.withResolvers();
/**
* @type {Promise<MockServiceWorkerRegistration>}
* @private
*/
this._readyPromise = promise;
/**
* @type {(value: MockServiceWorkerRegistration) => void}
* @private
*/
this._resolveReady = resolve;
}
async getRegistration(scope = "/") {
return this._registrations.get(scope);
}
async getRegistrations() {
return Array.from(this._registrations.values());
}
/**
* @param {string} scriptURL
* @param {{ scope?: string }} [options]
*/
async register(scriptURL, options = {}) {
const scope = options.scope ?? "/";
const registration = new MockServiceWorkerRegistration(scriptURL, scope);
this._registrations.set(scope, registration);
if (!this.controller) {
this.controller = registration.active;
this.dispatchEvent(new Event("controllerchange"));
}
if (!this._readyResolved) {
this._readyResolved = true;
this._resolveReady(registration);
}
return registration;
}
startMessages() {}
}
export class MockServiceWorkerRegistration {
/** @type {MockServiceWorker | null} */
installing = null;
/** @type {MockServiceWorker | null} */
waiting = null;
/**
* @param {string} scriptURL
* @param {string} scope
*/
constructor(scriptURL, scope) {
/** @type {MockServiceWorker | null} */
this.active = new MockServiceWorker(scriptURL);
/** @type {string} */
this.scope = scope;
}
async unregister() {
return true;
}
async update() {}
}
export const currentPermissions = getPermissions();
export const mockClipboard = new MockClipboard();
export const mockPermissions = new MockPermissions();
export const mockServiceWorker = new MockServiceWorkerContainer();
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 },
serviceWorker: { value: mockServiceWorker },
userAgent: { get: () => mockValues.userAgent },
vibrate: { get: () => mockValues.vibrate },
});
@ -291,6 +417,7 @@ export function cleanupNavigator() {
* @param {PermissionState} [value]
*/
export function mockPermission(name, value) {
ensureTest("mockPermission");
if (!(name in currentPermissions)) {
throw new TypeError(
`The provided value '${name}' is not a valid enum value of type PermissionName`
@ -310,6 +437,7 @@ export function mockPermission(name, value) {
* @param {Navigator["sendBeacon"]} callback
*/
export function mockSendBeacon(callback) {
ensureTest("mockSendBeacon");
mockValues.sendBeacon = callback;
}
@ -317,6 +445,7 @@ export function mockSendBeacon(callback) {
* @param {Platform} platform
*/
export function mockUserAgent(platform = "linux") {
ensureTest("mockUserAgent");
mockValues.userAgent = makeUserAgent(platform);
}
@ -324,5 +453,6 @@ export function mockUserAgent(platform = "linux") {
* @param {Navigator["vibrate"]} callback
*/
export function mockVibrate(callback) {
ensureTest("mockVibrate");
mockValues.vibrate = callback;
}

View file

@ -7,10 +7,22 @@ import {
} from "@web/../lib/hoot-dom/helpers/time";
import { isInstanceOf } from "../../hoot-dom/hoot_dom_utils";
import { makeNetworkLogger } from "../core/logger";
import { ensureArray, MIME_TYPE, MockEventTarget } from "../hoot_utils";
import { getSyncValue, MockBlob, setSyncValue } from "./sync_values";
import {
ensureArray,
getSyncValue,
isNil,
MIME_TYPE,
MockEventTarget,
setSyncValue,
} from "../hoot_utils";
import { ensureTest } from "../main_runner";
/**
* @typedef {ResponseInit & {
* type?: ResponseType;
* url?: string;
* }} MockResponseInit
*
* @typedef {AbortController
* | MockBroadcastChannel
* | MockMessageChannel
@ -28,19 +40,32 @@ import { getSyncValue, MockBlob, setSyncValue } from "./sync_values";
const {
AbortController,
Blob,
BroadcastChannel,
document,
fetch,
FormData,
Headers,
Map,
Math: { floor: $floor, max: $max, min: $min, random: $random },
Object: { assign: $assign, create: $create, entries: $entries },
Object: {
assign: $assign,
create: $create,
defineProperty: $defineProperty,
entries: $entries,
},
ProgressEvent,
ReadableStream,
Request,
Response,
Set,
TextEncoder,
Uint8Array,
URL,
WebSocket,
XMLHttpRequest,
} = globalThis;
const { parse: $parse, stringify: $stringify } = globalThis.JSON;
//-----------------------------------------------------------------------------
// Internal
@ -86,6 +111,34 @@ async function dispatchMessage(target, data, transfer) {
}
}
/**
*
* @param {{ headers?: HeadersInit } | undefined} object
* @param {string} content
*/
function getHeaders(object, content) {
/** @type {Headers} */
let headers;
if (isInstanceOf(object?.headers, Headers)) {
headers = object.headers;
} else {
headers = new Headers(object?.headers);
}
if (content && !headers.has(HEADER.contentType)) {
if (typeof content === "string") {
headers.set(HEADER.contentType, MIME_TYPE.text);
} else if (isInstanceOf(content, Blob)) {
headers.set(HEADER.contentType, MIME_TYPE.blob);
} else if (isInstanceOf(content, FormData)) {
headers.set(HEADER.contentType, MIME_TYPE.formData);
} else {
headers.set(HEADER.contentType, MIME_TYPE.json);
}
}
return headers;
}
/**
* @param {...NetworkInstance} instances
*/
@ -111,6 +164,37 @@ function markOpen(instance) {
return instance;
}
/**
* Helper used to parse JSON-RPC request/response parameters, and to make their
* "jsonrpc", "id" and "method" properties non-enumerable, as to make them more
* inconspicuous in console logs, effectively highlighting the 'params' or 'result'
* keys.
*
* @param {string} stringParams
*/
function parseJsonRpcParams(stringParams) {
const jsonParams = $assign($create(null), $parse(stringParams));
if (jsonParams && "jsonrpc" in jsonParams) {
$defineProperty(jsonParams, "jsonrpc", {
value: jsonParams.jsonrpc,
enumerable: false,
});
if ("id" in jsonParams) {
$defineProperty(jsonParams, "id", {
value: jsonParams.id,
enumerable: false,
});
}
if ("method" in jsonParams) {
$defineProperty(jsonParams, "method", {
value: jsonParams.method,
enumerable: false,
});
}
}
return jsonParams;
}
/**
* @param {number} min
* @param {number} [max]
@ -130,15 +214,50 @@ function parseNetworkDelay(min, max) {
}
}
/**
* @param {Uint8Array<ArrayBuffer> | string} value
* @returns {Uint8Array<ArrayBuffer>}
*/
function toBytes(value) {
return isInstanceOf(value, Uint8Array) ? value : new TextEncoder().encode(value);
}
const DEFAULT_URL = "https://www.hoot.test/";
const ENDLESS_PROMISE = new Promise(() => {});
const HEADER = {
contentLength: "Content-Length",
contentType: "Content-Type",
};
const R_EQUAL = /\s*=\s*/;
const R_INTERNAL_URL = /^(blob|data|file):/;
const R_INTERNAL_URL = /^(blob|data):/;
const R_SEMICOLON = /\s*;\s*/;
const requestResponseMixin = {
async arrayBuffer() {
return toBytes(this._readValue("arrayBuffer", true)).buffer;
},
async blob() {
const value = this._readValue("blob", false);
return isInstanceOf(value, Blob) ? value : new MockBlob([value]);
},
async bytes() {
return toBytes(this._readValue("bytes", true));
},
async formData() {
const data = this._readValue("formData", false);
if (!isInstanceOf(data, FormData)) {
throw new TypeError("Failed to fetch");
}
return data;
},
async json() {
return $parse(this._readValue("json", true));
},
async text() {
return this._readValue("text", true);
},
};
/** @type {Set<NetworkInstance>} */
const openNetworkInstances = new Set();
@ -148,8 +267,8 @@ let getNetworkDelay = null;
let mockFetchFn = null;
/** @type {((websocket: ServerWebSocket) => any) | null} */
let mockWebSocketConnection = null;
/** @type {Array<(worker: MockSharedWorker | MockWorker) => any>} */
let mockWorkerConnection = [];
/** @type {((worker: MockSharedWorker | MockWorker) => any)[]} */
const mockWorkerConnections = [];
//-----------------------------------------------------------------------------
// Exports
@ -159,7 +278,7 @@ export function cleanupNetwork() {
// Mocked functions
mockFetchFn = null;
mockWebSocketConnection = null;
mockWorkerConnection = [];
mockWorkerConnections.length = 0;
// Network instances
for (const instance of openNetworkInstances) {
@ -194,33 +313,57 @@ export function cleanupNetwork() {
/** @type {typeof fetch} */
export async function mockedFetch(input, init) {
if (R_INTERNAL_URL.test(input)) {
// Internal URL: directly handled by the browser
return fetch(input, init);
}
const strInput = String(input);
const isInternalUrl = R_INTERNAL_URL.test(strInput);
if (!mockFetchFn) {
throw new Error("Can't make a request when fetch is not mocked");
if (isInternalUrl) {
// Internal URL without mocked 'fetch': directly handled by the browser
return fetch(input, init);
}
throw new Error(
`Could not fetch "${strInput}": cannot make a request when fetch is not mocked`
);
}
init ||= {};
const method = init.method?.toUpperCase() || (init.body ? "POST" : "GET");
const { logRequest, logResponse } = makeNetworkLogger(method, input);
const controller = markOpen(new AbortController());
init.signal = controller.signal;
logRequest(() => (typeof init.body === "string" ? JSON.parse(init.body) : init));
init = { ...init };
init.headers = getHeaders(init, init.body);
init.method = init.method?.toUpperCase() || (isNil(init.body) ? "GET" : "POST");
// Allows 'signal' to not be logged with 'logRequest'.
$defineProperty(init, "signal", {
value: controller.signal,
enumerable: false,
});
const { logRequest, logResponse } = makeNetworkLogger(init.method, strInput);
logRequest(() => {
const readableInit = {
...init,
// Make headers easier to read in the console
headers: new Map(init.headers),
};
if (init.headers.get(HEADER.contentType) === MIME_TYPE.json) {
return [parseJsonRpcParams(init.body), readableInit];
} else {
return [init.body, readableInit];
}
});
if (getNetworkDelay) {
await getNetworkDelay();
}
// keep separate from 'error', as it can be null or undefined even though the
// callback has thrown an error.
let failed = false;
let result;
let error, result;
try {
result = await mockFetchFn(input, init);
} catch (err) {
result = err;
failed = true;
error = err;
}
if (isOpen(controller)) {
markClosed(controller);
@ -228,33 +371,34 @@ export async function mockedFetch(input, init) {
return ENDLESS_PROMISE;
}
if (failed) {
throw result;
throw error;
}
/** @type {Headers} */
let headers;
if (result && isInstanceOf(result.headers, Headers)) {
headers = result.headers;
} else if (isInstanceOf(init.headers, Headers)) {
headers = init.headers;
} else {
headers = new Headers(init.headers);
if (isInternalUrl && isNil(result)) {
// Internal URL without mocked result: directly handled by the browser
return fetch(input, init);
}
let contentType = headers.get(HEADER.contentType);
// Result can be a request or the final request value
const responseHeaders = getHeaders(result, result);
if (result instanceof MockResponse) {
// Mocked response
logResponse(async () => {
const textValue = getSyncValue(result);
return contentType === MIME_TYPE.json ? JSON.parse(textValue) : textValue;
logResponse(() => {
const textValue = getSyncValue(result, true);
return [
responseHeaders.get(HEADER.contentType) === MIME_TYPE.json
? parseJsonRpcParams(textValue)
: textValue,
result,
];
});
return result;
}
if (isInstanceOf(result, Response)) {
// Actual fetch
logResponse(() => "(go to network tab for request content)");
logResponse(() => ["(go to network tab for request content)", result]);
return result;
}
@ -262,26 +406,28 @@ export async function mockedFetch(input, init) {
// Determine the return type based on:
// - the content type header
// - or the type of the returned value
if (!contentType) {
if (typeof result === "string") {
contentType = MIME_TYPE.text;
} else if (isInstanceOf(result, Blob)) {
contentType = MIME_TYPE.blob;
} else {
contentType = MIME_TYPE.json;
}
}
if (contentType === MIME_TYPE.json) {
if (responseHeaders.get(HEADER.contentType) === MIME_TYPE.json) {
// JSON response
const strBody = JSON.stringify(result ?? null);
logResponse(() => result);
return new MockResponse(strBody, { [HEADER.contentType]: contentType });
const strBody = $stringify(result ?? null);
const response = new MockResponse(strBody, {
headers: responseHeaders,
statusText: "OK",
type: "basic",
url: strInput,
});
logResponse(() => [parseJsonRpcParams(strBody), response]);
return response;
}
// Any other type (blob / text)
logResponse(() => result);
return new MockResponse(result, { [HEADER.contentType]: contentType });
// Any other type
const response = new MockResponse(result, {
headers: responseHeaders,
statusText: "OK",
type: "basic",
url: strInput,
});
logResponse(() => [result, response]);
return response;
}
/**
@ -310,6 +456,7 @@ export async function mockedFetch(input, init) {
* });
*/
export function mockFetch(fetchFn) {
ensureTest("mockFetch");
mockFetchFn = fetchFn;
}
@ -321,6 +468,7 @@ export function mockFetch(fetchFn) {
* @param {typeof mockWebSocketConnection} [onWebSocketConnected]
*/
export function mockWebSocket(onWebSocketConnected) {
ensureTest("mockWebSocket");
mockWebSocketConnection = onWebSocketConnected;
}
@ -330,7 +478,7 @@ export function mockWebSocket(onWebSocketConnected) {
* (see {@link mockFetch});
* - the `onWorkerConnected` callback will be called after a worker has been created.
*
* @param {typeof mockWorkerConnection} [onWorkerConnected]
* @param {typeof mockWorkerConnections[number]} [onWorkerConnected]
* @example
* mockWorker((worker) => {
* worker.addEventListener("message", (event) => {
@ -339,7 +487,8 @@ export function mockWebSocket(onWebSocketConnected) {
* });
*/
export function mockWorker(onWorkerConnected) {
mockWorkerConnection.push(onWorkerConnected);
ensureTest("mockWorker");
mockWorkerConnections.push(onWorkerConnected);
}
/**
@ -349,6 +498,42 @@ export function throttleNetwork(...args) {
getNetworkDelay = parseNetworkDelay(...args);
}
/**
* @param {typeof mockFetchFn} fetchFn
* @param {() => void} callback
*/
export async function withFetch(fetchFn, callback) {
mockFetchFn = fetchFn;
const result = await callback();
mockFetchFn = null;
return result;
}
export class MockBlob extends Blob {
constructor(blobParts, options) {
super(blobParts, options);
setSyncValue(this, blobParts);
}
async arrayBuffer() {
return toBytes(getSyncValue(this, true)).buffer;
}
async bytes() {
return toBytes(getSyncValue(this, true));
}
async stream() {
const value = getSyncValue(this, true);
return isInstanceOf(value, ReadableStream) ? value : new ReadableStream(value);
}
async text() {
return getSyncValue(this, true);
}
}
export class MockBroadcastChannel extends BroadcastChannel {
constructor() {
super(...arguments);
@ -650,58 +835,91 @@ export class MockMessagePort extends MockEventTarget {
}
export class MockRequest extends Request {
static {
Object.assign(this.prototype, requestResponseMixin);
}
/**
* @param {RequestInfo} input
* @param {RequestInit} [init]
*/
constructor(input, init) {
super(input, init);
super(new MockURL(input), init);
setSyncValue(this, init?.body ?? null);
}
async arrayBuffer() {
return new TextEncoder().encode(getSyncValue(this));
clone() {
const request = new this.constructor(this.url, this);
setSyncValue(request, getSyncValue(this, false));
return request;
}
async blob() {
return new MockBlob([getSyncValue(this)]);
}
async json() {
return JSON.parse(getSyncValue(this));
}
async text() {
return getSyncValue(this);
/**
* In tests, requests objects are expected to be read by multiple network handlers.
* As such, their 'body' isn't consumed upon reading.
*
* @protected
* @param {string} reader
* @param {boolean} toStringValue
*/
_readValue(reader, toStringValue) {
return getSyncValue(this, toStringValue);
}
}
export class MockResponse extends Response {
static {
Object.assign(this.prototype, requestResponseMixin);
}
/**
* @param {BodyInit} body
* @param {ResponseInit} [init]
* @param {MockResponseInit} [init]
*/
constructor(body, init) {
super(body, init);
if (init?.type) {
$defineProperty(this, "type", {
value: init.type,
configurable: true,
enumerable: true,
writable: false,
});
}
if (init?.url) {
$defineProperty(this, "url", {
value: String(new MockURL(init.url)),
configurable: true,
enumerable: true,
writable: false,
});
}
setSyncValue(this, body ?? null);
}
async arrayBuffer() {
return new TextEncoder().encode(getSyncValue(this)).buffer;
clone() {
return new this.constructor(getSyncValue(this, false), this);
}
async blob() {
return new MockBlob([getSyncValue(this)]);
}
async json() {
return JSON.parse(getSyncValue(this));
}
async text() {
return getSyncValue(this);
/**
* Reading the 'body' of a response always consumes it, as opposed to the {@link MockRequest}
* body.
*
* @protected
* @param {string} reader
* @param {boolean} toStringValue
*/
_readValue(reader, toStringValue) {
if (this.bodyUsed) {
throw new TypeError(
`Failed to execute '${reader}' on '${this.constructor.name}': body stream already read`
);
}
$defineProperty(this, "bodyUsed", { value: true, configurable: true, enumerable: true });
return getSyncValue(this, toStringValue);
}
}
@ -732,7 +950,7 @@ export class MockSharedWorker extends MockEventTarget {
// First port has to be started manually
this._messageChannel.port2.start();
for (const onWorkerConnected of mockWorkerConnection) {
for (const onWorkerConnected of mockWorkerConnections) {
onWorkerConnected(this);
}
}
@ -745,6 +963,10 @@ export class MockURL extends URL {
}
export class MockWebSocket extends MockEventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static publicListeners = ["close", "error", "message", "open"];
/**
@ -788,7 +1010,7 @@ export class MockWebSocket extends MockEventTarget {
markOpen(this);
this._readyState = WebSocket.OPEN;
this._logger.logRequest(() => "connection open");
this._logger.logRequest(() => ["connection open"]);
this.dispatchEvent(new Event("open"));
});
@ -808,7 +1030,7 @@ export class MockWebSocket extends MockEventTarget {
if (this.readyState !== WebSocket.OPEN) {
return;
}
this._logger.logRequest(() => data);
this._logger.logRequest(() => [data]);
dispatchMessage(this._serverWs, data);
}
}
@ -839,7 +1061,7 @@ export class MockWorker extends MockEventTarget {
this.dispatchEvent(new MessageEvent("message", { data: ev.data }));
});
for (const onWorkerConnected of mockWorkerConnection) {
for (const onWorkerConnected of mockWorkerConnections) {
onWorkerConnected(this);
}
}
@ -865,11 +1087,11 @@ export class MockWorker extends MockEventTarget {
export class MockXMLHttpRequest extends MockEventTarget {
static publicListeners = ["error", "load"];
static {
// Assign status codes
Object.assign(this, XMLHttpRequest);
}
/**
* @private
*/
_headers = {};
/**
* @private
*/
@ -877,28 +1099,72 @@ export class MockXMLHttpRequest extends MockEventTarget {
/**
* @private
*/
_readyState = XMLHttpRequest.UNSENT;
/**
* @type {Record<string, string>}
* @private
*/
_requestHeaders = Object.create(null);
/**
* @private
*/
_requestUrl = "";
/**
* @type {Response | null}
* @private
*/
_response = null;
/**
* @private
*/
_status = XMLHttpRequest.UNSENT;
_responseMimeType = "";
/**
* @private
*/
_url = "";
_responseValue = null;
abort() {
markClosed(this);
get readyState() {
return this._readyState;
}
upload = new MockXMLHttpRequestUpload();
get response() {
return this._response;
return this._responseValue;
}
get responseText() {
return String(this._responseValue);
}
get responseURL() {
return this._response.url;
}
get responseXML() {
const parser = new DOMParser();
try {
return parser.parseFromString(this._responseValue, this._responseMimeType);
} catch {
return null;
}
}
get status() {
return this._status;
return this._response?.status || 0;
}
get statusText() {
return this._readyState >= XMLHttpRequest.LOADING ? "OK" : "";
}
/**
* @type {XMLHttpRequestResponseType}
*/
responseType = "";
upload = new MockXMLHttpRequestUpload();
abort() {
this._setReadyState(XMLHttpRequest.DONE);
markClosed(this);
}
/** @type {XMLHttpRequest["dispatchEvent"]} */
@ -909,12 +1175,31 @@ export class MockXMLHttpRequest extends MockEventTarget {
return super.dispatchEvent(event);
}
getAllResponseHeaders() {
let result = "";
for (const [key, value] of this._response?.headers || []) {
result += `${key}: ${value}\r\n`;
}
return result;
}
/** @type {XMLHttpRequest["getResponseHeader"]} */
getResponseHeader(name) {
return this._response?.headers.get(name) || "";
}
/** @type {XMLHttpRequest["open"]} */
open(method, url) {
markOpen(this);
this._method = method;
this._url = url;
this._requestUrl = url;
this._setReadyState(XMLHttpRequest.OPENED);
}
/** @type {XMLHttpRequest["overrideMimeType"]} */
overrideMimeType(mime) {
this._responseMimeType = mime;
}
/** @type {XMLHttpRequest["send"]} */
@ -922,35 +1207,56 @@ export class MockXMLHttpRequest extends MockEventTarget {
if (!isOpen(this)) {
return ENDLESS_PROMISE;
}
this._setReadyState(XMLHttpRequest.HEADERS_RECEIVED);
try {
const response = await window.fetch(this._url, {
this._response = await window.fetch(this._requestUrl, {
method: this._method,
body,
headers: this._headers,
headers: this._requestHeaders,
});
this._status = response.status;
if (new URL(this._url, mockLocation.origin).protocol === "blob:") {
this._response = await response.arrayBuffer();
this._setReadyState(XMLHttpRequest.LOADING);
if (!this._responseMimeType) {
if (this._response.url.startsWith("blob:")) {
this._responseMimeType = MIME_TYPE.blob;
} else {
this._responseMimeType = this._response.headers.get(HEADER.contentType);
}
}
if (this._response instanceof MockResponse) {
// Mock response: get bound value (synchronously)
this._responseValue = getSyncValue(this._response, false);
} else if (this._responseMimeType === MIME_TYPE.blob) {
// Actual "blob:" response: get array buffer
this._responseValue = await this._response.arrayBuffer();
} else if (this._responseMimeType === MIME_TYPE.json) {
// JSON response: get parsed JSON value
this._responseValue = await this._response.json();
} else {
this._response = await response.text();
// Anything else: parse response body as text
this._responseValue = await this._response.text();
}
this.dispatchEvent(new ProgressEvent("load"));
} catch (error) {
this.dispatchEvent(new ProgressEvent("error", { error }));
} catch {
this.dispatchEvent(new ProgressEvent("error"));
}
this._setReadyState(XMLHttpRequest.DONE);
markClosed(this);
}
/** @type {XMLHttpRequest["setRequestHeader"]} */
setRequestHeader(name, value) {
this._headers[name] = value;
this._requestHeaders[name] = value;
}
/** @type {XMLHttpRequest["getResponseHeader"]} */
getResponseHeader(name) {
return this._headers[name];
/**
* @private
* @param {number} readyState
*/
_setReadyState(readyState) {
this._readyState = readyState;
this.dispatchEvent(new Event("readystatechange"));
}
}
@ -989,7 +1295,6 @@ export class ServerWebSocket extends MockEventTarget {
/**
* @param {WebSocket} websocket
* @param {ReturnType<typeof makeNetworkLogger>} logger
* @param {ReturnType<typeof makeNetworkLogger>} logger
*/
constructor(websocket, logger) {
super();
@ -1001,7 +1306,7 @@ export class ServerWebSocket extends MockEventTarget {
this.addEventListener("close", (ev) => {
dispatchClose(this._clientWs, ev);
this._logger.logResponse(() => "connection closed");
this._logger.logResponse(() => ["connection closed", ev]);
});
mockWebSocketConnection?.(this);
@ -1018,7 +1323,7 @@ export class ServerWebSocket extends MockEventTarget {
if (!isOpen(this)) {
return;
}
this._logger.logResponse(() => data);
this._logger.logResponse(() => [data]);
dispatchMessage(this._clientWs, data);
}
}

View file

@ -1,48 +0,0 @@
/** @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

@ -12,7 +12,7 @@ import {
} 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 { ensureTest, getRunner } from "../main_runner";
import {
MockAnimation,
mockedAnimate,
@ -25,9 +25,11 @@ import {
mockedWindowScrollTo,
} from "./animation";
import { MockConsole } from "./console";
import { mockCrypto } from "./crypto";
import { MockDate, MockIntl } from "./date";
import { MockClipboardItem, mockNavigator } from "./navigator";
import {
MockBlob,
MockBroadcastChannel,
MockMessageChannel,
MockMessagePort,
@ -46,8 +48,6 @@ import {
} from "./network";
import { MockNotification } from "./notification";
import { MockStorage } from "./storage";
import { MockBlob } from "./sync_values";
import { mockCrypto } from "./crypto";
//-----------------------------------------------------------------------------
// Global
@ -61,6 +61,7 @@ const {
Object: {
assign: $assign,
defineProperties: $defineProperties,
defineProperty: $defineProperty,
entries: $entries,
getOwnPropertyDescriptor: $getOwnPropertyDescriptor,
getPrototypeOf: $getPrototypeOf,
@ -70,9 +71,11 @@ const {
Reflect: { ownKeys: $ownKeys },
Set,
WeakMap,
WeakSet,
} = globalThis;
const { addEventListener, removeEventListener } = EventTarget.prototype;
const { preventDefault } = Event.prototype;
//-----------------------------------------------------------------------------
// Internal
@ -327,6 +330,11 @@ function mockedMatchMedia(mediaQueryString) {
return new MockMediaQueryList(mediaQueryString);
}
function mockedPreventDefault() {
preventedEvents.add(this);
return preventDefault.call(this, ...arguments);
}
/** @type {typeof removeEventListener} */
function mockedRemoveEventListener(...args) {
if (getRunner().dry) {
@ -420,6 +428,18 @@ class MockMediaQueryList extends MockEventTarget {
}
}
class MockMutationObserver extends MutationObserver {
disconnect() {
activeMutationObservers.delete(this);
return super.disconnect(...arguments);
}
observe() {
activeMutationObservers.add(this);
return super.observe(...arguments);
}
}
const DEFAULT_MEDIA_VALUES = {
"display-mode": "browser",
pointer: "fine",
@ -438,12 +458,16 @@ const R_OWL_SYNTHETIC_LISTENER = /\bnativeToSyntheticEvent\b/;
const originalDescriptors = new WeakMap();
const originalTouchFunctions = getTouchTargets(globalThis).map(getTouchDescriptors);
/** @type {Set<MockMutationObserver>} */
const activeMutationObservers = new Set();
/** @type {Set<MockMediaQueryList>} */
const mediaQueryLists = new Set();
const mockConsole = new MockConsole();
const mockLocalStorage = new MockStorage();
const mockMediaValues = { ...DEFAULT_MEDIA_VALUES };
const mockSessionStorage = new MockStorage();
/** @type {WeakSet<Event>} */
const preventedEvents = new WeakSet();
let mockTitle = "";
// Mock descriptors
@ -493,6 +517,7 @@ const WINDOW_MOCK_DESCRIPTORS = {
matchMedia: { value: mockedMatchMedia },
MessageChannel: { value: MockMessageChannel },
MessagePort: { value: MockMessagePort },
MutationObserver: { value: MockMutationObserver },
navigator: { value: mockNavigator },
Notification: { value: MockNotification },
outerHeight: { get: () => getCurrentDimensions().height },
@ -547,6 +572,11 @@ export function cleanupWindow() {
// Touch
restoreTouch(view);
// Mutation observers
for (const observer of activeMutationObservers) {
observer.disconnect();
}
}
export function getTitle() {
@ -579,19 +609,37 @@ export function getViewPortWidth() {
}
}
/**
* @param {Event} event
*/
export function isPrevented(event) {
return event.defaultPrevented || preventedEvents.has(event);
}
/**
* @param {Record<string, string>} name
*/
export function mockMatchMedia(values) {
ensureTest("mockMatchMedia");
$assign(mockMediaValues, values);
callMediaQueryChanges($keys(values));
}
/**
* @param {Event} event
*/
export function mockPreventDefault(event) {
$defineProperty(event, "preventDefault", {
value: mockedPreventDefault,
});
}
/**
* @param {boolean} setTouch
*/
export function mockTouch(setTouch) {
ensureTest("mockTouch");
const objects = getTouchTargets(getWindow());
if (setTouch) {
for (const object of objects) {
@ -695,13 +743,13 @@ export function watchListeners(view = getWindow()) {
* afterEach(watchKeys(window, ["odoo"]));
*/
export function watchKeys(target, whiteList) {
const acceptedKeys = new Set([...$ownKeys(target), ...(whiteList || [])]);
const acceptedKeys = new Set($ownKeys(target).concat(whiteList || []));
return function checkKeys() {
const keysDiff = $ownKeys(target).filter(
(key) => $isNaN($parseFloat(key)) && !acceptedKeys.has(key)
);
for (const key of keysDiff) {
for (const key of $ownKeys(target)) {
if (!$isNaN($parseFloat(key)) || acceptedKeys.has(key)) {
continue;
}
const descriptor = $getOwnPropertyDescriptor(target, key);
if (descriptor.configurable) {
delete target[key];

View file

@ -228,6 +228,20 @@ describe(parseUrl(import.meta.url), () => {
expect(testResult.events.map(({ label }) => label)).toEqual(matchers.map(([name]) => name));
});
test("'expect' error handling", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
hooks.before();
expect(() => customExpect(undefined).toInclude("3")).toThrow(
"expected received value to be of type string, any[] or object, got undefined"
);
const testResult = hooks.after();
expect(testResult.pass).toBe(false);
});
test("assertions are prevented after an error", async () => {
const [customExpect, hooks] = makeExpect({ headless: true });
@ -414,7 +428,12 @@ describe(parseUrl(import.meta.url), () => {
});
test("verifyErrors", async () => {
expect.assertions(1);
expect.assertions(2);
expect(() => expect.verifyErrors(["event", "promise", "timeout"])).toThrow(
"cannot call `expect.verifyErrors()` without calling `expect.errors()` beforehand"
);
expect.errors(3);
const boom = (msg) => {

View file

@ -1,11 +1,13 @@
/** @odoo-module */
import { describe, expect, getFixture, test } from "@odoo/hoot";
import {
animationFrame,
click,
describe,
expect,
formatXml,
getActiveElement,
getFixture,
getFocusableElements,
getNextFocusableElement,
getPreviousFocusableElement,
@ -14,16 +16,17 @@ import {
isFocusable,
isInDOM,
isVisible,
mockTouch,
queryAll,
queryAllRects,
queryAllTexts,
queryFirst,
queryOne,
queryRect,
test,
waitFor,
waitForNone,
} from "@odoo/hoot-dom";
import { mockTouch } from "@odoo/hoot-mock";
} from "@odoo/hoot";
import { getParentFrame } from "@web/../lib/hoot-dom/helpers/dom";
import { mountForTest, parseUrl } from "../local_helpers";
@ -466,12 +469,16 @@ describe(parseUrl(import.meta.url), () => {
expectSelector(".title:eq('1')").toEqualNodes(".title", { index: 1 });
expectSelector('.title:eq("1")').toEqualNodes(".title", { index: 1 });
// :contains (text)
// :contains
expectSelector("main > .text:contains(ipsum)").toEqualNodes("p");
expectSelector(".text:contains(/\\bL\\w+\\b\\sipsum/)").toEqualNodes("p");
expectSelector(".text:contains(item)").toEqualNodes("li");
// :contains (value)
// :text
expectSelector(".text:text(item)").toEqualNodes("");
expectSelector(".text:text(first item)").toEqualNodes("li:first-of-type");
// :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]");
@ -493,6 +500,17 @@ describe(parseUrl(import.meta.url), () => {
});
});
test("query options", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
expect($$("input", { count: 2 })).toHaveLength(2);
expect(() => $$("input", { count: 1 })).toThrow();
expect($$("option", { count: 6 })).toHaveLength(6);
expect($$("option", { count: 3, root: "[name=title]" })).toHaveLength(3);
expect(() => $$("option", { count: 6, root: "[name=title]" })).toThrow();
});
test("advanced use cases", async () => {
await mountForTest(FULL_HTML_TEMPLATE);
@ -596,9 +614,9 @@ describe(parseUrl(import.meta.url), () => {
<div>PA4: PAV41</div>
</span>
`);
expectSelector(
`span:contains("Matrix (PAV11, PAV22, PAV31)\nPA4: PAV41")`
).toEqualNodes("span");
expectSelector(`span:contains("Matrix (PAV11, PAV22, PAV31) PA4: PAV41")`).toEqualNodes(
"span"
);
});
test(":has(...):first", async () => {
@ -730,7 +748,7 @@ describe(parseUrl(import.meta.url), () => {
`);
expectSelector(
`.o_content:has(.o_field_widget[name=messages]):has(td:contains(/^bbb$/)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin$/))`
`.o_content:has(.o_field_widget[name=messages]):has(td:text(bbb)):has(td:contains(/^\\[test_trigger\\] Mitchell Admin/))`
).toEqualNodes(".o_content");
});
@ -861,7 +879,7 @@ describe(parseUrl(import.meta.url), () => {
expect($1(".title:first")).toBe(getFixture().querySelector("header .title"));
expect(() => $1(".title")).toThrow();
expect(() => $1(".title", { exact: 2 })).toThrow();
expect(() => $1(".title", { count: 2 })).toThrow();
});
test("queryRect", async () => {
@ -899,10 +917,10 @@ describe(parseUrl(import.meta.url), () => {
// queryOne error messages
expect(() => $1()).toThrow(`found 0 elements instead of 1`);
expect(() => $$([], { exact: 18 })).toThrow(`found 0 elements instead of 18`);
expect(() => $$([], { count: 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(() => $$(".tralalero", { count: -20 })).toThrow(
`invalid 'count' option: should be a positive integer`
);
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)`

View file

@ -1,20 +1,26 @@
/** @odoo-module */
import { after, describe, expect, getFixture, test } from "@odoo/hoot";
import {
advanceTime,
after,
animationFrame,
clear,
click,
dblclick,
describe,
drag,
edit,
expect,
fill,
getFixture,
hover,
keyDown,
keyUp,
leave,
middleClick,
mockFetch,
mockTouch,
mockUserAgent,
on,
pointerDown,
pointerUp,
@ -26,9 +32,9 @@ import {
select,
setInputFiles,
setInputRange,
test,
uncheck,
} from "@odoo/hoot-dom";
import { mockFetch, mockTouch, mockUserAgent } from "@odoo/hoot-mock";
} from "@odoo/hoot";
import { Component, xml } from "@odoo/owl";
import { EventList } from "@web/../lib/hoot-dom/helpers/events";
import { mountForTest, parseUrl } from "../local_helpers";
@ -1230,8 +1236,8 @@ describe(parseUrl(import.meta.url), () => {
"pointerleave:0@input",
"mouseleave:0@input",
// Change
"blur@input",
"change@input",
"blur@input",
"focusout@input",
]);
});
@ -1428,6 +1434,24 @@ describe(parseUrl(import.meta.url), () => {
expect("input").toHaveValue(/file\.txt/);
});
test("setInputFiles: shadow root", async () => {
await mountForTest(/* xml */ `
<div class="container" />
`);
const shadow = queryOne(".container").attachShadow({
mode: "open",
});
const input = document.createElement("input");
input.type = "file";
shadow.appendChild(input);
await click(".container:shadow input");
await setInputFiles(new File([""], "file.txt"));
expect(".container:shadow input").toHaveValue(/file\.txt/);
});
test("setInputRange: basic case and events", async () => {
await mountForTest(/* xml */ `<input type="range" min="10" max="40" />`);
@ -1735,6 +1759,8 @@ describe(parseUrl(import.meta.url), () => {
"focus@input",
"focusin@input",
"focusin@form",
"keyup:Tab@input",
"keyup:Tab@form",
// Enter
"keydown:Enter@input",
"keydown:Enter@form",
@ -1766,6 +1792,8 @@ describe(parseUrl(import.meta.url), () => {
"focus@button",
"focusin@button",
"focusin@form",
"keyup:Tab@button",
"keyup:Tab@form",
// Enter
"keydown:Enter@button",
"keydown:Enter@form",
@ -1798,6 +1826,8 @@ describe(parseUrl(import.meta.url), () => {
"focus@button",
"focusin@button",
"focusin@form",
"keyup:Tab@button",
"keyup:Tab@form",
// Enter
"keydown:Enter@button",
"keydown:Enter@form",
@ -1817,10 +1847,11 @@ describe(parseUrl(import.meta.url), () => {
</form>
`);
mockFetch((url, { body, method }) => {
mockFetch((url, { body, headers, method }) => {
expect.step(new URL(url).pathname);
expect(method).toBe("post");
expect(method).toBe("POST");
expect(headers).toEqual(new Headers([["Content-Type", "multipart/form-data"]]));
expect(body).toBeInstanceOf(FormData);
expect(body.get("csrf_token")).toBe("CSRF_TOKEN_VALUE");
expect(body.get("name")).toBe("Pierre");

View file

@ -1,15 +1,17 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import {
Deferred,
advanceTime,
animationFrame,
describe,
expect,
microTick,
runAllTimers,
test,
tick,
waitUntil,
} from "@odoo/hoot-dom";
} from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
// timeout of 1 second to ensure all timeouts are actually mocked

View file

@ -4,6 +4,7 @@ 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 {
deepCopy,
deepEqual,
formatHumanReadable,
formatTechnical,
@ -12,16 +13,30 @@ import {
lookup,
match,
parseQuery,
S_CIRCULAR,
title,
toExplicitString,
} from "../hoot_utils";
import { mountForTest, parseUrl } from "./local_helpers";
describe(parseUrl(import.meta.url), () => {
test("deepEqual", () => {
const recursive = {};
recursive.self = recursive;
const recursive = {};
recursive.self = recursive;
describe(parseUrl(import.meta.url), () => {
test("deepCopy", () => {
expect(deepCopy(true)).toEqual(true);
expect(deepCopy(false)).toEqual(false);
expect(deepCopy(null)).toEqual(null);
expect(deepCopy(recursive)).toEqual({ self: S_CIRCULAR });
expect(deepCopy(new Date(0))).toEqual(new Date(0));
expect(deepCopy({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
expect(deepCopy({ o: { a: [{ b: 1 }] } })).toEqual({ o: { a: [{ b: 1 }] } });
expect(deepCopy(Symbol.for("a"))).toEqual(Symbol.for("a"));
expect(deepCopy(document.createElement("div"))).toEqual(document.createElement("div"));
expect(deepCopy([1, 2, 3])).toEqual([1, 2, 3]);
});
test("deepEqual", () => {
const TRUTHY_CASES = [
[true, true],
[false, false],

View file

@ -10,7 +10,6 @@
{
"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",
@ -34,7 +33,6 @@
"/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",
@ -47,7 +45,6 @@
"/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",
@ -85,7 +82,6 @@
<!-- 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" />

View file

@ -1,7 +1,6 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { mockSendBeacon, mockTouch, mockVibrate } from "@odoo/hoot-mock";
import { describe, expect, mockSendBeacon, mockTouch, mockVibrate, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
/**

View file

@ -1,9 +1,17 @@
/** @odoo-module */
import { describe, expect, test } from "@odoo/hoot";
import { mockFetch } from "@odoo/hoot-mock";
import { after, describe, expect, mockFetch, test } from "@odoo/hoot";
import { parseUrl } from "../local_helpers";
/**
* @param {Blob | MediaSource} obj
*/
function createObjectURL(obj) {
const url = URL.createObjectURL(obj);
after(() => URL.revokeObjectURL(url));
return url;
}
describe(parseUrl(import.meta.url), () => {
test("setup network values", async () => {
expect(document.cookie).toBe("");
@ -20,13 +28,170 @@ describe(parseUrl(import.meta.url), () => {
expect(document.title).toBe("");
});
test("fetch should not mock internal URLs", async () => {
test("fetch with internal URLs works without mocking fetch", async () => {
const blob = new Blob([JSON.stringify({ name: "coucou" })], {
type: "application/json",
});
const blobUrl = createObjectURL(blob);
const blobResponse = await fetch(blobUrl).then((res) => res.json());
const dataResponse = await fetch("data:text/html,<body></body>").then((res) => res.text());
expect(blobResponse).toEqual({ name: "coucou" });
expect(dataResponse).toBe("<body></body>");
await expect(fetch("http://some.url")).rejects.toThrow(/fetch is not mocked/);
});
test("fetch with internal URLs should return default value", async () => {
mockFetch(expect.step);
await fetch("http://some.url");
await fetch("/odoo");
await fetch(URL.createObjectURL(new Blob([""])));
const external = await fetch("http://some.url").then((res) => res.text());
const internal = await fetch("/odoo").then((res) => res.text());
const data = await fetch("data:text/html,<body></body>").then((res) => res.text());
expect.verifySteps(["http://some.url", "/odoo"]);
expect(external).toBe("null");
expect(internal).toBe("null");
expect(data).toBe("<body></body>");
expect.verifySteps(["http://some.url", "/odoo", "data:text/html,<body></body>"]);
});
test("fetch JSON with blob URLs", async () => {
mockFetch(expect.step);
const blob = new Blob([JSON.stringify({ name: "coucou" })], {
type: "application/json",
});
const blobUrl = createObjectURL(blob);
const response = await fetch(blobUrl);
const json = await response.json();
expect(json).toEqual({ name: "coucou" });
expect.verifySteps([blobUrl]);
});
test("fetch with mocked blob URLs", async () => {
mockFetch((input) => {
expect.step(input);
return "Some other content";
});
const blob = new Blob([JSON.stringify({ name: "coucou" })], {
type: "application/json",
});
const blobUrl = createObjectURL(blob);
const response = await fetch(blobUrl);
expect(response.headers).toEqual(new Headers([["Content-Type", "text/plain"]]));
const text = await response.text();
expect(text).toBe("Some other content");
expect.verifySteps([blobUrl]);
});
test("mock response with nested blobs", async () => {
mockFetch(
() =>
new Blob(["some blob", new Blob([" with nested content"], { type: "text/plain" })])
);
const response = await fetch("/nestedBlob");
const blob = await response.blob();
const result = await blob.text();
expect(result).toBe("some blob with nested content");
});
test("mock responses: array buffer", async () => {
mockFetch(() => "some text");
const response = await fetch("/arrayBuffer");
const result = await response.arrayBuffer();
expect(result).toBeInstanceOf(ArrayBuffer);
expect(new TextDecoder("utf-8").decode(result)).toBe("some text");
});
test("mock responses: blob", async () => {
mockFetch(() => "blob content");
const response = await fetch("/blob");
const result = await response.blob();
expect(result).toBeInstanceOf(Blob);
expect(result.size).toBe(12);
const buffer = await result.arrayBuffer();
expect(new TextDecoder("utf-8").decode(buffer)).toBe("blob content");
});
test("mock responses: bytes", async () => {
mockFetch(() => "some text");
const response = await fetch("/bytes");
const result = await response.bytes();
expect(result).toBeInstanceOf(Uint8Array);
expect(new TextDecoder("utf-8").decode(result)).toBe("some text");
});
test("mock responses: formData", async () => {
mockFetch(() => {
const data = new FormData();
data.append("name", "Frodo");
return data;
});
const response = await fetch("/formData");
const result = await response.formData();
expect(result).toBeInstanceOf(FormData);
expect(result.get("name")).toBe("Frodo");
});
test("mock responses: json", async () => {
mockFetch(() => ({ json: "content" }));
const response = await fetch("/json");
const result = await response.json();
expect(result).toEqual({ json: "content" });
});
test("mock responses: text", async () => {
mockFetch(() => "some text");
const response = await fetch("/text");
const result = await response.text();
expect(result).toBe("some text");
});
test("mock responses: error handling after reading body", async () => {
mockFetch(() => "some text");
const response = await fetch("/text");
const responseClone = response.clone();
const result = await response.text(); // read once
expect(result).toBe("some text");
// Rejects for every reader after body is used
await expect(response.arrayBuffer()).rejects.toThrow(TypeError);
await expect(response.blob()).rejects.toThrow(TypeError);
await expect(response.bytes()).rejects.toThrow(TypeError);
await expect(response.formData()).rejects.toThrow(TypeError);
await expect(response.json()).rejects.toThrow(TypeError);
await expect(response.text()).rejects.toThrow(TypeError);
const cloneResult = await responseClone.text(); // read clone
expect(cloneResult).toBe(result);
// Clone rejects reader as well
await expect(responseClone.text()).rejects.toThrow(TypeError);
});
});

View file

@ -1,10 +1,9 @@
/** @odoo-module */
import { after, describe, expect, test } from "@odoo/hoot";
import { after, describe, expect, test, watchListeners } 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 {

View file

@ -40,7 +40,6 @@ export class HootButtons extends Component {
<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"
@ -69,29 +68,35 @@ export class HootButtons extends Component {
</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"
class="
w-fit absolute animate-slide-down
flex flex-col end-0
bg-base text-base shadow rounded z-2"
>
<t t-if="showAll">
<HootLink class="'bg-btn p-2 whitespace-nowrap transition-colors'">
Run <strong>all</strong> tests
<HootLink
class="'p-3 whitespace-nowrap transition-colors hover:bg-gray-300 dark:hover:bg-gray-700'"
title="'Run all tests'"
>
Run <strong class="text-primary">all</strong> tests
</HootLink>
</t>
<t t-if="showFailed">
<HootLink
ids="{ id: runnerState.failedIds }"
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
class="'p-3 whitespace-nowrap transition-colors hover:bg-gray-300 dark:hover:bg-gray-700'"
title="'Run failed tests'"
ids="{ id: runnerState.failedIds }"
onClick="onRunFailedClick"
>
Run failed <strong>tests</strong>
Run <strong class="text-rose">failed</strong> tests
</HootLink>
<HootLink
ids="{ id: failedSuites }"
class="'bg-btn p-2 whitespace-nowrap transition-colors'"
class="'p-3 whitespace-nowrap transition-colors hover:bg-gray-300 dark:hover:bg-gray-700'"
title="'Run failed suites'"
ids="{ id: getFailedSuiteIds() }"
onClick="onRunFailedClick"
>
Run failed <strong>suites</strong>
Run <strong class="text-rose">failed</strong> suites
</HootLink>
</t>
</div>

View file

@ -118,7 +118,7 @@ export class HootConfigMenu extends Component {
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
class="appearance-none border border-primary rounded-xs w-4 h-4"
t-model="config.manual"
/>
<span>Run tests manually</span>
@ -129,7 +129,7 @@ export class HootConfigMenu extends Component {
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
class="appearance-none border border-primary rounded-xs w-4 h-4"
t-att-checked="config.bail"
t-on-change="onBailChange"
/>
@ -152,7 +152,7 @@ export class HootConfigMenu extends Component {
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
class="appearance-none border border-primary rounded-xs w-4 h-4"
t-att-checked="config.loglevel"
t-on-change="onLogLevelChange"
/>
@ -181,7 +181,7 @@ export class HootConfigMenu extends Component {
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
class="appearance-none border border-primary rounded-xs w-4 h-4"
t-model="config.notrycatch"
/>
<span>No try/catch</span>
@ -232,7 +232,7 @@ export class HootConfigMenu extends Component {
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
class="appearance-none border border-primary rounded-xs w-4 h-4"
t-model="config.headless"
/>
<span>Headless</span>
@ -243,7 +243,7 @@ export class HootConfigMenu extends Component {
>
<input
type="checkbox"
class="appearance-none border border-primary rounded-sm w-4 h-4"
class="appearance-none border border-primary rounded-xs w-4 h-4"
t-model="config.fun"
/>
<span>Enable incentives</span>

View file

@ -154,6 +154,34 @@ export class HootReporting extends Component {
</t>.
</em>
</t>
<t t-elif="!runnerReporting.tests">
<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">
Test runner is ready
</h3>
<div class="flex items-center gap-2">
<t t-if="config.manual">
<button
class="bg-btn px-2 py-1 transition-colors rounded"
t-on-click="onRunClick"
>
<strong>Start</strong>
</button>
or press
<kbd class="px-2 py-1 rounded text-primary bg-gray-300 dark:bg-gray-700">
Enter
</kbd>
</t>
<t t-else="">
Waiting for assets
<div
class="animate-spin shrink-0 grow-0 w-4 h-4 border-2 border-primary border-t-transparent rounded-full"
role="status"
/>
</t>
</div>
</div>
</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">
@ -350,6 +378,10 @@ export class HootReporting extends Component {
});
}
onRunClick() {
this.env.runner.manualStart();
}
/**
* @param {PointerEvent} ev
* @param {string} id

View file

@ -339,7 +339,7 @@ export class HootSearch extends Component {
<input
type="search"
class="w-full rounded p-1 outline-none"
autofocus="autofocus"
t-att-autofocus="!config.manual"
placeholder="Filter suites, tests or tags"
t-ref="search-input"
t-att-class="{ 'text-gray': !config.filter }"

View file

@ -115,7 +115,10 @@ export class HootStatusPanel extends Component {
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="${HootStatusPanel.name} flex items-center justify-between gap-3 px-3 py-1 min-h-10 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

View file

@ -5,6 +5,8 @@
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 0.875rem;
line-height: 1.5;
--hoot-spacing: 0.25rem;
}
/* Scrollbar */
@ -116,7 +118,7 @@ ul {
.hoot-controls {
align-items: center;
display: flex;
gap: 0.5rem;
gap: calc(var(--hoot-spacing) * 2);
}
.hoot-dropdown {
@ -775,14 +777,14 @@ input[type="checkbox"]:checked {
inset-inline-end: 0;
}
.end-2 {
inset-inline-end: 0.5rem;
inset-inline-end: calc(var(--hoot-spacing) * 2);
}
.top-0 {
top: 0;
}
.top-2 {
top: 0.5rem;
top: calc(var(--hoot-spacing) * 2);
}
.bottom-0 {
@ -826,19 +828,22 @@ input[type="checkbox"]:checked {
width: 0;
}
.w-1 {
width: 0.25rem;
width: calc(var(--hoot-spacing) * 1);
}
.w-2 {
width: 0.5rem;
width: calc(var(--hoot-spacing) * 2);
}
.w-3 {
width: 0.75rem;
width: calc(var(--hoot-spacing) * 3);
}
.w-4 {
width: 1rem;
width: calc(var(--hoot-spacing) * 4);
}
.w-5 {
width: 1.25rem;
width: calc(var(--hoot-spacing) * 5);
}
.w-64 {
width: calc(var(--hoot-spacing) * 64);
}
.w-fit {
width: fit-content;
@ -847,27 +852,23 @@ input[type="checkbox"]:checked {
width: 100%;
}
.w-64 {
width: 16rem;
}
.min-w-0 {
min-width: 0;
}
.min-w-1 {
min-width: 0.25rem;
min-width: calc(var(--hoot-spacing) * 1);
}
.min-w-2 {
min-width: 0.5rem;
min-width: calc(var(--hoot-spacing) * 2);
}
.min-w-3 {
min-width: 0.75rem;
min-width: calc(var(--hoot-spacing) * 3);
}
.min-w-4 {
min-width: 1rem;
min-width: calc(var(--hoot-spacing) * 4);
}
.min-w-5 {
min-width: 1.25rem;
min-width: calc(var(--hoot-spacing) * 5);
}
.min-w-fit {
min-width: fit-content;
@ -880,19 +881,19 @@ input[type="checkbox"]:checked {
max-width: 0;
}
.max-w-1 {
max-width: 0.25rem;
max-width: calc(var(--hoot-spacing) * 1);
}
.max-w-2 {
max-width: 0.5rem;
max-width: calc(var(--hoot-spacing) * 2);
}
.max-w-3 {
max-width: 0.75rem;
max-width: calc(var(--hoot-spacing) * 3);
}
.max-w-4 {
max-width: 1rem;
max-width: calc(var(--hoot-spacing) * 4);
}
.max-w-5 {
max-width: 1.25rem;
max-width: calc(var(--hoot-spacing) * 5);
}
.max-w-full {
max-width: 100%;
@ -902,19 +903,22 @@ input[type="checkbox"]:checked {
height: 0;
}
.h-1 {
height: 0.25rem;
height: calc(var(--hoot-spacing) * 1);
}
.h-2 {
height: 0.5rem;
height: calc(var(--hoot-spacing) * 2);
}
.h-3 {
height: 0.75rem;
height: calc(var(--hoot-spacing) * 3);
}
.h-4 {
height: 1rem;
height: calc(var(--hoot-spacing) * 4);
}
.h-5 {
height: 1.25rem;
height: calc(var(--hoot-spacing) * 5);
}
.h-7 {
height: calc(var(--hoot-spacing) * 7);
}
.h-fit {
height: fit-content;
@ -923,27 +927,26 @@ input[type="checkbox"]:checked {
height: 100%;
}
.h-7 {
height: 1.75rem;
}
.min-h-0 {
min-height: 0;
}
.min-h-1 {
min-height: 0.25rem;
min-height: calc(var(--hoot-spacing) * 1);
}
.min-h-2 {
min-height: 0.5rem;
min-height: calc(var(--hoot-spacing) * 2);
}
.min-h-3 {
min-height: 0.75rem;
min-height: calc(var(--hoot-spacing) * 3);
}
.min-h-4 {
min-height: 1rem;
min-height: calc(var(--hoot-spacing) * 4);
}
.min-h-5 {
min-height: 1.25rem;
min-height: calc(var(--hoot-spacing) * 5);
}
.min-h-10 {
min-height: calc(var(--hoot-spacing) * 10);
}
.min-h-full {
min-height: 100%;
@ -953,22 +956,22 @@ input[type="checkbox"]:checked {
max-height: 0;
}
.max-h-1 {
max-height: 0.25rem;
max-height: calc(var(--hoot-spacing) * 1);
}
.max-h-2 {
max-height: 0.5rem;
max-height: calc(var(--hoot-spacing) * 2);
}
.max-h-3 {
max-height: 0.75rem;
max-height: calc(var(--hoot-spacing) * 3);
}
.max-h-4 {
max-height: 1rem;
max-height: calc(var(--hoot-spacing) * 4);
}
.max-h-5 {
max-height: 1.25rem;
max-height: calc(var(--hoot-spacing) * 5);
}
.max-h-48 {
max-height: 12rem;
max-height: calc(var(--hoot-spacing) * 48);
}
.max-h-full {
max-height: 100%;
@ -983,48 +986,48 @@ input[type="checkbox"]:checked {
gap: 1px;
}
.gap-1 {
gap: 0.25rem;
gap: calc(var(--hoot-spacing) * 1);
}
.gap-2 {
gap: 0.5rem;
gap: calc(var(--hoot-spacing) * 2);
}
.gap-3 {
gap: 0.75rem;
gap: calc(var(--hoot-spacing) * 3);
}
.gap-4 {
gap: 1rem;
gap: calc(var(--hoot-spacing) * 4);
}
.gap-x-0 {
column-gap: 0;
}
.gap-x-1 {
column-gap: 0.25rem;
column-gap: calc(var(--hoot-spacing) * 1);
}
.gap-x-2 {
column-gap: 0.5rem;
column-gap: calc(var(--hoot-spacing) * 2);
}
.gap-x-3 {
column-gap: 0.75rem;
column-gap: calc(var(--hoot-spacing) * 3);
}
.gap-x-4 {
column-gap: 1rem;
column-gap: calc(var(--hoot-spacing) * 4);
}
.gap-y-0 {
row-gap: 0;
}
.gap-y-1 {
row-gap: 0.25rem;
row-gap: calc(var(--hoot-spacing) * 1);
}
.gap-y-2 {
row-gap: 0.5rem;
row-gap: calc(var(--hoot-spacing) * 2);
}
.gap-y-3 {
row-gap: 0.75rem;
row-gap: calc(var(--hoot-spacing) * 3);
}
.gap-y-4 {
row-gap: 1rem;
row-gap: calc(var(--hoot-spacing) * 4);
}
/* Spacing: margin */
@ -1033,16 +1036,16 @@ input[type="checkbox"]:checked {
margin: 0;
}
.m-1 {
margin: 0.25rem;
margin: calc(var(--hoot-spacing) * 1);
}
.m-2 {
margin: 0.5rem;
margin: calc(var(--hoot-spacing) * 2);
}
.m-3 {
margin: 0.75rem;
margin: calc(var(--hoot-spacing) * 3);
}
.m-4 {
margin: 1rem;
margin: calc(var(--hoot-spacing) * 4);
}
.mx-0 {
@ -1050,20 +1053,20 @@ input[type="checkbox"]:checked {
margin-right: 0;
}
.mx-1 {
margin-left: 0.25rem;
margin-right: 0.25rem;
margin-left: calc(var(--hoot-spacing) * 1);
margin-right: calc(var(--hoot-spacing) * 1);
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
margin-left: calc(var(--hoot-spacing) * 2);
margin-right: calc(var(--hoot-spacing) * 2);
}
.mx-3 {
margin-left: 0.75rem;
margin-right: 0.75rem;
margin-left: calc(var(--hoot-spacing) * 3);
margin-right: calc(var(--hoot-spacing) * 3);
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
margin-left: calc(var(--hoot-spacing) * 4);
margin-right: calc(var(--hoot-spacing) * 4);
}
.my-0 {
@ -1071,36 +1074,36 @@ input[type="checkbox"]:checked {
margin-bottom: 0;
}
.my-1 {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
margin-top: calc(var(--hoot-spacing) * 1);
margin-bottom: calc(var(--hoot-spacing) * 1);
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
margin-top: calc(var(--hoot-spacing) * 2);
margin-bottom: calc(var(--hoot-spacing) * 2);
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem;
margin-top: calc(var(--hoot-spacing) * 3);
margin-bottom: calc(var(--hoot-spacing) * 3);
}
.my-3 {
margin-top: 1rem;
margin-bottom: 1rem;
margin-top: calc(var(--hoot-spacing) * 4);
margin-bottom: calc(var(--hoot-spacing) * 4);
}
.ms-0 {
margin-inline-start: 0;
}
.ms-1 {
margin-inline-start: 0.25rem;
margin-inline-start: calc(var(--hoot-spacing) * 1);
}
.ms-2 {
margin-inline-start: 0.5rem;
margin-inline-start: calc(var(--hoot-spacing) * 2);
}
.ms-3 {
margin-inline-start: 0.75rem;
margin-inline-start: calc(var(--hoot-spacing) * 3);
}
.ms-4 {
margin-inline-start: 1rem;
margin-inline-start: calc(var(--hoot-spacing) * 4);
}
.ms-auto {
margin-inline-start: auto;
@ -1110,16 +1113,16 @@ input[type="checkbox"]:checked {
margin-inline-end: 0;
}
.me-1 {
margin-inline-end: 0.25rem;
margin-inline-end: calc(var(--hoot-spacing) * 1);
}
.me-2 {
margin-inline-end: 0.5rem;
margin-inline-end: calc(var(--hoot-spacing) * 2);
}
.me-3 {
margin-inline-end: 0.75rem;
margin-inline-end: calc(var(--hoot-spacing) * 3);
}
.me-4 {
margin-inline-end: 1rem;
margin-inline-end: calc(var(--hoot-spacing) * 4);
}
.me-auto {
margin-inline-end: auto;
@ -1129,32 +1132,32 @@ input[type="checkbox"]:checked {
margin-top: 0;
}
.mt-1 {
margin-top: 0.25rem;
margin-top: calc(var(--hoot-spacing) * 1);
}
.mt-2 {
margin-top: 0.5rem;
margin-top: calc(var(--hoot-spacing) * 2);
}
.mt-3 {
margin-top: 0.75rem;
margin-top: calc(var(--hoot-spacing) * 3);
}
.mt-4 {
margin-top: 1rem;
margin-top: calc(var(--hoot-spacing) * 4);
}
.mb-0 {
margin-bottom: 0;
}
.mb-1 {
margin-bottom: 0.25rem;
margin-bottom: calc(var(--hoot-spacing) * 1);
}
.mb-2 {
margin-bottom: 0.5rem;
margin-bottom: calc(var(--hoot-spacing) * 2);
}
.mb-3 {
margin-bottom: 0.75rem;
margin-bottom: calc(var(--hoot-spacing) * 3);
}
.mb-4 {
margin-bottom: 1rem;
margin-bottom: calc(var(--hoot-spacing) * 4);
}
/* Spacing: padding */
@ -1166,19 +1169,19 @@ input[type="checkbox"]:checked {
padding: 1px;
}
.p-1 {
padding: 0.25rem;
padding: calc(var(--hoot-spacing) * 1);
}
.p-2 {
padding: 0.5rem;
padding: calc(var(--hoot-spacing) * 2);
}
.p-3 {
padding: 0.75rem;
padding: calc(var(--hoot-spacing) * 3);
}
.p-4 {
padding: 1rem;
padding: calc(var(--hoot-spacing) * 4);
}
.p-5 {
padding: 1.25rem;
padding: calc(var(--hoot-spacing) * 5);
}
.px-0 {
@ -1186,20 +1189,20 @@ input[type="checkbox"]:checked {
padding-right: 0;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
padding-left: calc(var(--hoot-spacing) * 1);
padding-right: calc(var(--hoot-spacing) * 1);
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-left: calc(var(--hoot-spacing) * 2);
padding-right: calc(var(--hoot-spacing) * 2);
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
padding-left: calc(var(--hoot-spacing) * 3);
padding-right: calc(var(--hoot-spacing) * 3);
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
padding-left: calc(var(--hoot-spacing) * 4);
padding-right: calc(var(--hoot-spacing) * 4);
}
.py-0 {
@ -1207,84 +1210,84 @@ input[type="checkbox"]:checked {
padding-bottom: 0;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
padding-top: calc(var(--hoot-spacing) * 1);
padding-bottom: calc(var(--hoot-spacing) * 1);
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-top: calc(var(--hoot-spacing) * 2);
padding-bottom: calc(var(--hoot-spacing) * 2);
}
.py-3 {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
padding-top: calc(var(--hoot-spacing) * 3);
padding-bottom: calc(var(--hoot-spacing) * 3);
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
padding-top: calc(var(--hoot-spacing) * 4);
padding-bottom: calc(var(--hoot-spacing) * 4);
}
.ps-0 {
padding-inline-start: 0;
}
.ps-1 {
padding-inline-start: 0.25rem;
padding-inline-start: calc(var(--hoot-spacing) * 1);
}
.ps-2 {
padding-inline-start: 0.5rem;
padding-inline-start: calc(var(--hoot-spacing) * 2);
}
.ps-3 {
padding-inline-start: 0.75rem;
padding-inline-start: calc(var(--hoot-spacing) * 3);
}
.ps-4 {
padding-inline-start: 1rem;
padding-inline-start: calc(var(--hoot-spacing) * 4);
}
.pe-0 {
padding-inline-end: 0;
}
.pe-1 {
padding-inline-end: 0.25rem;
padding-inline-end: calc(var(--hoot-spacing) * 1);
}
.pe-2 {
padding-inline-end: 0.5rem;
padding-inline-end: calc(var(--hoot-spacing) * 2);
}
.pe-3 {
padding-inline-end: 0.75rem;
padding-inline-end: calc(var(--hoot-spacing) * 3);
}
.pe-4 {
padding-inline-end: 1rem;
padding-inline-end: calc(var(--hoot-spacing) * 4);
}
.pt-0 {
padding-top: 0;
}
.pt-1 {
padding-top: 0.25rem;
padding-top: calc(var(--hoot-spacing) * 1);
}
.pt-2 {
padding-top: 0.5rem;
padding-top: calc(var(--hoot-spacing) * 2);
}
.pt-3 {
padding-top: 0.75rem;
padding-top: calc(var(--hoot-spacing) * 3);
}
.pt-4 {
padding-top: 1rem;
padding-top: calc(var(--hoot-spacing) * 4);
}
.pb-0 {
padding-bottom: 0;
}
.pb-1 {
padding-bottom: 0.25rem;
padding-bottom: calc(var(--hoot-spacing) * 1);
}
.pb-2 {
padding-bottom: 0.5rem;
padding-bottom: calc(var(--hoot-spacing) * 2);
}
.pb-3 {
padding-bottom: 0.75rem;
padding-bottom: calc(var(--hoot-spacing) * 3);
}
.pb-4 {
padding-bottom: 1rem;
padding-bottom: calc(var(--hoot-spacing) * 4);
}
/* Text: alignment */
@ -1472,15 +1475,15 @@ input[type="checkbox"]:checked {
.text-xs {
font-size: 0.625rem;
line-height: 1rem;
line-height: calc(var(--hoot-spacing) * 4);
}
.text-sm {
font-size: 0.75rem;
line-height: 1.25rem;
line-height: calc(var(--hoot-spacing) * 5);
}
.text-2xl {
font-size: 1.25rem;
line-height: 1.75rem;
line-height: calc(var(--hoot-spacing) * 7);
}
/* Transform: rotate */
@ -1566,8 +1569,8 @@ input[type="checkbox"]:checked {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sm\:px-4 {
padding-left: 1rem;
padding-right: 1rem;
padding-left: calc(var(--hoot-spacing) * 4);
padding-right: calc(var(--hoot-spacing) * 4);
}
}
@ -1596,7 +1599,7 @@ input[type="checkbox"]:checked {
@media (max-width: 640px) {
.hoot-controls {
display: grid;
gap: 0.25rem;
gap: var(--hoot-spacing);
grid-template: 1fr auto auto / 1fr auto auto;
}

View file

@ -9,13 +9,14 @@ import {
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 { isInstanceOf, isIterable, isPromise } from "@web/../lib/hoot-dom/hoot_dom_utils";
import { logger } from "../core/logger";
import {
getTypeOf,
isSafe,
Markup,
S_ANY,
S_CIRCULAR,
S_NONE,
stringify,
toExplicitString,
@ -96,7 +97,7 @@ export class HootTechnicalValue extends Component {
<t>/&gt;</t>
</button>
</t>
<t t-elif="value === S_ANY or value === S_NONE">
<t t-elif="SPECIAL_SYMBOLS.includes(value)">
<span class="italic">
&lt;<t t-esc="symbolValue(value)" />&gt;
</span>
@ -175,8 +176,7 @@ export class HootTechnicalValue extends Component {
stringify = stringify;
toSelector = toSelector;
S_ANY = S_ANY;
S_NONE = S_NONE;
SPECIAL_SYMBOLS = [S_ANY, S_CIRCULAR, S_NONE];
get explicitValue() {
return toExplicitString(this.value);
@ -249,7 +249,7 @@ export class HootTechnicalValue extends Component {
}
wrapPromiseValue(promise) {
if (!isInstanceOf(promise, Promise)) {
if (!isPromise(promise)) {
return;
}
this.state.promiseState = ["pending", null];

View file

@ -90,6 +90,8 @@ function stackTemplate(label, owner) {
`;
}
const DOC_URL = `https://www.odoo.com/documentation/18.0/developer/reference/frontend/unit_testing/hoot.html#`;
const ERROR_TEMPLATE = /* xml */ `
<div class="text-rose flex items-center gap-1 px-2 truncate">
<i class="fa fa-exclamation" />
@ -114,8 +116,12 @@ const EVENT_TEMPLATE = /* xml */ `
<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' }">
<a
class="hover:text-primary flex gap-1 items-center"
t-att-class="{ 'text-cyan': sType === 'assertion' }"
t-att-href="DOC_URL + (event.docLabel or event.label)"
target="_blank"
>
<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" />
@ -306,6 +312,7 @@ export class HootTestResult extends Component {
`;
CASE_EVENT_TYPES = CASE_EVENT_TYPES;
DOC_URL = DOC_URL;
Tag = Tag;
formatHumanReadable = formatHumanReadable;