[ct] Add chromium-builds-sk element.

Change-Id: I84e7c4f77e855535046fca958a19ff30e1ab575b
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/304036
Reviewed-by: Ravi Mistry <rmistry@google.com>
diff --git a/ct/modules/chromium-builds-sk/chromium-builds-sk-demo.html b/ct/modules/chromium-builds-sk/chromium-builds-sk-demo.html
new file mode 100644
index 0000000..d5b0bfc
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/chromium-builds-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-builds-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-builds-sk/chromium-builds-sk-demo.js b/ct/modules/chromium-builds-sk/chromium-builds-sk-demo.js
new file mode 100644
index 0000000..ca3cd39
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/chromium-builds-sk-demo.js
@@ -0,0 +1,20 @@
+import './index';
+import '../../../infra-sk/modules/theme-chooser-sk';
+import { $$ } from 'common-sk/modules/dom';
+import { fetchMock } from 'fetch-mock';
+import { chromiumRevResult, skiaRevResult } from './test_data';
+import 'elements-sk/error-toast-sk';
+
+fetchMock.config.overwriteRoutes = false;
+fetchMock.post('begin:/_/chromium_rev_data', chromiumRevResult, { delay: 1000 });
+fetchMock.post('begin:/_/skia_rev_data', skiaRevResult, { 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-builds-sk');
+$$('#container').appendChild(chromiumPerf);
diff --git a/ct/modules/chromium-builds-sk/chromium-builds-sk.js b/ct/modules/chromium-builds-sk/chromium-builds-sk.js
new file mode 100644
index 0000000..986be7a
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/chromium-builds-sk.js
@@ -0,0 +1,236 @@
+/**
+ * @fileoverview The bulk of the Chromium Builds 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/spinner-sk';
+import 'elements-sk/toast-sk';
+import '../../../infra-sk/modules/confirm-dialog-sk';
+import '../chromium-build-selector-sk';
+import '../suggest-input-sk';
+import '../input-sk';
+import '../pageset-selector-sk';
+import '../task-repeater-sk';
+
+import { $$ } from 'common-sk/modules/dom';
+import { fromObject } from 'common-sk/modules/query';
+import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
+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 {
+  moreThanThreeActiveTasksChecker,
+} from '../ctfe_utils';
+
+const template = (el) => html`
+<confirm-dialog-sk id=confirm_dialog></confirm-dialog-sk>
+
+<table class=options>
+  <tr>
+    <td>Chromium Commit Hash</td>
+    <td>
+      <input-sk id=chromium_rev value=LKGR
+        @input=${el._chromiumRevChanged} class=hash-field>
+      </input-sk>
+    </td>
+    <td>
+      <div class="rev-detail-container">
+        <div class="loading-rev-spinner">
+          <spinner-sk id=chromium_spinner alt="Loading Chromium commit details"></spinner-sk>
+        </div>
+        <div class="rev-detail">${el._formatRevData(el._chromiumRevData)}</div>
+      </div>
+    </td>
+  </tr>
+  <tr>
+    <td>Skia Commit Hash</td>
+    <td>
+      <input-sk id=skia_rev value=LKGR  @input=${el._skiaRevChanged} class=hash-field></input-sk>
+    </td>
+    <td>
+      <div class="rev-detail-container">
+        <div class="loading-rev-spinner">
+          <spinner-sk id=skia_spinner alt="Loading Skia commit details"></spinner-sk>
+        </div>
+        <div class="rev-detail">${el._formatRevData(el._skiaRevData)}</div>
+      </div>
+    </td>
+  </tr>
+  <tr>
+    <td>Repeat this task</td>
+    <td>
+      <task-repeater-sk id=repeat_after_days></task-repeater-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-builds-sk', class extends ElementSk {
+  constructor() {
+    super(template);
+    this._triggeringTask = false;
+    this._moreThanThreeActiveTasks = moreThanThreeActiveTasksChecker();
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+    // Load LKGR data.
+    this._chromiumRevChanged();
+    this._skiaRevChanged();
+  }
+
+
+  _chromiumRevChanged() {
+    const spinner = $$('#chromium_spinner', this);
+    const newValue = $$('#chromium_rev', this).value;
+    this._chromiumRev = newValue;
+    if (!newValue) {
+      this._chromiumRevData = null;
+      spinner.active = false;
+      return;
+    }
+    spinner.active = true;
+    const params = { rev: newValue };
+    const url = `/_/chromium_rev_data?${fromObject(params)}`;
+
+    fetch(url, { method: 'POST' })
+      .then(jsonOrThrow)
+      .then((json) => {
+        if (this._chromiumRev === newValue) {
+          if (json.commit) {
+            this._chromiumRevData = json;
+          } else {
+            this._chromiumRevData = null;
+          }
+        }
+      })
+      .catch((err) => {
+        if (this._chromiumRev === newValue) {
+          this._chromiumRevData = { error: err };
+        }
+      })
+      .finally(() => {
+        if (this._chromiumRev === newValue) {
+          spinner.active = false;
+        }
+        this._render();
+      });
+  }
+
+  _skiaRevChanged() {
+    const spinner = $$('#skia_spinner', this);
+    const newValue = $$('#skia_rev', this).value;
+    this._skiaRev = newValue;
+    if (!newValue) {
+      this._skiaRevData = null;
+      spinner.active = false;
+      return;
+    }
+    spinner.active = true;
+    const params = { rev: newValue };
+    const url = `/_/skia_rev_data?${fromObject(params)}`;
+
+    fetch(url, { method: 'POST' })
+      .then(jsonOrThrow)
+      .then((json) => {
+        if (this._skiaRev === newValue) {
+          if (json.commit) {
+            this._skiaRevData = json;
+          } else {
+            this._skiaRevData = null;
+          }
+        }
+      })
+      .catch((err) => {
+        if (this._skiaRev === newValue) {
+          this._skiaRevData = { error: err };
+        }
+      })
+      .finally(() => {
+        if (this._skiaRev === newValue) {
+          spinner.active = false;
+        }
+        this._render();
+      });
+  }
+
+  _formatRevData(revData) {
+    if (revData) {
+      if (!revData.error) {
+        return `${revData.commit} by ${revData.author.name} submitted ${
+          revData.committer.time}`;
+      }
+      return revData.error;
+    }
+    return '';
+  }
+
+  _validateTask() {
+    if (!this._chromiumRevData || !this._chromiumRevData.commit) {
+      errorMessage('Please enter a valid Chromium commit hash.');
+      $$('#chromium_rev', this).focus();
+      return;
+    }
+    if (!this._skiaRevData || !this._skiaRevData.commit) {
+      errorMessage('Please enter a valid Skia commit hash.');
+      $$('#skia_rev', 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.chromium_rev = this._chromiumRevData.commit;
+    params.chromium_rev_ts = this._chromiumRevData.committer.time;
+    params.skia_rev = this._skiaRevData.commit;
+    params.repeat_after_days = $$('#repeat_after_days', this).frequency;
+
+    fetch('/_/add_chromium_build_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_builds_runs/';
+  }
+});
diff --git a/ct/modules/chromium-builds-sk/chromium-builds-sk.scss b/ct/modules/chromium-builds-sk/chromium-builds-sk.scss
new file mode 100644
index 0000000..1a103eb
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/chromium-builds-sk.scss
@@ -0,0 +1,47 @@
+@import '../colors.css';
+
+chromium-builds-sk {
+  background-color: var(--surface);
+  color: var(--on-surface);
+  table.options {
+    padding-left: 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-builds-sk table.options {
+  background-color: var(--surface-2dp);
+}
diff --git a/ct/modules/chromium-builds-sk/chromium-builds-sk_test.js b/ct/modules/chromium-builds-sk/chromium-builds-sk_test.js
new file mode 100644
index 0000000..c5205e1
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/chromium-builds-sk_test.js
@@ -0,0 +1,174 @@
+import './index';
+
+import { $, $$ } from 'common-sk/modules/dom';
+import { fetchMock } from 'fetch-mock';
+import { chromiumRevResult, skiaRevResult } from './test_data';
+import { buildsJson } from '../chromium-build-selector-sk/test_data';
+import {
+  eventPromise,
+  setUpElementUnderTest,
+} from '../../../infra-sk/modules/test_util';
+
+describe('chromium-builds-sk', () => {
+  fetchMock.config.overwriteRoutes = false;
+  const factory = setUpElementUnderTest('chromium-builds-sk');
+  // Returns a new element with the pagesets, task priorirites, and
+  // active tasks fetches complete, and benchmarks and platforms set.
+  const newInstance = async (activeTasks) => {
+    mockActiveTasks(activeTasks);
+    // Create a unique dummy result to distinguish from when we load
+    // non-LKGR revisions.
+    const lkgrDummy = Object.assign({}, chromiumRevResult);
+    lkgrDummy.commit = 'aaaaaa';
+    fetchMock.postOnce('begin:/_/chromium_rev_data?rev=LKGR', lkgrDummy);
+    fetchMock.postOnce('begin:/_/skia_rev_data?rev=LKGR', lkgrDummy);
+    const ele = factory();
+    await fetchMock.flush(true);
+    return ele;
+  };
+
+  // Make our test object global to make helper functions convenient.
+  let chromiumBuilds;
+
+  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 clickSubmit = () => {
+    $$('#submit', chromiumBuilds).click();
+  };
+
+  const setChromiumRev = async (value, result) => {
+    if (result) {
+      fetchMock.postOnce('begin:/_/chromium_rev_data', result);
+    }
+    const input = $$('#chromium_rev');
+    input.value = value;
+    input.dispatchEvent(new Event('input', {
+      bubbles: true,
+    }));
+    await fetchMock.flush(true);
+  };
+
+  const setSkiaRev = async (value, result) => {
+    if (result) {
+      fetchMock.postOnce('begin:/_/skia_rev_data', result);
+    }
+    const input = $$('#skia_rev');
+    input.value = value;
+    input.dispatchEvent(new Event('input', {
+      bubbles: true,
+    }));
+    await fetchMock.flush(true);
+  };
+
+  it('loads, has defaults set', async () => {
+    chromiumBuilds = await newInstance();
+    expect($$('#repeat_after_days', this)).to.have.property('frequency', '0');
+    expect($$('#chromium_rev', this)).to.have.property('value', 'LKGR');
+    expect($$('#skia_rev', this)).to.have.property('value', 'LKGR');
+  });
+
+  it('requires Chromium hash', async () => {
+    chromiumBuilds = await newInstance();
+    // Empty input is invalid.
+    await setChromiumRev('');
+    let event = eventPromise('error-sk');
+    clickSubmit();
+    let err = await event;
+    expect(err.detail.message).to.contain('Please enter a valid Chromium commit hash.');
+    // Backend doesn't give a valid revision.
+    await setChromiumRev('abc', 503);
+    event = eventPromise('error-sk');
+    clickSubmit();
+    err = await event;
+    expect(err.detail.message).to.contain('Please enter a valid Chromium commit hash.');
+  });
+
+  it('requires Skia hash', async () => {
+    chromiumBuilds = await newInstance();
+    // Empty input is invalid.
+    await setSkiaRev('');
+    let event = eventPromise('error-sk');
+    clickSubmit();
+    let err = await event;
+    expect(err.detail.message).to.contain('Please enter a valid Skia commit hash.');
+    // Backend doesn't give a valid revision.
+    await setSkiaRev('abc', 503);
+    event = eventPromise('error-sk');
+    clickSubmit();
+    err = await event;
+    expect(err.detail.message).to.contain('Please enter a valid Skia commit hash.');
+  });
+
+  it('triggers a new task, LKGR', async () => {
+    chromiumBuilds = await newInstance();
+    // Karma can't handlje page reloads, so disable it.
+    chromiumBuilds._gotoRunsHistory = () => { };
+    clickSubmit();
+    fetchMock.postOnce('begin:/_/add_chromium_build_task', {});
+    $('#confirm_dialog button')[1].click(); // brittle way to press 'ok'
+    await fetchMock.flush(true);
+    const taskJson = JSON.parse(fetchMock.lastOptions().body);
+    const expectation = {
+      chromium_rev: 'aaaaaa',
+      chromium_rev_ts: 'Mon May 08 21:08:33 2017',
+      skia_rev: 'aaaaaa',
+      repeat_after_days: '0',
+    };
+
+    expect(taskJson).to.deep.equal(expectation);
+  });
+
+  it('triggers a new task, explicit', async () => {
+    chromiumBuilds = await newInstance();
+    // Karma can't handlje page reloads, so disable it.
+    chromiumBuilds._gotoRunsHistory = () => { };
+    await setChromiumRev('abc', chromiumRevResult);
+    await setSkiaRev('abc', skiaRevResult);
+    clickSubmit();
+    fetchMock.postOnce('begin:/_/add_chromium_build_task', {});
+    $('#confirm_dialog button')[1].click(); // brittle way to press 'ok'
+    await fetchMock.flush(true);
+    const taskJson = JSON.parse(fetchMock.lastOptions().body);
+    const expectation = {
+      chromium_rev: 'deadbeefdeadbeef',
+      chromium_rev_ts: 'Mon May 08 21:08:33 2017',
+      skia_rev: '123456789abcdef',
+      repeat_after_days: '0',
+    };
+
+    console.log(taskJson);
+    console.log(expectation);
+    expect(taskJson).to.deep.equal(expectation);
+  });
+
+  it('rejects if too many active tasks', async () => {
+    // Report user as having 4 active tasks.
+    chromiumBuilds = await newInstance(4);
+    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-builds-sk/index.js b/ct/modules/chromium-builds-sk/index.js
new file mode 100644
index 0000000..9378d73
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/index.js
@@ -0,0 +1,2 @@
+import './chromium-builds-sk';
+import './chromium-builds-sk.scss';
diff --git a/ct/modules/chromium-builds-sk/test_data.js b/ct/modules/chromium-builds-sk/test_data.js
new file mode 100644
index 0000000..6e4db65
--- /dev/null
+++ b/ct/modules/chromium-builds-sk/test_data.js
@@ -0,0 +1,59 @@
+export const chromiumRevResult = {
+  commit: 'deadbeefdeadbeef',
+  tree: 'badbeef',
+  parents: [
+    '123456789',
+  ],
+  author: {
+    name: 'alice',
+    email: 'alice@bob.org',
+    time: 'Mon May 08 21:08:33 2017',
+  },
+  committer: {
+    name: 'Commit bot',
+    email: 'commit-bot@chromium.org',
+    time: 'Mon May 08 21:08:33 2017',
+  },
+  message: 'Do a thing',
+  tree_diff: [
+    {
+      type: 'modify',
+      old_id: '123456',
+      old_mode: 33188,
+      old_path: 'some/path',
+      new_id: '789123',
+      new_mode: 33188,
+      new_path: 'some/path',
+    },
+  ],
+};
+
+export const skiaRevResult = {
+  commit: '123456789abcdef',
+  tree: 'abcdef123456789',
+  parents: [
+    'deadbeef',
+  ],
+  author: {
+    name: 'bob',
+    email: 'bob@alice.org',
+    time: 'Mon May 08 21:08:33 2017',
+  },
+  committer: {
+    name: 'Commit bot',
+    email: 'commit-bot@chromium.org',
+    time: 'Mon May 08 21:08:33 2017',
+  },
+  message: 'Do a thing',
+  tree_diff: [
+    {
+      type: 'modify',
+      old_id: '123456',
+      old_mode: 33188,
+      old_path: 'some/path',
+      new_id: '789123',
+      new_mode: 33188,
+      new_path: 'some/path',
+    },
+  ],
+};
diff --git a/ct/package-lock.json b/ct/package-lock.json
index cb2b410..f4e70df 100644
--- a/ct/package-lock.json
+++ b/ct/package-lock.json
@@ -3158,14 +3158,15 @@
       }
     },
     "fetch-mock": {
-      "version": "7.3.9",
-      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.3.9.tgz",
-      "integrity": "sha512-PgsTbiQBNapFz2P2UwDl3gowK3nZqfV4HdyDZ1dI4eTGGH9MLAeBglIPbyDbbNQoGYBOfla6/9uaiq7az2z4Aw==",
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.7.3.tgz",
+      "integrity": "sha512-I4OkK90JFQnjH8/n3HDtWxH/I6D1wrxoAM2ri+nb444jpuH3RTcgvXx2el+G20KO873W727/66T7QhOvFxNHPg==",
       "dev": true,
       "requires": {
         "babel-polyfill": "^6.26.0",
         "core-js": "^2.6.9",
         "glob-to-regexp": "^0.4.0",
+        "lodash.isequal": "^4.5.0",
         "path-to-regexp": "^2.2.1",
         "whatwg-url": "^6.5.0"
       },
@@ -6166,6 +6167,12 @@
       "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==",
       "dev": true
     },
+    "lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
+      "dev": true
+    },
     "lodash.sortby": {
       "version": "4.7.0",
       "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
diff --git a/ct/package.json b/ct/package.json
index b3ab530..8aca22e 100644
--- a/ct/package.json
+++ b/ct/package.json
@@ -21,7 +21,7 @@
     "chai": "~4.2.0",
     "css-loader": "^3.5.3",
     "cssmin": "~0.4.2",
-    "fetch-mock": "~7.3.9",
+    "fetch-mock": "~7.7.0",
     "html-loader": "^0.5.5",
     "html-minifier": "~3.0.0",
     "karma": "~4.3.0",