diff --git a/.eslintignore b/.eslintignore index f9bddbbe5d7b..35dfd77cb57a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -358,6 +358,7 @@ toolkit/components/reader/Readerable.jsm toolkit/content/widgets/wizard.xml toolkit/modules/AppConstants.jsm toolkit/mozapps/update/tests/data/xpcshellConstantsPP.js +tools/tryselect/selectors/chooser/templates/chooser.html # Third party toolkit/modules/third_party/** diff --git a/tools/tryselect/docs/selectors/chooser.rst b/tools/tryselect/docs/selectors/chooser.rst new file mode 100644 index 000000000000..7268a571960c --- /dev/null +++ b/tools/tryselect/docs/selectors/chooser.rst @@ -0,0 +1,33 @@ +Chooser Selector +================ + +When pushing to try, there are a very large amount of builds and tests to choose from. Often too +many to remember, making it easy to forget a set of tasks which should otherwise have been run. + +This selector allows you to select tasks from a web interface that lists all the possible build and +test tasks and allows you to select them from a list. It is similar in concept to the old `try +syntax chooser`_ page, except that the values are dynamically generated using the `taskgraph`_ as an +input. This ensures that it will never be out of date. + +To use: + +.. code-block:: shell + + $ mach try chooser + +This will spin up a local web server (using Flask) which serves the chooser app. After making your +selection, simply press ``Push`` and the rest will be handled from there. No need to copy/paste any +syntax strings or the like. + +You can run: + +.. code-block:: shell + + $ mach try chooser --full + +To generate the interface using the full taskgraph instead. This will include tasks that don't run +on mozilla-central. + + +.. _try syntax chooser: https://mozilla-releng.net/trychooser +.. _taskgraph: https://firefox-source-docs.mozilla.org/taskcluster/taskcluster/index.html diff --git a/tools/tryselect/docs/selectors/index.rst b/tools/tryselect/docs/selectors/index.rst index cc09e90c6b28..9cc520c32e8d 100644 --- a/tools/tryselect/docs/selectors/index.rst +++ b/tools/tryselect/docs/selectors/index.rst @@ -3,6 +3,7 @@ Selectors These are the currently implemented try selectors: +* :doc:`chooser `: Select tasks using a web interface. * :doc:`fuzzy `: Select tasks using a fuzzy finding algorithm and a terminal interface. * :doc:`again `: Re-run a previous ``try_task_config.json`` based @@ -30,6 +31,7 @@ See selector specific options by running: :maxdepth: 1 :hidden: + Chooser Fuzzy Again Empty diff --git a/tools/tryselect/mach_commands.py b/tools/tryselect/mach_commands.py index f68036c42176..2495db10df00 100644 --- a/tools/tryselect/mach_commands.py +++ b/tools/tryselect/mach_commands.py @@ -149,6 +149,26 @@ class TrySelect(MachCommandBase): from tryselect.selectors.fuzzy import run_fuzzy_try return run_fuzzy_try(**kwargs) + @SubCommand('try', + 'chooser', + description='Schedule tasks by selecting them from a web ' + 'interface.', + parser=get_parser('chooser')) + def try_chooser(self, **kwargs): + """Push tasks selected from a web interface to try. + + This selector will build the taskgraph and spin up a dynamically + created 'trychooser-like' web-page on the localhost. After a selection + has been made, pressing the 'Push' button will automatically push the + selection to try. + """ + self._activate_virtualenv() + self.virtualenv_manager.install_pip_package('flask') + self.virtualenv_manager.install_pip_package('flask-wtf') + + from tryselect.selectors.chooser import run_try_chooser + return run_try_chooser(**kwargs) + @SubCommand('try', 'again', description='Schedule a previously generated (non try syntax) ' diff --git a/tools/tryselect/selectors/chooser/.eslintrc.js b/tools/tryselect/selectors/chooser/.eslintrc.js new file mode 100644 index 000000000000..6992960fd569 --- /dev/null +++ b/tools/tryselect/selectors/chooser/.eslintrc.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = { + env: { + "jquery": true + }, + globals: { + "apply": true, + "applyChunks": true, + "tasks": true + } +}; diff --git a/tools/tryselect/selectors/chooser/__init__.py b/tools/tryselect/selectors/chooser/__init__.py new file mode 100644 index 000000000000..8d4516d708d2 --- /dev/null +++ b/tools/tryselect/selectors/chooser/__init__.py @@ -0,0 +1,50 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import, print_function, unicode_literals + +import os +import webbrowser +from threading import Timer + +from tryselect.cli import BaseTryParser +from tryselect.tasks import generate_tasks +from tryselect.push import check_working_directory, push_to_try, vcs + +here = os.path.abspath(os.path.dirname(__file__)) + + +class ChooserParser(BaseTryParser): + name = 'chooser' + arguments = [] + common_groups = ['push', 'task'] + templates = ['artifact', 'env', 'rebuild', 'chemspill-prio', 'gecko-profile'] + + +def run_try_chooser(update=False, query=None, templates=None, full=False, parameters=None, + save=False, preset=None, mod_presets=False, push=True, message='{msg}', + **kwargs): + from .app import create_application + check_working_directory(push) + + tg = generate_tasks(parameters, full, root=vcs.path) + app = create_application(tg) + + if os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + # we are in the reloader process, don't open the browser or do any try stuff + app.run() + return + + # give app a second to start before opening the browser + Timer(1, lambda: webbrowser.open('http://127.0.0.1:5000')).start() + app.run() + + selected = app.tasks + if not selected: + print("no tasks selected") + return + + msg = "Try Chooser Enhanced ({} tasks selected)".format(len(selected)) + return push_to_try('chooser', message.format(msg=msg), selected, templates, push=push, + closed_tree=kwargs["closed_tree"]) diff --git a/tools/tryselect/selectors/chooser/app.py b/tools/tryselect/selectors/chooser/app.py new file mode 100644 index 000000000000..2d03d4195217 --- /dev/null +++ b/tools/tryselect/selectors/chooser/app.py @@ -0,0 +1,190 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from __future__ import absolute_import, print_function + +from abc import ABCMeta, abstractproperty +from collections import defaultdict + +from flask import ( + Flask, + render_template, + request, +) + +SECTIONS = [] +SUPPORTED_KINDS = set() + + +def register_section(cls): + assert issubclass(cls, Section) + instance = cls() + SECTIONS.append(instance) + SUPPORTED_KINDS.update(instance.kind.split(',')) + + +class Section(object): + __metaclass__ = ABCMeta + + @abstractproperty + def name(self): + pass + + @abstractproperty + def kind(self): + pass + + @abstractproperty + def title(self): + pass + + @abstractproperty + def attrs(self): + pass + + def contains(self, task): + return task.kind in self.kind.split(',') + + def get_context(self, tasks): + labels = defaultdict(lambda: {'max_chunk': 0, 'attrs': defaultdict(list)}) + + for task in tasks.values(): + if not self.contains(task): + continue + + task = task.attributes + label = labels[self.labelfn(task)] + for attr in self.attrs: + if attr in task and task[attr] not in label['attrs'][attr]: + label['attrs'][attr].append(task[attr]) + + if 'test_chunk' in task: + label['max_chunk'] = max(label['max_chunk'], int(task['test_chunk'])) + + return { + 'name': self.name, + 'kind': self.kind, + 'title': self.title, + 'labels': labels, + } + + +@register_section +class Platform(Section): + name = 'platform' + kind = 'build' + title = 'Platforms' + attrs = ['build_platform'] + + def labelfn(self, task): + return task['build_platform'] + + def contains(self, task): + if not Section.contains(self, task): + return False + + # android-stuff tasks aren't actual platforms + return task.task['tags'].get('android-stuff', False) != "true" + + +@register_section +class Test(Section): + name = 'test' + kind = 'test' + title = 'Test Suites' + attrs = ['unittest_suite', 'unittest_flavor'] + + def labelfn(self, task): + suite = task['unittest_suite'].replace(' ', '-') + flavor = task['unittest_flavor'].replace(' ', '-') + + if flavor.endswith('chunked'): + flavor = flavor[:-len('chunked')] + + if flavor.startswith(suite): + flavor = flavor[len(suite):] + flavor = flavor.strip('-') + + if flavor in ('crashtest', 'jsreftest'): + return flavor + + if flavor: + return '{}-{}'.format(suite, flavor) + return suite + + def contains(self, task): + if not Section.contains(self, task): + return False + return task.attributes['unittest_suite'] not in ('raptor', 'talos') + + +@register_section +class Perf(Section): + name = 'perf' + kind = 'test' + title = 'Performance' + attrs = ['unittest_suite', 'unittest_flavor', 'raptor_try_name', 'talos_try_name'] + + def labelfn(self, task): + suite = task['unittest_suite'] + label = task['{}_try_name'.format(suite)] + + if not label.startswith(suite): + label = '{}-{}'.format(suite, label) + + if label.endswith('-e10s'): + label = label[:-len('-e10s')] + + return label + + def contains(self, task): + if not Section.contains(self, task): + return False + return task.attributes['unittest_suite'] in ('raptor', 'talos') + + +@register_section +class Analysis(Section): + name = 'analysis' + kind = 'build,static-analysis-autotest' + title = 'Analysis' + attrs = ['build_platform'] + + def labelfn(self, task): + return task['build_platform'] + + def contains(self, task): + if not Section.contains(self, task): + return False + if task.kind == 'build': + return task.task['tags'].get('android-stuff', False) == "true" + return True + + +def create_application(tg): + tasks = {l: t for l, t in tg.tasks.items() if t.kind in SUPPORTED_KINDS} + sections = [s.get_context(tasks) for s in SECTIONS] + context = { + 'tasks': {l: t.attributes for l, t in tasks.items()}, + 'sections': sections, + } + + app = Flask(__name__) + app.env = 'development' + app.tasks = [] + + @app.route('/', methods=['GET', 'POST']) + def chooser(): + if request.method == 'GET': + return render_template('chooser.html', **context) + + if request.form['action'] == 'Push': + labels = request.form['selected-tasks'].splitlines() + app.tasks.extend(labels) + + shutdown = request.environ.get('werkzeug.server.shutdown') + shutdown() + return render_template('close.html') + + return app diff --git a/tools/tryselect/selectors/chooser/static/filter.js b/tools/tryselect/selectors/chooser/static/filter.js new file mode 100644 index 000000000000..3c867a34ddbc --- /dev/null +++ b/tools/tryselect/selectors/chooser/static/filter.js @@ -0,0 +1,105 @@ +const selection = $("#selection")[0]; +const count = $("#selection-count")[0]; +const pluralize = (count, noun, suffix = "s") => + `${count} ${noun}${count !== 1 ? suffix : ""}`; + +var selected = []; + +var updateLabels = () => { + $(".tab-pane.active > .filter-label").each(function(index) { + let box = $("#" + this.htmlFor)[0]; + let method = box.checked ? "add" : "remove"; + $(this)[method + "Class"]("is-checked"); + }); +}; + +var apply = () => { + let filters = {}; + let kinds = []; + + $(".filter:checked").each(function(index) { + for (let kind of this.name.split(",")) { + if (!(kinds.includes(kind))) + kinds.push(kind); + } + + // Checkbox element values are generated by Section.get_context() in app.py + let attrs = JSON.parse(this.value); + for (let attr in attrs) { + if (!(attr in filters)) + filters[attr] = []; + + let values = attrs[attr]; + filters[attr] = filters[attr].concat(values); + } + }); + updateLabels(); + + if (Object.keys(filters).length == 0 || (Object.keys(filters).length == 1 && "build_type" in filters)) { + selection.value = ""; + count.innerHTML = "0 tasks selected"; + return; + } + + var taskMatches = (label) => { + let task = tasks[label]; + + // If no box for the given kind has been checked, this task is + // automatically not selected. + if (!(kinds.includes(task.kind))) + return false; + + for (let attr in filters) { + let values = filters[attr]; + if (!(attr in task) || values.includes(task[attr])) + continue; + return false; + } + return true; + }; + + selected = Object.keys(tasks).filter(taskMatches); + applyChunks(); +}; + +var applyChunks = () => { + // For tasks that have a chunk filter applied, we handle that here. + let filters = {}; + $(".filter:text").each(function(index) { + let value = $(this).val(); + if (value === "") { + return; + } + + let attrs = JSON.parse(this.name); + let key = `${attrs.unittest_suite}-${attrs.unittest_flavor}`; + if (!(key in filters)) { + filters[key] = []; + } + + // Parse the chunk strings. These are formatted like printer page setups, e.g: "1,4-6,9" + for (let item of value.split(",")) { + if (!item.includes("-")) { + filters[key].push(parseInt(item)); + continue; + } + + let [start, end] = item.split("-"); + for (let i = parseInt(start); i <= parseInt(end); ++i) { + filters[key].push(i); + } + } + }); + + let chunked = selected.filter(function(label) { + let task = tasks[label]; + let key = task.unittest_suite + "-" + task.unittest_flavor; + if (key in filters && !filters[key].includes(parseInt(task.test_chunk))) { + return false; + } + return true; + }); + + selection.value = chunked.join("\n"); + count.innerText = pluralize(chunked.length, "task") + " selected"; +}; diff --git a/tools/tryselect/selectors/chooser/static/select.js b/tools/tryselect/selectors/chooser/static/select.js new file mode 100644 index 000000000000..8277b3005a8b --- /dev/null +++ b/tools/tryselect/selectors/chooser/static/select.js @@ -0,0 +1,38 @@ +const labels = $("label.multiselect"); +const boxes = $("label.multiselect input:checkbox"); +var lastChecked = {}; + +// implements shift+click +labels.click(function(e) { + if (e.target.tagName === "INPUT") + return; + + let box = $("#" + this.htmlFor)[0]; + let activeSection = $("div.tab-pane.active")[0].id; + + if (activeSection in lastChecked) { + // Bug 559506 - In Firefox shift/ctrl/alt+clicking a label doesn't check the box. + let isFirefox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; + + if (e.shiftKey) { + if (isFirefox) + box.checked = !box.checked; + + let start = boxes.index(box); + let end = boxes.index(lastChecked[activeSection]); + + boxes.slice(Math.min(start, end), Math.max(start, end) + 1).prop("checked", box.checked); + apply(); + } + } + + lastChecked[activeSection] = box; +}); + +function selectAll(btn) { + let checked = !!btn.value; + $("div.active label.filter-label").each(function(index) { + $(this).find("input:checkbox")[0].checked = checked; + }); + apply(); +} diff --git a/tools/tryselect/selectors/chooser/static/style.css b/tools/tryselect/selectors/chooser/static/style.css new file mode 100644 index 000000000000..f7eda3387aa9 --- /dev/null +++ b/tools/tryselect/selectors/chooser/static/style.css @@ -0,0 +1,104 @@ +body { + padding-top: 70px; +} + +/* Tabs */ + +#tabbar .nav-link { + color: #009570; + font-size: 18px; + padding-bottom: 15px; + padding-top: 15px; +} + +#tabbar .nav-link.active { + color: #212529; +} + +#tabbar .nav-link:hover { + color: #0f5a3a; +} + +/* Sections */ + +.tab-content button { + font-size: 14px; + margin-bottom: 5px; + margin-top: 10px; +} + +.filter-label { + display: block; + font-size: 16px; + position: relative; + padding-left: 15px; + padding-right: 15px; + padding-top: 10px; + padding-bottom: 10px; + margin-bottom: 0; + -moz-user-select: none; + user-select: none; + vertical-align: middle; +} + +.filter-label span { + display: flex; + min-height: 34px; + align-items: center; + justify-content: space-between; +} + +.filter-label input[type="checkbox"] { + position: absolute; + opacity: 0; + height: 0; + width: 0; +} + +.filter-label input[type="text"] { + width: 50px; +} + +.filter-label:hover { + background-color: #91a0b0; +} + +.filter-label.is-checked:hover { + background-color: #91a0b0; +} + +.filter-label.is-checked { + background-color: #404c59; + color: white; +} + +/* Preview pane */ + +#preview { + position: fixed; + height: 100vh; + margin-left: 66%; + width: 100%; +} + +#submit-tasks { + display: flex; + flex-direction: column; + height: 80%; +} + +#buttons { + display: flex; + justify-content: space-between; +} + +#push { + background-color: #00e9b7; + margin-left: 5px; + width: 100%; +} + +#selection { + height: 100%; + width: 100%; +} diff --git a/tools/tryselect/selectors/chooser/templates/chooser.html b/tools/tryselect/selectors/chooser/templates/chooser.html new file mode 100644 index 000000000000..9f989ad8dabb --- /dev/null +++ b/tools/tryselect/selectors/chooser/templates/chooser.html @@ -0,0 +1,74 @@ +{% extends 'layout.html' %} +{% block content %} +
+
+
+
+ Build Type +
+ + +
+ {% for type in ["opt", "debug"] %} +
+ + +
+ {% endfor %} +
+ +
+ + + {% for section in sections %} + {% if loop.first %} +
+ {% else %} +
+ {% endif %} + {% for label, meta in section.labels|dictsort %} + + {% endfor %} +
+ {% endfor %} +
+
+
+
+ + 0 tasks selected
+ + + + +
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/tools/tryselect/selectors/chooser/templates/close.html b/tools/tryselect/selectors/chooser/templates/close.html new file mode 100644 index 000000000000..18ad5c6560de --- /dev/null +++ b/tools/tryselect/selectors/chooser/templates/close.html @@ -0,0 +1,8 @@ +{% extends 'layout.html' %} +{% block content %} +
+ +
+{% endblock %} diff --git a/tools/tryselect/selectors/chooser/templates/layout.html b/tools/tryselect/selectors/chooser/templates/layout.html new file mode 100644 index 000000000000..f987422a1410 --- /dev/null +++ b/tools/tryselect/selectors/chooser/templates/layout.html @@ -0,0 +1,33 @@ + + + + Try Chooser Enhanced + + + + + + {% block content %}{% endblock %} + + + + {% block scripts %}{% endblock %} + +