[gold] Add lit-html component byblameentry-sk, to be used by the "by blame" lit-html page.

Bug: skia:9525
Change-Id: I839733356218ac9f4546032ccdc23f22dd6ec206
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/255089
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
Reviewed-by: Kevin Lubick <kjlubick@google.com>
diff --git a/golden/modules/byblameentry-sk/byblameentry-sk-demo.html b/golden/modules/byblameentry-sk/byblameentry-sk-demo.html
new file mode 100644
index 0000000..874b0a3
--- /dev/null
+++ b/golden/modules/byblameentry-sk/byblameentry-sk-demo.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>byblameentry-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>
+  <!-- byblameentry-sk elements will be inserted here dynamically. -->
+</gold-scaffold-sk>
+</body>
+</html>
diff --git a/golden/modules/byblameentry-sk/byblameentry-sk-demo.js b/golden/modules/byblameentry-sk/byblameentry-sk-demo.js
new file mode 100644
index 0000000..1d7d05c
--- /dev/null
+++ b/golden/modules/byblameentry-sk/byblameentry-sk-demo.js
@@ -0,0 +1,11 @@
+import './index.js'
+import '../gold-scaffold-sk'
+import { $$ } from 'common-sk/modules/dom'
+import { byBlameEntry, gitLog } from './test_data'
+
+const entry = document.createElement('byblameentry-sk');
+entry.byBlameEntry = byBlameEntry;
+entry.gitLog = gitLog;
+entry.baseRepoUrl = 'https://skia.googlesource.com/skia.git';
+entry.corpus = 'gm';
+$$('gold-scaffold-sk').appendChild(entry);
diff --git a/golden/modules/byblameentry-sk/byblameentry-sk.js b/golden/modules/byblameentry-sk/byblameentry-sk.js
new file mode 100644
index 0000000..58565c4
--- /dev/null
+++ b/golden/modules/byblameentry-sk/byblameentry-sk.js
@@ -0,0 +1,162 @@
+/**
+ * @module modules/byblame-page-sk/byblameentry-sk
+ * @description <h2><code>byblameentry-sk</code></h2>
+ *
+ * Displays a
+ * [ByBlameEntry]{@link https://github.com/google/skia-buildbot/blob/0e14ae66aa226821e981bbd4c63dc8d07776997a/golden/go/web/web.go#L318},
+ * that is, a blame group of untriaged digests.
+ */
+
+import { define } from 'elements-sk/define'
+import { ElementSk } from '../../../infra-sk/modules/ElementSk'
+import { html } from 'lit-html'
+import { diffDate } from '../../../common-sk/modules/human';
+
+const template = (el) => html`
+<div class=blame>
+  <p>
+    <a href=${el._blameHref()} class=triage target=_blank rel=noopener>
+      ${el.byBlameEntry.nDigests === 1
+          ? '1 untriaged digest'
+          : `${el.byBlameEntry.nDigests} untriaged digests`}
+    </a>
+  </p>
+
+  ${!el.byBlameEntry.commits || el.byBlameEntry.commits.length === 0
+      ? html`<p class=no-blamelist>No blamelist.</p>`
+      : blameListTemplate(el)}
+
+  <h3>Tests affected</h3>
+  <p class=num-tests-affected>
+    ${el.byBlameEntry.nTests === 1
+        ? '1 test affected.'
+        : `${el.byBlameEntry.nTests} tests affected.`}
+  </p>
+
+  ${affectedTestsTemplate(el.byBlameEntry.affectedTests)}
+</div>
+`;
+
+const blameListTemplate = (el) => html`
+<h3>Blame</h3>
+
+<ul class=blames>
+  ${el.byBlameEntry.commits.map(
+      (commit) => html`
+          <li>
+            <a href=${el._commitHref(commit.hash)}
+               target=_blank
+               rel=noopener>
+              ${commit.hash.slice(0, 7)}
+            </a>
+            <span class=commit-message>
+              ${el._commitMessage(commit.hash)}
+            </span>
+            <br/>
+            <small>
+              <span class=author>${commit.author}</span>,
+              <span class=age>
+                ${diffDate(commit.commit_time * 1000)}
+              </span> ago.
+            </small>
+          </li>`)}
+</ul>`;
+
+const affectedTestsTemplate =
+    (affectedTests) =>
+        !affectedTests || affectedTests.length === 0
+            ? ''
+            : html`
+<table class=affected-tests>
+  <thead>
+    <tr>
+      <th>Test</th>
+      <th># Digests</th>
+      <th>Example</th>
+    </tr>
+  </thead>
+  <tbody>
+    ${affectedTests.map(
+        (test) => html`
+            <tr>
+              <td class=test>${test.test}</td>
+              <td class=num-digests>${test.num}</td>
+              <td>
+                <a href=${detailHref(test)}
+                   class=example-link
+                   target=_blank
+                   rel=noopener>
+                  ${test.sample_digest}
+                </a>
+              </td>
+            </tr>`)}
+  </tbody>
+</table>`;
+
+const detailHref =
+    (test) => `/detail?test=${test.test}&digest=${test.sample_digest}`;
+
+define('byblameentry-sk', class extends ElementSk {
+  constructor() {
+    super(template);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+  }
+
+  /**
+   * @prop byBlameEntry {Object} A ByBlameEntry object returned by the
+   *     /json/byblame RPC endpoint.
+   */
+  get byBlameEntry() { return this._byBlameEntry; }
+  set byBlameEntry(v) {
+    this._byBlameEntry = v;
+    this._render();
+  }
+
+  /**
+   * @prop gitLog {Object} A gitLog object fetched from /json/gitlog containing
+   *     commit messages for the commits referenced by the ByBlameEntry object.
+   */
+  get gitLog() { return this._gitLog; }
+  set gitLog(v) {
+    this._gitLog = v;
+    this._render();
+  }
+
+  /** @prop corpus {string} The corpus corresponding to this blame group. */
+  get corpus() { return this._corpus; }
+  set corpus(v) {
+    this._corpus = v;
+    this._render();
+  }
+
+  /** @prop baseRepoUrl {string} Base repository URL. */
+  get baseRepoUrl() { return this._baseRepoUrl; }
+  set baseRepoUrl(v) {
+    this._baseRepoUrl = v;
+    this._render();
+  }
+
+  _blameHref() {
+    const query = encodeURIComponent(`source_type=${this.corpus}`);
+    const groupID = this.byBlameEntry.groupID;
+    return `/search?blame=${groupID}&unt=true&head=true&query=${query}`;
+  }
+
+  _commitHref(hash) {
+    if (!hash || !this.baseRepoUrl) {
+      return '';
+    }
+    const path = this.baseRepoUrl.indexOf('github.com') !== -1 ? 'commit' :'+';
+    return `${this.baseRepoUrl}/${path}/${hash}`;
+  }
+
+  _commitMessage(hash) {
+    const commitInfo =
+        this.gitLog.log.find((commitInfo) => commitInfo.commit === hash);
+    return commitInfo ? commitInfo.message : '';
+  }
+});
diff --git a/golden/modules/byblameentry-sk/byblameentry-sk.scss b/golden/modules/byblameentry-sk/byblameentry-sk.scss
new file mode 100644
index 0000000..d799a6f
--- /dev/null
+++ b/golden/modules/byblameentry-sk/byblameentry-sk.scss
@@ -0,0 +1,19 @@
+@import '~elements-sk/colors';
+
+byblameentry-sk {
+  .blame {
+    border: 1px solid var(--light-gray);
+    margin: 10px;
+    padding: 10px;
+  }
+
+  table.affected-tests {
+    td {
+      padding: 5px;
+    }
+  }
+
+  ul.blames li {
+    margin-bottom: 1em;
+  }
+}
\ No newline at end of file
diff --git a/golden/modules/byblameentry-sk/byblameentry-sk_test.js b/golden/modules/byblameentry-sk/byblameentry-sk_test.js
new file mode 100644
index 0000000..ccba35f
--- /dev/null
+++ b/golden/modules/byblameentry-sk/byblameentry-sk_test.js
@@ -0,0 +1,274 @@
+import './index.js'
+import { $, $$ } from 'common-sk/modules/dom'
+import { deepCopy } from 'common-sk/modules/object'
+import { byBlameEntry, gitLog } from './test_data'
+
+describe('byblameentry-sk', () => {
+  let byBlameEntrySk;
+
+  function newByBlameEntrySk(
+      byBlameEntry,
+      {
+        testGitLog = gitLog,
+        baseRepoUrl = 'https://skia.googlesource.com/skia.git',
+        corpus = 'gm'
+      } = {}) {
+    byBlameEntrySk = document.createElement('byblameentry-sk');
+    byBlameEntrySk.byBlameEntry = byBlameEntry;
+    byBlameEntrySk.gitLog = testGitLog;
+    byBlameEntrySk.baseRepoUrl = baseRepoUrl;
+    byBlameEntrySk.corpus = corpus;
+    document.body.appendChild(byBlameEntrySk);
+  }
+
+  let clock;
+
+  beforeEach(() => {
+    // This is necessary to make commit ages deterministic, and is set to 50
+    // seconds after Elisa's commit in the 'full example' test case below.
+    clock = sinon.useFakeTimers(1573149864000); // Nov 7, 2019 6:04:24 PM GMT
+  });
+
+  afterEach(() => {
+    // Remove the stale instance under test.
+    if (byBlameEntrySk) {
+      document.body.removeChild(byBlameEntrySk);
+      byBlameEntrySk = null;
+    }
+    clock.restore();
+  });
+
+  describe('full example', () => {
+    it('renders correctly', async () => {
+      // This is a comprehensive example of a blame group with multiple
+      // untriaged digests that could have originated from two different
+      // commits, and includes a list of affected tests along with links to an
+      // example untriaged digest for each affected test.
+
+      await newByBlameEntrySk(byBlameEntry);
+      expectTriageLinkEquals(
+          '112 untriaged digests',
+          // This server-generated blame ID is a colon-separated list of the
+          // commit hashes blamed for these untriaged digests.
+          '/search?blame=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&unt=true&head=true&query=source_type%3Dgm');
+      expectBlamesListEquals([{
+        linkText: 'bbbbbbb',
+        linkHref: 'https://skia.googlesource.com/skia.git/+/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+        commitMessage: 'One glyph() to rule them all!!!',
+        author: 'Elisa (elisa@example.com)',
+        age: '50s'
+      }, {
+        linkText: 'aaaaaaa',
+        linkHref: 'https://skia.googlesource.com/skia.git/+/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+        commitMessage: 'flesh out blendmodes through Screen',
+        author: 'Joe (joe@example.com)',
+        age: '5m'
+      }]);
+      expectNumTestsAffectedEquals('7 tests affected.');
+      expectAffectedTestsTableEquals([{
+        test: 'aarectmodes',
+        numDigests: 50,
+        exampleLinkText: 'c6476baec94eb6a5071606575318e4df',
+        exampleLinkHref: '/detail?test=aarectmodes&digest=c6476baec94eb6a5071606575318e4df',
+      }, {
+        test: 'aaxfermodes',
+        numDigests: 32,
+        exampleLinkText: '4acfd6b3a3943cc5d75cd22e966ae6f1',
+        exampleLinkHref: '/detail?test=aaxfermodes&digest=4acfd6b3a3943cc5d75cd22e966ae6f1',
+      }, {
+        test: 'hairmodes',
+        numDigests: 21,
+        exampleLinkText: 'f9e20c63b5ce3b58d9b6a90fa3e7224c',
+        exampleLinkHref: '/detail?test=hairmodes&digest=f9e20c63b5ce3b58d9b6a90fa3e7224c',
+      }, {
+        test: 'imagefilters_xfermodes',
+        numDigests: 5,
+        exampleLinkText: '47644613317040264fea6fa815af32e8',
+        exampleLinkHref: '/detail?test=imagefilters_xfermodes&digest=47644613317040264fea6fa815af32e8',
+      }, {
+        test: 'lattice2',
+        numDigests: 2,
+        exampleLinkText: '16e41798ecd59b1523322a57b49cc17f',
+        exampleLinkHref: '/detail?test=lattice2&digest=16e41798ecd59b1523322a57b49cc17f',
+      }, {
+        test: 'xfermodes',
+        numDigests: 1,
+        exampleLinkText: '8fbee03f794c455c4e4842ec2736b744',
+        exampleLinkHref: '/detail?test=xfermodes&digest=8fbee03f794c455c4e4842ec2736b744',
+      }, {
+        test: 'xfermodes3',
+        numDigests: 1,
+        exampleLinkText: 'fed2ff29abe371fc0ec1b2c65dfb3949',
+        exampleLinkHref: '/detail?test=xfermodes3&digest=fed2ff29abe371fc0ec1b2c65dfb3949',
+      }]);
+    });
+  });
+
+  describe('triage link', () => {
+    it('renders link text in singular if there is just 1 digest', async () => {
+      const testByBlameEntry = deepCopy(byBlameEntry);
+      testByBlameEntry.nDigests = 1;
+      await newByBlameEntrySk(testByBlameEntry);
+      expectTriageLinkEquals(
+          '1 untriaged digest',
+          '/search?blame=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&unt=true&head=true&query=source_type%3Dgm');
+    });
+
+    it('includes the right corpus in the link href', async () => {
+      await newByBlameEntrySk(byBlameEntry, {corpus: 'svg'});
+      expectTriageLinkEquals(
+          '112 untriaged digests',
+          '/search?blame=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb&unt=true&head=true&query=source_type%3Dsvg');
+    })
+  });
+
+  describe('blamelist', () => {
+    it('shows "No blamelist" message if there are 0 blames', async () => {
+      const testByBlameEntry = deepCopy(byBlameEntry);
+      testByBlameEntry.commits = [];
+      await newByBlameEntrySk(testByBlameEntry);
+      expectBlamesListEquals([]);
+    });
+
+    it('points commit links to GitHub if repo is hosted there', async () => {
+      await newByBlameEntrySk(
+          byBlameEntry,
+          {baseRepoUrl: 'https://github.com/google/skia'});
+      expectBlamesListEquals([{
+        linkText: 'bbbbbbb',
+        linkHref: 'https://github.com/google/skia/commit/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+        commitMessage: 'One glyph() to rule them all!!!',
+        author: 'Elisa (elisa@example.com)',
+        age: '50s'
+      }, {
+        linkText: 'aaaaaaa',
+        linkHref: 'https://github.com/google/skia/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+        commitMessage: 'flesh out blendmodes through Screen',
+        author: 'Joe (joe@example.com)',
+        age: '5m'
+      }]);
+    });
+
+    it('shows empty commit messages if Git log is empty/missing', async () => {
+      await newByBlameEntrySk(byBlameEntry, {testGitLog: {log: []}});
+      expectBlamesListEquals([{
+        linkText: 'bbbbbbb',
+        linkHref: 'https://skia.googlesource.com/skia.git/+/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
+        commitMessage: '',
+        author: 'Elisa (elisa@example.com)',
+        age: '50s'
+      }, {
+        linkText: 'aaaaaaa',
+        linkHref: 'https://skia.googlesource.com/skia.git/+/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+        commitMessage: '',
+        author: 'Joe (joe@example.com)',
+        age: '5m'
+      }]);
+    })
+  });
+
+  describe('affected tests', () => {
+    it('renders correctly with nTests = 0', async () => {
+      const testByBlameEntry = deepCopy(byBlameEntry);
+      testByBlameEntry.nTests = 0;
+      testByBlameEntry.affectedTests = [];
+      await newByBlameEntrySk(testByBlameEntry);
+      expectNumTestsAffectedEquals('0 tests affected.');
+      expectAffectedTestsTableEquals([]);
+    });
+
+    it('renders correctly with nTests = 1 and 0 affected tests', async () => {
+      const testByBlameEntry = deepCopy(byBlameEntry);
+      testByBlameEntry.nTests = 1;
+      testByBlameEntry.affectedTests = [];
+      await newByBlameEntrySk(testByBlameEntry);
+      expectNumTestsAffectedEquals('1 test affected.');
+      expectAffectedTestsTableEquals([]);
+    });
+
+    it('renders correctly with nTests = 2 and 0 affected tests', async () => {
+      const testByBlameEntry = deepCopy(byBlameEntry);
+      testByBlameEntry.nTests = 2;
+      testByBlameEntry.affectedTests = [];
+      await newByBlameEntrySk(testByBlameEntry);
+      expectNumTestsAffectedEquals('2 tests affected.');
+      expectAffectedTestsTableEquals([]);
+    });
+
+    it('renders correctly with nTests = 2 and one affected test', async () => {
+      const testByBlameEntry = deepCopy(byBlameEntry);
+      testByBlameEntry.nTests = 2;
+      testByBlameEntry.affectedTests = [{
+        "test": "aarectmodes",
+        "num": 5,
+        "sample_digest": "c6476baec94eb6a5071606575318e4df",
+      }];
+      await newByBlameEntrySk(testByBlameEntry);
+      expectNumTestsAffectedEquals('2 tests affected.');
+      expectAffectedTestsTableEquals([{
+        test: 'aarectmodes',
+        numDigests: 5,
+        exampleLinkText: 'c6476baec94eb6a5071606575318e4df',
+        exampleLinkHref:
+            '/detail?test=aarectmodes&digest=c6476baec94eb6a5071606575318e4df',
+      }]);
+    });
+  });
+
+  const expectTriageLinkEquals = (text, href) => {
+    const triageLink = $$('a.triage', byBlameEntrySk);
+    expect(triageLink.innerText).to.contain(text);
+    expect(triageLink.href).to.have.string(href);
+  };
+
+  const expectBlamesListEquals = (expectedBlames) => {
+    const noBlameList = $$('p.no-blamelist', byBlameEntrySk);
+    const blames = $('ul.blames li', byBlameEntrySk);
+
+    if (!expectedBlames.length) {
+      expect(noBlameList.innerText).to.contain('No blamelist.');
+      expect(blames).to.be.empty;
+    } else {
+      expect(noBlameList).to.be.null;
+      expect(blames).to.have.length(expectedBlames.length);
+
+      for (let i = 0; i < expectedBlames.length; i++) {
+        const linkText = $$('a', blames[i]).innerText;
+        const linkHref = $$('a', blames[i]).href;
+        const commitMessage =
+            $$('.commit-message', blames[i]).innerText;
+        const author = $$('.author', blames[i]).innerText;
+        const age = $$('.age', blames[i]).innerText;
+
+        expect(linkText).to.contain(expectedBlames[i].linkText);
+        expect(linkHref).to.equal(expectedBlames[i].linkHref);
+        expect(commitMessage).to.contain(expectedBlames[i].commitMessage);
+        expect(author).to.contain(expectedBlames[i].author);
+        expect(age).to.contain(expectedBlames[i].age);
+      }
+    }
+  };
+
+  const expectNumTestsAffectedEquals =
+      (numTestsAffected) =>
+          expect($$('.num-tests-affected', byBlameEntrySk).innerText)
+              .to.contain(numTestsAffected);
+
+  const expectAffectedTestsTableEquals = (expectedRows) => {
+    const actualRows = $('.affected-tests tbody tr');
+    expect(actualRows.length).to.equal(expectedRows.length);
+
+    for (let i = 0; i < expectedRows.length; i++) {
+      const test = $$('.test', actualRows[i]).innerText;
+      const numDigests =
+          +$$('.num-digests', actualRows[i]).innerText;
+      const exampleLinkText = $$('a.example-link', actualRows[i]).innerText;
+      const exampleLinkHref = $$('a.example-link', actualRows[i]).href;
+
+      expect(test).to.contain(expectedRows[i].test);
+      expect(numDigests).to.equal(expectedRows[i].numDigests);
+      expect(exampleLinkText).to.contain(expectedRows[i].exampleLinkText);
+      expect(exampleLinkHref).to.contain(expectedRows[i].exampleLinkHref);
+    }
+  };
+});
diff --git a/golden/modules/byblameentry-sk/index.js b/golden/modules/byblameentry-sk/index.js
new file mode 100644
index 0000000..43cb8c9
--- /dev/null
+++ b/golden/modules/byblameentry-sk/index.js
@@ -0,0 +1,2 @@
+import './byblameentry-sk.js'
+import './byblameentry-sk.scss'
diff --git a/golden/modules/byblameentry-sk/test_data.js b/golden/modules/byblameentry-sk/test_data.js
new file mode 100644
index 0000000..7dfc6e6
--- /dev/null
+++ b/golden/modules/byblameentry-sk/test_data.js
@@ -0,0 +1,53 @@
+export const byBlameEntry = {
+  "groupID": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+  "nDigests": 112,
+  "nTests": 7,
+  "affectedTests": [{
+    "test": "aarectmodes",
+    "num": 50,
+    "sample_digest": "c6476baec94eb6a5071606575318e4df"
+  }, {
+    "test": "aaxfermodes",
+    "num": 32,
+    "sample_digest": "4acfd6b3a3943cc5d75cd22e966ae6f1"
+  }, {
+    "test": "hairmodes",
+    "num": 21,
+    "sample_digest": "f9e20c63b5ce3b58d9b6a90fa3e7224c"
+  }, {
+    "test": "imagefilters_xfermodes",
+    "num": 5,
+    "sample_digest": "47644613317040264fea6fa815af32e8"
+  }, {
+    "test": "lattice2",
+    "num": 2,
+    "sample_digest": "16e41798ecd59b1523322a57b49cc17f"
+  }, {
+    "test": "xfermodes",
+    "num": 1,
+    "sample_digest": "8fbee03f794c455c4e4842ec2736b744"
+  }, {
+    "test": "xfermodes3",
+    "num": 1,
+    "sample_digest": "fed2ff29abe371fc0ec1b2c65dfb3949"
+  }],
+  "commits": [{
+    "commit_time": 1573149814,
+    "hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+    "author": "Elisa (elisa@example.com)"
+  }, {
+    "commit_time": 1573149564,
+    "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "author": "Joe (joe@example.com)"
+  }]
+};
+
+export const gitLog = {
+  log: [{
+    "commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+    "message": "One glyph() to rule them all!!!"
+  }, {
+    "commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+    "message": "flesh out blendmodes through Screen"
+  }]
+};