Bug 1281004: Specify test tasks more flexibly; r=gps; r=gbrown draft
authorDustin J. Mitchell <dustin@mozilla.com>
Mon, 11 Jul 2016 23:27:14 +0000
changeset 389205 03e70902c2d3a297eb9e3ce852f8737c2550d5a6
parent 389204 8190d2f6f2368acd1c9c11a3e16062e4589a23ac
child 389206 d8e7730649df6303bc2f6783ba3f1095ace73361
push id23312
push userdmitchell@mozilla.com
push dateMon, 18 Jul 2016 17:58:50 +0000
reviewersgps, gbrown
bugs1281004
milestone50.0a1
Bug 1281004: Specify test tasks more flexibly; r=gps; r=gbrown This introduces a completely new way of specifying test task in-tree, completely replacing the old spider-web of YAML files. The high-level view is this: - some configuration files are used to determine which test suites to run for each test platform, and against which build platforms - each test suite is then represented by a dictionary, and modified by a sequence of transforms, duplicating as necessary (e.g., chunks), until it becomes a task definition The transforms allow sufficient generality to support just about any desired configuration, with the advantage that common configurations are "easy" while unusual configurations are supported but notable for their oddness (they require a custom transform). As of this commit, this system produces the same set of test graphs as the existing YAML, modulo: - extra.treeherder.groupName -- this was not consistent in the YAML - extra.treeherder.build -- this is ignored by taskcluster-treeherder anyway - mozharness command argument order - boolean True values for environment variables are now the string "true" - metadata -- this is now much more consistent, with task name being the label Testing of this commit demonstrates that it produces the same set of test tasks for the following projects (those which had special cases defined in the YAML): - autoland - ash (*) - willow - mozilla-inbound - mozilla-central - try: -b do -p all -t all -u all -b d -p linux64,linux64-asan -u reftest -t none -b d -p linux64,linux64-asan -u reftest[x64] -t none[x64] (*) this patch omits the linux64/debug tc-M-e10s(dt) test, which is enabled on ash; ash will require a small changeset to re-enable this test. MozReview-Commit-ID: G34dg9f17Hq
taskcluster/ci/android-test/kind.yml
taskcluster/ci/android-test/test-platforms.yml
taskcluster/ci/android-test/test-sets.yml
taskcluster/ci/android-test/tests.yml
taskcluster/ci/desktop-test/kind.yml
taskcluster/ci/desktop-test/test-platforms.yml
taskcluster/ci/desktop-test/test-sets.yml
taskcluster/ci/desktop-test/tests.yml
taskcluster/ci/docker-image/kind.yml
taskcluster/ci/legacy/kind.yml
taskcluster/docs/attributes.rst
taskcluster/docs/how-tos.rst
taskcluster/docs/index.rst
taskcluster/docs/kinds.rst
taskcluster/docs/old.rst
taskcluster/docs/taskgraph.rst
taskcluster/docs/transforms.rst
taskcluster/docs/yaml-templates.rst
taskcluster/taskgraph/create.py
taskcluster/taskgraph/decision.py
taskcluster/taskgraph/generator.py
taskcluster/taskgraph/kind/__init__.py
taskcluster/taskgraph/kind/base.py
taskcluster/taskgraph/kind/docker_image.py
taskcluster/taskgraph/kind/legacy.py
taskcluster/taskgraph/target_tasks.py
taskcluster/taskgraph/task/__init__.py
taskcluster/taskgraph/task/base.py
taskcluster/taskgraph/task/docker_image.py
taskcluster/taskgraph/task/legacy.py
taskcluster/taskgraph/task/test.py
taskcluster/taskgraph/test/test_generator.py
taskcluster/taskgraph/test/test_kind_docker_image.py
taskcluster/taskgraph/test/test_kind_legacy.py
taskcluster/taskgraph/test/test_task_docker_image.py
taskcluster/taskgraph/test/test_task_legacy.py
taskcluster/taskgraph/test/test_taskgraph.py
taskcluster/taskgraph/test/test_transforms_base.py
taskcluster/taskgraph/test/test_util_attributes.py
taskcluster/taskgraph/test/test_util_treeherder.py
taskcluster/taskgraph/test/util.py
taskcluster/taskgraph/transforms/__init__.py
taskcluster/taskgraph/transforms/base.py
taskcluster/taskgraph/transforms/make_task.py
taskcluster/taskgraph/transforms/tests/__init__.py
taskcluster/taskgraph/transforms/tests/all_kinds.py
taskcluster/taskgraph/transforms/tests/android_test.py
taskcluster/taskgraph/transforms/tests/desktop_test.py
taskcluster/taskgraph/transforms/tests/make_task_description.py
taskcluster/taskgraph/transforms/tests/test_description.py
taskcluster/taskgraph/try_option_syntax.py
taskcluster/taskgraph/util/attributes.py
taskcluster/taskgraph/util/treeherder.py
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/android-test/kind.yml
@@ -0,0 +1,12 @@
+implementation: taskgraph.task.test:TestTask
+
+kind-dependencies:
+    - legacy
+
+transforms:
+   - taskgraph.transforms.tests.test_description:validate
+   - taskgraph.transforms.tests.android_test:transforms
+   - taskgraph.transforms.tests.all_kinds:transforms
+   - taskgraph.transforms.tests.test_description:validate
+   - taskgraph.transforms.tests.make_task_description:transforms
+   - taskgraph.transforms.make_task:transforms
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/android-test/test-platforms.yml
@@ -0,0 +1,19 @@
+# This file maps build platforms to test platforms.  In some cases, a
+# single build may be tested on multiple test platforms, but a single test
+# platform can only link to one build platform.  Both build and test platforms
+# are represented as <platform>/<type>, where <type> is what Treeherder calls a
+# collection.
+#
+# Each test platform further specifies the set of tests that will be scheduled
+# for the platform, referring to tests defined in test-sets.yml.
+#
+# Note that set does not depend on the tree; tree-dependent job selection
+# should be performed in the target task selection phase of task-graph
+# generation.
+
+android-4.3-arm7-api-15/debug:
+    build-platform: android-api-15/debug
+    test-set: debug-tests
+android-4.3-arm7-api-15/opt:
+    build-platform: android-api-15/opt
+    test-set: opt-tests
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/android-test/test-sets.yml
@@ -0,0 +1,37 @@
+# Each key in this file specifies a set of tests to run.  Different test sets
+# may, for example, be bound to different test platforms.
+#
+# Note that set does not depend on the tree; tree-dependent job selection
+# should be performed in the target task selection phase of task-graph
+# generation.
+#
+# A test set has a name, and a list of tests that it contains.
+#
+# Test names given here reference tests.yml.
+
+debug-tests:
+    - cppunit
+    - crashtest
+    - jsreftest
+    - mochitest
+    - mochitest-chrome
+    - mochitest-clipboard
+    - mochitest-gpu
+    - mochitest-media
+    - mochitest-webgl
+    - reftest
+    - xpcshell
+
+opt-tests:
+    - cppunit
+    - crashtest
+    - jsreftest
+    - mochitest
+    - mochitest-chrome
+    - mochitest-clipboard
+    - mochitest-gpu
+    - mochitest-media
+    - mochitest-webgl
+    - reftest
+    - robocop
+    - xpcshell
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/android-test/tests.yml
@@ -0,0 +1,248 @@
+# Each stanza here describes a particular test suite or sub-suite.  These are
+# processed through the transformations described in kind.yml to produce a
+# bunch of tasks.  See the schema in `test-descriptions.py` for a description
+# of the fields used here.
+
+# The Android tests have separate test definitions from desktop because,
+# despite sharing test names, the invocation of these test suites differ
+# substantially from desktop.
+
+# Note that these are in lexical order
+
+cppunit:
+    description: "CPP Unit Tests"
+    suite: cppunittest
+    treeherder-symbol: tc(Cpp)
+    e10s: false
+    loopback-video: true
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=cppunittest
+
+crashtest:
+    description: "Crashtest run"
+    suite: crashtest
+    treeherder-symbol: tc-R(C)
+    instance-size: xlarge
+    chunks:
+        by-test-platform:
+            android-4.3-arm7-api-15/debug: 10
+            android-4.3-arm7-api-15/opt: 4
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=crashtest
+
+jsreftest:
+    description: "JS Reftest run"
+    suite: reftest/jsreftest
+    treeherder-symbol: tc-R(J)
+    instance-size: xlarge
+    chunks:
+        by-test-platform:
+            android-4.3-arm7-api-15/debug: 20
+            android-4.3-arm7-api-15/opt: 6
+    loopback-video: true
+    max-run-time: 7200
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=jsreftest
+
+mochitest:
+    description: "Mochitest plain run"
+    suite: mochitest/plain-chunked
+    treeherder-symbol: tc-M()
+    instance-size: xlarge
+    chunks: 20
+    loopback-video: true
+    e10s: false
+    max-run-time:
+        by-test-platform:
+            android-4.3-arm7-api-15/debug: 10800
+            android-4.3-arm7-api-15/opt: 3600
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=mochitest
+
+mochitest-chrome:
+    description: "Mochitest chrome run"
+    suite: mochitest/chrome
+    treeherder-symbol: tc-M(c)
+    instance-size: xlarge
+    loopback-video: true
+    e10s: false
+    max-run-time: 5400
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=mochitest-chrome
+
+mochitest-clipboard:
+    description: "Mochitest clipboard run"
+    suite: mochitest/plain-clipboard
+    treeherder-symbol: tc-M(cl)
+    instance-size: xlarge
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=mochitest-plain-clipboard
+
+mochitest-gpu:
+    description: "Mochitest gpu run"
+    suite: mochitest/plain-gpu
+    treeherder-symbol: tc-M(gpu)
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=mochitest-plain-gpu
+
+mochitest-media:
+    description: "Mochitest media run"
+    suite: mochitest/mochitest-media
+    treeherder-symbol: tc-M(mda)
+    instance-size: xlarge
+    chunks: 2
+    loopback-video: true
+    e10s: false
+    max-run-time:
+        by-test-platform:
+            android-4.3-arm7-api-15/debug: 5400
+            android-4.3-arm7-api-15/opt: 3600
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=mochitest-media
+
+mochitest-webgl:
+    description: "Mochitest webgl run"
+    suite: mochitest/mochitest-gl
+    treeherder-symbol: tc-M(gl)
+    chunks: 10
+    loopback-video: true
+    e10s: false
+    max-run-time: 7200
+    instance-size:
+        by-test-platform:
+            android-4.3-arm7-api-15/opt: default
+            android-4.3-arm7-api-15/debug: xlarge
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=mochitest-gl
+
+reftest:
+    description: "Reftest run"
+    suite: reftest/reftest
+    treeherder-symbol: tc-R(R)
+    chunks:
+        by-test-platform:
+            android-4.3-arm7-api-15/debug: 48
+            android-4.3-arm7-api-15/opt: 16
+    instance-size: xlarge
+    max-run-time: 10800
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=reftest
+
+robocop:
+    description: "Robocop run"
+    suite: robocop
+    treeherder-symbol: tc-M(rc)
+    instance-size: xlarge
+    chunks:
+        by-test-platform:
+            # android-4.3-arm7-api-15/debug -- not run
+            android-4.3-arm7-api-15/opt: 4
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=robocop
+
+xpcshell:
+    description: "xpcshell test run"
+    suite: xpcshell
+    treeherder-symbol: tc-X()
+    chunks: 3
+    instance-size: xlarge
+    max-run-time: 7200
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/android_emulator_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/android/androidarm_4_3.py
+            - mozharness/configs/remove_executables.py
+            - mozharness/configs/android/androidarm_4_3-tc.py
+        extra-options:
+            - --test-suite=xpcshell
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/desktop-test/kind.yml
@@ -0,0 +1,12 @@
+implementation: taskgraph.task.test:TestTask
+
+kind-dependencies:
+    - legacy
+
+transforms:
+   - taskgraph.transforms.tests.test_description:validate
+   - taskgraph.transforms.tests.desktop_test:transforms
+   - taskgraph.transforms.tests.all_kinds:transforms
+   - taskgraph.transforms.tests.test_description:validate
+   - taskgraph.transforms.tests.make_task_description:transforms
+   - taskgraph.transforms.make_task:transforms
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/desktop-test/test-platforms.yml
@@ -0,0 +1,27 @@
+# This file maps build platforms to test platforms.  In some cases, a
+# single build may be tested on multiple test platforms, but a single test
+# platform can only link to one build platform.  Both build and test platforms
+# are represented as <platform>/<type>, where <type> is what Treeherder calls a
+# collection.
+#
+# Each test platform further specifies the set of tests that will be scheduled
+# for the platform, referring to tests defined in test-sets.yml.
+#
+# Note that set does not depend on the tree; tree-dependent job selection
+# should be performed in the target task selection phase of task-graph
+# generation.
+
+linux64/debug:
+    build-platform: linux64/debug
+    test-set: all-tests
+linux64/opt:
+    build-platform: linux64/opt
+    test-set: all-tests
+
+# TODO: use 'pgo' and 'asan' labels here, instead of -pgo/opt
+linux64-pgo/opt:
+    build-platform: linux64-pgo/opt
+    test-set: all-tests
+linux64-asan/opt:
+    build-platform: linux64-asan/opt
+    test-set: asan-tests
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/desktop-test/test-sets.yml
@@ -0,0 +1,54 @@
+# Each key in this file specifies a set of tests to run.  Different test sets
+# may, for example, be bound to different test platforms.
+#
+# Note that set does not depend on the tree; tree-dependent job selection
+# should be performed in the target task selection phase of task-graph
+# generation.
+#
+# A test set has a name, and a list of tests that it contains.
+#
+# Test names given here reference tests.yml.
+
+all-tests:
+    - cppunit
+    - crashtest
+    - external-media-tests
+    - firefox-ui-functional-local
+    - firefox-ui-functional-remote
+    - gtest
+    - jittests
+    - jsreftest
+    - marionette
+    - mochitest
+    - mochitest-a11y
+    - mochitest-browser-chrome
+    - mochitest-chrome
+    - mochitest-clipboard
+    - mochitest-devtools-chrome
+    - mochitest-gpu
+    - mochitest-jetpack
+    - mochitest-media
+    - mochitest-webgl
+    - reftest
+    - reftest-no-accel
+    - web-platform-tests
+    - web-platform-tests-reftests
+    - xpcshell
+
+asan-tests:
+    - cppunit
+    - crashtest
+    - gtest
+    - jittests
+    - jsreftest
+    - marionette
+    - mochitest
+    - mochitest-browser-chrome
+    - mochitest-chrome
+    - mochitest-clipboard
+    - mochitest-devtools-chrome
+    - mochitest-gpu
+    - mochitest-jetpack
+    - mochitest-media
+    - mochitest-webgl
+    - xpcshell
new file mode 100644
--- /dev/null
+++ b/taskcluster/ci/desktop-test/tests.yml
@@ -0,0 +1,378 @@
+# Each stanza here describes a particular test suite or sub-suite.  These are
+# processed through the transformations described in kind.yml to produce a
+# bunch of tasks.  See the schema in `test-descriptions.py` for a description
+# of the fields used here.
+
+# Note that these are in lexical order
+
+cppunit:
+    description: "CPP Unit Tests"
+    suite: cppunittest
+    treeherder-symbol: tc(Cpp)
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --cppunittest-suite=cppunittest
+
+crashtest:
+    description: "Crashtest run"
+    suite: reftest/crashtest
+    treeherder-symbol: tc-R(C)
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        chunked: true
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --reftest-suite=crashtest
+
+external-media-tests:
+    description: "External Media Test run"
+    suite: external-media-tests
+    treeherder-symbol: tc-VP(b-m)
+    e10s: false
+    tier: 2
+    max-run-time: 5400
+    mozharness:
+        script: mozharness/scripts/firefox_media_tests_buildbot.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/mediatests/buildbot_posix_config.py
+            - mozharness/configs/remove_executables.py
+
+firefox-ui-functional-local:
+    description: "Firefox-ui-tests functional run"
+    suite: "firefox-ui/functional local"
+    treeherder-symbol: tc-Fxfn-l(en-US)
+    max-run-time: 5400
+    tier: 1
+    mozharness:
+        script: mozharness/scripts/firefox_ui_tests/functional.py
+        config:
+            - mozharness/configs/firefox_ui_tests/taskcluster.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - "--tag local"
+
+firefox-ui-functional-remote:
+    description: "Firefox-ui-tests functional run"
+    suite: "firefox-ui/functional remote"
+    treeherder-symbol: tc-Fxfn-r(en-US)
+    max-run-time: 5400
+    tier: 2
+    mozharness:
+        script: mozharness/scripts/firefox_ui_tests/functional.py
+        config:
+            - mozharness/configs/firefox_ui_tests/taskcluster.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - "--tag remote"
+
+gtest:
+    description: "GTests run"
+    suite: gtest
+    treeherder-symbol: tc(GTest)
+    e10s: false
+    instance-size: xlarge
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --gtest-suite=gtest
+
+jittests:
+    description: "JIT Test run"
+    suite: jittest/jittest-chunked
+    treeherder-symbol: tc(Jit)
+    e10s: false
+    chunks: 6
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --jittest-suite=jittest-chunked
+
+jsreftest:
+    description: "JS Reftest run"
+    suite: reftest/jsreftest
+    treeherder-symbol: tc-R(J)
+    chunks: 2
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --reftest-suite=jsreftest
+
+marionette:
+    description: "Marionette unittest run"
+    suite: marionette
+    treeherder-symbol: tc(Mn)
+    max-run-time: 5400
+    mozharness:
+        script: mozharness/scripts/marionette.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/marionette/prod_config.py
+            - mozharness/configs/remove_executables.py
+
+mochitest:
+    description: "Mochitest plain run"
+    suite: mochitest/plain-chunked
+    treeherder-symbol: tc-M()
+    loopback-video: true
+    chunks: 10
+    max-run-time: 5400
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=plain-chunked
+
+mochitest-a11y:
+    description: "Mochitest a11y run"
+    suite: mochitest/a11y
+    treeherder-symbol: tc-M(a11y)
+    loopback-video: true
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=a11y
+
+mochitest-browser-chrome:
+    description: "Mochitest browser-chrome run"
+    suite: mochitest/browser-chrome-chunked
+    treeherder-symbol: tc-M(bc)
+    loopback-video: true
+    chunks: 7
+    max-run-time:
+        by-test-platform:
+            linux64/debug: 5400
+            default: 3600
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=browser-chrome-chunked
+
+mochitest-chrome:
+    description: "Mochitest chrome run"
+    suite: mochitest/chrome
+    treeherder-symbol: tc-M(c)
+    loopback-video: true
+    chunks: 3
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=chrome
+
+mochitest-clipboard:
+    description: "Mochitest clipboard run"
+    suite: mochitest/plain-clipboard,chrome-clipboard,browser-chrome-clipboard,jetpack-package-clipboard
+    treeherder-symbol: tc-M(cl)
+    loopback-video: true
+    instance-size: xlarge
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=plain-clipboard,chrome-clipboard,browser-chrome-clipboard,jetpack-package-clipboard
+
+mochitest-devtools-chrome:
+    description: "Mochitest devtools-chrome run"
+    suite: mochitest/mochitest-devtools-chrome-chunked
+    treeherder-symbol: tc-M(dt)
+    loopback-video: true
+    max-run-time: 5400
+    chunks: 10
+    e10s:
+        by-test-platform:
+            # Bug 1242986: linux64/debug mochitest-devtools-chrome e10s is not greened up yet
+            linux64/debug: false
+            default: both
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=mochitest-devtools-chrome-chunked
+
+mochitest-gpu:
+    description: "Mochitest GPU run"
+    suite: mochitest/plain-gpu,chrome-gpu,browser-chrome-gpu
+    treeherder-symbol: tc-M(gpu)
+    loopback-video: true
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=plain-gpu,chrome-gpu,browser-chrome-gpu
+
+mochitest-jetpack:
+    description: "Mochitest jetpack run"
+    suite: mochitest/jetpack-package
+    treeherder-symbol: tc-M(JP)
+    loopback-video: true
+    e10s: false
+    max-run-time: 5400
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=jetpack-package
+
+mochitest-media:
+    description: "Mochitest media run"
+    suite: mochitest/mochitest-media
+    treeherder-symbol: tc-M(mda)
+    max-run-time: 5400
+    loopback-video: true
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=mochitest-media
+
+mochitest-webgl:
+    description: "Mochitest webgl run"
+    suite: mochitest/mochitest-gl
+    treeherder-symbol: tc-M(gl)
+    loopback-video: true
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        chunked: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --mochitest-suite=mochitest-gl
+
+reftest:
+    description: "Reftest run"
+    suite: reftest/reftest
+    treeherder-symbol: tc-R(R)
+    chunks: 8
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --reftest-suite=reftest
+
+reftest-no-accel:
+    description: "Reftest not accelerated run"
+    suite: reftest/reftest-no-accel
+    treeherder-symbol: tc-R(Ru)
+    chunks: 8
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --reftest-suite=reftest-no-accel
+
+web-platform-tests:
+    description: "Web platform test run"
+    suite: web-platform-tests
+    treeherder-symbol: tc-W()
+    chunks: 12
+    max-run-time: 7200
+    instance-size: xlarge
+    mozharness:
+        script: mozharness/scripts/web_platform_tests.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/web_platform_tests/prod_config.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --test-type=testharness
+
+web-platform-tests-reftests:
+    description: "Web platform reftest run"
+    suite: web-platform-tests-reftests
+    treeherder-symbol: tc-W(Wr)
+    max-run-time: 5400
+    instance-size: xlarge
+    mozharness:
+        script: mozharness/scripts/web_platform_tests.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/web_platform_tests/prod_config.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --test-type=reftest
+
+xpcshell:
+    description: "xpcshell test run"
+    suite: xpcshell
+    treeherder-symbol: tc-X()
+    chunks:
+        by-test-platform:
+            linux64/debug: 10
+            default: 8
+    max-run-time: 5400
+    e10s: false
+    mozharness:
+        script: mozharness/scripts/desktop_unittest.py
+        no-read-buildbot-config: true
+        config:
+            - mozharness/configs/unittests/linux_unittest.py
+            - mozharness/configs/remove_executables.py
+        extra-options:
+            - --xpcshell-suite=xpcshell
--- a/taskcluster/ci/docker-image/kind.yml
+++ b/taskcluster/ci/docker-image/kind.yml
@@ -1,13 +1,13 @@
 # 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/.
 
