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