5a5325c516cfbd7b1e79d08b7dec8c03f51ca5a7
[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             if (!ToolTip.isIn({x: event.x, y: event.y}))
248                 ToolTip.unset();
249         },
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));});
255         },
256     });
257 }
258
259 const testsRegex = /tests_([a-z])+/;
260 const failureTypeOrder = ['failed', 'timedout', 'crashed'];
261 const failureTypeMapping = {
262     failed: 'ERROR',
263     timedout: 'TIMEOUT',
264     crashed: 'CRASH',
265 }
266
267 function inPlaceCombine(out, obj)
268 {
269     if (!obj)
270         return out;
271
272     if (!out) {
273         out = {};
274         Object.keys(obj).forEach(key => {
275             if (key[0] === '_')
276                 return;
277             if (obj[key] instanceof Object)
278                 out[key] = inPlaceCombine(out[key], obj[key]);
279             else
280                 out[key] = obj[key];
281         });
282     } else {
283         Object.keys(out).forEach(key => {
284             if (key[0] === '_')
285                 return;
286
287             if (out[key] instanceof Object) {
288                 out[key] = inPlaceCombine(out[key], obj[key]);
289                 return;
290             }
291
292             // Set of special case keys which need to be added together
293             if (key.match(testsRegex)) {
294                 out[key] += obj[key];
295                 return;
296             }
297
298             // If the key exists, but doesn't match, delete it
299             if (!(key in obj) || out[key] !== obj[key]) {
300                 delete out[key];
301                 return;
302             }
303         });
304         Object.keys(obj).forEach(key => {
305             if (key.match(testsRegex) && !(key in out))
306                 out[key] = obj[key];
307         });
308     }
309     return out;
310 }
311
312 function statsForSingleResult(result) {
313     const actualId = Expectations.stringToStateId(result.actual);
314     const unexpectedId = Expectations.stringToStateId(Expectations.unexpectedResults(result.actual, result.expected));
315     let stats = {
316         tests_run: 1,
317         tests_skipped: 0,
318     }
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;
323     });
324     return stats;
325 }
326
327 function combineResults() {
328     let counts = new Array(arguments.length).fill(0);
329     let data = [];
330
331     while (true) {
332         // Find candidate uuid
333         let uuid = 0;
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;
338                 if (candidateUuid)
339                     break;
340                 ++counts[i];
341             }
342             if (candidateUuid)
343                 uuid = Math.max(uuid, candidateUuid);
344         }
345
346         if (!uuid)
347             return data;
348
349         // Combine relevant results
350         let dataNode = null;
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);
355
356                 dataNode = inPlaceCombine(dataNode, arguments[i][counts[i]]);
357
358                 if (dataNode.stats && !arguments[i][counts[i]].stats)
359                     dataNode.stats = inPlaceCombine(dataNode.stats, statsForSingleResult(arguments[i][counts[i]]));
360
361                 ++counts[i];
362             }
363         }
364         if (dataNode)
365             data.push(dataNode);
366     }
367     return data;
368 }
369
370 class TimelineFromEndpoint {
371     constructor(endpoint, suite = null) {
372         this.endpoint = endpoint;
373         this.displayAllCommits = true;
374
375         this.configurations = Configuration.fromQuery();
376         this.results = {};
377         this.suite = suite;  // Suite is often implied by the endpoint, but trying to determine suite from endpoint is not trivial.
378
379         this.updates = [];
380         this.xaxisUpdates = [];
381         this.timelineUpdate = null;
382         this.repositories = [];
383
384         const self = this;
385
386         this.latestDispatch = Date.now();
387         this.ref = REF.createRef({
388             state: {},
389             onStateUpdate: (element, state) => {
390                 if (state.error)
391                     element.innerHTML = ErrorDisplay(state);
392                 else if (state > 0)
393                     DOM.inject(element, this.render(state));
394                 else
395                     element.innerHTML = this.placeholder();
396             }
397         });
398
399         this.commit_callback = () => {
400             self.update();
401         };
402         CommitBank.callbacks.push(this.commit_callback);
403
404         this.reload();
405     }
406     teardown() {
407         CommitBank.callbacks = CommitBank.callbacks.filter((value, index, arr) => {
408             return this.commit_callback === value;
409         });
410     }
411     update() {
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);
415
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 = [];
422             let top = true;
423             let components = [];
424
425             newRepositories.forEach(repository => {
426                 components.push(xAxisFromScale(scale, repository, this.xaxisUpdates, top));
427                 top = false;
428             });
429
430             this.timelineUpdate(components);
431             this.repositories = newRepositories;
432         }
433
434         this.updates.forEach(func => {func(scale);})
435         this.xaxisUpdates.forEach(func => {func(scale);});
436     }
437     rerender() {
438         const params = queryToParams(document.URL.split('?')[1]);
439         this.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT);
440     }
441     reload() {
442         let myDispatch = Date.now();
443         this.latestDispatch = Math.max(this.latestDispatch, myDispatch);
444         this.ref.setState(-1);
445
446         const self = this;
447         let sharedParams = queryToParams(document.URL.split('?')[1]);
448         Configuration.members().forEach(member => {
449             delete sharedParams[member];
450         });
451         delete sharedParams.suite;
452         delete sharedParams.test;
453         delete sharedParams.repository_id;
454
455         let newConfigs = Configuration.fromQuery();
456         if (!deepCompare(newConfigs, this.configurations)) {
457             this.configurations = newConfigs;
458             this.results = {};
459             this.configurations.forEach(configuration => {
460                 this.results[configuration.toKey()] = [];
461             });
462         }
463
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);
469
470             fetch(query ? this.endpoint + '?' + query : this.endpoint).then(response => {
471                 response.json().then(json => {
472                     if (myDispatch !== this.latestDispatch)
473                         return;
474
475                     let oldestUuid = Date.now() / 10;
476                     let newestUuid = 0;
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;
485                     });
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);
490                         });
491                     });
492
493                     if (oldestUuid < newestUuid)
494                         CommitBank.add(oldestUuid, newestUuid);
495
496                     self.ref.setState(params.limit ? parseInt(params.limit[params.limit.length - 1]) : DEFAULT_LIMIT);
497                 });
498             }).catch(error => {
499                 if (myDispatch === this.latestDispatch)
500                     this.ref.setState({error: "Connection Error", description: error});
501             });
502         });
503     }
504     placeholder() {
505         return `<div class="loader">
506                 <div class="spinner"></div>
507             </div>`;
508     }
509     toString() {
510         this.ref = REF.createRef({
511             state: this.ref.state,
512             onStateUpdate: (element, state) => {
513                 if (state.error)
514                     DOM.inject(element, ErrorDisplay(state));
515                 else if (state > 0)
516                     DOM.inject(element, this.render(state));
517                 else
518                     DOM.inject(element, this.placeholder());
519             }
520         });
521
522         return `<div class="content" ref="${this.ref}"></div>`;
523     }
524
525     render(limit) {
526         const branch = queryToParams(document.URL.split('?')[1]).branch;
527         const self = this;
528         const commits = commitsForResults(this.results, limit, this.allCommits);
529         const scale = scaleForCommits(commits);
530
531         const computedStyle = getComputedStyle(document.body);
532         const colorMap = {
533             success: computedStyle.getPropertyValue('--greenLight').trim(),
534             failed: computedStyle.getPropertyValue('--redLight').trim(),
535             timedout: computedStyle.getPropertyValue('--orangeLight').trim(),
536             crashed: computedStyle.getPropertyValue('--purpleLight').trim(),
537         }
538
539         this.updates = [];
540         const options = {
541             getScaleFunc: (value) => {
542                 if (value && value.uuid)
543                     return {uuid: value.uuid};
544                 return {};
545             },
546             compareFunc: (a, b) => {return b.uuid - a.uuid;},
547             renderFactory: (drawDot) => (data, context, x, y) => {
548                 if (!data)
549                     return drawDot(context, x, y, true);
550
551                 let tag = null;
552                 let color = colorMap.success;
553                 let symbol = TestResultsSymbolMap.success;
554                 if (data.stats) {
555                     tag = data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}failed`];
556
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);
560                         if (!tag)
561                             tag = '<1';
562                         tag = `${tag} %`
563                     }
564
565                     failureTypeOrder.forEach(type => {
566                         if (data.stats[`tests${willFilterExpected ? '_unexpected_' : '_'}${type}`] > 0) {
567                             color = colorMap[type];
568                             symbol = TestResultsSymbolMap[type];
569                         }
570                     });
571                 } else {
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];
579                         }
580                     });
581                 }
582
583                 return drawDot(context, x, y, false, tag ? tag : null, symbol, false, color);
584             },
585         };
586
587         function onDotClickFactory(configuration) {
588             return (data) => {
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');
592                     return;
593                 }
594
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];
600                 if (branch)
601                     buildParams['branch'] = branch;
602                 window.open(`/urls/build?${paramsToQuery(buildParams)}`, '_blank');
603             }
604         }
605
606         function onDotEnterFactory(configuration) {
607             return (data, event, canvas) => {
608                 const scrollDelta = document.documentElement.scrollTop || document.body.scrollTop;
609                 ToolTip.set(
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];
617                             if (branch)
618                                 buildParams['branch'] = branch;
619                             return buildParams;
620                         } ())}" target="_blank">Test run</a> @ ${new Date(data.start_time * 1000).toLocaleString()}<br>` : ''}
621                         Commits: ${CommitBank.commitsDuringUuid(data.uuid).map((commit) => {
622                             let params = {
623                                 branch: commit.branch ? [commit.branch] : branch,
624                                 uuid: [commit.uuid],
625                             }
626                             if (!params.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>`;
630                         }).join(', ')}
631                         <br>
632
633                         ${data.expected ? `Expected: ${data.expected}<br>` : ''}
634                         ${data.actual ? `Actual: ${data.actual}<br>` : ''}
635                     </div>`,
636                     data.tipPoints.map((point) => {
637                         return {x: canvas.x + point.x, y: canvas.y + scrollDelta + point.y};
638                     }),
639                     (event) => {onDotClickFactory(configuration)(data);},
640                 );
641             }
642         }
643
644         function onDotLeave(event, canvas) {
645             if (!ToolTip.isIn({x: event.pageX, y: event.pageY}))
646                 ToolTip.unset();
647         }
648
649         function exporterFactory(data) {
650             return (updateFunction) => {
651                 self.updates.push((scale) => {updateFunction(data, scale);});
652             }
653         }
654
655         let children = [];
656         this.configurations.forEach(configuration => {
657             if (!this.results[configuration.toKey()] || Object.keys(this.results[configuration.toKey()]).length === 0)
658                 return;
659
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));
672             });
673             let childrenConfigs = [];
674             Object.keys(mappedChildrenConfigs).forEach(key => {
675                 childrenConfigs.push(mappedChildrenConfigs[key]);
676             });
677             childrenConfigs.sort(function(a, b) {return a.compare(b);});
678
679             // Create the collapsed timelines, cobine results
680             let allResults = [];
681             let collapsedTimelines = [];
682             childrenConfigs.forEach(config => {
683                 childrenConfigsBySDK[config.toKey()].sort(function(a, b) {return a.compareSDKs(b);});
684
685                 let resultsForConfig = [];
686                 childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => {
687                     resultsForConfig = combineResults(resultsForConfig, resultsByKey[sdkConfig.toKey()]);
688                 });
689                 allResults = combineResults(allResults, resultsForConfig);
690
691                 let queueParams = config.toParams();
692                 queueParams['suite'] = [this.suite];
693                 if (branch)
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),
706                     }));
707
708                 if (childrenConfigsBySDK[config.toKey()].length > 1) {
709                     let timelinesBySDK = [];
710                     childrenConfigsBySDK[config.toKey()].forEach(sdkConfig => {
711                         timelinesBySDK.push(
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()]),
722                                 })));
723                     });
724                     myTimeline = Timeline.ExpandableSeriesWithHeaderExpanderComponent(myTimeline, {}, ...timelinesBySDK);
725                 }
726                 collapsedTimelines.push(myTimeline);
727             });
728
729             if (collapsedTimelines.length === 0)
730                 return;
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]);
735                 return;
736             }
737
738             children.push(
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),
749                     })),
750                 {expanded: this.configurations.length <= 1},
751                 ...collapsedTimelines
752             ));
753         });
754
755         let top = true;
756         self.xaxisUpdates = [];
757         this.repositories = repositoriesForCommits(commits);
758         this.repositories.forEach(repository => {
759             const xAxisComponent = xAxisFromScale(scale, repository, self.xaxisUpdates, top);
760             if (top)
761                 children.unshift(xAxisComponent);
762             else
763                 children.push(xAxisComponent);
764             top = false;
765         });
766
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);
772
773                 let top = true;
774                 xAxises.forEach(component => {
775                     if (top)
776                         children.unshift(component);
777                     else
778                         children.push(component);
779                     top = false;
780                 });
781                 updateTimeline(children);
782             };
783             self.notifiyRerender = notifiyRerender;
784         }));
785         return Timeline.CanvasContainer(composer, ...children);
786     }
787 }
788
789
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;
796         }
797     });
798     eventStream.action((willFilterExpected) => ref.setState(willFilterExpected));
799     return `<div class="label" ref="${ref}"></div>`;
800
801
802 function Legend(callback=null, plural=false) {
803     let updateLabelEvents = new EventStream();
804     let result = `<br>
805          <div class="lengend timeline">
806             <div class="item">
807                 <div class="dot success"><div class="text">${TestResultsSymbolMap.success}</div></div>
808                 ${LegendLabel(
809                     updateLabelEvents,
810                     plural ? 'No unexpected results' : 'Result expected',
811                     plural ? 'All tests passed' : 'Test passed',
812                 )}
813             </div>
814             <div class="item">
815                 <div class="dot failed"><div class="text">${TestResultsSymbolMap.failed}</div></div>
816                 ${LegendLabel(
817                     updateLabelEvents,
818                     plural ? 'Some tests unexpectedly failed' : 'Unexpectedly failed',
819                     plural ? 'Some tests failed' : 'Test failed',
820                 )}
821             </div>
822             <div class="item">
823                 <div class="dot timeout"><div class="text">${TestResultsSymbolMap.timedout}</div></div>
824                 ${LegendLabel(
825                     updateLabelEvents,
826                     plural ? 'Some tests unexpectedly timed out' : 'Unexpectedly timed out',
827                     plural ? 'Some tests timed out' : 'Test timed out',
828                 )}
829             </div>
830             <div class="item">
831                 <div class="dot crash"><div class="text">${TestResultsSymbolMap.crashed}</div></div>
832                 ${LegendLabel(
833                     updateLabelEvents,
834                     plural ? 'Some tests unexpectedly crashed' : 'Unexpectedly crashed',
835                     plural ? 'Some tests crashed' : 'Test crashed',
836                 )}
837             </div>
838             <br>
839         </div>`;
840
841     if (callback) {
842         const swtch = REF.createRef({
843             onElementMount: (element) => {
844                 element.onchange = () => {
845                     if (element.checked)
846                         willFilterExpected = true;
847                     else
848                         willFilterExpected = false;
849                     updateLabelEvents.add(willFilterExpected);
850                     callback();
851                 };
852             },
853         });
854
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>
860             </label>
861         </div>`;
862     }
863
864     return `<div class="content">${result}</div><br>`;
865 }
866
867 export {Legend, TimelineFromEndpoint, Expectations};