From 30037f3ceaef6491cbd6d345ec999a6e59a2537d Mon Sep 17 00:00:00 2001 From: Benoit Goupilleau Date: Fri, 26 Jun 2026 15:12:49 +0200 Subject: [PATCH 1/4] feat: added SummaryTab using _testsummary.jsonl artifact --- ui/helpers/testSummary.js | 221 ++++++++++++++++++ ui/job-view/details/tabs/TabsPanel.jsx | 105 +++++++-- .../details/tabs/summaryTab/SummaryItem.jsx | 94 ++++++++ .../details/tabs/summaryTab/SummaryTab.jsx | 210 +++++++++++++++++ ui/shared/JobArtifacts.jsx | 99 ++++++-- 5 files changed, 685 insertions(+), 44 deletions(-) create mode 100644 ui/helpers/testSummary.js create mode 100644 ui/job-view/details/tabs/summaryTab/SummaryItem.jsx create mode 100644 ui/job-view/details/tabs/summaryTab/SummaryTab.jsx diff --git a/ui/helpers/testSummary.js b/ui/helpers/testSummary.js new file mode 100644 index 00000000000..eaea7bdfd3b --- /dev/null +++ b/ui/helpers/testSummary.js @@ -0,0 +1,221 @@ +import { getSearchWords } from './display'; + +// Parses the contents of a `*_testsummary.jsonl` artifact into a structured +// object for the job-view Summary tab. +// +// Each line of the artifact is a JSON object describing an event. The events we +// care about are: +// +// {"action": "test_result", "group": "", "test": "", +// "status": "PASS", "start": , "end": , "message": "..."} +// +// {"action": "crash", "group": "", "test": "", +// "signature": "", ...} +// +// A single test can appear more than once (e.g. when it is retried), and a few +// results carry no `group`. We regroup the lines first by test (collapsing the +// repeated runs of the same test into one entry), then by group. + +export const NO_GROUP = '(no group)'; + +// Status buckets we report counts for. Anything unexpected falls back to its +// raw status string so nothing is silently dropped. +export const TEST_STATUSES = [ + 'PASS', + 'FAIL', + 'SKIP', + 'TIMEOUT', + 'ERROR', + 'CRASH', +]; + +const safeParse = (line) => { + try { + return JSON.parse(line); + } catch { + return null; + } +}; + +const parseLines = (content) => { + if (Array.isArray(content)) { + // Already an array of parsed objects or raw line strings. + return content + .map((line) => (typeof line === 'string' ? safeParse(line) : line)) + .filter(Boolean); + } + + if (typeof content !== 'string') { + return []; + } + + return content + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map(safeParse) + .filter(Boolean); +}; + +const emptyCounts = () => { + const counts = { total: 0 }; + TEST_STATUSES.forEach((status) => { + counts[status] = 0; + }); + return counts; +}; + +const tallyStatus = (counts, status) => { + counts.total += 1; + if (status in counts) { + counts[status] += 1; + } else { + counts[status] = (counts[status] || 0) + 1; + } +}; + +const durationOf = ({ start, end }) => + Number.isFinite(start) && Number.isFinite(end) ? end - start : null; + +/** + * Build the Summary tab data from a `*_testsummary.jsonl` artifact. + * + * @param {string|Array} content Raw artifact text, or an array of raw lines / + * already-parsed event objects. + * @returns {{ + * groups: Array<{ + * name: string, + * counts: Object, + * tests: Array<{ + * name: string, + * status: string, + * success: boolean, + * retried: boolean, + * results: Array<{ status: string, success: boolean, message: ?string, start: ?number, end: ?number, duration: ?number }>, + * }>, + * }>, + * counts: Object, + * }} + */ +export const buildTestSummary = (content) => { + const lines = parseLines(content); + + // group name -> Map(test name -> { name, results }) + const groups = new Map(); + + lines.forEach((line) => { + if (!line) return; + + let entry; + if (line.action === 'test_result' && line.test) { + entry = { + test: line.test, + group: line.group, + status: line.status, + success: !('expected' in line), + message: line.message || null, + start: Number.isFinite(line.start) ? line.start : null, + end: Number.isFinite(line.end) ? line.end : null, + duration: durationOf(line), + }; + } else if (line.action === 'crash') { + const testName = line.test || line.signature || '(unknown test)'; + entry = { + test: testName, + group: line.group, + status: 'CRASH', + success: false, + message: line.signature || null, + start: null, + end: null, + duration: null, + }; + } else { + return; + } + + const groupName = entry.group || NO_GROUP; + if (!groups.has(groupName)) { + groups.set(groupName, new Map()); + } + const tests = groups.get(groupName); + + if (!tests.has(entry.test)) { + tests.set(entry.test, { name: entry.test, results: [] }); + } + tests.get(entry.test).results.push({ + status: entry.status, + success: entry.success, + message: entry.message, + start: entry.start, + end: entry.end, + duration: entry.duration, + }); + }); + + const overallCounts = emptyCounts(); + // Per-status counts for tests where success=false (unexpected outcomes). + const overallRealFailCounts = {}; + + const groupList = Array.from(groups.entries()).map(([name, tests]) => { + const groupCounts = emptyCounts(); + + const testList = Array.from(tests.values()).map((test) => { + // The "final" status/success is the last run of the test (handles retries). + const lastResult = test.results[test.results.length - 1]; + const { status, success } = lastResult; + tallyStatus(groupCounts, status); + tallyStatus(overallCounts, status); + if (!success) { + overallRealFailCounts[status] = + (overallRealFailCounts[status] || 0) + 1; + } + return { + ...test, + status, + success, + retried: test.results.length > 1, + }; + }); + + return { name, counts: groupCounts, tests: testList }; + }); + + return { + groups: groupList, + counts: overallCounts, + realFailCounts: overallRealFailCounts, + }; +}; + +/** + * Build a flat, `bug_suggestions`-API-shaped list of the failing tests in a + * `buildTestSummary()` result, for use by the Summary tab (which + * reuses BugFiler/InternalIssueFiler but has no Bugzilla suggestion data). + * + * @param {ReturnType|null} summary + * @returns {Array<{ search: string, path_end: string, search_terms: string[], bugs: { open_recent: [], all_others: [] } }>} + */ +export const buildFailureSuggestions = (summary) => { + if (!summary) return []; + + const suggestions = []; + summary.groups.forEach((group) => { + group.tests.forEach((test) => { + if (test.success) return; + const lastResult = test.results[test.results.length - 1]; + const search = `TEST-UNEXPECTED-${test.status} | ${test.name}${ + lastResult.message ? ` | ${lastResult.message}` : '' + }`; + suggestions.push({ + search, + path_end: test.name, + search_terms: getSearchWords(search), + bugs: { open_recent: [], all_others: [] }, + }); + }); + }); + return suggestions; +}; + +export default buildTestSummary; diff --git a/ui/job-view/details/tabs/TabsPanel.jsx b/ui/job-view/details/tabs/TabsPanel.jsx index 1098d69814d..535cbae33e7 100644 --- a/ui/job-view/details/tabs/TabsPanel.jsx +++ b/ui/job-view/details/tabs/TabsPanel.jsx @@ -24,38 +24,56 @@ import FailureSummaryTab from '../../../shared/tabs/failureSummary/FailureSummar import PerformanceTab from './PerformanceTab'; import AnnotationsTab from './AnnotationsTab'; import SimilarJobsTab from './SimilarJobsTab'; +import SummaryTab from './summaryTab/SummaryTab'; + +const SUMMARY_ARTIFACT_SUFFIX = '_testsummary.jsonl'; + +const getSummaryArtifact = (jobDetails = []) => + jobDetails.find((detail) => detail.value?.endsWith(SUMMARY_ARTIFACT_SUFFIX)); + +const hasSummaryArtifact = (jobDetails = []) => + !!getSummaryArtifact(jobDetails); const showTabsFromProps = (props) => { - const { perfJobDetail } = props; + const { perfJobDetail, jobDetails } = props; return { showPerf: !!perfJobDetail.length, + showSummary: hasSummaryArtifact(jobDetails), }; }; -const getTabNames = ({ showPerf }) => { +const getTabNames = ({ showPerf, showSummary }) => { // The order in here has to match the order within the render method return [ + 'summary', 'artifacts', 'failure', 'annotations', 'similar', 'perf', 'test-groups', - ].filter((name) => !(name === 'perf' && !showPerf)); + ].filter( + (name) => + !(name === 'perf' && !showPerf) && !(name === 'summary' && !showSummary), + ); }; const getDefaultTabIndex = (status, props) => { - const { showPerf } = showTabsFromProps(props); + const { showPerf, showSummary } = showTabsFromProps(props); let idx = 0; - const tabNames = getTabNames({ showPerf }); + const tabNames = getTabNames({ showPerf, showSummary }); const tabIndexes = tabNames.reduce( (acc, name) => ({ ...acc, [name]: idx++ }), {}, ); - let tabIndex = showPerf ? tabIndexes.perf : tabIndexes.artifacts; + let tabIndex = showSummary + ? tabIndexes.summary + : showPerf + ? tabIndexes.perf + : tabIndexes.artifacts; if (['busted', 'testfailed', 'exception'].includes(status)) { - tabIndex = tabIndexes.failure; + tabIndex = showSummary ? tabIndexes.summary : tabIndexes.failure; } return tabIndex; }; @@ -130,16 +148,41 @@ const TabsPanel = ({ } }); - const { showPerf } = showTabsFromProps({ perfJobDetail }); + const { showPerf, showSummary } = showTabsFromProps({ + perfJobDetail, + jobDetails, + }); const enableTestGroupsTab = testGroups && testGroups.length > 0; - // Create tab data array - const allTabs = [ - { key: 'artifacts', label: 'Artifacts and Debugging Tools', index: 0 }, - { key: 'failure', label: 'Failure Summary', index: 1 }, - { key: 'annotations', label: 'Annotations', index: 2 }, - { key: 'similar', label: 'Similar Jobs', index: 3 }, - ]; + // Create tab data array (order must match the render method) + const allTabs = []; + if (showSummary) { + allTabs.push({ + key: 'summary', + label: 'Summary', + index: allTabs.length, + }); + } + allTabs.push({ + key: 'artifacts', + label: 'Artifacts and Debugging Tools', + index: allTabs.length, + }); + allTabs.push({ + key: 'failure', + label: 'Failure Summary', + index: allTabs.length, + }); + allTabs.push({ + key: 'annotations', + label: 'Annotations', + index: allTabs.length, + }); + allTabs.push({ + key: 'similar', + label: 'Similar Jobs', + index: allTabs.length, + }); if (showPerf) { allTabs.push({ @@ -176,7 +219,7 @@ const TabsPanel = ({ setOverflowTabs([]); setShowOverflowDropdown(false); } - }, [perfJobDetail, testGroups]); + }, [perfJobDetail, testGroups, jobDetails]); const setupResizeObserver = useCallback(() => { if (tabListRef.current && window.ResizeObserver) { @@ -195,9 +238,11 @@ const TabsPanel = ({ const onSelectNextTab = useCallback(() => { const nextIndex = tabIndex + 1; - const tabCount = getTabNames(showTabsFromProps({ perfJobDetail })).length; + const tabCount = getTabNames( + showTabsFromProps({ perfJobDetail, jobDetails }), + ).length; setTabIndex(nextIndex < tabCount ? nextIndex : 0); - }, [tabIndex, perfJobDetail]); + }, [tabIndex, perfJobDetail, jobDetails]); const handleOverflowTabClick = useCallback((newTabIndex) => { setTabIndex(newTabIndex); @@ -211,12 +256,13 @@ const TabsPanel = ({ ) { const newTabIndex = getDefaultTabIndex(selectedJob.resultStatus, { perfJobDetail, + jobDetails, }); setTabIndex(newTabIndex); setJobId(selectedJob.id); setPerfJobDetailSize(perfJobDetail.length); } - }, [selectedJob, perfJobDetail, jobId, perfJobDetailSize]); + }, [selectedJob, perfJobDetail, jobId, perfJobDetailSize, jobDetails]); // Effect for setting up event listeners and resize observer useEffect(() => { @@ -241,7 +287,10 @@ const TabsPanel = ({ }, [checkTabOverflow]); const countPinnedJobs = Object.keys(pinnedJobs).length; - const { showPerf } = showTabsFromProps({ perfJobDetail }); + const { showPerf, showSummary } = showTabsFromProps({ + perfJobDetail, + jobDetails, + }); const enableTestGroupsTab = testGroups && testGroups.length > 0; return ( @@ -255,6 +304,7 @@ const TabsPanel = ({
+ {showSummary && Summary} Artifacts and Debugging Tools Failure Summary Annotations @@ -380,6 +430,21 @@ const TabsPanel = ({
+ {showSummary && ( + + + + )} { + const path = filterTestPath[0].replace(/\/$/, ''); + const filterParams = { + ...parseQueryParams(window.location.search), + test_paths: path, + }; + + return `${window.location.pathname}${createQueryParams(filterParams)}`; +}; + +const SummaryItem = ({ + suggestion, + toggleBugFiler, + toggleInternalIssueFiler, + selectedJob, + jobDetails, +}) => { + const filterTestPath = suggestion.search.match(/([a-z_\-0-9]+[/])+/gi); + const line = formatLogLineWithLinks( + suggestion.search, + jobDetails, + selectedJob, + ); + + return ( +
  • +
    + + + {line} + + {filterTestPath && !isReftest(selectedJob) && ( + + + + )} + + +
    +
  • + ); +}; + +SummaryItem.propTypes = { + selectedJob: PropTypes.shape({}).isRequired, + suggestion: PropTypes.shape({}).isRequired, + jobDetails: PropTypes.arrayOf( + PropTypes.shape({ + url: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + }), + ).isRequired, + toggleBugFiler: PropTypes.func.isRequired, + toggleInternalIssueFiler: PropTypes.func.isRequired, +}; + +export default SummaryItem; diff --git a/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx b/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx new file mode 100644 index 00000000000..59a07baec0f --- /dev/null +++ b/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx @@ -0,0 +1,210 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +import { + buildTestSummary, + buildFailureSuggestions, +} from '../../../../helpers/testSummary'; +import { thEvents } from '../../../../helpers/constants'; +import { isReftest } from '../../../../helpers/job'; +import { getReftestUrl } from '../../../../helpers/url'; +import BugFiler from '../../../../shared/BugFiler'; +import InternalIssueFiler from '../../../../shared/InternalIssueFiler'; + +import SummaryItem from './SummaryItem'; + +const SummaryTab = ({ + artifactUrl = null, + selectedJob, + jobLogUrls = [], + jobDetails = [], + logViewerFullUrl = null, + addBug = null, + pinJob, + currentRepo, +}) => { + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isBugFilerOpen, setIsBugFilerOpen] = useState(false); + const [isInternalIssueFilerOpen, setIsInternalIssueFilerOpen] = + useState(false); + const [activeSuggestion, setActiveSuggestion] = useState(null); + + useEffect(() => { + if (!artifactUrl) { + setSummary(null); + return undefined; + } + + let cancelled = false; + setIsLoading(true); + setError(null); + + fetch(artifactUrl) + .then((resp) => { + if (!resp.ok) { + throw new Error(`Failed to load summary (${resp.status})`); + } + return resp.text(); + }) + .then((text) => { + if (!cancelled) { + setSummary(buildTestSummary(text)); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err.message); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [artifactUrl]); + + const suggestions = useMemo( + () => buildFailureSuggestions(summary), + [summary], + ); + + const fileBug = useCallback( + (suggestion) => { + pinJob(selectedJob); + setActiveSuggestion(suggestion); + setIsBugFilerOpen(true); + }, + [pinJob, selectedJob], + ); + + const fileInternalIssue = useCallback( + (suggestion) => { + pinJob(selectedJob); + setActiveSuggestion(suggestion); + setIsInternalIssueFilerOpen(true); + }, + [pinJob, selectedJob], + ); + + const bugFilerCallback = async (data) => { + await addBug({ id: data.id, newBug: data.id }); + window.dispatchEvent(new CustomEvent(thEvents.saveClassification)); + window.open(data.url); + }; + + const internalIssueFilerCallback = async (data) => { + await addBug({ ...data, newBug: `i${data.internal_id}` }); + window.dispatchEvent(new CustomEvent(thEvents.saveClassification)); + }; + + if (isLoading) { + return ( +
    +

    + + Loading summary… +

    +
    + ); + } + + if (error) { + return ( +
    +

    {error}

    +
    + ); + } + + const logs = jobLogUrls.filter( + (jlu) => !jlu.name.includes('perfherder-data'), + ); + + return ( +
    + {!!summary && ( +

    + {summary.counts.total} tests:{' '} + + {summary.counts.PASS || 0} passed + + {', '} + {suggestions.length} failed + {summary.counts.SKIP > 0 && ( + <> + {', '} + {summary.counts.SKIP} skipped + + )} +

    + )} +
      + {suggestions.length === 0 && ( +
    • +

      + No failures found in the summary. +

      +
    • + )} + {suggestions.map((suggestion, index) => ( + + ))} +
    + {isBugFilerOpen && ( + setIsBugFilerOpen(false)} + suggestion={activeSuggestion} + suggestions={suggestions} + fullLog={logs[0]?.url} + parsedLog={logViewerFullUrl} + reftestUrl={ + isReftest(selectedJob) && logs[0] ? getReftestUrl(logs[0].url) : '' + } + successCallback={bugFilerCallback} + selectedJob={selectedJob} + currentRepo={currentRepo} + platform={selectedJob.platform} + /> + )} + {isInternalIssueFilerOpen && ( + setIsInternalIssueFilerOpen(false)} + jobGroupName={selectedJob.job_group_name} + jobTypeName={selectedJob.job_type_name} + successCallback={internalIssueFilerCallback} + /> + )} +
    + ); +}; + +SummaryTab.propTypes = { + artifactUrl: PropTypes.string, + selectedJob: PropTypes.shape({}).isRequired, + jobLogUrls: PropTypes.arrayOf(PropTypes.shape({})), + jobDetails: PropTypes.arrayOf(PropTypes.shape({})), + logViewerFullUrl: PropTypes.string, + addBug: PropTypes.func, + pinJob: PropTypes.func.isRequired, + currentRepo: PropTypes.shape({}).isRequired, +}; + +export default SummaryTab; diff --git a/ui/shared/JobArtifacts.jsx b/ui/shared/JobArtifacts.jsx index b81af88a693..0e7185e1f84 100644 --- a/ui/shared/JobArtifacts.jsx +++ b/ui/shared/JobArtifacts.jsx @@ -21,19 +21,39 @@ import { } from '../helpers/display'; // Pattern to match crash dump files: UUID.{dmp,extra,json} -const CRASH_DUMP_PATTERN = /^([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\.(dmp|extra|json)$/i; +const CRASH_DUMP_PATTERN = + /^([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\.(dmp|extra|json)$/i; -const ArtifactLink = ({ artifact, children = null }) => ( - - {children || artifact.value} - -); +// Browsers download .jsonl files instead of displaying them. Fetch the content +// and open it as a text/plain blob so it renders inline in a new tab. +const openAsPlainText = async (url, event) => { + event.preventDefault(); + try { + const resp = await fetch(url); + const text = await resp.text(); + const blob = new Blob([text], { type: 'text/plain' }); + const blobUrl = URL.createObjectURL(blob); + window.open(blobUrl, '_blank', 'noopener,noreferrer'); + } catch { + window.open(url, '_blank', 'noopener,noreferrer'); + } +}; + +const ArtifactLink = ({ artifact, children = null }) => { + const isJsonl = artifact.value?.endsWith('.jsonl'); + return ( + openAsPlainText(artifact.url, e) : undefined} + > + {children || artifact.value} + + ); +}; ArtifactLink.propTypes = { artifact: PropTypes.shape({ @@ -64,7 +84,11 @@ export default class JobArtifacts extends React.PureComponent { this.setState((prev) => { const toggle = { asc: 'desc', desc: 'asc' }; const sortDir = - prev.sortKey === key ? toggle[prev.sortDir] : key === 'size' ? 'desc' : 'asc'; + prev.sortKey === key + ? toggle[prev.sortDir] + : key === 'size' + ? 'desc' + : 'asc'; return { sortKey: key, sortDir }; }); }; @@ -90,7 +114,9 @@ export default class JobArtifacts extends React.PureComponent { shouldShowPernoscoLink(repoName, selectedJob) { return ( - (repoName === 'try' || repoName === 'autoland' || repoName === 'enterprise-firefox-pr') && + (repoName === 'try' || + repoName === 'autoland' || + repoName === 'enterprise-firefox-pr') && selectedJob && selectedJob.task_id && selectedJob.result === 'testfailed' && @@ -173,8 +199,12 @@ export default class JobArtifacts extends React.PureComponent { const mul = sortDir === 'asc' ? 1 : -1; const sortedDetails = rows.sort((a, b) => { if (sortKey === 'size') { - const av = Number.isFinite(a.contentLength) ? a.contentLength : -Infinity; - const bv = Number.isFinite(b.contentLength) ? b.contentLength : -Infinity; + const av = Number.isFinite(a.contentLength) + ? a.contentLength + : -Infinity; + const bv = Number.isFinite(b.contentLength) + ? b.contentLength + : -Infinity; return (av - bv) * mul; } if (sortKey === 'expires') { @@ -212,7 +242,11 @@ export default class JobArtifacts extends React.PureComponent { {this.sortHeader('name', 'Name')} - {this.sortHeader('expires', 'Expires in', 'text-nowrap text-end')} + {this.sortHeader( + 'expires', + 'Expires in', + 'text-nowrap text-end', + )} {this.sortHeader('size', 'Size', 'text-end')} @@ -224,14 +258,19 @@ export default class JobArtifacts extends React.PureComponent { return ( - + {', '} .extra {', '} - .json{' '} + + .json + {' '} -{' '} - + {formatExpires(line.expires)} - + {formatByteSize(line.contentLength)} @@ -272,7 +317,9 @@ export default class JobArtifacts extends React.PureComponent { return ( - {primaryUrl && } + {primaryUrl && ( + + )} {line.path && ( {line.path}/ )} @@ -296,14 +343,18 @@ export default class JobArtifacts extends React.PureComponent { className="text-end text-nowrap text-muted" title={formatExpiresTooltip(line.expires)} > - {primaryUrl && } + {primaryUrl && ( + + )} {formatExpires(line.expires)} - {primaryUrl && } + {primaryUrl && ( + + )} {formatByteSize(line.contentLength)} From 249673b2daa5bb8710b53f5680d7812c56c6a569 Mon Sep 17 00:00:00 2001 From: Benoit Goupilleau Date: Mon, 29 Jun 2026 15:47:45 +0200 Subject: [PATCH 2/4] feat: added bug suggestion in SummaryTab --- tests/ui/helpers/testSummary.test.js | 110 ++++++++++++++++++ ui/helpers/testSummary.js | 81 +++++++++++++ .../details/tabs/summaryTab/SummaryItem.jsx | 65 +++++++++++ .../details/tabs/summaryTab/SummaryTab.jsx | 34 +++++- 4 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 tests/ui/helpers/testSummary.test.js diff --git a/tests/ui/helpers/testSummary.test.js b/tests/ui/helpers/testSummary.test.js new file mode 100644 index 00000000000..8640011e9c7 --- /dev/null +++ b/tests/ui/helpers/testSummary.test.js @@ -0,0 +1,110 @@ +import { + buildTestSummary, + buildFailureSuggestions, + matchBugSuggestions, +} from '../../../ui/helpers/testSummary'; + +const lines = [ + { + action: 'test_result', + group: 'dom/manifest.ini', + test: 'dom/tests/test_pass.html', + status: 'PASS', + }, + { + action: 'test_result', + group: 'dom/manifest.ini', + test: 'dom/tests/test_fail.html', + status: 'FAIL', + expected: 'PASS', + message: 'assertion failed', + }, + { + action: 'test_result', + group: 'layout/manifest.ini', + test: 'layout/tests/test_other.html', + status: 'TIMEOUT', + expected: 'PASS', + }, +]; + +describe('matchBugSuggestions', () => { + const buildFailures = () => + buildFailureSuggestions(buildTestSummary(lines)); + + test('attaches bugs whose path_end matches a failing test', () => { + const bugSuggestions = [ + { + path_end: 'dom/tests/test_fail.html', + bugs: { + open_recent: [{ id: 123, internal_id: 1, summary: 'bug A' }], + all_others: [], + }, + }, + ]; + + const failures = matchBugSuggestions(buildFailures(), bugSuggestions); + const failed = failures.find( + (s) => s.path_end === 'dom/tests/test_fail.html', + ); + + expect(failed.bugs.open_recent).toHaveLength(1); + expect(failed.valid_open_recent).toBe(true); + expect(failed.showBugSuggestions).toBe(true); + + const unmatched = failures.find( + (s) => s.path_end === 'layout/tests/test_other.html', + ); + expect(unmatched.bugs.open_recent).toHaveLength(0); + expect(unmatched.showBugSuggestions).toBe(false); + }); + + test('matches when the API only stores the path tail', () => { + const bugSuggestions = [ + { + path_end: 'test_fail.html', + bugs: { open_recent: [{ id: 9, internal_id: 9 }], all_others: [] }, + }, + ]; + + const failures = matchBugSuggestions(buildFailures(), bugSuggestions); + const failed = failures.find( + (s) => s.path_end === 'dom/tests/test_fail.html', + ); + expect(failed.bugs.open_recent).toHaveLength(1); + }); + + test('merges and de-dupes bugs from multiple error lines of one test', () => { + const bugSuggestions = [ + { + path_end: 'dom/tests/test_fail.html', + bugs: { open_recent: [{ id: 1, internal_id: 1 }], all_others: [] }, + }, + { + path_end: 'dom/tests/test_fail.html', + bugs: { + open_recent: [ + { id: 1, internal_id: 1 }, + { id: 2, internal_id: 2 }, + ], + all_others: [], + }, + }, + ]; + + const failures = matchBugSuggestions(buildFailures(), bugSuggestions); + const failed = failures.find( + (s) => s.path_end === 'dom/tests/test_fail.html', + ); + expect(failed.bugs.open_recent.map((b) => b.id)).toEqual([1, 2]); + }); + + test('returns decorated suggestions even with no bug suggestions', () => { + const failures = matchBugSuggestions(buildFailures(), []); + expect(failures).toHaveLength(2); + failures.forEach((s) => { + expect(s.showBugSuggestions).toBe(false); + expect(s.valid_open_recent).toBe(false); + }); + }); +}); diff --git a/ui/helpers/testSummary.js b/ui/helpers/testSummary.js index eaea7bdfd3b..3c4c03ed789 100644 --- a/ui/helpers/testSummary.js +++ b/ui/helpers/testSummary.js @@ -1,4 +1,5 @@ import { getSearchWords } from './display'; +import { thBugSuggestionLimit } from './constants'; // Parses the contents of a `*_testsummary.jsonl` artifact into a structured // object for the job-view Summary tab. @@ -218,4 +219,84 @@ export const buildFailureSuggestions = (summary) => { return suggestions; }; +// Normalize a test path so the testsummary test name and the bug_suggestions +// `path_end` (which the backend trims/cleans) can be compared. We keep only the +// trailing slash-free form so a full manifest path matches its `path_end`. +const normalizePath = (path) => + (path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + +// True when a bug_suggestion's path matches a failing test's path. The backend +// may store either the full path or only its tail in `path_end`, so we accept +// an exact match or one being the suffix of the other. +const pathsMatch = (testPath, bugPath) => { + if (!testPath || !bugPath) return false; + const a = normalizePath(testPath); + const b = normalizePath(bugPath); + if (!a || !b) return false; + return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`); +}; + +const bugKey = (bug) => bug.id ?? bug.internal_id; + +const mergeBugs = (target, incoming) => { + const seen = new Set(target.map(bugKey)); + incoming.forEach((bug) => { + const key = bugKey(bug); + if (!seen.has(key)) { + seen.add(key); + target.push(bug); + } + }); +}; + +// Mirror the validity flags FailureSummaryTab derives from the bug_suggestions +// API, so SummaryItem can reuse the same display logic. +const decorateBugs = (suggestion) => { + const { bugs } = suggestion; + bugs.too_many_open_recent = bugs.open_recent.length > thBugSuggestionLimit; + bugs.too_many_all_others = bugs.all_others.length > thBugSuggestionLimit; + suggestion.valid_open_recent = + bugs.open_recent.length > 0 && !bugs.too_many_open_recent; + suggestion.valid_all_others = + bugs.all_others.length > 0 && + !bugs.too_many_all_others && + !bugs.too_many_open_recent; + suggestion.showBugSuggestions = + suggestion.valid_open_recent || suggestion.valid_all_others; +}; + +/** + * Enrich the testsummary-derived failure suggestions with the Bugzilla bug + * suggestions returned by the `/bug_suggestions/` API, matching on test path. + * + * The testsummary artifact gives us the canonical list of failing tests, but + * carries no Bugzilla data; the API gives us bugs keyed by log error line. We + * match the two by test path (`path_end`) and attach every matching bug to the + * corresponding failing test, merging when several error lines map to one test. + * + * @param {ReturnType} failureSuggestions + * @param {Array<{ path_end: ?string, bugs: { open_recent: [], all_others: [] } }>} bugSuggestions + * @returns {typeof failureSuggestions} the same suggestions, bugs attached. + */ +export const matchBugSuggestions = (failureSuggestions, bugSuggestions) => { + if (!failureSuggestions || !failureSuggestions.length) return failureSuggestions; + if (!bugSuggestions || !bugSuggestions.length) { + failureSuggestions.forEach(decorateBugs); + return failureSuggestions; + } + + failureSuggestions.forEach((suggestion) => { + const matches = bugSuggestions.filter((bugSuggestion) => + pathsMatch(suggestion.path_end, bugSuggestion.path_end), + ); + matches.forEach((match) => { + mergeBugs(suggestion.bugs.open_recent, match.bugs?.open_recent || []); + mergeBugs(suggestion.bugs.all_others, match.bugs?.all_others || []); + }); + decorateBugs(suggestion); + }); + + return failureSuggestions; +}; + export default buildTestSummary; diff --git a/ui/job-view/details/tabs/summaryTab/SummaryItem.jsx b/ui/job-view/details/tabs/summaryTab/SummaryItem.jsx index 60923441b44..0e956e2f248 100644 --- a/ui/job-view/details/tabs/summaryTab/SummaryItem.jsx +++ b/ui/job-view/details/tabs/summaryTab/SummaryItem.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import PropTypes from 'prop-types'; import { Button } from 'react-bootstrap'; import { Link } from 'react-router'; @@ -9,7 +10,9 @@ import { } from '@fortawesome/free-solid-svg-icons'; import Clipboard from '../../../../shared/Clipboard'; +import BugListItem from '../../../../shared/tabs/failureSummary/BugListItem'; import { isReftest } from '../../../../helpers/job'; +import { thBugSuggestionLimit } from '../../../../helpers/constants'; import { createQueryParams, parseQueryParams, @@ -32,7 +35,9 @@ const SummaryItem = ({ toggleInternalIssueFiler, selectedJob, jobDetails, + addBug = null, }) => { + const [showMore, setShowMore] = useState(false); const filterTestPath = suggestion.search.match(/([a-z_\-0-9]+[/])+/gi); const line = formatLogLineWithLinks( suggestion.search, @@ -40,6 +45,14 @@ const SummaryItem = ({ selectedJob, ); + const { bugs } = suggestion; + const showOpenRecent = suggestion.valid_open_recent; + const showAllOthers = + suggestion.valid_all_others && (showMore || !showOpenRecent); + const tooMany = + bugs?.too_many_open_recent || + (bugs?.too_many_all_others && !suggestion.valid_open_recent); + return (
  • @@ -73,6 +86,57 @@ const SummaryItem = ({
    + {suggestion.showBugSuggestions && ( +
    + {showOpenRecent && ( +
      + {bugs.open_recent.map((bug) => ( + + ))} +
    + )} + {showOpenRecent && suggestion.valid_all_others && ( + + )} + {showAllOthers && ( +
      + {bugs.all_others.map((bug) => ( + + ))} +
    + )} + {tooMany && ( + + Exceeded max {thBugSuggestionLimit} bug suggestions, most of which + are likely false positives. + + )} +
    + )}
  • ); }; @@ -89,6 +153,7 @@ SummaryItem.propTypes = { ).isRequired, toggleBugFiler: PropTypes.func.isRequired, toggleInternalIssueFiler: PropTypes.func.isRequired, + addBug: PropTypes.func, }; export default SummaryItem; diff --git a/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx b/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx index 59a07baec0f..a7e80ebe55e 100644 --- a/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx +++ b/ui/job-view/details/tabs/summaryTab/SummaryTab.jsx @@ -6,12 +6,14 @@ import { faSpinner } from '@fortawesome/free-solid-svg-icons'; import { buildTestSummary, buildFailureSuggestions, + matchBugSuggestions, } from '../../../../helpers/testSummary'; import { thEvents } from '../../../../helpers/constants'; import { isReftest } from '../../../../helpers/job'; import { getReftestUrl } from '../../../../helpers/url'; import BugFiler from '../../../../shared/BugFiler'; import InternalIssueFiler from '../../../../shared/InternalIssueFiler'; +import BugSuggestionsModel from '../../../../models/bugSuggestions'; import SummaryItem from './SummaryItem'; @@ -26,6 +28,7 @@ const SummaryTab = ({ currentRepo, }) => { const [summary, setSummary] = useState(null); + const [bugSuggestions, setBugSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isBugFilerOpen, setIsBugFilerOpen] = useState(false); @@ -71,9 +74,35 @@ const SummaryTab = ({ }; }, [artifactUrl]); + const jobId = selectedJob?.id; + + useEffect(() => { + if (!jobId) { + setBugSuggestions([]); + return undefined; + } + + let cancelled = false; + BugSuggestionsModel.get(jobId) + .then((data) => { + if (!cancelled) { + setBugSuggestions(Array.isArray(data) ? data : []); + } + }) + .catch(() => { + if (!cancelled) { + setBugSuggestions([]); + } + }); + + return () => { + cancelled = true; + }; + }, [jobId]); + const suggestions = useMemo( - () => buildFailureSuggestions(summary), - [summary], + () => matchBugSuggestions(buildFailureSuggestions(summary), bugSuggestions), + [summary, bugSuggestions], ); const fileBug = useCallback( @@ -162,6 +191,7 @@ const SummaryTab = ({ toggleInternalIssueFiler={fileInternalIssue} selectedJob={selectedJob} jobDetails={jobDetails} + addBug={addBug} /> ))} From 6b7bfe8eaf0163e647aa4fe2990c3a034e1a3c40 Mon Sep 17 00:00:00 2001 From: Benoit Goupilleau Date: Mon, 29 Jun 2026 16:08:48 +0200 Subject: [PATCH 3/4] feat: rm default select of Summary tab --- ui/job-view/details/tabs/TabsPanel.jsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/ui/job-view/details/tabs/TabsPanel.jsx b/ui/job-view/details/tabs/TabsPanel.jsx index 535cbae33e7..4bb10d6d4de 100644 --- a/ui/job-view/details/tabs/TabsPanel.jsx +++ b/ui/job-view/details/tabs/TabsPanel.jsx @@ -67,13 +67,9 @@ const getDefaultTabIndex = (status, props) => { {}, ); - let tabIndex = showSummary - ? tabIndexes.summary - : showPerf - ? tabIndexes.perf - : tabIndexes.artifacts; + let tabIndex = showPerf ? tabIndexes.perf : tabIndexes.artifacts; if (['busted', 'testfailed', 'exception'].includes(status)) { - tabIndex = showSummary ? tabIndexes.summary : tabIndexes.failure; + tabIndex = tabIndexes.failure; } return tabIndex; }; From 2e9ad7306cbe391ce46e4480bf91a7bb4f8383b4 Mon Sep 17 00:00:00 2001 From: Benoit Goupilleau Date: Tue, 30 Jun 2026 18:10:37 +0200 Subject: [PATCH 4/4] feat: updated how summary tab reads data --- tests/ui/helpers/testSummary.test.js | 151 +++++++++++++++++++++++- ui/helpers/testSummary.js | 166 +++++++++++++++++++++------ 2 files changed, 278 insertions(+), 39 deletions(-) diff --git a/tests/ui/helpers/testSummary.test.js b/tests/ui/helpers/testSummary.test.js index 8640011e9c7..e21ef15f0c9 100644 --- a/tests/ui/helpers/testSummary.test.js +++ b/tests/ui/helpers/testSummary.test.js @@ -2,17 +2,33 @@ import { buildTestSummary, buildFailureSuggestions, matchBugSuggestions, + NO_GROUP, + INCOMPLETE_STATUS, } from '../../../ui/helpers/testSummary'; const lines = [ { - action: 'test_result', + action: 'test_start', + time: 100, + group: 'dom/manifest.ini', + test: 'dom/tests/test_pass.html', + }, + { + action: 'test_end', + time: 150, group: 'dom/manifest.ini', test: 'dom/tests/test_pass.html', status: 'PASS', }, { - action: 'test_result', + action: 'test_start', + time: 200, + group: 'dom/manifest.ini', + test: 'dom/tests/test_fail.html', + }, + { + action: 'test_end', + time: 260, group: 'dom/manifest.ini', test: 'dom/tests/test_fail.html', status: 'FAIL', @@ -20,7 +36,14 @@ const lines = [ message: 'assertion failed', }, { - action: 'test_result', + action: 'test_start', + time: 300, + group: 'layout/manifest.ini', + test: 'layout/tests/test_other.html', + }, + { + action: 'test_end', + time: 400, group: 'layout/manifest.ini', test: 'layout/tests/test_other.html', status: 'TIMEOUT', @@ -28,6 +51,128 @@ const lines = [ }, ]; +describe('buildTestSummary', () => { + const findTest = (summary, groupName, testName) => + summary.groups + .find((g) => g.name === groupName) + .tests.find((t) => t.name === testName); + + test('pairs test_start/test_end into a single run with a duration', () => { + const summary = buildTestSummary(lines); + const pass = findTest(summary, 'dom/manifest.ini', 'dom/tests/test_pass.html'); + + expect(pass.status).toBe('PASS'); + expect(pass.success).toBe(true); + expect(pass.retried).toBe(false); + expect(pass.results).toHaveLength(1); + expect(pass.results[0].duration).toBe(50); + }); + + test('marks a test_end without `expected` as a success', () => { + const summary = buildTestSummary(lines); + const fail = findTest(summary, 'dom/manifest.ini', 'dom/tests/test_fail.html'); + expect(fail.success).toBe(false); + expect(fail.status).toBe('FAIL'); + }); + + test('collapses repeated start/end pairs of one test into a retried entry', () => { + const retried = buildTestSummary([ + { action: 'test_start', time: 0, group: 'g', test: 't' }, + { action: 'test_end', time: 10, group: 'g', test: 't', status: 'FAIL', expected: 'PASS' }, + { action: 'test_start', time: 20, group: 'g', test: 't' }, + { action: 'test_end', time: 25, group: 'g', test: 't', status: 'PASS' }, + ]); + const test = findTest(retried, 'g', 't'); + expect(test.results).toHaveLength(2); + expect(test.retried).toBe(true); + // Final status comes from the last run. + expect(test.status).toBe('PASS'); + expect(test.success).toBe(true); + }); + + test('treats a test_start with no test_end as an unfinished (crashed) run', () => { + const summary = buildTestSummary([ + { action: 'test_start', time: 0, group: 'g', test: 'never_ends' }, + ]); + const test = findTest(summary, 'g', 'never_ends'); + expect(test.status).toBe(INCOMPLETE_STATUS); + expect(test.success).toBe(false); + expect(test.results[0].duration).toBe(null); + }); + + test('falls back to the group_start group when a test event omits `group`', () => { + const summary = buildTestSummary([ + { action: 'group_start', time: 0, name: 'manifest.toml' }, + { action: 'test_start', time: 1, test: 'orphan' }, + { action: 'test_end', time: 2, test: 'orphan', status: 'PASS' }, + ]); + expect(findTest(summary, 'manifest.toml', 'orphan')).toBeTruthy(); + }); + + test('enriches a failing test_end message with unexpected subtest messages', () => { + const summary = buildTestSummary([ + { action: 'test_start', time: 0, group: 'g', test: 'browser_x.js' }, + { + action: 'test_status', + time: 5, + group: 'g', + test: 'browser_x.js', + subtest: null, + status: 'FAIL', + expected: 'PASS', + message: 'This test exceeded the timeout threshold.', + }, + { + action: 'test_end', + time: 10, + group: 'g', + test: 'browser_x.js', + status: 'FAIL', + expected: 'PASS', + message: 'finished in 452487ms', + }, + ]); + const test = findTest(summary, 'g', 'browser_x.js'); + expect(test.success).toBe(false); + expect(test.results[0].message).toBe( + 'This test exceeded the timeout threshold.', + ); + }); + + test('ignores passing (expected) subtest statuses', () => { + const summary = buildTestSummary([ + { action: 'test_start', time: 0, group: 'g', test: 'browser_y.js' }, + { + action: 'test_status', + time: 5, + group: 'g', + test: 'browser_y.js', + subtest: 'checks a thing', + status: 'PASS', + }, + { + action: 'test_end', + time: 10, + group: 'g', + test: 'browser_y.js', + status: 'OK', + message: 'done', + }, + ]); + const test = findTest(summary, 'g', 'browser_y.js'); + expect(test.success).toBe(true); + expect(test.results[0].message).toBe('done'); + }); + + test('files a result with no resolvable group under NO_GROUP', () => { + const summary = buildTestSummary([ + { action: 'test_start', time: 1, test: 'lonely' }, + { action: 'test_end', time: 2, test: 'lonely', status: 'PASS' }, + ]); + expect(findTest(summary, NO_GROUP, 'lonely')).toBeTruthy(); + }); +}); + describe('matchBugSuggestions', () => { const buildFailures = () => buildFailureSuggestions(buildTestSummary(lines)); diff --git a/ui/helpers/testSummary.js b/ui/helpers/testSummary.js index 3c4c03ed789..51bbd0a4539 100644 --- a/ui/helpers/testSummary.js +++ b/ui/helpers/testSummary.js @@ -4,21 +4,35 @@ import { thBugSuggestionLimit } from './constants'; // Parses the contents of a `*_testsummary.jsonl` artifact into a structured // object for the job-view Summary tab. // -// Each line of the artifact is a JSON object describing an event. The events we -// care about are: +// Each line of the artifact is a JSON object describing a mozlog structured-log +// event. A test run is no longer a single `test_result` line — it is a +// `test_start` / `test_end` pair: // -// {"action": "test_result", "group": "", "test": "", -// "status": "PASS", "start": , "end": , "message": "..."} +// {"action": "test_start", "time": , "group": "", +// "test": ""} +// +// {"action": "test_end", "time": , "group": "", +// "test": "", "status": "PASS", "message": "...", +// "expected": ""} // `expected` present only when unexpected // // {"action": "crash", "group": "", "test": "", // "signature": "", ...} // +// The `end` event is not guaranteed: a test that crashes or hangs gets a +// `test_start` with no matching `test_end`. We pair the two events to recover +// each run's duration, and treat an unpaired `test_start` as a run that never +// finished (status CRASH). +// // A single test can appear more than once (e.g. when it is retried), and a few // results carry no `group`. We regroup the lines first by test (collapsing the // repeated runs of the same test into one entry), then by group. export const NO_GROUP = '(no group)'; +// Status given to a test that emitted a `test_start` but never a matching +// `test_end` — the harness died mid-run, which almost always means a crash. +export const INCOMPLETE_STATUS = 'CRASH'; + // Status buckets we report counts for. Anything unexpected falls back to its // raw status string so nothing is silently dropped. export const TEST_STATUSES = [ @@ -75,9 +89,11 @@ const tallyStatus = (counts, status) => { } }; -const durationOf = ({ start, end }) => +const durationOf = (start, end) => Number.isFinite(start) && Number.isFinite(end) ? end - start : null; +const finiteOrNull = (value) => (Number.isFinite(value) ? value : null); + /** * Build the Summary tab data from a `*_testsummary.jsonl` artifact. * @@ -104,37 +120,7 @@ export const buildTestSummary = (content) => { // group name -> Map(test name -> { name, results }) const groups = new Map(); - lines.forEach((line) => { - if (!line) return; - - let entry; - if (line.action === 'test_result' && line.test) { - entry = { - test: line.test, - group: line.group, - status: line.status, - success: !('expected' in line), - message: line.message || null, - start: Number.isFinite(line.start) ? line.start : null, - end: Number.isFinite(line.end) ? line.end : null, - duration: durationOf(line), - }; - } else if (line.action === 'crash') { - const testName = line.test || line.signature || '(unknown test)'; - entry = { - test: testName, - group: line.group, - status: 'CRASH', - success: false, - message: line.signature || null, - start: null, - end: null, - duration: null, - }; - } else { - return; - } - + const recordEntry = (entry) => { const groupName = entry.group || NO_GROUP; if (!groups.has(groupName)) { groups.set(groupName, new Map()); @@ -152,6 +138,114 @@ export const buildTestSummary = (content) => { end: entry.end, duration: entry.duration, }); + }; + + // Open `test_start` events awaiting their `test_end`, queued per test name so + // retries of the same test pair up in order (FIFO). + const pending = new Map(); + // The group most recently opened by `group_start`, used as a fallback when a + // test event carries no `group` of its own. + let currentGroup = null; + + const takePending = (testName) => { + const queue = pending.get(testName); + return queue && queue.length ? queue.shift() : null; + }; + + lines.forEach((line) => { + if (!line) return; + + switch (line.action) { + case 'group_start': + currentGroup = line.name || currentGroup; + return; + case 'group_end': + currentGroup = null; + return; + case 'test_start': { + if (!line.test) return; + const run = { + group: line.group || currentGroup, + start: finiteOrNull(line.time), + // Messages from unexpected `test_status` (subtest) events, which are + // usually more descriptive than the parent `test_end` message. + subtestFailures: [], + }; + if (!pending.has(line.test)) pending.set(line.test, []); + pending.get(line.test).push(run); + return; + } + case 'test_status': { + // A subtest result. We only keep the unexpected ones (those carrying an + // `expected` field) to enrich the parent test's failure message. The + // pending run's *first* queued start owns the in-progress subtests. + if (!line.test || !('expected' in line)) return; + const queue = pending.get(line.test); + const run = queue && queue.length ? queue[0] : null; + if (run && line.message) { + const label = line.subtest ? `${line.subtest}: ` : ''; + run.subtestFailures.push(`${label}${line.message}`); + } + return; + } + case 'test_end': { + if (!line.test) return; + const run = takePending(line.test); + const start = run ? run.start : null; + const end = finiteOrNull(line.time); + const success = !('expected' in line); + const subtestFailures = run?.subtestFailures || []; + // For a failing test prefer the (more informative) subtest messages; + // otherwise fall back to the test_end message. + const message = + (!success && subtestFailures.length + ? subtestFailures.join(' | ') + : line.message) || null; + recordEntry({ + test: line.test, + group: line.group || run?.group || currentGroup, + status: line.status, + success, + message, + start, + end, + duration: durationOf(start, end), + }); + return; + } + case 'crash': { + const testName = line.test || line.signature || '(unknown test)'; + recordEntry({ + test: testName, + group: line.group || currentGroup, + status: 'CRASH', + success: false, + message: line.signature || null, + start: null, + end: null, + duration: null, + }); + return; + } + default: + } + }); + + // Any `test_start` left unpaired never produced a `test_end`: the run did not + // finish, which we surface as a crash so it is not silently dropped. + pending.forEach((queue, testName) => { + queue.forEach((run) => { + recordEntry({ + test: testName, + group: run.group, + status: INCOMPLETE_STATUS, + success: false, + message: 'Test started but never finished', + start: run.start, + end: null, + duration: null, + }); + }); }); const overallCounts = emptyCounts();