UI: Add initial puppeteer-based integrationtests

This CL introduces support for UI integrationtests.
Those tests are based on puppeteer (Chrome headless)
and work by injecting user actions into the real UI,
capturing screenshots and diffing them against
reference ones.

Each test() unit in ui_integrationtests.ts automatically
captures and diffs the screenshot at the end of the run.
In case of failures, diffs are uploaded to the CI GCS
bucket (gs://perfetto-ci-artifacts/$key/).

I got to a state where tests can be run both on Linux
and Mac successfully. There are few rendering differences
between the two, due to the fact that font rendering
seems platform specific. But with enough tweaks the
difference can be contained to sub-pixel antialiasing
and can be dealt with a threshold in the diff algorithm.

Bug: 190075400
Change-Id: I5fb35c41ba5f95a4761465b9d0128edd92414510
diff --git a/test/ci/ui_tests.sh b/test/ci/ui_tests.sh
index fe8b1aa..619d0f2 100755
--- a/test/ci/ui_tests.sh
+++ b/test/ci/ui_tests.sh
@@ -21,3 +21,13 @@
 cp -a ${OUT_PATH}/ui/dist/ /ci/artifacts/ui
 
 ui/run-unittests --out ${OUT_PATH} --no-build
+
+set +e
+ui/run-integrationtests --out ${OUT_PATH} --no-build
+RES=$?
+
+# Copy the screenshots for diff testing when the test fails.
+if [ $RES -ne 0 -a -d ${OUT_PATH}/ui-test-artifacts ]; then
+  cp -a ${OUT_PATH}/ui-test-artifacts /ci/artifacts/ui-test-artifacts
+  exit $RES
+fi
diff --git a/tools/install-build-deps b/tools/install-build-deps
index 9e3305a..92b6ea8 100755
--- a/tools/install-build-deps
+++ b/tools/install-build-deps
@@ -234,8 +234,8 @@
     # Example traces for regression tests.
     Dependency(
         'test/data.zip',
-        'https://storage.googleapis.com/perfetto/test-data-20210604-141038.zip',
-        'f202d92ea541b7072562b579470771a5e2b414572a5421c501ea0785a57726eb',
+        'https://storage.googleapis.com/perfetto/test-data-20210611-152825.zip',
+        '8a4acb0583971d0894b44d89bd419b6a0c637b10eb9eca0ecf077c836a242217',
         'all', 'all',
     ),
 
diff --git a/ui/build.js b/ui/build.js
index 9867ad2..6873dd7 100644
--- a/ui/build.js
+++ b/ui/build.js
@@ -129,8 +129,10 @@
   parser.addArgument(['--no-build', '-n'], {action: 'storeTrue'});
   parser.addArgument(['--no-wasm', '-W'], {action: 'storeTrue'});
   parser.addArgument(['--run-unittests', '-t'], {action: 'storeTrue'});
+  parser.addArgument(['--run-integrationtests', '-T'], {action: 'storeTrue'});
   parser.addArgument(['--debug', '-d'], {action: 'storeTrue'});
   parser.addArgument(['--interactive', '-i'], {action: 'storeTrue'});
+  parser.addArgument(['--rebaseline', '-r'], {action: 'storeTrue'});
 
   const args = parser.parseArgs();
   const clean = !args.no_build;
@@ -150,6 +152,9 @@
   if (args.interactive) {
     process.env.PERFETTO_UI_TESTS_INTERACTIVE = '1';
   }
+  if (args.rebaseline) {
+    process.env.PERFETTO_UI_TESTS_REBASELINE = '1';
+  }
 
   process.on('SIGINT', () => {
     console.log('\nSIGINT received. Killing all child processes and exiting');
@@ -211,6 +216,9 @@
   if (args.run_unittests) {
     runTests('jest.unittest.config.js');
   }
+  if (args.run_integrationtests) {
+    runTests('jest.integrationtest.config.js');
+  }
 }
 
 // -----------
diff --git a/ui/config/integrationtest_env.js b/ui/config/integrationtest_env.js
new file mode 100644
index 0000000..b7509ee
--- /dev/null
+++ b/ui/config/integrationtest_env.js
@@ -0,0 +1,65 @@
+// Copyright (C) 2021 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.
+
+const NodeEnvironment = require('jest-environment-node');
+const puppeteer = require('puppeteer');
+
+module.exports = class IntegrationtestEnvironment extends NodeEnvironment {
+  constructor(config) {
+    super(config);
+  }
+
+  async setup() {
+    await super.setup();
+    const headless = process.env.PERFETTO_UI_TESTS_INTERACTIVE !== '1';
+    if (headless) {
+      console.log('Starting Perfetto UI tests in headless mode.');
+      console.log(
+          'Pass --interactive to run-integrationtests or set ' +
+          'PERFETTO_UI_TESTS_INTERACTIVE=1 to inspect the behavior ' +
+          'in a visible Chrome window');
+    }
+    this.global.__BROWSER__ = await puppeteer.launch({
+      args: [
+        '--window-size=1920,1080',
+        '--disable-accelerated-2d-canvas',
+        '--disable-gpu',
+        '--no-sandbox',  // Disable sandbox to run in Docker.
+        '--disable-setuid-sandbox',
+        '--font-render-hinting=none',
+        '--enable-benchmarking',  // Disable finch and other sources of non
+                                  // determinism.
+      ],
+
+      // This is so screenshot in --interactive and headles mode match. The
+      // scrollbars are never part of the screenshot, but without this cmdline
+      // switch, in headless mode we don't get any blank space (as if it was
+      // overflow:hidden) and that changes the layout of the page.
+      ignoreDefaultArgs: ['--hide-scrollbars'],
+
+      headless: headless,
+    });
+  }
+
+  async teardown() {
+    if (this.global.__BROWSER__) {
+      await this.global.__BROWSER__.close();
+    }
+    await super.teardown();
+  }
+
+  runScript(script) {
+    return super.runScript(script);
+  }
+}
diff --git a/ui/config/integrationtest_setup.js b/ui/config/integrationtest_setup.js
new file mode 100644
index 0000000..e4cd7e8
--- /dev/null
+++ b/ui/config/integrationtest_setup.js
@@ -0,0 +1,57 @@
+// Copyright (C) 2021 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.
+
+const path = require('path');
+const http = require('http');
+const child_process = require('child_process');
+
+module.exports = async function() {
+  // Start the local HTTP server.
+  const ROOT_DIR = path.dirname(path.dirname(__dirname));
+  const node = path.join(ROOT_DIR, 'ui', 'node');
+  const args = [
+    path.join(ROOT_DIR, 'ui', 'build.js'),
+    '--serve',
+    '--no-build',
+    '--out=.'
+  ];
+  const spwOpts = {stdio: ['ignore', 'inherit', 'inherit']};
+  const srvProc = child_process.spawn(node, args, spwOpts);
+  global.__DEV_SERVER__ = srvProc;
+
+  // Wait for the HTTP server to be ready.
+  let attempts = 10;
+  for (; attempts > 0; attempts--) {
+    await new Promise(r => setTimeout(r, 1000));
+    try {
+      await new Promise((resolve, reject) => {
+        const req = http.request('http://127.0.0.1:10000/frontend_bundle.js');
+        req.end();
+        req.on('error', err => reject(err));
+        req.on('finish', () => resolve());
+      });
+      break;
+    } catch (err) {
+      console.error('Waiting for HTTP server to come up', err.message);
+      continue;
+    }
+  }
+  if (attempts == 0) {
+    throw new Error('HTTP server didn\'t come up');
+  }
+  if (srvProc.exitCode !== null) {
+    throw new Error(
+        `The dev server unexpectedly exited, code=${srvProc.exitCode}`);
+  }
+}
diff --git a/ui/config/integrationtest_teardown.js b/ui/config/integrationtest_teardown.js
new file mode 100644
index 0000000..6d152dd
--- /dev/null
+++ b/ui/config/integrationtest_teardown.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2021 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.
+
+module.exports = async function() {
+  const proc = global.__DEV_SERVER__;
+  // Kill the HTTP server.
+  proc.kill();
+  for (;;) {
+    if (proc.exitCode !== null || proc.killed) break;
+    console.log('Waiting for dev server termination');
+    await new Promise(r => setTimeout(r, 1000));
+  }
+}
diff --git a/ui/config/jest.integrationtest.config.js b/ui/config/jest.integrationtest.config.js
new file mode 100644
index 0000000..079abaf
--- /dev/null
+++ b/ui/config/jest.integrationtest.config.js
@@ -0,0 +1,21 @@
+// Copyright (C) 2021 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.
+
+module.exports = {
+  transform: {},
+  testRegex: '.*_integrationtest.js$',
+  globalSetup: __dirname + '/integrationtest_setup.js',
+  globalTeardown: __dirname + '/integrationtest_teardown.js',
+  testEnvironment: __dirname + '/integrationtest_env.js',
+}
diff --git a/ui/package-lock.json b/ui/package-lock.json
index c0236d7..ec5e104 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -934,6 +934,15 @@
       "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.1.tgz",
       "integrity": "sha512-GdZbRSJ3Cv5fiwT6I0SQ3ckeN2PWNqxd26W9Z2fCK1tGrrasGy4puvNFtnddqH9UJFMQYXxEuuB7B8UK+LLwSg=="
     },
+    "@types/pixelmatch": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.3.tgz",
+      "integrity": "sha512-p+nAQVYK/DUx7+s1Xyu9dqAg0gobf7VmJ+iDA4lljg1o4XRgQHr7R2h1NwFt3gdNOZiftxWB11+0TuZqXYf19w==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/prettier": {
       "version": "2.2.3",
       "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.2.3.tgz",
@@ -4267,6 +4276,15 @@
       "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=",
       "dev": true
     },
+    "node-libpng": {
+      "version": "0.2.18",
+      "resolved": "https://registry.npmjs.org/node-libpng/-/node-libpng-0.2.18.tgz",
+      "integrity": "sha512-nr2j+Qn68ocw/0mfj09Yg8AEEdjrrQYMnFAWi8wLpwe2FMdLr36OFalIEkrJnwpqQxDybfqw7l1GH2wJ98wUIw==",
+      "dev": true,
+      "requires": {
+        "request": "^2.88.2"
+      }
+    },
     "node-modules-regexp": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz",
@@ -4789,6 +4807,15 @@
         "node-modules-regexp": "^1.0.0"
       }
     },
