Odoo18-Base/addons/web/static/lib/hoot/ui/hoot_test_result.js
KaySar12 8a9c49514f
Some checks are pending
Setup Native Action / native (3.12.7) (push) Waiting to run
Setup Native Action / docker (3.12.7) (push) Waiting to run
update base web lib
2025-05-23 14:51:51 +07:00

374 lines
13 KiB
JavaScript

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