Allow to write and run Typescript tests

Change-Id: I695901dca367b342705b7d86b001d2b400510112
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 8486019c..fe6bd36 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -280,6 +280,11 @@
         // it catches almost all errors related to invalid usage of this.
         "no-invalid-this": "off",
 
+        "node/no-extraneous-import": "off",
+
+        // Typescript already checks for undef
+        "no-undef": "off",
+
         "jsdoc/no-types": 2,
       },
       "parserOptions": {
@@ -287,22 +292,6 @@
       }
     },
     {
-      "files": ["**/*.ts"],
-      "excludedFiles": "*.d.ts",
-      "rules": {
-        // Custom rule from the //tools/js/eslint-rules directory.
-        // See //tools/js/eslint-rules/README.md for details
-        "ts-imports-js": 2,
-      }
-    },
-    {
-      "files": ["**/*.d.ts"],
-      "rules": {
-        // See details in the //tools/js/eslint-rules/report-ts-error.js file.
-        "report-ts-error": "error",
-      }
-    },
-    {
       "files": ["*.html", "test.js", "test-infra.js"],
       "rules": {
         "jsdoc/require-file-overview": "off"
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 41c3f17..c29663c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -3,7 +3,8 @@
 
 package(default_visibility = ["//visibility:public"])
 
-# This list must be in sync with the "include" list in the tsconfig.json file
+# This list must be in sync with the "include" list in the follwoing files:
+# tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
 src_dirs = [
     "constants",
     "elements",
@@ -27,6 +28,7 @@
         ]],
         exclude = [
             "**/*_test.js",
+            "**/*_test.ts",
         ],
     ),
     # The same outdir also appears in the following files:
@@ -40,6 +42,7 @@
         [
             "**/*.js",
             "**/*.ts",
+            "test/@types/*.d.ts",
         ],
         exclude = [
             "node_modules/**",
@@ -48,6 +51,7 @@
             "rollup.config.js",
         ],
     ),
+    include_tests = True,
     # The same outdir also appears in the following files:
     # wct_test.sh
     # karma.conf.js
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index e4dda13..badf8dc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -22,7 +22,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {sortComments} from '../../../utils/comment-util.js';
 import {Side} from '../../../constants/constants.js';
