Add the support for scheduling a A/B testing with a patch.
[WebKit-https.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._configurationNames = ['Baseline', 'Comparison'];
11         this._showComparison = false;
12         this._commitSetMap = {};
13         this._specifiedRevisions = {'Baseline': new Map, 'Comparison': new Map};
14         this._patchUploaders = {'Baseline': new Map, 'Comparison': new Map};
15         this._fetchedRevisions = {'Baseline': new Map, 'Comparison': new Map};
16         this._repositoryGroupByConfiguration = {'Baseline': null, 'Comparison': null};
17         this._updateTriggerableLazily = new LazilyEvaluatedFunction(this._updateTriggerable.bind(this));
18
19         this._renderTriggerableTestsLazily = new LazilyEvaluatedFunction(this._renderTriggerableTests.bind(this));
20         this._renderTriggerablePlatformsLazily = new LazilyEvaluatedFunction(this._renderTriggerablePlatforms.bind(this));
21         this._renderRepositoryPanesLazily = new LazilyEvaluatedFunction(this._renderRepositoryPanes.bind(this));
22
23         this._customRootUploaders = {};
24     }
25
26     tests() { return this._selectedTests; }
27     platform() { return this._selectedPlatform; }
28     commitSets()
29     {
30         const map = this._commitSetMap;
31         if (!map['Baseline'] || !map['Comparison'])
32             return null;
33         return [map['Baseline'], map['Comparison']];
34     }
35
36     selectTests(selectedTests)
37     {
38         this._selectedTests = selectedTests;
39
40         this._triggerablePlatforms = Triggerable.triggerablePlatformsForTests(this._selectedTests);
41         if (this._selectedTests.length && !this._triggerablePlatforms.includes(this._selectedPlatform))
42             this._selectedPlatform = null;
43
44         this.enqueueToRender();
45     }
46
47     selectPlatform(selectedPlatform)
48     {
49         this._selectedPlatform = selectedPlatform;
50
51         const [triggerable, error] = this._updateTriggerableLazily.evaluate(this._selectedTests, this._selectedPlatform);
52         this._updateRepositoryGroups(triggerable);
53         this._updateCommitSetMap();
54
55         this.enqueueToRender();
56     }
57
58     setCommitSets(baselineCommitSet, comparisonCommitSet)
59     {
60         const [triggerable, error] = this._updateTriggerableLazily.evaluate(this._selectedTests, this._selectedPlatform);
61
62         if (!triggerable)
63             return;
64
65         const baselineRepositoryGroup = triggerable.repositoryGroups().find((repositoryGroup) => repositoryGroup.accepts(baselineCommitSet));
66         if (baselineRepositoryGroup) {
67             this._repositoryGroupByConfiguration['Baseline'] = baselineRepositoryGroup;
68             this._setUploadedFilesIfEmpty(this._customRootUploaders['Baseline'], baselineCommitSet);
69             this._specifiedRevisions['Baseline'] = this._revisionMapFromCommitSet(baselineCommitSet);
70         }
71
72         const comparisonRepositoryGroup = triggerable.repositoryGroups().find((repositoryGroup) => repositoryGroup.accepts(baselineCommitSet));
73         if (comparisonRepositoryGroup) {
74             this._repositoryGroupByConfiguration['Comparison'] = comparisonRepositoryGroup;
75             this._setUploadedFilesIfEmpty(this._customRootUploaders['Comparison'], comparisonCommitSet);
76             this._specifiedRevisions['Comparison'] = this._revisionMapFromCommitSet(comparisonCommitSet);
77         }
78
79         this._showComparison = true;
80         this._updateCommitSetMap();
81     }
82
83     _setUploadedFilesIfEmpty(uploader, commitSet)
84     {
85         if (uploader.hasFileToUpload() || uploader.uploadedFiles().length)
86             return;
87         for (const uploadedFile of commitSet.customRoots())
88             uploader.addUploadedFile(uploadedFile);
89     }
90
91     _revisionMapFromCommitSet(commitSet)
92     {
93         const revisionMap = new Map;
94         for (const repository of commitSet.repositories())
95             revisionMap.set(repository, commitSet.revisionForRepository(repository));
96         return revisionMap;
97     }
98
99     didConstructShadowTree()
100     {
101         this.content('specify-comparison-button').onclick = this.createEventHandler(() => this._configureComparison());
102
103         const createRootUploader = () => {
104             const uploader = new InstantFileUploader;
105             uploader.allowMultipleFiles();
106             uploader.element().textContent = 'Add a new root';
107             uploader.listenToAction('removedFile', () => this._updateCommitSetMap());
108             return uploader;
109         }
110
111         const baselineRootsUploader = createRootUploader();
112         baselineRootsUploader.listenToAction('uploadedFile', (uploadedFile) => {
113             comparisonRootsUploader.addUploadedFile(uploadedFile);
114             this._updateCommitSetMap();
115         });
116
117         this._customRootUploaders['Baseline'] = baselineRootsUploader;
118
119         const comparisonRootsUploader = createRootUploader();
120         comparisonRootsUploader.listenToAction('uploadedFile', () => this._updateCommitSetMap());
121         this._customRootUploaders['Comparison'] = comparisonRootsUploader;
122     }
123
124     _ensurePatchUploader(configurationName, repository)
125     {
126         const uploaderMap = this._patchUploaders[configurationName];
127         let uploader = uploaderMap.get(repository);
128         if (uploader)
129             return uploader;
130
131         uploader = new InstantFileUploader;
132         uploader.element().textContent = 'Apply a patch';
133         uploader.listenToAction('uploadedFile', () => this._updateCommitSetMap());
134         uploader.listenToAction('removedFile', () => this._updateCommitSetMap());
135         uploaderMap.set(repository, uploader);
136
137         return uploader;
138     }
139
140     _configureComparison()
141     {
142         this._showComparison = true;
143         this._repositoryGroupByConfiguration['Comparison'] = this._repositoryGroupByConfiguration['Baseline'];
144
145         const specifiedBaselineRevisions = this._specifiedRevisions['Baseline'];
146         const specifiedComparisonRevisions = new Map;
147         for (let key of specifiedBaselineRevisions.keys())
148             specifiedComparisonRevisions.set(key, specifiedBaselineRevisions.get(key));
149         this._specifiedRevisions['Comparison'] = specifiedComparisonRevisions;
150
151         this.enqueueToRender();
152     }
153
154     render()
155     {
156         super.render();
157
158         const updateSelectedTestsLazily = this._renderTriggerableTestsLazily.evaluate();
159         updateSelectedTestsLazily.evaluate(...this._selectedTests);
160         const updateSelectedPlatformsLazily = this._renderTriggerablePlatformsLazily.evaluate(this._selectedTests, this._triggerablePlatforms);
161         if (updateSelectedPlatformsLazily)
162             updateSelectedPlatformsLazily.evaluate(this._selectedPlatform);
163
164         const [triggerable, error] = this._updateTriggerableLazily.evaluate(this._selectedTests, this._selectedPlatform);
165
166         this._renderRepositoryPanesLazily.evaluate(triggerable, error, this._selectedPlatform, this._repositoryGroupByConfiguration, this._showComparison);
167     }
168
169     _renderTriggerableTests()
170     {
171         const enabledTriggerables = Triggerable.all().filter((triggerable) => !triggerable.isDisabled());
172
173         let tests = Test.topLevelTests().filter((test) => test.metrics().length && enabledTriggerables.some((triggerable) => triggerable.acceptsTest(test)));
174         return this._renderRadioButtonList(this.content('test-list'), 'test', tests, this.selectTests.bind(this));
175     }
176
177     _renderTriggerablePlatforms(selectedTests, triggerablePlatforms)
178     {
179         if (!selectedTests.length) {
180             this.content('platform-pane').style.display = 'none';
181             return null;
182         }
183         this.content('platform-pane').style.display = null;
184
185         return this._renderRadioButtonList(this.content('platform-list'), 'platform', triggerablePlatforms, (selectedPlatforms) => {
186             this.selectPlatform(selectedPlatforms.length ? selectedPlatforms[0] : null);
187         });
188     }
189
190     _renderRadioButtonList(listContainer, name, objects, callback)
191     {
192         const listItems = [];
193         let selectedListItems = [];
194         const checkSelectedRadioButtons = (newSelectedListItems) => {
195             selectedListItems.forEach((item) => {
196                 item.label.classList.remove('selected');
197                 item.radioButton.checked = false;
198             });
199             selectedListItems = newSelectedListItems;
200             selectedListItems.forEach((item) => {
201                 item.label.classList.add('selected');
202                 item.radioButton.checked = true;
203             });
204         }
205
206         const element = ComponentBase.createElement;
207         this.renderReplace(listContainer, objects.map((object) => {
208             const radioButton = element('input', {type: 'radio', name: name, onchange: () => {
209                 checkSelectedRadioButtons(listItems.filter((item) => item.radioButton.checked));
210                 callback(selectedListItems.map((item) => item.object));
211                 this.enqueueToRender();
212             }});
213             const label = element('label', [radioButton, object.label()]);
214             listItems.push({radioButton, label, object});
215             return element('li', label);
216         }));
217
218         return new LazilyEvaluatedFunction((...selectedObjects) => {
219             const objects = new Set(selectedObjects);
220             checkSelectedRadioButtons(listItems.filter((item) => objects.has(item.object)));
221         });
222     }
223
224     _updateTriggerable(tests, platform)
225     {
226         let triggerable = null;
227         let error = null;
228         if (tests.length && platform) {
229             triggerable = Triggerable.findByTestConfiguration(tests[0], platform);
230             let matchingTests = new Set;
231             let mismatchingTests = new Set;
232             for (let test of tests) {
233                 if (Triggerable.findByTestConfiguration(test, platform) == triggerable)
234                     matchingTests.add(test);
235                 else
236                     mismatchingTests.add(test);
237             }
238             if (matchingTests.size < tests.length) {
239                 const matchingTestNames = [...matchingTests].map((test) => test.fullName()).sort().join('", "');
240                 const mismathingTestNames = [...mismatchingTests].map((test) => test.fullName()).sort().join('", "');
241                 error = `Tests "${matchingTestNames}" and "${mismathingTestNames}" cannot be scheduled
242                     simultenosuly on "${platform.name()}". Please select one of them at a time.`;
243             }
244         }
245
246         return [triggerable, error];
247     }
248
249     _updateRepositoryGroups(triggerable)
250     {
251         const repositoryGroups = triggerable ? TriggerableRepositoryGroup.sortByNamePreferringSmallerRepositories(triggerable.repositoryGroups()) : [];
252         for (let name in this._repositoryGroupByConfiguration) {
253             const currentGroup = this._repositoryGroupByConfiguration[name];
254             let matchingGroup = null;
255             if (currentGroup) {
256                 if (repositoryGroups.includes(currentGroup))
257                     matchingGroup = currentGroup;
258                 else
259                     matchingGroup = repositoryGroups.find((group) => group.name() == currentGroup.name());
260             }
261             if (!matchingGroup && repositoryGroups.length)
262                 matchingGroup = repositoryGroups[0];
263             this._repositoryGroupByConfiguration[name] = matchingGroup;
264         }
265     }
266
267     _updateCommitSetMap()
268     {
269         const newBaseline = this._computeCommitSet('Baseline');
270         let newComparison = this._computeCommitSet('Comparison');
271         if (newBaseline && newComparison && newBaseline.equals(newComparison))
272             newComparison = null;
273
274         const currentBaseline = this._commitSetMap['Baseline'];
275         const currentComparison = this._commitSetMap['Baseline'];
276         if (newBaseline == currentBaseline && newComparison == currentComparison)
277             return; // Both of them are null.
278
279         if (newBaseline && currentBaseline && newBaseline.equals(currentBaseline)
280             && newComparison && currentComparison && newComparison.equals(currentComparison))
281             return;
282
283         this._commitSetMap = {'Baseline': newBaseline, 'Comparison': newComparison};
284
285         this.dispatchAction('commitSetChange');
286         this.enqueueToRender();
287     }
288
289     _computeCommitSet(configurationName)
290     {
291         const repositoryGroup = this._repositoryGroupByConfiguration[configurationName];
292         if (!repositoryGroup)
293             return null;
294
295         const fileUploader = this._customRootUploaders[configurationName];
296         if (!fileUploader || fileUploader.hasFileToUpload())
297             return null;
298
299         const commitSet = new CustomCommitSet;
300         for (let repository of repositoryGroup.repositories()) {
301             let revision = this._specifiedRevisions[configurationName].get(repository);
302             if (!revision)
303                 revision = this._fetchedRevisions[configurationName].get(repository);
304             if (!revision)
305                 return null;
306             let patch = null;
307             if (repositoryGroup.acceptsPatchForRepository(repository)) {
308                 const uploaderMap = this._patchUploaders[configurationName];
309                 const uploader = uploaderMap.get(repository);
310                 if (uploader) {
311                     const files = uploader.uploadedFiles();
312                     console.assert(files.length <= 1);
313                     if (files.length)
314                         patch = files[0];
315                 }
316             }
317             commitSet.setRevisionForRepository(repository, revision, patch);
318         }
319
320         for (let uploadedFile of fileUploader.uploadedFiles())
321             commitSet.addCustomRoot(uploadedFile);
322
323         return commitSet;
324     }
325
326     _renderRepositoryPanes(triggerable, error, platform, repositoryGroupByConfiguration, showComparison)
327     {
328         this.content('repository-configuration-error-pane').style.display = error ? null : 'none';
329         this.content('error').textContent = error;
330
331         this.content('baseline-configuration-pane').style.display = triggerable ? null : 'none';
332         this.content('specify-comparison-pane').style.display = triggerable && !showComparison ? null : 'none';
333         this.content('comparison-configuration-pane').style.display = triggerable && showComparison ? null : 'none';
334
335         if (!triggerable)
336             return;
337
338         const repositoryGroups = TriggerableRepositoryGroup.sortByNamePreferringSmallerRepositories(triggerable.repositoryGroups());
339
340         const repositorySet = new Set;
341         for (let group of repositoryGroups) {
342             for (let repository of group.repositories())
343                 repositorySet.add(repository);
344         }
345
346         const repositories = Repository.sortByNamePreferringOnesWithURL([...repositorySet]);
347         const requiredRepositories = repositories.filter((repository) => {
348             return repositoryGroups.every((group) => group.repositories().includes(repository));
349         });
350         const alwaysAcceptsCustomRoots = repositoryGroups.every((group) => group.acceptsCustomRoots());
351
352         this._renderBaselineRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots);
353
354         if (showComparison)
355             this._renderComparisonRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots);
356     }
357
358     _renderBaselineRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots)
359     {
360         let currentGroup = repositoryGroupByConfiguration['Baseline'];
361         const optionalRepositoryList = this._optionalRepositoryList(currentGroup, requiredRepositories);
362         this.renderReplace(this.content('baseline-revision-table'),
363             this._buildRevisionTable('Baseline', repositoryGroups, currentGroup, platform, requiredRepositories, optionalRepositoryList, alwaysAcceptsCustomRoots));
364     }
365
366     _renderComparisonRevisionTable(platform, repositoryGroups, requiredRepositories, repositoryGroupByConfiguration, alwaysAcceptsCustomRoots)
367     {
368         let currentGroup = repositoryGroupByConfiguration['Comparison'];
369         const optionalRepositoryList = this._optionalRepositoryList(currentGroup, requiredRepositories);
370         this.renderReplace(this.content('comparison-revision-table'),
371             this._buildRevisionTable('Comparison', repositoryGroups, currentGroup, platform, requiredRepositories, optionalRepositoryList, alwaysAcceptsCustomRoots));
372     }
373
374     _optionalRepositoryList(currentGroup, requiredRepositories)
375     {
376         if (!currentGroup)
377             return [];
378         return Repository.sortByNamePreferringOnesWithURL(currentGroup.repositories().filter((repository) => !requiredRepositories.includes(repository)));
379     }
380
381     _buildRevisionTable(configurationName, repositoryGroups, currentGroup, platform, requiredRepositories, optionalRepositoryList, alwaysAcceptsCustomRoots)
382     {
383         const element = ComponentBase.createElement;
384         const link = ComponentBase.createLink;
385
386         const customRootsTBody = element('tbody', [
387             element('tr', [
388                 element('th', 'Roots'),
389                 element('td', this._customRootUploaders[configurationName]),
390             ]),
391         ]);
392
393         return [
394             element('tbody',
395                 requiredRepositories.map((repository) => {
396                     return element('tr', [
397                         element('th', repository.name()),
398                         element('td', this._buildRevisionInput(configurationName, repository, platform))
399                     ]);
400                 })),
401             alwaysAcceptsCustomRoots ? customRootsTBody : [],
402             element('tbody', [
403                 element('tr', {'class': 'group-row'},
404                     element('td', {colspan: 2}, this._buildRepositoryGroupList(repositoryGroups, currentGroup, configurationName))),
405             ]),
406             !alwaysAcceptsCustomRoots && currentGroup && currentGroup.acceptsCustomRoots() ? customRootsTBody : [],
407             element('tbody',
408                 optionalRepositoryList.map((repository) => {
409                     let uploader = currentGroup.acceptsPatchForRepository(repository)
410                         ? this._ensurePatchUploader(configurationName, repository) : null;
411
412                     return element('tr',[
413                         element('th', repository.name()),
414                         element('td', [
415                             this._buildRevisionInput(configurationName, repository, platform),
416                             uploader || [],
417                         ])
418                     ]);
419                 })
420             )];
421     }
422
423     _buildRepositoryGroupList(repositoryGroups, currentGroup, configurationName)
424     {
425         const element = ComponentBase.createElement;
426         return repositoryGroups.map((group) => {
427             const input = element('input', {
428                 type: 'radio',
429                 name: 'repositoryGroup-for-' + configurationName.toLowerCase(),
430                 checked: currentGroup == group,
431                 onchange: () => this._selectRepositoryGroup(configurationName, group)
432             });
433             return [element('label', [input, group.description()])];
434         });
435     }
436
437     _selectRepositoryGroup(configurationName, group)
438     {
439         const source = this._repositoryGroupByConfiguration;
440         const clone = {};
441         for (let key in source)
442             clone[key] = source[key];
443         clone[configurationName] = group;
444         this._repositoryGroupByConfiguration = clone;
445         this._updateCommitSetMap();
446         this.enqueueToRender();
447     }
448
449     _buildRevisionInput(configurationName, repository, platform)
450     {
451         const revision = this._specifiedRevisions[configurationName].get(repository) || '';
452         const element = ComponentBase.createElement;
453         const input = element('input', {value: revision, oninput: () => {
454             unmodifiedInput = null;
455             this._specifiedRevisions[configurationName].set(repository, input.value);
456             this._updateCommitSetMap();
457         }});
458         let unmodifiedInput = input;
459
460         if (!revision) {
461             CommitLog.fetchLatestCommitForPlatform(repository, platform).then((commit) => {
462                 if (commit && unmodifiedInput) {
463                     unmodifiedInput.value = commit.revision();
464                     this._fetchedRevisions[configurationName].set(repository, commit.revision());
465                     this._updateCommitSetMap();
466                 }
467             });
468         }
469
470         return input;
471     }
472
473     static htmlTemplate()
474     {
475         return `
476             <section id="test-pane" class="pane">
477                 <h2>1. Select a Test</h2>
478                 <ul id="test-list" class="config-list"></ul>
479             </section>
480             <section id="platform-pane" class="pane">
481                 <h2>2. Select a Platform</h2>
482                 <ul id="platform-list" class="config-list"></ul>
483             </section>
484             <section id="repository-configuration-error-pane" class="pane">
485                 <h2>Incompatible tests</h2>
486                 <p id="error"></p>
487             </section>
488             <section id="baseline-configuration-pane" class="pane">
489                 <h2>3. Configure Baseline</h2>
490                 <table id="baseline-revision-table" class="revision-table"></table>
491             </section>
492             <section id="specify-comparison-pane" class="pane">
493                 <button id="specify-comparison-button">Configure to Compare</button>
494             </section>
495             <section id="comparison-configuration-pane" class="pane">
496                 <h2>4. Configure Comparison</h2>
497                 <table id="comparison-revision-table" class="revision-table"></table>
498             </section>`;
499     }
500
501     static cssTemplate()
502     {
503         return `
504             :host {
505                 display: flex !important;
506                 flex-direction: row !important;
507             }
508             .pane {
509                 margin-right: 1rem;
510                 padding: 0;
511             }
512             .pane h2 {
513                 padding: 0;
514                 margin: 0;
515                 margin-bottom: 0.5rem;
516                 font-size: 1.2rem;
517                 font-weight: inherit;
518                 text-align: center;
519                 white-space: nowrap;
520             }
521
522             .config-list {
523                 height: 20rem;
524                 overflow: scroll;
525                 display: block;
526                 margin: 0;
527                 padding: 0;
528                 list-style: none;
529                 font-size: inherit;
530                 font-weight: inherit;
531                 border: none;
532                 border-top: solid 1px #ddd;
533                 border-bottom: solid 1px #ddd;
534                 white-space: nowrap;
535             }
536
537             #platform-list:empty:before {
538                 content: "No matching platform";
539                 display: block;
540                 margin: 1rem 0.5rem;
541                 text-align: center;
542             }
543
544             .config-list label {
545                 display: block;
546                 padding: 0.1rem 0.2rem;
547             }
548
549             .config-list label:hover,
550             .config-list a:hover {
551                 background: rgba(204, 153, 51, 0.1);
552             }
553
554             .config-list label.selected,
555             .config-list a.selected {
556                 background: #eee;
557             }
558
559             .config-list a {
560                 display: block;
561                 padding: 0.1rem 0.2rem;
562                 text-decoration: none;
563                 color: inherit;
564             }
565
566             #repository-configuration-pane {
567                 position: relative;
568             }
569
570             #repository-configuration-pane > button  {
571                 margin-left: 19.5rem;
572             }
573
574             .revision-table {
575                 border: none;
576                 border-collapse: collapse;
577                 font-size: 1rem;
578             }
579
580             .revision-table thead {
581                 font-size: 1.2rem;
582             }
583
584             .revision-table tbody:empty {
585                 display: none;
586             }
587
588             .revision-table tbody td,
589             .revision-table tbody th {
590                 border-top: solid 1px #ddd;
591                 padding-top: 0.5rem;
592                 padding-bottom: 0.5rem;
593             }
594
595             .revision-table td,
596             .revision-table th {
597                 width: 15rem;
598                 height: 1.8rem;
599                 padding: 0 0.2rem;
600                 border: none;
601                 font-weight: inherit;
602             }
603
604             .revision-table thead th {
605                 text-align: center;
606             }
607
608             .revision-table th close-button {
609                 vertical-align: bottom;
610             }
611
612             .revision-table td:first-child,
613             .revision-table th:first-child {
614                 width: 6rem;
615             }
616
617             .revision-table tr.group-row td {
618                 padding-left: 5rem;
619             }
620
621             label {
622                 white-space: nowrap;
623                 display: block;
624             }
625
626             input:not([type=radio]) {
627                 width: calc(100% - 0.6rem);
628                 padding: 0.1rem 0.2rem;
629                 font-size: 0.9rem;
630                 font-weight: inherit;
631             }
632
633             #specify-comparison-pane button {
634                 margin-top: 1.5rem;
635                 font-size: 1.1rem;
636                 font-weight: inherit;
637             }
638
639             #start-pane button {
640                 margin-top: 1.5rem;
641                 font-size: 1.2rem;
642                 font-weight: inherit;
643             }
644 `;
645     }
646 }
647
648 ComponentBase.defineElement('custom-analysis-task-configurator', CustomAnalysisTaskConfigurator);