results.webkit.org: Move legend into sidebar
[WebKit-https.git] / Tools / resultsdbpy / resultsdbpy / view / static / js / timeline.js
1 // Copyright (C) 2019 Apple Inc. All rights reserved.
2 //
3 // Redistribution and use in source and binary forms, with or without
4 // modification, are permitted provided that the following conditions
5 // are met:
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.
11 //
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.
23
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';
30
31
32 const DEFAULT_LIMIT = 100;
33
34 const stateToIDMapping = {
35     CRASH: 0x00,
36     TIMEOUT: 0x08,
37     IMAGE: 0x10,
38     AUDIO: 0x18,
39     TEXT: 0x20,
40     FAIL: 0x28,
41     ERROR: 0x30,
42     WARNING: 0x38,
43     PASS: 0x40,
44 };
45
46 const TestResultsSymbolMap = {
47     success: '✓',
48     failed: '𝖷',
49     timedout: '⎋',
50     crashed: '!',
51 }
52
53 class Expectations
54 {
55     static stringToStateId(string) {
56         return stateToIDMapping[string];
57     }
58
59     static unexpectedResults(results, expectations)
60     {
61         let r = results.split('.');
62         expectations.split(' ').forEach(expectation => {
63             const i = r.indexOf(expectation);
64             if (i > -1)
65                 r.splice(i, 1);
66             if (expectation === 'FAIL')
67                 ['TEXT', 'AUDIO', 'IMAGE'].forEach(expectation => {
68                     const i = r.indexOf(expectation);
69                     if (i > -1)
70                         r.splice(i, 1);
71                 });
72         });
73         let result = 'PASS';
74         r.forEach(candidate => {
75             if (Expectations.stringToStateId(candidate) < Expectations.stringToStateId(result))
76                 result = candidate;
77         });
78         return result;
79     }
80 }
81 let willFilterExpected = false;
82
83 function minimumUuidForResults(results, limit) {
84     const now = Math.floor(Date.now() / 10);
85     let minDisplayedUuid = now;
86     let maxLimitedUuid = 0;
87
88     Object.keys(results).forEach((key) => {
89         results[key].forEach(pair => {
90             if (!pair.results.length)
91                 return;
92             if (limit !== 1 && limit === pair.results.length)
93                 maxLimitedUuid = Math.max(pair.results[0].uuid, maxLimitedUuid);
94             else if (limit === 1)
95                 minDisplayedUuid = Math.min(pair.results[pair.results.length - 1].uuid, minDisplayedUuid);
96             else
97                 minDisplayedUuid = Math.min(pair.results[0].uuid, minDisplayedUuid);
98         });
99     });
100
101     if (minDisplayedUuid === now)
102         return maxLimitedUuid;
103     return Math.max(minDisplayedUuid, maxLimitedUuid);
104 }
105
106 function commitsForResults(results, limit, allCommits = true) {
107     const minDisplayedUuid = minimumUuidForResults(limit);
108     let commits = [];
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)
115                     return;
116                 let candidateCommits = [];
117
118                 if (!allCommits)
119                     currentCommitIndex = CommitBank.commits.length - 1;
120                 while (currentCommitIndex >= 0) {
121                     if (CommitBank.commits[currentCommitIndex].uuid < result.uuid)
122                         break;
123                     if (allCommits || CommitBank.commits[currentCommitIndex].uuid === result.uuid)
124                         candidateCommits.push(CommitBank.commits[currentCommitIndex]);
125                     --currentCommitIndex;
126                 }
127                 if (candidateCommits.length === 0 || candidateCommits[candidateCommits.length - 1].uuid !== result.uuid)
128                     candidateCommits.push({
129                         id: '?',
130                         uuid: result.uuid,
131                     });
132
133                 let index = 0;
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)
139                             return;
140                         if (commit.uuid > commits[index].uuid) {
141                             commits.splice(index, 0, commit);
142                             return;
143                         }
144                         ++index;
145                     }
146                     commits.push(commit);
147                 });
148             });
149         });
150     });
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);
159             }
160             --currentCommitIndex;
161         }
162     }
163
164     repositories = [...repositories];
165     repositories.sort();
166     return commits;
167 }
168
169 function scaleForCommits(commits) {
170     let scale = [];
171     for (let i = commits.length - 1; i >= 0; --i) {
172         const repository_id = commits[i].repository_id ? commits[i].repository_id : '?';
173         scale.unshift({});
174         scale[0][repository_id] = commits[i];
175         if (scale.length < 2)
176             continue;
177         Object.keys(scale[1]).forEach((key) => {
178             if (key === repository_id || key === '?' || key === 'uuid')
179                 return;
180             scale[0][key] = scale[1][key];
181         });
182         scale[0].uuid = Math.max(...Object.keys(scale[0]).map((key) => {
183             return scale[0][key].uuid;
184         }));
185     }
186     return scale;
187 }
188
189 function repositoriesForCommits(commits) {
190     let repositories = new Set();
191     commits.forEach((commit) => {
192         if (commit.repository_id)
193             repositories.add(commit.repository_id);
194     });
195     repositories = [...repositories];
196     if (!repositories.length)
197         repositories = ['?'];
198     repositories.sort();
199     return repositories;
200 }
201
202 function xAxisFromScale(scale, repository, updatesArray, isTop=false)
203 {
204     function scaleForRepository(scale) {
205         return scale.map(node => {
206             let commit = node[repository];
207             if (!commit)
208                 commit = node['?'];
209             if (!commit)
210                 return {id: '', uuid: null};
211             return commit;
212         });
213     }
214
215     function onScaleClick(node) {
216         if (!node.label.id)
217             return;
218         let params = {
219             branch: node.label.branch ? [node.label.branch] : queryToParams(document.URL.split('?')[1]).branch,
220             uuid: [node.label.uuid],
221         }
222         if (!params.branch)
223             delete params.branch;
224         const query = paramsToQuery(params);
225         window.open(`/commit?${query}`, '_blank');
226     }
227
228     return Timeline.CanvasXAxisComponent(scaleForRepository(scale), {
229         isTop: isTop,
230         height: 130,
231         onScaleClick: onScaleClick,
232         onScaleEnter: (node, event, canvas) => {
233             const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
234             ToolTip.set(
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>` : ''}
239                 </div>`,
240                 node.tipPoints.map((point) => {
241                     return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y};
242                 }),
243                 (event) => {return onScaleClick(node);},
244             );
245         },
246         onScaleLeave: (event, canvas) => {
247             const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
248             if (!ToolTip.isIn({x: event.x, y: event.y - scrollDelta}))
249                 ToolTip.unset();
250         },
251         // Per the birthday paradox, 10% change of collision with 7.7 million commits with 12 character commits
252         getLabelFunc: (commit) => {return commit ? commit.id.substring(0,12) : '?';},
253         getScaleFunc: (commit) => commit.uuid,
254         exporter: (updateFunction) => {
255             updatesArray.push((scale) => {updateFunction(scaleForRepository(scale));});
256         },
257     });
258 }
259
260 const testsRegex = /tests_([a-z])+/;
261 const failureTypeOrder = ['failed', 'timedout', 'crashed'];
262 const failureTypeMapping = {
263     failed: 'ERROR',
264     timedout: 'TIMEOUT',
265     crashed: 'CRASH',
266 }
267
268 function inPlaceCombine(out, obj)
269 {
270     if (!obj)
271         return out;
272
273     if (!out) {
274         out = {};
275         Object.keys(obj).forEach(key => {
276             if (key[0] === '_')
277                 return;
278             if (obj[key] instanceof Object)
279                 out[key] = inPlaceCombine(out[key], obj[key]);
280             else
281                 out[key] = obj[key];
282         });
283     } else {
284         Object.keys(out).forEach(key => {
285             if (key[0] === '_')
286                 return;
287
288             if (out[key] instanceof Object) {
289                 out[key] = inPlaceCombine(out[key], obj[key]);
290                 return;
291             }
292
293             // Set of special case keys which need to be added together
294             if (key.match(testsRegex)) {
295                 out[key] += obj[key];
296                 return;
297             }
298
299             // If the key exists, but doesn't match, delete it
300             if (!(key in obj) || out[key] !== obj[key]) {
301                 delete out[key];
302                 return;
303             }
304         });
305         Object.keys(obj).forEach(key => {
306             if (key.match(testsRegex) && !(key in out))
307                 out[key] = obj[key];
308         });
309     }
310     return out;
311 }
312
313 function statsForSingleResult(result) {
314     const actualId = Expectations.stringToStateId(result.actual);
315     const unexpectedId = Expectations.stringToStateId(Expectations.unexpectedResults(result.actual, result.expected));
316     let stats = {
317         tests_run: 1,
318         tests_skipped: 0,
319     }
320     failureTypeOrder.forEach(type => {
321         const idForType = Expectations.stringToStateId(failureTypeMapping[type]);
322         stats[`tests_${type}`] = actualId > idForType  ? 0 : 1;
323         stats[`tests_unexpected_${type}`] = unexpectedId > idForType  ? 0 : 1;
324     });
325     return stats;
326 }
327
328 function combineResults() {
329     let counts = new Array(arguments.length).fill(0);
330     let data = [];
331
332     while (true) {
333         // Find candidate uuid
334         let uuid = 0;
335         for (let i = 0; i < counts.length; ++i) {
336             let candidateUuid = null;
337             while (arguments[i] && arguments[i].length > counts[i]) {
338                 candidateUuid = arguments[i][counts[i]].uuid;
339                 if (candidateUuid)
340                     break;
341                 ++counts[i];
342             }
343             if (candidateUuid)
344                 uuid = Math.max(uuid, candidateUuid);
345         }
346
347         if (!uuid)
348             return data;
349
350         // Combine relevant results
351         let dataNode = null;
352         for (let i = 0; i < counts.length; ++i) {
353             while (counts[i] < arguments[i].length && arguments[i][counts[i]] && arguments[i][counts[i]].uuid === uuid) {
354                 if (dataNode && !dataNode.stats)
355                     dataNode.stats = statsForSingleResult(dataNode);
356
357                 dataNode = inPlaceCombine(dataNode, arguments[i][counts[i]]);
358
359                 if (dataNode.stats && !arguments[i][counts[i]].stats)
360                     dataNode.stats = inPlaceCombine(dataNode.stats, statsForSingleResult(arguments[i][counts[i]]));
361
362                 ++counts[i];
363             }
364         }
365         if (dataNode)
366             data.push(dataNode);
367     }
368     return data;
369 }
370
371 class TimelineFromEndpoint {
372     constructor(endpoint, suite = null) {
373         this.endpoint = endpoint;
374         this.displayAllCommits = true;
375
376         this.configurations = Configuration.fromQuery();
377         this.results = {};
378         this.suite = suite;  // Suite is often implied by the endpoint, but trying to determine suite from endpoint is not trivial.
379
380         this.updates = [];
381         this.xaxisUpdates = [];
382         this.timelineUpdate = null;
383         this.repositories = [];
384
385         const self = this;
386
387         this.latestDispatch = Date.now();
388         this.ref = REF.createRef({
389             state: {},
390             onStateUpdate: (element, state) => {
391                 if (state.error)
392                     element.innerHTML = ErrorDisplay(state);
393                 else if (state > 0)
394                     DOM.inject(element, this.render(state));
395                 else
396                     element.innerHTML = this.placeholder();
397             }
398         });
399
400         this.commit_callback = () => {
401             self.update();
402         };
403         CommitBank.callbacks.push(this.commit_callback);
404
405         this.reload();
406     }
407     teardown() {
408         CommitBank.callbacks = CommitBank.callbacks.filter((value, index, arr) => {
409             return this.commit_callback === value;
410         });
411     }
412     update() {
413         const params = queryToParams(document.URL.split('?')[1]);
414         const commits = commitsForResults(this.results, params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT, this.allCommits);
415         const scale = scaleForCommits(commits);
416
417         const newRepositories = repositoriesForCommits(commits);
418         let haveNewRepos = this.repositories.length !== newRepositories.length;
419         for (let i = 0; !haveNewRepos && i < this.repositories.length && i < newRepositories.length; ++i)
420             haveNewRepos = this.repositories[i] !== newRepositories[i];
421         if (haveNewRepos && this.timelineUpdate) {
422             this.xaxisUpdates = [];
423             let top = true;
424             let components = [];
425
426             newRepositories.forEach(repository => {
427                 components.push(xAxisFromScale(scale, repository, this.xaxisUpdates, top));
428                 top = false;
429             });
430
431             this.timelineUpdate(components);
432             this.repositories = newRepositories;
433         }
434
435         this.updates.forEach(func => {func(scale);})
436         this.xaxisUpdates.forEach(func => {func(scale);});
437     }
438     rerender() {
439         const params = queryToParams(document.URL.split('?')[1]);
440         this.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT);
441     }
442     reload() {
443         let myDispatch = Date.now();
444         this.latestDispatch = Math.max(this.latestDispatch, myDispatch);
445         this.ref.setState(-1);
446
447         const self = this;
448         let sharedParams = queryToParams(document.URL.split('?')[1]);
449         Configuration.members().forEach(member => {
450             delete sharedParams[member];
451         });
452         delete sharedParams.suite;
453         delete sharedParams.test;
454         delete sharedParams.repository_id;
455
456         let newConfigs = Configuration.fromQuery();
457         if (!deepCompare(newConfigs, this.configurations)) {
458             this.configurations = newConfigs;
459             this.results = {};
460             this.configurations.forEach(configuration => {
461                 this.results[configuration.toKey()] = [];
462             });
463         }
464
465         this.configurations.forEach(configuration => {
466             let params = configuration.toParams();
467             for (let key in sharedParams)
468                 params[key] = sharedParams[key];
469             const query = paramsToQuery(params);
470
471             fetch(query ? this.endpoint + '?' + query : this.endpoint).then(response => {
472                 response.json().then(json => {
473                     if (myDispatch !== this.latestDispatch)
474                         return;
475
476                     let oldestUuid = Date.now() / 10;
477                     let newestUuid = 0;
478                     self.results[configuration.toKey()] = json;
479                     self.results[configuration.toKey()].sort((a, b) => {
480                         const aConfig = new Configuration(a.configuration);
481                         const bConfig = new Configuration(b.configuration);
482                         let configCompare = aConfig.compare(bConfig);
483                         if (configCompare === 0)
484                             configCompare = aConfig.compareSDKs(bConfig);
485                         return configCompare;
486                     });
487                     self.results[configuration.toKey()].forEach(keyValue => {
488                         keyValue.results.forEach(result => {
489                             oldestUuid = Math.min(oldestUuid, result.uuid);
490                             newestUuid = Math.max(newestUuid, result.uuid);
491                         });
492                     });
493
494                     if (oldestUuid < newestUuid)
495                         CommitBank.add(oldestUuid, newestUuid);
496
497                     self.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT);
498                 });
499             }).catch(error => {
500                 if (myDispatch === this.latestDispatch)
501                     this.ref.setState({error: "Connection Error", description: error});
502             });
503         });
504     }
505     placeholder() {
506         return `<div class="loader">
507                 <div class="spinner"></div>
508             </div>`;
509     }
510     toString() {
511         this.ref = REF.createRef({
512             state: this.ref.state,
513             onStateUpdate: (element, state) => {
514                 if (state.error)
515                     DOM.inject(element, ErrorDisplay(state));
516                 else if (state > 0)
517                     DOM.inject(element, this.render(state));
518                 else
519                     DOM.inject(element, this.placeholder());
520             }
521         });
522
523         return `<div class="content" ref="${this.ref}"></div>`;
524     }
525
526     render(limit) {
527         const branch = queryToParams(document.URL.split('?')[1]).branch;
528         const self = this;
529         const commits = commitsForResults(this.results, limit, this.allCommits);
530         const scale = scaleForCommits(commits);
531
532         const computedStyle = getComputedStyle(document.body);
533         const colorMap = {
534             success: computedStyle.getPropertyValue('--greenLight').trim(),
535             failed: computedStyle.getPropertyValue('--redLight').trim(),
536             timedout: computedStyle.getPropertyValue('--orangeLight').trim(),
537             crashed: computedStyle.getPropertyValue('--purpleLight').trim(),
538         }
539
540         this.updates = [];
541         const options = {
542             getScaleFunc: (value) => {
543                 if (value && value.uuid)
544                     return {uuid: value.uuid};
545                 return {};
546             },
547             compareFunc: (a, b) => {return b.uuid - a.uuid;},
548             renderFactory: (drawDot) => (data, context, x, y) => {
549                 if (!data)
550                     return drawDot(context, x, y, true);
551
552                 let tag = null;
553                 let color = colorMap.success;
554                 let symbol = TestResultsSymbolMap.success;
555                 if (data.stats) {
556                     tag = data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}failed`];
557
558                     // If we have failures that are a result of multiple runs, combine them.
559                     if (tag && !data.start_time) {
560                         tag = Math.ceil(tag / data.stats.tests_run * 100 - .5);
561                         if (!tag)
562                             tag = '<1';
563                         tag = `${tag} %`
564                     }
565
566                     failureTypeOrder.forEach(type => {
567                         if (data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}${type}`] > 0) {
568                             color = colorMap[type];
569                             symbol = TestResultsSymbolMap[type];
570                         }
571                     });
572                 } else {
573                     let resultId = Expectations.stringToStateId(data.actual);
574                     if (willFilterExpected)
575                         resultId = Expectations.stringToStateId(Expectations.unexpectedResults(data.actual, data.expected));
576                     failureTypeOrder.forEach(type => {
577                         if (Expectations.stringToStateId(failureTypeMapping[type]) >= resultId) {
578                             color = colorMap[type];
579                             symbol = TestResultsSymbolMap[type];
580                         }
581                     });
582                 }
583
584                 return drawDot(context, x, y, false, tag ? tag : null, symbol, false, color);
585             },
586         };
587
588         function onDotClickFactory(configuration) {
589             return (data) => {
590                 // FIXME: We should do something sane here, but we probably need another endpoint
591                 if (!data.start_time) {
592                     alert('Node is a combination of multiple runs');
593                     return;
594                 }
595
596                 let buildParams = configuration.toParams();
597                 buildParams['suite'] = [self.suite];
598                 buildParams['uuid'] = [data.uuid];
599                 buildParams['after_time'] = [data.start_time];
600                 buildParams['before_time'] = [data.start_time];
601                 if (branch)
602                     buildParams['branch'] = branch;
603                 window.open(`/urls/build?${paramsToQuery(buildParams)}`, '_blank');
604             }
605         }
606
607         function onDotEnterFactory(configuration) {
608             return (data, event, canvas) => {
609                 const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
610                 ToolTip.set(
611                     `<div class="content">
612                         ${data.start_time ? `<a href="/urls/build?${paramsToQuery(function () {
613                             let buildParams = configuration.toParams();
614                             buildParams['suite'] = [self.suite];
615                             buildParams['uuid'] = [data.uuid];
616                             buildParams['after_time'] = [data.start_time];
617                             buildParams['before_time'] = [data.start_time];
618                             if (branch)
619                                 buildParams['branch'] = branch;
620                             return buildParams;
621                         } ())}" target="_blank">Test run</a> @ ${new Date(data.start_time * 1000).toLocaleString()}<br>` : ''}
622                         Commits: ${CommitBank.commitsDuringUuid(data.uuid).map((commit) => {
623                             let params = {
624                                 branch: commit.branch ? [commit.branch] : branch,
625                                 uuid: [commit.uuid],
626                             }
627                             if (!params.branch)
628                                 delete params.branch;
629                             const query = paramsToQuery(params);
630                             return `<a href="/commit/info?${query}" target="_blank">${commit.id.substring(0,12)}</a>`;
631                         }).join(', ')}
632                         <br>
633
634                         ${data.expected ? `Expected: ${data.expected}<br>` : ''}
635                         ${data.actual ? `Actual: ${data.actual}<br>` : ''}
636                     </div>`,
637                     data.tipPoints.map((point) => {
638                         return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y};
639                     }),
640                     (event) => {onDotClickFactory(configuration)(data);},
641                 );
642             }
643         }
644
645         function onDotLeave(event, canvas) {
646             const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
647             if (!ToolTip.isIn({x: event.pageX, y: event.pageY - scrollDelta}))
648                 ToolTip.unset();
649         }
650
651         function exporterFactory(data) {
652             return (updateFunction) => {
653                 self.updates.push((scale) => {updateFunction(data, scale);});
654             }
655         }
656
657         let children = [];
658         this.configurations.forEach(configuration => {
659             if (!this.results[configuration.toKey()] || Object.keys(this.results[configuration.toKey()]).length === 0)
660                 return;
661
662             // Create a list of configurations to display with SDKs stripped
663             let mappedChildrenConfigs = {};
664             let childrenConfigsBySDK = {}
665             let resultsByKey = {};
666             this.results[configuration.toKey()].forEach(pair => {
667                 const strippedConfig = new Configuration(pair.configuration);
668                 resultsByKey[strippedConfig.toKey()] = combineResults([], [...pair.results].sort(function(a, b) {return b.uuid - a.uuid;}));
669                 delete strippedConfig.sdk;
670                 mappedChildrenConfigs[strippedConfig.toKey()] = strippedConfig;
671                 if (!childrenConfigsBySDK[strippedConfig.toKey()])
672                     childrenConfigsBySDK[strippedConfig.toKey()] = [];
673                 childrenConfigsBySDK[strippedConfig.toKey()].push(new Configuration(pair.configuration));
674             });
675             let childrenConfigs = [];
676             Object.keys(mappedChildrenConfigs).forEach(key => {
677                 childrenConfigs.push(mappedChildrenConfigs[key]);
678             });
679             childrenConfigs.sort(function(a, b) {return a.compare(b);});
680
681             // Create the collapsed timelines, cobine results
682             let allResults = [];
683             let collapsedTimelines = [];
684             childrenConfigs.forEach(config => {
685                 childrenConfigsBySDK[config.toKey()].sort(function(a, b) {return a.compareSDKs(b);});
686
687                 let resultsForConfig = [];
688                 childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => {
689                     resultsForConfig = combineResults(resultsForConfig, resultsByKey[sdkConfig.toKey()]);
690                 });
691                 allResults = combineResults(allResults, resultsForConfig);
692
693                 let queueParams = config.toParams();
694                 queueParams['suite'] = [this.suite];
695                 if (branch)
696                     queueParams['branch'];
697                 let myTimeline = Timeline.SeriesWithHeaderComponent(
698                     `${childrenConfigsBySDK[config.toKey()].length > 1 ? ' | ' : ''}<a href="/urls/queue?${paramsToQuery(queueParams)}" target="_blank">${config}</a>`,
699                     Timeline.CanvasSeriesComponent(resultsForConfig, scale, {
700                         getScaleFunc: options.getScaleFunc,
701                         compareFunc: options.compareFunc,
702                         renderFactory: options.renderFactory,
703                         exporter: options.exporter,
704                         onDotClick: onDotClickFactory(config),
705                         onDotEnter: onDotEnterFactory(config),
706                         onDotLeave: onDotLeave,
707                         exporter: exporterFactory(resultsForConfig),
708                     }));
709
710                 if (childrenConfigsBySDK[config.toKey()].length > 1) {
711                     let timelinesBySDK = [];
712                     childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => {
713                         timelinesBySDK.push(
714                             Timeline.SeriesWithHeaderComponent(`${sdkConfig.sdk}`,
715                                 Timeline.CanvasSeriesComponent(resultsByKey[sdkConfig.toKey()], scale, {
716                                     getScaleFunc: options.getScaleFunc,
717                                     compareFunc: options.compareFunc,
718                                     renderFactory: options.renderFactory,
719                                     exporter: options.exporter,
720                                     onDotClick: onDotClickFactory(sdkConfig),
721                                     onDotEnter: onDotEnterFactory(sdkConfig),
722                                     onDotLeave: onDotLeave,
723                                     exporter: exporterFactory(resultsByKey[sdkConfig.toKey()]),
724                                 })));
725                     });
726                     myTimeline = Timeline.ExpandableSeriesWithHeaderExpanderComponent(myTimeline, {}, ...timelinesBySDK);
727                 }
728                 collapsedTimelines.push(myTimeline);
729             });
730
731             if (collapsedTimelines.length === 0)
732                 return;
733             if (collapsedTimelines.length === 1) {
734                 if (!collapsedTimelines[0].header.includes('class="series"'))
735                     collapsedTimelines[0].header = Timeline.HeaderComponent(collapsedTimelines[0].header);
736                 children.push(collapsedTimelines[0]);
737                 return;
738             }
739
740             children.push(
741                 Timeline.ExpandableSeriesWithHeaderExpanderComponent(
742                 Timeline.SeriesWithHeaderComponent(` ${configuration}`,
743                     Timeline.CanvasSeriesComponent(allResults, scale, {
744                         getScaleFunc: options.getScaleFunc,
745                         compareFunc: options.compareFunc,
746                         renderFactory: options.renderFactory,
747                         onDotClick: onDotClickFactory(configuration),
748                         onDotEnter: onDotEnterFactory(configuration),
749                         onDotLeave: onDotLeave,
750                         exporter: exporterFactory(allResults),
751                     })),
752                 {expanded: this.configurations.length <= 1},
753                 ...collapsedTimelines
754             ));
755         });
756
757         let top = true;
758         self.xaxisUpdates = [];
759         this.repositories = repositoriesForCommits(commits);
760         this.repositories.forEach(repository => {
761             const xAxisComponent = xAxisFromScale(scale, repository, self.xaxisUpdates, top);
762             if (top)
763                 children.unshift(xAxisComponent);
764             else
765                 children.push(xAxisComponent);
766             top = false;
767         });
768
769         const composer = FP.composer(FP.currying((updateTimeline, notifiyRerender) => {
770             self.timelineUpdate = (xAxises) => {
771                 children.splice(0, 1);
772                 if (self.repositories.length > 1)
773                     children.splice(children.length - self.repositories.length, self.repositories.length);
774
775                 let top = true;
776                 xAxises.forEach(component => {
777                     if (top)
778                         children.unshift(component);
779                     else
780                         children.push(component);
781                     top = false;
782                 });
783                 updateTimeline(children);
784             };
785             self.notifiyRerender = notifiyRerender;
786         }));
787         return Timeline.CanvasContainer(composer, ...children);
788     }
789 }
790
791
792 function LegendLabel(eventStream, filterExpectedText, filterUnexpectedText) {
793     let ref = REF.createRef({
794         state: willFilterExpected,
795         onStateUpdate: (element, state) => {
796             if (state) element.innerText = filterExpectedText;
797             else element.innerText = filterUnexpectedText;
798         }
799     });
800     eventStream.action((willFilterExpected) => ref.setState(willFilterExpected));
801     return `<div class="label" ref="${ref}"></div>`;
802
803
804 function Legend(callback=null, plural=false) {
805     let updateLabelEvents = new EventStream();
806     const legendDetails = {
807         success: {
808             expected: plural ? 'No unexpected results' : 'Result expected',
809             unexpected: plural ? 'All tests passed' : 'Test passed',
810         },
811         failed: {
812             expected: plural ? 'Some tests unexpectedly failed' : 'Unexpectedly failed',
813             unexpected: plural ? 'Some tests failed' : 'Test failed',
814         },
815         timedout: {
816             expected: plural ? 'Some tests unexpectedly timed out' : 'Unexpectedly timed out',
817             unexpected: plural ? 'Some tests timed out' : 'Test timed out',
818         },
819         crashed: {
820             expected: plural ? 'Some tests unexpectedly crashed' : 'Unexpectedly crashed',
821             unexpected: plural ? 'Some tests crashed' : 'Test crashed',
822         },
823     };
824     let result = `<div class="lengend horizontal">
825             ${Object.keys(legendDetails).map((key) => {
826                 const dot = REF.createRef({
827                     onElementMount: (element) => {
828                         element.addEventListener('mouseleave', (event) => {
829                             if (!ToolTip.isIn({x: event.x, y: event.y}))
830                                 ToolTip.unset();
831                         });
832                         element.onmouseover = (event) => {
833                             if (!element.classList.contains('disabled'))
834                                 return;
835                             ToolTip.setByElement(
836                                 `<div class="content">
837                                     ${willFilterExpected ? legendDetails[key].expected : legendDetails[key].unexpected}
838                                 </div>`,
839                                 element,
840                                 {orientation: ToolTip.HORIZONTAL},
841                             );
842                         };
843                     }
844                 });
845                 return `<div class="item">
846                         <div class="dot ${key}" ref="${dot}"><div class="text">${TestResultsSymbolMap[key]}</div></div>
847                         ${LegendLabel(updateLabelEvents, legendDetails[key].expected, legendDetails[key].unexpected)}
848                     </div>`
849             }).join('')}
850         </div>`;
851
852     if (callback) {
853         const swtch = REF.createRef({
854             onElementMount: (element) => {
855                 element.onchange = () => {
856                     if (element.checked)
857                         willFilterExpected = true;
858                     else
859                         willFilterExpected = false;
860                     updateLabelEvents.add(willFilterExpected);
861                     callback();
862                 };
863             },
864         });
865
866         result += `<div class="input">
867             <label style="font-size: var(--tinySize); color: var(--boldInverseColor)">Filter expected results</label>
868             <label class="switch">
869                 <input type="checkbox"${willFilterExpected ? ' checked': ''} ref="${swtch}">
870                 <span class="slider"></span>
871             </label>
872         </div>`;
873     }
874
875     return `${result}`;
876 }
877
878 export {Legend, TimelineFromEndpoint, Expectations};