+    "pixelmatch": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz",
+      "integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==",
+      "dev": true,
+      "requires": {
+        "pngjs": "^4.0.1"
+      }
+    },
     "pkg-dir": {
       "version": "4.2.0",
       "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -4798,6 +4825,12 @@
         "find-up": "^4.0.0"
       }
     },
+    "pngjs": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz",
+      "integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==",
+      "dev": true
+    },
     "posix-character-classes": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
diff --git a/ui/package.json b/ui/package.json
index 1347b2a..35a7b36 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -33,11 +33,14 @@
     "@rollup/plugin-commonjs": "^14.0.0",
     "@rollup/plugin-node-resolve": "^8.4.0",
     "@types/jest": "^26.0.23",
+    "@types/pixelmatch": "^5.2.3",
     "@types/puppeteer": "^5.4.3",
     "dingusjs": "^0.0.3",
     "jest": "^26.6.3",
+    "node-libpng": "^0.2.18",
     "node-sass": "^4.14.1",
     "node-watch": "^0.7.1",
+    "pixelmatch": "^5.2.1",
     "puppeteer": "^10.0.0",
     "rollup": "^2.38.5",
     "rollup-plugin-re": "^1.0.7",
diff --git a/ui/run-all-tests b/ui/run-all-tests
new file mode 100755
index 0000000..7974fa1
--- /dev/null
+++ b/ui/run-all-tests
@@ -0,0 +1,20 @@
+#!/bin/bash
+# Copyright (C) 2021 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.
+
+UI_DIR="$(cd -P ${BASH_SOURCE[0]%/*}; pwd)"
+
+$UI_DIR/node $UI_DIR/build.js "$@"  # Build just once.
+$UI_DIR/node $UI_DIR/build.js --no-build --run-unittests "$@"
+$UI_DIR/node $UI_DIR/build.js --no-build --run-integrationtests "$@"
diff --git a/ui/run-integrationtests b/ui/run-integrationtests
new file mode 100755
index 0000000..abcbf4a
--- /dev/null
+++ b/ui/run-integrationtests
@@ -0,0 +1,18 @@
+#!/bin/bash
+# Copyright (C) 2021 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.
+
+UI_DIR="$(cd -P ${BASH_SOURCE[0]%/*}; pwd)"
+
+$UI_DIR/node $UI_DIR/build.js --run-integrationtests "$@"
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index 0324899..cf1c44c 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -91,6 +91,13 @@
     overscroll-behavior: none;
 }
 