-implementation: 'taskgraph.kind.docker_image:DockerImageTask'
+implementation: 'taskgraph.task.docker_image:DockerImageTask'
 images_path: '../../../testing/docker'
 
 # make a task for each docker-image we might want.  For the moment, since we
 # write artifacts for each, these are whitelisted, but ideally that will change
 # (to use subdirectory clones of the proper directory), at which point we can
 # generate tasks for every docker image in the directory, secure in the
 # knowledge that unnecessary images will be omitted from the target task graph
 images:
--- a/taskcluster/ci/legacy/kind.yml
+++ b/taskcluster/ci/legacy/kind.yml
@@ -1,6 +1,6 @@
 # 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/.
 
-implementation: 'taskgraph.kind.legacy:LegacyTask'
+implementation: 'taskgraph.task.legacy:LegacyTask'
 legacy_path: '.'
--- a/taskcluster/docs/attributes.rst
+++ b/taskcluster/docs/attributes.rst
@@ -2,18 +2,18 @@
 Task Attributes
 ===============
 
 Tasks can be filtered, for example to support "try" pushes which only perform a
 subset of the task graph or to link dependent tasks.  This filtering is the
 difference between a full task graph and a target task graph.
 
 Filtering takes place on the basis of attributes.  Each task has a dictionary
-of attributes (all strings), and a filter is an arbitrary expression over those
-attributes.  A task may not have a value for every attribute.
+of attributes and filters over those attributes can be expressed in Python.  A
+task may not have a value for every attribute.
 
 The attributes, and acceptable values, are defined here.  In general, attribute
 names and values are the short, lower-case form, with underscores.
 
 kind
 ====
 
 A task's ``kind`` attribute gives the name of the kind that generated it, e.g.,
@@ -21,46 +21,48 @@ A task's ``kind`` attribute gives the na
 
 build_platform
 ==============
 
 The build platform defines the platform for which the binary was built.  It is
 set for both build and test jobs, although test jobs may have a different
 ``test_platform``.
 
+build_type
+==========
+
+The type of build being performed.  This is a subdivision of ``build_platform``,
+used for different kinds of builds that target the same platform.  Values are
+
+ * ``debug``
+ * ``opt``
+
 test_platform
 =============
 
 The test platform defines the platform on which tests are run.  It is only
 defined for test jobs and may differ from ``build_platform`` when the same binary
 is tested on several platforms (for example, on several versions of Windows).
 This applies for both talos and unit tests.
 
-build_type
-==========
-
-The type of build being performed.  This is a subdivision of ``build_platform``,
-used for different kinds of builds that target the same platform.  Values are
-
- * ``debug``
- * ``opt``
+Unlike build_platform, the test platform is represented in a slash-separated
+format, e.g., ``linux64/opt``.
 
 unittest_suite
 ==============
 
 This is the unit test suite being run in a unit test task.  For example,
 ``mochitest`` or ``cppunittest``.
 
 unittest_flavor
 ===============
 
 If a unittest suite has subdivisions, those are represented as flavors.  Not
-all suites have flavors, in which case this attribute should be omitted (but is
-sometimes set to match the suite).  Examples:
-``mochitest-devtools-chrome-chunked`` or ``a11y``.
+all suites have flavors, in which case this attribute should be set to match
+the suite.  Examples: ``mochitest-devtools-chrome-chunked`` or ``a11y``.
 
 unittest_try_name
 =================
 
 (deprecated) This is the name used to refer to a unit test via try syntax.  It
 may not match either of ``unittest_suite`` or ``unittest_flavor``.
 
 talos_try_name
@@ -69,16 +71,22 @@ talos_try_name
 (deprecated) This is the name used to refer to a talos job via try syntax.
 
 test_chunk
 ==========
 
 This is the chunk number of a chunked test suite (talos or unittest).  Note
 that this is a string!
 
+e10s
+====
+
+For test suites which distinguish whether they run with or without e10s, this
+boolean value identifies this particular run.
+
 legacy_kind
 ===========
 
 (deprecated) The kind of task as created by the legacy kind.  This is valid
 only for the ``legacy`` kind.  One of ``build``, ``unittest,``, ``talos``,
 ``post_build``, or ``job``.
 
 job
