Upstream the PolyGerrit plugin for Checks.
This is just a basic buildable and runnable copy of the experimentally
devloped plugin. There are some obvious remaining TODOs:
* Rename build-result to checks everywhere.
* Integrate PolyGerrit plugin into the backend plugin jar.
* Hook up with the new Rest API endpoints for checks.
Change-Id: Ic6f24c434ad48f694d68b32e87023d60afecc6fa
diff --git a/gr-checks/BUILD b/gr-checks/BUILD
new file mode 100644
index 0000000..9711ad1
--- /dev/null
+++ b/gr-checks/BUILD
@@ -0,0 +1,19 @@
+package_group(
+ name = "visibility",
+ packages = ["//plugins/checks/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+load("//tools/bzl:js.bzl", "bundle_assets", "polygerrit_plugin")
+
+polygerrit_plugin(
+ name = "build_results",
+ app = "build-results.html",
+ plugin_name = "build_results",
+ externs = ["externs.js"],
+ srcs = glob([
+ "*.html",
+ "*.js",
+ ]),
+)
diff --git a/gr-checks/all-statuses.js b/gr-checks/all-statuses.js
new file mode 100644
index 0000000..d318830
--- /dev/null
+++ b/gr-checks/all-statuses.js
@@ -0,0 +1,72 @@
+(function() {
+'use strict';
+
+window.Gerrit = (window.Gerrit || {});
+window.Gerrit.BuildResults = (window.Gerrit.BuildResults || {});
+
+// Prevent redefinition.
+if (window.Gerrit.BuildResults.Statuses) return;
+
+const Statuses = {
+ // non-terminal statuses
+ STATUS_UNKNOWN: 'STATUS_UNKNOWN',
+ QUEUING: 'QUEUING',
+ QUEUED: 'QUEUED',
+ WORKING: 'WORKING',
+
+ // terminal statuses
+ SUCCESS: 'SUCCESS',
+ FAILURE: 'FAILURE',
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
+ TIMEOUT: 'TIMEOUT',
+ CANCELLED: 'CANCELLED',
+};
+
+
+function isStatus(status, includedStatuses) {
+ return includedStatuses.includes(status);
+}
+
+
+function isUnevaluated(status) {
+ return isStatus(status, [Statuses.STATUS_UNKNOWN, Statuses.CANCELLED]);
+}
+
+function isInProgress(status) {
+ return isStatus(
+ status, [Statuses.QUEUING, Statuses.QUEUED, Statuses.WORKING]);
+}
+
+function isSuccessful(status) {
+ return isStatus(status, [Statuses.SUCCESS]);
+}
+
+function isFailed(status) {
+ return isStatus(
+ status, [Statuses.FAILURE, Statuses.INTERNAL_ERROR, Statuses.TIMEOUT]);
+}
+
+
+function statusClass(status) {
+ if (isUnevaluated(status)) {
+ return 'unevaluated';
+ }
+ if (isInProgress(status)) {
+ return 'in-progress';
+ }
+ if (isSuccessful(status)) {
+ return 'successful';
+ }
+ if (isFailed(status)) {
+ return 'failed';
+ }
+ return 'unevaluated';
+}
+
+window.Gerrit.BuildResults.Statuses = Statuses;
+window.Gerrit.BuildResults.isUnevaluated = isUnevaluated;
+window.Gerrit.BuildResults.isInProgress = isInProgress;
+window.Gerrit.BuildResults.isSuccessful = isSuccessful;
+window.Gerrit.BuildResults.isFailed = isFailed;
+window.Gerrit.BuildResults.statusClass = statusClass;
+})();
diff --git a/gr-checks/build-result-item.html b/gr-checks/build-result-item.html
new file mode 100644
index 0000000..c134205
--- /dev/null
+++ b/gr-checks/build-result-item.html
@@ -0,0 +1,46 @@
+<dom-module id="build-result-item">
+ <template>
+ <style>
+ :host {
+ border-top: 1px solid #ddd;
+ }
+
+ td:first-child {
+ padding-left: 1rem;
+ }
+
+ td {
+ padding: 1px 32px 1px 0;
+ white-space: nowrap;
+ }
+
+ a.log {
+ margin-right: 16px;
+ display: inline-block;
+ }
+ </style>
+
+ <td>[[_triggerDescription]]</td>
+ <td><!-- required for merge--></td>
+ <td>
+ <build-result-status show-text status="[[build.status]]"></build-result-status>
+ </td>
+ <td>Google Cloud Build</td>
+ <td>[[_startTime]]</td>
+ <td>[[_duration]]</td>
+ <td>
+ <a href$="[[build.logUrl]]" target="_blank" class="log">
+ <gr-button link no-uppercase>
+ View log
+ </gr-button>
+ </a>
+ <gr-button
+ link
+ no-uppercase
+ on-tap="handleClick">
+ Re-run
+ </gr-button>
+ </td>
+ </template>
+ <script src="build-result-item.js"></script>
+</dom-module>
diff --git a/gr-checks/build-result-item.js b/gr-checks/build-result-item.js
new file mode 100644
index 0000000..5405359
--- /dev/null
+++ b/gr-checks/build-result-item.js
@@ -0,0 +1,121 @@
+(function() {
+'use strict';
+
+const Defs = {};
+/**
+ * @typedef {{
+ * id: string,
+ * projectId: string,
+ * buildTriggerId: string,
+ * startTime: string,
+ * finishTime: string,
+ * }}
+ */
+Defs.Build;
+
+Polymer({
+ is: 'build-result-item',
+
+ properties: {
+ build: Object,
+ /** @type {function(string): !Promise<!Object>} */
+ getTrigger: Function,
+ /** @type {function(string): !Promise<!Object>} */
+ retryBuild: Function,
+ _triggerDescription: String,
+ _startTime: {
+ type: String,
+ computed: '_computeStartTime(build)',
+ },
+ _duration: {
+ type: String,
+ computed: '_computeDuration(build)',
+ },
+ },
+
+ observers: [
+ '_updateTriggerName(build, getTrigger)',
+ ],
+
+ /**
+ * @param {!Defs.Build} build
+ * @param {function(string): !Promise<!Object>} getTrigger
+ */
+ _updateTriggerName(build, getTrigger) {
+ const triggerId = build.buildTriggerId;
+ getTrigger(triggerId)
+ .then(
+ trigger => trigger && trigger.description || triggerId,
+ () => triggerId)
+ .then(triggerDescription => {
+ this.set('_triggerDescription', triggerDescription);
+ });
+ },
+
+ /**
+ * @param {!Defs.Build} build
+ * @return {string}
+ */
+ _computeStartTime(build) {
+ return moment(build.startTime).format('l');
+ },
+
+ /**
+ * @param {!Defs.Build} build
+ * @return {string}
+ */
+ _computeDuration(build) {
+ const startTime = moment(build.startTime);
+ const finishTime = moment(build.finishTime);
+ return generateDurationString(moment.duration(finishTime.diff(startTime)));
+ },
+
+ handleClick(event) {
+ event.preventDefault();
+ this.retryBuild(this.build.id);
+ }
+});
+
+const ZERO_SECONDS = '0 sec';
+
+/**
+ * @param {!Moment.Duration} duration a moment object
+ * @return {string}
+ */
+function generateDurationString(duration) {
+ if (duration.asSeconds() === 0) {
+ return ZERO_SECONDS;
+ }
+
+ const durationSegments = [];
+ if (duration.seconds()) {
+ durationSegments.push(`${duration.seconds()} sec`);
+ }
+ if (duration.minutes()) {
+ durationSegments.push(`${duration.minutes()} min`);
+ }
+ if (duration.hours()) {
+ const hours = pluralize(duration.hours(), 'hour', 'hours');
+ durationSegments.push(`${duration.hours()} ${hours}`);
+ }
+ if (duration.days()) {
+ const days = pluralize(duration.days(), 'day', 'days');
+ durationSegments.push(`${duration.days()} ${days}`);
+ }
+ if (duration.months()) {
+ const months = pluralize(duration.months(), 'month', 'months');
+ durationSegments.push(`${duration.months()} ${months}`);
+ }
+ return durationSegments.join(' ');
+}
+
+/**
+ * @param {number} unit
+ * @param {string} singular
+ * @param {string} plural
+ * @return {string}
+ */
+function pluralize(unit, singular, plural) {
+ return unit === 1 ? singular : plural;
+}
+})();
diff --git a/gr-checks/build-result-item_test.html b/gr-checks/build-result-item_test.html
new file mode 100644
index 0000000..cbf9309
--- /dev/null
+++ b/gr-checks/build-result-item_test.html
@@ -0,0 +1,157 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>build-result-item</title>
+<link rel="import" href="webcomponent_lib/build-result-item.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <build-result-item
+ build="{{build}}"
+ get-trigger="[[getTrigger]]"
+ retry-build="[[retryBuild]]">
+ </build-result-item>
+ </template>
+</test-fixture>
+
+<script>
+ suite('build-result-item tests', () => {
+ let element;
+ let sandbox;
+ let getTriggerSpy;
+ let resolveGetTrigger;
+ let rejectGetTrigger;
+ let retryBuildSpy;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+ const getTriggerPromise = new Promise((resolve, reject) => {
+ resolveGetTrigger = resolve;
+ rejectGetTrigger = reject;
+ });
+ getTriggerSpy = sinon.stub();
+ getTriggerSpy.returns(getTriggerPromise);
+ retryBuildSpy = sinon.stub();
+ retryBuildSpy.returns(Promise.resolve());
+
+ element = fixture('basic', {
+ getTrigger: getTriggerSpy,
+ retryBuild: retryBuildSpy,
+ build: {
+ buildTriggerId: 'test-build-trigger-id',
+ logUrl: 'http://example.com/test-log-url',
+ startTime: "2019-02-06T22:25:19.269Z",
+ finishTime: "2019-02-06T22:25:44.574Z",
+ },
+ });
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ suite('trigger name', () => {
+ test('calls getTrigger', () => {
+ assert.isTrue(getTriggerSpy.called);
+ assert.isTrue(getTriggerSpy.calledWith('test-build-trigger-id'));
+ });
+
+ function assertTriggerText(text) {
+ const name = element.$$('td:nth-child(1)');
+ assert.equal(name.textContent.trim(), text);
+ }
+
+ suite('on success', () => {
+ setup(done => {
+ resolveGetTrigger({description: 'test trigger name'});
+ flush(done);
+ });
+
+ test('renders the trigger name on success', () => {
+ assertTriggerText('test trigger name');
+ });
+ });
+
+ suite('on empty response', () => {
+ setup(done => {
+ resolveGetTrigger();
+ flush(done);
+ });
+
+ test('renders the id when there is no description', () => {
+ assertTriggerText('test-build-trigger-id');
+ });
+ });
+
+ suite('on error', () => {
+ setup(done => {
+ rejectGetTrigger(new Error('broken'));
+ flush(done);
+ });
+
+ test('renders the id when the call fails', () => {
+ assertTriggerText('test-build-trigger-id');
+ });
+ });
+ });
+
+ test('renders the status', () => {
+ const status = element.$$('td:nth-child(3) > build-result-status');
+ assert.exists(status);
+ });
+
+ test('renders the checking system', () => {
+ const checkingSystem = element.$$('td:nth-child(4)');
+ assert.equal(checkingSystem .textContent.trim(), "Google Cloud Build");
+ });
+
+ test('renders the run date', () => {
+ const name = element.$$('td:nth-child(5)');
+ assert.equal(name .textContent.trim(), "2/6/2019");
+ });
+
+ suite('duration', () => {
+ test('renders the run duration', () => {
+ const name = element.$$('td:nth-child(6)');
+ assert.equal(name .textContent.trim(), "25 sec");
+ });
+
+ test('renders 0 sec when the start and end time are the same', () => {
+ element.build = {
+ buildTriggerId: 'test-build-trigger-id',
+ logUrl: 'http://example.com/test-log-url',
+ startTime: "2019-02-06T22:25:19.269Z",
+ finishTime: "2019-02-06T22:25:19.269Z",
+ };
+ const name = element.$$('td:nth-child(6)');
+ assert.equal(name .textContent.trim(), "0 sec");
+ });
+ });
+
+ test('renders a link to the log', () => {
+ const logLink = element.$$('td:nth-child(7) > a');
+ assert.equal(logLink.getAttribute('href'), "http://example.com/test-log-url");
+ assert.equal(logLink.textContent.trim(), "View log");
+ });
+
+ suite('retryBuild', () => {
+ let retryBuildLink;
+
+ setup(() => {
+ retryBuildLink = element.$$('td:nth-child(7) > gr-button');
+ });
+
+ test('shows a link to the log url', () => {
+ assert.equal(retryBuildLink.textContent.trim(), "Re-run");
+ });
+
+ test('clicking on the link calls the retryBuild property', () => {
+ assert.isFalse(retryBuildSpy.called);
+ retryBuildLink.click();
+ assert.isTrue(retryBuildSpy.called);
+ });
+ });
+ });
+</script>
diff --git a/gr-checks/build-result-status.html b/gr-checks/build-result-status.html
new file mode 100644
index 0000000..ca39add
--- /dev/null
+++ b/gr-checks/build-result-status.html
@@ -0,0 +1,67 @@
+<dom-module id="build-result-status">
+ <template>
+ <style>
+ :host {
+ display: inline-block;
+ }
+ i {
+ border-radius: 50%;
+ color: white;
+ display: inline-block;
+ font-style: normal;
+ height: 16px;
+ margin-right: 4px;
+ text-align: center;
+ width: 16px;
+ }
+ .successful > i {
+ background-color: #00C752;
+ }
+ .failed > i {
+ background-color: #DA4236;
+ }
+ .in-progress > i {
+ background-color: #ddd;
+ }
+ .unevaluated > i {
+ background-color: black;
+ }
+ </style>
+ <span class$="[[_className]]">
+ <template is="dom-if" if="[[_isUnevaluated(status)]]">
+ <i>⏹</i>
+ <template is="dom-if" if="[[showText]]">
+ <span>
+ Unevaluated
+ </span>
+ </template>
+ </template>
+ <template is="dom-if" if="[[_isInProgress(status)]]">
+ <i>…</i>
+ <template is="dom-if" if="[[showText]]">
+ <span>
+ In progress
+ </span>
+ </template>
+ </template>
+ <template is="dom-if" if="[[_isSuccessful(status)]]">
+ <i>✓</i>
+ <template is="dom-if" if="[[showText]]">
+ <span>
+ Successful
+ </span>
+ </template>
+ </template>
+ <template is="dom-if" if="[[_isFailed(status)]]">
+ <i>!</i>
+ <template is="dom-if" if="[[showText]]">
+ <span>
+ Failed
+ </span>
+ </template>
+ </template>
+ </span>
+ </template>
+ <script src="all-statuses.js"></script>
+ <script src="build-result-status.js"></script>
+</dom-module>
diff --git a/gr-checks/build-result-status.js b/gr-checks/build-result-status.js
new file mode 100644
index 0000000..f7f94fd
--- /dev/null
+++ b/gr-checks/build-result-status.js
@@ -0,0 +1,37 @@
+(function() {
+'use strict';
+
+Polymer({
+ is: 'build-result-status',
+
+ properties: {
+ showText: {
+ type: Boolean,
+ value: false,
+ reflectToAttribute: true,
+ },
+ status: String,
+ _className: {type: String, computed: '_computeClassName(status)'},
+ },
+
+ _isUnevaluated(status) {
+ return window.Gerrit.BuildResults.isUnevaluated(status);
+ },
+
+ _isInProgress(status) {
+ return window.Gerrit.BuildResults.isInProgress(status);
+ },
+
+ _isSuccessful(status) {
+ return window.Gerrit.BuildResults.isSuccessful(status);
+ },
+
+ _isFailed(status) {
+ return window.Gerrit.BuildResults.isFailed(status);
+ },
+
+ _computeClassName(status) {
+ return window.Gerrit.BuildResults.statusClass(status);
+ },
+});
+})();
diff --git a/gr-checks/build-result-status_test.html b/gr-checks/build-result-status_test.html
new file mode 100644
index 0000000..6592e85
--- /dev/null
+++ b/gr-checks/build-result-status_test.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>build-result-item</title>
+<link rel="import" href="webcomponent_lib/build-result-status.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <build-result-status show-text="{{showText}}" status="{{status}}"></build-result-status>
+ </template>
+</test-fixture>
+
+<script>
+ suite('build-result-status tests', () => {
+ let element;
+ let sandbox;
+
+ setup(() => {
+ sandbox = sinon.sandbox.create();
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ function testStatus(expectedStatusClass, statusText, statusesToTest) {
+ suite(expectedStatusClass, () => {
+
+ for(let statusToTest of statusesToTest) {
+ test(`renders ${expectedStatusClass} for ${statusToTest}`, (done) => {
+ const element = fixture('basic', {
+ status: statusToTest,
+ showText: false,
+ });
+
+ flush(() => {
+ const icon = element.$$(`span.${expectedStatusClass} i`);
+ assert.exists(icon);
+ done();
+ });
+ });
+ test(`renders ${expectedStatusClass} for ${statusToTest} rendering text ${statusText}`, (done) => {
+ const element = fixture('basic', {
+ status: statusToTest,
+ showText: true,
+ });
+
+ flush(() => {
+ const text = element.$$(`span.${expectedStatusClass} span`);
+ assert.equal(text.textContent.trim(), statusText);
+ done();
+ });
+ });
+ }
+ });
+ }
+
+ testStatus('successful', 'Successful', ['SUCCESS']);
+ testStatus('failed', 'Failed', ['FAILURE', 'INTERNAL_ERROR', 'TIMEOUT']);
+ testStatus('in-progress', 'In progress', ['QUEUING', 'QUEUED', 'WORKING']);
+ testStatus('unevaluated', 'Unevaluated', ['STATUS_UNKNOWN', 'CANCELLED']);
+ });
+</script>
diff --git a/gr-checks/build-results-configure-link.html b/gr-checks/build-results-configure-link.html
new file mode 100644
index 0000000..8a83e3e
--- /dev/null
+++ b/gr-checks/build-results-configure-link.html
@@ -0,0 +1,18 @@
+<dom-module id="build-results-configure-link">
+ <template>
+ <style>
+ iron-icon {
+ height: 1.2rem;
+ margin-right: 4px;
+ width: 1.2rem;
+ }
+ </style>
+ <a href$="[[configurePath]]">
+ <gr-button link no-uppercase>
+ <iron-icon icon="gr-icons:settings"></iron-icon>
+ Configure checks
+ </gr-button>
+ </a>
+ </template>
+ <script src="build-results-configure-link.js"></script>
+</dom-module>
diff --git a/gr-checks/build-results-configure-link.js b/gr-checks/build-results-configure-link.js
new file mode 100644
index 0000000..c17b329
--- /dev/null
+++ b/gr-checks/build-results-configure-link.js
@@ -0,0 +1,11 @@
+(function() {
+'use strict';
+
+Polymer({
+ is: 'build-results-configure-link',
+
+ properties: {
+ configurePath: String,
+ },
+});
+})();
diff --git a/gr-checks/build-results-configure-link_test.html b/gr-checks/build-results-configure-link_test.html
new file mode 100644
index 0000000..179ada2
--- /dev/null
+++ b/gr-checks/build-results-configure-link_test.html
@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>build-results-configure-link</title>
+<link rel="import" href="webcomponent_lib/build-results-configure-link.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <build-results-configure-link configure-path="[[configurePath]]"></build-results-configure-link>
+ </template>
+</test-fixture>
+
+<script>
+ suite('build-results-configure-link tests', () => {
+ let element;
+ let sandbox;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+ element = fixture('basic', {
+ configurePath: 'http://example.com/test-configure',
+ });
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ test('renders the link to configure a build', () => {
+ const configureLink = element.$$('a');
+ assert.equal(configureLink.getAttribute('href'), 'http://example.com/test-configure');
+ assert.equal(configureLink.textContent.trim(), 'Configure checks');
+ });
+ });
+</script>
diff --git a/gr-checks/build-results-view.html b/gr-checks/build-results-view.html
new file mode 100644
index 0000000..9f30421
--- /dev/null
+++ b/gr-checks/build-results-view.html
@@ -0,0 +1,116 @@
+<dom-module id="build-results-view">
+ <template>
+ <style>
+ :host {
+ display: block;
+ width: 100%;
+ }
+
+ table {
+ width: 100%;
+ }
+
+ build-result-item {
+ display: table-row;
+ }
+
+ .headerRow {
+ border-bottom: 1px solid #ddd;
+ }
+
+ .topHeader {
+ padding-bottom: 4px;
+ text-align: left;
+ white-space: nowrap;
+ }
+
+ th.topHeader:last-child {
+ width: 100%;
+ }
+
+ h2 {
+ font-size: 1.17em;
+ font-weight: 500;
+ }
+
+ h3 {
+ flex: 1;
+ font-size: 1.17em;
+ font-weight: 500;
+ margin-bottom: 1.5rem;
+ }
+
+ header {
+ display: flex;
+ margin: 1rem 1rem 0;
+ }
+
+ table {
+ margin-bottom: 16px;
+ }
+
+ th:first-child {
+ padding-left: 1rem;
+ }
+
+ .no-content {
+ min-height: 106px;
+ padding: 24px 0;
+ text-align: center;
+ }
+ </style>
+
+ <template is="dom-if" if="[[_isLoading(_status)]]">
+ <div class="no-content">
+ <p>Loading...</p>
+ </div>
+ </template>
+
+ <template is="dom-if" if="[[_isEmpty(_status)]]">
+ <div class="no-content">
+ <h2>No checks ran for this code review</h2>
+ <p>Configure code review checks to view the results here.</p>
+ <build-results-configure-link configure-path="[[configurePath]]">
+ </build-results-configure-link>
+ </div>
+ </template>
+
+ <template is="dom-if" if="[[_isNotConfigured(_status)]]">
+ <div class="no-content">
+ <h2>Code review checks not configured</h2>
+ <p>Configure code review checks to view the results here.</p>
+ <build-results-configure-link configure-path="[[configurePath]]">
+ </build-results-configure-link>
+ </div>
+ </template>
+
+ <template is="dom-if" if="[[_hasResults(_status)]]">
+ <header>
+ <h3>Latest checks for Patchset [[revision._number]]</h3>
+ <build-results-configure-link configure-path="[[configurePath]]">
+ </build-results-configure-link>
+ </header>
+
+ <table>
+ <thead>
+ <tr class="headerRow">
+ <th class="topHeader">Name</th>
+ <th class="topHeader"><!-- required for merge --></th>
+ <th class="topHeader">Status</th>
+ <th class="topHeader">Checking system</th>
+ <th class="topHeader">Started</th>
+ <th class="topHeader">Duration</th>
+ <th class="topHeader"><!-- actions --></th>
+ </tr>
+ </thead>
+ <tbody>
+ <template is="dom-repeat" items="[[_buildResults]]" as="build">
+ <build-result-item build="[[build]]" retry-build="[[retryBuild]]" get-trigger="[[getTrigger]]"></build-result-item>
+ </template>
+ </tbody>
+ </table>
+ </template>
+ </template>
+
+ <script src="build-results-view.js"></script>
+</dom-module>
diff --git a/gr-checks/build-results-view.js b/gr-checks/build-results-view.js
new file mode 100644
index 0000000..ca51876
--- /dev/null
+++ b/gr-checks/build-results-view.js
@@ -0,0 +1,103 @@
+(function() {
+'use strict';
+
+const Defs = {};
+/**
+ * @typedef {{
+ * revisions: !Object<string, !Object>,
+ * }}
+ */
+Defs.Change;
+
+/**
+ * @param {!Defs.Change} change The current CL.
+ * @param {!Object} revision The current patchset.
+ * @return {string|undefined}
+ */
+function currentRevisionSha(change, revision) {
+ return Object.keys(change.revisions)
+ .find(sha => change.revisions[sha] === revision);
+}
+
+const LoadingStatus = {
+ LOADING: 0,
+ EMPTY: 1,
+ RESULTS: 2,
+ NOT_CONFIGURED: 3,
+};
+
+Polymer({
+ is: 'build-results-view',
+
+ properties: {
+ revision: Object,
+ change: Object,
+ // TODO(brohlfs): Implement getBuildResults based on Checks Rest API.
+ /** @type {function(string, (string|undefined)): !Promise<!Object>} */
+ getBuildResults: Function,
+ // TODO(brohlfs): Implement isConfigured based on Checks Rest API.
+ /** @type {function(string): !Promise<!Object>} */
+ isConfigured: Function,
+ // TODO(brohlfs): Implement getTrigger based on Checks Rest API.
+ /** @type {function(string, string): !Promise<!Object>} */
+ getTrigger: Function,
+ // TODO(brohlfs): Implement retryBuild based on Checks Rest API.
+ /** @type {function(string, string): !Promise<!Object>} */
+ retryBuild: Function,
+ // TODO(brohlfs): Implement configurePath based on Checks Rest API.
+ // The url path to configure code review triggers.
+ configurePath: String,
+ _buildResults: Object,
+ _status: {
+ type: Object,
+ value: LoadingStatus.LOADING,
+ },
+ },
+
+ observers: [
+ '_fetchBuildResults(change, revision, getBuildResults)',
+ ],
+
+ /**
+ * @param {!Defs.Change} change The current CL.
+ * @param {!Object} revision The current patchset.
+ * @param {function(string, (string|undefined)): !Promise<!Object>}
+ * getBuildResults function to get build results.
+ */
+ _fetchBuildResults(change, revision, getBuildResults) {
+ const repository = change['project'];
+ const gitSha = currentRevisionSha(change, revision);
+
+ getBuildResults(repository, gitSha).then(buildResults => {
+ if (buildResults && buildResults.length) {
+ this.set('_buildResults', buildResults);
+ this.set('_status', LoadingStatus.RESULTS);
+ } else {
+ this._checkConfigured();
+ }
+ });
+ },
+
+ _checkConfigured() {
+ const repository = this.change['project'];
+ this.isConfigured(repository).then(configured => {
+ const status =
+ configured ? LoadingStatus.EMPTY : LoadingStatus.NOT_CONFIGURED;
+ this.set('_status', status);
+ });
+ },
+
+ _isLoading(status) {
+ return status === LoadingStatus.LOADING;
+ },
+ _isEmpty(status) {
+ return status === LoadingStatus.EMPTY;
+ },
+ _hasResults(status) {
+ return status === LoadingStatus.RESULTS;
+ },
+ _isNotConfigured(status) {
+ return status === LoadingStatus.NOT_CONFIGURED;
+ },
+});
+})();
diff --git a/gr-checks/build-results-view_test.html b/gr-checks/build-results-view_test.html
new file mode 100644
index 0000000..79851a0
--- /dev/null
+++ b/gr-checks/build-results-view_test.html
@@ -0,0 +1,178 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>build-result-item</title>
+<link rel="import" href="webcomponent_lib/build-results-view.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <build-results-view
+ change="[[change]]"
+ revision="[[revision]]"
+ configure-path="[[configurePath]]"
+ get-build-results="[[getBuildResults]]"
+ is-configured="[[isConfigured]]"
+ get-trigger="[[getTrigger]]"
+ retry-build="[[retryBuild]]">
+ </build-results-view>
+ </template>
+</test-fixture>
+
+<script>
+ const BUILD1 = {
+ buildTriggerId: 'test-build-trigger-id',
+ logUrl: 'http://example.com/test-log-url',
+ startTime: "2019-02-06T22:25:19.269Z",
+ finishTime: "2019-02-06T22:25:44.574Z",
+ };
+const REVISION = {
+ "kind": "REWORK",
+ "_number": 3,
+ "created": "2018-05-15 21:56:13.000000000",
+ "uploader": {
+ "_account_id": 1000000,
+ },
+ "ref": "refs/changes/00/1000/1",
+ "commit": {
+ "parents": [],
+ "subject": "added test file",
+ "message": "added test file\n\nChange-Id: I8df212a28ae23cc239afd10ee4f506887e03ab70\n",
+ "commit": "1c9a1dfd38ea51dc7880f3ddf669100710f0c91b"
+ },
+};
+
+suite('build-results-view tests', () => {
+ let element;
+ let sandbox;
+ let getBuildResultsSpy;
+ let getBuildResultsResolve;
+ let getTriggerSpy;
+ let retryBuildSpy;
+ let isConfiguredSpy;
+ let isConfiguredResolve;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+
+ getBuildResultsSpy = sinon.stub();
+ const getBuildResultsPromise = new Promise((resolve, reject) => {
+ getBuildResultsResolve = resolve;
+ });
+ getBuildResultsSpy.returns(getBuildResultsPromise);
+
+ isConfiguredSpy = sinon.stub();
+ const isConfiguredPromise = new Promise((resolve, reject) => {
+ isConfiguredResolve = resolve;
+ });
+ isConfiguredSpy.returns(isConfiguredPromise);
+
+ getTriggerSpy = sinon.stub();
+ getTriggerSpy.returns(Promise.resolve({description: 'test trigger name'}));
+ retryBuildSpy = sinon.stub();
+ retryBuildSpy.returns(Promise.resolve());
+
+ element = fixture('basic', {
+ getTrigger: getTriggerSpy,
+ retryBuild: retryBuildSpy,
+ getBuildResults: getBuildResultsSpy,
+ isConfigured: isConfiguredSpy,
+ configurePath: 'http://example.com/test-configure',
+ change: {
+ 'project': 'test-repository',
+ 'revisions': {
+ 'first-sha': "test-revision",
+ 'second-sha': REVISION,
+ }
+ },
+ revision: REVISION,
+ });
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ test('renders loading', () => {
+ assert.equal(element.textContent.trim(), 'Loading...');
+ });
+
+ test('queries the builds', () => {
+ assert.isTrue(getBuildResultsSpy.called);
+ assert.isTrue(getBuildResultsSpy.calledWith('test-repository', 'second-sha'));
+ });
+
+ suite('no builds returned', () => {
+ setup((done) => {
+ getBuildResultsResolve([]);
+ flush(done);
+ });
+
+ test('it calls to check if the builds are configured', () => {
+ assert.isTrue(isConfiguredSpy.called);
+ assert.isTrue(isConfiguredSpy.calledWith('test-repository'));
+ });
+
+ suite('not configured', () => {
+ setup((done) => {
+ isConfiguredResolve(false);
+ flush(done);
+ });
+
+ test('renders checks not configured', () => {
+ const header = element.$$('h2');
+ assert.equal(header.textContent.trim(), 'Code review checks not configured');
+ });
+
+ test('renders the link to configure a build', () => {
+ const configureLink = element.$$('build-results-configure-link');
+ assert.exists(configureLink);
+ });
+ });
+
+ suite('no checks ran', () => {
+ setup((done) => {
+ isConfiguredResolve(true);
+ flush(done);
+ });
+
+ test('renders checks not configured', () => {
+ const header = element.$$('h2');
+ assert.equal(header.textContent.trim(), 'No checks ran for this code review');
+ });
+
+ test('renders the link to configure a build', () => {
+ const configureLink = element.$$('build-results-configure-link');
+ assert.exists(configureLink);
+ });
+ });
+ });
+
+ suite('with build results', () => {
+ setup(done => {
+ getBuildResultsResolve([BUILD1, BUILD1, BUILD1]);
+ flush(done);
+ });
+
+ test('it calls to check if the builds are configured', () => {
+ assert.isFalse(isConfiguredSpy.called);
+ });
+
+ test('renders the header', () => {
+ const header = element.$$('header > h3');
+ assert.equal(header.textContent.trim(), 'Latest checks for Patchset 3');
+ });
+
+ test('renders the link to configure a build', () => {
+ const configureLink = element.$$('header > build-results-configure-link');
+ assert.exists(configureLink);
+ });
+
+ test('renders a table of all the builds', () => {
+ const tbody = element.$$('table > tbody');
+ assert.lengthOf(tbody.querySelectorAll('build-result-item'), 3)
+ });
+ });
+});
+</script>
diff --git a/gr-checks/build-results.html b/gr-checks/build-results.html
new file mode 100644
index 0000000..c47ec37
--- /dev/null
+++ b/gr-checks/build-results.html
@@ -0,0 +1,28 @@
+<link rel="import" href="build-results-view.html">
+<link rel="import" href="build-result-item.html">
+<link rel="import" href="build-result-status.html">
+<link rel="import" href="builds-chip-view.html">
+<link rel="import" href="builds-results-change-list-item-cell-view.html">
+<link rel="import" href="builds-results-change-list-header-view.html">
+<link rel="import" href="builds-results-change-view-tab-header-view.html">
+<link rel="import" href="build-results-configure-link.html">
+
+<dom-module id="build-results">
+ <script>
+ Gerrit.install(plugin => {
+ plugin.registerDynamicCustomComponent(
+ 'change-list-header',
+ 'builds-results-change-list-header-view');
+ plugin.registerDynamicCustomComponent(
+ 'change-list-item-cell',
+ 'builds-results-change-list-item-cell-view');
+ plugin.registerDynamicCustomComponent(
+ 'change-view-tab-header',
+ 'builds-results-change-view-tab-header-view');
+ plugin.registerDynamicCustomComponent(
+ 'change-view-tab-content',
+ 'build-results-view').onAttached(
+ buildResultsView => console.log('build-results-view attached'));
+ });
+ </script>
+</dom-module>
diff --git a/gr-checks/builds-chip-view.html b/gr-checks/builds-chip-view.html
new file mode 100644
index 0000000..110d26b
--- /dev/null
+++ b/gr-checks/builds-chip-view.html
@@ -0,0 +1,31 @@
+<dom-module id="builds-chip-view">
+ <template>
+ <style>
+
+ :host {
+ display: inline-block;
+ }
+
+ .chip {
+ border-color: #D0D0D0;
+ border-radius: 4px;
+ border-style: solid;
+ border-width: 1px;
+ padding: 4px 8px;
+ }
+ .chip.failed {
+ border-color: #DA4236;
+ }
+ </style>
+ <template is="dom-if" if="[[_hasBuilds]]">
+ Checks:
+ <span class$="[[_chipClasses]]">
+ <build-result-status status="[[_status]]"></build-result-status>
+ [[_statusString]]
+ </span>
+ </template>
+ </template>
+
+ <script src="all-statuses.js"></script>
+ <script src="builds-chip-view.js"></script>
+</dom-module>
diff --git a/gr-checks/builds-chip-view.js b/gr-checks/builds-chip-view.js
new file mode 100644
index 0000000..2fff5bf
--- /dev/null
+++ b/gr-checks/builds-chip-view.js
@@ -0,0 +1,124 @@
+(function() {
+'use strict';
+const Statuses = window.Gerrit.BuildResults.Statuses;
+
+const StatusPriorityOrder = [
+ Statuses.INTERNAL_ERROR, Statuses.TIMEOUT, Statuses.FAILURE,
+ Statuses.STATUS_UNKNOWN, Statuses.CANCELLED, Statuses.QUEUED,
+ Statuses.QUEUING, Statuses.WORKING, Statuses.SUCCESS
+];
+
+const HumanizedStatuses = {
+ // non-terminal statuses
+ STATUS_UNKNOWN: 'unevaluated',
+ QUEUING: 'in progress',
+ QUEUED: 'in progress',
+ WORKING: 'in progress',
+
+ // terminal statuses
+ SUCCESS: 'successful',
+ FAILURE: 'failed',
+ INTERNAL_ERROR: 'failed',
+ TIMEOUT: 'failed',
+ CANCELLED: 'unevaluated',
+};
+
+
+const Defs = {};
+/**
+ * @typedef {{
+ * revisions: !Object<string, !Object>,
+ * }}
+ */
+Defs.Change;
+
+/**
+ * @param {!Defs.Change} change The current CL.
+ * @param {!Object} revision The current patchset.
+ * @return {string|undefined}
+ */
+function currentRevisionSha(change, revision) {
+ return Object.keys(change.revisions)
+ .find(sha => change.revisions[sha] === revision);
+}
+
+function computeBuildResultStatuses(buildResults) {
+ return buildResults.reduce((accum, buildResult) => {
+ accum[buildResult.status] || (accum[buildResult.status] = 0);
+ accum[buildResult.status]++;
+ return accum;
+ }, {total: buildResults.length});
+}
+
+Polymer({
+ is: 'builds-chip-view',
+
+ properties: {
+ revision: Object,
+ change: Object,
+ // TODO(brohlfs): Implement getBuildResults based on new Rest APIs.
+ /** @type {function(string, (string|undefined)): !Promise<!Object>} */
+ getBuildResults: Function,
+ _buildResultStatuses: Object,
+ _hasBuilds: Boolean,
+ _status: {type: String, computed: '_computeStatus(_buildResultStatuses)'},
+ _statusString: {
+ type: String,
+ computed: '_computeStatusString(_status, _buildResultStatuses)'
+ },
+ _chipClasses: {type: String, computed: '_computeChipClass(_status)'},
+ },
+
+ observers: [
+ '_fetchBuildResults(change, revision, getBuildResults)',
+ ],
+
+ /**
+ * @param {!Defs.Change} change The current CL.
+ * @param {!Object} revision The current patchset.
+ * @param {function(string, (string|undefined)): !Promise<!Object>}
+ * getBuildResults function to get build results.
+ */
+ _fetchBuildResults(change, revision, getBuildResults) {
+ const repository = change['project'];
+ const gitSha = currentRevisionSha(change, revision);
+
+ getBuildResults(repository, gitSha).then(buildResults => {
+ this.set('_hasBuilds', buildResults.length > 0);
+ if (buildResults.length > 0) {
+ this.set(
+ '_buildResultStatuses', computeBuildResultStatuses(buildResults));
+ }
+ });
+ },
+
+ /**
+ * @param {!Object} buildResultStatuses The number of builds in each status.
+ * @return {string}
+ */
+ _computeStatus(buildResultStatuses) {
+ return StatusPriorityOrder.find(
+ status => buildResultStatuses[status] > 0) ||
+ Statuses.STATUS_UNKNOWN;
+ },
+
+ /**
+ * @param {string} status The overall status of the build results.
+ * @param {!Object} buildResultStatuses The number of builds in each status.
+ * @return {string}
+ */
+ _computeStatusString(status, buildResultStatuses) {
+ if (buildResultStatuses.total === 0) return 'No builds';
+ return `${buildResultStatuses[status]} of ${
+ buildResultStatuses.total} builds ${HumanizedStatuses[status]}`;
+ },
+
+ /**
+ * @param {string} status The overall status of the build results.
+ * @return {string}
+ */
+ _computeChipClass(status) {
+ return `chip ${window.Gerrit.BuildResults.statusClass(status)}`;
+ },
+});
+})();
diff --git a/gr-checks/builds-chip-view_test.html b/gr-checks/builds-chip-view_test.html
new file mode 100644
index 0000000..2f0feb5
--- /dev/null
+++ b/gr-checks/builds-chip-view_test.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>builds-chip-view</title>
+<link rel="import" href="webcomponent_lib/builds-chip-view.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <builds-chip-view change="[[change]]" revision="[[revision]]" get-build-results="[[getBuildResults]]"></builds-chip-view>
+ </template>
+</test-fixture>
+
+<script>
+ const BUILD1 = {
+ buildTriggerId: 'test-build-trigger-id',
+ logUrl: 'http://example.com/test-log-url',
+ startTime: "2019-02-06T22:25:19.269Z",
+ finishTime: "2019-02-06T22:25:44.574Z",
+ status: 'SUCCESS',
+ };
+
+ const BUILD2 = {
+ buildTriggerId: 'test-build-trigger-id-2',
+ logUrl: 'http://example.com/test-log-url',
+ startTime: "2019-02-06T22:25:19.269Z",
+ finishTime: "2019-02-06T22:25:44.574Z",
+ status: 'FAILURE',
+ };
+
+ suite('build-chip-view tests', () => {
+ let element;
+ let sandbox;
+ let getBuildResultsSpy;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+
+ getBuildResultsSpy = sinon.stub();
+ getBuildResultsSpy.returns(Promise.resolve([BUILD1, BUILD2, BUILD1]));
+
+ element = fixture('basic', {
+ getBuildResults: getBuildResultsSpy,
+ change: {
+ 'project': 'test-repository',
+ 'revisions': {
+ 'first-sha': "test-revision",
+ 'second-sha': "test-revision2",
+ }
+ },
+ revision: 'test-revision2',
+ });
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ test('renders the checks prefix', () => {
+ assert.include(element.textContent.trim(), 'Checks:');
+ });
+
+ suite('builds chip contents', () => {
+ test('queries the builds', () => {
+ assert.isTrue(getBuildResultsSpy.called);
+ assert.isTrue(getBuildResultsSpy.calledWith('test-repository', 'second-sha'));
+ });
+
+ test('renders the text of failed builds', () => {
+ const chip = element.$$('.chip');
+ assert.equal(chip.textContent.trim(), '1 of 3 builds failed');
+ });
+ });
+ });
+</script>
diff --git a/gr-checks/builds-results-change-list-header-view.html b/gr-checks/builds-results-change-list-header-view.html
new file mode 100644
index 0000000..596adfe
--- /dev/null
+++ b/gr-checks/builds-results-change-list-header-view.html
@@ -0,0 +1,13 @@
+<dom-module id="builds-results-change-list-header-view">
+ <style>
+ :host {
+ display: table-cell;
+ padding: 0 3px;
+ }
+ </style>
+ <template>
+ Checks
+ </template>
+
+ <script src="builds-results-change-list-header-view.js"></script>
+</dom-module>
diff --git a/gr-checks/builds-results-change-list-header-view.js b/gr-checks/builds-results-change-list-header-view.js
new file mode 100644
index 0000000..19aad70
--- /dev/null
+++ b/gr-checks/builds-results-change-list-header-view.js
@@ -0,0 +1,7 @@
+(function() {
+'use strict';
+
+Polymer({
+ is: 'builds-results-change-list-header-view',
+});
+})();
diff --git a/gr-checks/builds-results-change-list-header-view_test.html b/gr-checks/builds-results-change-list-header-view_test.html
new file mode 100644
index 0000000..b380930
--- /dev/null
+++ b/gr-checks/builds-results-change-list-header-view_test.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>builds-results-change-list-header-view</title>
+<link rel="import" href="webcomponent_lib/builds-results-change-list-header-view.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <builds-results-change-list-header-view></builds-results-change-list-header-view>
+ </template>
+</test-fixture>
+
+<script>
+
+ suite('builds-results-change-list-header-view tests', () => {
+ let element;
+ let sandbox;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+
+
+ element = fixture('basic');
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ test('renders the header', () => {
+ assert.include(element.textContent.trim(), 'Checks');
+ });
+ });
+</script>
diff --git a/gr-checks/builds-results-change-list-item-cell-view.html b/gr-checks/builds-results-change-list-item-cell-view.html
new file mode 100644
index 0000000..a919c3d
--- /dev/null
+++ b/gr-checks/builds-results-change-list-item-cell-view.html
@@ -0,0 +1,13 @@
+<dom-module id="builds-results-change-list-item-cell-view">
+ <style>
+ :host {
+ display: inline-block;
+ text-align: center;
+ width: 100%;
+ }
+ </style>
+ <template>
+ </template>
+
+ <script src="builds-results-change-list-item-cell-view.js"></script>
+</dom-module>
diff --git a/gr-checks/builds-results-change-list-item-cell-view.js b/gr-checks/builds-results-change-list-item-cell-view.js
new file mode 100644
index 0000000..ff8b556
--- /dev/null
+++ b/gr-checks/builds-results-change-list-item-cell-view.js
@@ -0,0 +1,11 @@
+(function() {
+'use strict';
+
+Polymer({
+ is: 'builds-results-change-list-item-cell-view',
+
+ properties: {
+ change: Object,
+ },
+});
+})();
diff --git a/gr-checks/builds-results-change-list-item-cell-view_test.html b/gr-checks/builds-results-change-list-item-cell-view_test.html
new file mode 100644
index 0000000..f1047fe
--- /dev/null
+++ b/gr-checks/builds-results-change-list-item-cell-view_test.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>builds-results-change-list-item-cell-view</title>
+<link rel="import" href="webcomponent_lib/builds-results-change-list-item-cell-view.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <builds-results-change-list-item-cell-view change="[[change]]"></builds-results-change-list-item-cell-view>
+ </template>
+</test-fixture>
+
+<script>
+
+ suite('builds-results-change-list-item-cell-view tests', () => {
+ let element;
+ let sandbox;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+
+
+ element = fixture('basic', {
+ change: {
+ 'project': 'test-repository',
+ 'revisions': {
+ 'first-sha': "test-revision",
+ 'second-sha': "test-revision2",
+ }
+ },
+ });
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ test('renders the element', () => {
+ assert.exists(element);
+ });
+ });
+</script>
diff --git a/gr-checks/builds-results-change-view-tab-header-view.html b/gr-checks/builds-results-change-view-tab-header-view.html
new file mode 100644
index 0000000..2982f70
--- /dev/null
+++ b/gr-checks/builds-results-change-view-tab-header-view.html
@@ -0,0 +1,7 @@
+<dom-module id="builds-results-change-view-tab-header-view">
+ <template>
+ Checks
+ </template>
+
+ <script src="builds-results-change-view-tab-header-view.js"></script>
+</dom-module>
diff --git a/gr-checks/builds-results-change-view-tab-header-view.js b/gr-checks/builds-results-change-view-tab-header-view.js
new file mode 100644
index 0000000..27ccf83
--- /dev/null
+++ b/gr-checks/builds-results-change-view-tab-header-view.js
@@ -0,0 +1,7 @@
+(function() {
+'use strict';
+
+Polymer({
+ is: 'builds-results-change-view-tab-header-view',
+});
+})();
diff --git a/gr-checks/builds-results-change-view-tab-header-view_test.html b/gr-checks/builds-results-change-view-tab-header-view_test.html
new file mode 100644
index 0000000..b71371b
--- /dev/null
+++ b/gr-checks/builds-results-change-view-tab-header-view_test.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<script src="imports.js"></script>
+<script src="webcomponentsjs/webcomponents-lite.js"></script>
+<link rel="import" href="polymer/polymer.html">
+
+<title>builds-results-change-view-tab-header-view</title>
+<link rel="import" href="webcomponent_lib/builds-results-change-view-tab-header-view.html">
+
+<test-fixture id="basic">
+ <template is="dom-template">
+ <builds-results-change-view-tab-header-view></builds-results-change-view-tab-header-view>
+ </template>
+</test-fixture>
+
+<script>
+ suite('builds-results-change-view-tab-header-view tests', () => {
+ let element;
+ let sandbox;
+
+ setup((done) => {
+ sandbox = sinon.sandbox.create();
+
+
+ element = fixture('basic');
+ flush(done);
+ });
+
+ teardown(() => { sandbox.restore(); });
+
+ test('renders the header', () => {
+ assert.include(element.textContent.trim(), 'Checks');
+ });
+ });
+</script>
diff --git a/gr-checks/externs.js b/gr-checks/externs.js
new file mode 100644
index 0000000..f14959b
--- /dev/null
+++ b/gr-checks/externs.js
@@ -0,0 +1,6 @@
+/**
+ * @fileoverview externs for moment.js
+ * @externs
+ */
+
+function moment() {};