+// This is to minimize Mac vs Linux Chrome Headless rendering differences
+// when running UI intergrationtests via puppeteer.
+body.testing {
+  -webkit-font-smoothing: antialiased !important;
+  font-kerning: none !important;
+}
+
 h1,
 h2,
 h3 {
diff --git a/ui/src/assets/sidebar.scss b/ui/src/assets/sidebar.scss
index 8bded2f..7bd6dc9 100644
--- a/ui/src/assets/sidebar.scss
+++ b/ui/src/assets/sidebar.scss
@@ -280,6 +280,12 @@
     }
 }
 
+// Hide the footer when running integration tests, as the version code and the
+// tiny text with pending queries can fail the screenshot diff test.
+body.testing .sidebar-footer {
+  visibility: hidden;
+}
+
 .keycap {
   background-color: #fafbfc;
   border: 1px solid #d1d5da;
diff --git a/ui/src/controller/record_controller_jsdomtest.ts b/ui/src/controller/record_controller_jsdomtest.ts
index b98f868..5213665 100644
--- a/ui/src/controller/record_controller_jsdomtest.ts
+++ b/ui/src/controller/record_controller_jsdomtest.ts
@@ -132,7 +132,7 @@
   expect(traceConfig).toEqual(expectedTraceConfig);
 });
 
