[gold] New component corpus-selector-sk.

Bug: skia:9525
Change-Id: I01ddc62227f488add6dc54e1850e5a8c622a62ca
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/254776
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/golden/modules/corpus-selector-sk/corpus-selector-sk-demo.html b/golden/modules/corpus-selector-sk/corpus-selector-sk-demo.html
new file mode 100644
index 0000000..b4bb21f
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/corpus-selector-sk-demo.html
@@ -0,0 +1,52 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>corpus-selector-sk</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>
+<gold-scaffold-sk app_title="Skia Public" testing_offline>
+  <h2>Default corpus renderer function:</h2>
+  <div id="default-fn-corpus-selector-placeholder">
+    <!-- A corpus-selector-sk element will be inserted here dynamically. -->
+  </div>
+  <small>Does not update automatically.</small>
+
+  <h2>Custom corpus renderer function:</h2>
+  <div id="custom-fn-corpus-selector-placeholder">
+    <!-- A corpus-selector-sk element will be inserted here dynamically. -->
+  </div>
+  <small>
+    Updates automatically every 3 seconds.
+    For demonstration purposes, all negative counts increase by 1 at each update
+    cycle.
+  </small>
+
+  <h2>Custom corpus renderer function (long):</h2>
+  <div id="custom-fn-long-corpus-selector-placeholder">
+    <!-- A corpus-selector-sk element will be inserted here dynamically. -->
+  </div>
+  <small>
+    Long corpus labels to demonstrate wrapping.
+  </small>
+
+</gold-scaffold-sk>
+
+<div style="margin: 5em 1em 1em; padding: 1em; border: 1px solid lightgrey">
+  <p><strong>Event log</strong></p>
+  <textarea id="event-log" readonly rows="10" cols="80"></textarea>
+</div>
+
+<div style="margin: 5em 1em 1em; padding: 1em; border: 1px solid lightgrey">
+  <p><strong>DEBUGGING</strong></p>
+  <p>
+    <input type="checkbox" id="simulate-rpc-failure"/>
+    <label for="simulate-rpc-failure">
+      Simulate RPC internal server error on subsequent roundtrips.
+    </label>
+  </p>
+</div>
+</body>
+</html>
diff --git a/golden/modules/corpus-selector-sk/corpus-selector-sk-demo.js b/golden/modules/corpus-selector-sk/corpus-selector-sk-demo.js
new file mode 100644
index 0000000..d26e09b
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/corpus-selector-sk-demo.js
@@ -0,0 +1,52 @@
+import './index.js'
+import '../gold-scaffold-sk'
+import { delay } from '../demo_util';
+import { trstatus } from './test_data';
+import { $$ } from 'common-sk/modules/dom'
+import { fetchMock } from 'fetch-mock';
+
+const fakeRpcDelayMillis = 300;
+
+fetchMock.get('/json/trstatus', () => {
+  if ($$("#simulate-rpc-failure").checked) {
+    return 500;  // Fake an internal server error.
+  }
+
+  // Increase negative triaged count by 1 at every update cycle.
+  trstatus.corpStatus.forEach((corpus) => corpus.negativeCount++);
+
+  return delay(trstatus, fakeRpcDelayMillis);
+});
+
+// Create the components after we've had a chance to mock the JSON endpoint.
+
+const handleCorpusSelected = (e) => {
+  const corpus = e.detail.corpus;
+  const log = $$("#event-log");
+  log.value = corpus.padEnd(15) + new Date() + '\n' + log.value;
+};
+
+// Default corpus renderer function.
+const el1 = document.createElement('corpus-selector-sk');
+el1.selectedCorpus = 'gm';
+el1.addEventListener('corpus-selected', handleCorpusSelected);
+$$('#default-fn-corpus-selector-placeholder').appendChild(el1);
+
+// Custom corpus renderer function.
+const el2 = document.createElement('corpus-selector-sk');
+el2.selectedCorpus = 'gm';
+el2.setAttribute('update-freq-seconds', '3');
+el2.corpusRendererFn =
+    (corpus) =>
+        `${corpus.name} : ${corpus.untriagedCount} / ${corpus.negativeCount}`;
+el2.addEventListener('corpus-selected', handleCorpusSelected);
+$$('#custom-fn-corpus-selector-placeholder').appendChild(el2);
+
+// Custom corpus renderer function (long).
+const el3 = document.createElement('corpus-selector-sk');
+el3.selectedCorpus = 'gm';
+el3.corpusRendererFn =
+    (corpus) => `${corpus.name} : yadda yadda yadda yadda yadda`;
+el3.addEventListener('corpus-selected', handleCorpusSelected);
+$$('#custom-fn-long-corpus-selector-placeholder').appendChild(el3);
+
diff --git a/golden/modules/corpus-selector-sk/corpus-selector-sk.js b/golden/modules/corpus-selector-sk/corpus-selector-sk.js
new file mode 100644
index 0000000..0ffae9d
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/corpus-selector-sk.js
@@ -0,0 +1,129 @@
+/**
+ * @module modules/corpus-selector-sk
+ * @description <h2><code>corpus-selector-sk</code></h2>
+ *
+ * Lists the available corpora and lets the user select a corpus. Obtains the
+ * available corpora from /json/trstatus.
+ *
+ * @attr update-freq-seconds {int} how often to ping the server for updates.
+ *
+ * @evt corpus-selected - Sent when the user selects a different corpus. Field
+ *      event.detail.corpus will contain the selected corpus.
+ */
+
+import { define } from 'elements-sk/define';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { html } from 'lit-html';
+import { classMap } from 'lit-html/directives/class-map.js';
+import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow';
+
+const template = (el) => html`
+${!el.corpora ? html`<p>Loading corpora details...</p>` : html`
+  <ul>
+    ${el.corpora.map((corpus) => html`
+      <li class=${classMap({selected: el.selectedCorpus === corpus.name})}
+          title="${el.corpusRendererFn(corpus)}"
+          @click=${() => el.selectedCorpus = corpus.name}>
+        ${el.corpusRendererFn(corpus)}
+      </li>
+    `)}
+  </ul>
+`}
+`;
+
+define('corpus-selector-sk', class extends ElementSk {
+  constructor() {
+    super(template);
+    this._corpusRendererFn = (corpus) => corpus.name;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render(); // Render loading indicator.
+    this._fetch();
+    if (this._updateFreqSeconds > 0) {
+      this._interval =
+          setInterval(() => this._fetch(), this._updateFreqSeconds * 1000);
+    }
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    if (this._interval) {
+      clearInterval(this._interval);
+      this._interval = null;
+    }
+  }
+
+  _fetch() {
+    // Force only one fetch at a time. Abort any outstanding requests. Useful if
+    // a request takes longer than the update frequency.
+    if (this._fetchController) {
+      this._fetchController.abort();
+    }
+    this._fetchController = new AbortController();
+
+    fetch('/json/trstatus', {
+      method: 'GET',
+      signal: this._fetchController.signal
+    })
+        .then(jsonOrThrow)
+        .then((json) => {
+          this.corpora = json.corpStatus;
+          this._render();
+          this._sendLoaded();
+        })
+        .catch((e) => {
+          this._sendError(e);
+        });
+  }
+
+  get _updateFreqSeconds() {
+    return +this.getAttribute('update-freq-seconds');
+  }
+
+  /**
+   * @prop corpusRendererFn {function} A function that takes a corpus and
+   *     returns the text to be displayed on the corpus selector widget.
+   */
+  get corpusRendererFn() { return this._corpusRendererFn; }
+  set corpusRendererFn(fn) {
+    this._corpusRendererFn = fn;
+    this._render();
+  }
+
+  /** @prop selectedCorpus {string} The selected corpus name. */
+  get selectedCorpus() { return this._selectedCorpus; }
+  set selectedCorpus(corpus) {
+    if (this._selectedCorpus === corpus) {
+      return;
+    }
+    this._selectedCorpus = corpus;
+    this._render();
+    this._sendCorpusSelected();
+  }
+
+  // Intended to be used only from corpus-selector-sk_test.js.
+  _sendLoaded() {
+    this.dispatchEvent(
+        new CustomEvent('corpus-selector-sk-loaded', {bubbles: true}));
+  }
+
+  _sendCorpusSelected() {
+    this.dispatchEvent(
+        new CustomEvent('corpus-selected', {
+          detail: {
+            corpus: this.selectedCorpus
+          }, bubbles: true,
+        }));
+  }
+
+  _sendError(e) {
+    this.dispatchEvent(new CustomEvent('fetch-error', {
+      detail: {
+        error: e,
+        loading: 'corpus selector',
+      }, bubbles: true
+    }));
+  }
+});
diff --git a/golden/modules/corpus-selector-sk/corpus-selector-sk.scss b/golden/modules/corpus-selector-sk/corpus-selector-sk.scss
new file mode 100644
index 0000000..8709d45
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/corpus-selector-sk.scss
@@ -0,0 +1,44 @@
+@import '~elements-sk/colors';
+
+corpus-selector-sk {
+  ul {
+    display: flex;
+    flex-wrap: wrap;
+    padding: 0;
+  }
+
+  li {
+    // This invisible border prevents vertical flickering when the <ul> wraps
+    // into multiple lines and the user selects a corpus in a different line.
+    border-bottom: 2px solid transparent;
+    cursor: pointer;
+    display: block;
+    margin: 0 1em;
+    padding: 0.5em 0;
+
+    &.selected {
+      border-bottom: 2px solid;
+      font-weight: bold;
+    }
+
+    // Normally the li's width would increase when the "selected" class is
+    // applied, which makes its inner text bold. This would make the corpus bar
+    // "flicker" every time the user clicks on a different corpus.
+    //
+    // To prevent this, a pseudo-element is added to the li with the same
+    // contents but in bold text. The pseudo-element has zero height but
+    // non-zero width, which makes it effectively invisible, but with the effect
+    // of stretching the li to the width it would be if its contents were in
+    // bold text.
+    //
+    // See https://stackoverflow.com/a/32570813.
+    &::after {
+      content: attr(title); // The "title" attribute match the li's contents.
+      display: block;
+      font-weight: bold;
+      height: 0;
+      overflow: hidden;
+      visibility: hidden;
+    }
+  }
+}
diff --git a/golden/modules/corpus-selector-sk/corpus-selector-sk_test.js b/golden/modules/corpus-selector-sk/corpus-selector-sk_test.js
new file mode 100644
index 0000000..7239b97
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/corpus-selector-sk_test.js
@@ -0,0 +1,301 @@
+import './index.js';
+import { $, $$ } from 'common-sk/modules/dom';
+import { deepCopy } from 'common-sk/modules/object';
+import { fetchMock } from 'fetch-mock';
+import { trstatus } from './test_data';
+
+describe('corpus-selector-sk', () => {
+  // Component under test.
+  let corpusSelectorSk;
+
+  // Creates a new corpus-selector-sk instance with the given options and
+  // attaches it to the DOM. Variable corpusSelectorSk is set to the new
+  // instance.
+  function newCorpusSelectorSk(
+      {updateFreqSeconds, corpusRendererFn, selectedCorpus}={}) {
+    corpusSelectorSk = document.createElement('corpus-selector-sk');
+    if (updateFreqSeconds)
+      corpusSelectorSk.setAttribute('update-freq-seconds', updateFreqSeconds);
+    if (corpusRendererFn) corpusSelectorSk.corpusRendererFn = corpusRendererFn;
+    if (selectedCorpus) corpusSelectorSk.selectedCorpus = selectedCorpus;
+    document.body.appendChild(corpusSelectorSk);
+  }
+
+  // Same as newCorpusSelectorSk, except it returns a promise that resolves when
+  // the corpora is loaded.
+  function loadCorpusSelectorSk(options) {
+    const loaded = eventPromise('corpus-selector-sk-loaded');
+    newCorpusSelectorSk(options);
+    return loaded;
+  }
+
+  const corporaLiText =
+      (el) => $("li", el).map((li) => li.innerText);
+
+  const selectedCorpusLiText = (el) => {
+    const li = $$('li.selected', el);
+    return li ? li.innerText : null;
+  };
+
+  let clock;
+
+  beforeEach(() => {
+    clock = sinon.useFakeTimers();
+  });
+
+  afterEach(() => {
+    // Remove the stale instance under test.
+    if (corpusSelectorSk) {
+      document.body.removeChild(corpusSelectorSk);
+      corpusSelectorSk = null;
+    }
+
+    fetchMock.reset();
+    clock.restore();
+  });
+
+  it('shows loading indicator', () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    newCorpusSelectorSk(); // Don't wait for the corpora to load.
+    expect(corpusSelectorSk.innerText).to.equal('Loading corpora details...');
+  });
+
+  it('renders corpora with unspecified default corpus', async () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    await loadCorpusSelectorSk();
+    expect(corporaLiText(corpusSelectorSk)).to.deep.equal(
+        ['canvaskit', 'colorImage', 'gm', 'image', 'pathkit', 'skp', 'svg']);
+    expect(corpusSelectorSk.selectedCorpus).to.be.undefined;
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.be.null;
+  });
+
+  it('renders corpora with default corpus', async () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    await loadCorpusSelectorSk({selectedCorpus: 'gm'});
+    expect(corporaLiText(corpusSelectorSk)).to.deep.equal(
+        ['canvaskit', 'colorImage', 'gm', 'image', 'pathkit', 'skp', 'svg']);
+    expect(corpusSelectorSk.selectedCorpus).to.equal('gm');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('gm');
+  });
+
+  it('renders corpora with custom function', async () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    await loadCorpusSelectorSk({
+      corpusRendererFn:
+          (c) => `${c.name} : ${c.untriagedCount} / ${c.negativeCount}`
+    });
+    expect(corporaLiText(corpusSelectorSk)).to.deep.equal([
+        'canvaskit : 2 / 2',
+        'colorImage : 0 / 1',
+        'gm : 61 / 1494',
+        'image : 22 / 35',
+        'pathkit : 0 / 0',
+        'skp : 0 / 1',
+        'svg : 19 / 21']);
+  });
+
+  it('selects corpus and emits "corpus-selected" event when clicked',
+      async () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    await loadCorpusSelectorSk({selectedCorpus: 'gm'});
+    expect(corpusSelectorSk.selectedCorpus).to.equal('gm');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('gm');
+
+    // Click on 'svg' corpus.
+    const corpusSelected = eventPromise('corpus-selected');
+    $$('li[title="svg"]', corpusSelectorSk).click();
+    const ev = await corpusSelected;
+
+    // Assert that selected corpus changed.
+    expect(ev.detail.corpus).to.equal('svg');
+    expect(corpusSelectorSk.selectedCorpus).to.equal('svg');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('svg');
+  });
+
+  it('can set the selected corpus programmatically', async () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    await loadCorpusSelectorSk({selectedCorpus: 'gm'});
+    expect(corpusSelectorSk.selectedCorpus).to.equal('gm');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('gm');
+
+    // Select corpus 'svg' programmatically.
+    const corpusSelected = eventPromise('corpus-selected');
+    corpusSelectorSk.selectedCorpus = 'svg';
+    const ev = await corpusSelected;
+
+    // Assert that selected corpus changed.
+    expect(ev.detail.corpus).to.equal('svg');
+    expect(corpusSelectorSk.selectedCorpus).to.equal('svg');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('svg');
+  });
+
+  it('does not trigger corpus change event if selected corpus is clicked',
+      async () => {
+    fetchMock.get('/json/trstatus', trstatus);
+    await loadCorpusSelectorSk({selectedCorpus: 'gm'});
+    expect(corpusSelectorSk.selectedCorpus).to.equal('gm');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('gm');
+
+    // Click on 'gm' corpus.
+    corpusSelectorSk.dispatchEvent = sinon.fake();
+    $$('li[title="gm"]', corpusSelectorSk).click();
+
+    // Assert that selected corpus didn't change and that no event was emitted.
+    expect(corpusSelectorSk.dispatchEvent.callCount).to.equal(0);
+    expect(corpusSelectorSk.selectedCorpus).to.equal('gm');
+    expect(selectedCorpusLiText(corpusSelectorSk)).to.equal('gm');
+  });
+
+  it('updates automatically with the specified frequency', async () => {
+    // Mock /json/trstatus such that the negativeCounts will increase by 1000
+    // after each call.
+    let updatedStatus = deepCopy(trstatus);
+    const fakeRpcEndpoint = sinon.fake(() => {
+      const retval = deepCopy(updatedStatus);
+      updatedStatus.corpStatus.forEach((corp) => corp.negativeCount += 1000);
+      return retval;
+    });
+    fetchMock.get('/json/trstatus', fakeRpcEndpoint);
+
+    // Initial load.
+    await loadCorpusSelectorSk({
+      corpusRendererFn:
+          (c) => `${c.name} : ${c.untriagedCount} / ${c.negativeCount}`,
+      updateFreqSeconds: 10,
+    });
+    expect(fakeRpcEndpoint.callCount).to.equal(1);
+    expect(corporaLiText(corpusSelectorSk)).to.deep.equal([
+      'canvaskit : 2 / 2',
+      'colorImage : 0 / 1',
+      'gm : 61 / 1494',
+      'image : 22 / 35',
+      'pathkit : 0 / 0',
+      'skp : 0 / 1',
+      'svg : 19 / 21']);
+
+    // First update.
+    let updated = eventPromise('corpus-selector-sk-loaded', 0);
+    clock.tick(10000);
+    expect(fakeRpcEndpoint.callCount).to.equal(2);
+    await updated;
+    expect(corporaLiText(corpusSelectorSk)).to.deep.equal([
+      'canvaskit : 2 / 1002',
+      'colorImage : 0 / 1001',
+      'gm : 61 / 2494',
+      'image : 22 / 1035',
+      'pathkit : 0 / 1000',
+      'skp : 0 / 1001',
+      'svg : 19 / 1021']);
+
+    // Second update.
+    updated = eventPromise('corpus-selector-sk-loaded', 0);
+    clock.tick(10000);
+    expect(fakeRpcEndpoint.callCount).to.equal(3);
+    await updated;
+    expect(corporaLiText(corpusSelectorSk)).to.deep.equal([
+      'canvaskit : 2 / 2002',
+      'colorImage : 0 / 2001',
+      'gm : 61 / 3494',
+      'image : 22 / 2035',
+      'pathkit : 0 / 2000',
+      'skp : 0 / 2001',
+      'svg : 19 / 2021']);
+  });
+
+  it('does not update if update frequency is not specified', async () => {
+    const fakeRpcEndpoint = sinon.fake.returns(trstatus);
+    fetchMock.get('/json/trstatus', fakeRpcEndpoint);
+
+    // RPC end-point called once on creation.
+    await loadCorpusSelectorSk();
+    expect(fakeRpcEndpoint.callCount).to.equal(1);
+
+    // No further RPC calls after waiting a long time.
+    clock.tick(Number.MAX_SAFE_INTEGER);
+    expect(fakeRpcEndpoint.callCount).to.equal(1);
+  });
+
+  it('stops pinging server for updates after detached from DOM', async () => {
+    const fakeRpcEndpoint = sinon.fake.returns(trstatus);
+    fetchMock.get('/json/trstatus', fakeRpcEndpoint);
+
+    // RPC end-point called once on creation.
+    await loadCorpusSelectorSk({updateFreqSeconds: 10});
+    expect(fakeRpcEndpoint.callCount).to.equal(1);
+
+    // Does update.
+    clock.tick(20000);
+    expect(fakeRpcEndpoint.callCount).to.equal(3);
+
+    // Detach component from DOM.
+    document.body.removeChild(corpusSelectorSk);
+
+    // No further RPC calls.
+    clock.tick(20000);
+    expect(fakeRpcEndpoint.callCount).to.equal(3);
+
+    // Reattach component, otherwise afterEach() will try to remove it and fail.
+    document.body.appendChild(corpusSelectorSk);
+  });
+
+
+  it('should emit event "fetch-error" on RPC failure', async () => {
+    fetchMock.get('/json/trstatus', 500);
+
+    const fetchError = eventPromise('fetch-error');
+    newCorpusSelectorSk();
+    await fetchError;
+
+    expect(corporaLiText(corpusSelectorSk)).to.be.empty;
+  })
+});
+
+// TODO(lovisolo): Move to test_util.js.
+// Returns a promise that will resolve when the given event is caught at the
+// document's body element, or reject if the event isn't caught within the given
+// amount of time.
+//
+// Set timeoutMillis = 0 to skip call to setTimeout(). This is necessary on
+// tests that simulate the passing of time with sinon.useFakeTimers(), which
+// could trigger the timeout before the promise has a chance to catch the event.
+//
+// Sample usage:
+//
+//   // Code under test.
+//   function doSomethingThatTriggersCustomEvent() {
+//     ...
+//     this.dispatchEvent(
+//         new CustomEvent('my-event', {detail: {foo: 'bar'}, bubbles: true});
+//   }
+//
+//   // Test.
+//   it('should trigger a custom event', async () => {
+//     const myEvent = eventPromise('my-event');
+//     doSomethingThatTriggersCustomEvent();
+//     const ev = await myEvent;
+//     expect(ev.detail.foo).to.equal('bar');
+//   });
+function eventPromise(event, timeoutMillis = 5000) {
+  // The executor function passed as a constructor argument to the Promise
+  // object is executed immediately. This guarantees that the event handler
+  // is added to document.body before returning.
+  return new Promise((resolve, reject) => {
+    let timeout;
+    const handler = (e) => {
+      document.body.removeEventListener(event, handler);
+      clearTimeout(timeout);
+      resolve(e);
+    };
+    // Skip setTimeout() call with timeoutMillis = 0. Useful when faking time in
+    // tests with sinon.useFakeTimers(). See
+    // https://sinonjs.org/releases/v7.5.0/fake-timers/.
+    if (timeoutMillis !== 0) {
+      timeout = setTimeout(() => {
+        document.body.removeEventListener(event, handler);
+        reject(new Error(`timed out after ${timeoutMillis} ms ` +
+            `while waiting to catch event "${event}"`));
+      }, timeoutMillis);
+    }
+    document.body.addEventListener(event, handler);
+  });
+}
diff --git a/golden/modules/corpus-selector-sk/index.js b/golden/modules/corpus-selector-sk/index.js
new file mode 100644
index 0000000..47635ec
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/index.js
@@ -0,0 +1,2 @@
+import './corpus-selector-sk.js'
+import './corpus-selector-sk.scss'
diff --git a/golden/modules/corpus-selector-sk/test_data.js b/golden/modules/corpus-selector-sk/test_data.js
new file mode 100644
index 0000000..5582170
--- /dev/null
+++ b/golden/modules/corpus-selector-sk/test_data.js
@@ -0,0 +1,58 @@
+export const trstatus = {
+  "ok": false,
+  "firstCommit": {
+    "commit_time": 1572357082,
+    "hash": "ee08d523f60a04499c9023a349ef8174ab301f8f",
+    "author": "Alice (alice@example.com)",
+  },
+  "lastCommit": {
+    "commit_time": 1573598625,
+    "hash": "9501212cd0580acfed85a90c3a16b81847fde482",
+    "author": "Bob (bob@example.com)",
+  },
+  "totalCommits": 256,
+  "filledCommits": 256,
+  "corpStatus": [{
+    "name": "canvaskit",
+    "ok": false,
+    "minCommitHash": "",
+    "untriagedCount": 2,
+    "negativeCount": 2,
+  }, {
+    "name": "colorImage",
+    "ok": true,
+    "minCommitHash": "",
+    "untriagedCount": 0,
+    "negativeCount": 1,
+  }, {
+    "name": "gm",
+    "ok": false,
+    "minCommitHash": "",
+    "untriagedCount": 61,
+    "negativeCount": 1494,
+  }, {
+    "name": "image",
+    "ok": false,
+    "minCommitHash": "",
+    "untriagedCount": 22,
+    "negativeCount": 35,
+  }, {
+    "name": "pathkit",
+    "ok": true,
+    "minCommitHash": "",
+    "untriagedCount": 0,
+    "negativeCount": 0,
+  }, {
+    "name": "skp",
+    "ok": true,
+    "minCommitHash": "",
+    "untriagedCount": 0,
+    "negativeCount": 1,
+  }, {
+    "name": "svg",
+    "ok": false,
+    "minCommitHash": "",
+    "untriagedCount": 19,
+    "negativeCount": 21,
+  }]
+};