Screenshot test support

See gr-file-list_screenshot_test.ts for an example of how to create a
screenshot test. When a test fails it creates a gitignore'd file of the
diff heatmap. These tests are excluded from the normal test command
while we figure out how to normalize screenshots across operating
systems for the CI, besides this they could easily be included in our
standard test files.

Run tests excluding screenshot tests:
> yarn test

Run tests including screenshot tests:
> yarn test:screenshot

Run test and update goldens:
> yarn test:screenshot-update "**/gr-file-list_screenshot_test.ts"

Release-Notes: skip
Change-Id: If9e755f051e2af3906a27b6306ff0811a78a3c68
diff --git a/.gitignore b/.gitignore
index 623369b..53bc9f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,7 @@
 /plugins/*
 /polygerrit-ui/coverage/
 /polygerrit-ui/app/plugins/*
+/polygerrit-ui/screenshots/Chrome/failed/
 !/plugins/.eslintignore
 !/plugins/.eslintrc.js
 !/plugins/.prettierrc.js
diff --git a/package.json b/package.json
index 1dfee7c..cdaf400 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,8 @@
     "start": "run-p -rl compile:watch start:server",
     "start:server": "web-dev-server",
     "test": "yarn --cwd=polygerrit-ui test",
+    "test:screenshot": "yarn --cwd=polygerrit-ui test:screenshot",
+    "test:screenshot-update": "yarn --cwd=polygerrit-ui test:screenshot-update",
     "test:coverage": "yarn --cwd=polygerrit-ui test:coverage",
     "test:watch": "yarn --cwd=polygerrit-ui test:watch",
     "test:single": "yarn --cwd=polygerrit-ui test:single",
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts
new file mode 100644
index 0000000..f80f48b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_screenshot_test.ts
@@ -0,0 +1,58 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import {fixture, html} from '@open-wc/testing';
+import {visualDiff} from '@web/test-runner-visual-regression';
+import {FileInfo, PARENT, RevisionPatchSetNum} from '../../../api/rest-api';
+import {normalize} from '../../../models/change/files-model';
+import {PatchRange} from '../../../types/common';
+import {DiffPreferencesInfo} from '../../../api/diff';
+import {NormalizedFileInfo, GrFileList} from './gr-file-list';
+import './gr-file-list';
+
+suite('gr-file-list screenshot tests', () => {
+  let element: GrFileList;
+
+  function createFiles(
+    count: number,
+    fileInfo: FileInfo
+  ): NormalizedFileInfo[] {
+    return Array.from(Array(count).keys()).map(index =>
+      normalize(fileInfo, `/file${index}`)
+    );
+  }
+
+  setup(async () => {
+    const patchRange: PatchRange = {
+      basePatchNum: PARENT,
+      patchNum: 2 as RevisionPatchSetNum,
+    };
+    const diffPrefs: DiffPreferencesInfo = {
+      context: 10,
+      tab_size: 8,
+      font_size: 12,
+      line_length: 100,
+      ignore_whitespace: 'IGNORE_NONE',
+    };
+    element = await fixture(
+      html`<gr-file-list
+        .patchRange=${patchRange}
+        .diffPrefs=${diffPrefs}
+      ></gr-file-list>`
+    );
+  });
+
+  test('screenshot', async () => {
+    element.files = [
+      ...createFiles(3, {lines_inserted: 9}),
+      ...createFiles(2, {lines_deleted: 14}),
+    ];
+    await element.updateComplete;
+
+    await visualDiff(element, 'gr-file-list');
+  });
+});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 6f6fe73..306747b 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -38,6 +38,7 @@
   Provider,
 } from '../models/dependency';
 import * as sinon from 'sinon';
+import '../styles/themes/app-theme.ts';
 
 declare global {
   interface Window {
@@ -52,8 +53,7 @@
   const log = _testOnly_defaultResinReportHandler;
   log(isViolation, fmt, ...args);
   if (isViolation) {
-    // This will cause the test to fail if there is a data binding
-    // violation.
+    // This will cause the test to fail if there is a data binding violation.
     throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
   }
 });
@@ -132,7 +132,7 @@
 // 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'];
+  const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER', 'LINK'];
   if (allowedTags.includes(element.tagName)) {
     return;
   }
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index b8fef7d..1287d0c 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -11,6 +11,7 @@
     "@open-wc/testing": "^3.1.6",
     "@web/dev-server-esbuild": "^0.3.2",
     "@web/test-runner": "^0.14.0",
+    "@web/test-runner-visual-regression": "^0.6.6",
     "accessibility-developer-tools": "^2.12.0",
     "karma": "^6.3.20",
     "karma-chrome-launcher": "^3.1.1",
@@ -22,6 +23,8 @@
   },
   "scripts": {
     "test": "web-test-runner",
+    "test:screenshot": "web-test-runner --run-screenshots",
+    "test:screenshot-update": "web-test-runner --update-screenshots --files",
     "test:coverage": "web-test-runner --coverage",
     "test:watch": "web-test-runner --watch",
     "test:single": "web-test-runner --watch --files",
diff --git a/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png b/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png
new file mode 100644
index 0000000..4d0cbed
--- /dev/null
+++ b/polygerrit-ui/screenshots/Chrome/baseline/gr-file-list.png
Binary files differ
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs
index 44b5969..552e609 100644
--- a/polygerrit-ui/web-test-runner.config.mjs
+++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -1,24 +1,61 @@
 import { esbuildPlugin } from "@web/dev-server-esbuild";
 import { defaultReporter, summaryReporter } from "@web/test-runner";
+import { visualRegressionPlugin } from "@web/test-runner-visual-regression/plugin";
 
 /** @type {import('@web/test-runner').TestRunnerConfig} */
 const config = {
-  files: ["app/**/*_test.{ts,js}", "!**/node_modules/**/*"],
+  files: [
+    "app/**/*_test.{ts,js}",
+    "!**/node_modules/**/*",
+    ...(process.argv.includes("--run-screenshots")
+      ? []
+      : ["!app/**/*_screenshot_test.{ts,js}"]),
+  ],
   port: 9876,
   nodeResolve: true,
-  testFramework: {
-    config: {
-      ui: "tdd",
-      timeout: 5000,
-    },
-  },
+  testFramework: { config: { ui: "tdd", timeout: 5000 } },
   plugins: [
     esbuildPlugin({
       ts: true,
       target: "es2020",
       tsconfig: "app/tsconfig.json",
     }),
+    visualRegressionPlugin({
+      diffOptions: {
+        threshold: 0.8,
+      },
+      update: process.argv.includes("--update-screenshots"),
+    }),
   ],
+  // serve from gerrit root directory so that we can serve fonts from
+  // /lib/fonts/, see middleware.
+  rootDir: "..",
   reporters: [defaultReporter(), summaryReporter()],
+  middleware: [
+    // Fonts are in /lib/fonts/, but css tries to load from
+    // /polygerrit-ui/app/fonts/. In production this works because our build
+    // copies them over, see /polygerrit-ui/BUILD
+    async (context, next) => {
+      if (context.url.startsWith("/polygerrit-ui/app/fonts/")) {
+        context.url = context.url.replace("/polygerrit-ui/app/", "/lib/");
+      }
+      await next();
+    },
+  ],
+  testRunnerHtml: (testFramework) => `
+    <!DOCTYPE html>
+    <html>
+      <head>
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css">
+        <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css">
+        <link
+          rel="stylesheet"
+          href="polygerrit-ui/app/styles/material-icons.css">
+      </head>
+      <body>
+        <script type="module" src="${testFramework}"></script>
+      </body>
+    </html>
+  `,
 };
 export default config;
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 68b6fff..ca6943d 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -1526,6 +1526,13 @@
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
   integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
