Diff report for screenshot tests

Change-Id: I0037f901762348b1e9ac5447639563fba199353b
diff --git a/infra/ci/controller/controller.py b/infra/ci/controller/controller.py
index 7647fed..d5d2afe 100644
--- a/infra/ci/controller/controller.py
+++ b/infra/ci/controller/controller.py
@@ -318,6 +318,9 @@
     if '-ui-' in job_id:
       ui_links.append('https://storage.googleapis.com/%s/%s/ui/index.html' %
                       (GCS_ARTIFACTS, job_id))
+      ui_links.append(
+          'https://storage.googleapis.com/%s/%s/ui-test-artifacts/index.html' %
+          (GCS_ARTIFACTS, job_id))
     if job_obj['status'] == 'COMPLETED':
       passed_jobs.append(job_id)
     elif not job_config.get('SKIP_VOTING', False):
diff --git a/ui/build.js b/ui/build.js
index 758dee6..8a2ad50 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -100,6 +100,7 @@
   outDir: pjoin(ROOT_DIR, 'out/ui'),
   version: '',  // v1.2.3, derived from the CHANGELOG + git.
   outUiDir: '',
+  outUiTestArtifactsDir: '',
   outDistRootDir: '',
   outTscDir: '',
   outGenDir: '',
@@ -115,6 +116,10 @@
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/assets\/.+[.]scss/, f: compileScss},
   {r: /ui\/src\/chrome_extension\/.*/, f: copyExtensionAssets},
+  {
+    r: /ui\/src\/test\/diff_viewer\/(.+[.](?:html|js))/,
+    f: copyUiTestArtifactsAssets,
+  },
   {r: /.*\/dist\/.+\/(?!manifest\.json).*/, f: genServiceWorkerManifestJson},
   {r: /.*\/dist\/.*/, f: notifyLiveServer},
 ];
@@ -150,6 +155,7 @@
   const clean = !args.no_build;
   cfg.outDir = path.resolve(ensureDir(args.out || cfg.outDir));
   cfg.outUiDir = ensureDir(pjoin(cfg.outDir, 'ui'), clean);
+  cfg.outUiTestArtifactsDir = ensureDir(pjoin(cfg.outDir, 'ui-test-artifacts'));
   cfg.outExtDir = ensureDir(pjoin(cfg.outUiDir, 'chrome_extension'));
   cfg.outDistRootDir = ensureDir(pjoin(cfg.outUiDir, 'dist'));
   const proc = exec('python3', [VERSION_SCRIPT, '--stdout'], {stdout: 'pipe'});
@@ -219,6 +225,7 @@
     buildWasm(args.no_wasm);
     scanDir('ui/src/assets');
     scanDir('ui/src/chrome_extension');
+    scanDir('ui/src/test/diff_viewer');
     scanDir('buildtools/typefaces');
     scanDir('buildtools/catapult_trace_viewer');
     generateImports('ui/src/tracks', 'all_tracks.ts');
@@ -306,6 +313,10 @@
   addTask(cp, [src, pjoin(cfg.outDistDir, 'assets', dst)]);
 }
 