new file mode 100644
--- /dev/null
+++ b/taskcluster/docs/how-tos.rst
@@ -0,0 +1,124 @@
+How Tos
+=======
+
+All of this equipment is here to help you get your work done more efficiently.
+However, learning how task-graphs are generated is probably not the work you
+are interested in doing.  This section should help you accomplish some of the
+more common changes to the task graph with minimal fuss.
+
+.. important::
+
+    If you cannot accomplish what you need with the information provided here,
+    please consider whether you can achieve your goal in a different way.
+    Perhaps something simpler would cost a bit more in compute time, but save
+    the much more expensive resource of developers' mental bandwidth.
+    Task-graph generation is already complex enough!
+
+    If you want to proceed, you may need to delve into the implementation of
+    task-graph generation.  The documentation and code are designed to help, as
+    are the authors - ``hg blame`` may help track down helpful people.
+
+    As you write your new transform or add a new kind, please consider the next
+    developer.  Where possible, make your change data-driven and general, so
+    that others can make a much smaller change.  Document the semantics of what
+    you are changing clearly, especially if it involves modifying a transform
+    schema.  And if you are adding complexity temporarily while making a
+    gradual transition, please open a new bug to remind yourself to remove the
+    complexity when the transition is complete.
+
+Hacking Task Graphs
+-------------------
+
+The recommended process for changing task graphs is this:
+
+1. Find a recent decision task on the project or branch you are working on,
+   and download its ``parameters.yml`` from the Task Inspector.  This file
+   contains all of the inputs to the task-graph generation process.  Its
+   contents are simple enough if you would like to modify it, and it is
+   documented in :doc:`parameters`.
+
+2. Run one of the ``mach taskgraph`` subcommands (see :doc:`taskgraph`) to
+   generate a baseline against which to measure your changes.  For example:
+
+   .. code-block:: none
+
+       ./mach taskgraph --json -p parameters.yml tasks > old-tasks.json
+
+3. Make your modifications under ``tsakcluster/``.
+
+4. Run the same ``mach taskgraph`` command, sending the output to a new file,
+   and use ``diff`` to compare the old and new files.  Make sure your changes
+   have the desired effect and no undesirable side-effects.
+
+5. When you are satisfied with the changes, push them to try to ensure that the
+   modified tasks work as expected.
+
+Common Changes
+--------------
+
+Changing Test Characteristics
+.............................
+
+First, find the test description.  This will be in
+``taskcluster/ci/*/tests.yml``, for the appropriate kind (consult
+:doc:`kinds`).  You will find a YAML stanza for each test suite, and each
+stanza defines the test's characteristics.  For example, the ``chunks``
+property gives the number of chunks to run.  This can be specified as a simple
+integer if all platforms have the same chunk count, or it can be keyed by test
+platform.  For example:
+
+.. code-block:: yaml
+
+    chunks:
+        by-test-platform:
+            linux64/debug: 10
+            default: 8
+
+The full set of available properties is in
+``taskcluster/taskgraph/transform/tests/test_description.py``.  Some other
+commonly-modified properties are ``max-run-time`` (useful if tests are being
+killed for exceeding maxRunTime) and ``treeherder-symbol``.
+
+.. note::
+
+    Android tests are also chunked at the mozharness level, so you will need to
+    modify the relevant mozharness config, as well.
+
+Adding a Test Suite
+...................
+
+To add a new test suite, you will need to know the proper mozharness invocation
+for that suite, and which kind it fits into (consult :doc:`kinds`).
+
+Add a new stanza to ``taskcluster/ci/<kind>/tests.yml``, copying from the other
+stanzas in that file.  The meanings should be clear, but authoritative
+documentation is in
+``taskcluster/taskgraph/transform/tests/test_description.py`` should you need
+it.  The stanza name is the name by which the test will be referenced in try
+syntax.
+
+Add your new test to a test set in ``test-sets.yml`` in the same directory.  If
+the test should only run on a limited set of platforms, you may need to define
+a new test set and reference that from the appropriate platforms in
+``test-platforms.yml``.  If you do so, include some helpful comments in
+``test-sets.yml`` for the next person.
+
+Greening Up a New Test
+......................
+
+When a test is not yet reliably green, configuration for that test should not
+be landed on integration branches.  Of course, you can control where the
+configuration is landed!  For many cases, it is easiest to green up a test in
+try: push the configuration to run the test to try along with your work to fix
+the remaining test failures.
+
+When working with a group, check out a "twig" repository to share among your
+group, and land the test configuration in that repository.  Once the test is
+green, merge to an integration branch and the test will begin running there as
+well.
+
+Something Else?
+...............
+
+If you make another change not described here that turns out to be simple or
+common, please include an update to this file in your patch.
--- a/taskcluster/docs/index.rst
+++ b/taskcluster/docs/index.rst
@@ -6,16 +6,25 @@ TaskCluster Task-Graph Generation
 The ``taskcluster`` directory contains support for defining the graph of tasks
 that must be executed to build and test the Gecko tree.  This is more complex
 than you might suppose!  This implementation supports:
 
  * A huge array of tasks
  * Different behavior for different repositories
  * "Try" pushes, with special means to select a subset of the graph for execution
  * Optimization -- skipping tasks that have already been performed
+ * Extremely flexible generation of a variety of tasks using an approach of
+   incrementally transforming job descriptions into task definitions.
+
+This section of the documentation describes the process in some detail,
+referring to the source where necessary.  If you are reading this with a
+particular goal in mind and would rather avoid becoming a task-graph expert,
+check out the :doc:`how-to section <how-tos>`.
 
 .. toctree::
 
     taskgraph
     parameters
     attributes
+    kinds
+    transforms
     yaml-templates
-    old
+    how-tos
new file mode 100644
--- /dev/null
+++ b/taskcluster/docs/kinds.rst
@@ -0,0 +1,83 @@
+Task Kinds
+==========
+
+This section lists and documents the available task kinds.
+
+Builds
+------
+
+Builds are currently implemented by the ``legacy`` kind.
+
+Tests
+-----
+
+Test tasks for Gecko products are divided into several kinds, but share a
+common implementation.  The process goes like this, based on a set of YAML
+files named in ``kind.yml``:
+
+ * For each build task, determine the related test platforms based on the build
+   platform.  For example, a Windows 2010 build might be tested on Windows 7
+   and Windows 10.  Each test platform specifies a "test set" indicating which
+   tests to run.  This is configured in the file named
+   ``test-platforms.yml``.
+
+ * Each test set is expanded to a list of tests to run.  This is configured in
+   the file named by ``test-sets.yml``.
+
+ * Each named test is looked up in the file named by ``tests.yml`` to find a
+   test description.  This test description indicates what the test does, how
+   it is reported to treeherder, and how to perform the test, all in a
+   platform-independent fashion.
+
+ * Each test description is converted into one or more tasks.  This is
+   performed by a sequence of transforms defined in the ``transforms`` key in
+   ``kind.yml``.  See :doc:`transforms`: for more information on these
+   transforms.
+
+ * The resulting tasks become a part of the task graph.
+
+.. important::
+
+    This process generates *all* test jobs, regardless of tree or try syntax.
+    It is up to a later stage of the task-graph generation (the target set) to
+    select the tests that will actually be performed.
+
+desktop-test
+............
+
+The ``desktop-test`` kind defines tests for Desktop builds.  Its ``tests.yml``
+defines the full suite of desktop tests and their particulars, leaving it to
+the transforms to determine how those particulars apply to Linux, OS X, and
+Windows.
+
+android-test
+............
+
+The ``android-test`` kind defines tests for Android builds.
+
+It is very similar to ``desktop-test``, but the details of running the tests
+differ substantially, so they are defined separately.
+
+legacy
+------
+
+The legacy kind is the old, templated-yaml-based task definition mechanism.  It
+is still used for builds and generic tasks, but not for long!
+
+docker-image
+------------
+
+Tasks of the ``docker-image`` kind build the Docker images in which other
+Docker tasks run.
+
+The tasks to generate each docker image have predictable labels:
+``build-docker-image-<name>``.
+
+Docker images are built from subdirectories of ``testing/docker``, using
+``docker build``.  There is currently no capability for one Docker image to
+depend on another in-tree docker image, without uploading the latter to a
+Docker repository
+
+The task definition used to create the image-building tasks is given in
+``image.yml`` in the kind directory, and is interpreted as a :doc:`YAML
+Template <yaml-templates>`.
deleted file mode 100644
--- a/taskcluster/docs/old.rst
+++ /dev/null
@@ -1,234 +0,0 @@
-==================================
-Legacy TaskCluster Task Definition
-==================================
-
-The "legacy" task definitions are in ``testing/taskcluster``.
-
-These are being replaced by a more flexible system in ``taskcluster``.
-
-Directory structure
-===================
-
-tasks/
-   All task definitions
-
-tests/
-   Tests for the mach target internals related to task graph
-   generation
-
-scripts/
-   Various scripts used by taskcluster docker images and
-   utilities these exist in tree primarily to avoid rebuilding
-   docker images.
-
-Task Conventions
-================
-
-In order to properly enable task reuse there are a few
-conventions and parameters that are specialized for build tasks vs test
-tasks. The goal here should be to provide as much of the power of
-taskcluster while still making it easy to support the current
-model of build/test.
-
-All tasks are in the YAML format and are also processed via mustache to
-allow for greater customizations. All tasks have the following
-templates variables:
-
-``docker_image``
-----------------
-Helper for always using the latest version of a docker image that exists
-in the tree::
-
-   {{#docker_image}}base{{/docker_image}}
-
-Will produce something like (see the docker folder):
-
-   quay.io/mozilla.com/base:0.11
-
-
-``from_now``
-------------
-
-Helper for crafting a JSON date in the future.::
-
-
-   {{#from_now}}1 year{{/from_now}}
-
-Will produce::
-
-   2014-10-19T22:45:45.655Z
-
-
-``now``
--------
-
-Current time as a json formatted date.
-
-Build tasks
-===========
-
-By convention build tasks are stored in ``tasks/builds/`` the location of
-each particular type of build is specified in ``job_flags.yml`` (and more
-locations in the future), which is located in the appropriate subdirectory
-of ``branches/``.
-
-Task format
------------
-
-To facilitate better reuse of tasks there are some expectations of the
-build tasks. These are required for the test tasks to interact with the
-builds correctly but may not affect the builds or indexing services.
-
-.. code-block:: yaml
-
-    # This is an example of just the special fields. Other fields that are
-    # required by taskcluster are omitted and documented on http://docs.taskcluster.net/
-    task:
-
-      payload:
-        # Builders usually create at least two important artifacts: the build
-        # and the tests. These can be anywhere in the task and may have
-        # different path names to include things like arch and extension
-        artifacts:
-          # The build this can be anything as long as its referenced in
-          # locations.
-          'public/name_i_made_up.tar.gz': '/path/to/build'
-          'public/some_tests.zip': '/path/to/tests'
-
-      extra:
-        # Build tasks may name their artifacts anything, but there are common
-        # resources that test tasks need to do their job correctly so we
-        # need to provide an easy way to lookup the correct aritfact path.
-        locations:
-          build: 'public/name_i_made_up.tar.gz'
-          tests: 'public/some_tests.zip' or test_packages: 'public/target.test_packages.json'
-
-
-Templates properties
---------------------
-
-``repository``
-   Target HG repository (e.g.: ``https://hg.mozilla.org/mozilla-central``)
-
-``revision``
-   Target HG revision for gecko
-
-``owner``
-   Email address of the committer
-
-Test Tasks
-==========
-
-By convention test tasks are stored in ``tasks/tests/`` the location of
-each particular type of build is specified in ``job_flags.yml`` (and more
-locations in the future)
-
-Template properties
--------------------
-
-repository
-   Target HG repository (e.g.: ``https://hg.mozilla.org/mozilla-central``)
-
-revision
-   Target HG revision for gecko
-
-owner
-   Email address of the committer
-
-build_url
-   Location of the build
-
-tests_url
-   Location of the tests.zip package
-
-chunk
-   Current chunk
-
-total_chunks
-   Total number of chunks
-
-Generic Tasks
-=============
-
-Generic tasks are neither build tasks nor test tasks. They are intended for
-tasks that don't fit into either category.
-
-.. important::
-
-   Generic tasks are a new feature and still under development. The
-   conventions will likely change significantly.
-
-Generic tasks are defined under a top-level ``tasks`` dictionary in the
-YAML. Keys in the dictionary are the unique task name. Values are
-dictionaries of task attributes. The following attributes can be defined:
-
-task
-   *required* Path to the YAML file declaring the task.
-
-root
-   *optional* Boolean indicating whether this is a *root* task. Root
-   tasks are scheduled immediately, if scheduled to run.
-
-additional-parameters
-   *optional* Dictionary of additional parameters to pass to template
-   expansion.
-
-when
-   *optional* Dictionary of conditions that must be met for this task
-   to run. See the section below for more details.
-
-tags
-   *optional* List of string labels attached to the task. Multiple tasks
-   with the same tag can all be scheduled at once by specifying the tag
-   with the ``-j <tag>`` try syntax.
-
-Conditional Execution
----------------------
-
-The ``when`` generic task dictionary entry can declare conditions that
-must be true for a task to run. Valid entries in this dictionary are
-described below.
-
-file_patterns
-   List of path patterns that will be matched against all files changed.
-
-   The set of changed files is obtained from version control. If the changed
-   files could not be determined, this condition is ignored and no filtering
-   occurs.
-
-   Values use the ``mozpack`` matching code. ``*`` is a wildcard for
-   all path characters except ``/``. ``**`` matches all directories. To
-   e.g. match against all ``.js`` files, one would use ``**/*.js``.
-
-   If a single pattern matches a single changed file, the task will be
-   scheduled.
-
-Developing
-==========
-
-Running commands via mach is the best way to invoke commands testing
-works a little differently (I have not figured out how to invoke
-python-test without running install steps first)::
-
-   mach python-test tests/
-
-Examples
---------
-
-Requires `taskcluster-cli <https://github.com/taskcluster/taskcluster-cli>`_::
-
-    mach taskcluster-trygraph --message 'try: -b do -p all' \
-     --head-rev=33c0181c4a25 \
-     --head-repository=http://hg.mozilla.org/mozilla-central \
-     --owner=jlal@mozilla.com | taskcluster run-graph
-
-Creating only a build task and submitting to taskcluster::
-
-    mach taskcluster-build \
-      --head-revision=33c0181c4a25 \
-      --head-repository=http://hg.mozilla.org/mozilla-central \
-      --owner=user@domain.com tasks/builds/b2g_desktop.yml | taskcluster run-task --verbose
-
-    mach taskcluster-tests --task-id=Mcnvz7wUR_SEMhmWb7cGdQ  \
-     --owner=user@domain.com tasks/tests/b2g_mochitest.yml | taskcluster run-task --verbose
-
--- a/taskcluster/docs/taskgraph.rst
+++ b/taskcluster/docs/taskgraph.rst
@@ -31,17 +31,17 @@ Kinds
 
 Kinds are the focal point of this system.  They provide an interface between
 the large-scale graph-generation process and the small-scale task-definition
 needs of different kinds of tasks.  Each kind may implement task generation
 differently.  Some kinds may generate task definitions entirely internally (for
 example, symbol-upload tasks are all alike, and very simple), while other kinds
 may do little more than parse a directory of YAML files.
 
-A `kind.yml` file contains data about the kind, as well as referring to a
+A ``kind.yml`` file contains data about the kind, as well as referring to a
 Python class implementing the kind in its ``implementation`` key.  That
 implementation may rely on lots of code shared with other kinds, or contain a
 completely unique implementation of some functionality.
 
 The full list of pre-defined keys in this file is:
 
 ``implementation``
    Class implementing this kind, in the form ``<module-path>:<object-path>``.
@@ -56,36 +56,38 @@ Any other keys are subject to interpreta
 
 The result is a nice segmentation of implementation so that the more esoteric
 in-tree projects can do their crazy stuff in an isolated kind without making
 the bread-and-butter build and test configuration more complicated.
 
 Dependencies
 ------------
 
-Dependency links between tasks are always between different kinds(*).  At a
-large scale, you can think of the dependency graph as one between kinds, rather
-than between tasks.  For example, the unittest kind depends on the build kind.
-The details of *which* tasks of the two kinds are linked is left to the kind
-definition.
-
-(*) A kind can depend on itself, though.  You can safely ignore that detail.
-Tasks can also be linked within a kind using explicit dependencies.
+Dependencies between tasks are represented as labeled edges in the task graph.
+For example, a test task must depend on the build task creating the artifact it
+tests, and this dependency edge is named 'build'.  The task graph generation
+process later resolves these dependencies to specific taskIds.
 
 Decision Task
 -------------
 
 The decision task is the first task created when a new graph begins.  It is
 responsible for creating the rest of the task graph.
 
-The decision task for pushes is defined in-tree, in ``.taskcluster.yml``.  The
+The decision task for pushes is defined in-tree, in ``.taskcluster.yml``.  That
 task description invokes ``mach taskcluster decision`` with some metadata about
 the push.  That mach command determines the optimized task graph, then calls
 the TaskCluster API to create the tasks.
 
