[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,
+ }]
+};