+function copyUiTestArtifactsAssets(src, dst) {
+  addTask(cp, [src, pjoin(cfg.outUiTestArtifactsDir, dst)]);
+}
+
 function compileScss() {
   const src = pjoin(ROOT_DIR, 'ui/src/assets/perfetto.scss');
   const dst = pjoin(cfg.outDistDir, 'perfetto.css');
diff --git a/ui/src/test/diff_viewer/README.md b/ui/src/test/diff_viewer/README.md
new file mode 100644
index 0000000..119dd75
--- /dev/null
+++ b/ui/src/test/diff_viewer/README.md
@@ -0,0 +1,18 @@
+# CI screenshot diff viewer
+
+This directory contains the source of screenshots diff viewer used on Perfetto
+CI. The way it works as follows:
+
+When a screenshot test is failing, the testing code will write a line of the
+form
+
+```
+failed-screenshot.png;failed-screenshot-diff.png
+```
+
+To a file called `report.txt`. Diff viewer is just a static page that uses Fetch
+API to download this file, parse it, and display images in a list of rows.
+
+The page assumes `report.txt` to be present in the same directory, same goes for
+screenshot files. To simplify deployment, the viewer is developed without a
+framework and constructs DOM using `document.createElement` API.
diff --git a/ui/src/test/diff_viewer/index.html b/ui/src/test/diff_viewer/index.html
new file mode 100644
index 0000000..d4742b4
--- /dev/null
+++ b/ui/src/test/diff_viewer/index.html
@@ -0,0 +1,27 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<meta http-equiv="X-UA-Compatible" content="IE=edge">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>Diff screenshots report</title>
+	<style>
+		.row {
+			display: flex;
+			padding: 1rem;
+			border-radius: .5rem;
+			border: 1px solid black;
+			margin-bottom: 1rem;
+		}
+		.image-wrapper img {
+			max-width: 45vw;
+		}
+	</style>
+</head>
+<body>
+	<div class="container">
+		Loading...
+	</div>
+	<script src="script.js"></script>
+</body>
+</html>
\ No newline at end of file
diff --git a/ui/src/test/diff_viewer/script.js b/ui/src/test/diff_viewer/script.js
new file mode 100644
index 0000000..97a6f64
--- /dev/null
+++ b/ui/src/test/diff_viewer/script.js
@@ -0,0 +1,79 @@
+// Copyright (C) 2022 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.
+
+// Helper function to create DOM elements faster: takes a Mithril-style
+// "selector" of the form "tag.class1.class2" and a list of child objects that
+// can be either strings or DOM elements.
+function m(selector, ...children) {
+  const parts = selector.split('.');
+  if (parts.length === 0) {
+    throw new Error(
+        'Selector passed to element should be of a form tag.class1.class2');
+  }
+
+  const result = document.createElement(parts[0]);
+  for (let i = 1; i < parts.length; i++) {
+    result.classList.add(parts[i]);
+  }
+  for (const child of children) {
+    if (typeof child === 'string') {
+      const childNode = document.createTextNode(child);
+      result.appendChild(childNode);
+    } else {
+      result.appendChild(child);
+    }
+  }
+  return result;
+}
+
+async function loadDiffs() {
+  // report.txt is a text file with a pair of file names on each line, separated
+  // by semicolon. E.g. "screenshot.png;screenshot-diff.png"
+  const report = await fetch('report.txt');
+  const response = await report.text();
+  console.log(response);
+
+  const container = document.querySelector('.container');
+  container.innerHTML = '';
+
+  const lines = response.split('\n');
+  for (const line of lines) {
+    const parts = line.split(';');
+    if (parts.length !== 2) {
+      console.warn(
+          `Malformed line (expected two files separated via semicolon) ${
+              line}!`);
+      continue;
+    }
+
+    const [output, diff] = parts;
+    const outputImage = m('img');
+    outputImage.src = output;
+    const diffImage = m('img');
+    diffImage.src = diff;
+
+    container.appendChild(
+        m('div.row',
+          m('div.cell', output, m('div.image-wrapper', outputImage)),
+          m('div.cell', diff, m('div.image-wrapper', diffImage))));
+  }
+
+  if (lines.length === 0) {
+    container.appendChild(m('div', 'All good!'));
+  }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+  loadDiffs();
+});
diff --git a/ui/src/test/perfetto_ui_test_helper.ts b/ui/src/test/perfetto_ui_test_helper.ts
index 44e927c..97c9bd4 100644
--- a/ui/src/test/perfetto_ui_test_helper.ts
+++ b/ui/src/test/perfetto_ui_test_helper.ts
@@ -84,7 +84,7 @@
 }
 
 export async function compareScreenshots(
-    actualFilename: string, expectedFilename: string) {
+    reportPath: string, actualFilename: string, expectedFilename: string) {
   if (!fs.existsSync(expectedFilename)) {
     throw new Error(
         `Could not find ${expectedFilename}. Run wih REBASELINE=1.`);
@@ -102,6 +102,9 @@
   if (diff > DIFF_MAX_PIXELS) {
     const diffFilename = actualFilename.replace('.png', '-diff.png');
     fs.writeFileSync(diffFilename, PNG.sync.write(diffPng));
+    fs.appendFileSync(
+        reportPath,
+        `${path.basename(actualFilename)};${path.basename(diffFilename)}\n`);
     fail(`Diff test failed on ${diffFilename}, delta: ${diff} pixels`);
   }
   return diff;
diff --git a/ui/src/test/ui_integrationtest.ts b/ui/src/test/ui_integrationtest.ts
index 24a6533..4794d8d 100644
--- a/ui/src/test/ui_integrationtest.ts
+++ b/ui/src/test/ui_integrationtest.ts
@@ -28,6 +28,8 @@
 declare let global: {__BROWSER__: puppeteer.Browser;};
 const browser = assertExists(global.__BROWSER__);
 const expectedScreenshotPath = path.join('test', 'data', 'ui-screenshots');
+const tmpDir = path.resolve('./ui-test-artifacts');
+const reportPath = path.join(tmpDir, 'report.txt');
 
 async function getPage(): Promise<puppeteer.Page> {
   const pages = (await browser.pages());
@@ -41,6 +43,9 @@
   jest.setTimeout(60000);
   const page = await getPage();
   await page.setViewport({width: 1920, height: 1080});
+
+  // Empty the file with collected screenshot diffs
+  fs.writeFileSync(reportPath, '');
 });
 
 // After each test (regardless of nesting) capture a screenshot named after the
@@ -51,10 +56,6 @@
   testName = testName.replace(/[^a-z0-9-]/gmi, '_').toLowerCase();
   const page = await getPage();
 
-  // cwd() is set to //out/ui when running tests, just create a subdir in there.
-  // The CI picks up this directory and uploads to GCS after every failed run.
-  const tmpDir = path.resolve('./ui-test-artifacts');
-  if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir);
   const screenshotName = `ui-${testName}.png`;
   const actualFilename = path.join(tmpDir, screenshotName);
   const expectedFilename = path.join(expectedScreenshotPath, screenshotName);
@@ -64,7 +65,7 @@
     console.log('Saving reference screenshot into', expectedFilename);
     fs.copyFileSync(actualFilename, expectedFilename);
   } else {
-    await compareScreenshots(actualFilename, expectedFilename);
+    await compareScreenshots(reportPath, actualFilename, expectedFilename);
   }
 });