diff --git a/packages/core-api/src/constants.ts b/packages/core-api/src/constants.ts index 95a1b06ffe2..c4ca7cae7a7 100644 --- a/packages/core-api/src/constants.ts +++ b/packages/core-api/src/constants.ts @@ -6,6 +6,7 @@ export const statusesList: readonly TestStatus[] = ["failed", "broken", "passed" export const severityLevels: readonly SeverityLevel[] = ["blocker", "critical", "normal", "minor", "trivial"]; export const severityLabelName = "severity"; + export const fallbackTestCaseIdLabelName = "_fallbackTestCaseId"; export const unsuccessfulStatuses = new Set(["failed", "broken"]); diff --git a/packages/plugin-api/src/report.ts b/packages/plugin-api/src/report.ts index 2cfec224ec6..e2fdbb15ab3 100644 --- a/packages/plugin-api/src/report.ts +++ b/packages/plugin-api/src/report.ts @@ -134,6 +134,10 @@ export type ReportTreeLeaf = Pick< tooltips?: Record; tags?: string[]; categories?: string[]; + /** + * Value of the test result's `severity` label. Absent when the test result has no severity label. + */ + severity?: string; }; export type ReportTreeGroup = WithChildren & DefaultTreeGroup & { nodeId: string }; diff --git a/packages/plugin-awesome/src/generators.ts b/packages/plugin-awesome/src/generators.ts index 1d185c7d971..be751ce2c19 100644 --- a/packages/plugin-awesome/src/generators.ts +++ b/packages/plugin-awesome/src/generators.ts @@ -27,6 +27,7 @@ import { joinPosixPath, nullsLast, ordinal, + severityLabelName, } from "@allurereport/core-api"; import type { AllureStore, @@ -482,6 +483,12 @@ const leafFactory = ({ tooltips, }; + const severity = groupedLabels[severityLabelName]?.[0]; + + if (severity) { + leaf.severity = severity; + } + if (groupedLabels.tag && groupedLabels.tag.length > 0) { leaf.tags = groupedLabels.tag; } diff --git a/packages/plugin-awesome/test/generators.test.ts b/packages/plugin-awesome/test/generators.test.ts index 493e1b7acdd..3798d46149d 100644 --- a/packages/plugin-awesome/test/generators.test.ts +++ b/packages/plugin-awesome/test/generators.test.ts @@ -809,6 +809,51 @@ describe("generateQualityGateResults", () => { }); }); + it("should expose the severity label on tree leaves and omit it when there is none", async () => { + const writtenWidgets = new Map(); + const writer: AwesomeDataWriter = { + writeData: vi.fn().mockResolvedValue(undefined), + writeWidget: vi.fn(async (fileName: string, data: unknown) => { + writtenWidgets.set(fileName, data); + }), + writeTestCase: vi.fn().mockResolvedValue(undefined), + writeAttachment: vi.fn().mockResolvedValue(undefined), + }; + const tests = [ + { + ...mockTestResult("tr-blocker", "blocker test", "failed"), + groupedLabels: { severity: ["blocker"] }, + }, + { + ...mockTestResult("tr-no-severity", "test without severity", "failed"), + groupedLabels: {}, + }, + ] as AwesomeTestResult[]; + + await generateQualityGateResults( + writer, + { + default: [ + { + rule: "maxFailures", + success: false, + expected: 0, + actual: 2, + message: "Too many failures", + testResults: ["tr-blocker", "tr-no-severity"], + }, + ], + }, + { tests }, + ); + + const results = writtenWidgets.get("quality-gate.json") as AwesomeQualityGateResults; + const leavesById = results.default[0].testResultsTree?.leavesById ?? {}; + + expect(leavesById["tr-blocker"]).toMatchObject({ severity: "blocker" }); + expect(leavesById["tr-no-severity"]).not.toHaveProperty("severity"); + }); + it("should omit the tree when no related test result can be resolved", async () => { const writer: AwesomeDataWriter = { writeData: vi.fn().mockResolvedValue(undefined), diff --git a/packages/web-awesome/src/components/ReportFilters/SeverityFilter.tsx b/packages/web-awesome/src/components/ReportFilters/SeverityFilter.tsx new file mode 100644 index 00000000000..4edf3c25309 --- /dev/null +++ b/packages/web-awesome/src/components/ReportFilters/SeverityFilter.tsx @@ -0,0 +1,58 @@ +import { capitalize, severityLevels } from "@allurereport/core-api"; +import { allureIcons } from "@allurereport/web-components"; +import { useMemo } from "preact/hooks"; + +import { useI18n } from "@/stores"; + +import { NO_SEVERITY } from "../../stores/treeFilters/constants"; +import type { AwesomeFilterGroupSimple } from "../../stores/treeFilters/model"; +import { MultipleChoiceFieldFilter } from "./BaseFilters"; + +const severityIcons: Record = { + blocker: allureIcons.lineArrowsChevronUpDouble, + critical: allureIcons.lineArrowsChevronUp, + normal: allureIcons.lineGeneralEqual, + minor: allureIcons.lineArrowsChevronDown, + trivial: allureIcons.lineArrowsChevronDownDouble, + [NO_SEVERITY]: allureIcons.lineGeneralXClose, +}; + +const severityOptions = [...severityLevels, NO_SEVERITY]; + +export const SeverityFilter = (props: { + group: AwesomeFilterGroupSimple; + onChange: (group: AwesomeFilterGroupSimple) => void; +}) => { + const { group, onChange } = props; + const { t } = useI18n("filters"); + const { t: tSeverity } = useI18n("severity"); + const options = useMemo( + () => + severityOptions.map((severity) => ({ + key: severity, + icon: severityIcons[severity], + label: capitalize(tSeverity(severity)), + })), + [tSeverity], + ); + + return ( + + onChange({ + ...group, + value: [], + }) + } + /> + ); +}; diff --git a/packages/web-awesome/src/components/ReportFilters/index.tsx b/packages/web-awesome/src/components/ReportFilters/index.tsx index 3fb2522a842..a3cb8c855bc 100644 --- a/packages/web-awesome/src/components/ReportFilters/index.tsx +++ b/packages/web-awesome/src/components/ReportFilters/index.tsx @@ -9,6 +9,7 @@ import { isFlakyFilter, isResolutionFilter, isRetryFilter, + isSeverityFilter, isTagFilter, isTransitionFilter, } from "@/stores/treeFilters/utils"; @@ -17,6 +18,7 @@ import { BooleanFieldFilter } from "./BaseFilters"; import { CategoriesFilter } from "./CategoriesFilter"; import { ResolutionFilter } from "./ResolutionFilter"; import { RetryFlakyFilter } from "./RetryFlaky"; +import { SeverityFilter } from "./SeverityFilter"; import { TagsFilter } from "./TagsFilter"; import { TransitionFilter } from "./TransitionFilter"; @@ -34,6 +36,10 @@ const Filter = (props: { filter: AwesomeFilter; onChange: (filter: AwesomeFilter return ; } + if (isSeverityFilter(filter)) { + return ; + } + if (isResolutionFilter(filter)) { return ; } diff --git a/packages/web-awesome/src/locales/ar.json b/packages/web-awesome/src/locales/ar.json index 9191d7a8dd1..27900574eba 100644 --- a/packages/web-awesome/src/locales/ar.json +++ b/packages/web-awesome/src/locales/ar.json @@ -132,7 +132,8 @@ "critical": "خطورة عالية", "normal": "عادي", "minor": "ثانوي", - "trivial": "بسيط" + "trivial": "بسيط", + "none": "بدون خطورة" }, "execution": { "name": "التنفيذ", diff --git a/packages/web-awesome/src/locales/az.json b/packages/web-awesome/src/locales/az.json index f097302ef50..9257a07146a 100644 --- a/packages/web-awesome/src/locales/az.json +++ b/packages/web-awesome/src/locales/az.json @@ -132,7 +132,8 @@ "critical": "kritik", "normal": "normal", "minor": "kiçik", - "trivial": "əhəmiyyətsiz" + "trivial": "əhəmiyyətsiz", + "none": "ciddilik dərəcəsi yoxdur" }, "execution": { "name": "İcra", diff --git a/packages/web-awesome/src/locales/de.json b/packages/web-awesome/src/locales/de.json index b0822aee974..39e5908455e 100644 --- a/packages/web-awesome/src/locales/de.json +++ b/packages/web-awesome/src/locales/de.json @@ -132,7 +132,8 @@ "critical": "kritisch", "normal": "normal", "minor": "geringfügig", - "trivial": "trivial" + "trivial": "trivial", + "none": "kein Schweregrad" }, "execution": { "name": "Ausführung", diff --git a/packages/web-awesome/src/locales/en.json b/packages/web-awesome/src/locales/en.json index 2c65b656da5..f3236e4705c 100644 --- a/packages/web-awesome/src/locales/en.json +++ b/packages/web-awesome/src/locales/en.json @@ -132,7 +132,8 @@ "critical": "critical", "normal": "normal", "minor": "minor", - "trivial": "trivial" + "trivial": "trivial", + "none": "no severity" }, "execution": { "name": "Execution", diff --git a/packages/web-awesome/src/locales/es.json b/packages/web-awesome/src/locales/es.json index 5f2291b343f..13307f1dabd 100644 --- a/packages/web-awesome/src/locales/es.json +++ b/packages/web-awesome/src/locales/es.json @@ -132,7 +132,8 @@ "critical": "crítico", "normal": "normal", "minor": "menor", - "trivial": "trivial" + "trivial": "trivial", + "none": "sin severidad" }, "execution": { "name": "Ejecución", diff --git a/packages/web-awesome/src/locales/fr.json b/packages/web-awesome/src/locales/fr.json index b3259e208ff..cd8fd6899b2 100644 --- a/packages/web-awesome/src/locales/fr.json +++ b/packages/web-awesome/src/locales/fr.json @@ -132,7 +132,8 @@ "critical": "critique", "normal": "normal", "minor": "mineur", - "trivial": "trivial" + "trivial": "trivial", + "none": "sans sévérité" }, "execution": { "name": "Exécution", diff --git a/packages/web-awesome/src/locales/he.json b/packages/web-awesome/src/locales/he.json index 68cf2d21b81..4b572eff16f 100644 --- a/packages/web-awesome/src/locales/he.json +++ b/packages/web-awesome/src/locales/he.json @@ -132,7 +132,8 @@ "critical": "קריטי", "normal": "רגיל", "minor": "קל", - "trivial": "פעוט" + "trivial": "פעוט", + "none": "ללא חומרה" }, "execution": { "name": "ביצוע", diff --git a/packages/web-awesome/src/locales/hy.json b/packages/web-awesome/src/locales/hy.json index 8d7a1ebad84..21c087c852f 100644 --- a/packages/web-awesome/src/locales/hy.json +++ b/packages/web-awesome/src/locales/hy.json @@ -132,7 +132,8 @@ "critical": "կրիտիկական", "normal": "նորմալ", "minor": "աննշան", - "trivial": "աննշան" + "trivial": "աննշան", + "none": "առանց ծանրության" }, "execution": { "name": "Կատարում", diff --git a/packages/web-awesome/src/locales/it.json b/packages/web-awesome/src/locales/it.json index 1effee25fdc..ca0e93ac2dc 100644 --- a/packages/web-awesome/src/locales/it.json +++ b/packages/web-awesome/src/locales/it.json @@ -132,7 +132,8 @@ "critical": "critico", "normal": "normale", "minor": "minore", - "trivial": "banale" + "trivial": "banale", + "none": "senza gravità" }, "execution": { "name": "Esecuzione", diff --git a/packages/web-awesome/src/locales/ja.json b/packages/web-awesome/src/locales/ja.json index 08947971136..bf5a0316638 100644 --- a/packages/web-awesome/src/locales/ja.json +++ b/packages/web-awesome/src/locales/ja.json @@ -132,7 +132,8 @@ "critical": "重大", "normal": "通常", "minor": "小規模", - "trivial": "些細" + "trivial": "些細", + "none": "重要度なし" }, "execution": { "name": "実行", diff --git a/packages/web-awesome/src/locales/ka.json b/packages/web-awesome/src/locales/ka.json index 8cfd30f5556..b4af447cad0 100644 --- a/packages/web-awesome/src/locales/ka.json +++ b/packages/web-awesome/src/locales/ka.json @@ -132,7 +132,8 @@ "critical": "კრიტიკული", "normal": "ჩვეულებრივი", "minor": "მნიშვნელობა", - "trivial": "უმნიშვნელო" + "trivial": "უმნიშვნელო", + "none": "სიმძიმის გარეშე" }, "execution": { "name": "შესრულება", diff --git a/packages/web-awesome/src/locales/kr.json b/packages/web-awesome/src/locales/kr.json index 124a7c1f51e..e856a1bee15 100644 --- a/packages/web-awesome/src/locales/kr.json +++ b/packages/web-awesome/src/locales/kr.json @@ -132,7 +132,8 @@ "critical": "치명적", "normal": "보통", "minor": "경미함", - "trivial": "사소함" + "trivial": "사소함", + "none": "심각도 없음" }, "execution": { "name": "실행", diff --git a/packages/web-awesome/src/locales/nl.json b/packages/web-awesome/src/locales/nl.json index 3d5302417af..982d70402b5 100644 --- a/packages/web-awesome/src/locales/nl.json +++ b/packages/web-awesome/src/locales/nl.json @@ -132,7 +132,8 @@ "critical": "kritiek", "normal": "normaal", "minor": "klein", - "trivial": "onbeduidend" + "trivial": "onbeduidend", + "none": "geen ernst" }, "execution": { "name": "Uitvoering", diff --git a/packages/web-awesome/src/locales/pl.json b/packages/web-awesome/src/locales/pl.json index d73bd18e1ef..a47da172f38 100644 --- a/packages/web-awesome/src/locales/pl.json +++ b/packages/web-awesome/src/locales/pl.json @@ -132,7 +132,8 @@ "critical": "krytyczna", "normal": "zwykła", "minor": "niska", - "trivial": "minimalna" + "trivial": "minimalna", + "none": "brak ważności" }, "execution": { "name": "Wykonanie", diff --git a/packages/web-awesome/src/locales/pt.json b/packages/web-awesome/src/locales/pt.json index e8c117c6dfc..5f0b0057bb7 100644 --- a/packages/web-awesome/src/locales/pt.json +++ b/packages/web-awesome/src/locales/pt.json @@ -132,7 +132,8 @@ "critical": "crítico", "normal": "normal", "minor": "menor", - "trivial": "trivial" + "trivial": "trivial", + "none": "sem severidade" }, "execution": { "name": "Execução", diff --git a/packages/web-awesome/src/locales/ru.json b/packages/web-awesome/src/locales/ru.json index 6d8a00e0462..d18a9f2085b 100644 --- a/packages/web-awesome/src/locales/ru.json +++ b/packages/web-awesome/src/locales/ru.json @@ -132,7 +132,8 @@ "critical": "критическая", "normal": "обычная", "minor": "невысокая", - "trivial": "минимальная" + "trivial": "минимальная", + "none": "без важности" }, "execution": { "name": "Выполнение", diff --git a/packages/web-awesome/src/locales/sv.json b/packages/web-awesome/src/locales/sv.json index ac9d8191957..f6d1d7d717e 100644 --- a/packages/web-awesome/src/locales/sv.json +++ b/packages/web-awesome/src/locales/sv.json @@ -132,7 +132,8 @@ "critical": "kritisk", "normal": "normal", "minor": "mindre", - "trivial": "obetydlig" + "trivial": "obetydlig", + "none": "ingen allvarlighetsgrad" }, "execution": { "name": "Utförande", diff --git a/packages/web-awesome/src/locales/tr.json b/packages/web-awesome/src/locales/tr.json index 3b77a63342a..68fe7f8bf49 100644 --- a/packages/web-awesome/src/locales/tr.json +++ b/packages/web-awesome/src/locales/tr.json @@ -132,7 +132,8 @@ "critical": "kritik", "normal": "normal", "minor": "küçük", - "trivial": "önemsiz" + "trivial": "önemsiz", + "none": "önem derecesi yok" }, "execution": { "name": "Yürütme", diff --git a/packages/web-awesome/src/locales/uk.json b/packages/web-awesome/src/locales/uk.json index f18822b22db..9d5ce9fc5f3 100644 --- a/packages/web-awesome/src/locales/uk.json +++ b/packages/web-awesome/src/locales/uk.json @@ -132,7 +132,8 @@ "critical": "критична", "normal": "звичайна", "minor": "невисока", - "trivial": "мінімальна" + "trivial": "мінімальна", + "none": "без важливості" }, "execution": { "name": "Виконання", diff --git a/packages/web-awesome/src/locales/zh-TW.json b/packages/web-awesome/src/locales/zh-TW.json index 9b9053f71fa..9ab583d4f81 100644 --- a/packages/web-awesome/src/locales/zh-TW.json +++ b/packages/web-awesome/src/locales/zh-TW.json @@ -132,7 +132,8 @@ "critical": "嚴重", "normal": "正常", "minor": "次要", - "trivial": "輕微" + "trivial": "輕微", + "none": "無嚴重程度" }, "execution": { "name": "執行", diff --git a/packages/web-awesome/src/locales/zh.json b/packages/web-awesome/src/locales/zh.json index 82ba54d0f92..aececae3bbe 100644 --- a/packages/web-awesome/src/locales/zh.json +++ b/packages/web-awesome/src/locales/zh.json @@ -132,7 +132,8 @@ "critical": "严重", "normal": "正常", "minor": "次要", - "trivial": "轻微" + "trivial": "轻微", + "none": "无严重程度" }, "execution": { "name": "执行", diff --git a/packages/web-awesome/src/stores/treeFilters/actions.ts b/packages/web-awesome/src/stores/treeFilters/actions.ts index a393b5acd4e..d022c6b8cdd 100644 --- a/packages/web-awesome/src/stores/treeFilters/actions.ts +++ b/packages/web-awesome/src/stores/treeFilters/actions.ts @@ -61,6 +61,13 @@ export const setCategoriesFilter = (categories: string[]) => { }); }; +export const setSeverityFilter = (severities: string[]) => { + setParams({ + key: PARAMS.SEVERITY, + value: severities, + }); +}; + export const clearTreeFilterParams = () => { setParams( { key: PARAMS.QUERY, value: undefined }, @@ -70,6 +77,7 @@ export const clearTreeFilterParams = () => { { key: PARAMS.TRANSITION, value: [] }, { key: PARAMS.TAGS, value: [] }, { key: PARAMS.CATEGORIES, value: [] }, + { key: PARAMS.SEVERITY, value: [] }, { key: PARAMS.STATUS, value: undefined }, ); }; diff --git a/packages/web-awesome/src/stores/treeFilters/constants.ts b/packages/web-awesome/src/stores/treeFilters/constants.ts index c5cb83b6ecb..b2833b63cd6 100644 --- a/packages/web-awesome/src/stores/treeFilters/constants.ts +++ b/packages/web-awesome/src/stores/treeFilters/constants.ts @@ -1,9 +1,22 @@ -import type { ResolutionCategory, TestStatus, TestStatusTransition } from "@allurereport/core-api"; +import { + type ResolutionCategory, + type TestStatus, + type TestStatusTransition, + severityLevels, +} from "@allurereport/core-api"; export const TRANSITIONS: TestStatusTransition[] = ["new", "fixed", "regressed", "malfunctioned"]; export const STATUSES: TestStatus[] = ["passed", "failed", "skipped", "broken", "unknown"]; export const RESOLUTIONS: ResolutionCategory[] = ["issue", "muted", "accepted"]; +/** + * Option key of the "no severity" filter choice. It only exists in the UI and in the URL: + * tree leaves of test results without a severity label don't carry the property at all. + */ +export const NO_SEVERITY = "none"; + +export const SEVERITIES: string[] = [...severityLevels, NO_SEVERITY]; + export const PARAMS = { QUERY: "query", STATUS: "status", @@ -13,4 +26,5 @@ export const PARAMS = { TRANSITION: "transition", TAGS: "tags", CATEGORIES: "categories", + SEVERITY: "severity", } as const; diff --git a/packages/web-awesome/src/stores/treeFilters/model.ts b/packages/web-awesome/src/stores/treeFilters/model.ts index 8ad1a0572d4..240c7f7e474 100644 --- a/packages/web-awesome/src/stores/treeFilters/model.ts +++ b/packages/web-awesome/src/stores/treeFilters/model.ts @@ -18,6 +18,7 @@ export type Filters = { transition?: TestStatusTransition[]; tags?: string[]; categories?: string[]; + severity?: string[]; }; export type AwesomeFieldFilter = FieldFilter; diff --git a/packages/web-awesome/src/stores/treeFilters/store.ts b/packages/web-awesome/src/stores/treeFilters/store.ts index 516b1a11880..d23f2e0df2b 100644 --- a/packages/web-awesome/src/stores/treeFilters/store.ts +++ b/packages/web-awesome/src/stores/treeFilters/store.ts @@ -10,6 +10,7 @@ import { setQueryFilter, setResolutionFilter, setRetryFilter, + setSeverityFilter, setStatusFilter, setTagsFilter, setTransitionFilter, @@ -28,8 +29,11 @@ import { isFlakyFilter, isResolutionFilter, isRetryFilter, + isSeverityFilter, isTagFilter, isTransitionFilter, + toSeverityPredicateFilter, + validateSeverity, validateStatus, validateTransition, validateResolution, @@ -89,6 +93,18 @@ const urlTransitionFilter = computed(() => { return transitions.filter((transition) => validateTransition(transition)); }); +const EMPTY_SEVERITIES: string[] = []; + +const urlSeverityFilter = computed(() => { + const severities = getParamValues(PARAMS.SEVERITY) ?? EMPTY_SEVERITIES; + + if (severities.length === 0) { + return EMPTY_SEVERITIES; + } + + return severities.filter((severity) => validateSeverity(severity)); +}); + const EMPTY_TAGS: string[] = []; const urlTagsFilter = computed(() => { @@ -192,6 +208,22 @@ const treeTransitionFilter = computed(() => ({ })), })); +const treeSeverityFilter = computed(() => ({ + type: "group", + logicalOperator: "AND", + fieldKey: "severity", + value: urlSeverityFilter.value.map((severity) => ({ + type: "field", + logicalOperator: "OR", + value: { + key: "severity", + value: severity, + type: "string", + strict: true, + }, + })), +})); + const treeTagsFilter = computed(() => ({ type: "field", logicalOperator: "AND", @@ -219,6 +251,7 @@ export const treeQuickFilters = computed(() => [ treeFlakyFilter.value, treeResolutionFilter.value, treeTransitionFilter.value, + treeSeverityFilter.value, treeTagsFilter.value, treeCategoriesFilter.value, ]); @@ -233,6 +266,7 @@ export const hasActiveTreeFilters = computed(() => transition: urlTransitionFilter.value, tags: urlTagsFilter.value, categories: urlCategoriesFilter.value, + severity: urlSeverityFilter.value, }), ); @@ -268,6 +302,10 @@ export const treeNonQueryFilters = computed(() => { filters.push(treeTransitionFilter.value); } + if (urlSeverityFilter.value.length > 0) { + filters.push(toSeverityPredicateFilter(treeSeverityFilter.value)); + } + if (urlTagsFilter.value.length > 0) { filters.push(treeTagsFilter.value); } @@ -296,6 +334,18 @@ export const setTreeFilter = (filter: AwesomeFilter) => { setTransitionFilter(transitions); } + if (isSeverityFilter(filter)) { + const severities: string[] = []; + + for (const v of filter.value) { + if (v.type === "field" && v.value.type === "string" && v.value.key === "severity") { + severities.push(v.value.value); + } + } + + setSeverityFilter(severities); + } + if (isRetryFilter(filter)) { setRetryFilter(filter.value.value); } diff --git a/packages/web-awesome/src/stores/treeFilters/utils.ts b/packages/web-awesome/src/stores/treeFilters/utils.ts index 1f554221a23..825acba8517 100644 --- a/packages/web-awesome/src/stores/treeFilters/utils.ts +++ b/packages/web-awesome/src/stores/treeFilters/utils.ts @@ -1,7 +1,7 @@ import type { ResolutionCategory, TestStatus, TestStatusTransition } from "@allurereport/core-api"; import { MAX_ARRAY_FIELD_VALUES, getCurrentUrl, goTo } from "@allurereport/web-commons"; -import { PARAMS, RESOLUTIONS, STATUSES, TRANSITIONS } from "./constants"; +import { NO_SEVERITY, PARAMS, RESOLUTIONS, SEVERITIES, STATUSES, TRANSITIONS } from "./constants"; import type { AwesomeArrayFieldFilter, AwesomeBooleanFieldFilter, @@ -35,6 +35,10 @@ export const validateStatus = (status: string): status is TestStatus => { return STATUSES.includes(status as TestStatus); }; +export const validateSeverity = (severity: string): boolean => { + return SEVERITIES.includes(severity); +}; + export const validateResolution = (resolution: string): resolution is ResolutionCategory => { return RESOLUTIONS.includes(resolution as ResolutionCategory); }; @@ -93,7 +97,8 @@ export const hasActiveFilters = (filters: Filters): boolean => { (filters.resolution && filters.resolution.length > 0) || (filters.transition && filters.transition.length > 0) || (filters.tags && filters.tags.length > 0) || - (filters.categories && filters.categories.length > 0) + (filters.categories && filters.categories.length > 0) || + (filters.severity && filters.severity.length > 0) ); }; @@ -140,6 +145,12 @@ export const constructFilterParams = (filters: Filters) => { }); } + if (filters.severity) { + filters.severity.forEach((severity) => { + params.append(PARAMS.SEVERITY, severity); + }); + } + if (filters.status) { params.set(PARAMS.STATUS, filters.status); } @@ -170,3 +181,31 @@ export const isCategoryFilter = (filter: AwesomeFilter): filter is AwesomeArrayF export const isTransitionFilter = (filter: AwesomeFilter): filter is AwesomeFilterGroupSimple => { return filter.type === "group" && filter.fieldKey === "transition"; }; + +export const isSeverityFilter = (filter: AwesomeFilter): filter is AwesomeFilterGroupSimple => { + return filter.type === "group" && filter.fieldKey === "severity"; +}; + +/** + * Converts the severity filter group into the form used to match tree leaves. + * + * Test results without a severity label have no `severity` property, so the "no severity" option + * has to match the missing property instead of comparing it to a value. + */ +export const toSeverityPredicateFilter = (group: AwesomeFilterGroupSimple): AwesomeFilterGroupSimple => ({ + ...group, + value: group.value.map((filter) => { + if (filter.value.type !== "string" || filter.value.value !== NO_SEVERITY) { + return filter; + } + + return { + ...filter, + value: { + key: filter.value.key, + value: null, + type: "null", + }, + }; + }), +}); diff --git a/packages/web-awesome/test/components/ReportFilters.test.tsx b/packages/web-awesome/test/components/ReportFilters.test.tsx index 8fde8a98637..6f2abb67b6f 100644 --- a/packages/web-awesome/test/components/ReportFilters.test.tsx +++ b/packages/web-awesome/test/components/ReportFilters.test.tsx @@ -19,6 +19,7 @@ vi.mock("@/stores/treeFilters/utils", () => ({ isFlakyFilter: () => false, isResolutionFilter: () => false, isRetryFilter: () => false, + isSeverityFilter: () => false, isTagFilter: () => false, isTransitionFilter: () => false, })); @@ -37,6 +38,10 @@ vi.mock("@/components/ReportFilters/TransitionFilter", () => ({ TransitionFilter: () => null, })); +vi.mock("@/components/ReportFilters/SeverityFilter", () => ({ + SeverityFilter: () => null, +})); + vi.mock("@/components/ReportFilters/ResolutionFilter", () => ({ ResolutionFilter: () => null, })); diff --git a/packages/web-awesome/test/components/SeverityFilter.test.tsx b/packages/web-awesome/test/components/SeverityFilter.test.tsx new file mode 100644 index 00000000000..b15bfb7d7dd --- /dev/null +++ b/packages/web-awesome/test/components/SeverityFilter.test.tsx @@ -0,0 +1,126 @@ +import { render } from "@testing-library/preact"; +import { epic, feature, label, story } from "allure-js-commons"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +beforeEach(async () => { + await epic("coverage"); + await feature("filters"); + await story("severity"); + await label("coverage", "filters"); +}); + +const { multipleChoiceFieldFilterMock } = vi.hoisted(() => ({ + multipleChoiceFieldFilterMock: vi.fn(), +})); + +vi.mock("@/components/ReportFilters/BaseFilters", () => ({ + MultipleChoiceFieldFilter: (props: unknown) => { + multipleChoiceFieldFilterMock(props); + + return null; + }, +})); + +vi.mock("@allurereport/web-components", () => ({ + allureIcons: new Proxy({} as Record, { + get: (_target, key) => String(key), + }), +})); + +vi.mock("@/stores", () => ({ + useI18n: (namespace: string) => ({ + t: (key: string) => `${namespace}:${key}`, + }), +})); + +import { SeverityFilter } from "@/components/ReportFilters/SeverityFilter"; +import type { AwesomeFilterGroupSimple } from "@/stores/treeFilters/model"; + +const emptyGroup: AwesomeFilterGroupSimple = { + type: "group", + logicalOperator: "AND", + fieldKey: "severity", + value: [], +}; + +const lastProps = () => + multipleChoiceFieldFilterMock.mock.calls.at(-1)?.[0] as { + options: { key: string; label: string; icon: string }[]; + fieldKey: string; + logicalOperator: string; + strict: boolean; + label: string; + testId: string; + onClear: () => void; + }; + +describe("components > SeverityFilter", () => { + beforeEach(() => { + multipleChoiceFieldFilterMock.mockReset(); + }); + + it("should offer every severity level plus the missing severity option", () => { + render(); + + expect(lastProps().options.map(({ key }) => key)).toEqual([ + "blocker", + "critical", + "normal", + "minor", + "trivial", + "none", + ]); + }); + + it("should label the options from the severity namespace", () => { + render(); + + const { options, label: filterLabel } = lastProps(); + + expect(filterLabel).toBe("filters:severity"); + expect(options.map(({ label: optionLabel }) => optionLabel)).toEqual([ + "Severity:blocker", + "Severity:critical", + "Severity:normal", + "Severity:minor", + "Severity:trivial", + "Severity:none", + ]); + }); + + it("should give every option an icon", () => { + render(); + + expect(lastProps().options.every(({ icon }) => Boolean(icon))).toBe(true); + }); + + it("should apply the selected severities with OR", () => { + render(); + + const { fieldKey, logicalOperator, strict } = lastProps(); + + expect(fieldKey).toBe("severity"); + expect(logicalOperator).toBe("OR"); + expect(strict).toBe(true); + }); + + it("should reset the whole group when cleared", () => { + const onChange = vi.fn(); + const group: AwesomeFilterGroupSimple = { + ...emptyGroup, + value: [ + { + type: "field", + logicalOperator: "OR", + value: { key: "severity", value: "blocker", type: "string", strict: true }, + }, + ], + }; + + render(); + + lastProps().onClear(); + + expect(onChange).toHaveBeenCalledWith({ ...group, value: [] }); + }); +}); diff --git a/packages/web-awesome/test/stores/treeFilters/actions.test.ts b/packages/web-awesome/test/stores/treeFilters/actions.test.ts index d9d38cec515..0a65c38243d 100644 --- a/packages/web-awesome/test/stores/treeFilters/actions.test.ts +++ b/packages/web-awesome/test/stores/treeFilters/actions.test.ts @@ -27,7 +27,11 @@ vi.mock("@allurereport/web-commons", async () => { import { ReportFetchError } from "@allurereport/web-commons"; -import { clearTreeFilterParams, fetchTreeFiltersData } from "../../../src/stores/treeFilters/actions.js"; +import { + clearTreeFilterParams, + fetchTreeFiltersData, + setSeverityFilter, +} from "../../../src/stores/treeFilters/actions.js"; import { clearTreeFilters } from "../../../src/stores/treeFilters/store.js"; import { treeCategories, treeFiltersResetNonce, treeTags } from "../../../src/stores/treeFilters/store.js"; @@ -105,14 +109,27 @@ describe("stores > treeFilters > actions", () => { { key: "transition", value: [] }, { key: "tags", value: [] }, { key: "categories", value: [] }, + { key: "severity", value: [] }, { key: "status", value: undefined }, ); }); + it("should write severity values to the url as a repeated param", () => { + setSeverityFilter(["blocker", "none"]); + + expect(setParamsMock).toHaveBeenCalledWith({ key: "severity", value: ["blocker", "none"] }); + }); + + it("should clear the severity param when no severity is selected", () => { + setSeverityFilter([]); + + expect(setParamsMock).toHaveBeenCalledWith({ key: "severity", value: [] }); + }); + it("should reset filter params in a single URL update", () => { clearTreeFilterParams(); expect(setParamsMock).toHaveBeenCalledTimes(1); - expect(setParamsMock.mock.calls[0]).toHaveLength(8); + expect(setParamsMock.mock.calls[0]).toHaveLength(9); }); }); diff --git a/packages/web-awesome/test/stores/treeFilters/store.test.ts b/packages/web-awesome/test/stores/treeFilters/store.test.ts index 2f0833923af..c7a419f1dfd 100644 --- a/packages/web-awesome/test/stores/treeFilters/store.test.ts +++ b/packages/web-awesome/test/stores/treeFilters/store.test.ts @@ -3,7 +3,19 @@ import { buildFilterPredicate, setParams } from "@allurereport/web-commons"; import { epic, feature, label, story } from "allure-js-commons"; import { beforeEach, describe, expect, it } from "vitest"; -import { hasActiveTreeFilters, treeNonQueryFilters } from "../../../src/stores/treeFilters/store.js"; +import type { AwesomeFilterGroupSimple } from "../../../src/stores/treeFilters/model.js"; +import { + hasActiveTreeFilters, + setTreeFilter, + treeNonQueryFilters, + treeQuickFilters, +} from "../../../src/stores/treeFilters/store.js"; +import { isSeverityFilter, isTransitionFilter } from "../../../src/stores/treeFilters/utils.js"; + +const setSearch = (search: string) => { + window.history.replaceState(null, "", `/${search}`); + window.dispatchEvent(new Event("replaceState")); +}; beforeEach(async () => { await epic("coverage"); @@ -11,16 +23,146 @@ beforeEach(async () => { await story("store"); await label("coverage", "filters"); - setParams( - { key: "query", value: undefined }, - { key: "status", value: undefined }, - { key: "retry", value: undefined }, - { key: "flaky", value: undefined }, - { key: "resolution", value: [] }, - { key: "transition", value: [] }, - { key: "tags", value: [] }, - { key: "categories", value: [] }, - ); + setSearch(""); +}); + +const severityQuickFilter = () => { + const filter = treeQuickFilters.value.find(isSeverityFilter); + + expect(filter).toBeDefined(); + + return filter as AwesomeFilterGroupSimple; +}; + +const selectedSeverities = (group: AwesomeFilterGroupSimple) => + group.value.map((v) => (v.type === "field" && v.value.type === "string" ? v.value.value : undefined)); + +describe("stores > treeFilters > severity", () => { + it("should always expose the severity quick filter", () => { + expect(selectedSeverities(severityQuickFilter())).toEqual([]); + }); + + it("should read selected severities from the url", () => { + setSearch("?severity=blocker&severity=none"); + + expect(selectedSeverities(severityQuickFilter())).toEqual(["blocker", "none"]); + expect(hasActiveTreeFilters.value).toBe(true); + }); + + it("should drop unknown severity values", () => { + setSearch("?severity=urgent&severity=critical"); + + expect(selectedSeverities(severityQuickFilter())).toEqual(["critical"]); + }); + + it("should not apply the severity filter when nothing is selected", () => { + expect(treeNonQueryFilters.value.filter(isSeverityFilter)).toHaveLength(0); + expect(hasActiveTreeFilters.value).toBe(false); + }); + + it("should apply the severity filter when values are selected", () => { + setSearch("?severity=blocker"); + + expect(treeNonQueryFilters.value.filter(isSeverityFilter)).toHaveLength(1); + }); + + it("should write the selected severities back to the url", () => { + const group = severityQuickFilter(); + + setTreeFilter({ + ...group, + value: [ + { + type: "field", + value: { key: "severity", value: "minor", type: "string", strict: true }, + logicalOperator: "OR", + }, + ], + }); + + expect(new URL(window.location.href).searchParams.getAll("severity")).toEqual(["minor"]); + expect(selectedSeverities(severityQuickFilter())).toEqual(["minor"]); + }); + + describe("filter predicate", () => { + const leaves: { nodeId: string; severity?: string }[] = [ + { nodeId: "1", severity: "blocker" }, + { nodeId: "2", severity: "critical" }, + { nodeId: "3", severity: "normal" }, + { nodeId: "4" }, + ]; + + const matchingNodeIds = () => { + const predicate = buildFilterPredicate(treeNonQueryFilters.value); + + return leaves.filter(predicate).map(({ nodeId }) => nodeId); + }; + + it("should match a single selected severity", () => { + setSearch("?severity=blocker"); + + expect(matchingNodeIds()).toEqual(["1"]); + }); + + it("should match any of the selected severities", () => { + setSearch("?severity=blocker&severity=critical"); + + expect(matchingNodeIds()).toEqual(["1", "2"]); + }); + + it("should match test results without a severity property", () => { + setSearch("?severity=none"); + + expect(matchingNodeIds()).toEqual(["4"]); + }); + + it("should match selected severities next to test results without a severity property", () => { + setSearch("?severity=blocker&severity=none"); + + expect(matchingNodeIds()).toEqual(["1", "4"]); + }); + + it("should combine the severity filter with other filters", () => { + setSearch("?severity=blocker&severity=none&status=failed"); + + const predicate = buildFilterPredicate(treeNonQueryFilters.value); + const statusLeaves: { nodeId: string; severity?: string; status: string }[] = [ + { nodeId: "1", severity: "blocker", status: "failed" }, + { nodeId: "2", severity: "blocker", status: "passed" }, + { nodeId: "3", status: "failed" }, + { nodeId: "4", severity: "normal", status: "failed" }, + ]; + + expect(statusLeaves.filter(predicate).map(({ nodeId }) => nodeId)).toEqual(["1", "3"]); + }); + }); +}); + +describe("stores > treeFilters > transition", () => { + const leaves = [ + { nodeId: "1", transition: "new" }, + { nodeId: "2", transition: "fixed" }, + { nodeId: "3", transition: "regressed" }, + ]; + + const matchingNodeIds = () => { + const predicate = buildFilterPredicate(treeNonQueryFilters.value); + + return leaves.filter(predicate).map(({ nodeId }) => nodeId); + }; + + it("should match a single selected transition", () => { + setSearch("?transition=new"); + + expect(matchingNodeIds()).toEqual(["1"]); + }); + + it("should match any of the selected transitions", () => { + setSearch("?transition=new&transition=fixed"); + + expect(treeNonQueryFilters.value.filter(isTransitionFilter)).toHaveLength(1); + expect(matchingNodeIds()).toEqual(["1", "2"]); + }); }); const leaf = (params: { diff --git a/packages/web-awesome/test/stores/treeFilters/utils.test.ts b/packages/web-awesome/test/stores/treeFilters/utils.test.ts index 3abcc658d2a..c150e8c4db3 100644 --- a/packages/web-awesome/test/stores/treeFilters/utils.test.ts +++ b/packages/web-awesome/test/stores/treeFilters/utils.test.ts @@ -1,8 +1,14 @@ import { epic, feature, label, story } from "allure-js-commons"; import { beforeEach, describe, expect, it } from "vitest"; -import type { Filters } from "../../../src/stores/treeFilters/model.js"; -import { constructFilterParams, hasActiveFilters } from "../../../src/stores/treeFilters/utils.js"; +import type { AwesomeFilter, AwesomeFilterGroupSimple, Filters } from "../../../src/stores/treeFilters/model.js"; +import { + constructFilterParams, + hasActiveFilters, + isSeverityFilter, + toSeverityPredicateFilter, + validateSeverity, +} from "../../../src/stores/treeFilters/utils.js"; beforeEach(async () => { await epic("coverage"); @@ -28,6 +34,8 @@ describe("stores > treeFilters > utils", () => { [{ transition: ["new"] }, "transition"], [{ tags: ["smoke"] }, "tags"], [{ categories: ["Product Bug"] }, "categories"], + [{ severity: ["blocker"] }, "severity"], + [{ severity: ["none"] }, "severity"], ])("should return true when %s filter is active", (filters) => { expect(hasActiveFilters({ ...defaultFilters, ...filters })).toBe(true); }); @@ -43,12 +51,108 @@ describe("stores > treeFilters > utils", () => { resolution: [], tags: [], categories: [], + severity: [], }), ).toBe(false); }); }); + describe("validateSeverity", () => { + it.each(["blocker", "critical", "normal", "minor", "trivial", "none"])( + "should accept the known severity value %s", + (severity) => { + expect(validateSeverity(severity)).toBe(true); + }, + ); + + it.each(["", "urgent", "Blocker", "NONE"])("should reject the unknown severity value %s", (severity) => { + expect(validateSeverity(severity)).toBe(false); + }); + }); + + describe("isSeverityFilter", () => { + const severityFilter: AwesomeFilter = { + type: "group", + logicalOperator: "AND", + fieldKey: "severity", + value: [ + { + type: "field", + value: { key: "severity", value: "blocker", type: "string", strict: true }, + logicalOperator: "OR", + }, + ], + }; + + it("should detect the severity filter group", () => { + expect(isSeverityFilter(severityFilter)).toBe(true); + }); + + it("should not detect other filter groups", () => { + expect(isSeverityFilter({ ...severityFilter, fieldKey: "transition" })).toBe(false); + }); + + it("should not detect plain field filters", () => { + expect( + isSeverityFilter({ + type: "field", + logicalOperator: "AND", + value: { key: "severity", value: "blocker", type: "string", strict: true }, + }), + ).toBe(false); + }); + }); + + describe("toSeverityPredicateFilter", () => { + const severityGroup = (...severities: string[]): AwesomeFilterGroupSimple => ({ + type: "group", + logicalOperator: "AND", + fieldKey: "severity", + value: severities.map((severity) => ({ + type: "field", + logicalOperator: "OR", + value: { key: "severity", value: severity, type: "string", strict: true }, + })), + }); + + it("should keep the assigned severities as they are", () => { + const group = severityGroup("blocker", "minor"); + + expect(toSeverityPredicateFilter(group)).toEqual(group); + }); + + it("should match the missing property for the no severity option", () => { + expect(toSeverityPredicateFilter(severityGroup("none"))).toEqual({ + type: "group", + logicalOperator: "AND", + fieldKey: "severity", + value: [ + { + type: "field", + logicalOperator: "OR", + value: { key: "severity", value: null, type: "null" }, + }, + ], + }); + }); + + it("should convert only the no severity option of a mixed selection", () => { + const { value } = toSeverityPredicateFilter(severityGroup("blocker", "none")); + + expect(value.map((filter) => filter.value)).toEqual([ + { key: "severity", value: "blocker", type: "string", strict: true }, + { key: "severity", value: null, type: "null" }, + ]); + }); + }); + describe("constructFilterParams", () => { + it("should serialize severity values", () => { + const params = constructFilterParams({ severity: ["blocker", "none"] }); + + expect(params.getAll("severity")).toEqual(["blocker", "none"]); + }); + it("should write resolution category filter params", () => { const params = constructFilterParams({ resolution: ["issue", "muted"] }); diff --git a/packages/web-commons/src/filters/builders.ts b/packages/web-commons/src/filters/builders.ts index b13b6de9442..64cf5f937f3 100644 --- a/packages/web-commons/src/filters/builders.ts +++ b/packages/web-commons/src/filters/builders.ts @@ -73,6 +73,12 @@ const buildAqlFromFieldFilter = ( valueType = "STRING"; break; } + case "null": { + expressionValue = "null"; + valueType = "NULL"; + operator = "EQ"; + break; + } default: { // This should never happen with proper TypeScript typing const exhaustiveCheck: never = type; diff --git a/packages/web-commons/src/filters/index.ts b/packages/web-commons/src/filters/index.ts index 902f5634af6..c3807dc1d7c 100644 --- a/packages/web-commons/src/filters/index.ts +++ b/packages/web-commons/src/filters/index.ts @@ -7,6 +7,7 @@ export type { FieldFilterGroup, Filter, LogicalOperator, + NullField, NumberField, StringField, } from "./model.js"; diff --git a/packages/web-commons/src/filters/model.ts b/packages/web-commons/src/filters/model.ts index 5eb3d496cff..e9408e3167d 100644 --- a/packages/web-commons/src/filters/model.ts +++ b/packages/web-commons/src/filters/model.ts @@ -33,6 +33,16 @@ export type BooleanField = { type: "boolean"; }; +/** + * Matches items where the field holds no value at all, i.e. it's `null` or missing. + */ +export type NullField = { + key: T; + value: null; + strict?: never; + type: "null"; +}; + export type ArrayField = { key: T; value: string[]; @@ -40,10 +50,15 @@ export type ArrayField = { type: "array"; }; -export type Field = StringField | NumberField | BooleanField | ArrayField; +export type Field = + | StringField + | NumberField + | BooleanField + | ArrayField + | NullField; export type LogicalOperator = "AND" | "OR"; -export type AqlValueType = "STRING" | "NUMBER" | "BOOLEAN"; +export type AqlValueType = "STRING" | "NUMBER" | "BOOLEAN" | "NULL"; export const MAX_ARRAY_FIELD_VALUES = 20; diff --git a/packages/web-commons/test/filters/builders.test.ts b/packages/web-commons/test/filters/builders.test.ts index 0c03c3b3127..0da147b9996 100644 --- a/packages/web-commons/test/filters/builders.test.ts +++ b/packages/web-commons/test/filters/builders.test.ts @@ -126,6 +126,27 @@ describe("filters > builders", () => { }); }); + test("should build AQL expression for null field", () => { + const filter: Filter = { + type: "field", + value: { + key: "severity", + value: null, + type: "null", + }, + logicalOperator: "AND", + }; + + const result = buildFieldFilters([filter]); + + expect(result).toMatchObject({ + type: "condition", + left: { identifier: "severity" }, + operator: "EQ", + right: { value: "null", type: "NULL" }, + }); + }); + test("should build AQL expression for array field (strict mode - IN operator)", () => { const filter: Filter = { type: "field", @@ -528,6 +549,23 @@ describe("filters > builders", () => { expect(result[0].age).toBe(25); }); + test("should create predicate for null field matching missing and null values", () => { + const items = [{ name: "test1", severity: "blocker" }, { name: "test2" }, { name: "test3", severity: null }]; + + const filters: Filter[] = [ + { + type: "field", + value: { key: "severity", value: null, type: "null" }, + logicalOperator: "AND", + }, + ]; + + const predicate = buildFilterPredicate(filters); + const result = items.filter(predicate); + + expect(result.map(({ name }) => name)).toEqual(["test2", "test3"]); + }); + test("should create predicate for boolean field", () => { const items = [ { flaky: true, name: "test1" },