Merge "js/core: Add WebDriver library for UI Testing"
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