-import {generateChange} from '../../../test/test-utils';
+import {generateChange} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index d7fa86b..02b07dd 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,7 +19,7 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
 import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index 65f517a..be8836b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -21,7 +21,7 @@
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils';
+import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
 
 class GrUserTestPopupElement extends PolymerElement {
   static get is() { return 'gr-user-test-popup'; }
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index a926fdb..7aade93 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -551,6 +551,14 @@
 
   private readonly bindings = new Map<Shortcut, string[]>();
 
+  public _testOnly_getBindings() {
+    return this.bindings;
+  }
+
+  public _testOnly_isEmpty() {
+    return this.activeHosts.size === 0 && this.listeners.size === 0;
+  }
+
   private readonly listeners = new Set<ShortcutListener>();
 
   bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 8d9be62..feb1a82 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -34,7 +34,7 @@
         result.append(_get_ts_compiled_path(outdir, f))
     return result
 
-def compile_ts(name, srcs, ts_outdir):
+def compile_ts(name, srcs, ts_outdir, include_tests = False):
     """Compiles srcs files with the typescript compiler
 
     Args:
@@ -50,16 +50,31 @@
     # List of files produced by the typescript compiler
     generated_js = _get_ts_output_files(ts_outdir, srcs)
 
+    all_srcs = srcs + [
+        ":tsconfig.json",
+        ":tsconfig_bazel.json",
+        "@ui_npm//:node_modules",
+    ]
+    ts_project = "tsconfig_bazel.json"
+
+    if include_tests:
+        all_srcs = all_srcs + [
+            ":tsconfig_bazel_test.json",
+            "@ui_dev_npm//:node_modules",
+        ]
+        ts_project = "tsconfig_bazel_test.json"
+
     # Run the compiler
     native.genrule(
         name = ts_rule_name,
-        srcs = srcs + [
-            ":tsconfig.json",
-            "@ui_npm//:node_modules",
-        ],
+        srcs = all_srcs,
         outs = generated_js,
         cmd = " && ".join([
-            "$(location //tools/node_tools:tsc-bin) --project $(location :tsconfig.json) --outdir $(RULEDIR)/" + ts_outdir + " --baseUrl ./external/ui_npm/node_modules",
+            "$(location //tools/node_tools:tsc-bin) --project $(location :" +
+            ts_project +
+            ") --outdir $(RULEDIR)/" +
+            ts_outdir +
+            " --baseUrl ./external/ui_npm/node_modules/",
         ]),
         tools = ["//tools/node_tools:tsc-bin"],
     )
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
index 8a30254..ee03171 100644
--- a/polygerrit-ui/app/scripts/polymer-resin-install.ts
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -57,10 +57,16 @@
 
 const security = window.security;
 
-export function installPolymerResin(safeTypesBridge: SafeTypeBridge) {
+export const _testOnly_defaultResinReportHandler =
+  security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+
+export function installPolymerResin(
+  safeTypesBridge: SafeTypeBridge,
+  reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER
+) {
   window.security.polymer_resin.install({
     allowedIdentifierPrefixes: [''],
-    reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+    reportHandler,
     safeTypesBridge,
   });
 }
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index 1ef2483..924ddd9 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -14,7 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export const grReportingMock = {
+import {ReportingService, Timer} from './gr-reporting';
+
+export class MockTimer implements Timer {
+  end(): this {
+    return this;
+  }
+
+  reset(): this {
+    return this;
+  }
+
+  withMaximum(_: number): this {
+    return this;
+  }
+}
+
+export const grReportingMock: ReportingService = {
   appStarted: () => {},
   beforeLocationChanged: () => {},
   changeDisplayed: () => {},
@@ -25,7 +41,7 @@
   diffViewFullyLoaded: () => {},
   fileListDisplayed: () => {},
   getTimer: () => {
-    return {end: () => {}};
+    return new MockTimer();
   },
   locationChanged: () => {},
   onVisibilityChange: () => {},
diff --git a/polygerrit-ui/app/test/@types/sinon-esm.d.ts b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
new file mode 100644
index 0000000..c7ac374
--- /dev/null
+++ b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+declare module 'sinon/pkg/sinon-esm' {
+  // sinon-esm doesn't have it's own d.ts, reexport all types from sinon
+  // This is a trick - @types/sinon adds interfaces and sinon instance
+  // to a global variables/namespace. We reexport it here, so we
+  // can use in our code when importing sinon-esm
+  // eslint-disable-next-line import/no-default-export
+  export default sinon;
+  const sinon: Sinon.SinonStatic;
+  export {SinonSpy};
+}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.js b/polygerrit-ui/app/test/common-test-setup-karma.ts
similarity index 70%
rename from polygerrit-ui/app/test/common-test-setup-karma.js
rename to polygerrit-ui/app/test/common-test-setup-karma.ts
index 2335f28..f553ead 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.js
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -14,14 +14,23 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './common-test-setup.js';
-import '@polymer/test-fixture/test-fixture.js';
-import 'chai/chai.js';
-self.assert = window.chai.assert;
-self.expect = window.chai.expect;
+import './common-test-setup';
+import '@polymer/test-fixture/test-fixture';
+import 'chai/chai';
+
+declare global {
+  interface Window {
+    flush: typeof flushImpl;
+    fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+    fixtureFromElement: typeof fixtureFromElementImpl;
+  }
+  let flush: typeof flushImpl;
+  let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+  let fixtureFromElement: typeof fixtureFromElementImpl;
+}
 
 // Workaround for https://github.com/karma-runner/karma-mocha/issues/227
-let unhandledError = null;
+let unhandledError: ErrorEvent;
 
 window.addEventListener('error', e => {
   // For uncaught error mochajs doesn't print the full stack trace.
@@ -31,7 +40,7 @@
   unhandledError = e;
 });
 
-let originalOnBeforeUnload;
+let originalOnBeforeUnload: typeof window.onbeforeunload;
 
 suiteSetup(() => {
   // This suiteSetup() method is called only once before all tests
@@ -39,7 +48,7 @@
   // Can't use window.addEventListener("beforeunload",...) here,
   // the handler is raised too late.
   originalOnBeforeUnload = window.onbeforeunload;
-  window.onbeforeunload = e => {
+  window.onbeforeunload = function (e: BeforeUnloadEvent) {
     // If a test reloads a page, we can't prevent it.
     // However we can print earror and the stack trace with assert.fail
     try {
@@ -48,7 +57,9 @@
       console.error('Page reloading attempt detected.');
       console.error(e.stack.toString());
     }
-    originalOnBeforeUnload(e);
+    if (originalOnBeforeUnload) {
+      originalOnBeforeUnload.call(this, e);
+    }
   };
 });
 
@@ -64,18 +75,18 @@
 // Keep the original one for use in test utils methods.
 const nativeSetTimeout = window.setTimeout;
 
+function flushImpl(): Promise<void>;
+function flushImpl(callback: () => void): void;
 /**
  * Triggers a flush of any pending events, observations, etc and calls you back
  * after they have been processed if callback is passed; otherwise returns
  * promise.
- *
- * @param {function()} callback
  */
-function flush(callback) {
+function flushImpl(callback?: () => void): Promise<void> | void {
   // Ideally, this function would be a call to Polymer.dom.flush, but that
   // doesn't support a callback yet
   // (https://github.com/Polymer/polymer-dev/issues/851)
-  window.Polymer.dom.flush();
+  (window as any).Polymer.dom.flush();
   if (callback) {
     nativeSetTimeout(callback, 0);
   } else {
@@ -85,19 +96,12 @@
   }
 }
 
-self.flush = flush;
+self.flush = flushImpl;
 
 class TestFixtureIdProvider {
-  static get instance() {
-    if (!TestFixtureIdProvider._instance) {
-      TestFixtureIdProvider._instance = new TestFixtureIdProvider();
-    }
-    return TestFixtureIdProvider._instance;
-  }
+  public static readonly instance: TestFixtureIdProvider = new TestFixtureIdProvider();
 
-  constructor() {
-    this.fixturesCount = 1;
-  }
+  private fixturesCount = 1;
 
   generateNewFixtureId() {
     this.fixturesCount++;
@@ -105,22 +109,24 @@
   }
 }
 
+interface TagTestFixture<T extends Element> {
+  instantiate(model?: unknown): T;
+}
+
 class TestFixture {
-  constructor(fixtureId) {
-    this.fixtureId = fixtureId;
-  }
+  constructor(private readonly fixtureId: string) {}
 
   /**
    * Create an instance of a fixture's template.
    *
-   * @param {Object} model - see Data-bound sections at
+   * @param model - see Data-bound sections at
    *   https://www.webcomponents.org/element/@polymer/test-fixture
-   * @return {HTMLElement | HTMLElement[]} - if the fixture's template contains
+   * @return - if the fixture's template contains
    *   a single element, returns the appropriated instantiated element.
    *   Otherwise, it return an array of all instantiated elements from the
    *   template.
    */
-  instantiate(model) {
+  instantiate(model?: unknown): HTMLElement | HTMLElement[] {
     // The window.fixture method is defined in common-test-setup.js
     return window.fixture(this.fixtureId, model);
   }
@@ -153,10 +159,9 @@
  *   });
  * }
  *
- * @param {HTMLTemplateElement} template - a template for a fixture
- * @return {TestFixture} - the instance of TestFixture class
+ * @param template - a template for a fixture
  */
-function fixtureFromTemplate(template) {
+function fixtureFromTemplateImpl(template: HTMLTemplateElement): TestFixture {
   const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
   const testFixture = document.createElement('test-fixture');
   testFixture.setAttribute('id', fixtureId);
@@ -183,14 +188,17 @@
  *   });
  * }
  *
- * @param {HTMLTemplateElement} template - a template for a fixture
- * @return {TestFixture} - the instance of TestFixture class
+ * @param tagName - a template for a fixture is <tagName></tagName>
  */
-function fixtureFromElement(tagName) {
+function fixtureFromElementImpl<T extends keyof HTMLElementTagNameMap>(
+  tagName: T
+): TagTestFixture<HTMLElementTagNameMap[T]> {
   const template = document.createElement('template');
   template.innerHTML = `<${tagName}></${tagName}>`;
-  return fixtureFromTemplate(template);
+  return (fixtureFromTemplate(template) as unknown) as TagTestFixture<
+    HTMLElementTagNameMap[T]
+  >;
 }
 
-window.fixtureFromTemplate = fixtureFromTemplate;
-window.fixtureFromElement = fixtureFromElement;
+window.fixtureFromTemplate = fixtureFromTemplateImpl;
+window.fixtureFromElement = fixtureFromElementImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
deleted file mode 100644
index eead4f8..0000000
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-// This should be the first import to install handler before any other code
-import './source-map-support-install.js';
-// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
-// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
-import '../scripts/bundled-polymer.js';
-import 'polymer-resin/standalone/polymer-resin.js';
-import '@polymer/iron-test-helpers/iron-test-helpers.js';
-import './test-router.js';
-import {_testOnlyInitAppContext} from './test-app-context-init';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api.js';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {cleanupTestUtils, TestKeyboardShortcutBinder} from './test-utils.js';
-import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import sinon from 'sinon/pkg/sinon-esm.js';
-import {safeTypesBridge} from '../utils/safe-types-util.js';
-import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit.js';
-import {initGlobalVariables} from '../elements/gr-app-global-var-init.js';
-window.sinon = sinon;
-
-security.polymer_resin.install({
-  allowedIdentifierPrefixes: [''],
-  reportHandler(isViolation, fmt, ...args) {
-    const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
-    log(isViolation, fmt, ...args);
-    if (isViolation) {
-      // This will cause the test to fail if there is a data binding
-      // violation.
-      throw new Error(
-          'polymer-resin violation: ' + fmt +
-        JSON.stringify(args));
-    }
-  },
-  safeTypesBridge,
-});
-
-const cleanups = [];
-
-// For karma always set our implementation
-// (karma doesn't provide the fixture method)
-window.fixture = function(fixtureId, model) {
-  // This method is inspired by web-component-tester method
-  cleanups.push(() => document.getElementById(fixtureId).restore());
-  return document.getElementById(fixtureId).create(model);
-};
-
-setup(() => {
-  window.Gerrit = {};
-  initGlobalVariables();
-
-  // If the following asserts fails - then window.stub is
-  // overwritten by some other code.
-  assert.equal(cleanups.length, 0);
-  // The following calls is nessecary to avoid influence of previously executed
-  // tests.
-  TestKeyboardShortcutBinder.push();
-  _testOnlyInitAppContext();
-  _testOnly_initGerritPluginApi();
-  const mgr = _testOnly_getShortcutManagerInstance();
-  assert.equal(mgr.activeHosts.size, 0);
-  assert.equal(mgr.listeners.size, 0);
-  document.getSelection().removeAllRanges();
-  const pl = _testOnly_resetPluginLoader();
-  // For testing, always init with empty plugin list
-  // Since when serve in gr-app, we always retrieve the list
-  // from project config and init loading after that, all
-  // `awaitPluginsLoaded` will rely on that to kick off,
-  // in testing, we want to kick start this earlier.
-  // You still can manually call _testOnly_resetPluginLoader
-  // to reset this behavior if you need to test something specific.
-  pl.loadPlugins([]);
-  _testOnlyResetGrRestApiSharedObjects();
-  _testOnlyResetRestApi();
-});
-
-// For karma always set our implementation
-// (karma doesn't provide the stub method)
-window.stub = function(tagName, implementation) {
-  // This method is inspired by web-component-tester method
-  const proto = document.createElement(tagName).constructor.prototype;
-  const stubs = Object.keys(implementation)
-      .map(key => sinon.stub(proto, key).callsFake(implementation[key]));
-  cleanups.push(() => {
-    stubs.forEach(stub => {
-      stub.restore();
-    });
-  });
-};
-
-// Very simple function to catch unexpected elements in documents body.
-// It can't catch everything, but in most cases it is enough.
-function checkChildAllowed(element) {
-  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
-  if (allowedTags.includes(element.tagName)) {
-    return;
-  }
-  if (element.tagName === 'TEST-FIXTURE') {
-    if (element.children.length == 0 ||
-        (element.children.length == 1 &&
-        element.children[0].tagName === 'TEMPLATE')) {
-      return;
-    }
-    assert.fail(`Test fixture
-        ${element.outerHTML}` +
-        `isn't resotred after the test is finished. Please ensure that ` +
-        `restore() method is called for this test-fixture. Usually the call` +
-        `happens automatically.`);
-    return;
-  }
-  if (element.tagName === 'DIV' && element.id === 'gr-hovercard-container' &&
-      element.childNodes.length === 0) {
-    return;
-  }
-  assert.fail(
-      `The following node remains in document after the test:
-      ${element.tagName}
-      Outer HTML:
-      ${element.outerHTML},
-      Stack trace:
-      ${element.stackTrace}`);
-}
-function checkGlobalSpace() {
-  for (const child of document.body.children) {
-    checkChildAllowed(child);
-  }
-}
-
-teardown(() => {
-  sinon.restore();
-  cleanupTestUtils();
-  cleanups.forEach(cleanup => cleanup());
-  cleanups.splice(0);
-  TestKeyboardShortcutBinder.pop();
-  checkGlobalSpace();
-  // Clean Polymer debouncer queue, so next tests will not be affected.
-  flushDebouncers();
-});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
new file mode 100644
index 0000000..e8a35dc
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -0,0 +1,203 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+// This should be the first import to install handler before any other code
+import './source-map-support-install';
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+import '../scripts/bundled-polymer';
+import '@polymer/iron-test-helpers/iron-test-helpers';
+import './test-router';
+import {_testOnlyInitAppContext} from './test-app-context-init';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {
+  cleanupTestUtils,
+  getCleanupsCount,
+  registerTestCleanup,
+  TestKeyboardShortcutBinder,
+} from './test-utils';
+import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
+import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import sinon, {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {safeTypesBridge} from '../utils/safe-types-util';
+import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init';
+import 'chai/chai';
+import {
+  _testOnly_defaultResinReportHandler,
+  installPolymerResin,
+} from '../scripts/polymer-resin-install';
+import {hasOwnProperty} from '../utils/common-util';
+
+declare global {
+  interface Window {
+    assert: typeof chai.assert;
+    expect: typeof chai.expect;
+    fixture: typeof fixtureImpl;
+    stub: typeof stubImpl;
+    sinon: typeof sinon;
+  }
+  let assert: typeof chai.assert;
+  let expect: typeof chai.expect;
+  let stub: typeof stubImpl;
+  let sinon: typeof sinon;
+}
+window.assert = chai.assert;
+window.expect = chai.expect;
+
+window.sinon = sinon;
+
+installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
+  const log = _testOnly_defaultResinReportHandler;
+  log(isViolation, fmt, ...args);
+  if (isViolation) {
+    // This will cause the test to fail if there is a data binding
+    // violation.
+    throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
+  }
+});
+
+interface TestFixtureElement extends HTMLElement {
+  restore(): void;
+  create(model?: unknown): HTMLElement | HTMLElement[];
+}
+
+function getFixtureElementById(fixtureId: string) {
+  return document.getElementById(fixtureId) as TestFixtureElement;
+}
+
+// For karma always set our implementation
+// (karma doesn't provide the fixture method)
+function fixtureImpl(fixtureId: string, model: unknown) {
+  // This method is inspired by web-component-tester method
+  registerTestCleanup(() => getFixtureElementById(fixtureId).restore());
+  return getFixtureElementById(fixtureId).create(model);
+}
+
+window.fixture = fixtureImpl;
+
+setup(() => {
+  window.Gerrit = {};
+  initGlobalVariables();
+
+  // If the following asserts fails - then window.stub is
+  // overwritten by some other code.
+  assert.equal(getCleanupsCount(), 0);
+  // The following calls is nessecary to avoid influence of previously executed
+  // tests.
+  TestKeyboardShortcutBinder.push();
+  _testOnlyInitAppContext();
+  _testOnly_initGerritPluginApi();
+  const mgr = _testOnly_getShortcutManagerInstance();
+  assert.isTrue(mgr._testOnly_isEmpty());
+  const selection = document.getSelection();
+  if (selection) {
+    selection.removeAllRanges();
+  }
+  const pl = _testOnly_resetPluginLoader();
+  // For testing, always init with empty plugin list
+  // Since when serve in gr-app, we always retrieve the list
+  // from project config and init loading after that, all
+  // `awaitPluginsLoaded` will rely on that to kick off,
+  // in testing, we want to kick start this earlier.
+  // You still can manually call _testOnly_resetPluginLoader
+  // to reset this behavior if you need to test something specific.
+  pl.loadPlugins([]);
+  _testOnlyResetGrRestApiSharedObjects();
+  _testOnlyResetRestApi();
+});
+
+// For karma always set our implementation
+// (karma doesn't provide the stub method)
+function stubImpl<T extends keyof HTMLElementTagNameMap>(
+  tagName: T,
+  implementation: Partial<HTMLElementTagNameMap[T]>
+) {
+  // This method is inspired by web-component-tester method
+  const proto = document.createElement(tagName).constructor
+    .prototype as HTMLElementTagNameMap[T];
+  let key: keyof HTMLElementTagNameMap[T];
+  const stubs: SinonSpy[] = [];
+  for (key in implementation) {
+    if (hasOwnProperty(implementation, key)) {
+      stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
+    }
+  }
+  registerTestCleanup(() => {
+    stubs.forEach(stub => {
+      stub.restore();
+    });
+  });
+}
+
+window.stub = stubImpl;
+
+// Very simple function to catch unexpected elements in documents body.
+// It can't catch everything, but in most cases it is enough.
+function checkChildAllowed(element: Element) {
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+  if (allowedTags.includes(element.tagName)) {
+    return;
+  }
+  if (element.tagName === 'TEST-FIXTURE') {
+    if (
+      element.children.length === 0 ||
+      (element.children.length === 1 &&
+        element.children[0].tagName === 'TEMPLATE')
+    ) {
+      return;
+    }
+    assert.fail(
+      `Test fixture
+        ${element.outerHTML}` +
+        "isn't resotred after the test is finished. Please ensure that " +
+        'restore() method is called for this test-fixture. Usually the call' +
+        'happens automatically.'
+    );
+    return;
+  }
+  if (
+    element.tagName === 'DIV' &&
+    element.id === 'gr-hovercard-container' &&
+    element.childNodes.length === 0
+  ) {
+    return;
+  }
+  assert.fail(
+    `The following node remains in document after the test:
+      ${element.tagName}
+      Outer HTML:
+      ${element.outerHTML},
+      Stack trace:
+      ${(element as any).stackTrace}`
+  );
+}
+function checkGlobalSpace() {
+  for (const child of document.body.children) {
+    checkChildAllowed(child);
+  }
+}
+
+teardown(() => {
+  sinon.restore();
+  cleanupTestUtils();
+  TestKeyboardShortcutBinder.pop();
+  checkGlobalSpace();
+  // Clean Polymer debouncer queue, so next tests will not be affected.
+  flushDebouncers();
+});
diff --git a/polygerrit-ui/app/test/source-map-support-install.js b/polygerrit-ui/app/test/source-map-support-install.ts
similarity index 73%
rename from polygerrit-ui/app/test/source-map-support-install.js
rename to polygerrit-ui/app/test/source-map-support-install.ts
index a8f147382..b8798e2 100644
--- a/polygerrit-ui/app/test/source-map-support-install.js
+++ b/polygerrit-ui/app/test/source-map-support-install.ts
@@ -15,6 +15,19 @@
  * limitations under the License.
  */
 
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and doesn't allow "declare global".
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+declare global {
+  interface Window {
+    sourceMapSupport: {
+      install(): void;
+    };
+  }
+}
+
 // The karma.conf.js file loads required module before any other modules
 // The source-map-support.js can't be imported with import ... statement
 window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/test/test-app-context-init.js b/polygerrit-ui/app/test/test-app-context-init.ts
similarity index 79%
rename from polygerrit-ui/app/test/test-app-context-init.js
rename to polygerrit-ui/app/test/test-app-context-init.ts
index 68e68f0..7f19903 100644
--- a/polygerrit-ui/app/test/test-app-context-init.js
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -16,14 +16,17 @@
  */
 
 // Init app context before any other imports
-import {initAppContext} from '../services/app-context-init.js';
-import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock.js';
-import {appContext} from '../services/app-context.js';
+import {initAppContext} from '../services/app-context-init';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {AppContext, appContext} from '../services/app-context';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
 
-  function setMock(serviceName, setupMock) {
+  function setMock<T extends keyof AppContext>(
+    serviceName: T,
+    setupMock: AppContext[T]
+  ) {
     Object.defineProperty(appContext, serviceName, {
       get() {
         return setupMock;
diff --git a/polygerrit-ui/app/test/test-router.js b/polygerrit-ui/app/test/test-router.ts
similarity index 85%
rename from polygerrit-ui/app/test/test-router.js
rename to polygerrit-ui/app/test/test-router.ts
index 9b89744..a378e2d 100644
--- a/polygerrit-ui/app/test/test-router.js
+++ b/polygerrit-ui/app/test/test-router.ts
@@ -14,6 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
+import {GerritNav} from '../elements/core/gr-navigation/gr-navigation';
 
-GerritNav.setup(url => { /* noop */ }, params => '', () => []);
+GerritNav.setup(
+  () => {
+    /* noop */
+  },
+  () => '',
+  () => [],
+  () => {
+    return {};
+  }
+);
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
deleted file mode 100644
index 32430f1..0000000
--- a/polygerrit-ui/app/test/test-utils.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
-import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils.js';
-import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-export const mockPromise = () => {
-  let res;
-  const promise = new Promise(resolve => {
-    res = resolve;
-  });
-  promise.resolve = res;
-  return promise;
-};
-export const isHidden = el => getComputedStyle(el).display === 'none';
-
-// Some tests/elements can define its own binding. We want to restore bindings
-// at the end of the test. The TestKeyboardShortcutBinder store bindings in
-// stack, so it is possible to override bindings in nested suites.
-export class TestKeyboardShortcutBinder {
-  static push() {
-    if (!this.stack) {
-      this.stack = [];
-    }
-    const testBinder = new TestKeyboardShortcutBinder();
-    this.stack.push(testBinder);
-    return _testOnly_getShortcutManagerInstance();
-  }
-
-  static pop() {
-    this.stack.pop()._restoreShortcuts();
-  }
-
-  constructor() {
-    this._originalBinding = new Map(
-        _testOnly_getShortcutManagerInstance().bindings);
-  }
-
-  _restoreShortcuts() {
-    const bindings = _testOnly_getShortcutManagerInstance().bindings;
-    bindings.clear();
-    this._originalBinding.forEach((value, key) => {
-      bindings.set(key, value);
-    });
-  }
-}
-
-// Provide reset plugins function to clear installed plugins between tests.
-// No gr-app found (running tests)
-export const resetPlugins = () => {
-  testOnly_resetInternalState();
-  _testOnly_resetEndpoints();
-  const pl = _testOnly_resetPluginLoader();
-  pl.loadPlugins([]);
-};
-
-const cleanups = [];
-
-function registerTestCleanup(cleanupCallback) {
-  cleanups.push(cleanupCallback);
-}
-
-export function cleanupTestUtils() {
-  cleanups.forEach(cleanup => cleanup());
-  cleanups.splice(0);
-}
-
-export function stubBaseUrl(newUrl) {
-  const originalCanonicalPath = window.CANONICAL_PATH;
-  window.CANONICAL_PATH = newUrl;
-  registerTestCleanup(() => window.CANONICAL_PATH = originalCanonicalPath);
-}
-
-export function generateChange(options) {
-  const change = {
-    _number: 42,
-    project: 'testRepo',
-  };
-  const revisionIdStart = 1;
-  const messageIdStart = 1000;
-  // We want to distinguish between empty arrays/objects and undefined
-  // If an option is not set - the appropriate property is not set
-  // If an options is set - the property always set
-  if (options && typeof options.revisionsCount !== 'undefined') {
-    const revisions = {};
-    for (let i = 0; i < options.revisionsCount; i++) {
-      const revisionId = (i + revisionIdStart).toString(16);
-      revisions[revisionId] = {
-        _number: i+1,
-        commit: {parents: []},
-      };
-    }
-    change.revisions = revisions;
-  }
-  if (options && typeof options.messagesCount !== 'undefined') {
-    const messages = [];
-    for (let i = 0; i < options.messagesCount; i++) {
-      messages.push({
-        id: (i + messageIdStart).toString(16),
-        date: new Date(2020, 1, 1),
-        message: `This is a message N${i + 1}`,
-      });
-    }
-    change.messages = messages;
-  }
-  if (options && options.status) {
-    change.status = options.status;
-  }
-  return change;
-}
-
-/**
- * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
- * otherwise the backdrop stays around in the DOM for too long waiting for
- * an animation to finish. This could be considered to be moved to a
- * common-test-setup file.
- */
-export function createIronOverlayBackdropStyleEl() {
-  const ironOverlayBackdropStyleEl = document.createElement('style');
-  document.head.appendChild(ironOverlayBackdropStyleEl);
-  ironOverlayBackdropStyleEl.sheet.insertRule(
-      'body { --iron-overlay-backdrop-opacity: 0; }');
-  return ironOverlayBackdropStyleEl;
-}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
new file mode 100644
index 0000000..76fd8a8
--- /dev/null
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -0,0 +1,233 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../types/globals';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils';
+import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
+import {
+  _testOnly_getShortcutManagerInstance,
+  Shortcut,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {ChangeStatus, RevisionKind} from '../constants/constants';
+import {
+  AccountInfo,
+  BranchName,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  CommitInfo,
+  GitPersonInfo,
+  GitRef,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  RevisionInfo,
+  Timestamp,
+  TimezoneOffset,
+} from '../types/common';
+import {formatDate} from '../utils/date-util';
+
+export interface MockPromise extends Promise<unknown> {
+  resolve: (value?: unknown) => void;
+}
+
+export const mockPromise = () => {
+  let res: (value?: unknown) => void;
+  const promise: MockPromise = new Promise(resolve => {
+    res = resolve;
+  }) as MockPromise;
+  promise.resolve = res!;
+  return promise;
+};
+export const isHidden = (el: Element) =>
+  getComputedStyle(el).display === 'none';
+
+// Some tests/elements can define its own binding. We want to restore bindings
+// at the end of the test. The TestKeyboardShortcutBinder store bindings in
+// stack, so it is possible to override bindings in nested suites.
+export class TestKeyboardShortcutBinder {
+  private static stack: TestKeyboardShortcutBinder[] = [];
+
+  static push() {
+    const testBinder = new TestKeyboardShortcutBinder();
+    this.stack.push(testBinder);
+    return _testOnly_getShortcutManagerInstance();
+  }
+
+  static pop() {
+    const item = this.stack.pop();
+    if (!item) {
+      throw new Error('stack is empty');
+    }
+    item._restoreShortcuts();
+  }
+
+  private readonly originalBinding: Map<Shortcut, string[]>;
+
+  constructor() {
+    this.originalBinding = new Map(
+      _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
+    );
+  }
+
+  _restoreShortcuts() {
+    const bindings = _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
+    bindings.clear();
+    this.originalBinding.forEach((value, key) => {
+      bindings.set(key, value);
+    });
+  }
+}
+
+// Provide reset plugins function to clear installed plugins between tests.
+// No gr-app found (running tests)
+export const resetPlugins = () => {
+  testOnly_resetInternalState();
+  _testOnly_resetEndpoints();
+  const pl = _testOnly_resetPluginLoader();
+  pl.loadPlugins([]);
+};
+
+export type CleanupCallback = () => void;
+
+const cleanups: CleanupCallback[] = [];
+
+export function getCleanupsCount() {
+  return cleanups.length;
+}
+
+export function registerTestCleanup(cleanupCallback: CleanupCallback) {
+  cleanups.push(cleanupCallback);
+}
+
+export function cleanupTestUtils() {
+  cleanups.forEach(cleanup => cleanup());
+  cleanups.splice(0);
+}
+
+export function stubBaseUrl(newUrl: string) {
+  const originalCanonicalPath = window.CANONICAL_PATH;
+  window.CANONICAL_PATH = newUrl;
+  registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
+}
+
+export interface GenerateChangeOptions {
+  revisionsCount?: number;
+  messagesCount?: number;
+  status: ChangeStatus;
+}
+
+export function dateToTimestamp(date: Date): Timestamp {
+  const nanosecondSuffix = '.000000000';
+  return (formatDate(date, 'YYYY-MM-DD HH:mm:ss') +
+    nanosecondSuffix) as Timestamp;
+}
+
+export function generateChange(options: GenerateChangeOptions) {
+  const project = 'testRepo' as RepoName;
+  const branch = 'test_branch' as BranchName;
+  const changeId = 'abcdef' as ChangeId;
+  const id = `${project}~${branch}~${changeId}` as ChangeInfoId;
+  const owner: AccountInfo = {};
+  const createdDate = new Date(2020, 1, 1, 1, 2, 3);
+
+  const change: ChangeInfo = {
+    _number: 42 as NumericChangeId,
+    project,
+    branch,
+    change_id: changeId,
+    created: dateToTimestamp(createdDate),
+    deletions: 0,
+    id,
+    insertions: 0,
+    owner,
+    reviewers: {},
+    status: options?.status ?? ChangeStatus.NEW,
+    subject: '',
+    submitter: owner,
+    updated: dateToTimestamp(new Date(2020, 10, 5, 1, 2, 3)),
+  };
+  const revisionIdStart = 1;
+  const messageIdStart = 1000;
+  // We want to distinguish between empty arrays/objects and undefined
+  // If an option is not set - the appropriate property is not set
+  // If an options is set - the property always set
+  if (options && typeof options.revisionsCount !== 'undefined') {
+    const revisions: {[revisionId: string]: RevisionInfo} = {};
+    const revisionDate = createdDate;
+    for (let i = 0; i < options.revisionsCount; i++) {
+      const revisionId = (i + revisionIdStart).toString(16);
+      const person: GitPersonInfo = {
+        name: 'Test person',
+        email: 'email@google.com',
+        date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+        tz: 0 as TimezoneOffset,
+      };
+      const commit: CommitInfo = {
+        parents: [],
+        author: person,
+        committer: person,
+        subject: 'Test commit subject',
+        message: 'Test commit message',
+      };
+      const revision: RevisionInfo = {
+        _number: (i + 1) as PatchSetNum,
+        commit,
+        created: dateToTimestamp(revisionDate),
+        kind: RevisionKind.REWORK,
+        ref: `refs/changes/5/6/${i + 1}` as GitRef,
+        uploader: owner,
+      };
+      revisions[revisionId] = revision;
+      // advance 1 day
+      revisionDate.setDate(revisionDate.getDate() + 1);
+    }
+    change.revisions = revisions;
+  }
+  if (options && typeof options.messagesCount !== 'undefined') {
+    const messages: ChangeMessageInfo[] = [];
+    for (let i = 0; i < options.messagesCount; i++) {
+      messages.push({
+        id: (i + messageIdStart).toString(16) as ChangeMessageId,
+        date: '2020-01-01 00:00:00.000000000' as Timestamp,
+        message: `This is a message N${i + 1}`,
+      });
+    }
+    change.messages = messages;
+  }
+  if (options && options.status) {
+    change.status = options.status;
+  }
+  return change;
+}
+
+/**
+ * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
+ * otherwise the backdrop stays around in the DOM for too long waiting for
+ * an animation to finish. This could be considered to be moved to a
+ * common-test-setup file.
+ */
+export function createIronOverlayBackdropStyleEl() {
+  const ironOverlayBackdropStyleEl = document.createElement('style');
+  document.head.appendChild(ironOverlayBackdropStyleEl);
+  ironOverlayBackdropStyleEl.sheet!.insertRule(
+    'body { --iron-overlay-backdrop-opacity: 0; }'
+  );
+  return ironOverlayBackdropStyleEl;
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index bc6c2df..ac966d2 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -44,7 +44,9 @@
   // If allowJs is set to true, .js and .jsx files are included as well.
   // Note: gerrit doesn't have .tsx and .jsx files
   "include": [
-    // This items below must be in sync with the src_dirs list in the BUILD file
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
+    // (include and exclude arrays are overriden when extends)
     "constants/**/*",
     "elements/**/*",
     "embed/**/*",
@@ -56,7 +58,6 @@
     "styles/**/*",
     "types/**/*",
     "utils/**/*",
-    // Directory for test utils (not included in src_dirs in the BUILD file)
     "test/**/*"
   ]
 }
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
new file mode 100644
index 0000000..6365bf0
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -0,0 +1,29 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "../../external/ui_npm/node_modules/@types",
+      "../../external/ui_dev_npm/node_modules/@types"
+    ]
+  },
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*"
+  ],
+  "exclude": [
+    "**/*_test.ts",
+    "**/*_test.js"
+  ]
+}
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
new file mode 100644
index 0000000..123e063
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -0,0 +1,28 @@
+{
+  "extends": "./tsconfig_bazel.json",
+  "compilerOptions": {
+    "typeRoots": [
+      "./test/@types",
+      "../../external/ui_npm/node_modules/@types",
+      "../../external/ui_dev_npm/node_modules/@types"
+    ],
+  },
+  "include": [
+    // Items below must be in sync with the src_dirs list in the BUILD file
+    // Also items must be in sync with tsconfig.json, tsconfig_test.json
+    // (include and exclude arrays are overriden when extends)
+    "constants/**/*",
+    "elements/**/*",
+    "embed/**/*",
+    "gr-diff/**/*",
+    "mixins/**/*",
+    "samples/**/*",
+    "scripts/**/*",
+    "services/**/*",
+    "styles/**/*",
+    "types/**/*",
+    "utils/**/*",
+    "test/**/*"
+  ],
+  "exclude": []
+}
diff --git a/polygerrit-ui/app/utils/access-util_test.js b/polygerrit-ui/app/utils/access-util_test.ts
similarity index 66%
rename from polygerrit-ui/app/utils/access-util_test.js
rename to polygerrit-ui/app/utils/access-util_test.ts
index 209c2ff..f098d89 100644
--- a/polygerrit-ui/app/utils/access-util_test.js
+++ b/polygerrit-ui/app/utils/access-util_test.ts
@@ -15,28 +15,37 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {toSortedPermissionsArray} from './access-util.js';
+import '../test/common-test-setup-karma';
+import {toSortedPermissionsArray} from './access-util';
 
 suite('access-util tests', () => {
   test('toSortedPermissionsArray', () => {
     const rules = {
       'global:Project-Owners': {
-        action: 'ALLOW', force: false,
+        action: 'ALLOW',
+        force: false,
       },
       '4c97682e6ce6b7247f3381b6f1789356666de7f': {
-        action: 'ALLOW', force: false,
+        action: 'ALLOW',
+        force: false,
       },
     };
     const expectedResult = [
-      {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
-        action: 'ALLOW', force: false,
-      }},
-      {id: 'global:Project-Owners', value: {
-        action: 'ALLOW', force: false,
-      }},
+      {
+        id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      },
+      {
+        id: 'global:Project-Owners',
+        value: {
+          action: 'ALLOW',
+          force: false,
+        },
+      },
     ];
     assert.deepEqual(toSortedPermissionsArray(rules), expectedResult);
   });
 });
-
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 3cad21a..1dd2d2f 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -141,6 +141,7 @@
   if (format.includes('ss')) {
     options.second = '2-digit';
   }
+
   let locale = 'en-US';
   // Workaround for Chrome 80, en-US is using h24 (midnight is 24:00),
   // en-GB is using h23 (midnight is 00:00)
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 91b8579..534db57 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -2,7 +2,11 @@
   "name": "polygerrit-ui-dev-dependencies",
   "description": "Gerrit Code Review - Polygerrit dev dependencies",
   "browser": true,
-  "dependencies": {},
+  "dependencies": {
+    "@types/chai": "^4.2.14",
+    "@types/mocha": "^8.0.3",
+    "@types/sinon": "^9.0.8"
+  },
   "devDependencies": {
     "@open-wc/karma-esm": "^2.16.16",
     "@polymer/iron-test-helpers": "^3.0.1",
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index a70ded8..2243be9 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -989,6 +989,11 @@
   resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.0.tgz#af31cc52062be0ab24583be072fd49b634dcc2fe"
   integrity sha512-wT1VfnScjAftZsvLYaefu/UuwYJdYBwD2JDL2OQd01plGmuAoir5V6HnVHgrfh7zEwcasoiyO2wQ+W58sNh2sw==
 
+"@types/chai@^4.2.14":
+  version "4.2.14"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.14.tgz#44d2dd0b5de6185089375d976b4ec5caf6861193"
+  integrity sha512-G+ITQPXkwTrslfG5L/BksmbLUA0M1iybEsmCWPqzSxsRRhJZimBKJkoMi8fr/CPygPTj4zO5pJH7I2/cm9M7SQ==
+
 "@types/command-line-args@^5.0.0":
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.0.0.tgz#484e704d20dbb8754a8f091eee45cdd22bcff28c"
@@ -1140,6 +1145,11 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
   integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
+"@types/mocha@^8.0.3":
+  version "8.0.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.3.tgz#51b21b6acb6d1b923bbdc7725c38f9f455166402"
+  integrity sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg==
+
 "@types/node@*":
   version "14.0.14"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.14.tgz#24a0b5959f16ac141aeb0c5b3cd7a15b7c64cbce"
@@ -1175,6 +1185,18 @@
     "@types/express-serve-static-core" "*"
     "@types/mime" "*"
 
+"@types/sinon@^9.0.8":
+  version "9.0.8"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.8.tgz#1ed0038d356784f75b086104ef83bfd4130bb81b"
+  integrity sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==
+  dependencies:
+    "@types/sinonjs__fake-timers" "*"
+
+"@types/sinonjs__fake-timers@*":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae"
+  integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==
+
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
   resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"