-test.skip('ChromeMemoryConfig', () => {
+test('ChromeMemoryConfig', () => {
   const config = createEmptyRecordConfig();
   config.chromeCategoriesSelected = ['disabled-by-default-memory-infra'];
   const result =
@@ -149,10 +149,11 @@
   const chromeConfigM = assertExists(metadataConfigSource.chromeConfig);
   const traceConfigM = assertExists(chromeConfigM.traceConfig);
 
-  const expectedTraceConfig = '{"record_mode":"record-until-full",' +
-      '"included_categories":["disabled-by-default-memory-infra"],' +
-      '"memory_dump_config":{"triggers":' +
-      '[{"mode":"detailed","periodic_interval_ms":10000}]}}';
+  const expectedTraceConfig = '{\"record_mode\":\"record-until-full\",' +
+      '\"included_categories\":[\"disabled-by-default-memory-infra\"],' +
+      '\"memory_dump_config\":{\"allowed_dump_modes\":[\"background\",' +
+      '\"light\",\"detailed\"],\"triggers\":[{\"min_time_between_dumps_ms\":' +
+      '10000,\"mode\":\"detailed\",\"type\":\"periodic_interval\"}]}}';
   expect(traceConfigM).toEqual(expectedTraceConfig);
   expect(traceConfig).toEqual(expectedTraceConfig);
 });
diff --git a/ui/src/frontend/analytics.ts b/ui/src/frontend/analytics.ts
index c253154..998f326 100644
--- a/ui/src/frontend/analytics.ts
+++ b/ui/src/frontend/analytics.ts
@@ -25,7 +25,7 @@
   // Skip analytics is the fragment has "testing=1", this is used by UI tests.
   if ((window.location.origin.startsWith('http://localhost:') ||
        window.location.origin.endsWith('.perfetto.dev')) &&
-      window.location.search.indexOf('testing=1') < 0) {
+      !globals.testing) {
     return new AnalyticsImpl();
   }
   return new NullAnalytics();
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 67ba6ea..18aff6f 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -151,6 +151,7 @@
 class Globals {
   readonly root = getRoot();
 
+  private _testing = false;
   private _dispatch?: Dispatch = undefined;
   private _controllerWorker?: Worker = undefined;
   private _state?: State = undefined;
@@ -205,6 +206,8 @@
     this._frontendLocalState = new FrontendLocalState();
     this._rafScheduler = new RafScheduler();
     this._serviceWorkerController = new ServiceWorkerController();
+    this._testing =
+        self.location && self.location.search.indexOf('testing=1') >= 0;
     this._logging = initAnalytics();
 
     // TODO(hjd): Unify trackDataStore, queryResults, overviewStore, threads.
@@ -519,6 +522,10 @@
     return this._channel;
   }
 
+  get testing() {
+    return this._testing;
+  }
+
   // Used when switching to the legacy TraceViewer UI.
   // Most resources are cleaned up by replacing the current |window| object,
   // however pending RAFs and workers seem to outlive the |window| and need to
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 80712cf..6e76ce4 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -458,6 +458,10 @@
   }, {passive: false});
 
   cssLoadPromise.then(() => onCssLoaded(router));
