public class

TestFlowVisualizer

extends java.lang.Object

 java.lang.Object

↳androidx.test.espresso.internal.data.TestFlowVisualizer

Gradle dependencies

compile group: 'androidx.test.espresso', name: 'espresso-core', version: '3.6.1'

  • groupId: androidx.test.espresso
  • artifactId: espresso-core
  • version: 3.6.1

Artifact androidx.test.espresso:espresso-core:3.6.1 it located at Google repository (https://maven.google.com/)

Androidx artifact mapping:

androidx.test.espresso:espresso-core com.android.support.test.espresso:espresso-core

Overview

A class for visualizing test data. For every action, records screen data to output as a test artifact.

Run by setting the custom test argument "enable_testflow_gallery" to true.

This is an EXPERIMENTAL FEATURE to assist in Espresso test debuggability.

Summary

Methods
public voidafterActionGenerateTestArtifact(int actionIndex)

public voidafterActionRecordData(ActionData actionData)

Appends a node to the .

public voidbeforeActionGenerateTestArtifact(int actionIndex)

public voidbeforeActionRecordData(ActionData actionData, View view)

Appends a node to the .

public static TestFlowVisualizergetInstance(PlatformTestStorage platformTestStorage)

Gets an instance of TestFlowVisualizer.

public intgetLastActionIndex()

public intgetLastActionIndexAndIncrement()

public booleanisEnabled()

Returns whether this feature is enabled.

public voidvisualize()

Traverses the TestFlow graph and parses data to html.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Methods

public static TestFlowVisualizer getInstance(PlatformTestStorage platformTestStorage)

Gets an instance of TestFlowVisualizer. Ensures singleton behavior.

public boolean isEnabled()

Returns whether this feature is enabled.

To enable, pass in the --enable_testflow_gallery flag.

public int getLastActionIndexAndIncrement()

public int getLastActionIndex()

public void beforeActionRecordData(ActionData actionData, View view)

Appends a node to the .

Must be called before an action occurs, with afterActionRecordData after the action.

Must be called on main thread.

Parameters:

actionData: Data pertaining to a ViewAction to be performed.
view: The view an action is performed on.

public void afterActionRecordData(ActionData actionData)

Appends a node to the . Sets members and displays them.

Must be called after an action occurs, with beforeActionRecordData before the action.

Must be called on main thread.

Parameters:

actionData: The viewAction being performed.

public void beforeActionGenerateTestArtifact(int actionIndex)

public void afterActionGenerateTestArtifact(int actionIndex)

public void visualize()

Traverses the TestFlow graph and parses data to html.

TODO(b/196264719): Move this to a TestRule.

Source

/*
 * 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.
 */

package androidx.test.espresso.internal.data;

import static androidx.test.internal.util.Checks.checkNotNull;
import static androidx.test.internal.util.Checks.checkState;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.String.format;

import android.graphics.Rect;
import android.os.Looper;
import android.util.Log;
import android.view.View;
import androidx.annotation.VisibleForTesting;
import androidx.test.espresso.action.GeneralLocation;
import androidx.test.espresso.internal.data.model.ActionData;
import androidx.test.espresso.internal.data.model.ScreenData;
import androidx.test.espresso.internal.data.model.TestArtifact;
import androidx.test.espresso.internal.data.model.TestFlow;
import androidx.test.espresso.internal.data.model.ViewData;
import androidx.test.internal.platform.util.TestOutputEmitter;
import androidx.test.platform.io.PlatformTestStorage;
import java.io.IOException;
import java.io.PrintStream;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

/**
 * A class for visualizing test data. For every action, records screen data to output as a test
 * artifact.
 *
 * <p>Run by setting the custom test argument "enable_testflow_gallery" to true.
 *
 * <p>This is an EXPERIMENTAL FEATURE to assist in Espresso test debuggability.
 */
public class TestFlowVisualizer {
  private static TestFlowVisualizer testFlowVisualizer;
  private static final String TEST_FLOW_ARG = "enable_testflow_gallery";
  private final TestFlow testFlow;
  private final PlatformTestStorage platformTestStorage;
  private static final String LOG_TAG = "TestFlowVisualizer";
  private int actionIndex = 0;
  private Boolean enabled;

  TestFlowVisualizer(PlatformTestStorage testStorage) {
    this(testStorage, new TestFlow());
  }

  @VisibleForTesting
  TestFlowVisualizer(PlatformTestStorage testStorage, TestFlow testFlow) {
    this.platformTestStorage = checkNotNull(testStorage);
    this.testFlow = checkNotNull(testFlow);
  }

  /** Gets an instance of {@link TestFlowVisualizer}. Ensures singleton behavior. */
  public static TestFlowVisualizer getInstance(PlatformTestStorage platformTestStorage) {
    if (testFlowVisualizer != null) {
      if (testFlowVisualizer.platformTestStorage != platformTestStorage) {
        throw new IllegalStateException(
            "getInstance called with different instance of PlatformTestStorage.");
      }
    } else {
      testFlowVisualizer = new TestFlowVisualizer(platformTestStorage);
    }
    return testFlowVisualizer;
  }

  /**
   * Returns whether this feature is enabled.
   *
   * <p>To enable, pass in the --enable_testflow_gallery flag.
   */
  public boolean isEnabled() {
    if (enabled == null) {
      enabled =
          platformTestStorage.getInputArgs().containsKey(TEST_FLOW_ARG)
              && Boolean.parseBoolean(platformTestStorage.getInputArg(TEST_FLOW_ARG));
    }
    return enabled;
  }

  public int getLastActionIndexAndIncrement() {
    int index = actionIndex;
    actionIndex++;
    return index;
  }

  public int getLastActionIndex() {
    return this.actionIndex;
  }

  /**
   * Appends a {@link ScreenData} node to the {@link TestFlow}.
   *
   * <p>Must be called before an action occurs, with afterActionRecordData after the action.
   *
   * <p>Must be called on main thread.
   *
   * @param actionData Data pertaining to a ViewAction to be performed.
   * @param view The view an action is performed on.
   */
  public void beforeActionRecordData(ActionData actionData, View view) {
    // TODO(b/196263898): Fix currently-required sequential calling of data recording functions
    // TODO(b/196264377): Allow for appending data to ActionData upon test completion.
    checkState(
        Thread.currentThread().equals(Looper.getMainLooper().getThread()),
        "Method cannot be called off the main application thread (on: %s)",
        Thread.currentThread().getName());
    checkNotNull(actionData, "Requires actionData to store in graph.");
    checkNotNull(view, "Requires View to analyze.");
    if (actionData.getIndex() == null) {
      throw new IllegalStateException("ActionData must have a distinguishing index.");
    }
    if (testFlow.getEdge(actionData.getIndex()) != null) {
      throw new IllegalStateException(
          "Currently appending to existing ActionData objects is not supported.");
    }
    Rect visibleParts = new Rect();
    view.getGlobalVisibleRect(visibleParts);
    ScreenData screen = new ScreenData();
    screen.addViewData(new ViewData(view.toString(), adjustViewCoords(view), visibleParts));
    testFlow.addScreen(screen);
  }

  /**
   * Appends a {@link ScreenData} node to the {@link TestFlow}. Sets {@link ActionData} members and
   * displays them.
   *
   * <p>Must be called after an action occurs, with beforeActionRecordData before the action.
   *
   * <p>Must be called on main thread.
   *
   * @param actionData The viewAction being performed.
   */
  public void afterActionRecordData(ActionData actionData) {
    checkState(
        Thread.currentThread().equals(Looper.getMainLooper().getThread()),
        "Method cannot be called off the main application thread (on: %s)",
        Thread.currentThread().getName());
    checkNotNull(actionData, "Requires ActionData to store in graph.");
    ScreenData currScreen = testFlow.getTail();
    ScreenData nextScreen = new ScreenData();
    actionData.source = currScreen;
    actionData.dest = nextScreen;
    testFlow.addScreen(nextScreen, actionData);
  }

  public void beforeActionGenerateTestArtifact(int actionIndex) {
    TestOutputEmitter.takeScreenshot("screenshot-before-" + actionIndex + ".png");
  }

  public void afterActionGenerateTestArtifact(int actionIndex) {
    TestOutputEmitter.takeScreenshot("screenshot-after-" + actionIndex + ".png");
  }

  /**
   * Restricts the sometimes-unset lower coordinates of the view box.
   *
   * @param view The Espresso test's view.
   * @return The new array of coordinates.
   */
  private Rect adjustViewCoords(View view) {
    float[] tl = GeneralLocation.TOP_LEFT.calculateCoordinates(view);
    float[] br = GeneralLocation.BOTTOM_RIGHT.calculateCoordinates(view);
    // TODO(b/196263288): Replace with programmatically retrieved screen size
    br[1] = min(br[1], 800);
    return new Rect((int) tl[0], (int) tl[1], (int) br[0], (int) br[1]);
  }

  /**
   * Traverses the TestFlow graph and parses data to html.
   *
   * <p>TODO(b/196264719): Move this to a TestRule.
   */
  public void visualize() {
    try (PrintStream writer =
        new PrintStream(platformTestStorage.openOutputFile("output_gallery.html"))) {
      ScreenData curr = testFlow.getHead();
      if (curr == null) {
        Log.d(LOG_TAG, "Exiting process 'visualize()', TestFlow graph is empty.");
        return;
      }
      testFlow.resetTraversal();
      setStyling(writer);
      int actionCounter = 0;
      while (!curr.getActions().isEmpty() && curr.getActionIndex() < curr.getActions().size()) {
        // before action occurs
        beginActionOutput(writer);
        String pathname = "screenshot-before-" + actionCounter + ".png";
        curr.addArtifact(new TestArtifact(pathname, ".png"));
        displayScreenshot(pathname, writer);
        // action
        if (curr.getActions().isEmpty()) {
          return;
        }
        ActionData action = curr.getActions().get(curr.getActionIndex());
        List<ViewData> views = curr.getViews();
        if (action.getDesc() != null) {
          // View data not reliable for scroll actions.
          if (!action.getDesc().contains("scroll") && !curr.getViews().isEmpty()) {
            for (ViewData element : views) {
              displayViewData(element, writer);
            }
          } else {
            writer.append("<div class=\"action-item\">");
          }
          displayActionData(action, writer);
        } else if (!views.isEmpty()) {
          for (ViewData element : views) {
            displayViewData(element, writer);
          }
        }
        ScreenData temp = action.getDest();
        curr.setActionIndex(curr.getActionIndex() + 1);
        // after action occurs
        pathname = "screenshot-after-" + actionCounter + ".png";
        curr.addArtifact(new TestArtifact(pathname, ".png"));
        displayScreenshot(pathname, writer);
        if (!temp.getActions().isEmpty() && temp.getActions().get(temp.getActionIndex()) != null) {
          curr = temp.getActions().get(temp.getActionIndex()).getDest();
          temp.setActionIndex(temp.getActionIndex() + 1);
        }
        endActionOutput(writer);
        actionCounter++;
      }
    } catch (IOException e) {
      Log.e(LOG_TAG, "Exception thrown while trying to display TestFlow.", e);
    }
  }

  /** Displays the {@link ViewData}. */
  private void displayViewData(ViewData viewData, PrintStream writer) {
    Rect viewBox = viewData.getViewBox();
    Rect visible = viewData.getVisibleViewBox();
    int x0 = viewBox.left;
    int x1 = viewBox.right;
    int y0 = viewBox.top;
    int y1 = viewBox.bottom;
    writer.append(
        format(
            Locale.ENGLISH,
            "<div style=\"border:3px solid rgba(255, 0, 0, .5); width:%d; height:%d",
            visible.right - visible.left,
            visible.bottom - (visible.top + 3)));
    writer.append(
        format(
            Locale.ENGLISH,
            "px; position:absolute; top:%dpx; left: %dpx; z-index:10;\"></div>",
            visible.top - 3,
            visible.left - 3));
    writer.append(
        format(
            Locale.ENGLISH,
            "<div style=\"border:3px solid rgba(0, 0, 255, .5); width:%s; height:%s",
            x1 - x0,
            y1 - (y0 + 3)));
    writer.append(
        String.format(
            Locale.ENGLISH,
            "; position:absolute; top:%spx; left: %spx; z-index:9;\"></div>",
            y0 - 3,
            x0 - 3));
    writer.append("<div class=\"action-item\">");
    writer.append("<div style=\"border:3px solid rgba(255, 0, 0, .5);\">Visible View</div>");
    writer.append("<div style=\"border:3px solid rgba(0, 0, 255, .5);\">Actual View</div>");
    writer.append(format(Locale.ENGLISH, "<p>%s</p>", viewData.getDesc()));
    writer.append(String.format("View: %s<br />", viewBox));
    writer.append(
        format(Locale.ENGLISH, "<p>Visible portion: %s</p>", Objects.requireNonNull(visible)));
    float percentVisible =
        max(
            min(((float) visible.bottom - (float) visible.top) / (y1 - y0), 1)
                * min(((float) visible.right - (float) visible.left) / (x1 - x0), 1)
                * 100,
            0);
    writer.append(String.format(Locale.ENGLISH, "This view is %s%% visible.", percentVisible));
  }

  /**
   * Displays the {@link ActionData} members.
   *
   * @param action a {@link ActionData} object.
   */
  private void displayActionData(ActionData action, PrintStream writer) {
    if (action.getName() != null) {
      writer.append(format(Locale.getDefault(), "<p>Classname: %s</p>", action.getName()));
    }
    if (action.getDesc() != null) {
      writer.append(format(Locale.getDefault(), "<p>Description: %s</p>", action.getDesc()));
    }
    if (action.getConstraints() != null) {
      writer.append(
          format(
              Locale.getDefault(),
              "<p>Constraints: %s</p>",
              action.getConstraints().replace('<', '(').replace('>', ')')));
    }
    writer.append("</div>");
  }

  /** Appends opening wrappers for action data to be displayed. */
  private void beginActionOutput(PrintStream writer) {
    writer.append("<div class=\"action\"><div style=\"position:relative; display:inline-block;\">");
  }

  /** Appends closing wrappers of action data to be displayed. */
  private void endActionOutput(PrintStream writer) {
    writer.append("</div></div>");
  }

  /**
   * Appends html stylings to document.
   *
   * @param writer writes html stylings.
   */
  private void setStyling(PrintStream writer) {
    writer.append("<style>\n.action-item {\ndisplay:inline-block;\nwidth:450px;\n");
    writer.append("margin-left:10px;\nmargin-right:10px;\n}\n</style>");
  }

  /**
   * Parses the contents of nodes containing {@link TestArtifact} screenshot data.
   *
   * @param pathname the pathname of the dumped screenshot.
   */
  private void displayScreenshot(String pathname, PrintStream writer) {
    // TODO(b/196263288): Replace with programmatically retrieved screen size.
    writer.append("<div style=\"width:480px; display: inline-block\">");
    writer.append(format(Locale.ENGLISH, "<img src=\"./%s\" />\n", pathname));
    writer.append("</div>");
  }
}