1 // Copyright (C) 2019 Apple Inc. All rights reserved.
3 // Redistribution and use in source and binary forms, with or without
4 // modification, are permitted provided that the following conditions
6 // 1. Redistributions of source code must retain the above copyright
7 // notice, this list of conditions and the following disclaimer.
8 // 2. Redistributions in binary form must reproduce the above copyright
9 // notice, this list of conditions and the following disclaimer in the
10 // documentation and/or other materials provided with the distribution.
12 // THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS"
13 // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
14 // THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
15 // PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
16 // BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
17 // CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
18 // SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
19 // INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
20 // CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
21 // ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
22 // THE POSSIBILITY OF SUCH DAMAGE.
24 import {CommitBank} from '/assets/js/commit.js';
25 import {Configuration} from '/assets/js/configuration.js';
26 import {deepCompare, ErrorDisplay, escapeHTML, paramsToQuery, queryToParams} from '/assets/js/common.js';
27 import {ToolTip} from '/assets/js/tooltip.js';
28 import {Timeline} from '/library/js/components/TimelineComponents.js';
29 import {DOM, EventStream, REF, FP} from '/library/js/Ref.js';
32 const DEFAULT_LIMIT = 100;
34 const stateToIDMapping = {
46 const TestResultsSymbolMap = {
55 static stringToStateId(string) {
56 return stateToIDMapping[string];
59 static unexpectedResults(results, expectations)
61 let r = results.split('.');
62 expectations.split(' ').forEach(expectation => {
63 const i = r.indexOf(expectation);
66 if (expectation === 'FAIL')
67 ['TEXT', 'AUDIO', 'IMAGE'].forEach(expectation => {
68 const i = r.indexOf(expectation);
74 r.forEach(candidate => {
75 if (Expectations.stringToStateId(candidate) < Expectations.stringToStateId(result))
81 let willFilterExpected = false;
83 function minimumUuidForResults(results, limit) {
84 const now = Math.floor(Date.now() / 10);
85 let minDisplayedUuid = now;
86 let maxLimitedUuid = 0;
88 Object.keys(results).forEach((key) => {
89 results[key].forEach(pair => {
90 if (!pair.results.length)
92 if (limit !== 1 && limit === pair.results.length)
93 maxLimitedUuid = Math.max(pair.results[0].uuid, maxLimitedUuid);
95 minDisplayedUuid = Math.min(pair.results[pair.results.length - 1].uuid, minDisplayedUuid);
97 minDisplayedUuid = Math.min(pair.results[0].uuid, minDisplayedUuid);
101 if (minDisplayedUuid === now)
102 return maxLimitedUuid;
103 return Math.max(minDisplayedUuid, maxLimitedUuid);
106 function commitsForResults(results, limit, allCommits = true) {
107 const minDisplayedUuid = minimumUuidForResults(limit);
109 let repositories = new Set();
110 let currentCommitIndex = CommitBank.commits.length - 1;
111 Object.keys(results).forEach((key) => {
112 results[key].forEach(pair => {
113 pair.results.forEach(result => {
114 if (result.uuid < minDisplayedUuid)
116 let candidateCommits = [];
119 currentCommitIndex = CommitBank.commits.length - 1;
120 while (currentCommitIndex >= 0) {
121 if (CommitBank.commits[currentCommitIndex].uuid < result.uuid)
123 if (allCommits || CommitBank.commits[currentCommitIndex].uuid === result.uuid)
124 candidateCommits.push(CommitBank.commits[currentCommitIndex]);
125 --currentCommitIndex;
127 if (candidateCommits.length === 0 || candidateCommits[candidateCommits.length - 1].uuid !== result.uuid)
128 candidateCommits.push({
134 candidateCommits.forEach(commit => {
135 if (commit.repository_id)
136 repositories.add(commit.repository_id);
137 while (index < commits.length) {
138 if (commit.uuid === commits[index].uuid)
140 if (commit.uuid > commits[index].uuid) {
141 commits.splice(index, 0, commit);
146 commits.push(commit);
151 if (currentCommitIndex >= 0 && commits.length) {
152 let trailingRepositories = new Set(repositories);
153 trailingRepositories.delete(commits[commits.length - 1].repository_id);
154 while (currentCommitIndex >= 0 && trailingRepositories.size) {
155 const commit = CommitBank.commits[currentCommitIndex];
156 if (trailingRepositories.has(commit.repository_id)) {
157 commits.push(commit);
158 trailingRepositories.delete(commit.repository_id);
160 --currentCommitIndex;
164 repositories = [...repositories];
169 function scaleForCommits(commits) {
171 for (let i = commits.length - 1; i >= 0; --i) {
172 const repository_id = commits[i].repository_id ? commits[i].repository_id : '?';
174 scale[0][repository_id] = commits[i];
175 if (scale.length < 2)
177 Object.keys(scale[1]).forEach((key) => {
178 if (key === repository_id || key === '?' || key === 'uuid')
180 scale[0][key] = scale[1][key];
182 scale[0].uuid = Math.max(...Object.keys(scale[0]).map((key) => {
183 return scale[0][key].uuid;
189 function repositoriesForCommits(commits) {
190 let repositories = new Set();
191 commits.forEach((commit) => {
192 if (commit.repository_id)
193 repositories.add(commit.repository_id);
195 repositories = [...repositories];
196 if (!repositories.length)
197 repositories = ['?'];
202 function xAxisFromScale(scale, repository, updatesArray, isTop=false)
204 function scaleForRepository(scale) {
205 return scale.map(node => {
206 let commit = node[repository];
210 return {id: '', uuid: null};
215 function onScaleClick(node) {
219 branch: node.label.branch ? [node.label.branch] : queryToParams(document.URL.split('?')[1]).branch,
220 uuid: [node.label.uuid],
223 delete params.branch;
224 const query = paramsToQuery(params);
225 window.open(`/commit?${query}`, '_blank');
228 return Timeline.CanvasXAxisComponent(scaleForRepository(scale), {
231 onScaleClick: onScaleClick,
232 onScaleEnter: (node, event, canvas) => {
233 const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
235 `<div class="content">
236 Time: ${new Date(node.label.timestamp * 1000).toLocaleString()}<br>
237 Committer: ${node.label.committer}
238 ${node.label.message ? `<br><div>${escapeHTML(node.label.message.split('\n')[0])}</div>` : ''}
240 node.tipPoints.map((point) => {
241 return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y};
243 (event) => {return onScaleClick(node);},
246 onScaleLeave: (event, canvas) => {
247 if (!ToolTip.isIn({x: event.x, y: event.y}))
250 // Per the birthday paradox, 10% change of collision with 7.7 million commits with 12 character commits
251 getLabelFunc: (commit) => {return commit ? commit.id.substring(0,12) : '?';},
252 getScaleFunc: (commit) => commit.uuid,
253 exporter: (updateFunction) => {
254 updatesArray.push((scale) => {updateFunction(scaleForRepository(scale));});
259 const testsRegex = /tests_([a-z])+/;
260 const failureTypeOrder = ['failed', 'timedout', 'crashed'];
261 const failureTypeMapping = {
267 function inPlaceCombine(out, obj)
274 Object.keys(obj).forEach(key => {
277 if (obj[key] instanceof Object)
278 out[key] = inPlaceCombine(out[key], obj[key]);
283 Object.keys(out).forEach(key => {
287 if (out[key] instanceof Object) {
288 out[key] = inPlaceCombine(out[key], obj[key]);
292 // Set of special case keys which need to be added together
293 if (key.match(testsRegex)) {
294 out[key] += obj[key];
298 // If the key exists, but doesn't match, delete it
299 if (!(key in obj) || out[key] !== obj[key]) {
304 Object.keys(obj).forEach(key => {
305 if (key.match(testsRegex) && !(key in out))
312 function statsForSingleResult(result) {
313 const actualId = Expectations.stringToStateId(result.actual);
314 const unexpectedId = Expectations.stringToStateId(Expectations.unexpectedResults(result.actual, result.expected));
319 failureTypeOrder.forEach(type => {
320 const idForType = Expectations.stringToStateId(failureTypeMapping[type]);
321 stats[`tests_${type}`] = actualId > idForType ? 0 : 1;
322 stats[`tests_unexpected_${type}`] = unexpectedId > idForType ? 0 : 1;
327 function combineResults() {
328 let counts = new Array(arguments.length).fill(0);
332 // Find candidate uuid
334 for (let i = 0; i < counts.length; ++i) {
335 let candidateUuid = null;
336 while (arguments[i] && arguments[i].length > counts[i]) {
337 candidateUuid = arguments[i][counts[i]].uuid;
343 uuid = Math.max(uuid, candidateUuid);
349 // Combine relevant results
351 for (let i = 0; i < counts.length; ++i) {
352 while (counts[i] < arguments[i].length && arguments[i][counts[i]] && arguments[i][counts[i]].uuid === uuid) {
353 if (dataNode && !dataNode.stats)
354 dataNode.stats = statsForSingleResult(dataNode);
356 dataNode = inPlaceCombine(dataNode, arguments[i][counts[i]]);
358 if (dataNode.stats && !arguments[i][counts[i]].stats)
359 dataNode.stats = inPlaceCombine(dataNode.stats, statsForSingleResult(arguments[i][counts[i]]));
370 class TimelineFromEndpoint {
371 constructor(endpoint, suite = null) {
372 this.endpoint = endpoint;
373 this.displayAllCommits = true;
375 this.configurations = Configuration.fromQuery();
377 this.suite = suite; // Suite is often implied by the endpoint, but trying to determine suite from endpoint is not trivial.
380 this.xaxisUpdates = [];
381 this.timelineUpdate = null;
382 this.repositories = [];
386 this.latestDispatch = Date.now();
387 this.ref = REF.createRef({
389 onStateUpdate: (element, state) => {
391 element.innerHTML = ErrorDisplay(state);
393 DOM.inject(element, this.render(state));
395 element.innerHTML = this.placeholder();
399 this.commit_callback = () => {
402 CommitBank.callbacks.push(this.commit_callback);
407 CommitBank.callbacks = CommitBank.callbacks.filter((value, index, arr) => {
408 return this.commit_callback === value;
412 const params = queryToParams(document.URL.split('?')[1]);
413 const commits = commitsForResults(this.results, params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT, this.allCommits);
414 const scale = scaleForCommits(commits);
416 const newRepositories = repositoriesForCommits(commits);
417 let haveNewRepos = this.repositories.length !== newRepositories.length;
418 for (let i = 0; !haveNewRepos && i < this.repositories.length && i < newRepositories.length; ++i)
419 haveNewRepos = this.repositories[i] !== newRepositories[i];
420 if (haveNewRepos && this.timelineUpdate) {
421 this.xaxisUpdates = [];
425 newRepositories.forEach(repository => {
426 components.push(xAxisFromScale(scale, repository, this.xaxisUpdates, top));
430 this.timelineUpdate(components);
431 this.repositories = newRepositories;
434 this.updates.forEach(func => {func(scale);})
435 this.xaxisUpdates.forEach(func => {func(scale);});
438 const params = queryToParams(document.URL.split('?')[1]);
439 this.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT);
442 let myDispatch = Date.now();
443 this.latestDispatch = Math.max(this.latestDispatch, myDispatch);
444 this.ref.setState(-1);
447 let sharedParams = queryToParams(document.URL.split('?')[1]);
448 Configuration.members().forEach(member => {
449 delete sharedParams[member];
451 delete sharedParams.suite;
452 delete sharedParams.test;
453 delete sharedParams.repository_id;
455 let newConfigs = Configuration.fromQuery();
456 if (!deepCompare(newConfigs, this.configurations)) {
457 this.configurations = newConfigs;
459 this.configurations.forEach(configuration => {
460 this.results[configuration.toKey()] = [];
464 this.configurations.forEach(configuration => {
465 let params = configuration.toParams();
466 for (let key in sharedParams)
467 params[key] = sharedParams[key];
468 const query = paramsToQuery(params);
470 fetch(query ? this.endpoint + '?' + query : this.endpoint).then(response => {
471 response.json().then(json => {
472 if (myDispatch !== this.latestDispatch)
475 let oldestUuid = Date.now() / 10;
477 self.results[configuration.toKey()] = json;
478 self.results[configuration.toKey()].sort((a, b) => {
479 const aConfig = new Configuration(a.configuration);
480 const bConfig = new Configuration(b.configuration);
481 let configCompare = aConfig.compare(bConfig);
482 if (configCompare === 0)
483 configCompare = aConfig.compareSDKs(bConfig);
484 return configCompare;
486 self.results[configuration.toKey()].forEach(keyValue => {
487 keyValue.results.forEach(result => {
488 oldestUuid = Math.min(oldestUuid, result.uuid);
489 newestUuid = Math.max(newestUuid, result.uuid);
493 if (oldestUuid < newestUuid)
494 CommitBank.add(oldestUuid, newestUuid);
496 self.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT);
499 if (myDispatch === this.latestDispatch)
500 this.ref.setState({error: "Connection Error", description: error});
505 return `<div class="loader">
506 <div class="spinner"></div>
510 this.ref = REF.createRef({
511 state: this.ref.state,
512 onStateUpdate: (element, state) => {
514 DOM.inject(element, ErrorDisplay(state));
516 DOM.inject(element, this.render(state));
518 DOM.inject(element, this.placeholder());
522 return `<div class="content" ref="${this.ref}"></div>`;
526 const branch = queryToParams(document.URL.split('?')[1]).branch;
528 const commits = commitsForResults(this.results, limit, this.allCommits);
529 const scale = scaleForCommits(commits);
531 const computedStyle = getComputedStyle(document.body);
533 success: computedStyle.getPropertyValue('--greenLight').trim(),
534 failed: computedStyle.getPropertyValue('--redLight').trim(),
535 timedout: computedStyle.getPropertyValue('--orangeLight').trim(),
536 crashed: computedStyle.getPropertyValue('--purpleLight').trim(),
541 getScaleFunc: (value) => {
542 if (value && value.uuid)
543 return {uuid: value.uuid};
546 compareFunc: (a, b) => {return b.uuid - a.uuid;},
547 renderFactory: (drawDot) => (data, context, x, y) => {
549 return drawDot(context, x, y, true);
552 let color = colorMap.success;
553 let symbol = TestResultsSymbolMap.success;
555 tag = data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}failed`];
557 // If we have failures that are a result of multiple runs, combine them.
558 if (tag && !data.start_time) {
559 tag = Math.ceil(tag / data.stats.tests_run * 100 - .5);
565 failureTypeOrder.forEach(type => {
566 if (data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}${type}`] > 0) {
567 color = colorMap[type];
568 symbol = TestResultsSymbolMap[type];
572 let resultId = Expectations.stringToStateId(data.actual);
573 if (willFilterExpected)
574 resultId = Expectations.stringToStateId(Expectations.unexpectedResults(data.actual, data.expected));
575 failureTypeOrder.forEach(type => {
576 if (Expectations.stringToStateId(failureTypeMapping[type]) >= resultId) {
577 color = colorMap[type];
578 symbol = TestResultsSymbolMap[type];
583 return drawDot(context, x, y, false, tag ? tag : null, symbol, false, color);
587 function onDotClickFactory(configuration) {
589 // FIXME: We should do something sane here, but we probably need another endpoint
590 if (!data.start_time) {
591 alert('Node is a combination of multiple runs');
595 let buildParams = configuration.toParams();
596 buildParams['suite'] = [self.suite];
597 buildParams['uuid'] = [data.uuid];
598 buildParams['after_time'] = [data.start_time];
599 buildParams['before_time'] = [data.start_time];
601 buildParams['branch'] = branch;
602 window.open(`/urls/build?${paramsToQuery(buildParams)}`, '_blank');
606 function onDotEnterFactory(configuration) {
607 return (data, event, canvas) => {
608 const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
610 `<div class="content">
611 ${data.start_time ? `<a href="/urls/build?${paramsToQuery(function () {
612 let buildParams = configuration.toParams();
613 buildParams['suite'] = [self.suite];
614 buildParams['uuid'] = [data.uuid];
615 buildParams['after_time'] = [data.start_time];
616 buildParams['before_time'] = [data.start_time];
618 buildParams['branch'] = branch;
620 } ())}" target="_blank">Test run</a> @ ${new Date(data.start_time * 1000).toLocaleString()}<br>` : ''}
621 Commits: ${CommitBank.commitsDuringUuid(data.uuid).map((commit) => {
623 branch: commit.branch ? [commit.branch] : branch,
627 delete params.branch;
628 const query = paramsToQuery(params);
629 return `<a href="/commit/info?${query}" target="_blank">${commit.id.substring(0,12)}</a>`;
633 ${data.expected ? `Expected: ${data.expected}<br>` : ''}
634 ${data.actual ? `Actual: ${data.actual}<br>` : ''}
636 data.tipPoints.map((point) => {
637 return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y};
639 (event) => {onDotClickFactory(configuration)(data);},
644 function onDotLeave(event, canvas) {
645 if (!ToolTip.isIn({x: event.pageX, y: event.pageY}))
649 function exporterFactory(data) {
650 return (updateFunction) => {
651 self.updates.push((scale) => {updateFunction(data, scale);});
656 this.configurations.forEach(configuration => {
657 if (!this.results[configuration.toKey()] || Object.keys(this.results[configuration.toKey()]).length === 0)
660 // Create a list of configurations to display with SDKs stripped
661 let mappedChildrenConfigs = {};
662 let childrenConfigsBySDK = {}
663 let resultsByKey = {};
664 this.results[configuration.toKey()].forEach(pair => {
665 const strippedConfig = new Configuration(pair.configuration);
666 resultsByKey[strippedConfig.toKey()] = combineResults([], [...pair.results].sort(function(a, b) {return b.uuid - a.uuid;}));
667 delete strippedConfig.sdk;
668 mappedChildrenConfigs[strippedConfig.toKey()] = strippedConfig;
669 if (!childrenConfigsBySDK[strippedConfig.toKey()])
670 childrenConfigsBySDK[strippedConfig.toKey()] = [];
671 childrenConfigsBySDK[strippedConfig.toKey()].push(new Configuration(pair.configuration));
673 let childrenConfigs = [];
674 Object.keys(mappedChildrenConfigs).forEach(key => {
675 childrenConfigs.push(mappedChildrenConfigs[key]);
677 childrenConfigs.sort(function(a, b) {return a.compare(b);});
679 // Create the collapsed timelines, cobine results
681 let collapsedTimelines = [];
682 childrenConfigs.forEach(config => {
683 childrenConfigsBySDK[config.toKey()].sort(function(a, b) {return a.compareSDKs(b);});
685 let resultsForConfig = [];
686 childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => {
687 resultsForConfig = combineResults(resultsForConfig, resultsByKey[sdkConfig.toKey()]);
689 allResults = combineResults(allResults, resultsForConfig);
691 let queueParams = config.toParams();
692 queueParams['suite'] = [this.suite];
694 queueParams['branch'];
695 let myTimeline = Timeline.SeriesWithHeaderComponent(
696 `${childrenConfigsBySDK[config.toKey()].length > 1 ? ' | ' : ''}<a href="/urls/queue?${paramsToQuery(queueParams)}" target="_blank">${config}</a>`,
697 Timeline.CanvasSeriesComponent(resultsForConfig, scale, {
698 getScaleFunc: options.getScaleFunc,
699 compareFunc: options.compareFunc,
700 renderFactory: options.renderFactory,
701 exporter: options.exporter,
702 onDotClick: onDotClickFactory(config),
703 onDotEnter: onDotEnterFactory(config),
704 onDotLeave: onDotLeave,
705 exporter: exporterFactory(resultsForConfig),
708 if (childrenConfigsBySDK[config.toKey()].length > 1) {
709 let timelinesBySDK = [];
710 childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => {
712 Timeline.SeriesWithHeaderComponent(`${sdkConfig.sdk}`,
713 Timeline.CanvasSeriesComponent(resultsByKey[sdkConfig.toKey()], scale, {
714 getScaleFunc: options.getScaleFunc,
715 compareFunc: options.compareFunc,
716 renderFactory: options.renderFactory,
717 exporter: options.exporter,
718 onDotClick: onDotClickFactory(sdkConfig),
719 onDotEnter: onDotEnterFactory(sdkConfig),
720 onDotLeave: onDotLeave,
721 exporter: exporterFactory(resultsByKey[sdkConfig.toKey()]),
724 myTimeline = Timeline.ExpandableSeriesWithHeaderExpanderComponent(myTimeline, {}, ...timelinesBySDK);
726 collapsedTimelines.push(myTimeline);
729 if (collapsedTimelines.length === 0)
731 if (collapsedTimelines.length === 1) {
732 if (!collapsedTimelines[0].header.includes('class="series"'))
733 collapsedTimelines[0].header = Timeline.HeaderComponent(collapsedTimelines[0].header);
734 children.push(collapsedTimelines[0]);
739 Timeline.ExpandableSeriesWithHeaderExpanderComponent(
740 Timeline.SeriesWithHeaderComponent(` ${configuration}`,
741 Timeline.CanvasSeriesComponent(allResults, scale, {
742 getScaleFunc: options.getScaleFunc,
743 compareFunc: options.compareFunc,
744 renderFactory: options.renderFactory,
745 onDotClick: onDotClickFactory(configuration),
746 onDotEnter: onDotEnterFactory(configuration),
747 onDotLeave: onDotLeave,
748 exporter: exporterFactory(allResults),
750 {expanded: this.configurations.length <= 1},
751 ...collapsedTimelines
756 self.xaxisUpdates = [];
757 this.repositories = repositoriesForCommits(commits);
758 this.repositories.forEach(repository => {
759 const xAxisComponent = xAxisFromScale(scale, repository, self.xaxisUpdates, top);
761 children.unshift(xAxisComponent);
763 children.push(xAxisComponent);
767 const composer = FP.composer(FP.currying((updateTimeline, notifiyRerender) => {
768 self.timelineUpdate = (xAxises) => {
769 children.splice(0, 1);
770 if (self.repositories.length > 1)
771 children.splice(children.length - self.repositories.length, self.repositories.length);
774 xAxises.forEach(component => {
776 children.unshift(component);
778 children.push(component);
781 updateTimeline(children);
783 self.notifiyRerender = notifiyRerender;
785 return Timeline.CanvasContainer(composer, ...children);
790 function LegendLabel(eventStream, filterExpectedText, filterUnexpectedText) {
791 let ref = REF.createRef({
792 state: willFilterExpected,
793 onStateUpdate: (element, state) => {
794 if (state) element.innerText = filterExpectedText;
795 else element.innerText = filterUnexpectedText;
798 eventStream.action((willFilterExpected) => ref.setState(willFilterExpected));
799 return `<div class="label" ref="${ref}"></div>`;
802 function Legend(callback=null, plural=false) {
803 let updateLabelEvents = new EventStream();
805 <div class="lengend timeline">
807 <div class="dot success"><div class="text">${TestResultsSymbolMap.success}</div></div>
810 plural ? 'No unexpected results' : 'Result expected',
811 plural ? 'All tests passed' : 'Test passed',
815 <div class="dot failed"><div class="text">${TestResultsSymbolMap.failed}</div></div>
818 plural ? 'Some tests unexpectedly failed' : 'Unexpectedly failed',
819 plural ? 'Some tests failed' : 'Test failed',
823 <div class="dot timeout"><div class="text">${TestResultsSymbolMap.timedout}</div></div>
826 plural ? 'Some tests unexpectedly timed out' : 'Unexpectedly timed out',
827 plural ? 'Some tests timed out' : 'Test timed out',
831 <div class="dot crash"><div class="text">${TestResultsSymbolMap.crashed}</div></div>
834 plural ? 'Some tests unexpectedly crashed' : 'Unexpectedly crashed',
835 plural ? 'Some tests crashed' : 'Test crashed',
842 const swtch = REF.createRef({
843 onElementMount: (element) => {
844 element.onchange = () => {
846 willFilterExpected = true;
848 willFilterExpected = false;
849 updateLabelEvents.add(willFilterExpected);
855 result += `<div class="input" style="width:400px">
856 <label>Filter expected results</label>
857 <label class="switch">
858 <input type="checkbox"${willFilterExpected ? ' checked': ''} ref="${swtch}">
859 <span class="slider"></span>
864 return `<div class="content">${result}</div><br>`;
867 export {Legend, TimelineFromEndpoint, Expectations};