| // Copyright 2013 The Flutter 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 androidx.test.espresso.flutter.internal.protocol.impl; |
| |
| import static com.google.common.base.Preconditions.checkNotNull; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.common.util.concurrent.Futures.transform; |
| import static com.google.common.util.concurrent.MoreExecutors.directExecutor; |
| |
| import android.graphics.Rect; |
| import android.util.Log; |
| import androidx.test.espresso.flutter.api.FlutterTestingProtocol; |
| import androidx.test.espresso.flutter.api.SyntheticAction; |
| import androidx.test.espresso.flutter.api.WidgetMatcher; |
| import androidx.test.espresso.flutter.internal.idgenerator.IdGenerator; |
| import androidx.test.espresso.flutter.internal.jsonrpc.JsonRpcClient; |
| import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcRequest; |
| import androidx.test.espresso.flutter.internal.jsonrpc.message.JsonRpcResponse; |
| import androidx.test.espresso.flutter.internal.protocol.impl.GetOffsetAction.OffsetType; |
| import androidx.test.espresso.flutter.model.WidgetInfo; |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.base.Function; |
| import com.google.common.util.concurrent.Futures; |
| import com.google.common.util.concurrent.ListenableFuture; |
| import com.google.common.util.concurrent.ListeningExecutorService; |
| import com.google.common.util.concurrent.MoreExecutors; |
| import com.google.gson.Gson; |
| import com.google.gson.GsonBuilder; |
| import com.google.gson.JsonElement; |
| import com.google.gson.JsonObject; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.concurrent.ExecutionException; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Future; |
| import javax.annotation.Nonnull; |
| import javax.annotation.Nullable; |
| |
| /** |
| * An implementation of the Espresso-Flutter testing protocol by using the testing APIs exposed by |
| * Dart VM service protocol. |
| * |
| * @see <a href="https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md">Dart VM |
| * Service Protocol</a>. |
| */ |
| public final class DartVmService implements FlutterTestingProtocol { |
| |
| private static final String TAG = DartVmService.class.getSimpleName(); |
| |
| private static final Gson gson = |
| new GsonBuilder().excludeFieldsWithoutExposeAnnotation().create(); |
| |
| /** Prefix to be attached to the JSON-RPC message id. */ |
| private static final String MESSAGE_ID_PREFIX = "message-"; |
| |
| /** The JSON-RPC method for testing extension APIs. */ |
| private static final String TESTING_EXTENSION_METHOD = "ext.flutter.driver"; |
| /** The JSON-RPC method for retrieving Dart isolate info. */ |
| private static final String GET_ISOLATE_METHOD = "getIsolate"; |
| /** The JSON-RPC method for retrieving Dart VM info. */ |
| private static final String GET_VM_METHOD = "getVM"; |
| |
| /** Json property name for the Dart VM isolate id. */ |
| private static final String ISOLATE_ID_TAG = "isolateId"; |
| |
| private final JsonRpcClient client; |
| private final IdGenerator<Integer> messageIdGenerator; |
| private final String isolateId; |
| private final ListeningExecutorService taskExecutor; |
| |
| /** |
| * Constructs a {@code DartVmService} instance that can be used to talk to the testing protocol |
| * exposed by Dart VM service extension protocol. It uses the given {@code isolateId} in all the |
| * JSON-RPC requests. It waits until the service extension protocol is in a usable state before |
| * returning. |
| * |
| * @param isolateId the Dart isolate ID to be used in the JSON-RPC requests sent to Dart VM |
| * service protocol. |
| * @param jsonRpcClient a JSON-RPC web socket connection to send requests to the Dart VM service |
| * protocol. |
| * @param messageIdGenerator an ID generator for generating the JSON-RPC request IDs. |
| * @param taskExecutor an executor for running async tasks. |
| */ |
| public DartVmService( |
| String isolateId, |
| JsonRpcClient jsonRpcClient, |
| IdGenerator<Integer> messageIdGenerator, |
| ExecutorService taskExecutor) { |
| this.isolateId = |
| checkNotNull( |
| isolateId, "The ID of the Dart isolate that draws the Flutter UI shouldn't be null."); |
| this.client = |
| checkNotNull( |
| jsonRpcClient, |
| "The JsonRpcClient used to talk to Dart VM service protocol shouldn't be null."); |
| this.messageIdGenerator = |
| checkNotNull( |
| messageIdGenerator, "The id generator for generating request IDs shouldn't be null."); |
| this.taskExecutor = MoreExecutors.listeningDecorator(checkNotNull(taskExecutor)); |
| } |
| |
| /** |
| * {@inheritDoc} |
| * |
| * <p>This method ensures the Dart VM service is ready for use by checking: |
| * |
| * <ul> |
| * <li>Dart VM Observatory is up and running. |
| * <li>The Flutter testing API is registered with the running Dart VM service protocol. |
| * </ul> |
| */ |
| @Override |
| @SuppressWarnings("unchecked") |
| public Future<Void> connect() { |
| return (Future<Void>) taskExecutor.submit(new IsDartVmServiceReady(isolateId, this)); |
| } |
| |
| @Override |
| public Future<Void> perform( |
| @Nullable final WidgetMatcher widgetMatcher, final SyntheticAction action) { |
| // Assumes all the actions require a response. |
| ListenableFuture<JsonRpcResponse> responseFuture = |
| client.request(getActionRequest(widgetMatcher, action)); |
| Function<JsonRpcResponse, Void> resultTransformFunc = |
| new Function<JsonRpcResponse, Void>() { |
| public Void apply(JsonRpcResponse response) { |
| if (response.getError() == null) { |
| return null; |
| } else { |
| // TODO(https://github.com/android/android-test/issues/251): Update error case handling |
| // like |
| // AmbiguousWidgetMatcherException, NoMatchingWidgetException after nailing down the |
| // design with |
| // Flutter team. |
| throw new RuntimeException( |
| String.format( |
| "Error occurred when performing the given action %s on widget matched %s", |
| action, widgetMatcher)); |
| } |
| } |
| }; |
| return transform(responseFuture, resultTransformFunc, directExecutor()); |
| } |
| |
| @Override |
| public Future<WidgetInfo> matchWidget(@Nonnull WidgetMatcher widgetMatcher) { |
| JsonRpcRequest request = getActionRequest(widgetMatcher, new GetWidgetDiagnosticsAction()); |
| ListenableFuture<JsonRpcResponse> jsonResponseFuture = client.request(request); |
| |
| Function<JsonRpcResponse, WidgetInfo> widgetInfoTransformer = |
| new Function<JsonRpcResponse, WidgetInfo>() { |
| public WidgetInfo apply(JsonRpcResponse jsonResponse) { |
| GetWidgetDiagnosticsResponse widgetDiagnostics = |
| GetWidgetDiagnosticsResponse.fromJsonRpcResponse(jsonResponse); |
| return WidgetInfoFactory.createWidgetInfo(widgetDiagnostics); |
| } |
| }; |
| return transform(jsonResponseFuture, widgetInfoTransformer, directExecutor()); |
| } |
| |
| @Override |
| public Future<Rect> getLocalRect(@Nonnull WidgetMatcher widgetMatcher) { |
| ListenableFuture<JsonRpcResponse> topLeftFuture = |
| client.request(getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.TOP_LEFT))); |
| ListenableFuture<JsonRpcResponse> bottomRightFuture = |
| client.request( |
| getActionRequest(widgetMatcher, new GetOffsetAction(OffsetType.BOTTOM_RIGHT))); |
| ListenableFuture<List<JsonRpcResponse>> responses = |
| Futures.allAsList(topLeftFuture, bottomRightFuture); |
| Function<List<JsonRpcResponse>, Rect> rectTransformer = |
| new Function<List<JsonRpcResponse>, Rect>() { |
| public Rect apply(List<JsonRpcResponse> jsonResponses) { |
| GetOffsetResponse topLeft = GetOffsetResponse.fromJsonRpcResponse(jsonResponses.get(0)); |
| GetOffsetResponse bottomRight = |
| GetOffsetResponse.fromJsonRpcResponse(jsonResponses.get(1)); |
| checkState( |
| topLeft.getX() >= 0 && topLeft.getY() >= 0, |
| String.format( |
| "The relative coordinates [%.1f, %.1f] of a widget's top left vertex cannot be" |
| + " negative (negative means it's off the outer Flutter view)!", |
| topLeft.getX(), topLeft.getY())); |
| checkState( |
| bottomRight.getX() >= 0 && bottomRight.getY() >= 0, |
| String.format( |
| "The relative coordinates [%.1f, %.1f] of a widget's bottom right vertex cannot" |
| + " be negative (negative means it's off the outer Flutter view)!", |
| bottomRight.getX(), bottomRight.getY())); |
| checkState( |
| topLeft.getX() <= bottomRight.getX() && topLeft.getY() <= bottomRight.getY(), |
| String.format( |
| "The coordinates of the bottom right vertex [%.1f, %.1f] are not actually to the" |
| + " bottom right of the top left vertex [%.1f, %.1f]!", |
| topLeft.getX(), topLeft.getY(), bottomRight.getX(), bottomRight.getY())); |
| return new Rect( |
| (int) topLeft.getX(), |
| (int) topLeft.getY(), |
| (int) bottomRight.getX(), |
| (int) bottomRight.getY()); |
| } |
| }; |
| return transform(responses, rectTransformer, directExecutor()); |
| } |
| |
| @Override |
| public Future<Void> waitUntilIdle() { |
| return perform( |
| null, |
| new WaitForConditionAction( |
| new NoPendingPlatformMessagesCondition(), |
| new NoTransientCallbacksCondition(), |
| new NoPendingFrameCondition())); |
| } |
| |
| @Override |
| public void close() { |
| if (client != null) { |
| client.disconnect(); |
| } |
| } |
| |
| /** Queries the Dart isolate information. */ |
| public ListenableFuture<JsonRpcResponse> getIsolateInfo() { |
| JsonRpcRequest getIsolateReq = |
| new JsonRpcRequest.Builder(GET_ISOLATE_METHOD) |
| .setId(getNextMessageId()) |
| .addParam(ISOLATE_ID_TAG, isolateId) |
| .build(); |
| return client.request(getIsolateReq); |
| } |
| |
| /** Queries the Dart VM information. */ |
| public ListenableFuture<GetVmResponse> getVmInfo() { |
| JsonRpcRequest getVmReq = |
| new JsonRpcRequest.Builder(GET_VM_METHOD).setId(getNextMessageId()).build(); |
| ListenableFuture<JsonRpcResponse> jsonGetVmResp = client.request(getVmReq); |
| Function<JsonRpcResponse, GetVmResponse> jsonToResponse = |
| new Function<JsonRpcResponse, GetVmResponse>() { |
| public GetVmResponse apply(JsonRpcResponse jsonResp) { |
| return GetVmResponse.fromJsonRpcResponse(jsonResp); |
| } |
| }; |
| return transform(jsonGetVmResp, jsonToResponse, directExecutor()); |
| } |
| |
| /** Gets the next usable message id. */ |
| private String getNextMessageId() { |
| return MESSAGE_ID_PREFIX + messageIdGenerator.next(); |
| } |
| |
| /** Constructs a {@code JsonRpcRequest} based on the given matcher and action. */ |
| private JsonRpcRequest getActionRequest(WidgetMatcher widgetMatcher, SyntheticAction action) { |
| checkNotNull(action, "Action cannot be null."); |
| // Assumes all the actions require a response. |
| return new JsonRpcRequest.Builder(TESTING_EXTENSION_METHOD) |
| .setId(getNextMessageId()) |
| .setParams(constructParams(isolateId, widgetMatcher, action)) |
| .build(); |
| } |
| |
| /** Constructs the JSON-RPC request params. */ |
| private static JsonObject constructParams( |
| String isolateId, WidgetMatcher widgetMatcher, SyntheticAction action) { |
| JsonObject paramObject = new JsonObject(); |
| paramObject.addProperty(ISOLATE_ID_TAG, isolateId); |
| if (widgetMatcher != null) { |
| paramObject = merge(paramObject, (JsonObject) gson.toJsonTree(widgetMatcher)); |
| } |
| paramObject = merge(paramObject, (JsonObject) gson.toJsonTree(action)); |
| return paramObject; |
| } |
| |
| /** |
| * Returns a merged {@code JsonObject} of the two given {@code JsonObject}s, or an empty {@code |
| * JsonObject} if both of the objects to be merged are null. |
| */ |
| private static JsonObject merge(@Nullable JsonObject obj1, @Nullable JsonObject obj2) { |
| JsonObject result = new JsonObject(); |
| mergeTo(result, obj1); |
| mergeTo(result, obj2); |
| return result; |
| } |
| |
| private static void mergeTo(JsonObject obj, @Nullable JsonObject toBeMerged) { |
| if (toBeMerged != null) { |
| for (Map.Entry<String, JsonElement> entry : toBeMerged.entrySet()) { |
| obj.add(entry.getKey(), entry.getValue()); |
| } |
| } |
| } |
| |
| /** A {@link Runnable} that waits until the Dart VM testing extension is ready for use. */ |
| static class IsDartVmServiceReady implements Runnable { |
| |
| /** Maximum number of retries for checking extension APIs' availability. */ |
| private static final int EXTENSION_API_CHECKING_RETRIES = 5; |
| |
| /** Json param name for retrieving all the available extension APIs. */ |
| private static final String EXTENSION_RPCS_TAG = "extensionRPCs"; |
| |
| private final String isolateId; |
| private final DartVmService dartVmService; |
| |
| IsDartVmServiceReady(String isolateId, DartVmService dartVmService) { |
| this.isolateId = checkNotNull(isolateId); |
| this.dartVmService = checkNotNull(dartVmService); |
| } |
| |
| @Override |
| public void run() { |
| waitForTestingApiRegistered(); |
| } |
| |
| /** |
| * Blocks until the Flutter testing/driver API is registered with the running Dart VM service |
| * protocol by querying whether it's listed in the isolate's 'extensionRPCs'. |
| */ |
| @VisibleForTesting |
| void waitForTestingApiRegistered() { |
| int retries = EXTENSION_API_CHECKING_RETRIES; |
| boolean isApiRegistered = false; |
| do { |
| retries--; |
| try { |
| JsonRpcResponse isolateResp = dartVmService.getIsolateInfo().get(); |
| isApiRegistered = isTestingApiRegistered(isolateResp); |
| } catch (ExecutionException e) { |
| Log.d( |
| TAG, |
| "Error occurred during retrieving Dart isolate information. Retry.", |
| e.getCause()); |
| continue; |
| } catch (InterruptedException e) { |
| Log.d( |
| TAG, |
| "InterruptedException occurred during retrieving Dart isolate information. Retry.", |
| e); |
| Thread.currentThread().interrupt(); // Restores the interrupted status. |
| continue; |
| } |
| } while (!isApiRegistered && retries > 0); |
| |
| if (!isApiRegistered) { |
| throw new FlutterProtocolException( |
| String.format("Flutter testing APIs not registered with Dart isolate %s.", isolateId)); |
| } |
| } |
| |
| @VisibleForTesting |
| boolean isTestingApiRegistered(JsonRpcResponse isolateInfoResp) { |
| if (isolateInfoResp == null |
| || isolateInfoResp.getError() != null |
| || isolateInfoResp.getResult() == null) { |
| Log.w( |
| TAG, |
| String.format( |
| "Error occurred in JSON-RPC response when querying isolate info for %s: %s.", |
| isolateId, isolateInfoResp.getError())); |
| return false; |
| } |
| for (JsonElement jsonElement : |
| isolateInfoResp.getResult().get(EXTENSION_RPCS_TAG).getAsJsonArray()) { |
| String extensionApi = jsonElement.getAsString(); |
| if (TESTING_EXTENSION_METHOD.equals(extensionApi)) { |
| Log.d( |
| TAG, |
| String.format("Flutter testing API registered with Dart isolate %s.", isolateId)); |
| return true; |
| } |
| } |
| return false; |
| } |
| } |
| } |