summaryrefslogtreecommitdiffstats
path: root/taskcluster/taskgraph/test
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /taskcluster/taskgraph/test
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--taskcluster/taskgraph/test/__init__.py0
-rw-r--r--taskcluster/taskgraph/test/automationrelevance.json425
-rw-r--r--taskcluster/taskgraph/test/conftest.py52
-rw-r--r--taskcluster/taskgraph/test/docs/kinds.rst12
-rw-r--r--taskcluster/taskgraph/test/docs/parameters.rst14
-rw-r--r--taskcluster/taskgraph/test/python.ini34
-rw-r--r--taskcluster/taskgraph/test/test_actions_util.py182
-rw-r--r--taskcluster/taskgraph/test/test_create.py116
-rw-r--r--taskcluster/taskgraph/test/test_decision.py148
-rw-r--r--taskcluster/taskgraph/test/test_files_changed.py89
-rw-r--r--taskcluster/taskgraph/test/test_generator.py262
-rw-r--r--taskcluster/taskgraph/test/test_graph.py224
-rw-r--r--taskcluster/taskgraph/test/test_morph.py111
-rw-r--r--taskcluster/taskgraph/test/test_optimize.py476
-rw-r--r--taskcluster/taskgraph/test/test_optimize_strategies.py511
-rw-r--r--taskcluster/taskgraph/test/test_parameters.py170
-rw-r--r--taskcluster/taskgraph/test/test_target_tasks.py306
-rw-r--r--taskcluster/taskgraph/test/test_taskcluster_yml.py121
-rw-r--r--taskcluster/taskgraph/test/test_taskgraph.py127
-rw-r--r--taskcluster/taskgraph/test/test_transforms_base.py42
-rw-r--r--taskcluster/taskgraph/test/test_transforms_job.py101
-rw-r--r--taskcluster/taskgraph/test/test_try_option_syntax.py437
-rw-r--r--taskcluster/taskgraph/test/test_util_attributes.py102
-rw-r--r--taskcluster/taskgraph/test/test_util_backstop.py156
-rw-r--r--taskcluster/taskgraph/test/test_util_bugbug.py61
-rw-r--r--taskcluster/taskgraph/test/test_util_chunking.py392
-rw-r--r--taskcluster/taskgraph/test/test_util_docker.py241
-rw-r--r--taskcluster/taskgraph/test/test_util_parameterization.py232
-rw-r--r--taskcluster/taskgraph/test/test_util_python_path.py43
-rw-r--r--taskcluster/taskgraph/test/test_util_runnable_jobs.py76
-rw-r--r--taskcluster/taskgraph/test/test_util_schema.py206
-rw-r--r--taskcluster/taskgraph/test/test_util_taskcluster.py21
-rw-r--r--taskcluster/taskgraph/test/test_util_templates.py78
-rw-r--r--taskcluster/taskgraph/test/test_util_time.py57
-rw-r--r--taskcluster/taskgraph/test/test_util_treeherder.py33
-rw-r--r--taskcluster/taskgraph/test/test_util_verify.py150
-rw-r--r--taskcluster/taskgraph/test/test_util_yaml.py27
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()