+Note that this mach command is *not* designed to be invoked directly by humans.
+Instead, use the mach commands described below, supplying ``parameters.yml``
+from a recent decision task.  These commands allow testing everything the
+decision task does except the command-line processing and the
+``queue.createTask`` calls.
+
 Graph Generation
 ----------------
 
 Graph generation, as run via ``mach taskgraph decision``, proceeds as follows:
 
 #. For all kinds, generate all tasks.  The result is the "full task set"
 #. Create links between tasks using kind-specific mechanisms.  The result is
    the "full task graph".
@@ -168,22 +170,39 @@ graph-generation process and output the 
 
 ``mach taskgraph optimized``
    Get the optimized task graph
 
 Each of these commands taskes a ``--parameters`` option giving a file with
 parameters to guide the graph generation.  The decision task helpfully produces
 such a file on every run, and that is generally the easiest way to get a
 parameter file.  The parameter keys and values are described in
-:doc:`parameters`.
+:doc:`parameters`; using that information, you may modify an existing
+``parameters.yml`` or create your own.
+
+Task Parameterization
+---------------------
+
+A few components of tasks are only known at the very end of the decision task
+-- just before the ``queue.createTask`` call is made.  These are specified
+using simple parameterized values, as follows:
 
-Finally, the ``mach taskgraph decision`` subcommand performs the entire
-task-graph generation process, then creates the tasks.  This command should
-only be used within a decision task, as it assumes it is running in that
-context.
+``{"relative-datestamp": "certain number of seconds/hours/days/years"}``
+    Objects of this form will be replaced with an offset from the current time
+    just before the ``queue.createTask`` call is made.  For example, an
+    artifact expiration might be specified as ``{"relative-timestamp": "1
+    year"}``.
+
+``{"task-reference": "string containing <dep-name>"}``
+    The task definition may contain "task references" of this form.  These will
+    be replaced during the optimization step, with the appropriate taskId for
+    the named dependency substituted for ``<dep-name>`` in the string.
+    Multiple labels may be substituted in a single string, and ``<<>`` can be
+    used to escape a literal ``<``.
+
 
 The ``mach taskgraph action-task`` subcommand is used by Action Tasks to
 create a task graph of the requested jobs and its non-optimized dependencies.
 Action Tasks are currently scheduled by
 [pulse_actions](https://github.com/mozilla/pulse_actions)
 
 Taskgraph JSON Format
 ---------------------
@@ -212,28 +231,16 @@ Each task has the following properties:
 
 ``task``
    The task's TaskCluster task definition.
 
 ``kind_implementation``
    The module and the class name which was used to implement this particular task.
    It is always of the form ``<module-path>:<object-path>``
 
-The task definition may contain "relative datestamps" of the form
-``{"relative-datestamp": "certain number of seconds/hours/days/years"}``.
-These will be replaced in the last step, while creating tasks.
-The UTC timestamp at that moment is noted, and all the relative datestamps
-are replaced with respect to this timestamp.
-
-The task definition may contain "task references" of the form
-``{"task-reference": "string containing <task-label>"}``.  These will be
-replaced during the optimization step, with the appropriate taskId substituted
-for ``<task-label>`` in the string.  Multiple labels may be substituted in a
-single string, and ``<<>`` can be used to escape a literal ``<``.
-
 The results from each command are in the same format, but with some differences
 in the content:
 
 * The ``tasks`` and ``target`` subcommands both return graphs with no edges.
   That is, just collections of tasks without any dependencies indicated.
 
 * The ``optimized`` subcommand returns tasks that have been assigned taskIds.
   The dependencies array, too, contains taskIds instead of labels, with
new file mode 100644
--- /dev/null
+++ b/taskcluster/docs/transforms.rst
@@ -0,0 +1,130 @@
+Transforms
+==========
+
+Many task kinds generate tasks by a process of transforming job descriptions
+into task definitions.  The basic operation is simple, although the sequence of
+transforms applied for a particular kind may not be!
+
+Overview
+--------
+
+To begin, a kind implementation generates a collection of items.  For example,
+the test kind implementation generates a list of tests to run for each matching
+build, representing each as a test description.  The items are simply Python
+dictionaries.
+
+The kind also defines a sequence of transformations.  These are applied, in
+order, to each item.  Early transforms might apply default values or break
+items up into smaller items (for example, chunking a test suite).  Later
+transforms rewrite the items entirely, with the final result being a task
+definition.
+
+Each transformation looks like this:
+
+.. code-block::
+
+    @transforms.add
+    def transform_an_item(config, items):
+        """This transform ..."""  # always a docstring!
+        for item in items:
+            # ..
+            yield item
+
+The ``config`` argument is a Python object containing useful configuration for
+the kind, and is a subclass of
+:class:`taskgraph.transforms.base.TransformConfig`, which specifies a few of
+its attributes.  Kinds may subclass and add additional attributes if necessary.
+
+While most transforms yield one item for each item consumed, this is not always
+true: items that are not yielded are effectively filtered out.  Yielding
+multiple items for each consumed item implements item duplication; this is how
+test chunking is accomplished, for example.
+
+The ``transforms`` object is an instance of
+:class:`taskgraph.transforms.base.TransformSequence`, which serves as a simple
+mechanism to combine a sequence of transforms into one.
+
+Schemas
+-------
+
+The items used in transforms are validated against some simple schemas at
+various points in the transformation process.  These schemas accomplish two
+things: they provide a place to add comments about the meaning of each field,
+and they enforce that the fields are actually used in the documented fashion.
+
+Keyed By
+--------
+
+Several fields in the input items can be "keyed by" another value in the item.
+For example, a test description's chunks may be keyed by ``test-platform``.
+In the item, this looks like:
+
+.. code-block:: yaml
+
+    chunks:
+        by-test-platform:
+            linux64/debug: 12
+            linux64/opt: 8
+            default: 10
+
+This is a simple but powerful way to encode business rules in the items
+provided as input to the transforms, rather than expressing those rules in the
+transforms themselves.  If you are implementing a new business rule, prefer
+this mode where possible.  The structure is easily resolved to a single value
+using :func:`taskgraph.transform.base.get_keyed_by`.
+
+Task-Generation Transforms
+--------------------------
+
+Every kind needs to create tasks, and all of those tasks have some things in
+common.  They all run on one of a small set of worker implementations, each
+with their own idiosyncracies.  And they all report to TreeHerder in a similar
+way.
+
+The transforms in ``taskcluster/taskgraph/transforms/make_task.py`` implement
+this common functionality.  They expect a "task description", and produce a
+task definition.  The schema for a task description is defined at the top of
+``make_task.py``, with copious comments.  The result is a dictionary with keys
+``label``, ``attributes``, ``task``, and ``dependencies``, with the latter
+having the same format as the input dependencies.
+
+These transforms assign names to treeherder groups using an internal list of
+group names.  Feel free to add additional groups to this list as necessary.
+
+Test Transforms
+---------------
+
+The transforms configured for test kinds proceed as follows, based on
+configuration in ``kind.yml``:
+
+ * The test description is validated to conform to the schema in
+   ``taskcluster/taskgraph/transforms/tests/test_description.py``.  This schema
+   is extensively documented and is a the primary reference for anyone
+   modifying tests.
+
+ * Kind-specific transformations are applied.  These may apply default
+   settings, split tests (e.g., one to run with feature X enabled, one with it
+   disabled), or apply across-the-board business rules such as "all desktop
+   debug test platforms should have a max-run-time of 5400s".
+
+ * Transformations generic to all tests are applied.  These apply policies
+   which apply to multiple kinds, e.g., for treeherder tiers.  This is also the
+   place where most values which differ based on platform are resolved, and
+   where chunked tests are split out into a test per chunk.
+
+ * The test is again validated against the same schema.  At this point it is
+   still a test description, just with defaults and policies applied, and
+   per-platform options resolved.  So transforms up to this point do not modify
+   the "shape" of the test description, and are still governed by the schema in
+   ``test_description.py``.
+
+ * The ``taskgraph.transforms.tests.make_task_description:transforms`` then
+   take the test description and create a *task* description.  This transform
+   embodies the specifics of how test runs work: invoking mozharness, various
+   worker options, and so on.
+
+ * Finally, the ``taskgraph.transforms.make_task:transforms``, described above
+   under "Task-Generation Transforms", are applied.
+
+Test dependencies are produced in the form of a dictionary mapping dependency
+name to task label.
--- a/taskcluster/docs/yaml-templates.rst
+++ b/taskcluster/docs/yaml-templates.rst
@@ -1,14 +1,14 @@
 Task Definition YAML Templates
 ==============================
 
-Many kinds of tasks are described using YAML files.  These files allow some
-limited forms of inheritance and template substitution as well as the usual
-YAML features, as described below.
+Many kinds of tasks are described using templated YAML files.  These files
+allow some limited forms of inheritance and template substitution as well as
+the usual YAML features, as described below.
 
 Please use these features sparingly.  In many cases, it is better to add a
 feature to the implementation of a task kind rather than add complexity to the
 YAML files.
 
 Inheritance
 -----------
 
--- a/taskcluster/taskgraph/create.py
+++ b/taskcluster/taskgraph/create.py
@@ -45,16 +45,17 @@ def create_tasks(taskgraph, label_to_tas
             task_def = taskgraph.tasks[task_id].task
             # if this task has no dependencies, make it depend on this decision
             # task so that it does not start immediately; and so that if this loop
             # fails halfway through, none of the already-created tasks run.
             if decision_task_id and not task_def.get('dependencies'):
                 task_def['dependencies'] = [decision_task_id]
 
             task_def['taskGroupId'] = task_group_id
+            task_def['schedulerId'] = '-'
 
             # Wait for dependencies before submitting this.
             deps_fs = [fs[dep] for dep in task_def.get('dependencies', [])
                        if dep in fs]
             for f in futures.as_completed(deps_fs):
                 f.result()
 
             fs[task_id] = e.submit(_create_task, session, task_id,
--- a/taskcluster/taskgraph/decision.py
+++ b/taskcluster/taskgraph/decision.py
@@ -32,16 +32,21 @@ GECKO = os.path.realpath(os.path.join(__
 PER_PROJECT_PARAMETERS = {
     'try': {
         'target_tasks_method': 'try_option_syntax',
         # for try, if a task was specified as a target, it should
         # not be optimized away
         'optimize_target_tasks': False,
     },
 
+    'ash': {
+        'target_tasks_method': 'ash_tasks',
+        'optimize_target_tasks': True,
+    },
+
     # the default parameters are used for projects that do not match above.
     'default': {
         'target_tasks_method': 'all_builds_and_tests',
         'optimize_target_tasks': True,
     }
 }
 
 
--- a/taskcluster/taskgraph/generator.py
+++ b/taskcluster/taskgraph/generator.py
@@ -157,20 +157,22 @@ class TaskGraphGenerator(object):
                 edges.add((kind.name, dep, 'kind-dependency'))
         kind_graph = Graph(set(kinds), edges)
 
         logger.info("Generating full task set")
         all_tasks = {}
         for kind_name in kind_graph.visit_postorder():
             logger.debug("Loading tasks for kind {}".format(kind_name))
             kind = kinds[kind_name]
-            for task in kind.load_tasks(self.parameters, list(all_tasks.values())):
+            new_tasks = kind.load_tasks(self.parameters, list(all_tasks.values()))
+            for task in new_tasks:
                 if task.label in all_tasks:
                     raise Exception("duplicate tasks with label " + task.label)
                 all_tasks[task.label] = task
+            logger.info("Generated {} tasks for kind {}".format(len(new_tasks), kind_name))
         full_task_set = TaskGraph(all_tasks, Graph(set(all_tasks), set()))
         yield 'full_task_set', full_task_set
 
         logger.info("Generating full task graph")
         edges = set()
         for t in full_task_set:
             for dep, depname in t.get_dependencies(full_task_set):
                 edges.add((t.label, dep, depname))
deleted file mode 100644
--- a/taskcluster/taskgraph/target_tasks.py
+++ b/taskcluster/taskgraph/target_tasks.py
@@ -1,16 +1,23 @@
 # -*- 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
 from taskgraph import try_option_syntax
+from taskgraph.util.attributes import attrmatch
+
+BUILD_AND_TEST_KINDS = set([
+    'legacy',  # builds
+    'desktop-test',
+    'android-test',
+])
 
 _target_task_methods = {}
 
 
 def _target_task(name):
     def wrap(func):
         _target_task_methods[name] = func
         return func
@@ -39,10 +46,32 @@ def target_tasks_try_option_syntax(full_
             if options.task_matches(t.attributes)]
 
 
 @_target_task('all_builds_and_tests')
 def target_tasks_all_builds_and_tests(full_task_graph, parameters):
     """Trivially target all build and test tasks.  This is used for
     branches where we want to build "everyting", but "everything"
     does not include uninteresting things like docker images"""
-    return [t.label for t in full_task_graph.tasks.itervalues()
-            if t.attributes.get('kind') == 'legacy']
+    def filter(task):
+        return t.attributes.get('kind') in BUILD_AND_TEST_KINDS
+    return [l for l, t in full_task_graph.tasks.iteritems() if filter(t)]
+
+
+@_target_task('ash_tasks')
+def target_tasks_ash_tasks(full_task_graph, parameters):
+    """Special case for builds on ash."""
+    def filter(task):
+        # NOTE: on the ash branch, update taskcluster/ci/desktop-test/tests.yml to
+        # run the M-dt-e10s tasks
+        attrs = t.attributes
+        if attrs.get('kind') not in BUILD_AND_TEST_KINDS:
+            return False
+        if not attrmatch(attrs, build_platform=set([
+            'linux64',
+            'linux64-asan',
+            'linux64-pgo',
+        ])):
+            return False
+        if not attrmatch(attrs, e10s=True):
+            return False
+        return True
+    return [l for l, t in full_task_graph.tasks.iteritems() if filter(t)]
new file mode 100644
rename from taskcluster/taskgraph/kind/base.py
rename to taskcluster/taskgraph/task/base.py
--- a/taskcluster/taskgraph/kind/base.py
+++ b/taskcluster/taskgraph/task/base.py
@@ -34,26 +34,22 @@ class Task(object):
         self.attributes = attributes
         self.task = task
 
         self.task_id = None
         self.optimized = False
 
         self.attributes['kind'] = kind
 
-        if not (all(isinstance(x, basestring) for x in self.attributes.iterkeys()) and
-                all(isinstance(x, basestring) for x in self.attributes.itervalues())):
-            raise TypeError("attribute names and values must be strings")
-
     def __eq__(self, other):
         return self.kind == other.kind and \
-               self.label == other.label and \
-               self.attributes == other.attributes and \
-               self.task == other.task and \
-               self.task_id == other.task_id
+            self.label == other.label and \
+            self.attributes == other.attributes and \
+            self.task == other.task and \
+            self.task_id == other.task_id
 
     @classmethod
     @abc.abstractmethod
     def load_tasks(cls, kind, path, config, parameters, loaded_tasks):
         """
         Load the tasks for a given kind.
 
         The `kind` is the name of the kind; the configuration for that kind
@@ -92,8 +88,21 @@ class Task(object):
         true, then the task will be optimized (in other words, not included in
         the task graph).  If the second argument is a taskid, then any
         dependencies on this task will isntead depend on that taskId.  It is an
         error to return no taskId for a task on which other tasks depend.
 
         The default never optimizes.
         """
         return False, None
+
+    @classmethod
+    def from_json(cls, task_dict):
+        """
+        Given a data structure as produced by taskgraph.to_json, re-construct
+        the original Task object.  This is used to "resume" the task-graph
+        generation process, for example in Action tasks.
+        """
+        return cls(
+            kind=task_dict['attributes']['kind'],
+            label=task_dict['label'],
+            attributes=task_dict['attributes'],
+            task=task_dict['task'])
rename from taskcluster/taskgraph/kind/docker_image.py
rename to taskcluster/taskgraph/task/docker_image.py
rename from taskcluster/taskgraph/kind/legacy.py
rename to taskcluster/taskgraph/task/legacy.py
--- a/taskcluster/taskgraph/kind/legacy.py
+++ b/taskcluster/taskgraph/task/legacy.py
@@ -1,15 +1,14 @@
 # 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 copy
 import json
 import logging
 import os
 import re
 import time
 from collections import namedtuple
 
 from . import base
@@ -556,87 +555,16 @@ class LegacyTask(base.Task):
                 if project == "try":
                     set_expiration(post_task, TRY_EXPIRATION)
 
                 post_task['attributes'] = attributes.copy()
                 post_task['attributes']['legacy_kind'] = 'post_build'
                 post_task['attributes']['post_build'] = post_build['job_flag']
                 graph['tasks'].append(post_task)
 
-            for test in build['dependents']:
-                test = test['allowed_build_tasks'][build['task']]
-                # TODO additional-parameters is currently not an option, only
-                # enabled for build tasks
-                test_parameters = merge_dicts(build_parameters,
-                                              test.get('additional-parameters', {}))
-                test_parameters = copy.copy(build_parameters)
-
-                test_definition = templates.load(test['task'], {})['task']
-                chunk_config = test_definition['extra'].get('chunks', {})
-
-                # Allow branch configs to override task level chunking...
-                if 'chunks' in test:
-                    chunk_config['total'] = test['chunks']
-
-                chunked = 'total' in chunk_config
-                if chunked:
-                    test_parameters['total_chunks'] = chunk_config['total']
-
-                if 'suite' in test_definition['extra']:
-                    suite_config = test_definition['extra']['suite']
-                    test_parameters['suite'] = suite_config['name']
-                    test_parameters['flavor'] = suite_config.get('flavor', '')
-
-                for chunk in range(1, chunk_config.get('total', 1) + 1):
-                    if 'only_chunks' in test and chunked and \
-                            chunk not in test['only_chunks']:
-                        continue
-
-                    if chunked:
-                        test_parameters['chunk'] = chunk
-                    test_task = configure_dependent_task(test['task'],
-                                                         test_parameters,
-                                                         mklabel(),
-                                                         templates,
-                                                         build_treeherder_config)
-                    set_interactive_task(test_task, interactive)
-
-                    decorate_task_treeherder_routes(test_task['task'],
-                                                    test_parameters['project'],
-                                                    test_parameters['head_rev'],
-                                                    test_parameters['pushlog_id'])
-
-                    if project == "try":
-                        set_expiration(test_task, TRY_EXPIRATION)
-
-                    test_task['attributes'] = attributes.copy()
-                    test_task['attributes']['legacy_kind'] = 'unittest'
-                    test_task['attributes']['test_platform'] = attributes['build_platform']
-                    test_task['attributes']['unittest_try_name'] = test['unittest_try_name']
-                    for param, attr in [
-                            ('suite', 'unittest_suite'),
-                            ('flavor', 'unittest_flavor'),
-                            ('chunk', 'test_chunk')]:
-                        if param in test_parameters:
-                            test_task['attributes'][attr] = str(test_parameters[param])
-
-                    # This will schedule test jobs N times
-                    for i in range(0, trigger_tests):
-                        graph['tasks'].append(test_task)
-                        # If we're scheduling more tasks each have to be unique
-                        test_task = copy.deepcopy(test_task)
-                        test_task['taskId'] = mklabel()
-
-                    define_task = DEFINE_TASK.format(
-                        test_task['task']['workerType']
-                    )
-
-                    graph['scopes'].add(define_task)
-                    graph['scopes'] |= set(test_task['task'].get('scopes', []))
-
         graph['scopes'] = sorted(graph['scopes'])
 
         # Convert to a dictionary of tasks.  The process above has invented a
         # taskId for each task, and we use those as the *labels* for the tasks;
         # taskgraph will later assign them new taskIds.
         return [
             cls(kind, t['taskId'], task=t['task'], attributes=t['attributes'], task_dict=t)
             for t in graph['tasks']
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/task/test.py
@@ -0,0 +1,159 @@
+# 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 copy
+import logging
+import os
+import yaml
+
+from . import base
+from taskgraph.util.python_path import find_object
+from taskgraph.transforms.base import TransformSequence, TransformConfig
+
+logger = logging.getLogger(__name__)
+
+
+class TestTask(base.Task):
+    """
+    A task implementing a Gecko test.
+    """
+
+    @classmethod
+    def load_tasks(cls, kind, path, config, params, loaded_tasks):
+
+        # the kind on which this one depends
+        if len(config.get('kind-dependencies', [])) != 1:
+            raise Exception("TestTask kinds must have exactly one item in kind-dependencies")
+        dep_kind = config['kind-dependencies'][0]
+
+        # get build tasks, keyed by build platform
+        builds_by_platform = cls.get_builds_by_platform(dep_kind, loaded_tasks)
+
+        # get the test platforms for those build tasks
+        test_platforms_cfg = load_yaml(path, 'test-platforms.yml')
+        test_platforms = cls.get_test_platforms(test_platforms_cfg, builds_by_platform)
+
+        # expand the test sets for each of those platforms
+        test_sets_cfg = load_yaml(path, 'test-sets.yml')
+        test_platforms = cls.expand_tests(test_sets_cfg, test_platforms)
+
+        # load the test descriptions
+        test_descriptions = load_yaml(path, 'tests.yml')
+
+        # load the transformation functions
+        transforms = cls.load_transforms(config['transforms'])
+
+        # generate all tests for all test platforms
+        def tests():
+            for test_platform_name, test_platform in test_platforms.iteritems():
+                for test_name in test_platform['test-names']:
+                    test = copy.deepcopy(test_descriptions[test_name])
+                    test['build-platform'] = test_platform['build-platform']
+                    test['test-platform'] = test_platform_name
+                    test['build-label'] = test_platform['build-label']
+                    test['test-name'] = test_name
+                    yield test
+
+        # log each source test definition as it is created; this helps when debugging
+        # an exception in task generation
+        def log_tests(config, tests):
+            for test in tests:
+                logger.debug("Generating tasks for {} test {} on platform {}".format(
+                    kind, test['test-name'], test['test-platform']))
+                yield test
+        transforms = TransformSequence([log_tests] + transforms)
+
+        # perform the transformations
+        config = TransformConfig(kind, path, config, params)
+        tasks = [cls(config.kind, t) for t in transforms(config, tests())]
+        return tasks
+
+    @classmethod
+    def get_builds_by_platform(cls, dep_kind, loaded_tasks):
+        """Find the build tasks on which tests will depend, keyed by
+        platform/type.  Returns a dictionary mapping build platform to task
+        label."""
+        builds_by_platform = {}
+        for task in loaded_tasks:
+            if task.kind != dep_kind:
+                continue
+            # remove this check when builds are no longer legacy
+            if task.attributes['legacy_kind'] != 'build':
+                continue
+
+            build_platform = task.attributes.get('build_platform')
+            build_type = task.attributes.get('build_type')
+            if not build_platform or not build_type:
+                continue
+            platform = "{}/{}".format(build_platform, build_type)
+            if platform in builds_by_platform:
+                raise Exception("multiple build jobs for " + platform)
+            builds_by_platform[platform] = task.label
+        return builds_by_platform
+
+    @classmethod
+    def get_test_platforms(cls, test_platforms_cfg, builds_by_platform):
+        """Get the test platforms for which test tasks should be generated,
+        based on the available build platforms.  Returns a dictionary mapping
+        test platform to {test-set, build-platform, build-label}."""
+        test_platforms = {}
+        for test_platform, cfg in test_platforms_cfg.iteritems():
+            build_platform = cfg['build-platform']
+            if build_platform not in builds_by_platform:
+                logger.warning(
+                    "No build task with platform {}; ignoring test platform {}".format(
+                        build_platform, test_platform))
+                continue
+            test_platforms[test_platform] = {
+                'test-set': cfg['test-set'],
+                'build-platform': build_platform,
+                'build-label': builds_by_platform[build_platform],
+            }
+        return test_platforms
+
+    @classmethod
+    def expand_tests(cls, test_sets_cfg, test_platforms):
+        """Expand the test sets in `test_platforms` out to sets of test names.
+        Returns a dictionary like `get_test_platforms`, with an additional
+        `test-names` key for each test platform, containing a set of test
+        names."""
+        rv = {}
+        for test_platform, cfg in test_platforms.iteritems():
+            test_set = cfg['test-set']
+            if test_set not in test_sets_cfg:
+                raise Exception(
+                    "Test set '{}' for test platform {} is not defined".format(
+                        test_set, test_platform))
+            test_names = test_sets_cfg[test_set]
+            rv[test_platform] = cfg.copy()
+            rv[test_platform]['test-names'] = test_names
+        return rv
+
+    @classmethod
+    def load_transforms(cls, transforms_cfg):
+        """Load the transforms specified in kind.yml"""
+        transforms = []
+        for path in transforms_cfg:
+            transform = find_object(path)
+            transforms.append(transform)
+        return transforms
+
+    def __init__(self, kind, task):
+        self.dependencies = task['dependencies']
+        super(TestTask, self).__init__(kind, task['label'], task['attributes'], task['task'])
+
+    def get_dependencies(self, taskgraph):
+        return [(label, name) for name, label in self.dependencies.items()]
+
+    def optimize(self):
+        return False, None
+
+
+def load_yaml(path, name):
+    """Convenience method to load a YAML file in the kind directory"""
+    filename = os.path.join(path, name)
+    with open(filename, "rb") as f:
+        return yaml.load(f)
--- a/taskcluster/taskgraph/test/test_generator.py
+++ b/taskcluster/taskgraph/test/test_generator.py
@@ -3,17 +3,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 from __future__ import absolute_import, print_function, unicode_literals
 
 import unittest
 
 from ..generator import TaskGraphGenerator, Kind
 from .. import graph
-from ..kind import base
+from ..task import base
 from mozunit import main
 
 
 class FakeTask(base.Task):
 
     def __init__(self, **kwargs):
         self.i = kwargs.pop('i')
         super(FakeTask, self).__init__(**kwargs)
deleted file mode 100644
--- a/taskcluster/taskgraph/test/test_kind_docker_image.py
+++ /dev/null
@@ -1,42 +0,0 @@
-# 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 tempfile
-import os
-
-from ..kind import docker_image
-from mozunit import main
-
-
-KIND_PATH = os.path.join(docker_image.GECKO, 'taskcluster', 'ci', 'docker-image')
-
-
-class TestDockerImageKind(unittest.TestCase):
-
-    def setUp(self):
-        self.task = docker_image.DockerImageTask(
-            'docker-image',
-            KIND_PATH,
-            {},
-            {},
-            index_paths=[])
-
-    def test_get_task_dependencies(self):
-        # this one's easy!
-        self.assertEqual(self.task.get_dependencies(None), [])
-
-    # TODO: optimize_task
-
-    def test_create_context_tar(self):
-        image_dir = os.path.join(docker_image.GECKO, 'testing', 'docker', 'image_builder')
-        tarball = tempfile.mkstemp()[1]
-        self.task.create_context_tar(image_dir, tarball, 'image_builder')
-        self.failUnless(os.path.exists(tarball))
-        os.unlink(tarball)
-
-if __name__ == '__main__':
-    main()
deleted file mode 100644
--- a/taskcluster/taskgraph/test/test_kind_legacy.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# 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 ..kind.legacy import (
-    validate_build_task,
-    BuildTaskValidationException
-)
-from mozunit import main
-
-
-class TestValidateBuildTask(unittest.TestCase):
-
-    def test_validate_missing_extra(self):
-        with self.assertRaises(BuildTaskValidationException):
-            validate_build_task({})
-
-    def test_validate_valid(self):
-        with self.assertRaises(BuildTaskValidationException):
-            validate_build_task({
-                'extra': {
-                    'locations': {
-                        'build': '',
-                        'tests': ''
-                    }
-                }
-            })
-
-
-if __name__ == '__main__':
-    main()
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/test/test_task_docker_image.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
+import tempfile
+import os
+
+from ..task import docker_image
+from mozunit import main
+
+
+KIND_PATH = os.path.join(docker_image.GECKO, 'taskcluster', 'ci', 'docker-image')
+
+
+class TestDockerImageKind(unittest.TestCase):
+
+    def setUp(self):
+        self.task = docker_image.DockerImageTask(
+            'docker-image',
+            KIND_PATH,
+            {},
+            {},
+            index_paths=[])
+
+    def test_get_task_dependencies(self):
+        # this one's easy!
+        self.assertEqual(self.task.get_dependencies(None), [])
+
+    # TODO: optimize_task
+
+    def test_create_context_tar(self):
+        image_dir = os.path.join(docker_image.GECKO, 'testing', 'docker', 'image_builder')
+        tarball = tempfile.mkstemp()[1]
+        self.task.create_context_tar(image_dir, tarball, 'image_builder')
+        self.failUnless(os.path.exists(tarball))
+        os.unlink(tarball)
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/test/test_task_legacy.py
@@ -0,0 +1,35 @@
+# 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 ..task.legacy import (
+    validate_build_task,
+    BuildTaskValidationException
+)
+from mozunit import main
+
+
+class TestValidateBuildTask(unittest.TestCase):
+
+    def test_validate_missing_extra(self):
+        with self.assertRaises(BuildTaskValidationException):
+            validate_build_task({})
+
+    def test_validate_valid(self):
+        with self.assertRaises(BuildTaskValidationException):
+            validate_build_task({
+                'extra': {
+                    'locations': {
+                        'build': '',
+                        'tests': ''
+                    }
+                }
+            })
+
+
+if __name__ == '__main__':
+    main()
--- a/taskcluster/taskgraph/test/test_taskgraph.py
+++ b/taskcluster/taskgraph/test/test_taskgraph.py
@@ -2,42 +2,43 @@
 # 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 ..graph import Graph
-from ..kind.docker_image import DockerImageTask
-from ..kind.legacy import LegacyTask
+from ..task.docker_image import DockerImageTask
+from ..task.legacy import LegacyTask
 from ..taskgraph import TaskGraph
 from mozunit import main
 
 
 class TestTargetTasks(unittest.TestCase):
 
     def test_from_json(self):
         legacy_dict = {
             'attributes': {'kind': 'legacy'},
             'task': {},
             'dependencies': {},
             'label': 'a',
-            'kind_implementation': 'taskgraph.kind.legacy:LegacyTask'
+            'kind_implementation': 'taskgraph.task.legacy:LegacyTask'
         }
         graph = TaskGraph(tasks={
             'a': LegacyTask(kind='legacy',
                             label='a',
                             attributes={},
                             task={},
                             task_dict=legacy_dict),
             'b': DockerImageTask(kind='docker-image',
                                  label='b',
                                  attributes={},
                                  task={"routes": []},
                                  index_paths=[]),
         }, graph=Graph(nodes={'a', 'b'}, edges=set()))
 
         tasks, new_graph = TaskGraph.from_json(graph.to_json(), "taskcluster/ci")
+        self.assertEqual(graph.tasks['a'], new_graph.tasks['a'])
         self.assertEqual(graph, new_graph)
 
 if __name__ == '__main__':
     main()
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/test/test_transforms_base.py
@@ -0,0 +1,129 @@
+# 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 (
+    validate_schema,
+    get_keyed_by,
+    TransformSequence
+)
+from voluptuous import Schema
+
+schema = Schema({
+    'x': int,
+    'y': basestring,
+})
+
+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, [
+            {u'two': 2, u'one': 1},
+            {u'second': True, u'two': 2, u'one': 1},
+        ])
+
+
+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, e:
+            self.failUnless(str(e).startswith("pfx\n"))
+
+
+class TestKeyedBy(unittest.TestCase):
+
+    def test_simple_value(self):
+        test = {
+            'test-name': 'tname',
+            'option': 10,
+        }
+        self.assertEqual(get_keyed_by(test, 'option', 'x'), 10)
+
+    def test_by_value(self):
+        test = {
+            'test-name': 'tname',
+            'option': {
+                'by-other-value': {
+                    'a': 10,
+                    'b': 20,
+                },
+            },
+            'other-value': 'b',
+        }
+        self.assertEqual(get_keyed_by(test, 'option', 'x'), 20)
+
+    def test_by_value_default(self):
+        test = {
+            'test-name': 'tname',
+            'option': {
+                'by-other-value': {
+                    'a': 10,
+                    'default': 30,
+                },
+            },
+            'other-value': 'xxx',
+        }
+        self.assertEqual(get_keyed_by(test, 'option', 'x'), 30)
+
+    def test_by_value_invalid_dict(self):
+        test = {
+            'test-name': 'tname',
+            'option': {
+                'by-something-else': {},
+                'by-other-value': {},
+            },
+        }
+        self.assertRaises(Exception, get_keyed_by, test, 'option', 'x')
+
+    def test_by_value_invalid_no_default(self):
+        test = {
+            'test-name': 'tname',
+            'option': {
+                'by-other-value': {
+                    'a': 10,
+                },
+            },
+            'other-value': 'b',
+        }
+        self.assertRaises(Exception, get_keyed_by, test, 'option', 'x')
+
+    def test_by_value_invalid_no_by(self):
+        test = {
+            'test-name': 'tname',
+            'option': {
+                'other-value': {},
+            },
+        }
+        self.assertRaises(Exception, get_keyed_by, test, 'option', 'x')
+
+if __name__ == '__main__':
+    main()
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/test/test_util_attributes.py
@@ -0,0 +1,45 @@
+# -*- 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/.
+
+import unittest
+from taskgraph.util.attributes import attrmatch
+
+
+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))
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/test/test_util_treeherder.py
@@ -0,0 +1,23 @@
+# 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
+
+
+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)')
--- a/taskcluster/taskgraph/test/util.py
+++ b/taskcluster/taskgraph/test/util.py
@@ -1,15 +1,15 @@
 # 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 ..kind import base
