Odoo18-Base/addons/web/static/lib/hoot/ui/hoot_reporting.js
2025-01-06 10:57:38 +07:00

371 lines
14 KiB
JavaScript

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