js/core: Add WebDriver library for UI Testing

This adds a library for testing the Vanadium extension with any of
the sub-projects that depend on JavaScript.

The library has code to install the Vanadium Extension but has no
tests on its own.

Use of this library will be optional since it needs many components
- Chrome WebDriver
- Google username/password
- Xvfb
- maven

This part of the change does the following
- Adds the library to test/ui
- Adds a README to explain how to use the library.

MultiPart: 2/4
Change-Id: Id07d04a3938fa4a6fb878ba67f50113d1805d241
diff --git a/test/ui/README.md b/test/ui/README.md
new file mode 100644
index 0000000..12d70c9
--- /dev/null
+++ b/test/ui/README.md
@@ -0,0 +1,57 @@
+# Vanadium JavaScript UI Test
+
+## About
+
+The UI tests use the maven architecture. This package manager uses
+the build information from pom.xml to compile a WebDriver program.
+
+The UI library in this repository defines VanadiumUITestBase, which
+uses WebDriver to run Chrome and install the Vanadium Extension.
+
+It also defines HTMLReporter, which is used to grab screenshots and
+generate an HTML report of the WebDriver test.
+
+## Usage
+
+The expected use of the library is to subclass VanadiumUITestBase and
+write a JUnit test function. The HTMLReporter should be started at the
+beginning of the test and generated upon test completion.
+
+A distinct pom.xml should be written for the tests, and it should
+include a dependency to this library.
+
+In order to install and use the Vanadium Extension, it is important to
+have the credentials of a Google account. It is not recommended that
+these credentials be visible to the public. Maven properties and
+environment variables can be used to keep them private.
+
+See release.projects.browser and www for example usages.
+
+## Display Port
+
+To run a test, one will generally set the DISPLAY environment variable
+to :0 or run Xvfb at a specific display port. Set this value in the
+test project's pom.xml file.
+
+Note that when running with :0, the test will run with the current
+display. Mouse and key actions performed during this period will interfere with the WebDriver test.
+
+While :0 is good for watching tests as they run, the screenshots taken
+are not guaranteed to be accurate.
+
+## Invoking maven
+
+At least 4 flags are required when running the maven test:
+- chromeDriverBin: Location of the Chrome WebDriver binary
+- htmlReportsRelativePath: Directory to output an HTML report (relative to $WORKSPACE)
+- googleBotUsername: Username for a Google account
+- googleBotPassword: Password for that user
+
+Here is an example invocation:
+WORKSPACE=$WORKSPACE mvn test \
+  -f=$LOCATION_OF_POM_XML \
+  -Dtest=$UI_TEST_NAME \
+  -DchromeDriverBin=$CHROME_WEBDRIVER \
+  -DhtmlReportsRelativePath=$HTML_REPORT_RELATIVE_PATH \
+  -DgoogleBotUsername=$BOT_USERNAME \
+  -DgoogleBotPassword=$BOT_PASSWORD
diff --git a/test/ui/pom.xml b/test/ui/pom.xml
new file mode 100644
index 0000000..f233058
--- /dev/null
+++ b/test/ui/pom.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+  <modelVersion>4.0.0</modelVersion>
+
+  <name>Vanadium Webdriver</name>
+  <groupId>io.v.webdriver</groupId>
+  <artifactId>vanadium_webdriver</artifactId>
+  <version>0.1</version>
+
+  <dependencies>
+    <dependency>
+      <groupId>commons-io</groupId>
+      <artifactId>commons-io</artifactId>
+      <version>2.4</version>
+    </dependency>
+    <dependency>
+      <groupId>junit</groupId>
+      <artifactId>junit</artifactId>
+      <version>4.12</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.freemarker</groupId>
+      <artifactId>freemarker</artifactId>
+      <version>2.3.22</version>
+    </dependency>
+    <dependency>
+      <groupId>org.seleniumhq.selenium</groupId>
+      <artifactId>selenium-chrome-driver</artifactId>
+      <version>2.45.0</version>
+    </dependency>
+    <dependency>
+      <groupId>org.seleniumhq.selenium</groupId>
+      <artifactId>selenium-support</artifactId>
+      <version>2.45.0</version>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <version>3.2</version>
+        <configuration>
+            <source>1.7</source>
+            <target>1.7</target>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+</project>
diff --git a/test/ui/src/test/java/io/v/webdriver/RobotHelper.java b/test/ui/src/test/java/io/v/webdriver/RobotHelper.java
new file mode 100644
index 0000000..bca09c4
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/RobotHelper.java
@@ -0,0 +1,81 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver;
+
+import java.awt.AWTException;
+import java.awt.Robot;
+import java.awt.Toolkit;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.StringSelection;
+import java.awt.event.KeyEvent;
+
+/**
+ * A helper class for Robot related functions.
+ * <p>
+ * Most of the automation should be done through WebDriver APIs, but some elements (e.g. Chrome's
+ * native Sign-in page or the confirmation dialog after an extension is installed) cannot be
+ * accessed and controlled by WebDriver. In those cases, we use Robot to send key strokes, mouse
+ * clicks, etc.
+ *
+ * @author jingjin@google.com
+ */
+public class RobotHelper {
+  /**
+   * This is a singleton instance.
+   */
+  private static RobotHelper instance;
+
+  private final Robot robot;
+
+  public static RobotHelper sharedInstance() throws AWTException {
+    if (instance == null) {
+      instance = new RobotHelper();
+    }
+    return instance;
+  }
+
+  private RobotHelper() throws AWTException {
+    robot = new Robot();
+    robot.setAutoDelay(200);
+    robot.setAutoWaitForIdle(true);
+  }
+
+  /**
+   * Enters the given text to the focused element.
+   *
+   * <p>To make the process easier, we copy the given text to system clipboard and paste it into the
+   * target element. Otherwise, we have to call "keyPress" and "keyRelease" on each key stroke.
+   *
+   * @param text the text to enter.
+   */
+  public void enterText(String text) {
+    // Copy text to clipboard.
+    Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+    StringSelection stringSelection = new StringSelection(text);
+    clipboard.setContents(stringSelection, stringSelection);
+
+    // Send Ctrl+V to paste.
+    robot.keyPress(KeyEvent.VK_CONTROL);
+    robot.keyPress(KeyEvent.VK_V);
+    robot.keyRelease(KeyEvent.VK_V);
+    robot.keyRelease(KeyEvent.VK_CONTROL);
+  }
+
+  /**
+   * Presses TAB.
+   */
+  public void tab() {
+    robot.keyPress(KeyEvent.VK_TAB);
+    robot.keyRelease(KeyEvent.VK_TAB);
+  }
+
+  /**
+   * Presses Enter.
+   */
+  public void enter() {
+    robot.keyPress(KeyEvent.VK_ENTER);
+    robot.keyRelease(KeyEvent.VK_ENTER);
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/TestFailureWatcher.java b/test/ui/src/test/java/io/v/webdriver/TestFailureWatcher.java
new file mode 100644
index 0000000..68dcd47
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/TestFailureWatcher.java
@@ -0,0 +1,49 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver;
+
+import org.junit.rules.TestWatcher;
+import org.junit.runner.Description;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.chrome.ChromeDriverService;
+
+import io.v.webdriver.htmlreport.HTMLReportData;
+import io.v.webdriver.htmlreport.HTMLReporter;
+
+/**
+ * A class for performing actions when any test case fails.
+ *
+ * @author jingjin@google.com
+ */
+public class TestFailureWatcher extends TestWatcher {
+  private VanadiumUITestBase uiTest;
+  private WebDriver driver;
+  private ChromeDriverService service;
+
+  public void setup(VanadiumUITestBase uiTest, WebDriver driver, ChromeDriverService service) {
+    this.uiTest = uiTest;
+    this.driver = driver;
+    this.service = service;
+  }
+
+  @Override
+  protected void failed(Throwable e, Description description) {
+    // Take a screenshot for the current screen and write the html report.
+    HTMLReportData data = uiTest.getCurrentHTMLReportData();
+    Util.takeScreenshot("test-failed.png", "Test Failed", data);
+    HTMLReporter reporter = new HTMLReporter(data);
+    try {
+      reporter.generateReport();
+    } catch (Exception e1) {
+      System.err.println("Failed to write html report.\n" + Util.getStackTrace(e1));
+    }
+  }
+
+  @Override
+  protected void finished(Description description) {
+    driver.quit();
+    service.stop();
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/Util.java b/test/ui/src/test/java/io/v/webdriver/Util.java
new file mode 100644
index 0000000..46e81a0
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/Util.java
@@ -0,0 +1,85 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver;
+
+import io.v.webdriver.htmlreport.HTMLReportData;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+
+/**
+ * Utility functions.
+ *
+ * @author jingjin@google.com
+ */
+public class Util {
+
+  /**
+   * Sleeps for given milliseconds.
+   *
+   * <p>This is mainly used for waiting between Robot related operations. For WebDriver, we should
+   * use "wait.until".
+   *
+   * @param ms number of milliseconds to sleep.
+   */
+  public static void sleep(int ms) {
+    try {
+      Thread.sleep(ms);
+    } catch (InterruptedException e) {
+      e.printStackTrace();
+    }
+  }
+
+  /**
+   * Converts the given exception's stack trace to a string.
+   *
+   * @param e the exception to convert to string.
+   */
+  public static String getStackTrace(Exception e) {
+    StringWriter sw = new StringWriter();
+    PrintWriter pw = new PrintWriter(sw);
+    e.printStackTrace(pw);
+    return sw.toString();
+  }
+
+  /**
+   * Replaces illegal character in the given file name.
+   *
+   * @param filename the original file name.
+   */
+  public static String getSafeFilename(String filename) {
+    return filename.replaceAll("[^a-zA-Z0-9.-]", "_").toLowerCase();
+  }
+
+  /**
+   * Takes a screenshot.
+   *
+   * <p>It uses the "import" command, saves it to the given file, and records it in the given
+   * htmlReportData.
+   *
+   * @param fileName the file to save the screenshot to.
+   * @param caption the caption of the screenshot.
+   * @param htmlReportData the data model to add screenshot data to.
+   */
+  public static void takeScreenshot(String fileName, String caption,
+      HTMLReportData htmlReportData) {
+    String fullFileName =
+        String.format("%s-%s", getSafeFilename(htmlReportData.getTestName()), fileName);
+    Runtime rt = Runtime.getRuntime();
+    try {
+      String imagePath = String.format("%s/%s", htmlReportData.getReportDir(), fullFileName);
+      Process pr = rt.exec(
+          String.format("import -window root -crop 1004x748+10+10 -resize 800 %s", imagePath));
+      int retValue = pr.waitFor();
+      if (retValue != 0) {
+        System.err.println(String.format("Failed to capture screenshot: %s", imagePath));
+      }
+    } catch (Exception e) {
+      e.printStackTrace();
+    }
+
+    htmlReportData.addScreenshotData(fullFileName, caption);
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/VanadiumUITestBase.java b/test/ui/src/test/java/io/v/webdriver/VanadiumUITestBase.java
new file mode 100644
index 0000000..129bf3e
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/VanadiumUITestBase.java
@@ -0,0 +1,147 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver;
+
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.chrome.ChromeDriver;
+import org.openqa.selenium.chrome.ChromeDriverService;
+
+import io.v.webdriver.htmlreport.HTMLReportData;
+import io.v.webdriver.commonpages.ChromeSignInPage;
+import io.v.webdriver.commonpages.ExtensionInstallationPage;
+import io.v.webdriver.commonpages.ExtensionOptionPage;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * The base class for all Vanadium UI tests.
+ *
+ * @author jingjin@google.com
+ */
+public class VanadiumUITestBase {
+  /**
+   * System property name for Chrome driver binary. This will be set from the mvn command line.
+   */
+  private static final String PROPERTY_CHROME_DRIVER_BIN = "chromeDriverBin";
+
+  /**
+   * System property name for html report path relative to Jenkins workspace dir. This will be set
+   * from the mvn command line.
+   */
+  private static final String PROPERTY_HTML_REPORTS_RELATIVE_PATH = "htmlReportsRelativePath";
+
+  /**
+   * System property name for the bot username and bot password. These will be set from the mvn command line.
+   */
+  private static final String PROPERTY_GOOGLE_BOT_USERNAME = "googleBotUsername";
+  private static final String PROPERTY_GOOGLE_BOT_PASSWORD = "googleBotPassword";
+
+  /**
+   * The base dir to store html reports
+   */
+  protected final String htmlReportsDir;
+
+  /**
+   * A variable to keep track of the current htmlReportData.
+   */
+  protected HTMLReportData curHTMLReportData;
+
+  /**
+   * The username and password of the bot account used to sign in to Google/Chrome.
+   */
+  protected String botUsername;
+  protected String botPassword;
+
+  /**
+   * Vanadium extension installation url.
+   */
+  private static final String URL_EXTENSION = "https://chrome.google.com/webstore/detail/"
+      + "vanadium-extension/jcaelnibllfoobpedofhlaobfcoknpap";
+
+  protected ChromeDriverService service;
+
+  protected WebDriver driver;
+
+  public VanadiumUITestBase() {
+    htmlReportsDir = createReportsDir();
+  }
+
+  /**
+   * This "Rule" is used to catch failed test cases so we can act on it.
+   */
+  @Rule
+  public TestFailureWatcher testFailureWatcher = new TestFailureWatcher();
+
+  /**
+   * Basic WebDriver and other setup tasks. This will be executed before each test case.
+   */
+  @Before
+  public void setup() throws IOException {
+    String chromeDriverBin = System.getProperty(PROPERTY_CHROME_DRIVER_BIN);
+    botUsername = System.getProperty(PROPERTY_GOOGLE_BOT_USERNAME);
+    botPassword = System.getProperty(PROPERTY_GOOGLE_BOT_PASSWORD);
+    System.out.println("ChromeDriver binary: " + chromeDriverBin);
+    service = new ChromeDriverService.Builder().usingDriverExecutable(new File(chromeDriverBin))
+        .usingAnyFreePort().build();
+    service.stop();
+    service.start();
+    driver = new ChromeDriver(service);
+    driver.manage().window().maximize();
+
+    testFailureWatcher.setup(this, driver, service);
+  }
+
+  public HTMLReportData getCurrentHTMLReportData() {
+    return curHTMLReportData;
+  }
+
+  private String createReportsDir() {
+    String workspaceRoot = System.getenv("WORKSPACE");
+    if (workspaceRoot == null) {
+      workspaceRoot = System.getenv("HOME");
+    }
+    if (workspaceRoot == null) {
+      return "";
+    }
+    String reportsDir = String.format("%s/%s", workspaceRoot,
+        System.getProperty(PROPERTY_HTML_REPORTS_RELATIVE_PATH, "htmlReports"));
+    File reportsFile = new File(reportsDir);
+    if (!reportsFile.exists()) {
+      if (reportsFile.mkdirs()) {
+        System.out.println(String.format("Reports dir '%s' creatred", reportsDir));
+      } else {
+        throw new RuntimeException(String.format("Failed to create reports dir '%s'", reportsDir));
+      }
+    }
+    return reportsDir;
+  }
+
+  /**
+   * UI tests will commonly need to install the extension.
+   * The process involves signing into Chrome, installing the extension, and
+   * verifying that it was installed successfully.
+   */
+  protected void installExtension(HTMLReportData reportData) throws Exception {
+    // Sign into Chrome.
+    ChromeSignInPage chromeSignInPage = new ChromeSignInPage(driver, reportData);
+    chromeSignInPage.go();
+    chromeSignInPage.signIn(botUsername, botPassword);
+
+    // Install Vanadium extension.
+    ExtensionInstallationPage extensionInstallationPage =
+        new ExtensionInstallationPage(driver, URL_EXTENSION, reportData);
+    extensionInstallationPage.go();
+    extensionInstallationPage.login(botPassword);
+    extensionInstallationPage.install();
+
+    // Check Vanadium extension option page.
+    ExtensionOptionPage extensionOptionPage = new ExtensionOptionPage(driver, reportData);
+    extensionOptionPage.go();
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/commonpages/ChromeSignInPage.java b/test/ui/src/test/java/io/v/webdriver/commonpages/ChromeSignInPage.java
new file mode 100644
index 0000000..6b51909
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/commonpages/ChromeSignInPage.java
@@ -0,0 +1,55 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.commonpages;
+
+import org.openqa.selenium.WebDriver;
+
+import io.v.webdriver.RobotHelper;
+import io.v.webdriver.Util;
+import io.v.webdriver.htmlreport.HTMLReportData;
+
+import java.awt.AWTException;
+
+/**
+ * Chrome's native Sign-In page.
+ *
+ * @author jingjin@google.com
+ */
+public class ChromeSignInPage extends PageBase {
+  private static final String URL_CHROME_SIGNIN = "chrome://chrome-signin";
+
+  public ChromeSignInPage(WebDriver driver, HTMLReportData htmlReportData) {
+    super(driver, URL_CHROME_SIGNIN, htmlReportData);
+  }
+
+  @Override
+  public void go() {
+    super.goWithoutTakingScreenshot();
+
+    // For this page we need to wait a little bit before entering username/password.
+    Util.sleep(2000);
+    takeScreenshotUsingPageName();
+  }
+
+  public void signIn(String username, String password) throws AWTException {
+    // Sign in.
+    log("Sign in");
+    RobotHelper robotHelper = RobotHelper.sharedInstance();
+    robotHelper.enterText(username);
+    robotHelper.tab();
+    robotHelper.enter();
+    robotHelper.enterText(password);
+    robotHelper.tab();
+    Util.takeScreenshot("before-chrome-signin.png", "Before Signing In Chrome", htmlReportData);
+    robotHelper.enter();
+    Util.sleep(2000);
+
+    // Dismiss a "Sign-in successful" popup.
+    // This popup is not accessible by WebDriver.
+    log("Dismiss 'Sign-in successful' prompt");
+    robotHelper.enter();
+    Util.takeScreenshot("after-chrome-signin.png", "After Signing In Chrome", htmlReportData);
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/commonpages/ExtensionInstallationPage.java b/test/ui/src/test/java/io/v/webdriver/commonpages/ExtensionInstallationPage.java
new file mode 100644
index 0000000..8a81494
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/commonpages/ExtensionInstallationPage.java
@@ -0,0 +1,66 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.commonpages;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.TimeoutException;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+
+import io.v.webdriver.RobotHelper;
+import io.v.webdriver.Util;
+import io.v.webdriver.htmlreport.HTMLReportData;
+
+import java.awt.AWTException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+
+/**
+ * Vanadium extension installation page.
+ *
+ * @author jingjin@google.com
+ */
+public class ExtensionInstallationPage extends PageBase {
+  private static final String GOOGLE_LOGIN_URL = "https://accounts.google.com/ServiceLogin";
+
+  public ExtensionInstallationPage(WebDriver driver, String extentionUrl,
+      HTMLReportData htmlReportData) throws UnsupportedEncodingException {
+    super(driver, GOOGLE_LOGIN_URL + "?continue=" + URLEncoder.encode(extentionUrl, "UTF-8"),
+        htmlReportData);
+  }
+
+  public void login(String password) throws TimeoutException {
+    log("Log in using Google account");
+    WebElement btnSignin = wait.until(ExpectedConditions.elementToBeClickable(By.id("signIn")));
+    WebElement passwdTextField = driver.findElement(By.id("Passwd"));
+    passwdTextField.sendKeys(password);
+    Util.takeScreenshot("google-account-signin.png", "Google Account Sign-In", htmlReportData);
+    btnSignin.click();
+  }
+
+  public void install() throws AWTException, TimeoutException {
+    log("Install extension");
+    WebElement btnAddToChrome = wait.until(
+        ExpectedConditions.elementToBeClickable(By.cssSelector("div[aria-label='Add to Chrome']")));
+    Util.takeScreenshot("before-install-extension.png", "Before Installing Extension",
+        htmlReportData);
+    btnAddToChrome.click();
+    Util.sleep(2000);
+
+    // Click on the "Add" button in the extension installation popup.
+    // This popup is not accessible by WebDriver.
+    log("Confirm adding extension to Chrome");
+    RobotHelper robotHelper = RobotHelper.sharedInstance();
+    robotHelper.tab();
+    robotHelper.enter();
+    wait.until(ExpectedConditions.elementToBeClickable(
+        By.cssSelector("div[aria-label='Added to Chrome']")));
+    // It might take some time for the extension to actually be installed.
+    Util.sleep(3000);
+    Util.takeScreenshot("after-install-extension.png", "After Installing Extension",
+        htmlReportData);
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/commonpages/ExtensionOptionPage.java b/test/ui/src/test/java/io/v/webdriver/commonpages/ExtensionOptionPage.java
new file mode 100644
index 0000000..30885c6
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/commonpages/ExtensionOptionPage.java
@@ -0,0 +1,39 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.commonpages;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+
+import io.v.webdriver.Util;
+import io.v.webdriver.htmlreport.HTMLReportData;
+
+/**
+ * Vanadium extension's option page.
+ *
+ * @author jingjin@google.com
+ */
+public class ExtensionOptionPage extends PageBase {
+  private static final String URL_OPTIONS =
+      "chrome-extension://jcaelnibllfoobpedofhlaobfcoknpap/html/options.html";
+
+  public ExtensionOptionPage(WebDriver driver, HTMLReportData htmlReportData) {
+    super(driver, URL_OPTIONS, htmlReportData);
+  }
+
+  @Override
+  public void go() {
+    super.goWithoutTakingScreenshot();
+
+    // For this page we need to do an extra refresh once to make the page appear.
+    driver.navigate().refresh();
+    Util.sleep(1000);
+    takeScreenshotUsingPageName();
+
+    // Verify the "Reload plugin" link is present.
+    wait.until(ExpectedConditions.presenceOfElementLocated(By.linkText("Reload plugin")));
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/commonpages/OAuthLoginPage.java b/test/ui/src/test/java/io/v/webdriver/commonpages/OAuthLoginPage.java
new file mode 100644
index 0000000..bb5bc95
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/commonpages/OAuthLoginPage.java
@@ -0,0 +1,39 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.commonpages;
+
+import org.openqa.selenium.By;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+import org.openqa.selenium.support.ui.ExpectedConditions;
+
+import io.v.webdriver.Util;
+import io.v.webdriver.htmlreport.HTMLReportData;
+
+/**
+ * Google OAuth login page.
+ *
+ * @author jingjin@google.com
+ */
+public class OAuthLoginPage extends PageBase {
+  public OAuthLoginPage(WebDriver driver, HTMLReportData htmlReportData) {
+    // This page is often triggered by some other page.
+    super(driver, "", htmlReportData);
+  }
+
+  public void login() {
+    log("OAuth login");
+    Util.takeScreenshot("oauth-login.png", "OAuth Login", htmlReportData);
+    WebElement btnSignInGoogle = wait.until(ExpectedConditions.elementToBeClickable(
+        By.xpath("//button[contains(text(), 'Sign in with a Google Account')]")));
+    btnSignInGoogle.click();
+
+    log("Accept access info");
+    Util.takeScreenshot("accept-access-info.png", "Accepting Access Info", htmlReportData);
+    WebElement btnAccept =
+        wait.until(ExpectedConditions.elementToBeClickable(By.id("submit_approve_access")));
+    btnAccept.click();
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/commonpages/PageBase.java b/test/ui/src/test/java/io/v/webdriver/commonpages/PageBase.java
new file mode 100644
index 0000000..bd30fd2
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/commonpages/PageBase.java
@@ -0,0 +1,70 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.commonpages;
+
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.support.ui.WebDriverWait;
+
+import io.v.webdriver.Util;
+import io.v.webdriver.htmlreport.HTMLReportData;
+
+/**
+ * Base page class.
+ *
+ * @author jingjin@google.com
+ */
+public class PageBase {
+  /**
+   * Default timeout in seconds for WebDriver to wait for a condition.
+   */
+  public static final int TIMEOUT_SECONDS = 10;
+
+  /**
+   * Page URL.
+   */
+  private String url;
+
+  /**
+   * Informative page name used in logging.
+   */
+  private final String pageName;
+
+  protected final WebDriver driver;
+
+  protected final WebDriverWait wait;
+
+  protected final HTMLReportData htmlReportData;
+
+  public PageBase(WebDriver driver, String url, HTMLReportData htmlReportData) {
+    this.driver = driver;
+    this.url = url;
+    this.htmlReportData = htmlReportData;
+    this.pageName = this.getClass().getSimpleName();
+
+    wait = new WebDriverWait(driver, TIMEOUT_SECONDS);
+  }
+
+  public void go() {
+    goWithoutTakingScreenshot();
+    takeScreenshotUsingPageName();
+  }
+
+  public void goWithoutTakingScreenshot() {
+    log("Go to " + url);
+    driver.get(url);
+  }
+
+  protected void log(String msg) {
+    System.out.println(String.format("[%s]: %s", pageName, msg));
+  }
+
+  /**
+   * Takes a screenshot for the current page and names it using pageName.
+   */
+  protected void takeScreenshotUsingPageName() {
+    Util.takeScreenshot(String.format("%s.png", Util.getSafeFilename(pageName)), pageName,
+        htmlReportData);
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/htmlreport/HTMLReportData.java b/test/ui/src/test/java/io/v/webdriver/htmlreport/HTMLReportData.java
new file mode 100644
index 0000000..998258d
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/htmlreport/HTMLReportData.java
@@ -0,0 +1,51 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.htmlreport;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Data model class for used in HTML report generation.
+ *
+ * @author jingjin@google.com
+ */
+public class HTMLReportData {
+  /**
+   * A list of screenshots in the report.
+   */
+  private final List<ScreenshotData> screenshots = new ArrayList<ScreenshotData>();
+
+  /**
+   * The report's base dir.
+   */
+  private final String reportDir;
+
+  /**
+   * The name of the test associated with the data.
+   */
+  private final String testName;
+
+  public HTMLReportData(String testName, String reportDir) {
+    this.testName = testName;
+    this.reportDir = reportDir;
+  }
+
+  public String getReportDir() {
+    return reportDir;
+  }
+
+  public String getTestName() {
+    return testName;
+  }
+
+  public void addScreenshotData(String fileName, String caption) {
+    screenshots.add(new ScreenshotData(fileName, caption));
+  }
+
+  public List<ScreenshotData> getScreenshots() {
+    return screenshots;
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/htmlreport/HTMLReporter.java b/test/ui/src/test/java/io/v/webdriver/htmlreport/HTMLReporter.java
new file mode 100644
index 0000000..9bb9497
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/htmlreport/HTMLReporter.java
@@ -0,0 +1,63 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.htmlreport;
+
+import freemarker.template.Configuration;
+import freemarker.template.Template;
+import freemarker.template.TemplateExceptionHandler;
+
+import io.v.webdriver.Util;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.Writer;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Generate HTML report that has screenshots of all test steps.
+ *
+ * @author jingjin@google.com
+ */
+public class HTMLReporter {
+  private final String reportFileName;
+  private final HTMLReportData data;
+
+  public HTMLReporter(HTMLReportData data) {
+    this.reportFileName = String.format("%s.html", Util.getSafeFilename(data.getTestName()));
+    this.data = data;
+  }
+
+  public void generateReport() throws Exception {
+    // Setup and load template.
+    Configuration cfg = new Configuration(Configuration.VERSION_2_3_22);
+    cfg.setClassForTemplateLoading(this.getClass(), "");
+    cfg.setDefaultEncoding("UTF-8");
+    cfg.setLocale(Locale.US);
+    cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
+
+    // Figure out where the report.ftl file is.
+    File f = new File(System.getenv("V23_ROOT") + "/release/javascript/core/test/ui/src/test/java/io/v/webdriver/htmlreport");
+    String path = f.getAbsolutePath();
+    cfg.setDirectoryForTemplateLoading(new File(path));
+
+    // This template formats the HTML report.
+    Template template = cfg.getTemplate("report.ftl");
+
+    // Prepare data.
+    Map<String, Object> input = new HashMap<String, Object>();
+    input.put("data", data);
+
+    // Generate output.
+    String reportPath = String.format("%s/%s", data.getReportDir(), reportFileName);
+    Writer fileWriter = new FileWriter(new File(reportPath));
+    try {
+      template.process(input, fileWriter);
+    } finally {
+      fileWriter.close();
+    }
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/htmlreport/ScreenshotData.java b/test/ui/src/test/java/io/v/webdriver/htmlreport/ScreenshotData.java
new file mode 100644
index 0000000..db7c77f
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/htmlreport/ScreenshotData.java
@@ -0,0 +1,35 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package io.v.webdriver.htmlreport;
+
+/**
+ * Data model for a screenshot image.
+ *
+ * @author jingjin@google.com
+ */
+public class ScreenshotData {
+  /**
+   * The base file name (not including the full path) of the screenshot image file.
+   */
+  private final String fileName;
+
+  /**
+   * The caption of the screenshot.
+   */
+  private final String caption;
+
+  public ScreenshotData(String fileName, String caption) {
+    this.fileName = fileName;
+    this.caption = caption;
+  }
+
+  public String getFileName() {
+    return fileName;
+  }
+
+  public String getCaption() {
+    return caption;
+  }
+}
diff --git a/test/ui/src/test/java/io/v/webdriver/htmlreport/report.ftl b/test/ui/src/test/java/io/v/webdriver/htmlreport/report.ftl
new file mode 100644
index 0000000..166a919
--- /dev/null
+++ b/test/ui/src/test/java/io/v/webdriver/htmlreport/report.ftl
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>${data.testName}</title>
+    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
+    <link  href="https://cdnjs.cloudflare.com/ajax/libs/fotorama/4.6.3/fotorama.css" rel="stylesheet">
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/fotorama/4.6.3/fotorama.js"></script>
+    <script>
+     // Set "Test Failed" caption to red background.
+     $(function() {
+        $('.fotorama').on('fotorama:show', function(e, fotorama, extra) {
+          $(".fotorama__caption__wrap").each(function(index) {
+            if ($(this).text() == "Test Failed") {
+              $(this).addClass('failed-test');
+            }
+          });
+        });
+      });
+    </script>
+    <style>
+      body {
+        font-family: Helvetica, Arial, sans-serif;
+      }
+
+      h1 {
+        font-size: 20px;
+        color: #0097A7;
+      }
+
+      .failed-test {
+        background-color: rgba(200, 0, 0, 0.9) !important;
+        color: white !important;
+      }
+
+      .fotorama__caption {
+        left: auto !important;
+        right: 0px;
+        top: 0px;
+      }
+
+      .fotorama__caption__wrap {
+        border: solid 1px #AAA;
+        font-weight: bold;
+      }
+
+      .fotorama__thumb-border {
+        border-color: #0097A7 !important;
+      }
+
+      .fotorama__wrap {
+        margin: 0 auto;
+      }
+    </style>
+  </head>
+  <body>
+    <h1>${data.testName}</h1>
+    <div class="fotorama"
+        data-width="800px"
+        data-ratio="800/600"
+        data-nav="thumbs"
+        data-keyboard="true"
+        data-thumbwidth=60
+        data-thumbheight=45>
+      <#list data.screenshots as screenshot>
+        <img class="failed-test" src="${screenshot.fileName}" data-caption="${screenshot.caption}"/>
+      </#list>
+    </div>
+  </body>
+</html>
\ No newline at end of file