[ct] Add chromium-analysis-sk element.


Change-Id: Ia51e0143dd171ebbd0538bc73e5573f27f7aefb0
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/298836
Reviewed-by: Ravi Mistry <rmistry@google.com>
Commit-Queue: Weston Tracey <westont@google.com>
diff --git a/ct/modules/chromium-analysis-sk/chromium-analysis-sk-demo.html b/ct/modules/chromium-analysis-sk/chromium-analysis-sk-demo.html
new file mode 100644
index 0000000..becbfd0
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/chromium-analysis-sk-demo.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>Chromium Perf Demo</title>
+    <meta charset="utf-8"/>
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  </head>
+  <body>
+    <h2>chromium-analysis-sk demo</h2><theme-chooser-sk></theme-chooser-sk>
+    <div id=container></div>
+    <error-toast-sk></error-toast-sk>
+  </body>
+</html>
diff --git a/ct/modules/chromium-analysis-sk/chromium-analysis-sk-demo.js b/ct/modules/chromium-analysis-sk/chromium-analysis-sk-demo.js
new file mode 100644
index 0000000..8d12fed
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/chromium-analysis-sk-demo.js
@@ -0,0 +1,25 @@
+import './index';
+import '../../../infra-sk/modules/theme-chooser-sk';
+import { $$ } from 'common-sk/modules/dom';
+import { fetchMock } from 'fetch-mock';
+import { benchmarks_platforms } from './test_data';
+import { pageSets } from '../pageset-selector-sk/test_data';
+import { priorities } from '../task-priority-sk/test_data';
+import { chromiumPatchResult } from '../patch-sk/test_data';
+import 'elements-sk/error-toast-sk';
+
+fetchMock.config.overwriteRoutes = false;
+fetchMock.post('begin:/_/page_sets/', pageSets);
+fetchMock.post('begin:/_/benchmarks_platforms/', benchmarks_platforms);
+fetchMock.get('begin:/_/task_priorities/', priorities);
+fetchMock.post('begin:/_/cl_data', chromiumPatchResult, { delay: 1000 });
+// For determining running tasks for the user we just say 2.
+fetchMock.postOnce('begin:/_/get', {
+  data: [], ids: [], pagination: { offset: 0, size: 1, total: 2 }, permissions: [],
+});
+fetchMock.post('begin:/_/get', {
+  data: [], ids: [], pagination: { offset: 0, size: 1, total: 0 }, permissions: [],
+});
+
+const chromiumPerf = document.createElement('chromium-analysis-sk');
+$$('#container').appendChild(chromiumPerf);
diff --git a/ct/modules/chromium-analysis-sk/chromium-analysis-sk.js b/ct/modules/chromium-analysis-sk/chromium-analysis-sk.js
new file mode 100644
index 0000000..f691b87
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/chromium-analysis-sk.js
@@ -0,0 +1,426 @@
+/**
+ * @fileoverview The bulk of the Analysis page of CT.
+ */
+
+import 'elements-sk/icon/delete-icon-sk';
+import 'elements-sk/icon/cancel-icon-sk';
+import 'elements-sk/icon/check-circle-icon-sk';
+import 'elements-sk/icon/help-icon-sk';
+import 'elements-sk/toast-sk';
+import '../../../infra-sk/modules/confirm-dialog-sk';
+import '../suggest-input-sk';
+import '../input-sk';
+import '../patch-sk';
+import '../pageset-selector-sk';
+import '../task-repeater-sk';
+import '../task-priority-sk';
+
+import { $$, $ } from 'common-sk/modules/dom';
+import { define } from 'elements-sk/define';
+import 'elements-sk/select-sk';
+import { errorMessage } from 'elements-sk/errorMessage';
+import { html } from 'lit-html';
+
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import {
+  combineClDescriptions,
+  missingLiveSitesWithCustomWebpages,
+  moreThanThreeActiveTasksChecker,
+  fetchBenchmarksAndPlatforms,
+} from '../ctfe_utils';
+
+// Chromium analysis doesn't support 1M pageset, and only Linux supports 100k.
+const unsupportedPageSetStrings = ['All', '100k'];
+const unsupportedPageSetStringsLinux = ['All'];
+
+const template = (el) => html`
+<confirm-dialog-sk id=confirm_dialog></confirm-dialog-sk>
+
+<table class=options>
+  <tr>
+    <td>Benchmark Name</td>
+    <td>
+      <suggest-input-sk
+        id=benchmark_name
+        .options=${el._benchmarks}
+        .label=${'Hit <enter> at end if entering custom benchmark'}
+        accept-custom-value
+        @value-changed=${el._refreshBenchmarkDoc}
+      ></suggest-input-sk>
+      <div>
+        <a hidden id=benchmark_doc href=#
+        target=_blank rel="noopener noreferrer">
+          Documentation
+        </a>
+      </div>
+    </td>
+  </tr>
+  <tr>
+    <td>Target Platform</td>
+    <td>
+      <select-sk id=platform_selector @selection-changed=${el._platformChanged}>
+        ${el._platforms.map((p, i) => (html`<div ?selected=${i === 1}>${p[1]}</div>`))}
+      </select-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Run on GCE
+    </td>
+    <td>
+      <select-sk id=run_on_gce>
+        <div selected id=gce_true>True</div>
+        <div id=gce_false>False</div>
+      </select-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>PageSets Type</td>
+    <td>
+      <pageset-selector-sk id=pageset_selector></pageset-selector-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Run in Parallel<br/>
+      Read about the trade-offs <a href="https://docs.google.com/document/d/1GhqosQcwsy6F-eBAmFn_ITDF7_Iv_rY9FhCKwAnk9qQ/edit?pli=1#heading=h.xz46aihphb8z">here</a>
+    </td>
+    <td>
+      <select-sk id=run_in_parallel @selection-changed=${el._updatePageSets}>
+        <div selected>True</div>
+        <div>False</div>
+      </select-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>Look for text in stdout</td>
+    <td>
+      <input-sk value="" id=match_stdout_txt class=long-field></input-sk>
+      <span class=smaller-font><b>Note:</b> All lines that contain this field in stdout will show up under CT_stdout_lines in the output CSV.</span><br/>
+      <span class=smaller-font><b>Note:</b> The count of non-overlapping exact matches of this field in stdout will show up under CT_stdout_count in the output CSV.</span><br/>
+    </td>
+  </tr>
+  <tr>
+    <td>Benchmark Arguments</td>
+    <td>
+      <input-sk value="--output-format=csv --skip-typ-expectations-tags-validation --legacy-json-trace-format" id=benchmark_args class=long-field></input-sk>
+      <span class=smaller-font><b>Note:</b> Use --num-analysis-retries=[num] to specify how many times run_benchmark should be retried. 2 is the default. 0 calls run_benchmark once.</span><br/>
+      <span class=smaller-font><b>Note:</b> Use --run-benchmark-timeout=[secs] to specify the timeout of the run_benchmark script. 300 is the default.</span><br/>
+      <span class=smaller-font><b>Note:</b> Use --max-pages-per-bot=[num] to specify the number of pages to run per bot. 100 is the default.</span>
+    </td>
+  </tr>
+  <tr>
+    <td>Browser Arguments</td>
+    <td>
+      <input-sk value="" id=browser_args class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>Field Value Column Name</td>
+    <td>
+      <input-sk value="avg" id=value_column_name class="medium-field"></input-sk>
+      <span class=smaller-font>Which column's entries to use as field values.</span>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Chromium Git patch (optional)<br/>
+      Applied to Chromium ToT<br/>
+      or to the hash specified below.
+    </td>
+    <td>
+      <patch-sk id=chromium_patch
+                patchType=chromium
+                @cl-description-changed=${el._patchChanged}>
+      </patch-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Custom APK location (optional)<br/> (See
+      <a href="https://bugs.chromium.org/p/skia/issues/detail?id=9805">skbug/9805</a>)
+    </td>
+    <td>
+      <input-sk value="" id=apk_gs_path label="Eg: gs://chrome-unsigned/android-B0urB0N/73.0.3655.0/arm_64/ChromeModern.apk" class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Telemetry Isolate Hash (optional))<br/> (See
+      <a href="https://bugs.chromium.org/p/skia/issues/detail?id=9853">skbug/9853</a>)
+    </td>
+    <td>
+      <input-sk value="" id=telemetry_isolate_hash class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>Chromium hash to sync to (optional)<br/></td>
+    <td>
+      <input-sk value="" id=chromium_hash class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Skia Git patch (optional)<br/>
+      Applied to Skia Rev in <a href="https://chromium.googlesource.com/chromium/src/+show/HEAD/DEPS">DEPS</a>
+    </td>
+    <td>
+      <patch-sk id=skia_patch
+                patchType=skia
+                @cl-description-changed=${el._patchChanged}>
+      </patch-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      V8 Git patch (optional)<br/>
+      Applied to V8 Rev in <a href="https://chromium.googlesource.com/chromium/src/+show/HEAD/DEPS">DEPS</a>
+    </td>
+    <td>
+      <patch-sk id=v8_patch
+                patchType=v8
+                @cl-description-changed=${el._patchChanged}>
+      </patch-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Catapult Git patch (optional)<br/>
+      Applied to Catapult Rev in <a href="https://chromium.googlesource.com/chromium/src/+show/HEAD/DEPS">DEPS</a>
+    </td>
+    <td>
+      <patch-sk id=catapult_patch
+                patchType=catapult
+                @cl-description-changed=${el._patchChanged}>
+      </patch-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>Repeat this task</td>
+    <td>
+      <task-repeater-sk id=repeat_after_days></task-repeater-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>Task Priority</td>
+    <td>
+      <task-priority-sk id=task_priority></task-priority-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Notifications CC list (optional)<br/>
+      Email will be sent by ct@skia.org
+    </td>
+    <td>
+      <input-sk value="" id=cc_list label="email1,email2,email3" class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>
+      Group name (optional)<br/>
+      Will be used to track runs
+    </td>
+    <td>
+      <input-sk value="" id=group_name class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td>Description</td>
+    <td>
+      <input-sk value="" id=description label="Description is required" class=long-field></input-sk>
+    </td>
+  </tr>
+  <tr>
+    <td colspan="2" class="center">
+      <div class="triggering-spinner">
+        <spinner-sk .active=${el._triggeringTask} alt="Trigger task"></spinner-sk>
+      </div>
+      <button id=submit ?disabled=${el._triggeringTask} @click=${el._validateTask}>Queue Task</button>
+    </td>
+  </tr>
+  <tr>
+    <td colspan=2 class=center>
+      <button id=view_history @click=${el._gotoRunsHistory}>View runs history</button>
+    </td>
+  </tr>
+</table>
+`;
+
+define('chromium-analysis-sk', class extends ElementSk {
+  constructor() {
+    super(template);
+    this._benchmarksToDocs = {};
+    this._benchmarks = [];
+    this._platforms = [];
+    this._triggeringTask = false;
+    this._unsupportedPageSets = unsupportedPageSetStringsLinux;
+    this._moreThanThreeActiveTasks = moreThanThreeActiveTasksChecker();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+    fetchBenchmarksAndPlatforms((json) => {
+      this._benchmarksToDocs = json.benchmarks;
+      this._benchmarks = Object.keys(json.benchmarks);
+      // { 'p1' : 'p1Desc', ... } -> [[p1, p1Desc], ...]
+      // Allows rendering descriptions in the select-sk, and converting the
+      // integer selection to platform name easily.
+      this._platforms = Object.entries(json.platforms);
+      this._render();
+      // Do this after the template is rendered, or else it fails, and don't
+      // inline a child 'selected' attribute since it won't rationalize in
+      // select-sk until later via the mutationObserver.
+      $$('#platform_selector', this).selection = 1;
+      // This gets the defaults in a valid state.
+      this._platformChanged();
+    });
+  }
+
+  _refreshBenchmarkDoc(e) {
+    const benchmarkName = e.detail.value;
+    const docElement = $$('#benchmark_doc', this);
+    if (benchmarkName && this._benchmarksToDocs[benchmarkName]) {
+      docElement.hidden = false;
+      docElement.href = this._benchmarksToDocs[benchmarkName];
+    } else {
+      docElement.hidden = true;
+      docElement.href = '#';
+    }
+  }
+
+  _platformChanged() {
+    const trueIndex = 0;
+    const falseIndex = 1;
+    const platform = this._platform();
+    let offerGCETrue = true;
+    let offerGCEFalse = true;
+    let offerParallelTrue = true;
+    if (platform === 'Android') {
+      offerGCETrue = false;
+      offerParallelTrue = false;
+    } else if (platform === 'Windows') {
+      offerGCEFalse = false;
+    }
+    // We default to use GCE for Linux, require if for Windows, and
+    // disallow it for Android.
+    const runOnGCE = $$('#run_on_gce', this);
+    runOnGCE.children[trueIndex].hidden = !offerGCETrue;
+    runOnGCE.children[falseIndex].hidden = !offerGCEFalse;
+    runOnGCE.selection = offerGCETrue ? trueIndex : falseIndex;
+
+    // We default to run in parallel, except for Android which disallows it.
+    const runInParallel = $$('#run_in_parallel', this);
+    runInParallel.children[trueIndex].hidden = !offerParallelTrue;
+    runInParallel.selection = offerParallelTrue ? trueIndex : falseIndex;
+
+    this._updatePageSets();
+  }
+
+  _updatePageSets() {
+    const platform = this._platform();
+    const runInParallel = this._runInParallel();
+    const unsupportedPageSets = (platform === 'Linux' && runInParallel)
+      ? unsupportedPageSetStringsLinux
+      : unsupportedPageSetStrings;
+    const pageSetDefault = (platform === 'Android')
+      ? 'Mobile10k'
+      : '10k';
+    const pagesetSelector = $$('pageset-selector-sk', this);
+    pagesetSelector.hideIfKeyContains = unsupportedPageSets;
+    pagesetSelector.selected = pageSetDefault;
+  }
+
+  _platform() {
+    return this._platforms[$$('#platform_selector', this).selection][0];
+  }
+
+  _runInParallel() {
+    return $$('#run_in_parallel', this).selection === 0;
+  }
+
+  _patchChanged() {
+    $$('#description', this).value = combineClDescriptions(
+      $('patch-sk', this).map((patch) => patch.clDescription),
+    );
+  }
+
+  _validateTask() {
+    if (!$('patch-sk', this).every((patch) => patch.validate())) {
+      return;
+    }
+    if (!$$('#description', this).value) {
+      errorMessage('Please specify a description');
+      $$('#description', this).focus();
+      return;
+    }
+    if (!$$('#benchmark_name', this).value) {
+      errorMessage('Please specify a benchmark');
+      $$('#benchmark_name', this).focus();
+      return;
+    }
+    if (missingLiveSitesWithCustomWebpages(
+      $$('#pageset_selector', this).customPages, $$('#benchmark_args', this).value,
+    )) {
+      $$('#benchmark_args', this).focus();
+      return;
+    }
+    if (this._moreThanThreeActiveTasks()) {
+      return;
+    }
+    $$('#confirm_dialog', this).open('Proceed with queueing task?')
+      .then(() => this._queueTask())
+      .catch(() => {
+        errorMessage('Unable to queue task');
+      });
+  }
+
+  _queueTask() {
+    this._triggeringTask = true;
+    const params = {};
+    params.benchmark = $$('#benchmark_name', this).value;
+    params.platform = this._platforms[$$('#platform_selector', this).selection][0];
+    params.page_sets = $$('#pageset_selector', this).selected;
+    params.run_on_gce = $$('#run_on_gce', this).selection === 0;
+    params.match_stdout_txt = $$('#match_stdout_txt', this).value;
+    params.apk_gs_path = $$('#apk_gs_path', this).value;
+    params.telemetry_isolate_hash = $$('#telemetry_isolate_hash', this).value;
+    params.custom_webpages = $$('#pageset_selector', this).customPages;
+    params.run_in_parallel = $$('#run_in_parallel', this).selection === 0;
+    params.benchmark_args = $$('#benchmark_args', this).value;
+    params.browser_args = $$('#browser_args', this).value;
+    params.value_column_name = $$('#value_column_name', this).value;
+    params.desc = $$('#description', this).value;
+    params.chromium_patch = $$('#chromium_patch', this).patch;
+    params.skia_patch = $$('#skia_patch', this).patch;
+    params.v8_patch = $$('#v8_patch', this).patch;
+    params.catapult_patch = $$('#catapult_patch', this).patch;
+    params.chromium_hash = $$('#chromium_hash', this).value;
+    params.repeat_after_days = $$('#repeat_after_days', this).frequency;
+    params.task_priority = $$('#task_priority', this).priority;
+    if ($$('#cc_list', this).value) {
+      params.cc_list = $$('#cc_list', this).value.split(',');
+    }
+    if ($$('#group_name', this).value) {
+      params.group_name = $$('#group_name', this).value;
+    }
+
+    fetch('/_/add_chromium_analysis_task', {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+      },
+      body: JSON.stringify(params),
+    })
+      .then(() => this._gotoRunsHistory())
+      .catch((e) => {
+        this._triggeringTask = false;
+        errorMessage(e);
+      });
+  }
+
+  _gotoRunsHistory() {
+    window.location.href = '/chromium_analysis_runs/';
+  }
+});
diff --git a/ct/modules/chromium-analysis-sk/chromium-analysis-sk.scss b/ct/modules/chromium-analysis-sk/chromium-analysis-sk.scss
new file mode 100644
index 0000000..a60fbea
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/chromium-analysis-sk.scss
@@ -0,0 +1,45 @@
+@import '../colors.css';
+
+chromium-analysis-sk {
+  table.options {
+    padding: 2em;
+    margin: 1em;
+    border-style: solid;
+  }
+
+  select-sk {
+    * {
+    padding: 0;
+    }
+    [selected] {
+      background-color: var(--primary-variant);
+    }
+  }
+
+  .triggering-spinner {
+    margin: auto;
+    vertical-align: middle;
+  }
+
+
+  .long-field {
+    width: 40em;
+  }
+
+  .smaller-font {
+    font-size: 80%;
+  }
+
+  table.options>tbody>tr>td {
+    padding: 1em 2em;
+  }
+
+  td.center {
+    text-align:center;
+    padding-top:2em;
+  }
+}
+
+.darkmode chromium-analysis-sk table.options {
+  background-color: var(--surface-2dp);
+}
diff --git a/ct/modules/chromium-analysis-sk/chromium-analysis-sk_test.js b/ct/modules/chromium-analysis-sk/chromium-analysis-sk_test.js
new file mode 100644
index 0000000..bce0eb5
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/chromium-analysis-sk_test.js
@@ -0,0 +1,185 @@
+import './index';
+
+import { $, $$ } from 'common-sk/modules/dom';
+import { fetchMock } from 'fetch-mock';
+import { benchmarks_platforms } from './test_data';
+import { pageSets } from '../pageset-selector-sk/test_data';
+import { priorities } from '../task-priority-sk/test_data';
+import { chromiumPatchResult } from '../patch-sk/test_data';
+import {
+  eventPromise,
+  setUpElementUnderTest,
+} from '../../../infra-sk/modules/test_util';
+
+describe('chromium-analysis-sk', () => {
+  fetchMock.config.overwriteRoutes = false;
+  const factory = setUpElementUnderTest('chromium-analysis-sk');
+  // Returns a new element with the pagesets, task priorirites, and
+  // active tasks fetches complete, and benchmarks and platforms set.
+  const newInstance = async (activeTasks, init) => {
+    fetchMock.postOnce('begin:/_/page_sets/', pageSets);
+    fetchMock.postOnce('begin:/_/benchmarks_platforms/', benchmarks_platforms);
+    fetchMock.getOnce('begin:/_/task_priorities/', priorities);
+    mockActiveTasks(activeTasks);
+    const wrappedInit = (ele) => {
+      if (init) {
+        init(ele);
+      }
+    };
+    const ele = factory(wrappedInit);
+    await fetchMock.flush(true);
+    return ele;
+  };
+
+  // Make our test object global to make helper functions convenient.
+  let chromiumAnalysis;
+
+  afterEach(() => {
+    //  Check all mock fetches called at least once and reset.
+    expect(fetchMock.done()).to.be.true;
+    fetchMock.reset();
+  });
+
+  const mockActiveTasks = (n) => {
+    n = n || 0;
+    // For running tasks for the user we put a nonzero total in one of the
+    // responses, and 0 in the remaining 6.
+    fetchMock.postOnce('begin:/_/get', {
+      data: [],
+      ids: [],
+      pagination: { offset: 0, size: 1, total: n },
+      permissions: [],
+    });
+    fetchMock.post('begin:/_/get', {
+      data: [],
+      ids: [],
+      pagination: { offset: 0, size: 1, total: 0 },
+      permissions: [],
+    }, { repeat: 6 });
+  };
+
+  const setDescription = (d) => {
+    $$('#description', chromiumAnalysis).value = d;
+  };
+
+  const setBenchmark = (b) => {
+    $$('#benchmark_name', chromiumAnalysis).value = b;
+  };
+
+  const setPatch = async (patchtype, value, response) => {
+    fetchMock.postOnce('begin:/_/cl_data', response);
+    const input = $$(`#${patchtype}_patch input-sk`);
+    input.value = value;
+    input.dispatchEvent(new Event('input', {
+      bubbles: true,
+    }));
+    await fetchMock.flush(true);
+  };
+
+  const clickSubmit = () => {
+    $$('#submit', chromiumAnalysis).click();
+  };
+
+  const expectTaskTriggered = () => {
+    fetchMock.postOnce('begin:/_/add_chromium_analysis_task', {});
+  };
+
+  it('loads, has defaults set', async () => {
+    chromiumAnalysis = await newInstance();
+    expect(chromiumAnalysis._platforms[$$('#platform_selector', chromiumAnalysis)
+      .selection][0]).to.equal('Linux');
+    expect($$('#pageset_selector', chromiumAnalysis)).to.have.property('selected', '10k');
+    expect($$('#pageset_selector', chromiumAnalysis)).to.have.property('customPages', '');
+    expect($$('#repeat_after_days', this)).to.have.property('frequency', '0');
+    expect($$('#task_priority', this)).to.have.property('priority', '100');
+    expect($$('#benchmark_args', this)).to.have.property('value',
+      '--output-format=csv --skip-typ-expectations-tags-validation'
+      + ' --legacy-json-trace-format');
+    expect($$('#value_column_name', this)).to.have.property('value', 'avg');
+  });
+
+  it('requires description', async () => {
+    chromiumAnalysis = await newInstance();
+    const event = eventPromise('error-sk');
+    clickSubmit();
+    const err = await event;
+    expect(err.detail.message).to.equal('Please specify a description');
+  });
+
+  it('requires benchmark', async () => {
+    chromiumAnalysis = await newInstance();
+    setDescription('Testing things');
+    const event = eventPromise('error-sk');
+    clickSubmit();
+    const err = await event;
+    expect(err.detail.message).to.equal('Please specify a benchmark');
+  });
+
+  it('rejects bad patch', async () => {
+    chromiumAnalysis = await newInstance();
+    setDescription('Testing things');
+    setBenchmark('a benchmark');
+    await setPatch('skia', '1234', { cl: '1234' }); // Patch result is bogus.
+    let event = eventPromise('error-sk');
+    clickSubmit();
+    let err = await event;
+    expect(err.detail.message).to.contain('Unable to fetch skia patch from CL 1234');
+
+    await setPatch('skia', '1234', {}); // CL doesn't load.
+    event = eventPromise('error-sk');
+    clickSubmit();
+    err = await event;
+    expect(err.detail.message).to.contain('Unable to load skia CL 1234');
+  });
+
+  it('triggers a new task', async () => {
+    chromiumAnalysis = await newInstance();
+    setDescription('Testing things');
+    setBenchmark('a benchmark');
+    await setPatch('chromium', '1234', chromiumPatchResult);
+    // Karma can't handle page reloads, so disable it.
+    chromiumAnalysis._gotoRunsHistory = () => { };
+    expectTaskTriggered();
+    clickSubmit();
+    $('#confirm_dialog button')[1].click(); // brittle way to press 'ok'
+    await fetchMock.flush(true);
+    const taskJson = JSON.parse(fetchMock.lastOptions().body);
+    // Here we test the 'interesting' arguments. We try a single patch,
+    // and we don't bother filling in the simple string arguments.
+    const expectation = {
+      benchmark: 'a benchmark',
+      page_sets: '10k',
+      match_stdout_txt: '',
+      benchmark_args: '--output-format=csv --skip-typ-expectations-tags-validation --legacy-json-trace-format',
+      browser_args: '',
+      value_column_name: 'avg',
+      desc: 'Testing https://chromium-review.googlesource.com/c/2222715/3 (Roll Skia from cc7ec24ca824 to 1dbc3b533962 (3 revisions))',
+      chromium_patch: '\n\ndiff --git a/DEPS b/DEPS\nindex 849ae22..ee07579 100644\n--- a/DEPS\n+++ b/DEPS\n@@ -178,7 +178,7 @@\n   # Three lines of non-changing comments so that\n   # the commit queue can handle CLs rolling Skia\n   # and whatever else without interference from each other.\n-  \'skia_revision\': \'cc7ec24ca824ca13d5a8a8e562fcec695ae54390\',\n+  \'skia_revision\': \'1dbc3b533962b0ae803a2a5ee89f61146228d73b\',\n   # Three lines of non-changing comments so that\n   # the commit queue can handle CLs rolling V8\n   # and whatever else without interference from each other.\n',
+      chromium_hash: '',
+      apk_gs_path: '',
+      telemetry_isolate_hash: '',
+      repeat_after_days: '0',
+      task_priority: '100',
+      run_in_parallel: true,
+      platform: 'Linux',
+      run_on_gce: true,
+      custom_webpages: '',
+      skia_patch: '',
+      v8_patch: '',
+      catapult_patch: '',
+    };
+
+    expect(taskJson).to.deep.equal(expectation);
+  });
+
+  it('rejects if too many active tasks', async () => {
+    // Report user as having 4 active tasks.
+    chromiumAnalysis = await newInstance(4);
+    setDescription('Testing things');
+    setBenchmark('a benchmark');
+    const event = eventPromise('error-sk');
+    clickSubmit();
+    const err = await event;
+    expect(err.detail.message).to.contain('You have 4 currently running tasks');
+  });
+});
diff --git a/ct/modules/chromium-analysis-sk/index.js b/ct/modules/chromium-analysis-sk/index.js
new file mode 100644
index 0000000..09f9880
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/index.js
@@ -0,0 +1,2 @@
+import './chromium-analysis-sk';
+import './chromium-analysis-sk.scss';
diff --git a/ct/modules/chromium-analysis-sk/test_data.js b/ct/modules/chromium-analysis-sk/test_data.js
new file mode 100644
index 0000000..9f122a4
--- /dev/null
+++ b/ct/modules/chromium-analysis-sk/test_data.js
@@ -0,0 +1,20 @@
+export const benchmarks_platforms = {
+  benchmarks: {
+    'ad_tagging.cluster_telemetry': 'https://docs.google.com/document/d/1zlWQoLjGuYOWDR_vkVRYoVbU89JetNDOlcDuOaNAzDc/',
+    generic_trace_ct: 'https://docs.google.com/document/d/1vGd7dnrxayMYHPO72wWkwTvjMnIRrel4yxzCr1bMiis/',
+    'leak_detection.cluster_telemetry': 'https://docs.google.com/document/d/1wUWa7dWUdvr6dLdYHFfMQdnvgzt7lrrvzYfpAK-_6e0/',
+    'loading.cluster_telemetry': 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/v8_loading_ct.py',
+    'memory.cluster_telemetry': 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/memory_ct.py',
+    rasterize_and_record_micro_ct: 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/rasterize_and_record_micro_ct.py',
+    'rendering.cluster_telemetry': 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/rendering_ct.py',
+    repaint_ct: 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/repaint.py',
+    usecounter_ct: 'https://docs.google.com/document/d/1FSzJm2L2ow6pZTM_CuyHNJecXuX7Mx3XmBzL4SFHyLA/',
+    'v8.loading.cluster_telemetry': 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/v8_loading_ct.py',
+    'v8.loading_runtime_stats.cluster_telemetry': 'https://cs.chromium.org/chromium/src/tools/analysis/contrib/cluster_telemetry/v8_loading_runtime_stats_ct.py',
+  },
+  platforms: {
+    Android: 'Android (Pixel2 devices)',
+    Linux: 'Linux (Ubuntu18.04 machines)',
+    Windows: 'Windows (2016 DataCenter Server cloud instances)',
+  },
+};
diff --git a/ct/modules/chromium-perf-sk/chromium-perf-sk.js b/ct/modules/chromium-perf-sk/chromium-perf-sk.js
index 5e9e87b..20f2b7d 100644
--- a/ct/modules/chromium-perf-sk/chromium-perf-sk.js
+++ b/ct/modules/chromium-perf-sk/chromium-perf-sk.js
@@ -63,7 +63,7 @@
     </td>
   </tr>
   <tr>
-    <td>PagestSets Type</td>
+    <td>PageSets Type</td>
     <td>
       <pageset-selector-sk id=pageset_selector
         .hideIfKeyContains=${unsupportedPageSetStrings}>
@@ -220,7 +220,7 @@
       <div class="triggering-spinner">
         <spinner-sk .active=${el._triggeringTask} alt="Trigger task"></spinner-sk>
       </div>
-      <button  id=submit ?disabled=${el._triggeringTask} @click=${el._validateTask}>Queue Task</button>
+      <button id=submit ?disabled=${el._triggeringTask} @click=${el._validateTask}>Queue Task</button>
     </td>
   </tr>
   <tr>