[gold] Port edit-ignore-rule-sk to TypeScript.

Bug: skia:10246
Change-Id: Id58ce9c01c2bc75bda9757d1e042ff13c8112fff
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/401223
Reviewed-by: Kevin Lubick <kjlubick@google.com>
Commit-Queue: Leandro Lovisolo <lovisolo@google.com>
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk-demo.js b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk-demo.js
deleted file mode 100644
index 23d53a4..0000000
--- a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk-demo.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import './index';
-import { $$ } from 'common-sk/modules/dom';
-import { manyParams } from '../shared_demo_data';
-
-Date.now = () => Date.parse('2020-02-01T00:00:00Z');
-
-function newEditIgnoreRule(parentSelector, query = '', expires = '', note = '') {
-  const ele = document.createElement('edit-ignore-rule-sk');
-  ele.paramset = manyParams;
-  ele.query = query;
-  ele.expires = expires;
-  ele.note = note;
-  $$(parentSelector).appendChild(ele);
-}
-
-newEditIgnoreRule('#empty');
-newEditIgnoreRule('#filled',
-  'alpha_type=Opaque&compiler=GCC&cpu_or_gpu_value=Adreno540&cpu_or_gpu_value=GTX660&'
-  + 'name=01_original.jpg_0.333&source_options=codec_animated_kNonNative_unpremul',
-  '2020-02-03T00:00:00Z', 'this is my note');
-$$('#filled query-sk .selection div:nth-child(1)').click();
-newEditIgnoreRule('#missing');
-$$('#missing edit-ignore-rule-sk').verifyFields();
-
-newEditIgnoreRule('#partial_custom_values');
-$$('#partial_custom_values input.custom_key').value = 'oops';
-$$('#partial_custom_values button.add_custom').click();
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk-demo.ts b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk-demo.ts
new file mode 100644
index 0000000..24e9df5
--- /dev/null
+++ b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk-demo.ts
@@ -0,0 +1,40 @@
+import './index';
+import { $$ } from 'common-sk/modules/dom';
+import { manyParams } from '../shared_demo_data';
+import { EditIgnoreRuleSk } from './edit-ignore-rule-sk';
+import { EditIgnoreRuleSkPO } from './edit-ignore-rule-sk_po';
+
+Date.now = () => Date.parse('2020-02-01T00:00:00Z');
+
+function newEditIgnoreRule(
+    parentSelector: string,
+    query = '',
+    expires = '',
+    note = ''): {el: EditIgnoreRuleSk, po: EditIgnoreRuleSkPO} {
+  const el = new EditIgnoreRuleSk();
+  el.paramset = manyParams;
+  el.query = query;
+  el.expires = expires;
+  el.note = note;
+  $$(parentSelector)!.appendChild(el);
+  return {el: el, po: new EditIgnoreRuleSkPO(el)};
+}
+
+async function populate() {
+   newEditIgnoreRule('#empty');
+
+  const {po: filledPO} = newEditIgnoreRule('#filled',
+    'alpha_type=Opaque&compiler=GCC&cpu_or_gpu_value=Adreno540&cpu_or_gpu_value=GTX660&'
+    + 'name=01_original.jpg_0.333&source_options=codec_animated_kNonNative_unpremul',
+    '2020-02-03T00:00:00Z', 'this is my note');
+  await (await filledPO.getQuerySkPO()).clickKey('alpha_type');
+
+  const {el: missing} = newEditIgnoreRule('#missing');
+  missing.verifyFields();
+
+  const {po: partialCustomValuesPO} = newEditIgnoreRule('#partial_custom_values');
+  await partialCustomValuesPO.setCustomKey('oops');
+  await partialCustomValuesPO.clickAddCustomParamBtn();
+}
+
+populate();
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk.js b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk.js
deleted file mode 100644
index f40a7cb..0000000
--- a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk.js
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * @module modules/edit-ignore-rule-sk
- * @description <h2><code>edit-ignore-rule-sk</code></h2>
- *
- * The edit-ignore-rule-sk element shows the pieces of a Gold ignore rule and allows for the
- * modification of them.
- *
- * TODO(kjlubick) Add client-side validation of expires values.
- */
-
-import { $$ } from 'common-sk/modules/dom';
-import { define } from 'elements-sk/define';
-import { html } from 'lit-html';
-import { diffDate } from 'common-sk/modules/human';
-import { ElementSk } from '../../../infra-sk/modules/ElementSk';
-import { humanReadableQuery } from '../common';
-
-import '../../../infra-sk/modules/query-sk';
-
-const template = (ele) => html`
-  <div class=columns>
-    <label for=expires>Expires in</label>
-    <input placeholder="(e.g. 2w, 4h)" id=expires value=${ele._expiresText}>
-  </div>
-
-  <div class=columns>
-    <textarea placeholder="Enter a note, e.g 'skia:1234'" id=note>${ele._note}</textarea>
-    <div class=query>${humanReadableQuery(ele.query)}</div>
-  </div>
-
-  <query-sk .paramset=${ele.paramset} .current_query=${ele.query} hide_invert hide_regex
-    @query-change=${ele._queryChanged}></query-sk>
-
-  <div>
-    <input class=custom_key placeholder="specify a key">
-    <input class=custom_value placeholder="specify a value">
-    <button class=add_custom @click=${ele._addCustomParam}
-      title="Add a custom key/value pair to ignore. For example, if adding a new test/corpus and you
-        want to avoid spurious comments about untriaged digests, use this to add a rule before the
-        CL lands.">
-      Add Custom Param
-     </button>
-  </div>
-
-  <div class=error ?hidden=${!ele._errMsg}>${ele._errMsg}</div>
-`;
-
-
-define('edit-ignore-rule-sk', class extends ElementSk {
-  constructor() {
-    super(template);
-    this._paramset = {};
-    this._query = '';
-    this._note = '';
-    this._expiresText = '';
-    this._errMsg = '';
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._render();
-  }
-
-  /**
-   * @prop paramset {Object} A map of String -> Array<String> containing the available key/value
-   *       pairs from which ignore rules may be built. For example, {'os': ['linux', 'windows']}.
-   */
-  get paramset() { return this._paramset; }
-
-  set paramset(p) {
-    this._paramset = p;
-    this._render();
-  }
-
-  /**
-   * @prop query {String} A URL-encoded string containing the selected query. For example,
-   *       'alpha=beta&mind%20the_gap=space'.
-   */
-  get query() { return this._query; }
-
-  set query(q) {
-    this._query = q;
-    this._render();
-  }
-
-  /**
-   * @prop expires {String} The human readable shorthand for a time duration (e.g. '2w'). If set
-   *       with a date, it will be converted into shorthand notation when displayed to the user.
-   *       This time duration represents how long until the ignore rule "expires", i.e. when it
-   *       should be re-evaluated if it is needed still.
-   */
-  get expires() {
-    // Note, this is a string like 2w, 3h - it will be parsed server-side.
-    return $$('#expires', this).value;
-  }
-
-  set expires(d) {
-    // We are given a date string, we turn it into the human readable text. There might be some
-    // loss of fidelity (e.g. a time of 2 weeks minus 10 minutes gets rounded to 2w gets rounded),
-    // but that's ok - the developers don't expect expiration times to be ultra precise.
-    const nowMS = Date.now();
-    const newMS = Date.parse(d);
-    if (!newMS || newMS < nowMS) {
-      this._expiresText = '';
-    } else {
-      this._expiresText = diffDate(d);
-    }
-    this._render();
-  }
-
-  /**
-   * @prop note {String} A note, usually a comment, to accompany the ignore rule.
-   */
-  get note() { return $$('#note', this).value; }
-
-  set note(n) {
-    this._note = n;
-    this._render();
-  }
-
-  _addCustomParam() {
-    const keyInput = $$('input.custom_key', this);
-    const valueInput = $$('input.custom_value', this);
-
-    const key = keyInput.value;
-    const value = valueInput.value;
-    if (!key || !value) {
-      this._errMsg = 'Must specify both a key and a value';
-      this._render();
-      return;
-    }
-    // Push the key/value to the _paramset so the query-sk can treat it like a normal value.
-    const values = this._paramset[key] || [];
-    values.push(value);
-    this._paramset[key] = values;
-    this._errMsg = '';
-    // Add the selection to the query so it shows up for the user.
-    const newParam = `${key}=${value}`;
-    if (this._query) {
-      this._query += `&${newParam}`;
-    } else {
-      this._query = newParam;
-    }
-    this._render();
-  }
-
-  _queryChanged(e) {
-    // Stop the query-sk event from leaving this element to avoid confusing a parent element
-    // with unexpected events.
-    e.stopPropagation();
-    this.query = e.detail.q;
-  }
-
-  /**
-   * Resets input, outputs, and error, other than the paramset. The paramset field will likely
-   * not change over the lifetime of this element.
-   */
-  reset() {
-    this._query = '';
-    this._note = '';
-    this._expiresText = '';
-    this._errMsg = '';
-    this._render();
-  }
-
-  /**
-   * Checks that all the required inputs (query and expires) have non-empty values. If so, it
-   * returns true, otherwise, it will display an error on the element.
-   * @return {boolean}
-   */
-  verifyFields() {
-    if (this.query && this.expires) {
-      this._errMsg = '';
-      this._render();
-      return true;
-    }
-    this._errMsg = 'Must specify a non-empty filter and an expiration.';
-    this._render();
-    return false;
-  }
-});
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk.ts b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk.ts
new file mode 100644
index 0000000..72e516a
--- /dev/null
+++ b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk.ts
@@ -0,0 +1,182 @@
+/**
+ * @module modules/edit-ignore-rule-sk
+ * @description <h2><code>edit-ignore-rule-sk</code></h2>
+ *
+ * The edit-ignore-rule-sk element shows the pieces of a Gold ignore rule and allows for the
+ * modification of them.
+ *
+ * TODO(kjlubick) Add client-side validation of expires values.
+ */
+
+import { define } from 'elements-sk/define';
+import { html } from 'lit-html';
+import { diffDate } from 'common-sk/modules/human';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { humanReadableQuery } from '../common';
+import { ParamSet } from '../rpc_types';
+import { QuerySkQueryChangeEventDetail } from '../../../infra-sk/modules/query-sk/query-sk';
+
+import '../../../infra-sk/modules/query-sk';
+
+export class EditIgnoreRuleSk extends ElementSk {
+
+  private static template = (ele: EditIgnoreRuleSk) => html`
+    <div class=columns>
+      <label for=expires>Expires in</label>
+      <input placeholder="(e.g. 2w, 4h)" id=expires value=${ele._expires}>
+    </div>
+
+    <div class=columns>
+      <textarea placeholder="Enter a note, e.g 'skia:1234'" id=note>${ele._note}</textarea>
+      <div class=query>${humanReadableQuery(ele.query)}</div>
+    </div>
+
+    <query-sk .paramset=${ele.paramset} .current_query=${ele.query} hide_invert hide_regex
+      @query-change=${ele.queryChanged}></query-sk>
+
+    <div>
+      <input class=custom_key placeholder="specify a key">
+      <input class=custom_value placeholder="specify a value">
+      <button class=add_custom @click=${ele.addCustomParam}
+        title="Add a custom key/value pair to ignore. For example, if adding a new test/corpus and
+          you want to avoid spurious comments about untriaged digests, use this to add a rule
+          before the CL lands.">
+        Add Custom Param
+       </button>
+    </div>
+
+    <div class=error ?hidden=${!ele.errMsg}>${ele.errMsg}</div>
+  `;
+
+  private _paramset: ParamSet = {};
+  private _query = '';
+  private _note = '';
+  private _expires = '';
+  private errMsg = '';
+
+  constructor() {
+    super(EditIgnoreRuleSk.template);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this._render();
+  }
+
+  /**
+   * Key/value pairs from which ignore rules may be built. For example,
+   * `{'os': ['linux', 'windows']}`.
+   */
+  get paramset(): ParamSet { return this._paramset; }
+
+  set paramset(p: ParamSet) {
+    this._paramset = p;
+    this._render();
+  }
+
+  /**
+   * A URL-encoded string containing the selected query. For example,
+   * 'alpha=beta&mind%20the_gap=space'`.
+   */
+  get query(): string { return this._query; }
+
+  set query(q: string) {
+    this._query = q;
+    this._render();
+  }
+
+  /**
+   * The human readable shorthand for a time duration (e.g. '2w'). If set with a date, it will be
+   * converted into shorthand notation when displayed to the user. This time duration represents
+   * how long until the ignore rule "expires", i.e. when it should be re-evaluated if it is needed
+   * still.
+   */
+  get expires(): string {
+    // Note, this is a string like 2w, 3h - it will be parsed server-side.
+    return this.querySelector<HTMLInputElement>('#expires')!.value;
+  }
+
+  set expires(d: string) {
+    // We are given a date string, we turn it into the human readable text. There might be some
+    // loss of fidelity (e.g. a time of 2 weeks minus 10 minutes gets rounded to 2w gets rounded),
+    // but that's ok - the developers don't expect expiration times to be ultra precise.
+    const nowMS = Date.now();
+    const newMS = Date.parse(d);
+    if (!newMS || newMS < nowMS) {
+      this._expires = '';
+    } else {
+      this._expires = diffDate(d);
+    }
+    this._render();
+  }
+
+  /** A note, usually a comment, to accompany the ignore rule. */
+  get note(): string { return this.querySelector<HTMLInputElement>('#note')!.value; }
+
+  set note(n: string) {
+    this._note = n;
+    this._render();
+  }
+
+  private addCustomParam() {
+    const keyInput = this.querySelector<HTMLInputElement>('input.custom_key')!;
+    const valueInput = this.querySelector<HTMLInputElement>('input.custom_value')!;
+
+    const key = keyInput.value;
+    const value = valueInput.value;
+    if (!key || !value) {
+      this.errMsg = 'Must specify both a key and a value.';
+      this._render();
+      return;
+    }
+    // Push the key/value to the _paramset so the query-sk can treat it like a normal value.
+    const values = this._paramset[key] || [];
+    values.push(value);
+    this._paramset[key] = values;
+    this.errMsg = '';
+    // Add the selection to the query so it shows up for the user.
+    const newParam = `${key}=${value}`;
+    if (this._query) {
+      this._query += `&${newParam}`;
+    } else {
+      this._query = newParam;
+    }
+    this._render();
+  }
+
+  private queryChanged(e: CustomEvent<QuerySkQueryChangeEventDetail>) {
+    // Stop the query-sk event from leaving this element to avoid confusing a parent element
+    // with unexpected events.
+    e.stopPropagation();
+    this.query = e.detail.q;
+  }
+
+  /**
+   * Resets input, outputs, and error, other than the paramset. The paramset field will likely
+   * not change over the lifetime of this element.
+   */
+  reset() {
+    this._query = '';
+    this._note = '';
+    this._expires = '';
+    this.errMsg = '';
+    this._render();
+  }
+
+  /**
+   * Checks that all the required inputs (query and expires) have non-empty values. If so, it
+   * returns true, otherwise, it will display an error on the element.
+   */
+  verifyFields(): boolean {
+    if (this.query && this.expires) {
+      this.errMsg = '';
+      this._render();
+      return true;
+    }
+    this.errMsg = 'Must specify a non-empty filter and an expiration.';
+    this._render();
+    return false;
+  }
+}
+
+define('edit-ignore-rule-sk', EditIgnoreRuleSk);
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_po.ts b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_po.ts
new file mode 100644
index 0000000..a8db760
--- /dev/null
+++ b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_po.ts
@@ -0,0 +1,60 @@
+import { PageObject } from '../../../infra-sk/modules/page_object/page_object';
+import { QuerySkPO } from '../../../infra-sk/modules/query-sk/query-sk_po';
+
+/** A page object for the EditIgnoreRuleSk component. */
+export class EditIgnoreRuleSkPO extends PageObject {
+  getExpires(): Promise<string> {
+    return this.selectOnePOEThenApplyFn('#expires', (poe) => poe.value);
+  }
+
+  async setExpires(value: string) {
+    await this.selectOnePOEThenApplyFn('#expires', (poe) => poe.enterValue(value));
+  }
+
+  getNote(): Promise<string> {
+    return this.selectOnePOEThenApplyFn('#note', (poe) => poe.value);
+  }
+
+  async setNote(value: string) {
+    await this.selectOnePOEThenApplyFn('#note', (poe) => poe.enterValue(value));
+  }
+
+  getCustomKey(): Promise<string> {
+    return this.selectOnePOEThenApplyFn('.custom_key', (poe) => poe.value);
+  }
+
+  async setCustomKey(value: string) {
+    await this.selectOnePOEThenApplyFn('.custom_key', (poe) => poe.enterValue(value));
+  }
+
+  getCustomValue(): Promise<string> {
+    return this.selectOnePOEThenApplyFn('.custom_value', (poe) => poe.value);
+  }
+
+  async setCustomValue(value: string) {
+    await this.selectOnePOEThenApplyFn('.custom_value', (poe) => poe.enterValue(value));
+  }
+
+  async clickAddCustomParamBtn() {
+    const btn = await this.selectOnePOE('.add_custom');
+    await btn!.click();
+  }
+
+  getQuery(): Promise<string> {
+    return this.selectOnePOEThenApplyFn('.query', (poe) => poe.innerText);
+  }
+
+  isErrorMessageVisible(): Promise<boolean> {
+    return this.selectOnePOEThenApplyFn(
+        '.error',
+        async (poe) => !(await poe.hasAttribute('hidden')));
+  }
+
+  getErrorMessage(): Promise<string> {
+    return this.selectOnePOEThenApplyFn('.error', (poe) => poe.innerText);
+  }
+
+  getQuerySkPO(): Promise<QuerySkPO> {
+    return this.selectOnePOEThenApplyFn('query-sk', async (poe) => new QuerySkPO(poe));
+  }
+}
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_test.js b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_test.js
deleted file mode 100644
index 9671099..0000000
--- a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_test.js
+++ /dev/null
@@ -1,156 +0,0 @@
-import './index';
-import { $$ } from 'common-sk/modules/dom';
-import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
-
-describe('edit-ignore-rule-sk', () => {
-  const newInstance = setUpElementUnderTest('edit-ignore-rule-sk');
-
-  // This date is arbitrary
-  const fakeNow = Date.parse('2020-02-01T00:00:00Z');
-  const regularNow = Date.now;
-
-  let editIgnoreRuleSk;
-  beforeEach(() => {
-    editIgnoreRuleSk = newInstance();
-    // All tests will have the paramset loaded.
-    editIgnoreRuleSk.paramset = {
-      alpha_type: ['Opaque', 'Premul'],
-      arch: ['arm', 'arm64', 'x86', 'x86_64'],
-    };
-    Date.now = () => fakeNow;
-  });
-
-  afterEach(() => {
-    Date.now = regularNow;
-  });
-
-  describe('inputs and outputs', () => {
-    it('has no query, note or expires', () => {
-      expect(editIgnoreRuleSk.query).to.equal('');
-      expect(editIgnoreRuleSk.note).to.equal('');
-      expect(editIgnoreRuleSk.expires).to.equal('');
-    });
-
-    it('reflects typed in values', () => {
-      getExpiresInput(editIgnoreRuleSk).value = '2w';
-      getNoteInput(editIgnoreRuleSk).value = 'this is a bug';
-      expect(editIgnoreRuleSk.expires).to.equal('2w');
-      expect(editIgnoreRuleSk.note).to.equal('this is a bug');
-    });
-
-    it('reflects interactions with the query-sk element', () => {
-      // Select alpha_type key, which displays Opaque and Premul as values.
-      getFirstQuerySkKey(editIgnoreRuleSk).click();
-      // Select Opaque as a value
-      getFirstQuerySkValue(editIgnoreRuleSk).click();
-      expect(editIgnoreRuleSk.query).to.equal('alpha_type=Opaque');
-    });
-
-    it('converts future dates to human readable durations', () => {
-      editIgnoreRuleSk.expires = '2020-02-07T06:00:00Z';
-      // It is ok that the 6 hours gets rounded out.
-      expect(editIgnoreRuleSk.expires).to.equal('6d');
-    });
-
-    it('converts past or invalid dates to nothing (requiring them to be re-input)', () => {
-      editIgnoreRuleSk.expires = '2020-01-07T06:00:00Z';
-      expect(editIgnoreRuleSk.expires).to.equal('');
-      editIgnoreRuleSk.expires = 'invalid date';
-      expect(editIgnoreRuleSk.expires).to.equal('');
-    });
-
-    it('can add a custom key and value', () => {
-      editIgnoreRuleSk.query = 'arch=arm64';
-
-      // Add a new value to an existing param
-      getCustomKeyInput(editIgnoreRuleSk).value = 'arch';
-      getCustomValueInput(editIgnoreRuleSk).value = 'y75';
-      clickAddCustomParam(editIgnoreRuleSk);
-
-      // add a brand new key and value
-      getCustomKeyInput(editIgnoreRuleSk).value = 'custom';
-      getCustomValueInput(editIgnoreRuleSk).value = 'value';
-      clickAddCustomParam(editIgnoreRuleSk);
-
-      expect(editIgnoreRuleSk.query).to.equal('arch=arm64&arch=y75&custom=value');
-      // ParamSet should be mutated to have the new values
-      expect(editIgnoreRuleSk.paramset.arch).to.deep.equal(['arm', 'arm64', 'x86', 'x86_64', 'y75']);
-      expect(editIgnoreRuleSk.paramset.custom).to.deep.equal(['value']);
-    });
-  });
-
-  describe('validation', () => {
-    it('has the error msg hidden by default', () => {
-      expect(getErrorMessage(editIgnoreRuleSk).hasAttribute('hidden')).to.be.true;
-    });
-
-    it('does not validate when query is empty', () => {
-      editIgnoreRuleSk.query = '';
-      editIgnoreRuleSk.expires = '2w';
-      expect(editIgnoreRuleSk.verifyFields()).to.be.false;
-      expect(getErrorMessage(editIgnoreRuleSk).hasAttribute('hidden')).to.be.false;
-    });
-
-    it('does not validate when expires is empty', () => {
-      editIgnoreRuleSk.query = 'alpha_type=Opaque';
-      editIgnoreRuleSk.expires = '';
-      expect(editIgnoreRuleSk.verifyFields()).to.be.false;
-      expect(getErrorMessage(editIgnoreRuleSk).hasAttribute('hidden')).to.be.false;
-    });
-
-    it('does not validate when both expires and query are empty', () => {
-      editIgnoreRuleSk.query = '';
-      editIgnoreRuleSk.expires = '';
-      expect(editIgnoreRuleSk.verifyFields()).to.be.false;
-      expect(getErrorMessage(editIgnoreRuleSk).hasAttribute('hidden')).to.be.false;
-    });
-
-    it('does passes validation when both expires and query are set', () => {
-      editIgnoreRuleSk.query = 'foo=bar';
-      getExpiresInput(editIgnoreRuleSk).value = '1w';
-      expect(editIgnoreRuleSk.verifyFields()).to.be.true;
-      expect(getErrorMessage(editIgnoreRuleSk).hasAttribute('hidden')).to.be.true;
-    });
-
-    it('requires both a custom key and value', () => {
-      expect(editIgnoreRuleSk.query).to.equal('');
-
-      getCustomKeyInput(editIgnoreRuleSk).value = '';
-      getCustomValueInput(editIgnoreRuleSk).value = '';
-      clickAddCustomParam(editIgnoreRuleSk);
-
-      expect(editIgnoreRuleSk._errMsg).to.contain('both a key and a value');
-      expect(editIgnoreRuleSk.query).to.equal('');
-
-      getCustomKeyInput(editIgnoreRuleSk).value = 'custom';
-      getCustomValueInput(editIgnoreRuleSk).value = '';
-      clickAddCustomParam(editIgnoreRuleSk);
-
-      expect(editIgnoreRuleSk._errMsg).to.contain('both a key and a value');
-      expect(editIgnoreRuleSk.query).to.equal('');
-
-      getCustomKeyInput(editIgnoreRuleSk).value = '';
-      getCustomValueInput(editIgnoreRuleSk).value = 'value';
-      clickAddCustomParam(editIgnoreRuleSk);
-
-      expect(editIgnoreRuleSk._errMsg).to.contain('both a key and a value');
-      expect(editIgnoreRuleSk.query).to.equal('');
-    });
-  });
-});
-
-const getExpiresInput = (ele) => $$('#expires', ele);
-
-const getNoteInput = (ele) => $$('#note', ele);
-
-const getCustomKeyInput = (ele) => $$('input.custom_key', ele);
-
-const getCustomValueInput = (ele) => $$('input.custom_value', ele);
-
-const getErrorMessage = (ele) => $$('.error', ele);
-
-const getFirstQuerySkKey = (ele) => $$('query-sk .selection div:nth-child(1)', ele);
-
-const getFirstQuerySkValue = (ele) => $$('query-sk #values div:nth-child(1)', ele);
-
-const clickAddCustomParam = (ele) => $$('button.add_custom', ele).click();
diff --git a/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_test.ts b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_test.ts
new file mode 100644
index 0000000..6e9fd0a
--- /dev/null
+++ b/golden/modules/edit-ignore-rule-sk/edit-ignore-rule-sk_test.ts
@@ -0,0 +1,145 @@
+import './index';
+import { setUpElementUnderTest } from '../../../infra-sk/modules/test_util';
+import { EditIgnoreRuleSk } from './edit-ignore-rule-sk';
+import { EditIgnoreRuleSkPO } from './edit-ignore-rule-sk_po';
+import { expect } from 'chai';
+
+describe('edit-ignore-rule-sk', () => {
+  const newInstance = setUpElementUnderTest<EditIgnoreRuleSk>('edit-ignore-rule-sk');
+
+  // This date is arbitrary
+  const fakeNow = Date.parse('2020-02-01T00:00:00Z');
+  const regularNow = Date.now;
+
+  let editIgnoreRuleSk: EditIgnoreRuleSk;
+  let editIgnoreRuleSkPO: EditIgnoreRuleSkPO;
+
+  beforeEach(() => {
+    editIgnoreRuleSk = newInstance();
+    // All tests will have the paramset loaded.
+    editIgnoreRuleSk.paramset = {
+      alpha_type: ['Opaque', 'Premul'],
+      arch: ['arm', 'arm64', 'x86', 'x86_64'],
+    };
+    Date.now = () => fakeNow;
+    editIgnoreRuleSkPO = new EditIgnoreRuleSkPO(editIgnoreRuleSk);
+  });
+
+  afterEach(() => {
+    Date.now = regularNow;
+  });
+
+  describe('inputs and outputs', () => {
+    it('has no query, note or expires', () => {
+      expect(editIgnoreRuleSk.query).to.equal('');
+      expect(editIgnoreRuleSk.note).to.equal('');
+      expect(editIgnoreRuleSk.expires).to.equal('');
+    });
+
+    it('reflects typed in values', async () => {
+      await editIgnoreRuleSkPO.setExpires('2w');
+      await editIgnoreRuleSkPO.setNote('this is a bug');
+      expect(editIgnoreRuleSk.expires).to.equal('2w');
+      expect(editIgnoreRuleSk.note).to.equal('this is a bug');
+    });
+
+    it('reflects interactions with the query-sk element', async () => {
+      const querySkPO = await editIgnoreRuleSkPO.getQuerySkPO();
+      await querySkPO.clickKey('alpha_type');
+      await querySkPO.clickValue('Opaque');
+      expect(editIgnoreRuleSk.query).to.equal('alpha_type=Opaque');
+    });
+
+    it('converts future dates to human readable durations', () => {
+      editIgnoreRuleSk.expires = '2020-02-07T06:00:00Z';
+      // It is ok that the 6 hours gets rounded out.
+      expect(editIgnoreRuleSk.expires).to.equal('6d');
+    });
+
+    it('converts past or invalid dates to nothing (requiring them to be re-input)', () => {
+      editIgnoreRuleSk.expires = '2020-01-07T06:00:00Z';
+      expect(editIgnoreRuleSk.expires).to.equal('');
+      editIgnoreRuleSk.expires = 'invalid date';
+      expect(editIgnoreRuleSk.expires).to.equal('');
+    });
+
+    it('can add a custom key and value', async () => {
+      editIgnoreRuleSk.query = 'arch=arm64';
+
+      // Add a new value to an existing param
+      await editIgnoreRuleSkPO.setCustomKey('arch');
+      await editIgnoreRuleSkPO.setCustomValue('y75');
+      await editIgnoreRuleSkPO.clickAddCustomParamBtn();
+
+      // add a brand new key and value
+      await editIgnoreRuleSkPO.setCustomKey('custom');
+      await editIgnoreRuleSkPO.setCustomValue('value');
+      await editIgnoreRuleSkPO.clickAddCustomParamBtn();
+
+      expect(editIgnoreRuleSk.query).to.equal('arch=arm64&arch=y75&custom=value');
+      // ParamSet should be mutated to have the new values
+      expect(editIgnoreRuleSk.paramset.arch)
+          .to.deep.equal(['arm', 'arm64', 'x86', 'x86_64', 'y75']);
+      expect(editIgnoreRuleSk.paramset.custom).to.deep.equal(['value']);
+    });
+  });
+
+  describe('validation', () => {
+    it('has the error msg hidden by default', async () => {
+      expect(await editIgnoreRuleSkPO.isErrorMessageVisible()).to.be.false;
+    });
+
+    it('does not validate when query is empty', async () => {
+      editIgnoreRuleSk.query = '';
+      editIgnoreRuleSk.expires = '2w';
+      expect(editIgnoreRuleSk.verifyFields()).to.be.false;
+      expect(await editIgnoreRuleSkPO.isErrorMessageVisible()).to.be.true;
+    });
+
+    it('does not validate when expires is empty', async () => {
+      editIgnoreRuleSk.query = 'alpha_type=Opaque';
+      editIgnoreRuleSk.expires = '';
+      expect(editIgnoreRuleSk.verifyFields()).to.be.false;
+      expect(await editIgnoreRuleSkPO.isErrorMessageVisible()).to.be.true;
+    });
+
+    it('does not validate when both expires and query are empty', async () => {
+      editIgnoreRuleSk.query = '';
+      editIgnoreRuleSk.expires = '';
+      expect(editIgnoreRuleSk.verifyFields()).to.be.false;
+      expect(await editIgnoreRuleSkPO.isErrorMessageVisible()).to.be.true;
+    });
+
+    it('does passes validation when both expires and query are set', async () => {
+      editIgnoreRuleSk.query = 'foo=bar';
+      await editIgnoreRuleSkPO.setExpires('1w');
+      expect(editIgnoreRuleSk.verifyFields()).to.be.true;
+      expect(await editIgnoreRuleSkPO.isErrorMessageVisible()).to.be.false;
+    });
+
+    it('requires both a custom key and value', async () => {
+      expect(editIgnoreRuleSk.query).to.equal('');
+
+      await editIgnoreRuleSkPO.setCustomKey('');
+      await editIgnoreRuleSkPO.setCustomValue('');
+      await editIgnoreRuleSkPO.clickAddCustomParamBtn();
+
+      expect(await editIgnoreRuleSkPO.getErrorMessage()).to.contain('both a key and a value');
+      expect(editIgnoreRuleSk.query).to.equal('');
+
+      await editIgnoreRuleSkPO.setCustomKey('custom');
+      await editIgnoreRuleSkPO.setCustomValue('');
+      await editIgnoreRuleSkPO.clickAddCustomParamBtn();
+
+      expect(await editIgnoreRuleSkPO.getErrorMessage()).to.contain('both a key and a value');
+      expect(editIgnoreRuleSk.query).to.equal('');
+
+      await editIgnoreRuleSkPO.setCustomKey('');
+      await editIgnoreRuleSkPO.setCustomValue('value');
+      await editIgnoreRuleSkPO.clickAddCustomParamBtn();
+
+      expect(await editIgnoreRuleSkPO.getErrorMessage()).to.contain('both a key and a value');
+      expect(editIgnoreRuleSk.query).to.equal('');
+    });
+  });
+});
diff --git a/golden/modules/edit-ignore-rule-sk/index.js b/golden/modules/edit-ignore-rule-sk/index.ts
similarity index 100%
rename from golden/modules/edit-ignore-rule-sk/index.js
rename to golden/modules/edit-ignore-rule-sk/index.ts