Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.js b/static_src/elements/chops/chops-announcement/chops-announcement.js
new file mode 100644
index 0000000..477e7d2
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.js
@@ -0,0 +1,181 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+// URL where announcements are fetched from.
+const ANNOUNCEMENT_SERVICE =
+  'https://chopsdash.appspot.com/prpc/dashboard.ChopsAnnouncements/SearchAnnouncements';
+
+// Prefix prepended to responses for security reasons.
+export const XSSI_PREFIX = ')]}\'';
+
+const FETCH_HEADERS = Object.freeze({
+  'accept': 'application/json',
+  'content-type': 'application/json',
+});
+
+// How often to refresh announcements.
+export const REFRESH_TIME_MS = 5 * 60 * 1000;
+
+/**
+ * @typedef {Object} Announcement
+ * @property {string} id
+ * @property {string} messageContent
+ */
+
+/**
+ * @typedef {Object} AnnouncementResponse
+ * @property {Array<Announcement>} announcements
+ */
+
+/**
+ * `<chops-announcement>` displays a ChopsDash message when there's an outage
+ * or other important announcement.
+ *
+ * @customElement chops-announcement
+ */
+export class ChopsAnnouncement extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        width: 100%;
+      }
+      p {
+        display: block;
+        color: #222;
+        font-size: 13px;
+        background: #FFCDD2; /* Material design red */
+        width: 100%;
+        text-align: center;
+        padding: 0.5em 16px;
+        box-sizing: border-box;
+        margin: 0;
+        /* Using a red-tinted grey border makes hues feel harmonious. */
+        border-bottom: 1px solid #D6B3B6;
+      }
+    `;
+  }
+  /** @override */
+  render() {
+    if (this._error) {
+      return html`<p><strong>Error: </strong>${this._error}</p>`;
+    }
+    return html`
+      ${this._announcements.map(
+      ({messageContent}) => html`<p>${messageContent}</p>`)}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      service: {type: String},
+      _error: {type: String},
+      _announcements: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    /** @type {string} */
+    this.service = undefined;
+    /** @type {string} */
+    this._error = undefined;
+    /** @type {Array<Announcement>} */
+    this._announcements = [];
+
+    /** @type {number} Interval ID returned by window.setInterval. */
+    this._interval = undefined;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('service')) {
+      if (this.service) {
+        this.startRefresh();
+      } else {
+        this.stopRefresh();
+      }
+    }
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    this.stopRefresh();
+  }
+
+  /**
+   * Set up autorefreshing logic or announcement information.
+   */
+  startRefresh() {
+    this.stopRefresh();
+    this.refresh();
+    this._interval = window.setInterval(() => this.refresh(), REFRESH_TIME_MS);
+  }
+
+  /**
+   * Logic for clearing refresh behavior.
+   */
+  stopRefresh() {
+    if (this._interval) {
+      window.clearInterval(this._interval);
+    }
+  }
+
+  /**
+   * Refresh the announcement banner.
+   */
+  async refresh() {
+    try {
+      const {announcements = []} = await this.fetch(this.service);
+      this._error = undefined;
+      this._announcements = announcements;
+    } catch (e) {
+      this._error = e.message;
+      this._announcements = [];
+    }
+  }
+
+  /**
+   * Fetches the announcement for a given service.
+   * @param {string} service Name of the service to fetch from ChopsDash.
+   *   ie: "monorail"
+   * @return {Promise<AnnouncementResponse>} ChopsDash response JSON.
+   * @throws {Error} If something went wrong while fetching.
+   */
+  async fetch(service) {
+    const message = {
+      retired: false,
+      platformName: service,
+    };
+
+    const response = await window.fetch(ANNOUNCEMENT_SERVICE, {
+      method: 'POST',
+      headers: FETCH_HEADERS,
+      body: JSON.stringify(message),
+    });
+
+    if (!response.ok) {
+      throw new Error('Something went wrong while fetching announcements');
+    }
+
+    // We can't use response.json() because of the XSSI prefix.
+    const text = await response.text();
+
+    if (!text.startsWith(XSSI_PREFIX)) {
+      throw new Error(`No XSSI prefix in announce response: ${XSSI_PREFIX}`);
+    }
+
+    return JSON.parse(text.substr(XSSI_PREFIX.length));
+  }
+}
+
+customElements.define('chops-announcement', ChopsAnnouncement);
diff --git a/static_src/elements/chops/chops-announcement/chops-announcement.test.js b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
new file mode 100644
index 0000000..fa9643f
--- /dev/null
+++ b/static_src/elements/chops/chops-announcement/chops-announcement.test.js
@@ -0,0 +1,194 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+import {assert} from 'chai';
+import {ChopsAnnouncement, REFRESH_TIME_MS,
+  XSSI_PREFIX} from './chops-announcement.js';
+import sinon from 'sinon';
+
+let element;
+let clock;
+
+describe('chops-announcement', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-announcement');
+    document.body.appendChild(element);
+
+    clock = sinon.useFakeTimers({
+      now: new Date(0),
+      shouldAdvanceTime: false,
+    });
+
+    sinon.stub(window, 'fetch');
+  });
+
+  afterEach(() => {
+    if (document.body.contains(element)) {
+      document.body.removeChild(element);
+    }
+
+    clock.restore();
+
+    window.fetch.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsAnnouncement);
+  });
+
+  it('does not request announcements when no service specified', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetch);
+  });
+
+  it('requests announcements when service is specified', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('refreshes announcements regularly', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    clock.tick(REFRESH_TIME_MS);
+
+    await element.updateComplete;
+
+    sinon.assert.calledTwice(element.fetch);
+  });
+
+  it('stops refreshing when service removed', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    element.service = '';
+
+    await element.updateComplete;
+    clock.tick(REFRESH_TIME_MS);
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('stops refreshing when element is disconnected', async () => {
+    sinon.stub(element, 'fetch');
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+
+    document.body.removeChild(element);
+
+    await element.updateComplete;
+    clock.tick(REFRESH_TIME_MS);
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetch);
+  });
+
+  it('renders error when thrown', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.throws(() => Error('Something went wrong'));
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.equal(element._error, 'Something went wrong');
+    assert.include(element.shadowRoot.textContent, 'Something went wrong');
+  });
+
+  it('renders fetched announcement', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.returns(
+        {announcements: [{id: '1234', messageContent: 'test thing'}]});
+
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._announcements,
+        [{id: '1234', messageContent: 'test thing'}]);
+    assert.include(element.shadowRoot.textContent, 'test thing');
+  });
+
+  it('renders empty on empty announcement', async () => {
+    sinon.stub(element, 'fetch');
+    element.fetch.returns({});
+    element.service = 'monorail';
+
+    await element.updateComplete;
+
+    // Fetch runs here.
+
+    await element.updateComplete;
+
+    assert.deepEqual(element._announcements, []);
+    assert.equal(0, element.shadowRoot.children.length);
+  });
+
+  it('fetch returns response data', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse));
+
+    const resp = await element.fetch('monorail');
+
+    assert.deepEqual(resp, json);
+  });
+
+  it('fetch errors when no XSSI prefix', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse));
+
+    try {
+      await element.fetch('monorail');
+    } catch (e) {
+      assert.include(e.message, 'No XSSI prefix in announce response:');
+    }
+  });
+
+  it('fetch errors when response is not okay', async () => {
+    const json = {announcements: [{id: '1234', messageContent: 'test thing'}]};
+    const fakeResponse = XSSI_PREFIX + JSON.stringify(json);
+    window.fetch.returns(new window.Response(fakeResponse, {status: 500}));
+
+    try {
+      await element.fetch('monorail');
+    } catch (e) {
+      assert.include(e.message,
+          'Something went wrong while fetching announcements');
+    }
+  });
+});