+
+  if (globals.testing) {
+    document.body.classList.add('testing');
+  }
 }
 
 function onCssLoaded(router: Router) {
diff --git a/ui/src/test/perfetto_ui_test_helper.ts b/ui/src/test/perfetto_ui_test_helper.ts
new file mode 100644
index 0000000..628168c
--- /dev/null
+++ b/ui/src/test/perfetto_ui_test_helper.ts
@@ -0,0 +1,92 @@
+// Copyright (C) 2021 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 * as fs from 'fs';
+import * as libpng from 'node-libpng';
+import * as path from 'path';
+import * as pixelmatch from 'pixelmatch';
+import * as puppeteer from 'puppeteer';
+
+// These constants have been hand selected by comparing the diffs of screenshots
+// between Linux on Mac. Unfortunately font-rendering is platform-specific.
+// Even though we force the same antialiasing and hinting settings, some minimal
+// differences exist.
+const DIFF_PER_PIXEL_THRESHOLD = 0.35;
+const DIFF_MAX_PIXELS = 50;
+
+// Waits for the Perfetto UI to be quiescent, using a union of heuristics:
+// - Check that the progress bar is not animating.
+// - Check that the omnibox is not showing a message.
+// - Check that no redraws are pending in our RAF scheduler.
+// - Check that all the above is satisfied for |minIdleMs| consecutive ms.
+export async function waitForPerfettoIdle(
+    page: puppeteer.Page, minIdleMs?: number) {
+  minIdleMs = minIdleMs || 3000;
+  const tickMs = 250;
+  const timeoutMs = 30000;
+  const minIdleTicks = Math.ceil(minIdleMs / tickMs);
+  const timeoutTicks = Math.ceil(timeoutMs / tickMs);
+  let consecutiveIdleTicks = 0;
+  for (let ticks = 0; ticks < timeoutTicks; ticks++) {
+    await new Promise(r => setTimeout(r, tickMs));
+    const isShowingMsg = !!(await page.$('.omnibox.message-mode'));
+    const isShowingAnim = !!(await page.$('.progress.progress-anim'));
+    const hasPendingRedraws =
+        await (
+            await page.evaluateHandle('globals.rafScheduler.hasPendingRedraws'))
+            .jsonValue<number>();
+
+    if (isShowingAnim || isShowingMsg || hasPendingRedraws) {
+      consecutiveIdleTicks = 0;
+      continue;
+    }
+    if (++consecutiveIdleTicks >= minIdleTicks) {
+      return;
+    }
+  }
+  throw new Error(
+      `waitForPerfettoIdle() failed. Did not reach idle after ${timeoutMs} ms`);
+}
+
+export function getTestTracePath(fname: string): string {
+  const fPath = path.join('test', 'data', fname);
+  if (!fs.existsSync(fPath)) {
+    throw new Error('Could not locate trace file ' + fPath);
+  }
+  return fPath;
+}
+
+export async function compareScreenshots(
+    actualFilename: string, expectedFilename: string) {
+  if (!fs.existsSync(expectedFilename)) {
+    throw new Error(
+        `Could not find ${expectedFilename}. Run wih REBASELINE=1.`);
+  }
+  const actualImg = await libpng.readPngFile(actualFilename);
+  const expectedImg = await libpng.readPngFile(expectedFilename);
+  const {width, height} = actualImg;
+  expect(width).toEqual(expectedImg.width);
+  expect(height).toEqual(expectedImg.height);
+  const diffBuff = Buffer.alloc(actualImg.data.byteLength);
+  const diff = await pixelmatch(
+      actualImg.data, expectedImg.data, diffBuff, width, height, {
+        threshold: DIFF_PER_PIXEL_THRESHOLD
+      });
+  if (diff > DIFF_MAX_PIXELS) {
+    const diffFilename = actualFilename.replace('.png', '-diff.png');
+    libpng.writePngFile(diffFilename, diffBuff, {width, height});
+    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
new file mode 100644
index 0000000..c770991
--- /dev/null
+++ b/ui/src/test/ui_integrationtest.ts
@@ -0,0 +1,100 @@
+// Copyright (C) 2021 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 * as fs from 'fs';
+import * as path from 'path';
+import * as puppeteer from 'puppeteer';
+import {assertExists} from '../base/logging';
+
+import {
+  compareScreenshots,
+  getTestTracePath,
+  waitForPerfettoIdle
+} from './perfetto_ui_test_helper';
+
+declare var global: {__BROWSER__: puppeteer.Browser;};
+const browser = assertExists(global.__BROWSER__);
+const expectedScreenshotPath = path.join('test', 'data', 'ui-screenshots');
+
+
+async function getPage(): Promise<puppeteer.Page> {
+  const pages = (await browser.pages());
+  expect(pages.length).toBe(1);
+  return pages[pages.length - 1];
+}
+
+// Executed once at the beginning of the test. Navigates to the UI.
+beforeAll(async () => {
+  jest.setTimeout(60000);
+  const page = await getPage();
+  await page.setViewport({width: 1920, height: 1080});
+  await page.goto('http://localhost:10000/?testing=1');
+});
+
+// After each test (regardless of nesting) capture a screenshot named after the
+// test('') name and compare the screenshot with the expected one in
+// /test/data/ui-screenshots.
+afterEach(async () => {
+  let testName = expect.getState().currentTestName;
+  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);
+  await page.screenshot({path: actualFilename});
+  const rebaseline = process.env['PERFETTO_UI_TESTS_REBASELINE'] === '1';
+  if (rebaseline) {
+    console.log('Saving reference screenshot into', expectedFilename);
+    fs.copyFileSync(actualFilename, expectedFilename);
+  } else {
+    await compareScreenshots(actualFilename, expectedFilename);
+  }
+});
+
+describe('android_trace_30s', () => {
+  test('load', async () => {
+    const page = await getPage();
+    const file = await page.waitForSelector('input.trace_file');
+    const tracePath = getTestTracePath('example_android_trace_30s.pb');
+    assertExists(file).uploadFile(tracePath);
+    await waitForPerfettoIdle(page);
+  });
+
+  test('expand_camera', async () => {
+    const page = await getPage();
+    await page.click('.main-canvas');
+    await page.click('h1[title="com.google.android.GoogleCamera 5506"]');
+    await page.evaluate(() => {
+      document.querySelector('.scrolling-panel-container')!.scrollTo(0, 400);
+    });
+    await waitForPerfettoIdle(page);
+  });
+
+  test('search', async () => {
+    const page = await getPage();
+    const searchInput = '.omnibox input';
+    await page.focus(searchInput);
+    await page.keyboard.type('TrimMaps');
+    await waitForPerfettoIdle(page);
+    for (let i = 0; i < 10; i++) {
+      await page.keyboard.type('\n');
+    }
+    await waitForPerfettoIdle(page);
+  });
+});