Custom analysis task page should allow schedule any triggerable accepted tests.
[WebKit.git] / Websites / perf.webkit.org / public / v3 / components / custom-analysis-task-configurator.js
1 class CustomAnalysisTaskConfigurator extends ComponentBase {
2
3     constructor()
4     {
5         super('custom-analysis-task-configurator');
6
7         this._selectedTests = [];
8         this._triggerablePlatforms = [];
9         this._selectedPlatform = null;
10         this._showComparison = false;
11         this._commitSetMap = {};
12         this._specifiedRevisions = {'Baseline': new Map, 'Comparison': new Map};
13         this._patchUploaders = {'Baseline': new Map, 'Comparison': new Map};
14         this._customRootUploaders = {'Baseline': null, 'Comparison': null};
15         this._fetchedCommits = {'Baseline': new Map, 'Comparison': new Map};
16         this._repositoryGroupByConfiguration = {'Baseline': null, 'Comparison': null};
17         this._invalidRevisionsByConfiguration = {'Baseline': new Map, 'Comparison': new Map};
18
19         this._updateTriggerableLazily = new LazilyEvaluatedFunction(this._updateTriggerable.bind(this));
20         this._renderTriggerableTestsLazily = new LazilyEvaluatedFunction(this._renderTriggerableTests.bind(this));
21         this._renderTriggerablePlatformsLazily = new LazilyEvaluatedFunction(this._renderTriggerablePlatforms.bind(this));
22         this._renderRepositoryPanesLazily = new LazilyEvaluatedFunction(this._renderRepositoryPanes.bind(this));
23     }
24
25     tests() { return this._selectedTests; }
26     platform() { return this._selectedPlatform; }
27     commitSets()
28     {
29         const map = this._commitSetMap;
30         if (!map['Baseline'] || !map['Comparison'])
31             return null;
32         return [map['Baseline'], map['Comparison']];
33     }
34
35     selectTests(selectedTests)
36     {
37         this._selectedTests = selectedTests;
38
39         this._triggerablePlatforms = Triggerable.triggerablePlatformsForTests(this._selectedTests);
40         if (this._selectedTests.length && !this._triggerablePlatforms.includes(this._selectedPlatform))
41             this._selectedPlatform = null;
42
43         this._didUpdateSelectedPlatforms();
44     }
45
46     selectPlatform(selectedPlatform)
47     {
48         this._selectedPlatform = selectedPlatform;
49
50         const [triggerable, error] = this._updateTriggerableLazily.evaluate(this._selectedTests, this._selectedPlatform);
51         this._updateRepositoryGroups(triggerable);
52
53         this._didUpdateSelectedPlatforms();
54     }
55
56     _didUpdateSelectedPlatforms()
57     {
58         for (const configuration of ['Baseline', 'Comparison']) {
59             this._updateMapFromSpecifiedRevisionsForConfiguration(this._fetchedCommits, configuration);
60             this._updateMapFromSpecifiedRevisionsForConfiguration(this._invalidRevisionsByConfiguration, configuration);
61         }
62         this._updateCommitSetMap();
63         this.dispatchAction('testConfigChange');
64         this.enqueueToRender();
65     }
66
67     _updateMapFromSpecifiedRevisionsForConfiguration(map, configuration)
68     {
69         const referenceMap = this._specifiedRevisions[configuration];
70         const newValue = new Map;
71         for (const [key, value] of map[configuration].entries()) {
72             if (!referenceMap.has(key))
73                 continue;
74             newValue.set(key, value);
75         }
76         if (newValue.size !== map[configuration].size)
77             map[configuration] = newValue;
78     }
79
80     setCommitSets(baselineCommitSet, comparisonCommitSet)
81     {
82         const [triggerable, error] = this._updateTriggerableLazily.evaluate(this._selectedTests, this._selectedPlatform);
83
84         if (!triggerable)
85             return;
86
87         const baselineRepositoryGroup = triggerable.repositoryGroups().find((repositoryGroup) => repositoryGroup.accepts(baselineCommitSet));
88         if (baselineRepositoryGroup) {
89             this._repositoryGroupByConfiguration['Baseline'] = baselineRepositoryGroup;
90             this._setUploadedFilesToUploader(this._customRootUploaders['Baseline'], baselineCommitSet.customRoots());
91             this._specifiedRevisions['Baseline'] = this._revisionMapFromCommitSet(baselineCommitSet);
92             this._setPatchFiles('Baseline', baselineCommitSet);
93         }
94
95         const comparisonRepositoryGroup = triggerable.repositoryGroups().find((repositoryGroup) => repositoryGroup.accepts(baselineCommitSet));
96         if (comparisonRepositoryGroup) {
97             this._repositoryGroupByConfiguration['Comparison'] = comparisonRepositoryGroup;
98             this._setUploadedFilesToUploader(this._customRootUploaders['Comparison'], comparisonCommitSet.customRoots());
99             this._specifiedRevisions['Comparison'] = this._revisionMapFromCommitSet(comparisonCommitSet);
100             this._setPatchFiles('Comparison', comparisonCommitSet);
101         }
102
103         this._showComparison = true;
104         this._updateCommitSetMap();
105     }
106
107     _setUploadedFilesToUploader(uploader, files)
108     {
109         if (!uploader || uploader.hasFileToUpload() || uploader.uploadedFiles().length)
110             return;
111         uploader.clearUploads();
112         for (const uploadedFile of files)
113             uploader.addUploadedFile(uploadedFile);
114     }
115
116     _setPatchFiles(configurationName, commitSet)
117     {
118         for (const repository of commitSet.repositories()) {
119             const patch = commitSet.patchForRepository(repository);
120             if (patch)
121                 this._setUploadedFilesToUploader(this._ensurePatchUploader(configurationName, repository), [patch]);
122         }
123     }
124
125     _revisionMapFromCommitSet(commitSet)
126     {
127         const revisionMap = new Map;
128         for (const repository of commitSet.repositories())
129             revisionMap.set(repository, commitSet.revisionForRepository(repository));
130         return revisionMap;
131     }
132
133     didConstructShadowTree()
134     {
135         this.content('specify-comparison-button').onclick = this.createEventHandler(() => this._configureComparison());
136
137         const createRootUploader = () => {
138             const uploader = new InstantFileUploader;
139             uploader.allowMultipleFiles();
140             uploader.element().textContent = 'Add a new root';
141             uploader.listenToAction('removedFile', () => this._updateCommitSetMap());
142             return uploader;
143         }
144
145         const baselineRootsUploader = createRootUploader();
146         baselineRootsUploader.listenToAction('uploadedFile', (uploadedFile) => this._updateCommitSetMap());
147         this._customRootUploaders['Baseline'] = baselineRootsUploader;
148
149         const comparisonRootsUploader = createRootUploader();
150         comparisonRootsUploader.listenToAction('uploadedFile', () => this._updateCommitSetMap());
151         this._customRootUploaders['Comparison'] = comparisonRootsUploader;
152     }
153
154     _ensurePatchUploader(configurationName, repository)
155     {
156         const uploaderMap = this._patchUploaders[configurationName];
157         let uploader = uploaderMap.get(repository);
158         if (uploader)
159             return uploader;
160
161         uploader = new InstantFileUploader;
162         uploader.element().textContent = 'Apply a patch';
163         uploader.listenToAction('uploadedFile', () => this._updateCommitSetMap());
164         uploader.listenToAction('removedFile', () => this._updateCommitSetMap());
165         uploaderMap.set(repository, uploader);
166
167         return uploader;
168     }
169
170     _configureComparison()
171     {
172         this._showComparison = true;
173         this._repositoryGroupByConfiguration['Comparison'] = this._repositoryGroupByConfiguration['Baseline'];
174
175         const specifiedBaselineRevisions = this._specifiedRevisions['Baseline'];
176         const specifiedComparisonRevisions = new Map;
177         for (let key of specifiedBaselineRevisions.keys())
178             specifiedComparisonRevisions.set(key, specifiedBaselineRevisions.get(key));
179         this._specifiedRevisions['Comparison'] = specifiedComparisonRevisions;
180
181         for (const [repository, patchUploader] of this._patchUploaders['Baseline']) {
182             const files = patchUploader.uploadedFiles();
183             if (!files.length)
184                 continue;
185             const comparisonPatchUploader = this._ensurePatchUploader('Comparison', repository);
186             for (const uploadedFile of files)
187                 comparisonPatchUploader.addUploadedFile(uploadedFile);
188         }
189
190         const comparisonRootUploader = this._customRootUploaders['Comparison'];
191         for (const uploadedFile of this._customRootUploaders['Baseline'].uploadedFiles())
192             comparisonRootUploader.addUploadedFile(uploadedFile);
193
194         this.enqueueToRender();
195     }
196
197     render()
198     {
199         super.render();
200
201         const updateSelectedTestsLazily = this._renderTriggerableTestsLazily.evaluate();
202         updateSelectedTestsLazily.evaluate(...this._selectedTests);
203         const updateSelectedPlatformsLazily = this._renderTriggerablePlatformsLazily.evaluate(this._selectedTests, this._triggerablePlatforms);
204         if (updateSelectedPlatformsLazily)
205             updateSelectedPlatformsLazily.evaluate(this._selectedPlatform);
206
207         const [triggerable, error] = this._updateTriggerableLazily.evaluate(this._selectedTests, this._selectedPlatform);
208
209         this._renderRepositoryPanesLazily.evaluate(triggerable, error, this._selectedPlatform, this._repositoryGroupByConfiguration, this._showComparison);
210
211         this.renderReplace(this.content('baseline-testability'), this._buildTestabilityList(this._commitSetMap['Baseline'],
212             'Baseline', this._invalidRevisionsByConfiguration['Baseline']));
213
214         this.renderReplace(this.content('comparison-testability'), !this._showComparison ? null :
215             this._buildTestabilityList(this._commitSetMap['Comparison'], 'Comparison', this._invalidRevisionsByConfiguration['Comparison']));
216     }
217
218     _renderTriggerableTests()
219     {
220         const enabledTriggerables = Triggerable.all().filter((triggerable) => !triggerable.isDisabled());
221
222         const acceptedTests = new Set;
223         for (const triggerable of enabledTriggerables) {
224             for (const test of triggerable.acceptedTests())
225                 acceptedTests.add(test);
226         }
227         const tests = [...acceptedTests].sort((testA, testB) => {
228             if (testA.fullName() == testB.fullName())
229                 return 0;
230             return testA.fullName() < testB.fullName() ? -1 : 1;
231         });
232         return this._renderRadioButtonList(this.content('test-list'), 'test', tests, this.selectTests.bind(this), (test) => test.fullName());
233     }
234
235     _renderTriggerablePlatforms(selectedTests, triggerablePlatforms)
236     {
237         if (!selectedTests.length) {
238             this.content('platform-pane').style.display = 'none';
239             return null;
240         }
241         this.content('platform-pane').style.display = null;
242
243         return this._renderRadioButtonList(this.content('platform-list'), 'platform', triggerablePlatforms, (selectedPlatforms) => {
244             this.selectPlatform(selectedPlatforms.length ? selectedPlatforms[0] : null);
245         });
246     }
247
248     _renderRadioButtonList(listContainer, name, objects, callback, labelForObject = (object) => object.label())
249     {
250         const listItems = [];
251         let selectedListItems = [];
252         const checkSelectedRadioButtons = (newSelectedListItems) => {
253             selectedListItems.forEach((item) => {
254                 item.label.classList.remove('selected');
255                 item.radioButton.checked = false;
256             });
257             selectedListItems = newSelectedListItems;
258             selectedListItems.forEach((item) => {
259                 item.label.classList.add('selected');
260                 item.radioButton.checked = true;
261             });
262         }
263
264         const element = ComponentBase.createElement;
265         this.renderReplace(listContainer, objects.map((object) => {
266             const radioButton = element('input', {type: 'radio', name: name, onchange: () => {
267                 checkSelectedRadioButtons(listItems.filter((item) => item.radioButton.checked));
268                 callback(selectedListItems.map((item) => item.object));
269                 this.enqueueToRender();
270             }});
271             const label = element('label', [radioButton, labelForObject(object)]);
272             listItems.push({radioButton, label, object});
273             return element('li', label);
274         }));
275
276         return new LazilyEvaluatedFunction((...selectedObjects) => {
277             const objects = new Set(selectedObjects);
278             checkSelectedRadioButtons(listItems.filter((item) => objects.has(item.object)));
279         });
280     }
281
282     _updateTriggerable(tests, platform)
283     {
284         let triggerable = null;
285         let error = null;
286         if (tests.length && platform) {
287             triggerable = Triggerable.findByTestConfiguration(tests[0], platform);
288             let matchingTests = new Set;
289             let mismatchingTests = new Set;
290             for (let test of tests) {
291                 if (Triggerable.findByTestConfiguration(test, platform) == triggerable)
292                     matchingTests.add(test);
293                 else
294                     mismatchingTests.add(test);
295             }
296             if (matchingTests.size < tests.length) {
297                 const matchingTestNames = [...matchingTests].map((test) => test.fullName()).sort().join('", "');
298                 const mismathingTestNames = [...mismatchingTests].map((test) => test.fullName()).sort().join('", "');
299                 error = `Tests "${matchingTestNames}" and "${mismathingTestNames}" cannot be scheduled
300                     simultenosuly on "${platform.name()}". Please select one of them at a time.`;
301             }
302         }
303
304         return [triggerable, error];
305     }
306
307     _updateRepositoryGroups(triggerable)
308     {
309         const repositoryGroups = triggerable ? TriggerableRepositoryGroup.sortByNamePreferringSmallerRepositories(triggerable.repositoryGroups()) : [];
310         for (let name in this._repositoryGroupByConfiguration) {
311             const currentGroup = this._repositoryGroupByConfiguration[name];
312             let matchingGroup = null;
313             if (currentGroup) {
314                 if (repositoryGroups.includes(currentGroup))
315                     matchingGroup = currentGroup;
316                 else
317                     matchingGroup = repositoryGroups.find((group) => group.name() == currentGroup.name());
318             }
319             if (!matchingGroup && repositoryGroups.length)
320                 matchingGroup = repositoryGroups[0];
321             this._repositoryGroupByConfiguration[name] = matchingGroup;
322         }
323     }
324
325     _updateCommitSetMap()
326     {
327         const newBaseline = this._computeCommitSet('Baseline');
328         let newComparison = this._computeCommitSet('Comparison');
329         if (newBaseline && newComparison && newBaseline.equals(newComparison))
330             newComparison = null;
331
332         const currentBaseline = this._commitSetMap['Baseline'];
333         const currentComparison = this._commitSetMap['Comparison'];
334         const areCommitSetsEqual = (commitSetA, commitSetB) => commitSetA == commitSetB || (commitSetA && commitSetB && commitSetA.equals(commitSetB));
335         const sameBaselineConfig = areCommitSetsEqual(currentBaseline, newBaseline);
336         const sameComparisionConfig = areCommitSetsEqual(currentComparison, newComparison);
337
338         if (sameBaselineConfig && sameComparisionConfig)
339             return;
340
341         this._commitSetMap = {'Baseline': newBaseline, 'Comparison': newComparison};
342
343         this.dispatchAction('testConfigChange');
344         this.enqueueToRender();
345     }
346
347     _computeCommitSet(configurationName)
348     {
349         const repositoryGroup = this._repositoryGroupByConfiguration[configurationName];
350         if (!repositoryGroup)
351             return null;
352
353         const fileUploader = this._customRootUploaders[configurationName];
354         if (!fileUploader || fileUploader.hasFileToUpload())
355             return null;
356
357         const commitSet = new CustomCommitSet;
358         for (let repository of repositoryGroup.repositories()) {
359             let revision = this._specifiedRevisions[configurationName].get(repository);
360             const commit = this._fetchedCommits[configurationName].get(repository);
361             if (commit) {
362                 const commitLabel = commit.label();
363                 if (!revision || commit.revision().startsWith(revision) || commitLabel.startsWith(revision) || revision.startsWith(commitLabel))
364                     revision = commit.revision();
365             }
366             if (!revision)
367                 return null;
368             let patch = null;
369             if (repositoryGroup.acceptsPatchForRepository(repository)) {
370                 const uploaderMap = this._patchUploaders[configurationName];
371                 const uploader = uploaderMap.get(repository);
372                 if (uploader) {
373                     const files = uploader.uploadedFiles();
374                     console.assert(files.length <= 1);
375                     if (files.length)
376                         patch = files[0];
377                 }
378             }
379             commitSet.setRevisionForRepository(repository, revision, patch);
380         }
381
382         for (let uploadedFile of fileUploader.uploadedFiles())
383             commitSet.addCustomRoot(uploadedFile);
384
385         return commitSet;
386     }
387
388     async _fetchCommitsForConfiguration(configurationName)
389     {
390         const commitSet = this._commitSetMap[configurationName];
391         if (!commitSet)
392             return;
393
394         const specifiedRevisions = this._specifiedRevisions[configurationName];
395         const fetchedCommits = this._fetchedCommits[configurationName];
396         const invalidRevisionForRepository = this._invalidRevisionsByConfiguration[configurationName];
397
398         await Promise.all(Array.from(commitSet.repositories()).map((repository) => {
399             const revision = commitSet.revisionForRepository(repository);
400             return this._resolveRevision(repository, revision, specifiedRevisions, invalidRevisionForRepository, fetchedCommits);
401         }));
402
403         const latestCommitSet = this._commitSetMap[configurationName];
404         if (commitSet != latestCommitSet)
405             return;
406         this.enqueueToRender();
407     }
408
409     async _resolveRevision(repository, revision, specifiedRevisions, invalidRevisionForRepository, fetchedCommits)
410     {
411         const fetchedCommit = fetchedCommits.get(repository);
412         const specifiedRevision = specifiedRevisions.get(repository);
413         if (fetchedCommit && fetchedCommit.revision() == revision && (!specifiedRevision || specifiedRevision == revision))
414             return;
415
416         fetchedCommits.delete(repository);
417         let commits = [];
418         const revisionToFetch = specifiedRevision || revision;
419         try {
420             commits = await CommitLog.fetchForSingleRevision(repository, revisionToFetch, true);
421         } catch (error) {
422             console.assert(error == 'UnknownCommit' || error == 'AmbiguousRevisionPrefix');
423             if (revisionToFetch != specifiedRevisions.get(repository))
424                 return;
425             invalidRevisionForRepository.set(repository, `"${revisionToFetch}": ${error == 'UnknownCommit' ? 'Invalid revision' : 'Ambiguous revision prefix'}`);
426             return;
427         }
428         console.assert(commits.length, 1);
429         if (revisionToFetch != specifiedRevisions.get(repository))
430             return;
431         invalidRevisionForRepository.delete(repository);
432         fetchedCommits.set(repository, commits[0]);
433         if (revisionToFetch != commits[0].revision())
434             this._updateCommitSetMap();
435     }
436
437     _renderRepositoryPanes(triggerable, error, platform, repositoryGroupByConfiguration, showComparison)
438     {
439         this.content('repository-configuration-error-pane').style.display = error ? null : 'none';
440         this.content('error').textContent = error;
441
442         this.content('baseline-configuration-pane').style.display = triggerable ? null : 'none';
443         this.content('specify-comparison-pane').style.display = triggerable && !showComparison ? null : 'none';
444         this.content('comparison-configuration-pane').style.display = triggerable && showComparison ? null : 'none';
445
446         if (!triggerable)
447             return;
448
449         const visibleRepositoryGroups = triggerable.repositoryGroups().filter((group) => !group.isHidden());
450         const repositoryGroups = TriggerableRepositoryGroup.sortByNamePreferringSmallerRepositories(visibleRepositoryGroups);
451
452         const repositorySet = new Set;
453         for (let group of repositoryGroups) {
454             for (let repository of group.repositories())
455                 repositorySet.add(repository);
456         }
457
458         const repositories = Repository.sortByNamePreferringOnesWithURL([...repositorySet]);
459         const requiredRepositories = repositories.filter((repository) => {
460             return repositoryGroups.every((group) => group.repositories().includes(repository));
461         });
462         const alwaysAcceptsCustomRoots = repositoryGroups.every((group) => group.acceptsCustomRoots());
463
464         this._renderBaselineRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots);
465
466         if (showComparison)
467             this._renderComparisonRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots);
468     }
469
470     _renderBaselineRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots)
471     {
472         let currentGroup = repositoryGroupByConfiguration['Baseline'];
473         const optionalRepositoryList = this._optionalRepositoryList(currentGroup, requiredRepositories);
474         this.renderReplace(this.content('baseline-revision-table'),
475             this._buildRevisionTable('Baseline', repositoryGroups, currentGroup, platform, requiredRepositories, optionalRepositoryList, alwaysAcceptsCustomRoots));
476     }
477
478     _renderComparisonRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots)
479     {
480         let currentGroup = repositoryGroupByConfiguration['Comparison'];
481         const optionalRepositoryList = this._optionalRepositoryList(currentGroup, requiredRepositories);
482         this.renderReplace(this.content('comparison-revision-table'),
483             this._buildRevisionTable('Comparison', repositoryGroups, currentGroup, platform, requiredRepositories, optionalRepositoryList, alwaysAcceptsCustomRoots));
484     }
485
486     _optionalRepositoryList(currentGroup, requiredRepositories)
487     {
488         if (!currentGroup)
489             return [];
490         return Repository.sortByNamePreferringOnesWithURL(currentGroup.repositories().filter((repository) => !requiredRepositories.includes(repository)));
491     }
492
493     _buildRevisionTable(configurationName, repositoryGroups, currentGroup, platform, requiredRepositories, optionalRepositoryList, alwaysAcceptsCustomRoots)
494     {
495         const element = ComponentBase.createElement;
496
497         const customRootsTBody = element('tbody', [
498             element('tr', [
499                 element('th', 'Roots'),
500                 element('td', this._customRootUploaders[configurationName]),
501             ]),
502         ]);
503
504         return [
505             element('tbody',
506                 requiredRepositories.map((repository) => {
507                     return element('tr', [
508                         element('th', repository.name()),
509                         element('td', this._buildRevisionInput(configurationName, repository, platform))
510                     ]);
511                 })),
512             alwaysAcceptsCustomRoots ? customRootsTBody : [],
513             element('tbody', [
514                 element('tr', {'class': 'group-row'},
515                     element('td', {colspan: 2}, this._buildRepositoryGroupList(repositoryGroups, currentGroup, configurationName))),
516             ]),
517             !alwaysAcceptsCustomRoots && currentGroup && currentGroup.acceptsCustomRoots() ? customRootsTBody : [],
518             element('tbody',
519                 optionalRepositoryList.map((repository) => {
520                     let uploader = currentGroup.acceptsPatchForRepository(repository)
521                         ? this._ensurePatchUploader(configurationName, repository) : null;
522
523                     return element('tr',[
524                         element('th', repository.name()),
525                         element('td', [
526                             this._buildRevisionInput(configurationName, repository, platform),
527                             uploader || [],
528                         ])
529                     ]);
530                 })
531             )];
532     }
533
534     _buildTestabilityList(commitSet, configurationName, invalidRevisionForRepository)
535     {
536         const element = ComponentBase.createElement;
537         const entries = [];
538
539         if (!commitSet || !commitSet.repositories().length)
540             return [];
541
542         for (const repository of commitSet.repositories()) {
543             const commit = this._fetchedCommits[configurationName].get(repository);
544             if (commit && commit.testability() && !invalidRevisionForRepository.has(repository))
545                 entries.push(element('li', `${commit.repository().name()} - "${commit.label()}": ${commit.testability()}`));
546             if (invalidRevisionForRepository.has(repository))
547                 entries.push(element('li', `${repository.name()} - ${invalidRevisionForRepository.get(repository)}`));
548         }
549
550         return entries;
551     }
552
553     _buildRepositoryGroupList(repositoryGroups, currentGroup, configurationName)
554     {
555         const element = ComponentBase.createElement;
556         return repositoryGroups.map((group) => {
557             const input = element('input', {
558                 type: 'radio',
559                 name: 'repositoryGroup-for-' + configurationName.toLowerCase(),
560                 checked: currentGroup == group,
561                 onchange: () => this._selectRepositoryGroup(configurationName, group)
562             });
563             return [element('label', [input, group.description()])];
564         });
565     }
566
567     _selectRepositoryGroup(configurationName, group)
568     {
569         const source = this._repositoryGroupByConfiguration;
570         const clone = {};
571         for (let key in source)
572             clone[key] = source[key];
573         clone[configurationName] = group;
574         this._repositoryGroupByConfiguration = clone;
575         this._updateCommitSetMap();
576         this._fetchCommitsForConfiguration(configurationName);
577         this.enqueueToRender();
578     }
579
580     _buildRevisionInput(configurationName, repository, platform)
581     {
582         const revision = this._specifiedRevisions[configurationName].get(repository) || '';
583         const element = ComponentBase.createElement;
584         let scheduledUpdate = null;
585         const input = element('input', {value: revision, oninput: () => {
586             unmodifiedInput = null;
587             const revisionToFetch = input.value;
588             this._specifiedRevisions[configurationName].set(repository, revisionToFetch);
589             this._updateCommitSetMap();
590             if (scheduledUpdate)
591                 clearTimeout(scheduledUpdate);
592             scheduledUpdate = setTimeout(() => {
593                 if (revisionToFetch == input.value)
594                     this._fetchCommitsForConfiguration(configurationName);
595                 scheduledUpdate = null;
596             }, CustomAnalysisTaskConfigurator.commitFetchInterval);
597         }});
598         let unmodifiedInput = input;
599
600         if (!revision) {
601             CommitLog.fetchLatestCommitForPlatform(repository, platform).then((commit) => {
602                 if (commit && unmodifiedInput) {
603                     unmodifiedInput.value = commit.revision();
604                     this._fetchedCommits[configurationName].set(repository, commit);
605                     this._updateCommitSetMap();
606                 }
607             });
608         }
609
610         return input;
611     }
612
613     static htmlTemplate()
614     {
615         return `
616             <section id="test-pane" class="pane">
617                 <h2>1. Select a Test</h2>
618                 <ul id="test-list" class="config-list"></ul>
619             </section>
620             <section id="platform-pane" class="pane">
621                 <h2>2. Select a Platform</h2>
622                 <ul id="platform-list" class="config-list"></ul>
623             </section>
624             <section id="repository-configuration-error-pane" class="pane">
625                 <h2>Incompatible tests</h2>
626                 <p id="error"></p>
627             </section>
628             <section id="baseline-configuration-pane" class="pane">
629                 <h2>3. Configure Baseline</h2>
630                 <table id="baseline-revision-table" class="revision-table"></table>
631                 <ul id="baseline-testability"></ul>
632             </section>
633             <section id="specify-comparison-pane" class="pane">
634                 <button id="specify-comparison-button">Configure to Compare</button>
635             </section>
636             <section id="comparison-configuration-pane" class="pane">
637                 <h2>4. Configure Comparison</h2>
638                 <table id="comparison-revision-table" class="revision-table"></table>
639                 <ul id="comparison-testability"></ul>
640             </section>`;
641     }
642
643     static cssTemplate()
644     {
645         return `
646             :host {
647                 display: flex !important;
648                 flex-direction: row !important;
649             }
650             .pane {
651                 margin-right: 1rem;
652                 padding: 0;
653             }
654             .pane h2 {
655                 padding: 0;
656                 margin: 0;
657                 margin-bottom: 0.5rem;
658                 font-size: 1.2rem;
659                 font-weight: inherit;
660                 text-align: center;
661                 white-space: nowrap;
662             }
663
664             .config-list {
665                 height: 20rem;
666                 overflow: scroll;
667                 display: block;
668                 margin: 0;
669                 padding: 0;
670                 list-style: none;
671                 font-size: inherit;
672                 font-weight: inherit;
673                 border: none;
674                 border-top: solid 1px #ddd;
675                 border-bottom: solid 1px #ddd;
676                 white-space: nowrap;
677             }
678
679             #platform-list:empty:before {
680                 content: "No matching platform";
681                 display: block;
682                 margin: 1rem 0.5rem;
683                 text-align: center;
684             }
685
686             .config-list label {
687                 display: block;
688                 padding: 0.1rem 0.2rem;
689             }
690
691             .config-list label:hover,
692             .config-list a:hover {
693                 background: rgba(204, 153, 51, 0.1);
694             }
695
696             .config-list label.selected,
697             .config-list a.selected {
698                 background: #eee;
699             }
700
701             .config-list a {
702                 display: block;
703                 padding: 0.1rem 0.2rem;
704                 text-decoration: none;
705                 color: inherit;
706             }
707
708             #repository-configuration-pane {
709                 position: relative;
710             }
711
712             #repository-configuration-pane > button  {
713                 margin-left: 19.5rem;
714             }
715
716             .revision-table {
717                 border: none;
718                 border-collapse: collapse;
719                 font-size: 1rem;
720             }
721
722             .revision-table thead {
723                 font-size: 1.2rem;
724             }
725
726             .revision-table tbody:empty {
727                 display: none;
728             }
729
730             .revision-table tbody td,
731             .revision-table tbody th {
732                 border-top: solid 1px #ddd;
733                 padding-top: 0.5rem;
734                 padding-bottom: 0.5rem;
735             }
736
737             .revision-table td,
738             .revision-table th {
739                 width: 15rem;
740                 height: 1.8rem;
741                 padding: 0 0.2rem;
742                 border: none;
743                 font-weight: inherit;
744             }
745
746             .revision-table thead th {
747                 text-align: center;
748             }
749
750             .revision-table th close-button {
751                 vertical-align: bottom;
752             }
753
754             .revision-table td:first-child,
755             .revision-table th:first-child {
756                 width: 6rem;
757             }
758
759             .revision-table tr.group-row td {
760                 padding-left: 5rem;
761             }
762
763             label {
764                 white-space: nowrap;
765                 display: block;
766             }
767
768             input:not([type=radio]) {
769                 width: calc(100% - 0.6rem);
770                 padding: 0.1rem 0.2rem;
771                 font-size: 0.9rem;
772                 font-weight: inherit;
773             }
774
775             #specify-comparison-pane button {
776                 margin-top: 1.5rem;
777                 font-size: 1.1rem;
778                 font-weight: inherit;
779             }
780
781             #start-pane button {
782                 margin-top: 1.5rem;
783                 font-size: 1.2rem;
784                 font-weight: inherit;
785             }
786
787             #baseline-testability li,
788             #comparison-testability li {
789                 color: #c33;
790                 width: 20rem;
791             }
792 `;
793     }
794 }
795
796 CustomAnalysisTaskConfigurator.commitFetchInterval = 100;
797
798 ComponentBase.defineElement('custom-analysis-task-configurator', CustomAnalysisTaskConfigurator);