+from ..task import base
 
 
 class TestTask(base.Task):
 
     def __init__(self, kind=None, label=None, attributes=None, task=None):
         super(TestTask, self).__init__(
                 kind or 'test',
                 label or 'test-label',
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/base.py
@@ -0,0 +1,105 @@
+# 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 voluptuous
+
+
+class TransformConfig(object):
+    """A container for configuration affecting transforms.  The `config`
+    argument to transforms is an instance of this class, possibly with
+    additional kind-specific attributes beyond those set here."""
+    def __init__(self, kind, path, config, params):
+        # the name of the current kind
+        self.kind = kind
+
+        # the path to the kind configuration directory
+        self.path = path
+
+        # the parsed contents of kind.yml
+        self.config = config
+
+        # the parameters for this task-graph generation run
+        self.params = params
+
+
+class TransformSequence(object):
+    """
+    Container for a sequence of transforms.  Each transform is represented as a
+    callable taking (config, items) and returning a generator which will yield
+    transformed items.  The resulting sequence has the same interface.
+
+    This is convenient to use in a file full of transforms, as it provides a
+    decorator, @transforms.add, that will add the decorated function to the
+    sequence.
+    """
+
+    def __init__(self, transforms=None):
+        self.transforms = transforms or []
+
+    def __call__(self, config, items):
+        for xform in self.transforms:
+            items = xform(config, items)
+            if items is None:
+                raise Exception("Transform {} is not a generator".format(xform))
+        return items
+
+    def __repr__(self):
+        return '\n'.join(
+            ['TransformSequence(['] +
+            [repr(x) for x in self.transforms] +
+            ['])'])
+
+    def add(self, func):
+        self.transforms.append(func)
+        return func
+
+
+def validate_schema(schema, obj, msg_prefix):
+    """
+    Validate that object satisfies schema.  If not, generate a useful exception
+    beginning with msg_prefix.
+    """
+    try:
+        return schema(obj)
+    except voluptuous.MultipleInvalid as exc:
+        msg = [msg_prefix]
+        for error in exc.errors:
+            msg.append(str(error))
+        raise Exception('\n'.join(msg))
+
+
+def get_keyed_by(item, field, item_name):
+    """
+    For values which can either accept a literal value, or be keyed by some
+    other attribute of the item, perform that lookup.  For example, this supports
+
+        chunks:
+            by-item-platform:
+                macosx-10.11/debug: 13
+                default: 12
+
+    The `item_name` parameter is used to generate useful error messages.
+    """
+    value = item[field]
+    if not isinstance(value, dict):
+        return value
+
+    assert len(value) == 1, "Invalid attribute {} in {}".format(field, item_name)
+    keyed_by = value.keys()[0]
+    values = value[keyed_by]
+    if keyed_by.startswith('by-'):
+        keyed_by = keyed_by[3:]  # extract just the keyed-by field name
+        for k in item[keyed_by], 'default':
+            if k in values:
+                return values[k]
+        else:
+            raise Exception(
+                "Neither {} {} nor 'default' found while determining item {} in {}".format(
+                    keyed_by, item[keyed_by], field, item_name))
+    else:
+        raise Exception(
+            "Invalid attribute {} keyed-by value {} in {}".format(
+                field, keyed_by, item_name))
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/make_task.py
@@ -0,0 +1,320 @@
+# 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/.
+"""
+These transformations take a task description and turn it into a TaskCluster
+task definition (along with attributes, label, etc.).  The input to these
+transformations is generic to any kind of task, but abstracts away some of the
+complexities of worker implementations, scopes, and treeherder annotations.
+"""
+
+from taskgraph.util.treeherder import split_symbol
+from taskgraph.transforms.base import (
+    validate_schema,
+    TransformSequence
+)
+from voluptuous import Schema, Any, Required, Optional, Extra
+
+# shortcut for a string where task references are allowed
+taskref_or_string = Any(
+    basestring,
+    {Required('task-reference'): basestring})
+
+# A task description is a general description of a TaskCluster task
+task_description_schema = Schema({
+    # the label for this task
+    'label': basestring,
+
+    # description of the task (for metadata)
+    'description': basestring,
+
+    # attributes for this task
+    'attributes': {basestring: object},
+
+    # dependencies of this task, keyed by name; these are passed through
+    # verbatim and subject to the interpretation of the Task's get_dependencies
+    # method.
+    'dependencies': {basestring: object},
+
+    # expiration and deadline times, relative to task creation, with units
+    # (e.g., "14 days")
+    'expires-after': basestring,
+    'deadline-after': basestring,
+
+    # custom routes for this task; the default treeherder routes will be added
+    # automatically
+    'routes': [basestring],
+
+    # custom scopes for this task; any scopes required for the worker will be
+    # added automatically
+    'scopes': [basestring],
+
+    # custom "task.extra" content
+    'extra': {basestring: object},
+
+    # treeherder-related information; see
+    # https://schemas.taskcluster.net/taskcluster-treeherder/v1/task-treeherder-config.json
+    'treeherder': {
+        # either a bare symbol, or "grp(sym)".
+        'symbol': basestring,
+
+        # the job kind
+        'kind': Any('build', 'test', 'other'),
+
+        # tier for this task
+        'tier': int,
+
+        # task platform, in the form platform/collection, used to set
+        # treeherder.machine.platform and treeherder.collection or
+        # treeherder.labels
+        'platform': basestring,
+
+        # treeherder environments (defaults to both staging and production)
+        Required('environments', default=['production', 'staging']): ['production', 'staging'],
+    },
+
+    # the provisioner-id/worker-type for the task
+    'worker-type': basestring,
+
+    # information specific to the worker implementation that will run this task
+    'worker': Any({
+        'implementation': Any('docker-worker', 'docker-engine'),
+
+        # the docker image (in docker's `host/repo/image:tag` format) in which
+        # to run the task; if omitted, this will be a reference to the image
+        # generated by the 'docker-image' dependency, which must be defined in
+        # 'dependencies'
+        Optional('docker-image'): basestring,
+
+        # worker features that should be enabled
+        Required('relengapi-proxy', default=False): bool,
+        Required('allow-ptrace', default=False): bool,
+        Required('loopback-video', default=False): bool,
+        Required('loopback-audio', default=False): bool,
+
+        # caches to set up for the task
+        'caches': [{
+            # only one type is supported by any of the workers right now
+            'type': 'persistent',
+
+            # name of the cache, allowing re-use by subsequent tasks naming the
+            # same cache
+            'name': basestring,
+
+            # location in the task image where the cache will be mounted
+            'mount-point': basestring,
+        }],
+
+        # artifacts to extract from the task image after completion
+        'artifacts': [{
+            # type of artifact -- simple file, or recursive directory
+            'type': Any('file', 'directory'),
+
+            # task image path from which to read artifact
+            'path': basestring,
+
+            # name of the produced artifact (root of the names for
+            # type=directory)
+            'name': basestring,
+        }],
+
+        # environment variables
+        'env': {basestring: taskref_or_string},
+
+        # the command to run
+        'command': [taskref_or_string],
+
+        # the maximum time to run, in seconds
+        'max-run-time': int,
+    }, {
+        'implementation': 'buildbot-bridge',
+
+        # see https://github.com/mozilla/buildbot-bridge/blob/master/bbb/schemas/payload.yml
+        'buildername': basestring,
+        'sourcestamp': {
+            'branch': basestring,
+            Optional('revision'): basestring,
+            Optional('repository'): basestring,
+            Optional('project'): basestring,
+        },
+        'properties': {
+            'product': basestring,
+            Extra: basestring,  # additional properties are allowed
+        },
+    }),
+
+})
+
+GROUP_NAMES = {
+    'tc': 'Executed by TaskCluster',
+    'tc-e10s': 'Executed by TaskCluster with e10s',
+    'tc-Fxfn-l': 'Firefox functional tests (local) executed by TaskCluster',
+    'tc-Fxfn-l-e10s': 'Firefox functional tests (local) executed by TaskCluster with e10s',
+    'tc-Fxfn-r': 'Firefox functional tests (remote) executed by TaskCluster',
+    'tc-Fxfn-r-e10s': 'Firefox functional tests (remote) executed by TaskCluster with e10s',
+    'tc-M': 'Mochitests executed by TaskCluster',
+    'tc-M-e10s': 'Mochitests executed by TaskCluster with e10s',
+    'tc-R': 'Reftests executed by TaskCluster',
+    'tc-R-e10s': 'Reftests executed by TaskCluster with e10s',
+    'tc-VP': 'VideoPuppeteer tests executed by TaskCluster',
+    'tc-W': 'Web platform tests executed by TaskCluster',
+    'tc-W-e10s': 'Web platform tests executed by TaskCluster with e10s',
+    'tc-X': 'Xpcshell tests executed by TaskCluster',
+    'tc-X-e10s': 'Xpcshell tests executed by TaskCluster with e10s',
+}
+UNKNOWN_GROUP_NAME = "Treeherder group {} has no name; add it to " + __file__
+
+
+# define a collection of payload builders, depending on the worker implementation
+payload_builders = {}
+
+
+def payload_builder(name):
+    def wrap(func):
+        payload_builders[name] = func
+        return func
+    return wrap
+
+
+@payload_builder('docker-worker')
+def build_docker_worker_payload(config, task, task_def):
+    worker = task['worker']
+
+    if 'docker-image' in worker:
+        # a literal image name
+        image = {
+            'type': 'docker-image',
+            'name': worker['docker-image'],
+        }
+    else:
+        assert 'docker-image' in task['dependencies'], 'no docker-worker dependency'
+        image = {
+            "path": "public/image.tar",
+            "taskId": {"task-reference": "<docker-image>"},
+            "type": "task-image",
+        }
+
+    features = {}
+
+    if worker.get('relengapi-proxy'):
+        features['relengAPIProxy'] = True
+
+    if worker.get('allow-ptrace'):
+        features['allowPtrace'] = True
+        task_def['scopes'].append('docker-worker:feature:allowPtrace')
+
+    capabilities = {}
+
+    for lo in 'audio', 'video':
+        if worker.get('loopback-' + lo):
+            capitalized = 'loopback' + lo.capitalize()
+            devices = capabilities.setdefault('devices', {})
+            devices[capitalized] = True
+            task_def['scopes'].append('docker-worker:capability:device:' + capitalized)
+
+    caches = {}
+
+    for cache in worker['caches']:
+        caches[cache['name']] = cache['mount-point']
+        task_def['scopes'].append('docker-worker:cache:' + cache['name'])
+
+    artifacts = {}
+
+    for artifact in worker['artifacts']:
+        artifacts[artifact['name']] = {
+            'path': artifact['path'],
+            'type': artifact['type'],
+            'expires': task_def['expires'],  # always expire with the task
+        }
+
+    task_def['payload'] = payload = {
+        'command': worker['command'],
+        'cache': caches,
+        'artifacts': artifacts,
+        'image': image,
+        'env': worker['env'],
+        'maxRunTime': worker['max-run-time'],
+    }
+    if features:
+        payload['features'] = features
+    if capabilities:
+        payload['capabilities'] = capabilities
+
+
+transforms = TransformSequence()
+
+
+@transforms.add
+def validate(config, tasks):
+    for task in tasks:
+        yield validate_schema(
+            task_description_schema, task,
+            "In task {!r}:".format(task.get('label', '?no-label?')))
+
+
+@transforms.add
+def build_task(config, tasks):
+    for task in tasks:
+        provisioner_id, worker_type = task['worker-type'].split('/', 1)
+        routes = task['routes']
+        scopes = task['scopes']
+
+        # set up extra
+        extra = task['extra']
+        extra['treeherderEnv'] = task['treeherder']['environments']
+
+        task_th = task['treeherder']
+        treeherder = extra.setdefault('treeherder', {})
+
+        machine_platform, collection = task_th['platform'].split('/', 1)
+        treeherder['machine'] = {'platform': machine_platform}
+        treeherder['collection'] = {collection: True}
+
+        groupSymbol, symbol = split_symbol(task_th['symbol'])
+        if groupSymbol != '?':
+            treeherder['groupSymbol'] = groupSymbol
+            if groupSymbol not in GROUP_NAMES:
+                raise Exception(UNKNOWN_GROUP_NAME.format(groupSymbol))
+            treeherder['groupName'] = GROUP_NAMES[groupSymbol]
+        treeherder['symbol'] = symbol
+        treeherder['jobKind'] = task_th['kind']
+        treeherder['tier'] = task_th['tier']
+
+        routes.extend([
+            '{}.v2.{}.{}.{}'.format(root,
+                                    config.params['project'],
+                                    config.params['head_rev'],
+                                    config.params['pushlog_id'])
+            for root in 'tc-treeherder', 'tc-treeherder-stage'
+        ])
+
+        task_def = {
+            'provisionerId': provisioner_id,
+            'workerType': worker_type,
+            'routes': routes,
+            'created': {'relative-datestamp': '0 seconds'},
+            'deadline': {'relative-datestamp': task['deadline-after']},
+            'expires': {'relative-datestamp': task['expires-after']},
+            'scopes': scopes,
+            'metadata': {
+                'description': task['description'],
+                'name': task['label'],
+                'owner': config.params['owner'],
+                'source': '{}/file/{}/{}'.format(
+                    config.params['head_repository'],
+                    config.params['head_rev'],
+                    config.path),
+            },
+            'extra': extra,
+            'tags': {'createdForUser': config.params['owner']},
+        }
+
+        # add the payload and adjust anything else as required (e.g., scopes)
+        payload_builders[task['worker']['implementation']](config, task, task_def)
+
+        yield {
+            'label': task['label'],
+            'task': task_def,
+            'dependencies': task['dependencies'],
+            'attributes': task['attributes'],
+        }
new file mode 100644
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/tests/all_kinds.py
@@ -0,0 +1,106 @@
+# 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/.
+"""
+Changes here apply to all tests, regardless of kind.
+
+This is a great place for:
+
+ * Applying rules based on platform, project, etc. that should span kinds
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from taskgraph.util.treeherder import split_symbol, join_symbol
+from taskgraph.transforms.base import TransformSequence, get_keyed_by
+
+import copy
+
+
+transforms = TransformSequence()
+
+
+@transforms.add
+def set_worker_implementation(config, tests):
+    """Set the worker implementation based on the test platform."""
+    for test in tests:
+        # this is simple for now, but soon will not be!
+        test['worker-implementation'] = 'docker-worker'
+        yield test
+
+
+@transforms.add
+def set_tier(config, tests):
+    """Set the tier based on policy for all test descriptions that do not
+    specify a tier otherwise."""
+    for test in tests:
+        # only override if not set for the test
+        if 'tier' not in test:
+            if test['test-platform'] == 'linux64/debug':
+                test['tier'] = 1
+            else:
+                test['tier'] = 2
+        yield test
+
+
+@transforms.add
+def set_expires_after(config, tests):
+    """Try jobs expire after 2 weeks; everything else lasts 1 year.  This helps
+    keep storage costs low."""
+    for test in tests:
+        if 'expires-after' not in test:
+            if config.params['project'] == 'try':
+                test['expires-after'] = "14 days"
+            else:
+                test['expires-after'] = "1 year"
+        yield test
+
+
+@transforms.add
+def set_download_symbols(config, tests):
+    """In general, we download symbols immediately for debug builds, but only
+    on demand for everything else."""
+    for test in tests:
+        if test['test-platform'].split('/')[-1] == 'debug':
+            test['mozharness']['download-symbols'] = True
+        else:
+            test['mozharness']['download-symbols'] = 'ondemand'
+        yield test
+
+
+@transforms.add
+def resolve_keyed_by(config, tests):
+    """Resolve fields that can be keyed by platform, etc."""
+    fields = [
+        'instance-size',
+        'max-run-time',
+        'chunks',
+    ]
+    for test in tests:
+        for field in fields:
+            test[field] = get_keyed_by(item=test, field=field, item_name=test['test-name'])
+        yield test
+
+
+@transforms.add
+def split_chunks(config, tests):
+    """Based on the 'chunks' key, split tests up into chunks by duplicating
+    them and assigning 'this-chunk' appropriately and updating the treeherder
+    symbol."""
+    for test in tests:
+        if test['chunks'] == 1:
+            test['this-chunk'] = 1
+            yield test
+            continue
+
+        for this_chunk in range(1, test['chunks'] + 1):
+            # copy the test and update with the chunk number
+            chunked = copy.deepcopy(test)
+            chunked['this-chunk'] = this_chunk
+
+            # add the chunk number to the TH symbol
+            group, symbol = split_symbol(chunked['treeherder-symbol'])
+            symbol += str(this_chunk)
+            chunked['treeherder-symbol'] = join_symbol(group, symbol)
+
+            yield chunked
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/tests/android_test.py
@@ -0,0 +1,71 @@
+# 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/.
+"""
+These transforms are specific to the android-test kind, and apply defaults to
+the test descriptions appropriate to that kind.
+
+Both the input to and output from these transforms must conform to
+`taskgraph.transforms.tests.test:test_schema`.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+from taskgraph.transforms.base import TransformSequence
+
+transforms = TransformSequence()
+
+
+@transforms.add
+def set_defaults(config, tests):
+    for test in tests:
+        # all Android test tasks download internal objects from tooltool
+        test['mozharness']['tooltool-downloads'] = True
+        test['mozharness']['build-artifact-name'] = 'public/build/target.apk'
+        test['mozharness']['actions'] = ['get-secrets']
+        yield test
+
+
+@transforms.add
+def set_treeherder_machine_platform(config, tests):
+    """Set the appropriate task.extra.treeherder.machine.platform"""
+    # The build names for these build platforms have partially evolved over the
+    # years..  This is temporary until we can clean up the handling of
+    # platforms
+    translation = {
+        'android-api-15/debug': 'android-4-3-armv7-api15/debug',
+        'android-api-15/opt': 'android-4-3-armv7-api15/opt',
+    }
+    for test in tests:
+        build_platform = test['build-platform']
+        test['treeherder-machine-platform'] = translation.get(build_platform, build_platform)
+        yield test
+
+
+@transforms.add
+def set_chunk_args(config, tests):
+    # Android tests do not take the --this-chunk/--total-chunk args like linux
+    # tests, preferring to define a --test-suite argument for each chunk.
+    # Where debug and opt have different chunk counts, there are *different*
+    # test-suite definitions for the debug and opt runs.
+    #
+    # Within the mozharness scripts, there is a translation *back* to
+    # --this-chunk/--total-chunk.
+    #
+    # TODO: remove the need for this with some changes to the mozharness script
+    # to take --total-chunk/this-chunk
+
+    for test in tests:
+        test['mozharness']['chunking-args'] = 'test-suite-suffix'
+
+        # if the chunks are an integer, then they do not differ between
+        # platforms, so the suffix is always "-<CHUNK>"
+        if isinstance(test['chunks'], int):
+            test['mozharness']['chunk-suffix'] = "-<CHUNK>"
+        else:
+            # otherwise, by convention, the debug version has "-debug" in the
+            # suite name and the opt version does not
+            if test['test-platform'].endswith('debug'):
+                test['mozharness']['chunk-suffix'] = '-debug-<CHUNK>'
+            else:
+                test['mozharness']['chunk-suffix'] = '-<CHUNK>'
+        yield test
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/tests/desktop_test.py
@@ -0,0 +1,68 @@
+# 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/.
+"""
+These transforms are specific to the desktop-test kind, and apply defaults to
+the test descriptions appropriate to that kind.
+
+Both the input to and output from these transforms must conform to
+`taskgraph.transforms.tests.test:test_schema`.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+from taskgraph.transforms.base import TransformSequence, get_keyed_by
+from taskgraph.util.treeherder import split_symbol, join_symbol
+
+import copy
+
+transforms = TransformSequence()
+
+
+@transforms.add
+def set_defaults(config, tests):
+    for test in tests:
+        test['mozharness']['build-artifact-name'] = 'public/build/target.tar.bz2'
+        # all desktop tests want to run the bits that require node
+        test['mozharness']['set-moz-node-path'] = True
+        yield test
+
+
+@transforms.add
+def set_treeherder_machine_platform(config, tests):
+    """Set the appropriate task.extra.treeherder.machine.platform"""
+    # Linux64 build platforms for asan and pgo are specified differently to
+    # treeherder.  This is temporary until we can clean up the handling of
+    # platforms
+    translation = {
+        'linux64-asan/opt': 'linux64/asan',
+        'linux64-pgo/opt': 'linux64/pgo',
+    }
+    for test in tests:
+        build_platform = test['build-platform']
+        test['treeherder-machine-platform'] = translation.get(build_platform, build_platform)
+        yield test
+
+
+@transforms.add
+def split_e10s(config, tests):
+    for test in tests:
+        e10s = get_keyed_by(item=test, field='e10s',
+                            item_name=test['test-name'])
+        test.setdefault('attributes', {})
+        test['e10s'] = False
+        test['attributes']['e10s'] = False
+
+        if e10s == 'both':
+            yield test
+            test = copy.deepcopy(test)
+            e10s = True
+        if e10s:
+            test['test-name'] += '-e10s'
+            test['e10s'] = True
+            test['attributes']['e10s'] = True
+            group, symbol = split_symbol(test['treeherder-symbol'])
+            if group != '?':
+                group += '-e10s'
+            test['treeherder-symbol'] = join_symbol(group, symbol)
+            test['mozharness'].setdefault('extra-options', []).append('--e10s')
+        yield test
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/tests/make_task_description.py
@@ -0,0 +1,219 @@
+# 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/.
+"""
+These transforms construct a task description to run the given test, based on a
+test description.  The implementation here is shared among all test kinds, but
+contains specific support for how we run tests in Gecko (via mozharness,
+invoked in particular ways).
+
+This is a good place to translate a test-description option such as
+`single-core: true` to the implementation of that option in a task description
+(worker options, mozharness commandline, environment variables, etc.)
+
+The test description should be fully formed by the time it reaches these
+transforms, and these transforms should not embody any specific knowledge about
+what should run where. this is the wrong place for special-casing platforms,
+for example - use `all_tests.py` instead.
+"""
+
+from taskgraph.transforms.base import TransformSequence
+
+import logging
+
+ARTIFACT_URL = 'https://queue.taskcluster.net/v1/task/{}/artifacts/{}'
+
+ARTIFACTS = [
+    # (artifact name prefix, in-image path)
+    ("public/logs/", "/home/worker/workspace/build/upload/logs/"),
+    ("public/test", "/home/worker/artifacts/"),
+    ("public/test_info/", "/home/worker/workspace/build/blobber_upload_dir/"),
+]
+
+logger = logging.getLogger(__name__)
+
+transforms = TransformSequence()
+
+
+@transforms.add
+def make_task_description(config, tests):
+    """Convert *test* descriptions to *task* descriptions, suitable for input
+    to make_task"""
+
+    for test in tests:
+        label = '{}-{}-{}'.format(config.kind, test['test-platform'], test['test-name'])
+        if test['chunks'] > 1:
+            label += '-{}'.format(test['this-chunk'])
+
+        build_label = test['build-label']
+
+        unittest_try_name = test.get('unittest-try-name', test['test-name'])
+
+        attr_build_platform, attr_build_type = test['build-platform'].split('/', 1)
+
+        suite = test['suite']
+        if '/' in suite:
+            suite, flavor = suite.split('/', 1)
+        else:
+            flavor = suite
+
+        attributes = test.get('attributes', {})
+        attributes.update({
+            'build_platform': attr_build_platform,
+            'build_type': attr_build_type,
+            # only keep the first portion of the test platform
+            'test_platform': test['test-platform'].split('/')[0],
+            'test_chunk': str(test['this-chunk']),
+            'unittest_suite': suite,
+            'unittest_flavor': flavor,
+            'unittest_try_name': unittest_try_name,
+        })
+
+        taskdesc = {}
+        taskdesc['label'] = label
+        taskdesc['description'] = test['description']
+        taskdesc['attributes'] = attributes
+        taskdesc['dependencies'] = {'build': build_label}
+        taskdesc['deadline-after'] = '1 day'
+        taskdesc['expires-after'] = test['expires-after']
+        taskdesc['routes'] = []
+        taskdesc['scopes'] = []
+        taskdesc['extra'] = {
+            'chunks': {
+                'current': test['this-chunk'],
+                'total': test['chunks'],
+            },
+            'suite': {
+                'name': suite,
+                'flavor': flavor,
+            },
+        }
+        taskdesc['treeherder'] = {
+            'symbol': test['treeherder-symbol'],
+            'kind': 'test',
+            'tier': test['tier'],
+            'platform': test.get('treeherder-machine-platform', test['build-platform']),
+        }
+
+        # the remainder (the worker-type and worker) differs depending on the
+        # worker implementation
+        worker_setup_functions[test['worker-implementation']](config, test, taskdesc)
+
+        # yield only the task description, discarding the test description
+        yield taskdesc
+
+
+worker_setup_functions = {}
+
+
+def worker_setup_function(name):
+    def wrap(func):
+        worker_setup_functions[name] = func
+        return func
+    return wrap
+
+
+@worker_setup_function("docker-engine")
+@worker_setup_function("docker-worker")
+def docker_worker_setup(config, test, taskdesc):
+    mozharness = test['mozharness']
+
+    installer_url = ARTIFACT_URL.format('<build>', mozharness['build-artifact-name'])
+    test_packages_url = ARTIFACT_URL.format('<build>',
+                                            'public/build/target.test_packages.json')
+    mozharness_url = ARTIFACT_URL.format('<build>',
+                                         'public/build/mozharness.zip')
+
+    taskdesc['worker-type'] = {
+        'default': 'aws-provisioner-v1/desktop-test',
+        'xlarge': 'aws-provisioner-v1/desktop-test-xlarge',
+    }[test['instance-size']]
+
+    worker = taskdesc['worker'] = {}
+    worker['implementation'] = test['worker-implementation']
+
+    docker_image = test.get('docker-image')
+    assert docker_image, "no docker image defined for a docker-worker/docker-engine task"
+    if isinstance(docker_image, dict):
+        taskdesc['dependencies']['docker-image'] = 'build-docker-image-' + docker_image['in-tree']
+    else:
+        # just a raw docker-image string
+        worker['docker-image'] = test['docker-image']
+
+    worker['allow-ptrace'] = True  # required for all tests, for crashreporter
+    worker['relengapi-proxy'] = False  # but maybe enabled for tooltool below
+    worker['loopback-video'] = test['loopback-video']
+    worker['loopback-audio'] = test['loopback-audio']
+    worker['max-run-time'] = test['max-run-time']
+
+    worker['artifacts'] = [{
+        'name': prefix,
+        'path': path,
+        'type': 'directory',
+    } for (prefix, path) in ARTIFACTS]
+
+    worker['caches'] = [{
+        'type': 'persistent',
+        'name': 'level-{}-{}-test-workspace'.format(
+            config.params['level'], config.params['project']),
+        'mount-point': "/home/worker/workspace",
+    }]
+
+    env = worker['env'] = {
+        'GECKO_HEAD_REPOSITORY': config.params['head_repository'],
+        'GECKO_HEAD_REV': config.params['head_rev'],
+        'MOZHARNESS_CONFIG': ' '.join(mozharness['config']),
+        'MOZHARNESS_SCRIPT': mozharness['script'],
+        'MOZHARNESS_URL': {'task-reference': mozharness_url},
+        'MOZILLA_BUILD_URL': {'task-reference': installer_url},
+        'NEED_PULSEAUDIO': 'true',
+        'NEED_WINDOW_MANAGER': 'true',
+    }
+
+    if mozharness['set-moz-node-path']:
+        env['MOZ_NODE_PATH'] = '/usr/local/bin/node'
+
+    if 'actions' in mozharness:
+        env['MOZHARNESS_ACTIONS'] = ' '.join(mozharness['actions'])
+
+    # handle some of the mozharness-specific options
+
+    if mozharness['tooltool-downloads']:
+        worker['relengapi-proxy'] = True
+        worker['caches'].append({
+            'type': 'persistent',
+            'name': 'tooltool-cache',
+            'mount-point': '/home/worker/tooltool-cache',
+        })
+        taskdesc['scopes'].extend([
+            'docker-worker:relengapi-proxy:tooltool.download.internal',
+            'docker-worker:relengapi-proxy:tooltool.download.public',
+        ])
+
+    # assemble the command line
+
+    command = worker['command'] = ["bash", "/home/worker/bin/test.sh"]
+    if mozharness.get('no-read-buildbot-config'):
+        command.append("--no-read-buildbot-config")
+    command.extend([
+        {"task-reference": "--installer-url=" + installer_url},
+        {"task-reference": "--test-packages-url=" + test_packages_url},
+    ])
+    command.extend(mozharness.get('extra-options', []))
+
+    # TODO: remove the need for run['chunked']
+    if mozharness.get('chunked') or test['chunks'] > 1:
+        # Implement mozharness['chunking-args'], modifying command in place
+        if mozharness['chunking-args'] == 'this-chunk':
+            command.append('--total-chunk={}'.format(test['chunks']))
+            command.append('--this-chunk={}'.format(test['this-chunk']))
+        elif mozharness['chunking-args'] == 'test-suite-suffix':
+            suffix = mozharness['chunk-suffix'].replace('<CHUNK>', str(test['this-chunk']))
+            for i, c in enumerate(command):
+                if isinstance(c, basestring) and c.startswith('--test-suite'):
+                    command[i] += suffix
+
+    if 'download-symbols' in mozharness:
+        download_symbols = mozharness['download-symbols']
+        download_symbols = {True: 'true', False: 'false'}.get(download_symbols, download_symbols)
+        command.append('--download-symbols=' + download_symbols)
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/transforms/tests/test_description.py
@@ -0,0 +1,195 @@
+# 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/.
+"""
+This file defines the schema for tests -- the things in `tests.yml`.  It should
+be run both before and after the kind-specific transforms, to ensure that the
+transforms do not generate invalid tests.
+"""
+
+from __future__ import absolute_import, print_function, unicode_literals
+
+from taskgraph.transforms.base import validate_schema
+from voluptuous import (
+    Any,
+    Optional,
+    Required,
+    Schema,
+)
+
+
+# Schema for a test description
+#
+# *****WARNING*****
+#
+# This is a great place for baffling cruft to accumulate, and that makes
+# everyone move more slowly.  Be considerate of your fellow hackers!
+# See the warnings in taskcluster/docs/how-tos.rst
+#
+# *****WARNING*****
+test_description_schema = Schema({
+    # description of the suite, for the task metadata
+    'description': basestring,
+
+    # test suite name, or <suite>/<flavor>
+    'suite': basestring,
+
+    # the name by which this test suite is addressed in try syntax; defaults to
+    # the test-name
+    Optional('unittest-try-name'): basestring,
+
+    # the symbol, or group(symbol), under which this task should appear in
+    # treeherder.
+    'treeherder-symbol': basestring,
+
+    # the value to place in task.extra.treeherder.machine.platform; ideally
+    # this is the same as build-platform, and that is the default, but in
+    # practice it's not always a match.
+    Optional('treeherder-machine-platform'): basestring,
+
+    # attributes to appear in the resulting task (later transforms will add the
+    # common attributes)
+    Optional('attributes'): {basestring: object},
+
+    # the sheriffing tier for this task (default: set based on test platform)
+    Optional('tier'): int,
+
+    # number of chunks to create for this task.  This can be keyed by test
+    # platform by passing a dictionary in the `by-test-platform` key.  If the
+    # test platform is not found, the key 'default' will be tried.
+    Required('chunks', default=1): Any(
+        int,
+        {'by-test-platform': {basestring: int}},
+    ),
+
+    # the time (with unit) after which this task is deleted; default depends on
+    # the branch (see below)
+    Optional('expires-after'): basestring,
+
+    # Whether to run this task with e10s (desktop-test only).  If false, run
+    # without e10s; if true, run with e10s; if 'both', run one task with and
+    # one task without e10s.  E10s tasks have "-e10s" appended to the test name
+    # and treeherder group.
+    Required('e10s', default='both'): Any(
+        bool, 'both',
+        {'by-test-platform': {basestring: Any(bool, 'both')}},
+    ),
+
+    # The EC2 instance size to run these tests on.
+    Required('instance-size', default='default'): Any(
+        Any('default', 'xlarge'),
+        {'by-test-platform': {basestring: Any('default', 'xlarge')}},
+    ),
+
+    # Whether the task requires loopback audio or video (whatever that may mean
+    # on the platform)
+    Required('loopback-audio', default=False): bool,
+    Required('loopback-video', default=False): bool,
+
+    # The worker implementation for this test, as dictated by policy and by the
+    # test platform.
+    Optional('worker-implementation'): Any(
+        'docker-worker',
+        # coming soon:
+        'generic-worker',
+        'docker-engine',
+        'buildbot-bridge',
+    ),
+
+    # For tasks that will run in docker-worker or docker-engine, this is the
+    # name of the docker image or in-tree docker image to run the task in.  If
+    # in-tree, then a dependency will be created automatically.  This is
+    # generally `desktop-test`, or an image that acts an awful lot like it.
+    Required('docker-image', default={'in-tree': 'desktop-test'}): Any(
+        # a raw Docker image path (repo/image:tag)
+        basestring,
+        # an in-tree generated docker image (from `testing/docker/<name>`)
+        {'in-tree': basestring}
+    ),
+
+    # seconds of runtime after which the task will be killed.  Like 'chunks',
+    # this can be keyed by test pltaform.
+    Required('max-run-time', default=3600): Any(
+        int,
+        {'by-test-platform': {basestring: int}},
+    ),
+
+    # What to run
+    Required('mozharness'): Any({
+        # the mozharness script used to run this task
+        Required('script'): basestring,
+
+        # the config files required for the task
+        Required('config'): [basestring],
+
+        # any additional actions to pass to the mozharness command
+        Optional('actions'): [basestring],
+
+        # additional command-line options for mozharness, beyond those
+        # automatically added
+        Required('extra-options', default=[]): [basestring],
+
+        # the artifact name (including path) to test on the build task; this is
+        # generally set in a per-kind transformation
+        Optional('build-artifact-name'): basestring,
+
+        # If true, tooltool downloads will be enabled via relengAPIProxy.
+        Required('tooltool-downloads', default=False): bool,
+
+        # This mozharness script also runs in Buildbot and tries to read a
+        # buildbot config file, so tell it not to do so in TaskCluster
+        Required('no-read-buildbot-config', default=False): bool,
+
+        # The setting for --download-symbols (if omitted, the option will not
+        # be passed to mozharness)
+        Optional('download-symbols'): Any(True, False, 'ondemand'),
+
+        # If set, then MOZ_NODE_PATH=/usr/local/bin/node is included in the
+        # environment.  This is more than just a helpful path setting -- it
+        # causes xpcshell tests to start additional servers, and runs
+        # additional tests.
+        Required('set-moz-node-path', default=False): bool,
+
+        # If true, include chunking information in the command even if the number
+        # of chunks is 1
+        Required('chunked', default=False): bool,
+
+        # The chunking argument format to use
+        Required('chunking-args', default='this-chunk'): Any(
+            # Use the usual --this-chunk/--total-chunk arguments
+            'this-chunk',
+            # Use --test-suite=<suite>-<chunk-suffix>; see chunk-suffix, below
+            'test-suite-suffix',
+        ),
+
+        # the string to append to the `--test-suite` arugment when
+        # chunking-args = test-suite-suffix; "<CHUNK>" in this string will
+        # be replaced with the chunk number.
+        Optional('chunk-suffix'): basestring,
+    }),
+
+    # The current chunk; this is filled in by `all_kinds.py`
+    Optional('this-chunk'): int,
+
+    # -- values supplied by the task-generation infrastructure
+
+    # the platform of the build this task is testing
+    'build-platform': basestring,
+
+    # the label of the build task generating the materials to test
+    'build-label': basestring,
+
+    # the platform on which the tests will run
+    'test-platform': basestring,
+
+    # the name of the test (the key in tests.yml)
+    'test-name': basestring,
+
+}, required=True)
+
+
+# TODO: can we have validate and validate_full for before and after?
+def validate(config, tests):
+    for test in tests:
+        yield validate_schema(test_description_schema, test,
+                              "In test {!r}:".format(test['test-name']))
--- a/taskcluster/taskgraph/try_option_syntax.py
+++ b/taskcluster/taskgraph/try_option_syntax.py
@@ -496,18 +496,19 @@ class TryOptionSyntax(object):
                     if attr('job') not in self.jobs:
                         return False
                 return True
             elif attr('legacy_kind') == 'unittest':
                 return match_test(self.unittests, 'unittest_try_name')
             elif attr('legacy_kind') == 'talos':
                 return match_test(self.talos, 'talos_try_name')
             return False
+        elif attr('kind') in ('desktop-test', 'android-test'):
+            return match_test(self.unittests, 'unittest_try_name')
         else:
-            # TODO: match other kinds
             return False
 
     def __str__(self):
         def none_for_all(list):
             if list is None:
                 return '<all>'
             return ', '.join(str(e) for e in list)
 
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/util/attributes.py
@@ -0,0 +1,26 @@
+# 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/.
+
+
+def attrmatch(attributes, **kwargs):
+    """Determine whether the given set of task attributes matches.  The
+    conditions are given as keyword arguments, where each keyword names an
+    attribute.  The keyword value can be a literal, a set, or a callable.  A
+    literal must match the attribute exactly.  Given a set, the attribute value
+    must be in the set.  A callable is called with the attribute value.  If an
+    attribute is specified as a keyword argument but not present in the
+    attributes, the result is False."""
+    for kwkey, kwval in kwargs.iteritems():
+        if kwkey not in attributes:
+            return False
+        attval = attributes[kwkey]
+        if isinstance(kwval, set):
+            if attval not in kwval:
+                return False
+        elif callable(kwval):
+            if not kwval(attval):
+                return False
+        elif kwval != attributes[kwkey]:
+            return False
+    return True
new file mode 100644
--- /dev/null
+++ b/taskcluster/taskgraph/util/treeherder.py
@@ -0,0 +1,24 @@
+# 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 re
+
+
+def split_symbol(treeherder_symbol):
+    """Split a symbol expressed as grp(sym) into its two parts.  If no group is
+    given, the returned group is '?'"""
+    groupSymbol = '?'
+    symbol = treeherder_symbol
+    if '(' in symbol:
+        groupSymbol, symbol = re.match(r'([^(]*)\(([^)]*)\)', symbol).groups()
+    return groupSymbol, symbol
+
+
+def join_symbol(group, symbol):
+    """Perform the reverse of split_symbol, combining the given group and
+    symbol.  If the group is '?', then it is omitted."""
+    if group == '?':
+        return symbol
+    return '{}({})'.format(group, symbol)