diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /taskcluster/taskgraph/test | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'taskcluster/taskgraph/test')
37 files changed, 5835 insertions, 0 deletions
diff --git a/taskcluster/taskgraph/test/__init__.py b/taskcluster/taskgraph/test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/taskcluster/taskgraph/test/__init__.py diff --git a/taskcluster/taskgraph/test/automationrelevance.json b/taskcluster/taskgraph/test/automationrelevance.json new file mode 100644 index 0000000000..721bd1264b --- /dev/null +++ b/taskcluster/taskgraph/test/automationrelevance.json @@ -0,0 +1,425 @@ +{ + "changesets": [ + { + "author": "James Long <longster@gmail.com>", + "backsoutnodes": [], + "bugs": [ + { + "no": "1300866", + "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=1300866" + } + ], + "date": [ + 1473196655.0, + 14400 + ], + "desc": "Bug 1300866 - expose devtools require to new debugger r=jlast,bgrins", + "extra": { + "branch": "default" + }, + "files": [ + "devtools/client/debugger/index.html" + ], + "node": "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "parents": [ + "37c9349b4e8167a61b08b7e119c21ea177b98942" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312890, + "reviewers": [ + { + "name": "jlast", + "revset": "reviewer(jlast)" + }, + { + "name": "bgrins", + "revset": "reviewer(bgrins)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Wes Kocher <wkocher@mozilla.com>", + "backsoutnodes": [], + "bugs": [], + "date": [ + 1473208638.0, + 25200 + ], + "desc": "Merge m-c to fx-team, a=merge", + "extra": { + "branch": "default" + }, + "files": [ + "taskcluster/scripts/builder/build-l10n.sh" + ], + "node": "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "parents": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "91c2b9d5c1354ca79e5b174591dbb03b32b15bbf" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312891, + "reviewers": [ + { + "name": "merge", + "revset": "reviewer(merge)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Towkir Ahmed <towkir17@gmail.com>", + "backsoutnodes": [], + "bugs": [ + { + "no": "1296648", + "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=1296648" + } + ], + "date": [ + 1472957580.0, + 14400 + ], + "desc": "Bug 1296648 - Fix direction of .ruleview-expander.theme-twisty in RTL locales. r=ntim", + "extra": { + "branch": "default" + }, + "files": [ + "devtools/client/themes/rules.css" + ], + "node": "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "parents": [ + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312892, + "reviewers": [ + { + "name": "ntim", + "revset": "reviewer(ntim)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Oriol <oriol-bugzilla@hotmail.com>", + "backsoutnodes": [], + "bugs": [ + { + "no": "1300336", + "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=1300336" + } + ], + "date": [ + 1472921160.0, + 14400 + ], + "desc": "Bug 1300336 - Allow pseudo-arrays to have a length property. r=fitzgen", + "extra": { + "branch": "default" + }, + "files": [ + "devtools/client/webconsole/test/browser_webconsole_output_06.js", + "devtools/server/actors/object.js" + ], + "node": "99c542fa43a72ee863c813b5624048d1b443549b", + "parents": [ + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312893, + "reviewers": [ + { + "name": "fitzgen", + "revset": "reviewer(fitzgen)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Ruturaj Vartak <ruturaj@gmail.com>", + "backsoutnodes": [], + "bugs": [ + { + "no": "1295010", + "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=1295010" + } + ], + "date": [ + 1472854020.0, + -7200 + ], + "desc": "Bug 1295010 - Don't move the eyedropper to the out of browser window by keyboard navigation. r=pbro\n\nMozReview-Commit-ID: vBwmSxVNXK", + "extra": { + "amend_source": "6885024ef00cfa33d73c59dc03c48ebcda9ccbdd", + "branch": "default", + "histedit_source": "c43167f0a7cbe9f4c733b15da726e5150a9529ba", + "rebase_source": "b74df421630fc46dab6b6cc026bf3e0ae6b4a651" + }, + "files": [ + "devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js", + "devtools/client/inspector/test/head.js", + "devtools/server/actors/highlighters/eye-dropper.js" + ], + "node": "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "parents": [ + "99c542fa43a72ee863c813b5624048d1b443549b" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312894, + "reviewers": [ + { + "name": "pbro", + "revset": "reviewer(pbro)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Matteo Ferretti <mferretti@mozilla.com>", + "backsoutnodes": [], + "bugs": [ + { + "no": "1299154", + "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=1299154" + } + ], + "date": [ + 1472629906.0, + -7200 + ], + "desc": "Bug 1299154 - added Set/GetOverrideDPPX to restorefromHistory; r=mstange\n\nMozReview-Commit-ID: AsyAcG3Igbn\n", + "extra": { + "branch": "default", + "committer": "Matteo Ferretti <mferretti@mozilla.com> 1473236511 -7200" + }, + "files": [ + "docshell/base/nsDocShell.cpp", + "dom/tests/mochitest/general/test_contentViewer_overrideDPPX.html" + ], + "node": "541c9086c0f27fba60beecc9bc94543103895c86", + "parents": [ + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312895, + "reviewers": [ + { + "name": "mstange", + "revset": "reviewer(mstange)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Patrick Brosset <pbrosset@mozilla.com>", + "backsoutnodes": [], + "bugs": [ + { + "no": "1295010", + "url": "https://bugzilla.mozilla.org/show_bug.cgi?id=1295010" + } + ], + "date": [ + 1473239449.0, + -7200 + ], + "desc": "Bug 1295010 - Removed testActor from highlighterHelper in inspector tests; r=me\n\nMozReview-Commit-ID: GMksl81iGcp", + "extra": { + "branch": "default" + }, + "files": [ + "devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js", + "devtools/client/inspector/test/head.js" + ], + "node": "041a925171e431bf51fb50193ab19d156088c89a", + "parents": [ + "541c9086c0f27fba60beecc9bc94543103895c86" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312896, + "reviewers": [ + { + "name": "me", + "revset": "reviewer(me)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + }, + { + "author": "Carsten \"Tomcat\" Book <cbook@mozilla.com>", + "backsoutnodes": [], + "bugs": [], + "date": [ + 1473261233.0, + -7200 + ], + "desc": "merge fx-team to mozilla-central a=merge", + "extra": { + "branch": "default" + }, + "files": [], + "node": "a14f88a9af7a59e677478694bafd9375ac53683e", + "parents": [ + "3d0b41fdd93bd8233745eadb4e0358e385bf2cb9", + "041a925171e431bf51fb50193ab19d156088c89a" + ], + "perfherderurl": "https://treeherder.mozilla.org/perf.html#/compare?originalProject=mozilla-central&originalRevision=a14f88a9af7a59e677478694bafd9375ac53683e&newProject=mozilla-central&newRevision=ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "pushdate": [ + 1473261248, + 0 + ], + "pushhead": "a14f88a9af7a59e677478694bafd9375ac53683e", + "pushid": 30664, + "pushnodes": [ + "ae2144aa4356b65c2f8c0de8c9082dcb7e330e24", + "73a6a267a50a0e1c41e689b265ad3eebe43d7ac6", + "16a1a91f9269ab95dd83eb29dc5d0227665f7d94", + "99c542fa43a72ee863c813b5624048d1b443549b", + "a6b6a93eb41a05e310a11f0172f01ba9b21d3eac", + "541c9086c0f27fba60beecc9bc94543103895c86", + "041a925171e431bf51fb50193ab19d156088c89a", + "a14f88a9af7a59e677478694bafd9375ac53683e" + ], + "pushuser": "cbook@mozilla.com", + "rev": 312897, + "reviewers": [ + { + "name": "merge", + "revset": "reviewer(merge)" + } + ], + "treeherderrepo": "mozilla-central", + "treeherderrepourl": "https://treeherder.mozilla.org/#/jobs?repo=mozilla-central" + } + ], + "visible": true +} + diff --git a/taskcluster/taskgraph/test/conftest.py b/taskcluster/taskgraph/test/conftest.py new file mode 100644 index 0000000000..ec8b241439 --- /dev/null +++ b/taskcluster/taskgraph/test/conftest.py @@ -0,0 +1,52 @@ +# Any copyright is dedicated to the public domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +from __future__ import absolute_import + +import os + +import pytest +from mach.logging import LoggingManager +from responses import RequestsMock + +from taskgraph import GECKO +from taskgraph.actions import render_actions_json +from taskgraph.config import load_graph_config +from taskgraph.parameters import Parameters + + +@pytest.fixture +def responses(): + with RequestsMock() as rsps: + yield rsps + + +@pytest.fixture(scope="session", autouse=True) +def patch_prefherder(request): + from _pytest.monkeypatch import MonkeyPatch + + m = MonkeyPatch() + m.setattr( + "taskgraph.util.bugbug._write_perfherder_data", + lambda lower_is_better: None, + ) + yield + m.undo() + + +@pytest.fixture(scope="session", autouse=True) +def enable_logging(): + """Ensure logs from taskgraph are displayed when a test fails.""" + lm = LoggingManager() + lm.add_terminal_logging() + + +@pytest.fixture(scope="session") +def graph_config(): + return load_graph_config(os.path.join(GECKO, "taskcluster", "ci")) + + +@pytest.fixture(scope="session") +def actions_json(graph_config): + decision_task_id = "abcdef" + return render_actions_json(Parameters(strict=False), graph_config, decision_task_id) diff --git a/taskcluster/taskgraph/test/docs/kinds.rst b/taskcluster/taskgraph/test/docs/kinds.rst new file mode 100644 index 0000000000..fdc16db1e3 --- /dev/null +++ b/taskcluster/taskgraph/test/docs/kinds.rst @@ -0,0 +1,12 @@ +Task Kinds +========== + +Fake task kind documentation. + +newkind +---------- +Kind found in separate doc dir, + +anotherkind +----------- +Here's another. diff --git a/taskcluster/taskgraph/test/docs/parameters.rst b/taskcluster/taskgraph/test/docs/parameters.rst new file mode 100644 index 0000000000..f943f48e69 --- /dev/null +++ b/taskcluster/taskgraph/test/docs/parameters.rst @@ -0,0 +1,14 @@ +========== +Parameters +========== + +Fake parameters documentation. + +Heading +------- + +``newparameter`` + A new parameter that could be defined in a project. + +``anotherparameter`` + And here is another one. diff --git a/taskcluster/taskgraph/test/python.ini b/taskcluster/taskgraph/test/python.ini new file mode 100644 index 0000000000..fa657c870d --- /dev/null +++ b/taskcluster/taskgraph/test/python.ini @@ -0,0 +1,34 @@ +[DEFAULT] +subsuite = taskgraph + +[test_actions_util.py] +[test_create.py] +[test_decision.py] +[test_files_changed.py] +[test_generator.py] +[test_graph.py] +[test_morph.py] +[test_optimize.py] +[test_optimize_strategies.py] +[test_parameters.py] +[test_target_tasks.py] +[test_taskgraph.py] +[test_taskcluster_yml.py] +[test_transforms_base.py] +[test_transforms_job.py] +[test_try_option_syntax.py] +[test_util_attributes.py] +[test_util_backstop.py] +[test_util_bugbug.py] +[test_util_chunking.py] +[test_util_docker.py] +[test_util_parameterization.py] +[test_util_python_path.py] +[test_util_runnable_jobs.py] +[test_util_schema.py] +[test_util_taskcluster.py] +[test_util_templates.py] +[test_util_time.py] +[test_util_treeherder.py] +[test_util_verify.py] +[test_util_yaml.py] diff --git a/taskcluster/taskgraph/test/test_actions_util.py b/taskcluster/taskgraph/test/test_actions_util.py new file mode 100644 index 0000000000..de5a6ca7ee --- /dev/null +++ b/taskcluster/taskgraph/test/test_actions_util.py @@ -0,0 +1,182 @@ +# 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 unittest +import json +from pprint import pprint + +import pytest +from mock import patch +from mozunit import main, MockedOpen + +from taskgraph import actions, create +from taskgraph.decision import read_artifact +from taskgraph.actions.util import ( + combine_task_graph_files, + relativize_datestamps, +) +from taskgraph.util import taskcluster + +TASK_DEF = { + "created": "2017-10-10T18:33:03.460Z", + # note that this is not an even number of seconds off! + "deadline": "2017-10-11T18:33:03.461Z", + "dependencies": [], + "expires": "2018-10-10T18:33:04.461Z", + "payload": { + "artifacts": { + "public": { + "expires": "2018-10-10T18:33:03.463Z", + "path": "/builds/worker/artifacts", + "type": "directory", + }, + }, + "maxRunTime": 1800, + }, +} + + +@pytest.fixture(scope="module", autouse=True) +def enable_test_mode(): + create.testing = True + taskcluster.testing = True + + +class TestRelativize(unittest.TestCase): + def test_relativize(self): + rel = relativize_datestamps(TASK_DEF) + import pprint + + pprint.pprint(rel) + assert rel["created"] == {"relative-datestamp": "0 seconds"} + assert rel["deadline"] == {"relative-datestamp": "86400 seconds"} + assert rel["expires"] == {"relative-datestamp": "31536001 seconds"} + assert rel["payload"]["artifacts"]["public"]["expires"] == { + "relative-datestamp": "31536000 seconds" + } + + +class TestCombineTaskGraphFiles(unittest.TestCase): + def test_no_suffixes(self): + with MockedOpen({}): + combine_task_graph_files([]) + self.assertRaises(Exception, open("artifacts/to-run.json")) + + @patch("taskgraph.actions.util.rename_artifact") + def test_one_suffix(self, rename_artifact): + combine_task_graph_files(["0"]) + rename_artifact.assert_any_call("task-graph-0.json", "task-graph.json") + rename_artifact.assert_any_call( + "label-to-taskid-0.json", "label-to-taskid.json" + ) + rename_artifact.assert_any_call("to-run-0.json", "to-run.json") + + def test_several_suffixes(self): + files = { + "artifacts/task-graph-0.json": json.dumps({"taska": {}}), + "artifacts/label-to-taskid-0.json": json.dumps({"taska": "TASKA"}), + "artifacts/to-run-0.json": json.dumps(["taska"]), + "artifacts/task-graph-1.json": json.dumps({"taskb": {}}), + "artifacts/label-to-taskid-1.json": json.dumps({"taskb": "TASKB"}), + "artifacts/to-run-1.json": json.dumps(["taskb"]), + } + with MockedOpen(files): + combine_task_graph_files(["0", "1"]) + self.assertEqual( + read_artifact("task-graph.json"), + { + "taska": {}, + "taskb": {}, + }, + ) + self.assertEqual( + read_artifact("label-to-taskid.json"), + { + "taska": "TASKA", + "taskb": "TASKB", + }, + ) + self.assertEqual( + sorted(read_artifact("to-run.json")), + [ + "taska", + "taskb", + ], + ) + + +def is_subset(subset, superset): + if isinstance(subset, dict): + return all( + key in superset and is_subset(val, superset[key]) + for key, val in subset.items() + ) + + if isinstance(subset, list) or isinstance(subset, set): + return all( + any(is_subset(subitem, superitem) for superitem in superset) + for subitem in subset + ) + + if isinstance(subset, str): + return subset in superset + + # assume that subset is a plain value if none of the above match + return subset == superset + + +@pytest.mark.parametrize( + "task_def,expected", + [ + pytest.param( + {"tags": {"kind": "decision-task"}}, + { + "hookPayload": { + "decision": { + "action": {"cb_name": "retrigger-decision"}, + }, + }, + }, + id="retrigger_decision", + ), + pytest.param( + {"tags": {"action": "backfill-task"}}, + { + "hookPayload": { + "decision": { + "action": {"cb_name": "retrigger-decision"}, + }, + }, + }, + id="retrigger_backfill", + ), + ], +) +def test_extract_applicable_action( + responses, monkeypatch, actions_json, task_def, expected +): + base_url = "https://taskcluster" + decision_task_id = "dddd" + task_id = "tttt" + + monkeypatch.setenv("TASK_ID", task_id) + monkeypatch.setenv("TASKCLUSTER_ROOT_URL", base_url) + monkeypatch.setenv("TASKCLUSTER_PROXY_URL", base_url) + responses.add( + responses.GET, + "{}/api/queue/v1/task/{}".format(base_url, task_id), + status=200, + json=task_def, + ) + action = actions.util._extract_applicable_action( + actions_json, "retrigger", decision_task_id, task_id + ) + pprint(action, indent=2) + assert is_subset(expected, action) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_create.py b/taskcluster/taskgraph/test/test_create.py new file mode 100644 index 0000000000..db1c274377 --- /dev/null +++ b/taskcluster/taskgraph/test/test_create.py @@ -0,0 +1,116 @@ +# 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 unittest + +import mock + +from taskgraph import create +from taskgraph.config import GraphConfig +from taskgraph.graph import Graph +from taskgraph.taskgraph import TaskGraph +from taskgraph.task import Task + +from mozunit import main + +GRAPH_CONFIG = GraphConfig({"trust-domain": "domain"}, "/var/empty") + + +class TestCreate(unittest.TestCase): + def setUp(self): + self.created_tasks = {} + self.old_create_task = create.create_task + create.create_task = self.fake_create_task + + def tearDown(self): + create.create_task = self.old_create_task + + def fake_create_task(self, session, task_id, label, task_def): + self.created_tasks[task_id] = task_def + + def test_create_tasks(self): + tasks = { + "tid-a": Task( + kind="test", label="a", attributes={}, task={"payload": "hello world"} + ), + "tid-b": Task( + kind="test", label="b", attributes={}, task={"payload": "hello world"} + ), + } + label_to_taskid = {"a": "tid-a", "b": "tid-b"} + graph = Graph(nodes={"tid-a", "tid-b"}, edges={("tid-a", "tid-b", "edge")}) + taskgraph = TaskGraph(tasks, graph) + + create.create_tasks( + GRAPH_CONFIG, + taskgraph, + label_to_taskid, + {"level": "4"}, + decision_task_id="decisiontask", + ) + + for tid, task in self.created_tasks.items(): + self.assertEqual(task["payload"], "hello world") + self.assertEqual(task["schedulerId"], "domain-level-4") + # make sure the dependencies exist, at least + for depid in task.get("dependencies", []): + if depid == "decisiontask": + # Don't look for decisiontask here + continue + self.assertIn(depid, self.created_tasks) + + def test_create_task_without_dependencies(self): + "a task with no dependencies depends on the decision task" + tasks = { + "tid-a": Task( + kind="test", label="a", attributes={}, task={"payload": "hello world"} + ), + } + label_to_taskid = {"a": "tid-a"} + graph = Graph(nodes={"tid-a"}, edges=set()) + taskgraph = TaskGraph(tasks, graph) + + create.create_tasks( + GRAPH_CONFIG, + taskgraph, + label_to_taskid, + {"level": "4"}, + decision_task_id="decisiontask", + ) + + for tid, task in self.created_tasks.items(): + self.assertEqual(task.get("dependencies"), ["decisiontask"]) + + @mock.patch("taskgraph.create.create_task") + def test_create_tasks_fails_if_create_fails(self, create_task): + "creat_tasks fails if a single create_task call fails" + tasks = { + "tid-a": Task( + kind="test", label="a", attributes={}, task={"payload": "hello world"} + ), + } + label_to_taskid = {"a": "tid-a"} + graph = Graph(nodes={"tid-a"}, edges=set()) + taskgraph = TaskGraph(tasks, graph) + + def fail(*args): + print("UHOH") + raise RuntimeError("oh noes!") + + create_task.side_effect = fail + + with self.assertRaises(RuntimeError): + create.create_tasks( + GRAPH_CONFIG, + taskgraph, + label_to_taskid, + {"level": "4"}, + decision_task_id="decisiontask", + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_decision.py b/taskcluster/taskgraph/test/test_decision.py new file mode 100644 index 0000000000..26dfdb714c --- /dev/null +++ b/taskcluster/taskgraph/test/test_decision.py @@ -0,0 +1,148 @@ +# 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 json +import shutil +import unittest +import tempfile + +from mock import patch +from mozunit import main, MockedOpen +from taskgraph import decision +from taskgraph.util.yaml import load_yaml + + +FAKE_GRAPH_CONFIG = {"product-dir": "browser", "taskgraph": {}} + + +class TestDecision(unittest.TestCase): + def test_write_artifact_json(self): + data = [{"some": "data"}] + tmpdir = tempfile.mkdtemp() + try: + decision.ARTIFACTS_DIR = os.path.join(tmpdir, "artifacts") + decision.write_artifact("artifact.json", data) + with open(os.path.join(decision.ARTIFACTS_DIR, "artifact.json")) as f: + self.assertEqual(json.load(f), data) + finally: + if os.path.exists(tmpdir): + shutil.rmtree(tmpdir) + decision.ARTIFACTS_DIR = "artifacts" + + def test_write_artifact_yml(self): + data = [{"some": "data"}] + tmpdir = tempfile.mkdtemp() + try: + decision.ARTIFACTS_DIR = os.path.join(tmpdir, "artifacts") + decision.write_artifact("artifact.yml", data) + self.assertEqual(load_yaml(decision.ARTIFACTS_DIR, "artifact.yml"), data) + finally: + if os.path.exists(tmpdir): + shutil.rmtree(tmpdir) + decision.ARTIFACTS_DIR = "artifacts" + + +class TestGetDecisionParameters(unittest.TestCase): + + ttc_file = os.path.join(os.getcwd(), "try_task_config.json") + + def setUp(self): + self.options = { + "base_repository": "https://hg.mozilla.org/mozilla-unified", + "head_repository": "https://hg.mozilla.org/mozilla-central", + "head_rev": "abcd", + "head_ref": "ef01", + "message": "", + "project": "mozilla-central", + "pushlog_id": "143", + "pushdate": 1503691511, + "owner": "nobody@mozilla.com", + "tasks_for": "hg-push", + "level": "3", + } + + @patch("taskgraph.decision.get_hg_revision_branch") + def test_simple_options(self, mock_get_hg_revision_branch): + mock_get_hg_revision_branch.return_value = "default" + with MockedOpen({self.ttc_file: None}): + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options) + self.assertEqual(params["pushlog_id"], "143") + self.assertEqual(params["build_date"], 1503691511) + self.assertEqual(params["hg_branch"], "default") + self.assertEqual(params["moz_build_date"], "20170825200511") + self.assertEqual(params["try_mode"], None) + self.assertEqual(params["try_options"], None) + self.assertEqual(params["try_task_config"], {}) + + @patch("taskgraph.decision.get_hg_revision_branch") + def test_no_email_owner(self, mock_get_hg_revision_branch): + mock_get_hg_revision_branch.return_value = "default" + self.options["owner"] = "ffxbld" + with MockedOpen({self.ttc_file: None}): + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options) + self.assertEqual(params["owner"], "ffxbld@noreply.mozilla.org") + + @patch("taskgraph.decision.get_hg_revision_branch") + @patch("taskgraph.decision.get_hg_commit_message") + def test_try_options(self, mock_get_hg_commit_message, mock_get_hg_revision_branch): + mock_get_hg_commit_message.return_value = "try: -b do -t all --artifact" + mock_get_hg_revision_branch.return_value = "default" + self.options["project"] = "try" + with MockedOpen({self.ttc_file: None}): + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options) + self.assertEqual(params["try_mode"], "try_option_syntax") + self.assertEqual(params["try_options"]["build_types"], "do") + self.assertEqual(params["try_options"]["unittests"], "all") + self.assertEqual( + params["try_task_config"], + { + "gecko-profile": False, + "use-artifact-builds": True, + "env": {}, + }, + ) + + @patch("taskgraph.decision.get_hg_revision_branch") + @patch("taskgraph.decision.get_hg_commit_message") + def test_try_task_config( + self, mock_get_hg_commit_message, mock_get_hg_revision_branch + ): + mock_get_hg_commit_message.return_value = "Fuzzy query=foo" + mock_get_hg_revision_branch.return_value = "default" + ttc = {"tasks": ["a", "b"]} + self.options["project"] = "try" + with MockedOpen({self.ttc_file: json.dumps(ttc)}): + params = decision.get_decision_parameters(FAKE_GRAPH_CONFIG, self.options) + self.assertEqual(params["try_mode"], "try_task_config") + self.assertEqual(params["try_options"], None) + self.assertEqual(params["try_task_config"], ttc) + + def test_try_syntax_from_message_empty(self): + self.assertEqual(decision.try_syntax_from_message(""), "") + + def test_try_syntax_from_message_no_try_syntax(self): + self.assertEqual(decision.try_syntax_from_message("abc | def"), "") + + def test_try_syntax_from_message_initial_try_syntax(self): + self.assertEqual( + decision.try_syntax_from_message("try: -f -o -o"), "try: -f -o -o" + ) + + def test_try_syntax_from_message_initial_try_syntax_multiline(self): + self.assertEqual( + decision.try_syntax_from_message("try: -f -o -o\nabc\ndef"), "try: -f -o -o" + ) + + def test_try_syntax_from_message_embedded_try_syntax_multiline(self): + self.assertEqual( + decision.try_syntax_from_message("some stuff\ntry: -f -o -o\nabc\ndef"), + "try: -f -o -o", + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_files_changed.py b/taskcluster/taskgraph/test/test_files_changed.py new file mode 100644 index 0000000000..98f784bfe7 --- /dev/null +++ b/taskcluster/taskgraph/test/test_files_changed.py @@ -0,0 +1,89 @@ +# 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 json +import os +import unittest + +from taskgraph import files_changed +from mozunit import main + +PARAMS = { + "head_repository": "https://hg.mozilla.org/mozilla-central", + "head_rev": "a14f88a9af7a", +} + +FILES_CHANGED = [ + "devtools/client/debugger/index.html", + "devtools/client/inspector/test/browser_inspector_highlighter-eyedropper-events.js", + "devtools/client/inspector/test/head.js", + "devtools/client/themes/rules.css", + "devtools/client/webconsole/test/browser_webconsole_output_06.js", + "devtools/server/actors/highlighters/eye-dropper.js", + "devtools/server/actors/object.js", + "docshell/base/nsDocShell.cpp", + "dom/tests/mochitest/general/test_contentViewer_overrideDPPX.html", + "taskcluster/scripts/builder/build-l10n.sh", +] + + +class FakeResponse: + def json(self): + with open( + os.path.join(os.path.dirname(__file__), "automationrelevance.json") + ) as f: + return json.load(f) + + +class TestGetChangedFiles(unittest.TestCase): + def setUp(self): + files_changed.get_changed_files.clear() + self.old_get = files_changed.requests.get + + def fake_get(url, **kwargs): + return FakeResponse() + + files_changed.requests.get = fake_get + + def tearDown(self): + files_changed.requests.get = self.old_get + files_changed.get_changed_files.clear() + + def test_get_changed_files(self): + """Get_changed_files correctly gets the list of changed files in a push. + This tests against the production hg.mozilla.org so that it will detect + any changes in the format of the returned data.""" + self.assertEqual( + sorted( + files_changed.get_changed_files( + PARAMS["head_repository"], PARAMS["head_rev"] + ) + ), + FILES_CHANGED, + ) + + +class TestCheck(unittest.TestCase): + def setUp(self): + files_changed.get_changed_files[ + PARAMS["head_repository"], PARAMS["head_rev"] + ] = FILES_CHANGED + + def tearDown(self): + files_changed.get_changed_files.clear() + + def test_check_no_params(self): + self.assertTrue(files_changed.check({}, ["ignored"])) + + def test_check_no_match(self): + self.assertFalse(files_changed.check(PARAMS, ["nosuch/**"])) + + def test_check_match(self): + self.assertTrue(files_changed.check(PARAMS, ["devtools/**"])) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_generator.py b/taskcluster/taskgraph/test/test_generator.py new file mode 100644 index 0000000000..b532e2f9d6 --- /dev/null +++ b/taskcluster/taskgraph/test/test_generator.py @@ -0,0 +1,262 @@ +# 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 pytest +import six +import unittest +from mozunit import main + +from taskgraph.generator import TaskGraphGenerator, Kind, load_tasks_for_kind +from taskgraph.optimize import OptimizationStrategy +from taskgraph.config import GraphConfig +from taskgraph.util.templates import merge +from taskgraph import ( + generator, + graph, + optimize as optimize_mod, + target_tasks as target_tasks_mod, +) + + +def fake_loader(kind, path, config, parameters, loaded_tasks): + for i in range(3): + dependencies = {} + if i >= 1: + dependencies["prev"] = "{}-t-{}".format(kind, i - 1) + + task = { + "kind": kind, + "label": "{}-t-{}".format(kind, i), + "description": "{} task {}".format(kind, i), + "attributes": {"_tasknum": six.text_type(i)}, + "task": {"i": i, "metadata": {"name": "t-{}".format(i)}}, + "dependencies": dependencies, + } + if "job-defaults" in config: + task = merge(config["job-defaults"], task) + yield task + + +class FakeKind(Kind): + def _get_loader(self): + return fake_loader + + def load_tasks(self, parameters, loaded_tasks, write_artifacts): + FakeKind.loaded_kinds.append(self.name) + return super(FakeKind, self).load_tasks( + parameters, loaded_tasks, write_artifacts + ) + + +class WithFakeKind(TaskGraphGenerator): + def _load_kinds(self, graph_config, target_kind=None): + for kind_name, cfg in self.parameters["_kinds"]: + config = { + "transforms": [], + } + if cfg: + config.update(cfg) + yield FakeKind(kind_name, "/fake", config, graph_config) + + +def fake_load_graph_config(root_dir): + graph_config = GraphConfig( + {"trust-domain": "test-domain", "taskgraph": {}}, root_dir + ) + graph_config.__dict__["register"] = lambda: None + return graph_config + + +class FakeParameters(dict): + strict = True + + +class FakeOptimization(OptimizationStrategy): + def __init__(self, mode, *args, **kwargs): + super(FakeOptimization, self).__init__(*args, **kwargs) + self.mode = mode + + def should_remove_task(self, task, params, arg): + if self.mode == "always": + return True + if self.mode == "even": + return task.task["i"] % 2 == 0 + if self.mode == "odd": + return task.task["i"] % 2 != 0 + return False + + +class TestGenerator(unittest.TestCase): + @pytest.fixture(autouse=True) + def patch(self, monkeypatch): + self.patch = monkeypatch + + def maketgg(self, target_tasks=None, kinds=[("_fake", [])], params=None): + params = params or {} + FakeKind.loaded_kinds = [] + self.target_tasks = target_tasks or [] + + def target_tasks_method(full_task_graph, parameters, graph_config): + return self.target_tasks + + fake_registry = { + mode: FakeOptimization(mode) for mode in ("always", "never", "even", "odd") + } + + target_tasks_mod._target_task_methods["test_method"] = target_tasks_method + self.patch.setattr(optimize_mod, "registry", fake_registry) + + parameters = FakeParameters( + { + "_kinds": kinds, + "backstop": False, + "target_tasks_method": "test_method", + "test_manifest_loader": "default", + "try_mode": None, + "try_task_config": {}, + "tasks_for": "hg-push", + "project": "mozilla-central", + } + ) + parameters.update(params) + + self.patch.setattr(generator, "load_graph_config", fake_load_graph_config) + + return WithFakeKind("/root", parameters) + + def test_kind_ordering(self): + "When task kinds depend on each other, they are loaded in postorder" + self.tgg = self.maketgg( + kinds=[ + ("_fake3", {"kind-dependencies": ["_fake2", "_fake1"]}), + ("_fake2", {"kind-dependencies": ["_fake1"]}), + ("_fake1", {"kind-dependencies": []}), + ] + ) + self.tgg._run_until("full_task_set") + self.assertEqual(FakeKind.loaded_kinds, ["_fake1", "_fake2", "_fake3"]) + + def test_full_task_set(self): + "The full_task_set property has all tasks" + self.tgg = self.maketgg() + self.assertEqual( + self.tgg.full_task_set.graph, + graph.Graph({"_fake-t-0", "_fake-t-1", "_fake-t-2"}, set()), + ) + self.assertEqual( + sorted(self.tgg.full_task_set.tasks.keys()), + sorted(["_fake-t-0", "_fake-t-1", "_fake-t-2"]), + ) + + def test_full_task_graph(self): + "The full_task_graph property has all tasks, and links" + self.tgg = self.maketgg() + self.assertEqual( + self.tgg.full_task_graph.graph, + graph.Graph( + {"_fake-t-0", "_fake-t-1", "_fake-t-2"}, + { + ("_fake-t-1", "_fake-t-0", "prev"), + ("_fake-t-2", "_fake-t-1", "prev"), + }, + ), + ) + self.assertEqual( + sorted(self.tgg.full_task_graph.tasks.keys()), + sorted(["_fake-t-0", "_fake-t-1", "_fake-t-2"]), + ) + + def test_target_task_set(self): + "The target_task_set property has the targeted tasks" + self.tgg = self.maketgg(["_fake-t-1"]) + self.assertEqual( + self.tgg.target_task_set.graph, graph.Graph({"_fake-t-1"}, set()) + ) + self.assertEqual( + set(six.iterkeys(self.tgg.target_task_set.tasks)), {"_fake-t-1"} + ) + + def test_target_task_graph(self): + "The target_task_graph property has the targeted tasks and deps" + self.tgg = self.maketgg(["_fake-t-1"]) + self.assertEqual( + self.tgg.target_task_graph.graph, + graph.Graph( + {"_fake-t-0", "_fake-t-1"}, {("_fake-t-1", "_fake-t-0", "prev")} + ), + ) + self.assertEqual( + sorted(self.tgg.target_task_graph.tasks.keys()), + sorted(["_fake-t-0", "_fake-t-1"]), + ) + + def test_always_target_tasks(self): + "The target_task_graph includes tasks with 'always_target'" + tgg_args = { + "target_tasks": ["_fake-t-0", "_fake-t-1", "_ignore-t-0", "_ignore-t-1"], + "kinds": [ + ("_fake", {"job-defaults": {"optimization": {"odd": None}}}), + ( + "_ignore", + { + "job-defaults": { + "attributes": {"always_target": True}, + "optimization": {"even": None}, + } + }, + ), + ], + "params": {"optimize_target_tasks": False}, + } + self.tgg = self.maketgg(**tgg_args) + self.assertEqual( + sorted(self.tgg.target_task_set.tasks.keys()), + sorted(["_fake-t-0", "_fake-t-1", "_ignore-t-0", "_ignore-t-1"]), + ) + self.assertEqual( + sorted(self.tgg.target_task_graph.tasks.keys()), + sorted( + ["_fake-t-0", "_fake-t-1", "_ignore-t-0", "_ignore-t-1", "_ignore-t-2"] + ), + ) + self.assertEqual( + sorted([t.label for t in self.tgg.optimized_task_graph.tasks.values()]), + sorted(["_fake-t-0", "_fake-t-1", "_ignore-t-0", "_ignore-t-1"]), + ) + + def test_optimized_task_graph(self): + "The optimized task graph contains task ids" + self.tgg = self.maketgg(["_fake-t-2"]) + tid = self.tgg.label_to_taskid + self.assertEqual( + self.tgg.optimized_task_graph.graph, + graph.Graph( + {tid["_fake-t-0"], tid["_fake-t-1"], tid["_fake-t-2"]}, + { + (tid["_fake-t-1"], tid["_fake-t-0"], "prev"), + (tid["_fake-t-2"], tid["_fake-t-1"], "prev"), + }, + ), + ) + + +def test_load_tasks_for_kind(monkeypatch): + """ + `load_tasks_for_kinds` will load the tasks for the provided kind + """ + monkeypatch.setattr(generator, "TaskGraphGenerator", WithFakeKind) + monkeypatch.setattr(generator, "load_graph_config", fake_load_graph_config) + + tasks = load_tasks_for_kind( + {"_kinds": [("_example-kind", []), ("docker-image", [])]}, + "_example-kind", + "/root", + ) + assert "t-1" in tasks and tasks["t-1"].label == "_example-kind-t-1" + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_graph.py b/taskcluster/taskgraph/test/test_graph.py new file mode 100644 index 0000000000..d372cd42bc --- /dev/null +++ b/taskcluster/taskgraph/test/test_graph.py @@ -0,0 +1,224 @@ +# -*- coding: utf-8 -*- + +# 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 unittest + +from taskgraph.graph import Graph +from mozunit import main + + +class TestGraph(unittest.TestCase): + + tree = Graph( + set(["a", "b", "c", "d", "e", "f", "g"]), + { + ("a", "b", "L"), + ("a", "c", "L"), + ("b", "d", "K"), + ("b", "e", "K"), + ("c", "f", "N"), + ("c", "g", "N"), + }, + ) + + linear = Graph( + set(["1", "2", "3", "4"]), + { + ("1", "2", "L"), + ("2", "3", "L"), + ("3", "4", "L"), + }, + ) + + diamonds = Graph( + set(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]), + set( + tuple(x) + for x in "AFL ADL BDL BEL CEL CHL DFL DGL EGL EHL FIL GIL GJL HJL".split() + ), + ) + + multi_edges = Graph( + set(["1", "2", "3", "4"]), + { + ("2", "1", "red"), + ("2", "1", "blue"), + ("3", "1", "red"), + ("3", "2", "blue"), + ("3", "2", "green"), + ("4", "3", "green"), + }, + ) + + disjoint = Graph( + set(["1", "2", "3", "4", "α", "β", "γ"]), + { + ("2", "1", "red"), + ("3", "1", "red"), + ("3", "2", "green"), + ("4", "3", "green"), + ("α", "β", "πράσινο"), + ("β", "γ", "κόκκινο"), + ("α", "γ", "μπλε"), + }, + ) + + def test_transitive_closure_empty(self): + "transitive closure of an empty set is an empty graph" + g = Graph(set(["a", "b", "c"]), {("a", "b", "L"), ("a", "c", "L")}) + self.assertEqual(g.transitive_closure(set()), Graph(set(), set())) + + def test_transitive_closure_disjoint(self): + "transitive closure of a disjoint set is a subset" + g = Graph(set(["a", "b", "c"]), set()) + self.assertEqual( + g.transitive_closure(set(["a", "c"])), Graph(set(["a", "c"]), set()) + ) + + def test_transitive_closure_trees(self): + "transitive closure of a tree, at two non-root nodes, is the two subtrees" + self.assertEqual( + self.tree.transitive_closure(set(["b", "c"])), + Graph( + set(["b", "c", "d", "e", "f", "g"]), + { + ("b", "d", "K"), + ("b", "e", "K"), + ("c", "f", "N"), + ("c", "g", "N"), + }, + ), + ) + + def test_transitive_closure_multi_edges(self): + "transitive closure of a tree with multiple edges between nodes keeps those edges" + self.assertEqual( + self.multi_edges.transitive_closure(set(["3"])), + Graph( + set(["1", "2", "3"]), + { + ("2", "1", "red"), + ("2", "1", "blue"), + ("3", "1", "red"), + ("3", "2", "blue"), + ("3", "2", "green"), + }, + ), + ) + + def test_transitive_closure_disjoint_edges(self): + "transitive closure of a disjoint graph keeps those edges" + self.assertEqual( + self.disjoint.transitive_closure(set(["3", "β"])), + Graph( + set(["1", "2", "3", "β", "γ"]), + { + ("2", "1", "red"), + ("3", "1", "red"), + ("3", "2", "green"), + ("β", "γ", "κόκκινο"), + }, + ), + ) + + def test_transitive_closure_linear(self): + "transitive closure of a linear graph includes all nodes in the line" + self.assertEqual(self.linear.transitive_closure(set(["1"])), self.linear) + + def test_visit_postorder_empty(self): + "postorder visit of an empty graph is empty" + self.assertEqual(list(Graph(set(), set()).visit_postorder()), []) + + def assert_postorder(self, seq, all_nodes): + seen = set() + for e in seq: + for l, r, n in self.tree.edges: + if l == e: + self.assertTrue(r in seen) + seen.add(e) + self.assertEqual(seen, all_nodes) + + def test_visit_postorder_tree(self): + "postorder visit of a tree satisfies invariant" + self.assert_postorder(self.tree.visit_postorder(), self.tree.nodes) + + def test_visit_postorder_diamonds(self): + "postorder visit of a graph full of diamonds satisfies invariant" + self.assert_postorder(self.diamonds.visit_postorder(), self.diamonds.nodes) + + def test_visit_postorder_multi_edges(self): + "postorder visit of a graph with duplicate edges satisfies invariant" + self.assert_postorder( + self.multi_edges.visit_postorder(), self.multi_edges.nodes + ) + + def test_visit_postorder_disjoint(self): + "postorder visit of a disjoint graph satisfies invariant" + self.assert_postorder(self.disjoint.visit_postorder(), self.disjoint.nodes) + + def assert_preorder(self, seq, all_nodes): + seen = set() + for e in seq: + for l, r, n in self.tree.edges: + if r == e: + self.assertTrue(l in seen) + seen.add(e) + self.assertEqual(seen, all_nodes) + + def test_visit_preorder_tree(self): + "preorder visit of a tree satisfies invariant" + self.assert_preorder(self.tree.visit_preorder(), self.tree.nodes) + + def test_visit_preorder_diamonds(self): + "preorder visit of a graph full of diamonds satisfies invariant" + self.assert_preorder(self.diamonds.visit_preorder(), self.diamonds.nodes) + + def test_visit_preorder_multi_edges(self): + "preorder visit of a graph with duplicate edges satisfies invariant" + self.assert_preorder(self.multi_edges.visit_preorder(), self.multi_edges.nodes) + + def test_visit_preorder_disjoint(self): + "preorder visit of a disjoint graph satisfies invariant" + self.assert_preorder(self.disjoint.visit_preorder(), self.disjoint.nodes) + + def test_links_dict(self): + "link dict for a graph with multiple edges is correct" + self.assertEqual( + self.multi_edges.links_dict(), + { + "2": set(["1"]), + "3": set(["1", "2"]), + "4": set(["3"]), + }, + ) + + def test_named_links_dict(self): + "named link dict for a graph with multiple edges is correct" + self.assertEqual( + self.multi_edges.named_links_dict(), + { + "2": dict(red="1", blue="1"), + "3": dict(red="1", blue="2", green="2"), + "4": dict(green="3"), + }, + ) + + def test_reverse_links_dict(self): + "reverse link dict for a graph with multiple edges is correct" + self.assertEqual( + self.multi_edges.reverse_links_dict(), + { + "1": set(["2", "3"]), + "2": set(["3"]), + "3": set(["4"]), + }, + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_morph.py b/taskcluster/taskgraph/test/test_morph.py new file mode 100644 index 0000000000..fe4ce39c56 --- /dev/null +++ b/taskcluster/taskgraph/test/test_morph.py @@ -0,0 +1,111 @@ +# 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 pytest +import six + +from taskgraph import morph +from taskgraph.graph import Graph +from taskgraph.parameters import Parameters +from taskgraph.taskgraph import TaskGraph +from taskgraph.task import Task + +from mozunit import main + + +@pytest.fixture +def make_taskgraph(): + def inner(tasks): + label_to_taskid = {k: k + "-tid" for k in tasks} + for label, task_id in six.iteritems(label_to_taskid): + tasks[label].task_id = task_id + graph = Graph(nodes=set(tasks), edges=set()) + taskgraph = TaskGraph(tasks, graph) + return taskgraph, label_to_taskid + + return inner + + +def test_make_index_tasks(make_taskgraph, graph_config): + task_def = { + "routes": [ + "index.gecko.v2.mozilla-central.latest.firefox-l10n.linux64-opt.es-MX", + "index.gecko.v2.mozilla-central.latest.firefox-l10n.linux64-opt.fy-NL", + "index.gecko.v2.mozilla-central.latest.firefox-l10n.linux64-opt.sk", + "index.gecko.v2.mozilla-central.latest.firefox-l10n.linux64-opt.sl", + "index.gecko.v2.mozilla-central.latest.firefox-l10n.linux64-opt.uk", + "index.gecko.v2.mozilla-central.latest.firefox-l10n.linux64-opt.zh-CN", + "index.gecko.v2.mozilla-central.pushdate." + "2017.04.04.20170404100210.firefox-l10n.linux64-opt.es-MX", + "index.gecko.v2.mozilla-central.pushdate." + "2017.04.04.20170404100210.firefox-l10n.linux64-opt.fy-NL", + "index.gecko.v2.mozilla-central.pushdate." + "2017.04.04.20170404100210.firefox-l10n.linux64-opt.sk", + "index.gecko.v2.mozilla-central.pushdate." + "2017.04.04.20170404100210.firefox-l10n.linux64-opt.sl", + "index.gecko.v2.mozilla-central.pushdate." + "2017.04.04.20170404100210.firefox-l10n.linux64-opt.uk", + "index.gecko.v2.mozilla-central.pushdate." + "2017.04.04.20170404100210.firefox-l10n.linux64-opt.zh-CN", + "index.gecko.v2.mozilla-central.revision." + "b5d8b27a753725c1de41ffae2e338798f3b5cacd.firefox-l10n.linux64-opt.es-MX", + "index.gecko.v2.mozilla-central.revision." + "b5d8b27a753725c1de41ffae2e338798f3b5cacd.firefox-l10n.linux64-opt.fy-NL", + "index.gecko.v2.mozilla-central.revision." + "b5d8b27a753725c1de41ffae2e338798f3b5cacd.firefox-l10n.linux64-opt.sk", + "index.gecko.v2.mozilla-central.revision." + "b5d8b27a753725c1de41ffae2e338798f3b5cacd.firefox-l10n.linux64-opt.sl", + "index.gecko.v2.mozilla-central.revision." + "b5d8b27a753725c1de41ffae2e338798f3b5cacd.firefox-l10n.linux64-opt.uk", + "index.gecko.v2.mozilla-central.revision." + "b5d8b27a753725c1de41ffae2e338798f3b5cacd.firefox-l10n.linux64-opt.zh-CN", + ], + "deadline": "soon", + "metadata": { + "description": "desc", + "owner": "owner@foo.com", + "source": "https://source", + }, + "extra": { + "index": {"rank": 1540722354}, + }, + } + task = Task(kind="test", label="a", attributes={}, task=task_def) + docker_task = Task( + kind="docker-image", label="docker-image-index-task", attributes={}, task={} + ) + taskgraph, label_to_taskid = make_taskgraph( + { + task.label: task, + docker_task.label: docker_task, + } + ) + + index_paths = [ + r.split(".", 1)[1] for r in task_def["routes"] if r.startswith("index.") + ] + index_task = morph.make_index_task( + task, + taskgraph, + label_to_taskid, + Parameters(strict=False), + graph_config, + index_paths=index_paths, + index_rank=1540722354, + purpose="index-task", + dependencies={}, + ) + + assert index_task.task["payload"]["command"][0] == "insert-indexes.js" + assert index_task.task["payload"]["env"]["TARGET_TASKID"] == "a-tid" + assert index_task.task["payload"]["env"]["INDEX_RANK"] == 1540722354 + + # check the scope summary + assert index_task.task["scopes"] == ["index:insert-task:gecko.v2.mozilla-central.*"] + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_optimize.py b/taskcluster/taskgraph/test/test_optimize.py new file mode 100644 index 0000000000..557e1bb9b0 --- /dev/null +++ b/taskcluster/taskgraph/test/test_optimize.py @@ -0,0 +1,476 @@ +# 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 + +from functools import partial + +import pytest +from taskgraph import graph, optimize +from taskgraph.optimize import OptimizationStrategy, All, Any, Not +from taskgraph.taskgraph import TaskGraph +from taskgraph.task import Task +from mozunit import main + + +class Remove(OptimizationStrategy): + def should_remove_task(self, task, params, arg): + return True + + +class Replace(OptimizationStrategy): + def should_replace_task(self, task, params, taskid): + return taskid + + +def default_strategies(): + return { + "never": OptimizationStrategy(), + "remove": Remove(), + "replace": Replace(), + } + + +def make_task( + label, + optimization=None, + task_def=None, + optimized=None, + task_id=None, + dependencies=None, + if_dependencies=None, +): + task_def = task_def or {"sample": "task-def"} + task = Task( + kind="test", + label=label, + attributes={}, + task=task_def, + if_dependencies=if_dependencies or [], + ) + task.optimization = optimization + task.task_id = task_id + if dependencies is not None: + task.task["dependencies"] = sorted(dependencies) + return task + + +def make_graph(*tasks_and_edges, **kwargs): + tasks = {t.label: t for t in tasks_and_edges if isinstance(t, Task)} + edges = {e for e in tasks_and_edges if not isinstance(e, Task)} + tg = TaskGraph(tasks, graph.Graph(set(tasks), edges)) + + if kwargs.get("deps", True): + # set dependencies based on edges + for l, r, name in tg.graph.edges: + tg.tasks[l].dependencies[name] = r + + return tg + + +def make_opt_graph(*tasks_and_edges): + tasks = {t.task_id: t for t in tasks_and_edges if isinstance(t, Task)} + edges = {e for e in tasks_and_edges if not isinstance(e, Task)} + return TaskGraph(tasks, graph.Graph(set(tasks), edges)) + + +def make_triangle(deps=True, **opts): + """ + Make a "triangle" graph like this: + + t1 <-------- t3 + `---- t2 --' + """ + return make_graph( + make_task("t1", opts.get("t1")), + make_task("t2", opts.get("t2")), + make_task("t3", opts.get("t3")), + ("t3", "t2", "dep"), + ("t3", "t1", "dep2"), + ("t2", "t1", "dep"), + deps=deps, + ) + + +@pytest.mark.parametrize( + "graph,kwargs,exp_removed", + ( + # A graph full of optimization=never has nothing removed + pytest.param( + make_triangle(), + {}, + # expectations + set(), + id="never", + ), + # A graph full of optimization=remove removes everything + pytest.param( + make_triangle( + t1={"remove": None}, + t2={"remove": None}, + t3={"remove": None}, + ), + {}, + # expectations + {"t1", "t2", "t3"}, + id="all", + ), + # Tasks with the 'any' composite strategy are removed when any substrategy says to + pytest.param( + make_triangle( + t1={"any": None}, + t2={"any": None}, + t3={"any": None}, + ), + {"strategies": lambda: {"any": Any("never", "remove")}}, + # expectations + {"t1", "t2", "t3"}, + id="composite_strategies_any", + ), + # Tasks with the 'all' composite strategy are removed when all substrategies say to + pytest.param( + make_triangle( + t1={"all": None}, + t2={"all": None}, + t3={"all": None}, + ), + {"strategies": lambda: {"all": All("never", "remove")}}, + # expectations + set(), + id="composite_strategies_all", + ), + # Tasks with the 'not' composite strategy are removed when the substrategy says not to + pytest.param( + make_graph( + make_task("t1", {"not-never": None}), + make_task("t2", {"not-remove": None}), + ), + { + "strategies": lambda: { + "not-never": Not("never"), + "not-remove": Not("remove"), + } + }, + # expectations + {"t1"}, + id="composite_strategies_not", + ), + # Removable tasks that are depended on by non-removable tasks are not removed + pytest.param( + make_triangle( + t1={"remove": None}, + t3={"remove": None}, + ), + {}, + # expectations + {"t3"}, + id="blocked", + ), + # Removable tasks that are marked do_not_optimize are not removed + pytest.param( + make_triangle( + t1={"remove": None}, + t2={"remove": None}, # but do_not_optimize + t3={"remove": None}, + ), + {"do_not_optimize": {"t2"}}, + # expectations + {"t3"}, + id="do_not_optimize", + ), + # Tasks with 'if_dependencies' are removed when deps are not run + pytest.param( + make_graph( + make_task("t1", {"remove": None}), + make_task("t2", {"remove": None}), + make_task("t3", {"never": None}, if_dependencies=["t1", "t2"]), + make_task("t4", {"never": None}, if_dependencies=["t1"]), + ("t3", "t2", "dep"), + ("t3", "t1", "dep2"), + ("t2", "t1", "dep"), + ("t4", "t1", "dep3"), + ), + {"requested_tasks": {"t3", "t4"}}, + # expectations + {"t1", "t2", "t3", "t4"}, + id="if_deps_removed", + ), + # Parents of tasks with 'if_dependencies' are also removed even if requested + pytest.param( + make_graph( + make_task("t1", {"remove": None}), + make_task("t2", {"remove": None}), + make_task("t3", {"never": None}, if_dependencies=["t1", "t2"]), + make_task("t4", {"never": None}, if_dependencies=["t1"]), + ("t3", "t2", "dep"), + ("t3", "t1", "dep2"), + ("t2", "t1", "dep"), + ("t4", "t1", "dep3"), + ), + {}, + # expectations + {"t1", "t2", "t3", "t4"}, + id="if_deps_parents_removed", + ), + # Tasks with 'if_dependencies' are kept if at least one of said dependencies are kept + pytest.param( + make_graph( + make_task("t1", {"never": None}), + make_task("t2", {"remove": None}), + make_task("t3", {"never": None}, if_dependencies=["t1", "t2"]), + make_task("t4", {"never": None}, if_dependencies=["t1"]), + ("t3", "t2", "dep"), + ("t3", "t1", "dep2"), + ("t2", "t1", "dep"), + ("t4", "t1", "dep3"), + ), + {}, + # expectations + set(), + id="if_deps_kept", + ), + # Ancestor of task with 'if_dependencies' does not cause it to be kept + pytest.param( + make_graph( + make_task("t1", {"never": None}), + make_task("t2", {"remove": None}), + make_task("t3", {"never": None}, if_dependencies=["t2"]), + ("t3", "t2", "dep"), + ("t2", "t1", "dep2"), + ), + {}, + # expectations + {"t2", "t3"}, + id="if_deps_ancestor_does_not_keep", + ), + # Unhandled edge case where 't1' and 't2' are kept even though they + # don't have any dependents and are not in 'requested_tasks' + pytest.param( + make_graph( + make_task("t1", {"never": None}), + make_task("t2", {"never": None}, if_dependencies=["t1"]), + make_task("t3", {"remove": None}), + make_task("t4", {"never": None}, if_dependencies=["t3"]), + ("t2", "t1", "e1"), + ("t4", "t2", "e2"), + ("t4", "t3", "e3"), + ), + {"requested_tasks": {"t3", "t4"}}, + # expectations + {"t1", "t2", "t3", "t4"}, + id="if_deps_edge_case_1", + marks=pytest.mark.xfail, + ), + ), +) +def test_remove_tasks(monkeypatch, graph, kwargs, exp_removed): + """Tests the `remove_tasks` function. + + Each test case takes three arguments: + + 1. A `TaskGraph` instance. + 2. Keyword arguments to pass into `remove_tasks`. + 3. The set of task labels that are expected to be removed. + """ + # set up strategies + strategies = default_strategies() + monkeypatch.setattr(optimize, "registry", strategies) + extra = kwargs.pop("strategies", None) + if extra: + if callable(extra): + extra = extra() + strategies.update(extra) + + kwargs.setdefault("params", {}) + kwargs.setdefault("do_not_optimize", set()) + kwargs.setdefault("requested_tasks", graph) + + got_removed = optimize.remove_tasks( + target_task_graph=graph, + optimizations=optimize._get_optimizations(graph, strategies), + **kwargs + ) + assert got_removed == exp_removed + + +@pytest.mark.parametrize( + "graph,kwargs,exp_replaced,exp_removed,exp_label_to_taskid", + ( + # A task cannot be replaced if it depends on one that was not replaced + pytest.param( + make_triangle( + t1={"replace": "e1"}, + t3={"replace": "e3"}, + ), + {}, + # expectations + {"t1"}, + set(), + {"t1": "e1"}, + id="blocked", + ), + # A task cannot be replaced if it should not be optimized + pytest.param( + make_triangle( + t1={"replace": "e1"}, + t2={"replace": "xxx"}, # but do_not_optimize + t3={"replace": "e3"}, + ), + {"do_not_optimize": {"t2"}}, + # expectations + {"t1"}, + set(), + {"t1": "e1"}, + id="do_not_optimize", + ), + # No tasks are replaced when strategy is 'never' + pytest.param( + make_triangle(), + {}, + # expectations + set(), + set(), + {}, + id="never", + ), + # All replacable tasks are replaced when strategy is 'replace' + pytest.param( + make_triangle( + t1={"replace": "e1"}, + t2={"replace": "e2"}, + t3={"replace": "e3"}, + ), + {}, + # expectations + {"t1", "t2", "t3"}, + set(), + {"t1": "e1", "t2": "e2", "t3": "e3"}, + id="all", + ), + # A task can be replaced with nothing + pytest.param( + make_triangle( + t1={"replace": "e1"}, + t2={"replace": True}, + t3={"replace": True}, + ), + {}, + # expectations + {"t1"}, + {"t2", "t3"}, + {"t1": "e1"}, + id="tasks_removed", + ), + ), +) +def test_replace_tasks( + graph, + kwargs, + exp_replaced, + exp_removed, + exp_label_to_taskid, +): + """Tests the `replace_tasks` function. + + Each test case takes five arguments: + + 1. A `TaskGraph` instance. + 2. Keyword arguments to pass into `replace_tasks`. + 3. The set of task labels that are expected to be replaced. + 4. The set of task labels that are expected to be removed. + 5. The expected label_to_taskid. + """ + kwargs.setdefault("params", {}) + kwargs.setdefault("do_not_optimize", set()) + kwargs.setdefault("label_to_taskid", {}) + kwargs.setdefault("removed_tasks", set()) + kwargs.setdefault("existing_tasks", {}) + + got_replaced = optimize.replace_tasks( + target_task_graph=graph, + optimizations=optimize._get_optimizations(graph, default_strategies()), + **kwargs + ) + assert got_replaced == exp_replaced + assert kwargs["removed_tasks"] == exp_removed + assert kwargs["label_to_taskid"] == exp_label_to_taskid + + +@pytest.mark.parametrize( + "graph,kwargs,exp_subgraph,exp_label_to_taskid", + ( + # Test get_subgraph returns a similarly-shaped subgraph when nothing is removed + pytest.param( + make_triangle(deps=False), + {}, + make_opt_graph( + make_task("t1", task_id="tid1", dependencies={}), + make_task("t2", task_id="tid2", dependencies={"tid1"}), + make_task("t3", task_id="tid3", dependencies={"tid1", "tid2"}), + ("tid3", "tid2", "dep"), + ("tid3", "tid1", "dep2"), + ("tid2", "tid1", "dep"), + ), + {"t1": "tid1", "t2": "tid2", "t3": "tid3"}, + id="no_change", + ), + # Test get_subgraph returns a smaller subgraph when tasks are removed + pytest.param( + make_triangle(deps=False), + { + "removed_tasks": {"t2", "t3"}, + }, + # expectations + make_opt_graph(make_task("t1", task_id="tid1", dependencies={})), + {"t1": "tid1"}, + id="removed", + ), + # Test get_subgraph returns a smaller subgraph when tasks are replaced + pytest.param( + make_triangle(deps=False), + { + "replaced_tasks": {"t1", "t2"}, + "label_to_taskid": {"t1": "e1", "t2": "e2"}, + }, + # expectations + make_opt_graph(make_task("t3", task_id="tid1", dependencies={"e1", "e2"})), + {"t1": "e1", "t2": "e2", "t3": "tid1"}, + id="replaced", + ), + ), +) +def test_get_subgraph(monkeypatch, graph, kwargs, exp_subgraph, exp_label_to_taskid): + """Tests the `get_subgraph` function. + + Each test case takes 4 arguments: + + 1. A `TaskGraph` instance. + 2. Keyword arguments to pass into `get_subgraph`. + 3. The expected subgraph. + 4. The expected label_to_taskid. + """ + monkeypatch.setattr( + optimize, "slugid", partial(next, (b"tid%d" % i for i in range(1, 10))) + ) + + kwargs.setdefault("removed_tasks", set()) + kwargs.setdefault("replaced_tasks", set()) + kwargs.setdefault("label_to_taskid", {}) + kwargs.setdefault("decision_task_id", "DECISION-TASK") + + got_subgraph = optimize.get_subgraph(graph, **kwargs) + assert got_subgraph.graph == exp_subgraph.graph + assert got_subgraph.tasks == exp_subgraph.tasks + assert kwargs["label_to_taskid"] == exp_label_to_taskid + + +def test_get_subgraph_removed_dep(): + "get_subgraph raises an Exception when a task depends on a removed task" + graph = make_triangle() + with pytest.raises(Exception): + optimize.get_subgraph(graph, {"t2"}, set(), {}) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_optimize_strategies.py b/taskcluster/taskgraph/test/test_optimize_strategies.py new file mode 100644 index 0000000000..3942cdb5ab --- /dev/null +++ b/taskcluster/taskgraph/test/test_optimize_strategies.py @@ -0,0 +1,511 @@ +# Any copyright is dedicated to the public domain. +# http://creativecommons.org/publicdomain/zero/1.0/ + +from __future__ import absolute_import + +import time +from datetime import datetime +from time import mktime + +import pytest +from mozunit import main + +from taskgraph.optimize import project, registry +from taskgraph.optimize.strategies import SkipUnlessSchedules +from taskgraph.optimize.backstop import SkipUnlessBackstop, SkipUnlessPushInterval +from taskgraph.optimize.bugbug import ( + BugBugPushSchedules, + DisperseGroups, + FALLBACK, + SkipUnlessDebug, +) +from taskgraph.task import Task +from taskgraph.util.backstop import BACKSTOP_PUSH_INTERVAL +from taskgraph.util.bugbug import ( + BUGBUG_BASE_URL, + BugbugTimeoutException, + push_schedules, +) + + +@pytest.fixture(autouse=True) +def clear_push_schedules_memoize(): + push_schedules.clear() + + +@pytest.fixture +def params(): + return { + "branch": "integration/autoland", + "head_repository": "https://hg.mozilla.org/integration/autoland", + "head_rev": "abcdef", + "project": "autoland", + "pushlog_id": 1, + "pushdate": mktime(datetime.now().timetuple()), + } + + +def generate_tasks(*tasks): + for i, task in enumerate(tasks): + task.setdefault("label", "task-{}".format(i)) + task.setdefault("kind", "test") + task.setdefault("task", {}) + task.setdefault("attributes", {}) + task["attributes"].setdefault("e10s", True) + + for attr in ( + "optimization", + "dependencies", + "soft_dependencies", + "release_artifacts", + ): + task.setdefault(attr, None) + + task["task"].setdefault("label", task["label"]) + yield Task.from_json(task) + + +# task sets + +default_tasks = list( + generate_tasks( + {"attributes": {"test_manifests": ["foo/test.ini", "bar/test.ini"]}}, + {"attributes": {"test_manifests": ["bar/test.ini"], "build_type": "debug"}}, + {"attributes": {"build_type": "debug"}}, + {"attributes": {"test_manifests": [], "build_type": "opt"}}, + {"attributes": {"build_type": "opt"}}, + ) +) + + +disperse_tasks = list( + generate_tasks( + { + "attributes": { + "test_manifests": ["foo/test.ini", "bar/test.ini"], + "test_platform": "linux/opt", + } + }, + { + "attributes": { + "test_manifests": ["bar/test.ini"], + "test_platform": "linux/opt", + } + }, + { + "attributes": { + "test_manifests": ["bar/test.ini"], + "test_platform": "windows/debug", + } + }, + { + "attributes": { + "test_manifests": ["bar/test.ini"], + "test_platform": "linux/opt", + "unittest_variant": "fission", + } + }, + { + "attributes": { + "e10s": False, + "test_manifests": ["bar/test.ini"], + "test_platform": "linux/opt", + } + }, + ) +) + + +def idfn(param): + if isinstance(param, tuple): + return param[0].__name__ + return None + + +@pytest.mark.parametrize( + "opt,tasks,arg,expected", + [ + # debug + pytest.param( + SkipUnlessDebug(), + default_tasks, + None, + ["task-0", "task-1", "task-2"], + ), + # disperse with no supplied importance + pytest.param( + DisperseGroups(), + disperse_tasks, + None, + [t.label for t in disperse_tasks], + ), + # disperse with low importance + pytest.param( + DisperseGroups(), + disperse_tasks, + {"bar/test.ini": "low"}, + ["task-0", "task-2"], + ), + # disperse with medium importance + pytest.param( + DisperseGroups(), + disperse_tasks, + {"bar/test.ini": "medium"}, + ["task-0", "task-1", "task-2"], + ), + # disperse with high importance + pytest.param( + DisperseGroups(), + disperse_tasks, + {"bar/test.ini": "high"}, + ["task-0", "task-1", "task-2", "task-3"], + ), + ], + ids=idfn, +) +def test_optimization_strategy(responses, params, opt, tasks, arg, expected): + labels = [t.label for t in tasks if not opt.should_remove_task(t, params, arg)] + assert sorted(labels) == sorted(expected) + + +@pytest.mark.parametrize( + "args,data,expected", + [ + # empty + pytest.param( + (0.1,), + {}, + [], + ), + # only tasks without test manifests selected + pytest.param( + (0.1,), + {"tasks": {"task-1": 0.9, "task-2": 0.1, "task-3": 0.5}}, + ["task-2"], + ), + # tasks which are unknown to bugbug are selected + pytest.param( + (0.1,), + { + "tasks": {"task-1": 0.9, "task-3": 0.5}, + "known_tasks": ["task-1", "task-3", "task-4"], + }, + ["task-2"], + ), + # tasks containing groups selected + pytest.param( + (0.1,), + {"groups": {"foo/test.ini": 0.4}}, + ["task-0"], + ), + # tasks matching "tasks" or "groups" selected + pytest.param( + (0.1,), + { + "tasks": {"task-2": 0.2}, + "groups": {"foo/test.ini": 0.25, "bar/test.ini": 0.75}, + }, + ["task-0", "task-1", "task-2"], + ), + # tasks matching "tasks" or "groups" selected, when they exceed the confidence threshold + pytest.param( + (0.5,), + { + "tasks": {"task-2": 0.2, "task-4": 0.5}, + "groups": {"foo/test.ini": 0.65, "bar/test.ini": 0.25}, + }, + ["task-0", "task-4"], + ), + # tasks matching "reduced_tasks" are selected, when they exceed the confidence threshold + pytest.param( + (0.7, True, True), + { + "tasks": {"task-2": 0.7, "task-4": 0.7}, + "reduced_tasks": {"task-4": 0.7}, + "groups": {"foo/test.ini": 0.75, "bar/test.ini": 0.25}, + }, + ["task-4"], + ), + # tasks matching "groups" selected, only on specific platforms. + pytest.param( + (0.1, False, False, None, 1, True), + { + "tasks": {"task-2": 0.2}, + "groups": {"foo/test.ini": 0.25, "bar/test.ini": 0.75}, + "config_groups": { + "foo/test.ini": ["task-1", "task-0"], + "bar/test.ini": ["task-0"], + }, + }, + ["task-0", "task-2"], + ), + pytest.param( + (0.1, False, False, None, 1, True), + { + "tasks": {"task-2": 0.2}, + "groups": {"foo/test.ini": 0.25, "bar/test.ini": 0.75}, + "config_groups": { + "foo/test.ini": ["task-1", "task-0"], + "bar/test.ini": ["task-1"], + }, + }, + ["task-0", "task-1", "task-2"], + ), + pytest.param( + (0.1, False, False, None, 1, True), + { + "tasks": {"task-2": 0.2}, + "groups": {"foo/test.ini": 0.25, "bar/test.ini": 0.75}, + "config_groups": { + "foo/test.ini": ["task-1"], + "bar/test.ini": ["task-0"], + }, + }, + ["task-0", "task-2"], + ), + pytest.param( + (0.1, False, False, None, 1, True), + { + "tasks": {"task-2": 0.2}, + "groups": {"foo/test.ini": 0.25, "bar/test.ini": 0.75}, + "config_groups": { + "foo/test.ini": ["task-1"], + "bar/test.ini": ["task-3"], + }, + }, + ["task-2"], + ), + ], + ids=idfn, +) +def test_bugbug_push_schedules(responses, params, args, data, expected): + query = "/push/{branch}/{head_rev}/schedules".format(**params) + url = BUGBUG_BASE_URL + query + + responses.add( + responses.GET, + url, + json=data, + status=200, + ) + + opt = BugBugPushSchedules(*args) + labels = [ + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + ] + assert sorted(labels) == sorted(expected) + + +def test_bugbug_multiple_pushes(responses, params): + pushes = {str(pid): {"changesets": ["c{}".format(pid)]} for pid in range(8, 10)} + + responses.add( + responses.GET, + "https://hg.mozilla.org/integration/autoland/json-pushes/?version=2&startID=8&endID=9", + json={"pushes": pushes}, + status=200, + ) + + responses.add( + responses.GET, + BUGBUG_BASE_URL + "/push/{}/c9/schedules".format(params["branch"]), + json={ + "tasks": {"task-2": 0.2, "task-4": 0.5}, + "groups": {"foo/test.ini": 0.2, "bar/test.ini": 0.25}, + "config_groups": {"foo/test.ini": ["linux-*"], "bar/test.ini": ["task-*"]}, + "known_tasks": ["task-4"], + }, + status=200, + ) + + # Tasks with a lower confidence don't override task with a higher one. + # Tasks with a higher confidence override tasks with a lower one. + # Known tasks are merged. + responses.add( + responses.GET, + BUGBUG_BASE_URL + "/push/{branch}/{head_rev}/schedules".format(**params), + json={ + "tasks": {"task-2": 0.2, "task-4": 0.2}, + "groups": {"foo/test.ini": 0.65, "bar/test.ini": 0.25}, + "config_groups": { + "foo/test.ini": ["task-*"], + "bar/test.ini": ["windows-*"], + }, + "known_tasks": ["task-1", "task-3"], + }, + status=200, + ) + + params["pushlog_id"] = 10 + + opt = BugBugPushSchedules(0.3, False, False, False, 2) + labels = [ + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + ] + assert sorted(labels) == sorted(["task-0", "task-2", "task-4"]) + + opt = BugBugPushSchedules(0.3, False, False, False, 2, True) + labels = [ + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + ] + assert sorted(labels) == sorted(["task-0", "task-2", "task-4"]) + + opt = BugBugPushSchedules(0.2, False, False, False, 2, True) + labels = [ + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + ] + assert sorted(labels) == sorted(["task-0", "task-1", "task-2", "task-4"]) + + +def test_bugbug_timeout(monkeypatch, responses, params): + query = "/push/{branch}/{head_rev}/schedules".format(**params) + url = BUGBUG_BASE_URL + query + responses.add( + responses.GET, + url, + json={"ready": False}, + status=202, + ) + + # Make sure the test runs fast. + monkeypatch.setattr(time, "sleep", lambda i: None) + + opt = BugBugPushSchedules(0.5) + with pytest.raises(BugbugTimeoutException): + opt.should_remove_task(default_tasks[0], params, None) + + +def test_bugbug_fallback(monkeypatch, responses, params): + query = "/push/{branch}/{head_rev}/schedules".format(**params) + url = BUGBUG_BASE_URL + query + responses.add( + responses.GET, + url, + json={"ready": False}, + status=202, + ) + + opt = BugBugPushSchedules(0.5, fallback=FALLBACK) + + # Make sure the test runs fast. + monkeypatch.setattr(time, "sleep", lambda i: None) + + def fake_should_remove_task(task, params, _): + return task.label == default_tasks[0].label + + monkeypatch.setattr( + registry[FALLBACK], "should_remove_task", fake_should_remove_task + ) + + assert opt.should_remove_task(default_tasks[0], params, None) + + # Make sure we don't hit bugbug more than once. + responses.reset() + + assert not opt.should_remove_task(default_tasks[1], params, None) + + +def test_backstop(params): + all_labels = {t.label for t in default_tasks} + opt = SkipUnlessBackstop() + + params["backstop"] = False + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == set() + + params["backstop"] = True + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == all_labels + + +def test_push_interval(params): + all_labels = {t.label for t in default_tasks} + opt = SkipUnlessPushInterval(10) # every 10th push + + # Only multiples of 10 schedule tasks. + params["pushlog_id"] = 9 + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == set() + + params["pushlog_id"] = 10 + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == all_labels + + +def test_expanded(params): + all_labels = {t.label for t in default_tasks} + opt = registry["skip-unless-expanded"] + + params["backstop"] = False + params["pushlog_id"] = BACKSTOP_PUSH_INTERVAL / 2 + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == all_labels + + params["pushlog_id"] += 1 + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == set() + + params["backstop"] = True + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, None) + } + assert scheduled == all_labels + + +def test_project_autoland_test(monkeypatch, responses, params): + """Tests the behaviour of the `project.autoland["test"]` strategy on + various types of pushes. + """ + # This is meant to test the composition of substrategies, and not the + # actual optimization implementations. So mock them out for simplicity. + monkeypatch.setattr(SkipUnlessSchedules, "should_remove_task", lambda *args: False) + monkeypatch.setattr(DisperseGroups, "should_remove_task", lambda *args: False) + + def fake_bugbug_should_remove_task(self, task, params, importance): + if self.num_pushes > 1: + return task.label == "task-4" + return task.label in ("task-2", "task-3", "task-4") + + monkeypatch.setattr( + BugBugPushSchedules, "should_remove_task", fake_bugbug_should_remove_task + ) + + opt = project.autoland["test"] + + # On backstop pushes, nothing gets optimized. + params["backstop"] = True + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + } + assert scheduled == {t.label for t in default_tasks} + + # On expanded pushes, some things are optimized. + params["backstop"] = False + params["pushlog_id"] = 10 + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + } + assert scheduled == {"task-0", "task-1", "task-2", "task-3"} + + # On regular pushes, more things are optimized. + params["pushlog_id"] = 11 + scheduled = { + t.label for t in default_tasks if not opt.should_remove_task(t, params, {}) + } + assert scheduled == {"task-0", "task-1"} + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_parameters.py b/taskcluster/taskgraph/test/test_parameters.py new file mode 100644 index 0000000000..ac138642f8 --- /dev/null +++ b/taskcluster/taskgraph/test/test_parameters.py @@ -0,0 +1,170 @@ +# 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 unittest + +from taskgraph.parameters import ( + Parameters, + load_parameters_file, +) +from mozunit import main, MockedOpen + + +class TestParameters(unittest.TestCase): + + vals = { + "app_version": "app_version", + "backstop": False, + "base_repository": "base_repository", + "build_date": 0, + "build_number": 0, + "do_not_optimize": [], + "existing_tasks": {}, + "filters": [], + "head_ref": "head_ref", + "head_repository": "head_repository", + "head_rev": "head_rev", + "hg_branch": "hg_branch", + "level": "level", + "message": "message", + "moz_build_date": "moz_build_date", + "next_version": "next_version", + "optimize_strategies": None, + "optimize_target_tasks": False, + "owner": "owner", + "phabricator_diff": "phabricator_diff", + "project": "project", + "pushdate": 0, + "pushlog_id": "pushlog_id", + "release_enable_emefree": False, + "release_enable_partner_repack": False, + "release_enable_partner_attribution": False, + "release_eta": None, + "release_history": {}, + "release_partners": [], + "release_partner_config": None, + "release_partner_build_number": 1, + "release_type": "release_type", + "release_product": None, + "required_signoffs": [], + "signoff_urls": {}, + "target_tasks_method": "target_tasks_method", + "test_manifest_loader": "default", + "tasks_for": "tasks_for", + "try_mode": "try_mode", + "try_options": None, + "try_task_config": {}, + "version": "version", + } + + def test_Parameters_immutable(self): + p = Parameters(**self.vals) + + def assign(): + p["head_ref"] = 20 + + self.assertRaises(Exception, assign) + + def test_Parameters_missing_KeyError(self): + p = Parameters(**self.vals) + self.assertRaises(KeyError, lambda: p["z"]) + + def test_Parameters_invalid_KeyError(self): + """even if the value is present, if it's not a valid property, raise KeyError""" + p = Parameters(xyz=10, strict=True, **self.vals) + self.assertRaises(Exception, lambda: p.check()) + + def test_Parameters_get(self): + p = Parameters(head_ref=10, level=20) + self.assertEqual(p["head_ref"], 10) + + def test_Parameters_check(self): + p = Parameters(**self.vals) + p.check() # should not raise + + def test_Parameters_check_missing(self): + p = Parameters() + self.assertRaises(Exception, lambda: p.check()) + + p = Parameters(strict=False) + p.check() # should not raise + + def test_Parameters_check_extra(self): + p = Parameters(xyz=10, **self.vals) + self.assertRaises(Exception, lambda: p.check()) + + p = Parameters(strict=False, xyz=10, **self.vals) + p.check() # should not raise + + def test_load_parameters_file_yaml(self): + with MockedOpen({"params.yml": "some: data\n"}): + self.assertEqual(load_parameters_file("params.yml"), {"some": "data"}) + + def test_load_parameters_file_json(self): + with MockedOpen({"params.json": '{"some": "data"}'}): + self.assertEqual(load_parameters_file("params.json"), {"some": "data"}) + + def test_load_parameters_override(self): + """ + When ``load_parameters_file`` is passed overrides, they are included in + the generated parameters. + """ + self.assertEqual( + load_parameters_file("", overrides={"some": "data"}), {"some": "data"} + ) + + def test_load_parameters_override_file(self): + """ + When ``load_parameters_file`` is passed overrides, they overwrite data + loaded from a file. + """ + with MockedOpen({"params.json": '{"some": "data"}'}): + self.assertEqual( + load_parameters_file("params.json", overrides={"some": "other"}), + {"some": "other"}, + ) + + +class TestCommParameters(unittest.TestCase): + vals = dict( + list( + { + "comm_base_repository": "comm_base_repository", + "comm_head_ref": "comm_head_ref", + "comm_head_repository": "comm_head_repository", + "comm_head_rev": "comm_head_rev", + }.items() + ) + + list(TestParameters.vals.items()) + ) + + def test_Parameters_check(self): + """ + Specifying all of the gecko and comm parameters doesn't result in an error. + """ + p = Parameters(**self.vals) + p.check() # should not raise + + def test_Parameters_check_missing(self): + """ + If any of the comm parameters are specified, all of them must be specified. + """ + vals = self.vals.copy() + del vals["comm_base_repository"] + p = Parameters(**vals) + self.assertRaises(Exception, p.check) + + def test_Parameters_check_extra(self): + """ + If parameters other than the global and comm parameters are specified, + an error is reported. + """ + p = Parameters(extra="data", **self.vals) + self.assertRaises(Exception, p.check) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_target_tasks.py b/taskcluster/taskgraph/test/test_target_tasks.py new file mode 100644 index 0000000000..27e0dd8b09 --- /dev/null +++ b/taskcluster/taskgraph/test/test_target_tasks.py @@ -0,0 +1,306 @@ +# 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 contextlib +import re +import unittest + +import pytest +from mozunit import main + +from taskgraph import target_tasks +from taskgraph import try_option_syntax +from taskgraph.graph import Graph +from taskgraph.taskgraph import TaskGraph +from taskgraph.task import Task + + +class FakeTryOptionSyntax(object): + def __init__(self, message, task_graph, graph_config): + self.trigger_tests = 0 + self.talos_trigger_tests = 0 + self.raptor_trigger_tests = 0 + self.notifications = None + self.env = [] + self.profile = False + self.tag = None + self.no_retry = False + + def task_matches(self, task): + return "at-at" in task.attributes + + +class TestTargetTasks(unittest.TestCase): + def default_matches_project(self, run_on_projects, project): + return self.default_matches( + attributes={ + "run_on_projects": run_on_projects, + }, + parameters={ + "project": project, + "hg_branch": "default", + }, + ) + + def default_matches_hg_branch(self, run_on_hg_branches, hg_branch): + attributes = {"run_on_projects": ["all"]} + if run_on_hg_branches is not None: + attributes["run_on_hg_branches"] = run_on_hg_branches + + return self.default_matches( + attributes=attributes, + parameters={ + "project": "mozilla-central", + "hg_branch": hg_branch, + }, + ) + + def default_matches(self, attributes, parameters): + method = target_tasks.get_method("default") + graph = TaskGraph( + tasks={ + "a": Task(kind="build", label="a", attributes=attributes, task={}), + }, + graph=Graph(nodes={"a"}, edges=set()), + ) + return "a" in method(graph, parameters, {}) + + def test_default_all(self): + """run_on_projects=[all] includes release, integration, and other projects""" + self.assertTrue(self.default_matches_project(["all"], "mozilla-central")) + self.assertTrue(self.default_matches_project(["all"], "baobab")) + + def test_default_integration(self): + """run_on_projects=[integration] includes integration projects""" + self.assertFalse( + self.default_matches_project(["integration"], "mozilla-central") + ) + self.assertFalse(self.default_matches_project(["integration"], "baobab")) + + def test_default_release(self): + """run_on_projects=[release] includes release projects""" + self.assertTrue(self.default_matches_project(["release"], "mozilla-central")) + self.assertFalse(self.default_matches_project(["release"], "baobab")) + + def test_default_nothing(self): + """run_on_projects=[] includes nothing""" + self.assertFalse(self.default_matches_project([], "mozilla-central")) + self.assertFalse(self.default_matches_project([], "baobab")) + + def test_default_hg_branch(self): + self.assertTrue(self.default_matches_hg_branch(None, "default")) + self.assertTrue(self.default_matches_hg_branch(None, "GECKOVIEW_62_RELBRANCH")) + + self.assertFalse(self.default_matches_hg_branch([], "default")) + self.assertFalse(self.default_matches_hg_branch([], "GECKOVIEW_62_RELBRANCH")) + + self.assertTrue(self.default_matches_hg_branch(["all"], "default")) + self.assertTrue( + self.default_matches_hg_branch(["all"], "GECKOVIEW_62_RELBRANCH") + ) + + self.assertTrue(self.default_matches_hg_branch(["default"], "default")) + self.assertTrue(self.default_matches_hg_branch([r"default"], "default")) + self.assertFalse( + self.default_matches_hg_branch([r"default"], "GECKOVIEW_62_RELBRANCH") + ) + + self.assertTrue( + self.default_matches_hg_branch( + ["GECKOVIEW_62_RELBRANCH"], "GECKOVIEW_62_RELBRANCH" + ) + ) + self.assertTrue( + self.default_matches_hg_branch( + ["GECKOVIEW_\d+_RELBRANCH"], "GECKOVIEW_62_RELBRANCH" + ) + ) + self.assertTrue( + self.default_matches_hg_branch( + [r"GECKOVIEW_\d+_RELBRANCH"], "GECKOVIEW_62_RELBRANCH" + ) + ) + self.assertFalse( + self.default_matches_hg_branch([r"GECKOVIEW_\d+_RELBRANCH"], "default") + ) + + def make_task_graph(self): + tasks = { + "a": Task(kind=None, label="a", attributes={}, task={}), + "b": Task(kind=None, label="b", attributes={"at-at": "yep"}, task={}), + "c": Task( + kind=None, label="c", attributes={"run_on_projects": ["try"]}, task={} + ), + } + graph = Graph(nodes=set("abc"), edges=set()) + return TaskGraph(tasks, graph) + + @contextlib.contextmanager + def fake_TryOptionSyntax(self): + orig_TryOptionSyntax = try_option_syntax.TryOptionSyntax + try: + try_option_syntax.TryOptionSyntax = FakeTryOptionSyntax + yield + finally: + try_option_syntax.TryOptionSyntax = orig_TryOptionSyntax + + def test_empty_try(self): + "try_mode = None runs nothing" + tg = self.make_task_graph() + method = target_tasks.get_method("try_tasks") + params = { + "try_mode": None, + "project": "try", + "message": "", + } + # only runs the task with run_on_projects: try + self.assertEqual(method(tg, params, {}), []) + + def test_try_option_syntax(self): + "try_mode = try_option_syntax uses TryOptionSyntax" + tg = self.make_task_graph() + method = target_tasks.get_method("try_tasks") + with self.fake_TryOptionSyntax(): + params = { + "try_mode": "try_option_syntax", + "message": "try: -p all", + } + self.assertEqual(method(tg, params, {}), ["b"]) + + def test_try_task_config(self): + "try_mode = try_task_config uses the try config" + tg = self.make_task_graph() + method = target_tasks.get_method("try_tasks") + params = { + "try_mode": "try_task_config", + "try_task_config": {"tasks": ["a"]}, + } + self.assertEqual(method(tg, params, {}), ["a"]) + + +# tests for specific filters + + +@pytest.mark.parametrize( + "name,params,expected", + ( + pytest.param( + "filter_tests_without_manifests", + { + "task": Task(kind="test", label="a", attributes={}, task={}), + "parameters": None, + }, + True, + id="filter_tests_without_manifests_not_in_attributes", + ), + pytest.param( + "filter_tests_without_manifests", + { + "task": Task( + kind="test", + label="a", + attributes={"test_manifests": ["foo"]}, + task={}, + ), + "parameters": None, + }, + True, + id="filter_tests_without_manifests_has_test_manifests", + ), + pytest.param( + "filter_tests_without_manifests", + { + "task": Task( + kind="build", + label="a", + attributes={"test_manifests": None}, + task={}, + ), + "parameters": None, + }, + True, + id="filter_tests_without_manifests_not_a_test", + ), + pytest.param( + "filter_tests_without_manifests", + { + "task": Task( + kind="test", label="a", attributes={"test_manifests": None}, task={} + ), + "parameters": None, + }, + False, + id="filter_tests_without_manifests_has_no_test_manifests", + ), + pytest.param( + "filter_by_regex", + { + "task_label": "build-linux64-debug", + "regexes": [re.compile("build")], + "mode": "include", + }, + True, + id="filter_regex_simple_include", + ), + pytest.param( + "filter_by_regex", + { + "task_label": "build-linux64-debug", + "regexes": [re.compile("linux(.+)debug")], + "mode": "include", + }, + True, + id="filter_regex_re_include", + ), + pytest.param( + "filter_by_regex", + { + "task_label": "build-linux64-debug", + "regexes": [re.compile("nothing"), re.compile("linux(.+)debug")], + "mode": "include", + }, + True, + id="filter_regex_re_include_multiple", + ), + pytest.param( + "filter_by_regex", + { + "task_label": "build-linux64-debug", + "regexes": [re.compile("build")], + "mode": "exclude", + }, + False, + id="filter_regex_simple_exclude", + ), + pytest.param( + "filter_by_regex", + { + "task_label": "build-linux64-debug", + "regexes": [re.compile("linux(.+)debug")], + "mode": "exclude", + }, + False, + id="filter_regex_re_exclude", + ), + pytest.param( + "filter_by_regex", + { + "task_label": "build-linux64-debug", + "regexes": [re.compile("linux(.+)debug"), re.compile("nothing")], + "mode": "exclude", + }, + False, + id="filter_regex_re_exclude_multiple", + ), + ), +) +def test_filters(name, params, expected): + func = getattr(target_tasks, name) + assert func(**params) is expected + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_taskcluster_yml.py b/taskcluster/taskgraph/test/test_taskcluster_yml.py new file mode 100644 index 0000000000..912141970b --- /dev/null +++ b/taskcluster/taskgraph/test/test_taskcluster_yml.py @@ -0,0 +1,121 @@ +# 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, unicode_literals + + +import jsone +import pprint + +import slugid +import unittest + +from mozunit import main + +from taskgraph.util.yaml import load_yaml +from taskgraph.util.time import current_json_time +from taskgraph import GECKO + + +class TestTaskclusterYml(unittest.TestCase): + @property + def taskcluster_yml(self): + return load_yaml(GECKO, ".taskcluster.yml") + + def test_push(self): + context = { + "tasks_for": "hg-push", + "push": { + "revision": "e8d2d9aff5026ef1f1777b781b47fdcbdb9d8f20", + "owner": "dustin@mozilla.com", + "pushlog_id": 1556565286, + "pushdate": 112957, + }, + "repository": { + "url": "https://hg.mozilla.org/mozilla-central", + "project": "mozilla-central", + "level": "3", + }, + "ownTaskId": slugid.nice().decode("ascii"), + } + rendered = jsone.render(self.taskcluster_yml, context) + pprint.pprint(rendered) + self.assertEqual( + rendered["tasks"][0]["metadata"]["name"], "Gecko Decision Task" + ) + + def test_cron(self): + context = { + "tasks_for": "cron", + "repository": { + "url": "https://hg.mozilla.org/mozilla-central", + "project": "mozilla-central", + "level": 3, + }, + "push": { + "revision": "e8aebe488b2f2e567940577de25013d00e818f7c", + "pushlog_id": -1, + "pushdate": 0, + "owner": "cron", + }, + "cron": { + "task_id": "<cron task id>", + "job_name": "test", + "job_symbol": "T", + "quoted_args": "abc def", + }, + "now": current_json_time(), + "ownTaskId": slugid.nice().decode("ascii"), + } + rendered = jsone.render(self.taskcluster_yml, context) + pprint.pprint(rendered) + self.assertEqual( + rendered["tasks"][0]["metadata"]["name"], "Decision Task for cron job test" + ) + + def test_action(self): + context = { + "tasks_for": "action", + "repository": { + "url": "https://hg.mozilla.org/mozilla-central", + "project": "mozilla-central", + "level": 3, + }, + "push": { + "revision": "e8d2d9aff5026ef1f1777b781b47fdcbdb9d8f20", + "owner": "dustin@mozilla.com", + "pushlog_id": 1556565286, + "pushdate": 112957, + }, + "action": { + "name": "test-action", + "title": "Test Action", + "description": "Just testing", + "taskGroupId": slugid.nice().decode("ascii"), + "symbol": "t", + "repo_scope": "assume:repo:hg.mozilla.org/try:action:generic", + "cb_name": "test_action", + }, + "input": {}, + "parameters": {}, + "now": current_json_time(), + "taskId": slugid.nice().decode("ascii"), + "ownTaskId": slugid.nice().decode("ascii"), + "clientId": "testing/testing/testing", + } + rendered = jsone.render(self.taskcluster_yml, context) + pprint.pprint(rendered) + self.assertEqual( + rendered["tasks"][0]["metadata"]["name"], "Action: Test Action" + ) + + def test_unknown(self): + context = {"tasks_for": "bitkeeper-push"} + rendered = jsone.render(self.taskcluster_yml, context) + pprint.pprint(rendered) + self.assertEqual(rendered["tasks"], []) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_taskgraph.py b/taskcluster/taskgraph/test/test_taskgraph.py new file mode 100644 index 0000000000..67966ce61f --- /dev/null +++ b/taskcluster/taskgraph/test/test_taskgraph.py @@ -0,0 +1,127 @@ +# 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 unittest + +from taskgraph.graph import Graph +from taskgraph.task import Task +from taskgraph.taskgraph import TaskGraph +from mozunit import main + + +class TestTaskGraph(unittest.TestCase): + + maxDiff = None + + def test_taskgraph_to_json(self): + tasks = { + "a": Task( + kind="test", + label="a", + description="Task A", + attributes={"attr": "a-task"}, + task={"taskdef": True}, + ), + "b": Task( + kind="test", + label="b", + attributes={}, + task={"task": "def"}, + optimization={"skip-unless-has-relevant-tests": None}, + # note that this dep is ignored, superseded by that + # from the taskgraph's edges + dependencies={"first": "a"}, + ), + } + graph = Graph(nodes=set("ab"), edges={("a", "b", "edgelabel")}) + taskgraph = TaskGraph(tasks, graph) + + res = taskgraph.to_json() + + self.assertEqual( + res, + { + "a": { + "kind": "test", + "label": "a", + "description": "Task A", + "attributes": {"attr": "a-task", "kind": "test"}, + "task": {"taskdef": True}, + "dependencies": {"edgelabel": "b"}, + "soft_dependencies": [], + "if_dependencies": [], + "optimization": None, + }, + "b": { + "kind": "test", + "label": "b", + "description": "", + "attributes": {"kind": "test"}, + "task": {"task": "def"}, + "dependencies": {}, + "soft_dependencies": [], + "if_dependencies": [], + "optimization": {"skip-unless-has-relevant-tests": None}, + }, + }, + ) + + def test_round_trip(self): + graph = TaskGraph( + tasks={ + "a": Task( + kind="fancy", + label="a", + description="Task A", + attributes={}, + dependencies={"prereq": "b"}, # must match edges, below + optimization={"skip-unless-has-relevant-tests": None}, + task={"task": "def"}, + ), + "b": Task( + kind="pre", + label="b", + attributes={}, + dependencies={}, + optimization={"skip-unless-has-relevant-tests": None}, + task={"task": "def2"}, + ), + }, + graph=Graph(nodes={"a", "b"}, edges={("a", "b", "prereq")}), + ) + + tasks, new_graph = TaskGraph.from_json(graph.to_json()) + self.assertEqual(graph, new_graph) + + simple_graph = TaskGraph( + tasks={ + "a": Task( + kind="fancy", + label="a", + attributes={}, + dependencies={"prereq": "b"}, # must match edges, below + optimization={"skip-unless-has-relevant-tests": None}, + task={"task": "def"}, + ), + "b": Task( + kind="pre", + label="b", + attributes={}, + dependencies={}, + optimization={"skip-unless-has-relevant-tests": None}, + task={"task": "def2"}, + ), + }, + graph=Graph(nodes={"a", "b"}, edges={("a", "b", "prereq")}), + ) + + def test_contains(self): + assert "a" in self.simple_graph + assert "c" not in self.simple_graph + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_transforms_base.py b/taskcluster/taskgraph/test/test_transforms_base.py new file mode 100644 index 0000000000..4dc69d7e58 --- /dev/null +++ b/taskcluster/taskgraph/test/test_transforms_base.py @@ -0,0 +1,42 @@ +# 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 unittest +from mozunit import main +from taskgraph.transforms.base import TransformSequence + +transforms = TransformSequence() + + +@transforms.add +def trans1(config, tests): + for test in tests: + test["one"] = 1 + yield test + + +@transforms.add +def trans2(config, tests): + for test in tests: + test["two"] = 2 + yield test + + +class TestTransformSequence(unittest.TestCase): + def test_sequence(self): + tests = [{}, {"two": 1, "second": True}] + res = list(transforms({}, tests)) + self.assertEqual( + res, + [ + {"two": 2, "one": 1}, + {"second": True, "two": 2, "one": 1}, + ], + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_transforms_job.py b/taskcluster/taskgraph/test/test_transforms_job.py new file mode 100644 index 0000000000..3786a3bbbf --- /dev/null +++ b/taskcluster/taskgraph/test/test_transforms_job.py @@ -0,0 +1,101 @@ +# 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/. + +""" +Tests for the 'job' transform subsystem. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import os +from copy import deepcopy + +import pytest +from mozunit import main + +from taskgraph import GECKO +from taskgraph.config import load_graph_config +from taskgraph.transforms import job +from taskgraph.transforms.base import TransformConfig +from taskgraph.transforms.job.common import add_cache +from taskgraph.transforms.task import payload_builders +from taskgraph.util.schema import Schema, validate_schema + +here = os.path.abspath(os.path.dirname(__file__)) + + +TASK_DEFAULTS = { + "description": "fake description", + "label": "fake-task-label", + "run": { + "using": "run-task", + }, +} + + +@pytest.fixture(scope="module") +def config(): + graph_config = load_graph_config(os.path.join(GECKO, "taskcluster", "ci")) + return TransformConfig( + "job_test", here, {}, {}, {}, graph_config, write_artifacts=False + ) + + +@pytest.fixture() +def transform(monkeypatch, config): + """Run the job transforms on the specified task but return the inputs to + `configure_taskdesc_for_run` without executing it. + + This gives test functions an easy way to generate the inputs required for + many of the `run_using` subsystems. + """ + + def inner(task_input): + task = deepcopy(TASK_DEFAULTS) + task.update(task_input) + frozen_args = [] + + def _configure_taskdesc_for_run(*args): + frozen_args.extend(args) + + monkeypatch.setattr( + job, "configure_taskdesc_for_run", _configure_taskdesc_for_run + ) + + for _ in job.transforms(config, [task]): + # This forces the generator to be evaluated + pass + + return frozen_args + + return inner + + +@pytest.mark.parametrize( + "task", + [ + {"worker-type": "b-linux"}, + {"worker-type": "t-win10-64-hw"}, + ], + ids=lambda t: t["worker-type"], +) +def test_worker_caches(task, transform): + config, job, taskdesc, impl = transform(task) + add_cache(job, taskdesc, "cache1", "/cache1") + add_cache(job, taskdesc, "cache2", "/cache2", skip_untrusted=True) + + if impl not in ("docker-worker", "generic-worker"): + pytest.xfail("caches not implemented for '{}'".format(impl)) + + key = "caches" if impl == "docker-worker" else "mounts" + assert key in taskdesc["worker"] + assert len(taskdesc["worker"][key]) == 2 + + # Create a new schema object with just the part relevant to caches. + partial_schema = Schema(payload_builders[impl].schema.schema[key]) + validate_schema(partial_schema, taskdesc["worker"][key], "validation error") + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_try_option_syntax.py b/taskcluster/taskgraph/test/test_try_option_syntax.py new file mode 100644 index 0000000000..30cf5cc877 --- /dev/null +++ b/taskcluster/taskgraph/test/test_try_option_syntax.py @@ -0,0 +1,437 @@ +# 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 unittest + +import six +from taskgraph.try_option_syntax import TryOptionSyntax, parse_message +from taskgraph.graph import Graph +from taskgraph.taskgraph import TaskGraph +from taskgraph.task import Task +from mozunit import main + + +def unittest_task(n, tp, bt="opt"): + return ( + n, + Task( + "test", + n, + { + "unittest_try_name": n, + "test_platform": tp.split("/")[0], + "build_type": bt, + }, + {}, + ), + ) + + +def talos_task(n, tp, bt="opt"): + return ( + n, + Task( + "test", + n, + { + "talos_try_name": n, + "test_platform": tp.split("/")[0], + "build_type": bt, + }, + {}, + ), + ) + + +tasks = { + k: v + for k, v in [ + unittest_task("mochitest-browser-chrome", "linux/opt"), + unittest_task("mochitest-browser-chrome-e10s", "linux64/opt"), + unittest_task("mochitest-chrome", "linux/debug", "debug"), + unittest_task("mochitest-webgl1-core", "linux/debug", "debug"), + unittest_task("mochitest-webgl1-ext", "linux/debug", "debug"), + unittest_task("mochitest-webgl2-core", "linux/debug", "debug"), + unittest_task("mochitest-webgl2-ext", "linux/debug", "debug"), + unittest_task("mochitest-webgl2-deqp", "linux/debug", "debug"), + unittest_task("extra1", "linux", "debug/opt"), + unittest_task("extra2", "win32/opt"), + unittest_task("crashtest-e10s", "linux/other"), + unittest_task("gtest", "linux64/asan"), + talos_task("dromaeojs", "linux64/psan"), + unittest_task("extra3", "linux/opt"), + unittest_task("extra4", "linux64/debug", "debug"), + unittest_task("extra5", "linux/this"), + unittest_task("extra6", "linux/that"), + unittest_task("extra7", "linux/other"), + unittest_task("extra8", "linux64/asan"), + talos_task("extra9", "linux64/psan"), + ] +} + +RIDEALONG_BUILDS = { + "linux": ["linux-ridealong"], + "linux64": ["linux64-ridealong"], +} + +GRAPH_CONFIG = { + "try": {"ridealong-builds": RIDEALONG_BUILDS}, +} + +for r in RIDEALONG_BUILDS.values(): + tasks.update({k: v for k, v in [unittest_task(n + "-test", n) for n in r]}) + +unittest_tasks = { + k: v for k, v in six.iteritems(tasks) if "unittest_try_name" in v.attributes +} +talos_tasks = { + k: v for k, v in six.iteritems(tasks) if "talos_try_name" in v.attributes +} +graph_with_jobs = TaskGraph(tasks, Graph(set(tasks), set())) + + +class TestTryOptionSyntax(unittest.TestCase): + def test_unknown_args(self): + "unknown arguments are ignored" + parameters = parse_message("try: --doubledash -z extra") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + # equilvant to "try:".. + self.assertEqual(tos.build_types, []) + self.assertEqual(tos.jobs, []) + + def test_apostrophe_in_message(self): + "apostrophe does not break parsing" + parameters = parse_message("Increase spammy log's log level. try: -b do") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.build_types), ["debug", "opt"]) + + def test_b_do(self): + "-b do should produce both build_types" + parameters = parse_message("try: -b do") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.build_types), ["debug", "opt"]) + + def test_b_d(self): + "-b d should produce build_types=['debug']" + parameters = parse_message("try: -b d") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.build_types), ["debug"]) + + def test_b_o(self): + "-b o should produce build_types=['opt']" + parameters = parse_message("try: -b o") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.build_types), ["opt"]) + + def test_build_o(self): + "--build o should produce build_types=['opt']" + parameters = parse_message("try: --build o") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.build_types), ["opt"]) + + def test_b_dx(self): + "-b dx should produce build_types=['debug'], silently ignoring the x" + parameters = parse_message("try: -b dx") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.build_types), ["debug"]) + + def test_j_job(self): + "-j somejob sets jobs=['somejob']" + parameters = parse_message("try: -j somejob") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.jobs), ["somejob"]) + + def test_j_jobs(self): + "-j job1,job2 sets jobs=['job1', 'job2']" + parameters = parse_message("try: -j job1,job2") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.jobs), ["job1", "job2"]) + + def test_j_all(self): + "-j all sets jobs=None" + parameters = parse_message("try: -j all") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.jobs, None) + + def test_j_twice(self): + "-j job1 -j job2 sets jobs=job1, job2" + parameters = parse_message("try: -j job1 -j job2") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.jobs), sorted(["job1", "job2"])) + + def test_p_all(self): + "-p all sets platforms=None" + parameters = parse_message("try: -p all") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.platforms, None) + + def test_p_linux(self): + "-p linux sets platforms=['linux', 'linux-ridealong']" + parameters = parse_message("try: -p linux") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.platforms, ["linux", "linux-ridealong"]) + + def test_p_linux_win32(self): + "-p linux,win32 sets platforms=['linux', 'linux-ridealong', 'win32']" + parameters = parse_message("try: -p linux,win32") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(sorted(tos.platforms), ["linux", "linux-ridealong", "win32"]) + + def test_p_expands_ridealongs(self): + "-p linux,linux64 includes the RIDEALONG_BUILDS" + parameters = parse_message("try: -p linux,linux64") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + platforms = set(["linux"] + RIDEALONG_BUILDS["linux"]) + platforms |= set(["linux64"] + RIDEALONG_BUILDS["linux64"]) + self.assertEqual(sorted(tos.platforms), sorted(platforms)) + + def test_u_none(self): + "-u none sets unittests=[]" + parameters = parse_message("try: -u none") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.unittests, []) + + def test_u_all(self): + "-u all sets unittests=[..whole list..]" + parameters = parse_message("try: -u all") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.unittests, [{"test": t} for t in sorted(unittest_tasks)]) + + def test_u_single(self): + "-u mochitest-webgl1-core sets unittests=[mochitest-webgl1-core]" + parameters = parse_message("try: -u mochitest-webgl1-core") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.unittests, [{"test": "mochitest-webgl1-core"}]) + + def test_u_alias(self): + "-u mochitest-gl sets unittests=[mochitest-webgl*]" + parameters = parse_message("try: -u mochitest-gl") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + tos.unittests, + [ + {"test": t} + for t in [ + "mochitest-webgl1-core", + "mochitest-webgl1-ext", + "mochitest-webgl2-core", + "mochitest-webgl2-deqp", + "mochitest-webgl2-ext", + ] + ], + ) + + def test_u_multi_alias(self): + "-u e10s sets unittests=[all e10s unittests]" + parameters = parse_message("try: -u e10s") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + tos.unittests, [{"test": t} for t in sorted(unittest_tasks) if "e10s" in t] + ) + + def test_u_commas(self): + "-u mochitest-webgl1-core,gtest sets unittests=both" + parameters = parse_message("try: -u mochitest-webgl1-core,gtest") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + tos.unittests, + [ + {"test": "gtest"}, + {"test": "mochitest-webgl1-core"}, + ], + ) + + def test_u_chunks(self): + "-u gtest-3,gtest-4 selects the third and fourth chunk of gtest" + parameters = parse_message("try: -u gtest-3,gtest-4") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + sorted(tos.unittests), + sorted( + [ + {"test": "gtest", "only_chunks": set("34")}, + ] + ), + ) + + def test_u_platform(self): + "-u gtest[linux] selects the linux platform for gtest" + parameters = parse_message("try: -u gtest[linux]") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + sorted(tos.unittests), + sorted( + [ + {"test": "gtest", "platforms": ["linux"]}, + ] + ), + ) + + def test_u_platforms(self): + "-u gtest[linux,win32] selects the linux and win32 platforms for gtest" + parameters = parse_message("try: -u gtest[linux,win32]") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + sorted(tos.unittests), + sorted( + [ + {"test": "gtest", "platforms": ["linux", "win32"]}, + ] + ), + ) + + def test_u_platforms_pretty(self): + """-u gtest[Ubuntu] selects the linux, linux64 and linux64-asan + platforms for gtest""" + parameters = parse_message("try: -u gtest[Ubuntu]") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + sorted(tos.unittests), + sorted( + [ + { + "test": "gtest", + "platforms": [ + "linux32", + "linux64", + "linux64-asan", + "linux1804-64", + "linux1804-64-asan", + ], + }, + ] + ), + ) + + def test_u_platforms_negated(self): + "-u gtest[-linux] selects all platforms but linux for gtest" + parameters = parse_message("try: -u gtest[-linux]") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + all_platforms = set( + [x.attributes["test_platform"] for x in unittest_tasks.values()] + ) + self.assertEqual( + sorted(tos.unittests[0]["platforms"]), + sorted([x for x in all_platforms if x != "linux"]), + ) + + def test_u_platforms_negated_pretty(self): + "-u gtest[Ubuntu,-x64] selects just linux for gtest" + parameters = parse_message("try: -u gtest[Ubuntu,-x64]") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + sorted(tos.unittests), + sorted( + [ + {"test": "gtest", "platforms": ["linux32"]}, + ] + ), + ) + + def test_u_chunks_platforms(self): + "-u gtest-1[linux,win32] selects the linux and win32 platforms for chunk 1 of gtest" + parameters = parse_message("try: -u gtest-1[linux,win32]") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual( + tos.unittests, + [ + { + "test": "gtest", + "platforms": ["linux", "win32"], + "only_chunks": set("1"), + }, + ], + ) + + def test_t_none(self): + "-t none sets talos=[]" + parameters = parse_message("try: -t none") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.talos, []) + + def test_t_all(self): + "-t all sets talos=[..whole list..]" + parameters = parse_message("try: -t all") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.talos, [{"test": t} for t in sorted(talos_tasks)]) + + def test_t_single(self): + "-t mochitest-webgl sets talos=[mochitest-webgl]" + parameters = parse_message("try: -t mochitest-webgl") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.talos, [{"test": "mochitest-webgl"}]) + + # -t shares an implementation with -u, so it's not tested heavily + + def test_trigger_tests(self): + "--rebuild 10 sets trigger_tests" + parameters = parse_message("try: --rebuild 10") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.trigger_tests, 10) + + def test_talos_trigger_tests(self): + "--rebuild-talos 10 sets talos_trigger_tests" + parameters = parse_message("try: --rebuild-talos 10") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.talos_trigger_tests, 10) + + def test_interactive(self): + "--interactive sets interactive" + parameters = parse_message("try: --interactive") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.interactive, True) + + def test_all_email(self): + "--all-emails sets notifications" + parameters = parse_message("try: --all-emails") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.notifications, "all") + + def test_fail_email(self): + "--failure-emails sets notifications" + parameters = parse_message("try: --failure-emails") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.notifications, "failure") + + def test_no_email(self): + "no email settings don't set notifications" + parameters = parse_message("try:") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.notifications, None) + + def test_setenv(self): + "--setenv VAR=value adds a environment variables setting to env" + parameters = parse_message("try: --setenv VAR1=value1 --setenv VAR2=value2") + assert parameters["try_task_config"]["env"] == { + "VAR1": "value1", + "VAR2": "value2", + } + + def test_profile(self): + "--gecko-profile sets profile to true" + parameters = parse_message("try: --gecko-profile") + assert parameters["try_task_config"]["gecko-profile"] is True + + def test_tag(self): + "--tag TAG sets tag to TAG value" + parameters = parse_message("try: --tag tagName") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertEqual(tos.tag, "tagName") + + def test_no_retry(self): + "--no-retry sets no_retry to true" + parameters = parse_message("try: --no-retry") + tos = TryOptionSyntax(parameters, graph_with_jobs, GRAPH_CONFIG) + self.assertTrue(tos.no_retry) + + def test_artifact(self): + "--artifact sets artifact to true" + parameters = parse_message("try: --artifact") + assert parameters["try_task_config"]["use-artifact-builds"] is True + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_attributes.py b/taskcluster/taskgraph/test/test_util_attributes.py new file mode 100644 index 0000000000..0f645fad8e --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_attributes.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- + +# 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 unittest +from taskgraph.util.attributes import ( + attrmatch, + match_run_on_projects, +) +from mozunit import main + + +class Attrmatch(unittest.TestCase): + def test_trivial_match(self): + """Given no conditions, anything matches""" + self.assertTrue(attrmatch({})) + + def test_missing_attribute(self): + """If a filtering attribute is not present, no match""" + self.assertFalse(attrmatch({}, someattr=10)) + + def test_literal_attribute(self): + """Literal attributes must match exactly""" + self.assertTrue(attrmatch({"att": 10}, att=10)) + self.assertFalse(attrmatch({"att": 10}, att=20)) + + def test_set_attribute(self): + """Set attributes require set membership""" + self.assertTrue(attrmatch({"att": 10}, att=set([9, 10]))) + self.assertFalse(attrmatch({"att": 10}, att=set([19, 20]))) + + def test_callable_attribute(self): + """Callable attributes are called and any False causes the match to fail""" + self.assertTrue(attrmatch({"att": 10}, att=lambda val: True)) + self.assertFalse(attrmatch({"att": 10}, att=lambda val: False)) + + def even(val): + return val % 2 == 0 + + self.assertTrue(attrmatch({"att": 10}, att=even)) + self.assertFalse(attrmatch({"att": 11}, att=even)) + + def test_all_matches_required(self): + """If only one attribute does not match, the result is False""" + self.assertFalse(attrmatch({"a": 1}, a=1, b=2, c=3)) + self.assertFalse(attrmatch({"a": 1, "b": 2}, a=1, b=2, c=3)) + self.assertTrue(attrmatch({"a": 1, "b": 2, "c": 3}, a=1, b=2, c=3)) + + +class MatchRunOnProjects(unittest.TestCase): + def test_empty(self): + self.assertFalse(match_run_on_projects("birch", [])) + + def test_all(self): + self.assertTrue(match_run_on_projects("birch", ["all"])) + self.assertTrue(match_run_on_projects("larch", ["all"])) + self.assertTrue(match_run_on_projects("autoland", ["all"])) + self.assertTrue(match_run_on_projects("mozilla-central", ["all"])) + self.assertTrue(match_run_on_projects("mozilla-beta", ["all"])) + self.assertTrue(match_run_on_projects("mozilla-release", ["all"])) + + def test_release(self): + self.assertFalse(match_run_on_projects("birch", ["release"])) + self.assertFalse(match_run_on_projects("larch", ["release"])) + self.assertFalse(match_run_on_projects("autoland", ["release"])) + self.assertTrue(match_run_on_projects("mozilla-central", ["release"])) + self.assertTrue(match_run_on_projects("mozilla-beta", ["release"])) + self.assertTrue(match_run_on_projects("mozilla-release", ["release"])) + + def test_integration(self): + self.assertFalse(match_run_on_projects("birch", ["integration"])) + self.assertFalse(match_run_on_projects("larch", ["integration"])) + self.assertTrue(match_run_on_projects("autoland", ["integration"])) + self.assertFalse(match_run_on_projects("mozilla-central", ["integration"])) + self.assertFalse(match_run_on_projects("mozilla-beta", ["integration"])) + self.assertFalse(match_run_on_projects("mozilla-integration", ["integration"])) + + def test_combo(self): + self.assertTrue(match_run_on_projects("birch", ["release", "birch", "maple"])) + self.assertFalse(match_run_on_projects("larch", ["release", "birch", "maple"])) + self.assertTrue(match_run_on_projects("maple", ["release", "birch", "maple"])) + self.assertFalse( + match_run_on_projects("autoland", ["release", "birch", "maple"]) + ) + self.assertTrue( + match_run_on_projects("mozilla-central", ["release", "birch", "maple"]) + ) + self.assertTrue( + match_run_on_projects("mozilla-beta", ["release", "birch", "maple"]) + ) + self.assertTrue( + match_run_on_projects("mozilla-release", ["release", "birch", "maple"]) + ) + self.assertTrue(match_run_on_projects("birch", ["birch", "trunk"])) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_backstop.py b/taskcluster/taskgraph/test/test_util_backstop.py new file mode 100644 index 0000000000..ec069303a1 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_backstop.py @@ -0,0 +1,156 @@ +# 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 + +from datetime import datetime +from textwrap import dedent +from time import mktime + +import pytest +from mozunit import main + +from taskgraph.util.backstop import ( + is_backstop, + BACKSTOP_INDEX, + BACKSTOP_PUSH_INTERVAL, + BACKSTOP_TIME_INTERVAL, +) +from taskgraph.util.taskcluster import ( + get_artifact_url, + get_index_url, + get_task_url, +) + +LAST_BACKSTOP_ID = 0 +LAST_BACKSTOP_PUSHDATE = mktime(datetime.now().timetuple()) +DEFAULT_RESPONSES = { + "index": { + "status": 200, + "json": {"taskId": LAST_BACKSTOP_ID}, + }, + "artifact": { + "status": 200, + "body": dedent( + """ + pushdate: {} + """.format( + LAST_BACKSTOP_PUSHDATE + ) + ), + }, + "status": { + "status": 200, + "json": {"status": {"state": "complete"}}, + }, +} + + +@pytest.fixture +def params(): + return { + "branch": "integration/autoland", + "head_repository": "https://hg.mozilla.org/integration/autoland", + "head_rev": "abcdef", + "project": "autoland", + "pushdate": LAST_BACKSTOP_PUSHDATE + 1, + "pushlog_id": LAST_BACKSTOP_ID + 1, + } + + +@pytest.mark.parametrize( + "response_args,extra_params,expected", + ( + pytest.param( + { + "index": {"status": 404}, + }, + {"pushlog_id": 1}, + True, + id="no previous backstop", + ), + pytest.param( + { + "index": DEFAULT_RESPONSES["index"], + "status": DEFAULT_RESPONSES["status"], + "artifact": {"status": 404}, + }, + {"pushlog_id": 1}, + False, + id="previous backstop not finished", + ), + pytest.param( + DEFAULT_RESPONSES, + { + "pushlog_id": LAST_BACKSTOP_ID + 1, + "pushdate": LAST_BACKSTOP_PUSHDATE + 1, + }, + False, + id="not a backstop", + ), + pytest.param( + {}, + { + "pushlog_id": BACKSTOP_PUSH_INTERVAL, + }, + True, + id="backstop interval", + ), + pytest.param( + DEFAULT_RESPONSES, + { + "pushdate": LAST_BACKSTOP_PUSHDATE + (BACKSTOP_TIME_INTERVAL * 60), + }, + True, + id="time elapsed", + ), + pytest.param( + {}, + { + "project": "try", + "pushlog_id": BACKSTOP_PUSH_INTERVAL, + }, + False, + id="try not a backstop", + ), + pytest.param( + {}, + { + "project": "mozilla-central", + }, + True, + id="release branches always a backstop", + ), + pytest.param( + { + "index": DEFAULT_RESPONSES["index"], + "status": { + "status": 200, + "json": {"status": {"state": "failed"}}, + }, + }, + {}, + True, + id="last backstop failed", + ), + ), +) +def test_is_backstop(responses, params, response_args, extra_params, expected): + urls = { + "index": get_index_url(BACKSTOP_INDEX.format(project=params["project"])), + "artifact": get_artifact_url(LAST_BACKSTOP_ID, "public/parameters.yml"), + "status": get_task_url(LAST_BACKSTOP_ID) + "/status", + } + + for key in ("index", "status", "artifact"): + if key in response_args: + print(urls[key]) + responses.add(responses.GET, urls[key], **response_args[key]) + + params.update(extra_params) + assert is_backstop(params) is expected + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_bugbug.py b/taskcluster/taskgraph/test/test_util_bugbug.py new file mode 100644 index 0000000000..923f8072c6 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_bugbug.py @@ -0,0 +1,61 @@ +# 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 mozunit + +from taskgraph.util.bugbug import ( + BUGBUG_BASE_URL, + push_schedules, +) + + +def test_group_translation(responses): + branch = ("integration/autoland",) + rev = "abcdef" + query = "/push/{}/{}/schedules".format(branch, rev) + url = BUGBUG_BASE_URL + query + + responses.add( + responses.GET, + url, + json={ + "groups": { + "dom/indexedDB": 1, + "testing/web-platform/tests/IndexedDB": 1, + "testing/web-platform/mozilla/tests/IndexedDB": 1, + }, + "config_groups": { + "dom/indexedDB": ["label1", "label2"], + "testing/web-platform/tests/IndexedDB": ["label3"], + "testing/web-platform/mozilla/tests/IndexedDB": ["label4"], + }, + }, + status=200, + ) + + assert len(push_schedules) == 0 + data = push_schedules(branch, rev) + print(data) + assert sorted(data["groups"]) == [ + "/IndexedDB", + "/_mozilla/IndexedDB", + "dom/indexedDB", + ] + assert data["config_groups"] == { + "dom/indexedDB": ["label1", "label2"], + "/IndexedDB": ["label3"], + "/_mozilla/IndexedDB": ["label4"], + } + assert len(push_schedules) == 1 + + # Value is memoized. + responses.reset() + push_schedules(branch, rev) + assert len(push_schedules) == 1 + + +if __name__ == "__main__": + mozunit.main() diff --git a/taskcluster/taskgraph/test/test_util_chunking.py b/taskcluster/taskgraph/test/test_util_chunking.py new file mode 100644 index 0000000000..bb1424e836 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_chunking.py @@ -0,0 +1,392 @@ +# 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, division, print_function, unicode_literals + +import re + +from itertools import combinations +from six.moves import range + +import pytest + +from mozunit import main +from taskgraph.util import chunking + + +pytestmark = pytest.mark.slow + + +@pytest.fixture(scope="module") +def mock_manifest_runtimes(): + """Deterministically produce a list of simulated manifest runtimes. + + Args: + manifests (list): list of manifests against which simulated manifest + runtimes would be paired up to. + + Returns: + dict of manifest data paired with a float value representing runtime. + """ + + def inner(manifests): + manifests = sorted(manifests) + # Generate deterministic runtime data. + runtimes = [(i / 10) ** (i / 10) for i in range(len(manifests))] + return dict(zip(manifests, runtimes)) + + return inner + + +@pytest.fixture(scope="module") +def unchunked_manifests(): + """Produce a list of unchunked manifests to be consumed by test method. + + Args: + length (int, optional): number of path elements to keep. + cutoff (int, optional): number of generated test paths to remove + from the test set if user wants to limit the number of paths. + + Returns: + list: list of test paths. + """ + data = ["blueberry", "nashi", "peach", "watermelon"] + + def inner(suite, length=2, cutoff=0): + if "web-platform" in suite: + suffix = "" + prefix = "/" + elif "reftest" in suite: + suffix = ".list" + prefix = "" + else: + suffix = ".ini" + prefix = "" + return [prefix + "/".join(p) + suffix for p in combinations(data, length)][ + cutoff: + ] + + return inner + + +@pytest.fixture(scope="module") +def mock_task_definition(): + """Builds a mock task definition for use in testing. + + Args: + platform (str): represents the build platform. + bits (int): software bits. + build_type (str): opt or debug. + suite (str): name of the unittest suite. + test_platform (str, optional): full name of the platform and major version. + variant (str, optional): specify fission or vanilla. + + Returns: + dict: mocked task definition. + """ + + def inner(platform, bits, build_type, suite, test_platform="", variant=""): + bits = str(bits) + test_variant = [suite, "e10s"] + if "fis" in variant: + test_variant.insert(1, "fis") + output = { + "build-attributes": { + "build_platform": platform + bits, + "build_type": build_type, + }, + "attributes": {"e10s": True, "unittest_variant": variant}, + "test-name": "-".join(test_variant), + "test-platform": "".join([test_platform, "-", bits, "/", build_type]), + } + return output + + return inner + + +@pytest.fixture(scope="module") +def mock_mozinfo(): + """Returns a mocked mozinfo object, similar to guess_mozinfo_from_task(). + + Args: + os (str): typically one of 'win, linux, mac, android'. + processor (str): processor architecture. + asan (bool, optional): addressanitizer build. + bits (int, optional): defaults to 64. + ccov (bool, optional): code coverage build. + debug (bool, optional): debug type build. + fission (bool, optional): process fission. + headless (bool, optional): headless browser testing without displays. + tsan (bool, optional): threadsanitizer build. + + Returns: + dict: Dictionary mimickign the results from guess_mozinfo_from_task. + """ + + def inner( + os, + processor, + asan=False, + bits=64, + ccov=False, + debug=False, + fission=False, + headless=False, + tsan=False, + ): + return { + "os": os, + "processor": processor, + "toolkit": "", + "asan": asan, + "bits": bits, + "ccov": ccov, + "debug": debug, + "e10s": True, + "fission": fission, + "headless": headless, + "tsan": tsan, + "webrender": False, + "appname": "firefox", + } + + return inner + + +@pytest.mark.parametrize( + "params,exception", + [ + [("win", 32, "opt", "web-platform-tests", "", ""), None], + [("win", 64, "opt", "web-platform-tests", "", ""), None], + [("linux", 64, "debug", "mochitest-plain", "", ""), None], + [("mac", 64, "debug", "mochitest-plain", "", ""), None], + [("mac", 64, "opt", "mochitest-plain-headless", "", ""), None], + [("android", 64, "debug", "xpcshell", "", ""), None], + [("and", 64, "debug", "awsy", "", ""), ValueError], + [("", 64, "opt", "", "", ""), ValueError], + [("linux-ccov", 64, "opt", "", "", ""), None], + [("linux-asan", 64, "opt", "", "", ""), None], + [("win-tsan", 64, "opt", "", "", ""), None], + [("mac-ccov", 64, "opt", "", "", ""), None], + [("android", 64, "opt", "crashtest", "arm64", "fission"), None], + [("win-aarch64", 64, "opt", "crashtest", "", ""), None], + ], +) +def test_guess_mozinfo_from_task(params, exception, mock_task_definition): + """Tests the mozinfo guessing process.""" + # Set up a mocked task object. + task = mock_task_definition(*params) + + if exception: + with pytest.raises(exception): + result = chunking.guess_mozinfo_from_task(task) + else: + expected_toolkits = { + "android": "android", + "linux": "gtk", + "mac": "cocoa", + "win": "windows", + } + result = chunking.guess_mozinfo_from_task(task) + + assert result["bits"] == (32 if "32" in task["test-platform"] else 64) + assert result["os"] in ("android", "linux", "mac", "win") + assert result["os"] in task["build-attributes"]["build_platform"] + assert result["toolkit"] == expected_toolkits[result["os"]] + + # Ensure the outcome of special build variants being present match what + # guess_mozinfo_from_task method returns for these attributes. + assert ("asan" in task["build-attributes"]["build_platform"]) == result["asan"] + assert ("tsan" in task["build-attributes"]["build_platform"]) == result["tsan"] + assert ("ccov" in task["build-attributes"]["build_platform"]) == result["ccov"] + + assert result["fission"] == any(task["attributes"]["unittest_variant"]) + assert result["e10s"] + + +@pytest.mark.parametrize("platform", ["unix", "windows", "android"]) +@pytest.mark.parametrize( + "suite", ["crashtest", "reftest", "web-platform-tests", "xpcshell"] +) +def test_get_runtimes(platform, suite): + """Tests that runtime information is returned for known good configurations.""" + assert chunking.get_runtimes(platform, suite) + + +@pytest.mark.parametrize( + "platform,suite,exception", + [ + ("nonexistent_platform", "nonexistent_suite", KeyError), + ("unix", "nonexistent_suite", KeyError), + ("unix", "", TypeError), + ("", "", TypeError), + ("", "nonexistent_suite", TypeError), + ], +) +def test_get_runtimes_invalid(platform, suite, exception): + """Ensure get_runtimes() method raises an exception if improper request is made.""" + with pytest.raises(exception): + chunking.get_runtimes(platform, suite) + + +@pytest.mark.parametrize( + "suite", + [ + "web-platform-tests", + "web-platform-tests-reftest", + "web-platform-tests-wdspec", + "web-platform-tests-crashtest", + ], +) +@pytest.mark.parametrize("chunks", [1, 3, 6, 20]) +def test_mock_chunk_manifests_wpt(unchunked_manifests, suite, chunks): + """Tests web-platform-tests and its subsuites chunking process.""" + # Setup. + manifests = unchunked_manifests(suite) + + # Generate the expected results, by generating list of indices that each + # manifest should go into and then appending each item to that index. + # This method is intentionally different from the way chunking.py performs + # chunking for cross-checking. + expected = [[] for _ in range(chunks)] + indexed = zip(manifests, list(range(0, chunks)) * len(manifests)) + for i in indexed: + expected[i[1]].append(i[0]) + + # Call the method under test on unchunked manifests. + chunked_manifests = chunking.chunk_manifests(suite, "unix", chunks, manifests) + + # Assertions and end test. + assert chunked_manifests + if chunks > len(manifests): + # If chunk count exceeds number of manifests, not all chunks will have + # manifests. + with pytest.raises(AssertionError): + assert all(chunked_manifests) + else: + assert all(chunked_manifests) + minimum = min([len(c) for c in chunked_manifests]) + maximum = max([len(c) for c in chunked_manifests]) + assert maximum - minimum <= 1 + assert expected == chunked_manifests + + +@pytest.mark.parametrize( + "suite", + [ + "mochitest-devtools-chrome", + "mochitest-browser-chrome", + "mochitest-plain", + "mochitest-chrome", + "xpcshell", + ], +) +@pytest.mark.parametrize("chunks", [1, 3, 6, 20]) +def test_mock_chunk_manifests( + mock_manifest_runtimes, unchunked_manifests, suite, chunks +): + """Tests non-WPT tests and its subsuites chunking process.""" + # Setup. + manifests = unchunked_manifests(suite) + + # Call the method under test on unchunked manifests. + chunked_manifests = chunking.chunk_manifests(suite, "unix", chunks, manifests) + + # Assertions and end test. + assert chunked_manifests + if chunks > len(manifests): + # If chunk count exceeds number of manifests, not all chunks will have + # manifests. + with pytest.raises(AssertionError): + assert all(chunked_manifests) + else: + assert all(chunked_manifests) + + +@pytest.mark.parametrize( + "suite", + [ + "web-platform-tests", + "web-platform-tests-reftest", + "xpcshell", + "mochitest-plain", + "mochitest-devtools-chrome", + "mochitest-browser-chrome", + "mochitest-chrome", + ], +) +@pytest.mark.parametrize( + "platform", + [ + ("mac", "x86_64"), + ("win", "x86_64"), + ("win", "x86"), + ("win", "aarch64"), + ("linux", "x86_64"), + ("linux", "x86"), + ], +) +def test_get_manifests(suite, platform, mock_mozinfo): + """Tests the DefaultLoader class' ability to load manifests.""" + mozinfo = mock_mozinfo(*platform) + + loader = chunking.DefaultLoader([]) + manifests = loader.get_manifests(suite, frozenset(mozinfo.items())) + + assert manifests + assert manifests["active"] + if "web-platform" in suite: + assert manifests["skipped"] == [] + else: + assert manifests["skipped"] + + items = manifests["active"] + if suite == "xpcshell": + assert all([re.search(r"xpcshell(.*)?.ini", m) for m in items]) + if "mochitest" in suite: + assert all([re.search(r"(mochitest|chrome|browser).*.ini", m) for m in items]) + if "web-platform" in suite: + assert all([m.startswith("/") and m.count("/") <= 4 for m in items]) + + +@pytest.mark.parametrize( + "suite", + [ + "mochitest-devtools-chrome", + "mochitest-browser-chrome", + "mochitest-plain", + "mochitest-chrome", + "web-platform-tests", + "web-platform-tests-reftest", + "xpcshell", + ], +) +@pytest.mark.parametrize( + "platform", + [ + ("mac", "x86_64"), + ("win", "x86_64"), + ("linux", "x86_64"), + ], +) +@pytest.mark.parametrize("chunks", [1, 3, 6, 20]) +def test_chunk_manifests(suite, platform, chunks, mock_mozinfo): + """Tests chunking with real manifests.""" + mozinfo = mock_mozinfo(*platform) + + loader = chunking.DefaultLoader([]) + manifests = loader.get_manifests(suite, frozenset(mozinfo.items())) + + chunked_manifests = chunking.chunk_manifests( + suite, platform, chunks, manifests["active"] + ) + + # Assertions and end test. + assert chunked_manifests + assert len(chunked_manifests) == chunks + assert all(chunked_manifests) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_docker.py b/taskcluster/taskgraph/test/test_util_docker.py new file mode 100644 index 0000000000..1a81d9b9ef --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_docker.py @@ -0,0 +1,241 @@ +# 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 shutil +import stat +import tarfile +import tempfile +import unittest +import mock +import taskcluster_urls as liburls + +from taskgraph.util import docker +from mozunit import main, MockedOpen + + +MODE_STANDARD = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH + + +@mock.patch.dict("os.environ", {"TASKCLUSTER_ROOT_URL": liburls.test_root_url()}) +class TestDocker(unittest.TestCase): + def test_generate_context_hash(self): + tmpdir = tempfile.mkdtemp() + try: + os.makedirs(os.path.join(tmpdir, "docker", "my-image")) + p = os.path.join(tmpdir, "docker", "my-image", "Dockerfile") + with open(p, "w") as f: + f.write("FROM node\nADD a-file\n") + os.chmod(p, MODE_STANDARD) + p = os.path.join(tmpdir, "docker", "my-image", "a-file") + with open(p, "w") as f: + f.write("data\n") + os.chmod(p, MODE_STANDARD) + self.assertEqual( + docker.generate_context_hash( + tmpdir, + os.path.join(tmpdir, "docker/my-image"), + "my-image", + {}, + ), + "680532a33c845e3b4f8ea8a7bd697da579b647f28c29f7a0a71e51e6cca33983", + ) + finally: + shutil.rmtree(tmpdir) + + def test_docker_image_explicit_registry(self): + files = {} + files["{}/myimage/REGISTRY".format(docker.IMAGE_DIR)] = "cool-images" + files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3" + files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..." + with MockedOpen(files): + self.assertEqual( + docker.docker_image("myimage"), "cool-images/myimage@sha256:434..." + ) + + def test_docker_image_explicit_registry_by_tag(self): + files = {} + files["{}/myimage/REGISTRY".format(docker.IMAGE_DIR)] = "myreg" + files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3" + files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..." + with MockedOpen(files): + self.assertEqual( + docker.docker_image("myimage", by_tag=True), "myreg/myimage:1.2.3" + ) + + def test_docker_image_default_registry(self): + files = {} + files["{}/REGISTRY".format(docker.IMAGE_DIR)] = "mozilla" + files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3" + files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..." + with MockedOpen(files): + self.assertEqual( + docker.docker_image("myimage"), "mozilla/myimage@sha256:434..." + ) + + def test_docker_image_default_registry_by_tag(self): + files = {} + files["{}/REGISTRY".format(docker.IMAGE_DIR)] = "mozilla" + files["{}/myimage/VERSION".format(docker.IMAGE_DIR)] = "1.2.3" + files["{}/myimage/HASH".format(docker.IMAGE_DIR)] = "sha256:434..." + with MockedOpen(files): + self.assertEqual( + docker.docker_image("myimage", by_tag=True), "mozilla/myimage:1.2.3" + ) + + def test_create_context_tar_basic(self): + tmp = tempfile.mkdtemp() + try: + d = os.path.join(tmp, "test_image") + os.mkdir(d) + with open(os.path.join(d, "Dockerfile"), "a"): + pass + os.chmod(os.path.join(d, "Dockerfile"), MODE_STANDARD) + + with open(os.path.join(d, "extra"), "a"): + pass + os.chmod(os.path.join(d, "extra"), MODE_STANDARD) + + tp = os.path.join(tmp, "tar") + h = docker.create_context_tar(tmp, d, tp, "my_image", {}) + self.assertEqual( + h, "eae3ad00936085eb3e5958912f79fb06ee8e14a91f7157c5f38625f7ddacb9c7" + ) + + # File prefix should be "my_image" + with tarfile.open(tp, "r:gz") as tf: + self.assertEqual( + tf.getnames(), + [ + "Dockerfile", + "extra", + ], + ) + finally: + shutil.rmtree(tmp) + + def test_create_context_topsrcdir_files(self): + tmp = tempfile.mkdtemp() + try: + d = os.path.join(tmp, "test-image") + os.mkdir(d) + with open(os.path.join(d, "Dockerfile"), "wb") as fh: + fh.write(b"# %include extra/file0\n") + os.chmod(os.path.join(d, "Dockerfile"), MODE_STANDARD) + + extra = os.path.join(tmp, "extra") + os.mkdir(extra) + with open(os.path.join(extra, "file0"), "a"): + pass + os.chmod(os.path.join(extra, "file0"), MODE_STANDARD) + + tp = os.path.join(tmp, "tar") + h = docker.create_context_tar(tmp, d, tp, "test_image", {}) + self.assertEqual( + h, "49dc3827530cd344d7bcc52e1fdd4aefc632568cf442cffd3dd9633a58f271bf" + ) + + with tarfile.open(tp, "r:gz") as tf: + self.assertEqual( + tf.getnames(), + [ + "Dockerfile", + "topsrcdir/extra/file0", + ], + ) + finally: + shutil.rmtree(tmp) + + def test_create_context_absolute_path(self): + tmp = tempfile.mkdtemp() + try: + d = os.path.join(tmp, "test-image") + os.mkdir(d) + + # Absolute paths in %include syntax are not allowed. + with open(os.path.join(d, "Dockerfile"), "wb") as fh: + fh.write(b"# %include /etc/shadow\n") + + with self.assertRaisesRegexp(Exception, "cannot be absolute"): + docker.create_context_tar(tmp, d, os.path.join(tmp, "tar"), "test", {}) + finally: + shutil.rmtree(tmp) + + def test_create_context_outside_topsrcdir(self): + tmp = tempfile.mkdtemp() + try: + d = os.path.join(tmp, "test-image") + os.mkdir(d) + + with open(os.path.join(d, "Dockerfile"), "wb") as fh: + fh.write(b"# %include foo/../../../etc/shadow\n") + + with self.assertRaisesRegexp(Exception, "path outside topsrcdir"): + docker.create_context_tar(tmp, d, os.path.join(tmp, "tar"), "test", {}) + finally: + shutil.rmtree(tmp) + + def test_create_context_missing_extra(self): + tmp = tempfile.mkdtemp() + try: + d = os.path.join(tmp, "test-image") + os.mkdir(d) + + with open(os.path.join(d, "Dockerfile"), "wb") as fh: + fh.write(b"# %include does/not/exist\n") + + with self.assertRaisesRegexp(Exception, "path does not exist"): + docker.create_context_tar(tmp, d, os.path.join(tmp, "tar"), "test", {}) + finally: + shutil.rmtree(tmp) + + def test_create_context_extra_directory(self): + tmp = tempfile.mkdtemp() + try: + d = os.path.join(tmp, "test-image") + os.mkdir(d) + + with open(os.path.join(d, "Dockerfile"), "wb") as fh: + fh.write(b"# %include extra\n") + fh.write(b"# %include file0\n") + os.chmod(os.path.join(d, "Dockerfile"), MODE_STANDARD) + + extra = os.path.join(tmp, "extra") + os.mkdir(extra) + for i in range(3): + p = os.path.join(extra, "file%d" % i) + with open(p, "wb") as fh: + fh.write(b"file%d" % i) + os.chmod(p, MODE_STANDARD) + + with open(os.path.join(tmp, "file0"), "a"): + pass + os.chmod(os.path.join(tmp, "file0"), MODE_STANDARD) + + tp = os.path.join(tmp, "tar") + h = docker.create_context_tar(tmp, d, tp, "my_image", {}) + + self.assertEqual( + h, "a392f23cd6606ae43116390a4d0113354cff1e688a41d46f48b0fb25e90baa13" + ) + + with tarfile.open(tp, "r:gz") as tf: + self.assertEqual( + tf.getnames(), + [ + "Dockerfile", + "topsrcdir/extra/file0", + "topsrcdir/extra/file1", + "topsrcdir/extra/file2", + "topsrcdir/file0", + ], + ) + finally: + shutil.rmtree(tmp) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_parameterization.py b/taskcluster/taskgraph/test/test_util_parameterization.py new file mode 100644 index 0000000000..73b6a98b5e --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_parameterization.py @@ -0,0 +1,232 @@ +# 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 unittest +import datetime +import mock +import os + +from mozunit import main +from taskgraph.util.parameterization import ( + resolve_timestamps, + resolve_task_references, +) + + +class TestTimestamps(unittest.TestCase): + def test_no_change(self): + now = datetime.datetime(2018, 1, 1) + input = { + "key": "value", + "numeric": 10, + "list": ["a", True, False, None], + } + self.assertEqual(resolve_timestamps(now, input), input) + + def test_buried_replacement(self): + now = datetime.datetime(2018, 1, 1) + input = {"key": [{"key2": [{"relative-datestamp": "1 day"}]}]} + self.assertEqual( + resolve_timestamps(now, input), + {"key": [{"key2": ["2018-01-02T00:00:00Z"]}]}, + ) + + def test_appears_with_other_keys(self): + now = datetime.datetime(2018, 1, 1) + input = [{"relative-datestamp": "1 day", "another-key": True}] + self.assertEqual( + resolve_timestamps(now, input), + [{"relative-datestamp": "1 day", "another-key": True}], + ) + + +class TestTaskRefs(unittest.TestCase): + def do(self, input, output): + taskid_for_edge_name = {"edge%d" % n: "tid%d" % n for n in range(1, 4)} + self.assertEqual( + resolve_task_references( + "subject", + input, + "tid-self", + "tid-decision", + taskid_for_edge_name, + ), + output, + ) + + def test_no_change(self): + "resolve_task_references does nothing when there are no task references" + self.do( + {"in-a-list": ["stuff", {"property": "<edge1>"}]}, + {"in-a-list": ["stuff", {"property": "<edge1>"}]}, + ) + + def test_in_list(self): + "resolve_task_references resolves task references in a list" + self.do( + {"in-a-list": ["stuff", {"task-reference": "<edge1>"}]}, + {"in-a-list": ["stuff", "tid1"]}, + ) + + def test_in_dict(self): + "resolve_task_references resolves task references in a dict" + self.do( + {"in-a-dict": {"stuff": {"task-reference": "<edge2>"}}}, + {"in-a-dict": {"stuff": "tid2"}}, + ) + + def test_multiple(self): + "resolve_task_references resolves multiple references in the same string" + self.do( + {"multiple": {"task-reference": "stuff <edge1> stuff <edge2> after"}}, + {"multiple": "stuff tid1 stuff tid2 after"}, + ) + + def test_embedded(self): + "resolve_task_references resolves ebmedded references" + self.do( + {"embedded": {"task-reference": "stuff before <edge3> stuff after"}}, + {"embedded": "stuff before tid3 stuff after"}, + ) + + def test_escaping(self): + "resolve_task_references resolves escapes in task references" + self.do({"escape": {"task-reference": "<<><edge3>>"}}, {"escape": "<tid3>"}) + + def test_multikey(self): + "resolve_task_references is ignored when there is another key in the dict" + self.do( + {"escape": {"task-reference": "<edge3>", "another-key": True}}, + {"escape": {"task-reference": "<edge3>", "another-key": True}}, + ) + + def test_self(self): + "resolve_task_references resolves `self` to the provided task id" + self.do({"escape": {"task-reference": "<self>"}}, {"escape": "tid-self"}) + + def test_decision(self): + "resolve_task_references resolves `decision` to the provided decision task id" + self.do( + {"escape": {"task-reference": "<decision>"}}, {"escape": "tid-decision"} + ) + + def test_invalid(self): + "resolve_task_references raises a KeyError on reference to an invalid task" + self.assertRaisesRegexp( + KeyError, + "task 'subject' has no dependency named 'no-such'", + lambda: resolve_task_references( + "subject", + {"task-reference": "<no-such>"}, + "tid-self", + "tid-decision", + {}, + ), + ) + + +class TestArtifactRefs(unittest.TestCase): + def do(self, input, output): + taskid_for_edge_name = {"edge%d" % n: "tid%d" % n for n in range(1, 4)} + with mock.patch.dict( + os.environ, {"TASKCLUSTER_ROOT_URL": "https://tc-tests.localhost"} + ): + self.assertEqual( + resolve_task_references( + "subject", input, "tid-self", "tid-decision", taskid_for_edge_name + ), + output, + ) + + def test_in_list(self): + "resolve_task_references resolves artifact references in a list" + self.do( + {"in-a-list": ["stuff", {"artifact-reference": "<edge1/public/foo/bar>"}]}, + { + "in-a-list": [ + "stuff", + "https://tc-tests.localhost/api/queue/v1" + "/task/tid1/artifacts/public/foo/bar", + ] + }, + ) + + def test_in_dict(self): + "resolve_task_references resolves artifact references in a dict" + self.do( + {"in-a-dict": {"stuff": {"artifact-reference": "<edge2/public/bar/foo>"}}}, + { + "in-a-dict": { + "stuff": "https://tc-tests.localhost/api/queue/v1" + "/task/tid2/artifacts/public/bar/foo" + } + }, + ) + + def test_in_string(self): + "resolve_task_references resolves artifact references embedded in a string" + self.do( + { + "stuff": { + "artifact-reference": "<edge1/public/filename> and <edge2/public/bar>" + } + }, + { + "stuff": "https://tc-tests.localhost/api/queue/v1" + "/task/tid1/artifacts/public/filename and " + "https://tc-tests.localhost/api/queue/v1/task/tid2/artifacts/public/bar" + }, + ) + + def test_self(self): + "resolve_task_references raises KeyError on artifact references to `self`" + self.assertRaisesRegexp( + KeyError, + "task 'subject' can't reference artifacts of self", + lambda: resolve_task_references( + "subject", + {"artifact-reference": "<self/public/artifact>"}, + "tid-self", + "tid-decision", + {}, + ), + ) + + def test_decision(self): + "resolve_task_references resolves `decision` to the provided decision task id" + self.do( + {"stuff": {"artifact-reference": "<decision/public/artifact>"}}, + { + "stuff": "https://tc-tests.localhost/api/queue/v1/task/tid-decision/" + "artifacts/public/artifact" + }, + ) + + def test_invalid(self): + "resolve_task_references raises a KeyError on reference to an invalid task" + self.assertRaisesRegexp( + KeyError, + "task 'subject' has no dependency named 'no-such'", + lambda: resolve_task_references( + "subject", + {"artifact-reference": "<no-such/public/artifact>"}, + "tid-self", + "tid-decision", + {}, + ), + ) + + def test_badly_formed(self): + "resolve_task_references ignores badly-formatted artifact references" + for inv in ["<edge1>", "edge1/foo>", "<edge1>/foo", "<edge1>foo"]: + resolved = resolve_task_references( + "subject", {"artifact-reference": inv}, "tid-self", "tid-decision", {} + ) + self.assertEqual(resolved, inv) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_python_path.py b/taskcluster/taskgraph/test/test_util_python_path.py new file mode 100644 index 0000000000..2c5f415b31 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_python_path.py @@ -0,0 +1,43 @@ +# 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 unittest +from taskgraph.util import python_path +from mozunit import main + + +class TestObject(object): + + testClassProperty = object() + + +class TestPythonPath(unittest.TestCase): + def test_find_object_no_such_module(self): + """find_object raises ImportError for a nonexistent module""" + self.assertRaises( + ImportError, python_path.find_object, "no_such_module:someobj" + ) + + def test_find_object_no_such_object(self): + """find_object raises AttributeError for a nonexistent object""" + self.assertRaises( + AttributeError, + python_path.find_object, + "taskgraph.test.test_util_python_path:NoSuchObject", + ) + + def test_find_object_exists(self): + """find_object finds an existing object""" + from taskgraph.test.test_util_python_path import TestObject + + obj = python_path.find_object( + "taskgraph.test.test_util_python_path:TestObject.testClassProperty" + ) + self.assertIs(obj, TestObject.testClassProperty) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_runnable_jobs.py b/taskcluster/taskgraph/test/test_util_runnable_jobs.py new file mode 100644 index 0000000000..38be7e29c6 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_runnable_jobs.py @@ -0,0 +1,76 @@ +# 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 + +import unittest + +from taskgraph.decision import full_task_graph_to_runnable_jobs +from taskgraph.graph import Graph +from taskgraph.taskgraph import TaskGraph +from taskgraph.task import Task +from mozunit import main + + +class TestRunnableJobs(unittest.TestCase): + + tasks = [ + { + "kind": "build", + "label": "a", + "attributes": {}, + "task": { + "extra": {"treeherder": {"symbol": "B"}}, + }, + }, + { + "kind": "test", + "label": "b", + "attributes": {}, + "task": { + "extra": { + "treeherder": { + "collection": {"opt": True}, + "groupName": "Some group", + "groupSymbol": "GS", + "machine": {"platform": "linux64"}, + "symbol": "t", + } + }, + }, + }, + ] + + def make_taskgraph(self, tasks): + label_to_taskid = {k: k + "-tid" for k in tasks} + for label, task_id in label_to_taskid.items(): + tasks[label].task_id = task_id + graph = Graph(nodes=set(tasks), edges=set()) + taskgraph = TaskGraph(tasks, graph) + return taskgraph, label_to_taskid + + def test_taskgraph_to_runnable_jobs(self): + tg, label_to_taskid = self.make_taskgraph( + {t["label"]: Task(**t) for t in self.tasks[:]} + ) + + res = full_task_graph_to_runnable_jobs(tg.to_json()) + + self.assertEqual( + res, + { + "a": {"symbol": "B"}, + "b": { + "collection": {"opt": True}, + "groupName": "Some group", + "groupSymbol": "GS", + "symbol": "t", + "platform": "linux64", + }, + }, + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_schema.py b/taskcluster/taskgraph/test/test_util_schema.py new file mode 100644 index 0000000000..854f027d45 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_schema.py @@ -0,0 +1,206 @@ +# 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 + +from six import text_type +import unittest +from mozunit import main +from taskgraph.util.schema import ( + validate_schema, + resolve_keyed_by, + Schema, +) + +schema = Schema( + { + "x": int, + "y": text_type, + } +) + + +class TestValidateSchema(unittest.TestCase): + def test_valid(self): + validate_schema(schema, {"x": 10, "y": "foo"}, "pfx") + + def test_invalid(self): + try: + validate_schema(schema, {"x": "not-int"}, "pfx") + self.fail("no exception raised") + except Exception as e: + self.assertTrue(str(e).startswith("pfx\n")) + + +class TestCheckSchema(unittest.TestCase): + def test_schema(self): + "Creating a schema applies taskgraph checks." + with self.assertRaises(Exception): + Schema({"camelCase": int}) + + def test_extend_schema(self): + "Extending a schema applies taskgraph checks." + with self.assertRaises(Exception): + Schema({"kebab-case": int}).extend({"camelCase": int}) + + def test_extend_schema_twice(self): + "Extending a schema twice applies taskgraph checks." + with self.assertRaises(Exception): + Schema({"kebab-case": int}).extend({"more-kebab": int}).extend( + {"camelCase": int} + ) + + +class TestResolveKeyedBy(unittest.TestCase): + def test_no_by(self): + self.assertEqual(resolve_keyed_by({"x": 10}, "z", "n"), {"x": 10}) + + def test_no_by_dotted(self): + self.assertEqual( + resolve_keyed_by({"x": {"y": 10}}, "x.z", "n"), {"x": {"y": 10}} + ) + + def test_no_by_not_dict(self): + self.assertEqual(resolve_keyed_by({"x": 10}, "x.y", "n"), {"x": 10}) + + def test_no_by_not_by(self): + self.assertEqual(resolve_keyed_by({"x": {"a": 10}}, "x", "n"), {"x": {"a": 10}}) + + def test_nested(self): + x = { + "by-foo": { + "F1": { + "by-bar": { + "B1": 11, + "B2": 12, + }, + }, + "F2": 20, + "default": 0, + }, + } + self.assertEqual( + resolve_keyed_by({"x": x}, "x", "x", foo="F1", bar="B1"), {"x": 11} + ) + self.assertEqual( + resolve_keyed_by({"x": x}, "x", "x", foo="F1", bar="B2"), {"x": 12} + ) + self.assertEqual(resolve_keyed_by({"x": x}, "x", "x", foo="F2"), {"x": 20}) + self.assertEqual( + resolve_keyed_by({"x": x}, "x", "x", foo="F99", bar="B1"), {"x": 0} + ) + + # bar is deferred + self.assertEqual( + resolve_keyed_by({"x": x}, "x", "x", defer=["bar"], foo="F1", bar="B1"), + {"x": {"by-bar": {"B1": 11, "B2": 12}}}, + ) + + def test_no_by_empty_dict(self): + self.assertEqual(resolve_keyed_by({"x": {}}, "x", "n"), {"x": {}}) + + def test_no_by_not_only_by(self): + self.assertEqual( + resolve_keyed_by({"x": {"by-y": True, "a": 10}}, "x", "n"), + {"x": {"by-y": True, "a": 10}}, + ) + + def test_match_nested_exact(self): + self.assertEqual( + resolve_keyed_by( + { + "f": "shoes", + "x": {"y": {"by-f": {"shoes": "feet", "gloves": "hands"}}}, + }, + "x.y", + "n", + ), + {"f": "shoes", "x": {"y": "feet"}}, + ) + + def test_match_regexp(self): + self.assertEqual( + resolve_keyed_by( + { + "f": "shoes", + "x": {"by-f": {"s?[hH]oes?": "feet", "gloves": "hands"}}, + }, + "x", + "n", + ), + {"f": "shoes", "x": "feet"}, + ) + + def test_match_partial_regexp(self): + self.assertEqual( + resolve_keyed_by( + {"f": "shoes", "x": {"by-f": {"sh": "feet", "default": "hands"}}}, + "x", + "n", + ), + {"f": "shoes", "x": "hands"}, + ) + + def test_match_default(self): + self.assertEqual( + resolve_keyed_by( + {"f": "shoes", "x": {"by-f": {"hat": "head", "default": "anywhere"}}}, + "x", + "n", + ), + {"f": "shoes", "x": "anywhere"}, + ) + + def test_match_extra_value(self): + self.assertEqual( + resolve_keyed_by({"f": {"by-foo": {"x": 10, "y": 20}}}, "f", "n", foo="y"), + {"f": 20}, + ) + + def test_no_match(self): + self.assertRaises( + Exception, + resolve_keyed_by, + {"f": "shoes", "x": {"by-f": {"hat": "head"}}}, + "x", + "n", + ) + + def test_multiple_matches(self): + self.assertRaises( + Exception, + resolve_keyed_by, + {"f": "hats", "x": {"by-f": {"hat.*": "head", "ha.*": "hair"}}}, + "x", + "n", + ) + + def test_no_key_no_default(self): + """ + When the key referenced in `by-*` doesn't exist, and there is not default value, + an exception is raised. + """ + self.assertRaises( + Exception, + resolve_keyed_by, + {"x": {"by-f": {"hat.*": "head", "ha.*": "hair"}}}, + "x", + "n", + ) + + def test_no_key(self): + """ + When the key referenced in `by-*` doesn't exist, and there is a default value, + that value is used as the result. + """ + self.assertEqual( + resolve_keyed_by( + {"x": {"by-f": {"hat": "head", "default": "anywhere"}}}, "x", "n" + ), + {"x": "anywhere"}, + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_taskcluster.py b/taskcluster/taskgraph/test/test_util_taskcluster.py new file mode 100644 index 0000000000..1619176a5c --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_taskcluster.py @@ -0,0 +1,21 @@ +# 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 datetime +import unittest + +import mozunit +from taskgraph.util.taskcluster import parse_time + + +class TestTCUtils(unittest.TestCase): + def test_parse_time(self): + exp = datetime.datetime(2018, 10, 10, 18, 33, 3, 463000) + assert parse_time("2018-10-10T18:33:03.463Z") == exp + + +if __name__ == "__main__": + mozunit.main() diff --git a/taskcluster/taskgraph/test/test_util_templates.py b/taskcluster/taskgraph/test/test_util_templates.py new file mode 100644 index 0000000000..afbdfa2f31 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_templates.py @@ -0,0 +1,78 @@ +# 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 unittest +import mozunit +from taskgraph.util.templates import merge_to, merge + + +class MergeTest(unittest.TestCase): + def test_merge_to_dicts(self): + source = {"a": 1, "b": 2} + dest = {"b": "20", "c": 30} + expected = { + "a": 1, # source only + "b": 2, # source overrides dest + "c": 30, # dest only + } + self.assertEqual(merge_to(source, dest), expected) + self.assertEqual(dest, expected) + + def test_merge_to_lists(self): + source = {"x": [3, 4]} + dest = {"x": [1, 2]} + expected = {"x": [1, 2, 3, 4]} # dest first + self.assertEqual(merge_to(source, dest), expected) + self.assertEqual(dest, expected) + + def test_merge_diff_types(self): + source = {"x": [1, 2]} + dest = {"x": "abc"} + expected = {"x": [1, 2]} # source wins + self.assertEqual(merge_to(source, dest), expected) + self.assertEqual(dest, expected) + + def test_merge(self): + first = {"a": 1, "b": 2, "d": 11} + second = {"b": 20, "c": 30} + third = {"c": 300, "d": 400} + expected = { + "a": 1, + "b": 20, + "c": 300, + "d": 400, + } + self.assertEqual(merge(first, second, third), expected) + + # inputs haven't changed.. + self.assertEqual(first, {"a": 1, "b": 2, "d": 11}) + self.assertEqual(second, {"b": 20, "c": 30}) + self.assertEqual(third, {"c": 300, "d": 400}) + + def test_merge_by(self): + source = { + "x": "abc", + "y": {"by-foo": {"quick": "fox", "default": ["a", "b", "c"]}}, + } + dest = {"y": {"by-foo": {"purple": "rain", "default": ["x", "y", "z"]}}} + expected = { + "x": "abc", + "y": {"by-foo": {"quick": "fox", "default": ["a", "b", "c"]}}, + } # source wins + self.assertEqual(merge_to(source, dest), expected) + self.assertEqual(dest, expected) + + def test_merge_multiple_by(self): + source = {"x": {"by-foo": {"quick": "fox", "default": ["a", "b", "c"]}}} + dest = {"x": {"by-bar": {"purple": "rain", "default": ["x", "y", "z"]}}} + expected = { + "x": {"by-foo": {"quick": "fox", "default": ["a", "b", "c"]}} + } # source wins + self.assertEqual(merge_to(source, dest), expected) + + +if __name__ == "__main__": + mozunit.main() diff --git a/taskcluster/taskgraph/test/test_util_time.py b/taskcluster/taskgraph/test/test_util_time.py new file mode 100644 index 0000000000..4d84a30c08 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_time.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +# 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 unittest +import mozunit +from datetime import datetime +from taskgraph.util.time import ( + InvalidString, + UnknownTimeMeasurement, + value_of, + json_time_from_now, +) + + +class FromNowTest(unittest.TestCase): + def test_invalid_str(self): + with self.assertRaises(InvalidString): + value_of("wtfs") + + def test_missing_unit(self): + with self.assertRaises(InvalidString): + value_of("1") + + def test_missing_unknown_unit(self): + with self.assertRaises(UnknownTimeMeasurement): + value_of("1z") + + def test_value_of(self): + self.assertEqual(value_of("1s").total_seconds(), 1) + self.assertEqual(value_of("1 second").total_seconds(), 1) + self.assertEqual(value_of("1min").total_seconds(), 60) + self.assertEqual(value_of("1h").total_seconds(), 3600) + self.assertEqual(value_of("1d").total_seconds(), 86400) + self.assertEqual(value_of("1mo").total_seconds(), 2592000) + self.assertEqual(value_of("1 month").total_seconds(), 2592000) + self.assertEqual(value_of("1y").total_seconds(), 31536000) + + with self.assertRaises(UnknownTimeMeasurement): + value_of("1m").total_seconds() # ambiguous between minute and month + + def test_json_from_now_utc_now(self): + # Just here to ensure we don't raise. + json_time_from_now("1 years") + + def test_json_from_now(self): + now = datetime(2014, 1, 1) + self.assertEqual(json_time_from_now("1 years", now), "2015-01-01T00:00:00Z") + self.assertEqual(json_time_from_now("6 days", now), "2014-01-07T00:00:00Z") + + +if __name__ == "__main__": + mozunit.main() diff --git a/taskcluster/taskgraph/test/test_util_treeherder.py b/taskcluster/taskgraph/test/test_util_treeherder.py new file mode 100644 index 0000000000..d72f8f9784 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_treeherder.py @@ -0,0 +1,33 @@ +# 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 unittest +from taskgraph.util.treeherder import split_symbol, join_symbol, add_suffix +from mozunit import main + + +class TestSymbols(unittest.TestCase): + def test_split_no_group(self): + self.assertEqual(split_symbol("xy"), ("?", "xy")) + + def test_split_with_group(self): + self.assertEqual(split_symbol("ab(xy)"), ("ab", "xy")) + + def test_join_no_group(self): + self.assertEqual(join_symbol("?", "xy"), "xy") + + def test_join_with_group(self): + self.assertEqual(join_symbol("ab", "xy"), "ab(xy)") + + def test_add_suffix_no_group(self): + self.assertEqual(add_suffix("xy", 1), "xy1") + + def test_add_suffix_with_group(self): + self.assertEqual(add_suffix("ab(xy)", 1), "ab(xy1)") + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_verify.py b/taskcluster/taskgraph/test/test_util_verify.py new file mode 100644 index 0000000000..d86a1d76f1 --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_verify.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# 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/. +""" +There are some basic tests run as part of the Decision task to make sure +documentation exists for taskgraph functionality. +These functions are defined in taskgraph.generator and call +taskgraph.util.verify.verify_docs with different parameters to do the +actual checking. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import os.path + +import pytest +import taskgraph.util.verify +from taskgraph.util.verify import DocPaths, verify_docs +from taskgraph import GECKO +from mozunit import main + +FF_DOCS_BASE = os.path.join(GECKO, "taskcluster", "docs") +EXTRA_DOCS_BASE = os.path.abspath(os.path.join(os.path.dirname(__file__), "docs")) + + +@pytest.fixture +def mock_single_doc_path(monkeypatch): + """Set a single path containing documentation""" + mocked_documentation_paths = DocPaths() + mocked_documentation_paths.add(FF_DOCS_BASE) + monkeypatch.setattr( + taskgraph.util.verify, "documentation_paths", mocked_documentation_paths + ) + + +@pytest.fixture +def mock_two_doc_paths(monkeypatch): + """Set two paths containing documentation""" + mocked_documentation_paths = DocPaths() + mocked_documentation_paths.add(FF_DOCS_BASE) + mocked_documentation_paths.add(EXTRA_DOCS_BASE) + monkeypatch.setattr( + taskgraph.util.verify, "documentation_paths", mocked_documentation_paths + ) + + +@pytest.mark.usefixtures("mock_single_doc_path") +class PyTestSingleDocPath: + """ + Taskcluster documentation for Firefox is in a single directory. Check the tests + running at build time to make sure documentation exists, actually work themselves. + """ + + def test_heading(self): + """ + Look for a headings in filename matching identifiers. This is used when making sure + documentation exists for kinds and attributes. + """ + verify_docs( + filename="kinds.rst", + identifiers=["build", "packages", "toolchain"], + appearing_as="heading", + ) + with pytest.raises(Exception, match="missing from doc file"): + verify_docs( + filename="kinds.rst", + identifiers=["build", "packages", "badvalue"], + appearing_as="heading", + ) + + def test_inline_literal(self): + """ + Look for inline-literals in filename. Used when checking documentation for decision + task parameters and run-using functions. + """ + verify_docs( + filename="parameters.rst", + identifiers=["base_repository", "head_repository", "owner"], + appearing_as="inline-literal", + ) + with pytest.raises(Exception, match="missing from doc file"): + verify_docs( + filename="parameters.rst", + identifiers=["base_repository", "head_repository", "badvalue"], + appearing_as="inline-literal", + ) + + +@pytest.mark.usefixtures("mock_two_doc_paths") +class PyTestTwoDocPaths: + """ + Thunderbird extends Firefox's taskgraph with additional kinds. The documentation + for Thunderbird kinds are in its repository, and documentation_paths will have + two places to look for files. Run the same tests as for a single documentation + path, and cover additional possible scenarios. + """ + + def test_heading(self): + """ + Look for a headings in filename matching identifiers. This is used when + making sure documentation exists for kinds and attributes. + The first test looks for headings that are all within the first doc path, + the second test is new and has a heading found in the second path. + The final check has a identifier that will not match and should + produce an error. + """ + verify_docs( + filename="kinds.rst", + identifiers=["build", "packages", "toolchain"], + appearing_as="heading", + ) + verify_docs( + filename="kinds.rst", + identifiers=["build", "packages", "newkind"], + appearing_as="heading", + ) + with pytest.raises(Exception, match="missing from doc file"): + verify_docs( + filename="kinds.rst", + identifiers=["build", "packages", "badvalue"], + appearing_as="heading", + ) + + def test_inline_literal(self): + """ + Look for inline-literals in filename. Used when checking documentation for decision + task parameters and run-using functions. As with the heading tests, + the second check looks for an identifier in the added documentation path. + """ + verify_docs( + filename="parameters.rst", + identifiers=["base_repository", "head_repository", "owner"], + appearing_as="inline-literal", + ) + verify_docs( + filename="parameters.rst", + identifiers=["base_repository", "head_repository", "newparameter"], + appearing_as="inline-literal", + ) + with pytest.raises(Exception, match="missing from doc file"): + verify_docs( + filename="parameters.rst", + identifiers=["base_repository", "head_repository", "badvalue"], + appearing_as="inline-literal", + ) + + +if __name__ == "__main__": + main() diff --git a/taskcluster/taskgraph/test/test_util_yaml.py b/taskcluster/taskgraph/test/test_util_yaml.py new file mode 100644 index 0000000000..5034db2d7c --- /dev/null +++ b/taskcluster/taskgraph/test/test_util_yaml.py @@ -0,0 +1,27 @@ +# 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 unittest + +from taskgraph.util import yaml +from mozunit import main, MockedOpen + +FOO_YML = """\ +prop: + - val1 +""" + + +class TestYaml(unittest.TestCase): + def test_load(self): + with MockedOpen({"/dir1/dir2/foo.yml": FOO_YML}): + self.assertEqual( + yaml.load_yaml("/dir1/dir2", "foo.yml"), {"prop": ["val1"]} + ) + + +if __name__ == "__main__": + main() |