+"@types/mkdirp@^1.0.1":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-1.0.2.tgz#8d0bad7aa793abe551860be1f7ae7f3198c16666"
+  integrity sha512-o0K1tSO0Dx5X6xlU5F1D6625FawhC3dU3iqr25lluNv/+/QIVH8RLNEiVokgIZo+mz+87w/3Mkg/VvQS+J51fQ==
+  dependencies:
+    "@types/node" "*"
+
 "@types/mocha@^8.2.0":
   version "8.2.3"
   resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
@@ -1551,6 +1558,20 @@
   resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
+"@types/pixelmatch@^5.2.2":
+  version "5.2.4"
+  resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6"
+  integrity sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==
+  dependencies:
+    "@types/node" "*"
+
+"@types/pngjs@^6.0.0":
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-6.0.1.tgz#c711ec3fbbf077fed274ecccaf85dd4673130072"
+  integrity sha512-J39njbdW1U/6YyVXvC9+1iflZghP8jgRf2ndYghdJb5xL49LYDB+1EuAxfbuJ2IBbWIL3AjHPQhgaTxT3YaYeg==
+  dependencies:
+    "@types/node" "*"
+
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -1780,6 +1801,14 @@
     "@web/test-runner-core" "^0.10.27"
     mkdirp "^1.0.4"
 
+"@web/test-runner-commands@^0.6.4":
+  version "0.6.5"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
+  integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
+  dependencies:
+    "@web/test-runner-core" "^0.10.27"
+    mkdirp "^1.0.4"
+
 "@web/test-runner-core@^0.10.20":
   version "0.10.22"
   resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.22.tgz#34bb67d12a79b01dc79c816f3d76f3419ef50eaf"
@@ -1862,6 +1891,20 @@
     "@types/mocha" "^8.2.0"
     "@web/test-runner-core" "^0.10.20"
 
+"@web/test-runner-visual-regression@^0.6.6":
+  version "0.6.6"
+  resolved "https://registry.yarnpkg.com/@web/test-runner-visual-regression/-/test-runner-visual-regression-0.6.6.tgz#4a4dc734f360cba66a005e07b4a1c0a9ef956444"
+  integrity sha512-010J3zE6z2v7eLLey/l5cYa9/WhPsgzZb3Z6K5nn4Mn5W5LGPs/f+XG3N6+Tx8Q1/RvDqLdFvRs/T6c4ul4dgQ==
+  dependencies:
+    "@types/mkdirp" "^1.0.1"
+    "@types/pixelmatch" "^5.2.2"
+    "@types/pngjs" "^6.0.0"
+    "@web/test-runner-commands" "^0.6.4"
+    "@web/test-runner-core" "^0.10.20"
+    mkdirp "^1.0.4"
+    pixelmatch "^5.2.1"
+    pngjs "^6.0.0"
+
 "@web/test-runner@^0.14.0":
   version "0.14.0"
   resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.0.tgz#fc7b206f3fdc5a1d774cfc8f60159a574d30b185"
@@ -4569,6 +4612,13 @@
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==
 
+pixelmatch@^5.2.1:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.3.0.tgz#5e5321a7abedfb7962d60dbf345deda87cb9560a"
+  integrity sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==
+  dependencies:
+    pngjs "^6.0.0"
+
 pkg-dir@4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@@ -4576,6 +4626,11 @@
   dependencies:
     find-up "^4.0.0"
 
+pngjs@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
+  integrity sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==
+
 polyfills-loader@^1.7.4:
   version "1.7.6"
   resolved "https://registry.yarnpkg.com/polyfills-loader/-/polyfills-loader-1.7.6.tgz#5cff98bfc9689cf10e44bdd32f498cfeb4374c51"