TestFlowVisualizer.java

/*
 * 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>");
  }
}