diff --git a/dev_docs/GUIDE.md b/dev_docs/GUIDE.md index b86f0d99b..1e1cc5d9b 100644 --- a/dev_docs/GUIDE.md +++ b/dev_docs/GUIDE.md @@ -1,17 +1,53 @@ # Working on the test bundling feature +_Test bundling_, also known as _native automation_, is a core feature of Patrol. +It bridges the native world of tests on Android and iOS with the Flutter/Dart +world of tests. + +It lives in the [patrol package](../packages/patrol). + +To learn more about test bundling, [read this article][test_bundling_article]. + +This document is a collection of tips and tricks to make it easier to work on +test bundling-related code. + +### Tools + `adb logcat` is your friend. Spice it up with `-v color`. If you need something more powerful, check out [`purr`](https://github.com/google/purr). +### Show Dart-side logs only + +Search for `flutter :`. + ### Find out when a test starts Search for `TestRunner: started`. ``` 09-21 12:24:09.223 23387 23406 I TestRunner: started: runDartTest[callbacks_test testA](pl.leancode.patrol.example.MainActivityTest) - ``` ### Find out when a test ends Search for `TestRunner: finished`. + +### I made some changes to test bundling code that result in a deadlock + +This can often happen when editing test bundling code. Because of various +limitations of the `test` package, which Patrol has to base on, test bundling +code is full of shared global mutable state and unobvious things happening in +parallel. + +When trying to find the cause of a deadlock: + +- search for `await`s in custom functions provided by Patrol (e.g. + `patrolTest()` and `patrolSetUpAll()`) and global lifecycle callbacks + registered by the generated Dart test bundle or PatrolBinding (e.g. + `tearDown()`s) +- Use `print`s amply to pinpint where the code is stuck. + +In the future, we should think about how to refactor this code to be more +maintainable and simpler. + +[test_bundling_article]: https://leancode.co/blog/patrol-2-0-improved-flutter-ui-testing diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/AutomatorServer.kt b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/AutomatorServer.kt index 6ea616c19..49031b6d3 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/AutomatorServer.kt +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/AutomatorServer.kt @@ -1,5 +1,6 @@ package pl.leancode.patrol +import androidx.test.platform.app.InstrumentationRegistry import pl.leancode.patrol.contracts.Contracts.ConfigureRequest import pl.leancode.patrol.contracts.Contracts.DarkModeRequest import pl.leancode.patrol.contracts.Contracts.EnterTextRequest @@ -9,6 +10,7 @@ import pl.leancode.patrol.contracts.Contracts.GetNotificationsRequest import pl.leancode.patrol.contracts.Contracts.GetNotificationsResponse import pl.leancode.patrol.contracts.Contracts.HandlePermissionRequest import pl.leancode.patrol.contracts.Contracts.HandlePermissionRequestCode +import pl.leancode.patrol.contracts.Contracts.MarkLifecycleCallbackExecutedRequest import pl.leancode.patrol.contracts.Contracts.OpenAppRequest import pl.leancode.patrol.contracts.Contracts.OpenQuickSettingsRequest import pl.leancode.patrol.contracts.Contracts.PermissionDialogVisibleRequest @@ -210,4 +212,9 @@ class AutomatorServer(private val automation: Automator) : NativeAutomatorServer override fun markPatrolAppServiceReady() { PatrolServer.appReady.open() } + + override fun markLifecycleCallbackExecuted(request: MarkLifecycleCallbackExecutedRequest) { + val instrumentation = InstrumentationRegistry.getInstrumentation() as PatrolJUnitRunner + instrumentation.markLifecycleCallbackExecuted(request.name) + } } diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolAppServiceClient.kt b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolAppServiceClient.kt index 7bdfa7bad..f072b97e0 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolAppServiceClient.kt +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolAppServiceClient.kt @@ -33,6 +33,20 @@ class PatrolAppServiceClient { return result.group } + @Throws(PatrolAppServiceClientException::class) + fun listDartLifecycleCallbacks(): Contracts.ListDartLifecycleCallbacksResponse { + Logger.i("PatrolAppServiceClient.listDartLifecycleCallbacks()") + val result = client.listDartLifecycleCallbacks() + return result + } + + @Throws(PatrolAppServiceClientException::class) + fun setLifecycleCallbacksState(data: Map): Contracts.Empty { + Logger.i("PatrolAppServiceClient.setLifecycleCallbacksState()") + val result = client.setLifecycleCallbacksState(Contracts.SetLifecycleCallbacksStateRequest(data)) + return result + } + @Throws(PatrolAppServiceClientException::class) fun runDartTest(name: String): Contracts.RunDartTestResponse { Logger.i("PatrolAppServiceClient.runDartTest($name)") diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolJUnitRunner.java b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolJUnitRunner.java index 03c969a7e..91ea9bcef 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolJUnitRunner.java +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolJUnitRunner.java @@ -5,19 +5,33 @@ package pl.leancode.patrol; +import android.annotation.SuppressLint; import android.app.Instrumentation; +import android.content.ContentResolver; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.runner.AndroidJUnitRunner; - +import androidx.test.services.storage.file.HostedFile; +import androidx.test.services.storage.internal.TestStorageUtil; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; import pl.leancode.patrol.contracts.Contracts; +import pl.leancode.patrol.contracts.Contracts.ListDartLifecycleCallbacksResponse; import pl.leancode.patrol.contracts.PatrolAppServiceClientException; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; -import java.util.concurrent.ExecutionException; +import java.util.Map; +import java.util.Scanner; import static pl.leancode.patrol.contracts.Contracts.DartGroupEntry; import static pl.leancode.patrol.contracts.Contracts.RunDartTestResponse; @@ -27,9 +41,30 @@ * A customized AndroidJUnitRunner that enables Patrol on Android. *

*/ +@SuppressLint({"UnsafeOptInUsageError", "RestrictedApi"}) public class PatrolJUnitRunner extends AndroidJUnitRunner { public PatrolAppServiceClient patrolAppServiceClient; + /** + *

+ * Available only after onCreate() has been run. + *

+ */ + protected boolean isInitialRun; + + private ContentResolver getContentResolver() { + return InstrumentationRegistry.getInstrumentation().getTargetContext().getContentResolver(); + } + + private Uri stateFileUri = HostedFile.buildUri( + HostedFile.FileHost.OUTPUT, + "patrol_callbacks.json" + ); + + public boolean isInitialRun() { + return isInitialRun; + } + @Override protected boolean shouldWaitForActivitiesToComplete() { return false; @@ -40,10 +75,10 @@ public void onCreate(Bundle arguments) { super.onCreate(arguments); // This is only true when the ATO requests a list of tests from the app during the initial run. - boolean isInitialRun = Boolean.parseBoolean(arguments.getString("listTestsForOrchestrator")); + this.isInitialRun = Boolean.parseBoolean(arguments.getString("listTestsForOrchestrator")); Logger.INSTANCE.i("--------------------------------"); - Logger.INSTANCE.i("PatrolJUnitRunner.onCreate() " + (isInitialRun ? "(initial run)" : "")); + Logger.INSTANCE.i("PatrolJUnitRunner.onCreate() " + (this.isInitialRun ? "(initial run)" : "")); } /** @@ -69,6 +104,7 @@ public void setUp(Class activityClass) { // Currently, the only synchronization point we're interested in is when the app under test returns the list of tests. Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); Intent intent = new Intent(Intent.ACTION_MAIN); + intent.putExtra("isInitialRun", isInitialRun); intent.setClassName(instrumentation.getTargetContext(), activityClass.getCanonicalName()); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); instrumentation.getTargetContext().startActivity(intent); @@ -94,7 +130,7 @@ public PatrolAppServiceClient createAppServiceClient() { *

*/ public void waitForPatrolAppService() { - final String TAG = "PatrolJUnitRunner.setUp(): "; + final String TAG = "PatrolJUnitRunner.waitForPatrolAppService(): "; Logger.INSTANCE.i(TAG + "Waiting for PatrolAppService to report its readiness..."); PatrolServer.Companion.getAppReady().block(); @@ -105,6 +141,11 @@ public void waitForPatrolAppService() { public Object[] listDartTests() { final String TAG = "PatrolJUnitRunner.listDartTests(): "; + // This call should be in MainActivityTest.java, but that would require + // users to change that file in their projects, thus breaking backward + // compatibility. + handleLifecycleCallbacks(); + try { final DartGroupEntry dartTestGroup = patrolAppServiceClient.listDartTests(); List dartTestCases = ContractsExtensionsKt.listTestsFlat(dartTestGroup, ""); @@ -121,12 +162,99 @@ public Object[] listDartTests() { } } + private void handleLifecycleCallbacks() { + if (isInitialRun()) { + Object[] lifecycleCallbacks = listLifecycleCallbacks(); + saveLifecycleCallbacks(lifecycleCallbacks); + } else { + setLifecycleCallbacksState(); + } + } + + public Object[] listLifecycleCallbacks() { + final String TAG = "PatrolJUnitRunner.listLifecycleCallbacks(): "; + + try { + final ListDartLifecycleCallbacksResponse response = patrolAppServiceClient.listDartLifecycleCallbacks(); + final List setUpAlls = response.getSetUpAlls(); + Logger.INSTANCE.i(TAG + "Got Dart lifecycle callbacks: " + setUpAlls); + + return setUpAlls.toArray(); + } catch (PatrolAppServiceClientException e) { + Logger.INSTANCE.e(TAG + "Failed to list Dart lifecycle callbacks: ", e); + throw new RuntimeException(e); + } + } + + public void saveLifecycleCallbacks(Object[] callbacks) { + Map callbackMap = new HashMap<>(); + for (Object callback : callbacks) { + callbackMap.put((String) callback, false); + } + + writeStateFile(callbackMap); + } + + public void markLifecycleCallbackExecuted(String name) { + Logger.INSTANCE.i("PatrolJUnitRunnerMarking.markLifecycleCallbackExecuted(" + name + ")"); + Map state = readStateFile(); + state.put(name, true); + writeStateFile(state); + } + + private Map readStateFile() { + try { + InputStream inputStream = TestStorageUtil.getInputStream(stateFileUri, getContentResolver()); + String content = convertStreamToString(inputStream); + Gson gson = new Gson(); + Type typeOfHashMap = new TypeToken>() {}.getType(); + Map data = gson.fromJson(content, typeOfHashMap); + return data; + } catch (FileNotFoundException e) { + throw new RuntimeException("Failed to read state file", e); + } + } + + private void writeStateFile(Map data) { + try { + OutputStream outputStream = TestStorageUtil.getOutputStream(stateFileUri, getContentResolver()); + Gson gson = new Gson(); + Type typeOfHashMap = new TypeToken>() {}.getType(); + String json = gson.toJson(data, typeOfHashMap); + outputStream.write(json.getBytes()); + outputStream.write("\n".getBytes()); + } catch (IOException e) { + throw new RuntimeException("Failed to write state file", e); + } + } + + static String convertStreamToString(InputStream inputStream) { + Scanner s = new Scanner(inputStream).useDelimiter("\\A"); + return s.hasNext() ? s.next() : ""; + } + + /** + * Sets the state of lifecycle callbacks in the app. + *

+ * This is required because the app is launched in a new process for each test. + */ + public void setLifecycleCallbacksState() { + final String TAG = "PatrolJUnitRunner.setLifecycleCallbacksStateInApp(): "; + + try { + patrolAppServiceClient.setLifecycleCallbacksState(readStateFile()); + } catch (PatrolAppServiceClientException e) { + Logger.INSTANCE.e(TAG + "Failed to set lifecycle callbacks state in app: ", e); + throw new RuntimeException(e); + } + } + /** * Requests execution of a Dart test and waits for it to finish. * Throws AssertionError if the test fails. */ public RunDartTestResponse runDartTest(String name) { - final String TAG = "PatrolJUnitRunner.runDartTest(" + name + "): "; + final String TAG = "PatrolJUnitRunner.runDartTest(\"" + name + "\"): "; try { Logger.INSTANCE.i(TAG + "Requested execution"); diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolPlugin.kt b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolPlugin.kt index 998420419..c930fd0ec 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolPlugin.kt +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/PatrolPlugin.kt @@ -1,24 +1,52 @@ package pl.leancode.patrol import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -class PatrolPlugin : FlutterPlugin, MethodCallHandler { +class PatrolPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var channel: MethodChannel + private var isInitialRun: Boolean? = null + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "pl.leancode.patrol/main") channel.setMethodCallHandler(this) } override fun onMethodCall(call: MethodCall, result: Result) { - result.notImplemented() + when (call.method) { + "isInitialRun" -> result.success(isInitialRun) + else -> result.notImplemented() + } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + val intent = binding.activity.intent + if (!intent.hasExtra("isInitialRun")) { + throw IllegalStateException("PatrolPlugin must be initialized with intent having isInitialRun boolean") + } + + isInitialRun = intent.getBooleanExtra("isInitialRun", false) + } + + override fun onDetachedFromActivityForConfigChanges() { + // Do nothing + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + // Do nothing + } + + override fun onDetachedFromActivity() { + // Do nothing + } } diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/Contracts.kt b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/Contracts.kt index 820eca3f8..e1545c5f7 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/Contracts.kt +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/Contracts.kt @@ -33,6 +33,10 @@ class Contracts { fine, } + class Empty ( + + ) + data class DartGroupEntry ( val name: String, val type: GroupEntryType, @@ -43,6 +47,11 @@ class Contracts { val group: DartGroupEntry ) + data class ListDartLifecycleCallbacksResponse ( + val setUpAlls: List, + val tearDownAlls: List + ) + data class RunDartTestRequest ( val name: String ) @@ -56,6 +65,10 @@ class Contracts { } } + data class SetLifecycleCallbacksStateRequest ( + val state: Map + ) + data class ConfigureRequest ( val findTimeoutMillis: Long ) @@ -247,4 +260,8 @@ class Contracts { val locationAccuracy: SetLocationAccuracyRequestLocationAccuracy ) + data class MarkLifecycleCallbackExecutedRequest ( + val name: String + ) + } diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/NativeAutomatorServer.kt b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/NativeAutomatorServer.kt index 617aee7cc..defb029aa 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/NativeAutomatorServer.kt +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/NativeAutomatorServer.kt @@ -47,6 +47,7 @@ abstract class NativeAutomatorServer { abstract fun setLocationAccuracy(request: Contracts.SetLocationAccuracyRequest) abstract fun debug() abstract fun markPatrolAppServiceReady() + abstract fun markLifecycleCallbackExecuted(request: Contracts.MarkLifecycleCallbackExecutedRequest) val router = routes( "initialize" bind POST to { @@ -200,6 +201,11 @@ abstract class NativeAutomatorServer { "markPatrolAppServiceReady" bind POST to { markPatrolAppServiceReady() Response(OK) + }, + "markLifecycleCallbackExecuted" bind POST to { + val body = json.fromJson(it.bodyString(), Contracts.MarkLifecycleCallbackExecutedRequest::class.java) + markLifecycleCallbackExecuted(body) + Response(OK) } ) diff --git a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/PatrolAppServiceClient.kt b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/PatrolAppServiceClient.kt index 3896e9f77..12bdc9bf8 100644 --- a/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/PatrolAppServiceClient.kt +++ b/packages/patrol/android/src/main/kotlin/pl/leancode/patrol/contracts/PatrolAppServiceClient.kt @@ -19,6 +19,16 @@ class PatrolAppServiceClient(address: String, port: Int, private val timeout: Lo return json.fromJson(response, Contracts.ListDartTestsResponse::class.java) } + fun listDartLifecycleCallbacks(): Contracts.ListDartLifecycleCallbacksResponse { + val response = performRequest("listDartLifecycleCallbacks") + return json.fromJson(response, Contracts.ListDartLifecycleCallbacksResponse::class.java) + } + + fun setLifecycleCallbacksState(request: Contracts.SetLifecycleCallbacksStateRequest): Contracts.Empty { + val response = performRequest("setLifecycleCallbacksState", json.toJson(request)) + return json.fromJson(response, Contracts.Empty::class.java) + } + fun runDartTest(request: Contracts.RunDartTestRequest): Contracts.RunDartTestResponse { val response = performRequest("runDartTest", json.toJson(request)) return json.fromJson(response, Contracts.RunDartTestResponse::class.java) diff --git a/packages/patrol/example/integration_test/internal/callbacks_all_test.dart b/packages/patrol/example/integration_test/internal/callbacks_all_test.dart new file mode 100644 index 000000000..b5b3654f2 --- /dev/null +++ b/packages/patrol/example/integration_test/internal/callbacks_all_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:patrol/src/extensions.dart'; +// ignore: depend_on_referenced_packages +import 'package:test_api/src/backend/invoker.dart'; + +import '../common.dart'; + +String get currentTest => Invoker.current!.fullCurrentTestName(); + +void _print(String text) => print('TEST_DEBUG: $text'); + +void main() { + group('parent', () { + patrolSetUpAll(() async { + await Future.delayed(Duration(seconds: 1)); + _print('ran patrolSetUpAll (1) before "$currentTest"'); + }); + + patrolSetUpAll(() async { + await Future.delayed(Duration(seconds: 1)); + _print('ran patrolSetUpAll (2) before "$currentTest"'); + }); + + patrolTest('testA', nativeAutomation: true, _body); + patrolTest('testB', nativeAutomation: true, _body); + patrolTest('testC', nativeAutomation: true, _body); + }); +} + +Future _body(PatrolTester $) async { + final testName = Invoker.current!.fullCurrentTestName(); + _print('ran body of test "$testName"'); + + await createApp($); + + await $(FloatingActionButton).tap(); + expect($(#counterText).text, '1'); + + await $(#textField).enterText(testName); +} diff --git a/packages/patrol/example/integration_test/callbacks_test.dart b/packages/patrol/example/integration_test/internal/callbacks_test.dart similarity index 69% rename from packages/patrol/example/integration_test/callbacks_test.dart rename to packages/patrol/example/integration_test/internal/callbacks_test.dart index 95d69ef4d..06fc129e8 100644 --- a/packages/patrol/example/integration_test/callbacks_test.dart +++ b/packages/patrol/example/integration_test/internal/callbacks_test.dart @@ -3,35 +3,36 @@ import 'package:patrol/src/extensions.dart'; // ignore: depend_on_referenced_packages import 'package:test_api/src/backend/invoker.dart'; -import 'common.dart'; +import '../common.dart'; String get currentTest => Invoker.current!.fullCurrentTestName(); -void _print(String text) => print('PATROL_DEBUG: $text'); +void _print(String text) => print('TEST_DEBUG: $text'); void main() { patrolSetUp(() async { await Future.delayed(Duration(seconds: 1)); - _print('setting up before $currentTest'); + _print('ran patrolSetUp (1) up before "$currentTest"'); }); patrolTearDown(() async { await Future.delayed(Duration(seconds: 1)); - _print('tearing down after $currentTest'); + _print('ran patrolTearDown (1) after "$currentTest"'); }); patrolTest('testFirst', nativeAutomation: true, _body); group('groupA', () { patrolSetUp(() async { - if (currentTest == 'callbacks_test groupA testB') { - throw Exception('PATROL_DEBUG: Crashing testB on purpose!'); + if (currentTest == 'internal.callbacks_test groupA testB') { + throw Exception('TEST_DEBUG: "$currentTest" crashed on purpose'); } - _print('setting up before $currentTest'); + + _print('ran patrolSetUp (2) before "$currentTest"'); }); patrolTearDown(() async { - _print('tearing down after $currentTest'); + _print('ran patrolTearDown (2) after "$currentTest"'); }); patrolTest('testA', nativeAutomation: true, _body); @@ -44,7 +45,7 @@ void main() { Future _body(PatrolTester $) async { final testName = Invoker.current!.fullCurrentTestName(); - _print('test body: name=$testName'); + _print('ran body of test "$testName"'); await createApp($); diff --git a/packages/patrol/example/ios/Podfile.lock b/packages/patrol/example/ios/Podfile.lock index 18e033a3c..5bd9677ea 100644 --- a/packages/patrol/example/ios/Podfile.lock +++ b/packages/patrol/example/ios/Podfile.lock @@ -70,4 +70,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: b2bb71756d032256bcb4043384dd40772d5e6a93 -COCOAPODS: 1.12.1 +COCOAPODS: 1.13.0 diff --git a/packages/patrol/ios/Classes/AutomatorServer/AutomatorServer.swift b/packages/patrol/ios/Classes/AutomatorServer/AutomatorServer.swift index 0f67dea3a..8c4cd1a36 100644 --- a/packages/patrol/ios/Classes/AutomatorServer/AutomatorServer.swift +++ b/packages/patrol/ios/Classes/AutomatorServer/AutomatorServer.swift @@ -5,11 +5,18 @@ final class AutomatorServer: NativeAutomatorServer { private let automator: Automator - private let onAppReady: (Bool) -> Void + private let onAppReady: () -> Void - init(automator: Automator, onAppReady: @escaping (Bool) -> Void) { + private let onDartLifecycleCallbackExecuted: (String) -> Void + + init( + automator: Automator, + onAppReady: @escaping () -> Void, + onDartLifecycleCallbackExecuted: @escaping (String) -> Void + ) { self.automator = automator self.onAppReady = onAppReady + self.onDartLifecycleCallbackExecuted = onDartLifecycleCallbackExecuted } func initialize() throws {} @@ -294,7 +301,11 @@ } func markPatrolAppServiceReady() throws { - onAppReady(true) + onAppReady() + } + + func markLifecycleCallbackExecuted(request: MarkLifecycleCallbackExecutedRequest) throws { + onDartLifecycleCallbackExecuted(request.name) } } diff --git a/packages/patrol/ios/Classes/AutomatorServer/Contracts.swift b/packages/patrol/ios/Classes/AutomatorServer/Contracts.swift index 7a7e4ac5c..543adcdc2 100644 --- a/packages/patrol/ios/Classes/AutomatorServer/Contracts.swift +++ b/packages/patrol/ios/Classes/AutomatorServer/Contracts.swift @@ -32,6 +32,10 @@ enum SetLocationAccuracyRequestLocationAccuracy: String, Codable { case fine } +struct Empty: Codable { + +} + struct DartGroupEntry: Codable { var name: String var type: GroupEntryType @@ -42,6 +46,11 @@ struct ListDartTestsResponse: Codable { var group: DartGroupEntry } +struct ListDartLifecycleCallbacksResponse: Codable { + var setUpAlls: [String] + var tearDownAlls: [String] +} + struct RunDartTestRequest: Codable { var name: String } @@ -51,6 +60,10 @@ struct RunDartTestResponse: Codable { var details: String? } +struct SetLifecycleCallbacksStateRequest: Codable { + var state: [String: Bool] +} + struct ConfigureRequest: Codable { var findTimeoutMillis: Int } @@ -165,3 +178,7 @@ struct SetLocationAccuracyRequest: Codable { var locationAccuracy: SetLocationAccuracyRequestLocationAccuracy } +struct MarkLifecycleCallbackExecutedRequest: Codable { + var name: String +} + diff --git a/packages/patrol/ios/Classes/AutomatorServer/NativeAutomatorServer.swift b/packages/patrol/ios/Classes/AutomatorServer/NativeAutomatorServer.swift index 6343bc8a8..4611b7385 100644 --- a/packages/patrol/ios/Classes/AutomatorServer/NativeAutomatorServer.swift +++ b/packages/patrol/ios/Classes/AutomatorServer/NativeAutomatorServer.swift @@ -42,6 +42,7 @@ protocol NativeAutomatorServer { func setLocationAccuracy(request: SetLocationAccuracyRequest) throws func debug() throws func markPatrolAppServiceReady() throws + func markLifecycleCallbackExecuted(request: MarkLifecycleCallbackExecutedRequest) throws } extension NativeAutomatorServer { @@ -233,6 +234,12 @@ extension NativeAutomatorServer { try markPatrolAppServiceReady() return HTTPResponse(.ok) } + + private func markLifecycleCallbackExecutedHandler(request: HTTPRequest) throws -> HTTPResponse { + let requestArg = try JSONDecoder().decode(MarkLifecycleCallbackExecutedRequest.self, from: request.body) + try markLifecycleCallbackExecuted(request: requestArg) + return HTTPResponse(.ok) + } } extension NativeAutomatorServer { @@ -407,6 +414,11 @@ extension NativeAutomatorServer { request: request, handler: markPatrolAppServiceReadyHandler) } + server.route(.POST, "markLifecycleCallbackExecuted") { + request in handleRequest( + request: request, + handler: markLifecycleCallbackExecutedHandler) + } } } diff --git a/packages/patrol/ios/Classes/AutomatorServer/PatrolAppServiceClient.swift b/packages/patrol/ios/Classes/AutomatorServer/PatrolAppServiceClient.swift index f6da06cbe..66ae8b542 100644 --- a/packages/patrol/ios/Classes/AutomatorServer/PatrolAppServiceClient.swift +++ b/packages/patrol/ios/Classes/AutomatorServer/PatrolAppServiceClient.swift @@ -17,20 +17,64 @@ class PatrolAppServiceClient { } func listDartTests(completion: @escaping (Result) -> Void) { - performRequest(requestName: "listDartTests", completion: completion) + performRequestWithResult(requestName: "listDartTests", completion: completion) + } + + func listDartLifecycleCallbacks(completion: @escaping (Result) -> Void) { + performRequestWithResult(requestName: "listDartLifecycleCallbacks", completion: completion) + } + + func setLifecycleCallbacksState(request: SetLifecycleCallbacksStateRequest, completion: @escaping (Result) -> Void) { + do { + let body = try JSONEncoder().encode(request) + performRequestWithResult(requestName: "setLifecycleCallbacksState", body: body, completion: completion) + } catch let err { + completion(.failure(err)) + } } func runDartTest(request: RunDartTestRequest, completion: @escaping (Result) -> Void) { do { let body = try JSONEncoder().encode(request) - performRequest(requestName: "runDartTest", body: body, completion: completion) + performRequestWithResult(requestName: "runDartTest", body: body, completion: completion) } catch let err { completion(.failure(err)) } } - private func performRequest( + private func performRequestWithResult( requestName: String, body: Data? = nil, completion: @escaping (Result) -> Void + ) { + performRequest(requestName: requestName, body: body) { result in + switch result { + case .success(let data): + do { + let object = try JSONDecoder().decode(TResult.self, from: data) + completion(.success(object)) + } catch let err { + completion(.failure(err)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + private func performRequestWithEmptyResult( + requestName: String, body: Data? = nil, completion: @escaping (Error?) -> Void + ) { + performRequest(requestName: requestName, body: body) { result in + switch result { + case .success(_): + completion(nil) + case .failure(let error): + completion(error) + } + } + } + + private func performRequest( + requestName: String, body: Data? = nil, completion: @escaping (Result) -> Void ) { let url = URL(string: "http://\(address):\(port)/\(requestName)")! @@ -47,12 +91,7 @@ class PatrolAppServiceClient { session.dataTask(with: request) { data, response, error in if (response as? HTTPURLResponse)?.statusCode == 200 { - do { - let object = try JSONDecoder().decode(TResult.self, from: data!) - completion(.success(object)) - } catch let err { - completion(.failure(err)) - } + completion(.success(data!)) } else { let message = "Invalid response: \(String(describing: response)) \(String(describing: data))" diff --git a/packages/patrol/ios/Classes/AutomatorServer/PatrolServer.swift b/packages/patrol/ios/Classes/AutomatorServer/PatrolServer.swift index 9c9b28d61..8841ae504 100644 --- a/packages/patrol/ios/Classes/AutomatorServer/PatrolServer.swift +++ b/packages/patrol/ios/Classes/AutomatorServer/PatrolServer.swift @@ -12,8 +12,9 @@ import Telegraph private let server: Server #endif - @objc - public private(set) var appReady = false + private let onAppReady: () -> Void + + private let onDartLifecycleCallbackExecuted: (String) -> Void private var passedPort: Int = { guard let portStr = ProcessInfo.processInfo.environment[envPortKey] else { @@ -31,7 +32,12 @@ import Telegraph return portInt }() - @objc public override init() { + @objc public init( + withOnAppReadyCallback onAppReady: @escaping () -> Void, + onDartLifecycleCallbackExecuted: @escaping (String) -> Void + ) { + self.onDartLifecycleCallbackExecuted = onDartLifecycleCallbackExecuted + self.onAppReady = onAppReady Logger.shared.i("PatrolServer constructor called") #if PATROL_ENABLED @@ -48,10 +54,18 @@ import Telegraph #if PATROL_ENABLED Logger.shared.i("Starting server...") - let provider = AutomatorServer(automator: automator) { appReady in - Logger.shared.i("App reported that it is ready") - self.appReady = appReady - } + let provider = AutomatorServer( + automator: automator, + onAppReady: { + Logger.shared.i("App reported that it is ready") + self.onAppReady() + }, + onDartLifecycleCallbackExecuted: { callback in + Logger.shared.i( + "App reported that Dart lifecycle callback \(format: callback) was executed") + self.onDartLifecycleCallbackExecuted(callback) + } + ) provider.setupRoutes(server: server) diff --git a/packages/patrol/ios/Classes/ObjCPatrolAppServiceClient.swift b/packages/patrol/ios/Classes/ObjCPatrolAppServiceClient.swift index 9b2b3290a..1c5c20c02 100644 --- a/packages/patrol/ios/Classes/ObjCPatrolAppServiceClient.swift +++ b/packages/patrol/ios/Classes/ObjCPatrolAppServiceClient.swift @@ -45,28 +45,53 @@ } } + @objc public func listDartLifecycleCallbacks( + completion: @escaping ([String]?, [String]?, Error?) -> Void + ) { + NSLog("PatrolAppService.listDartLifecycleCallbacks()") + + client.listDartLifecycleCallbacks { result in + switch result { + case .success(let result): + completion(result.setUpAlls, result.tearDownAlls, nil) + case .failure(let error): + completion(nil, nil, error) + } + } + } + + @objc public func setDartLifecycleCallbacksState( + _ state: [String: Bool], completion: @escaping (Error?) -> Void + ) { + NSLog("PatrolAppService.setDartLifecycleCallbacksState(\(state)") + + let request = SetLifecycleCallbacksStateRequest(state: state) + self.client.setLifecycleCallbacksState(request: request) { result in + switch result { + case .success(_): + completion(nil) + case .failure(let error): + completion(error) + } + } + } + @objc public func runDartTest( - name: String, completion: @escaping (ObjCRunDartTestResponse?, Error?) -> Void + _ name: String, completion: @escaping (ObjCRunDartTestResponse?, Error?) -> Void ) { - // TODO: simple workaround - patrolAppService starts running too slowly. - // We should wait for appReady in the dynamically created test case method, - // before calling runDartTest() (in PATROL_INTEGRATION_TEST_IOS_MACRO) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - NSLog("PatrolAppServiceClient.runDartTest(\(name))") - - let request = RunDartTestRequest(name: name) - self.client.runDartTest(request: request) { result in - switch result { - case .success(let result): - let testRespone = ObjCRunDartTestResponse( - passed: result.result == .success, - details: result.details - ) - completion(testRespone, nil) - case .failure(let error): - completion(nil, error) - } + NSLog("PatrolAppServiceClient.runDartTest(\(format: name))") + let request = RunDartTestRequest(name: name) + self.client.runDartTest(request: request) { result in + switch result { + case .success(let result): + let testRespone = ObjCRunDartTestResponse( + passed: result.result == .success, + details: result.details + ) + completion(testRespone, nil) + case .failure(let error): + completion(nil, error) } } } @@ -85,7 +110,7 @@ extension DartGroupEntry { // This case is invalid, because every test will have at least // 1 named group - its filename. - fatalError("Invariant violated: test \(test.name) has no named parent group") + fatalError("Invariant violated: test \(format: test.name) has no named parent group") } test.name = "\(parentGroupName) \(test.name)" diff --git a/packages/patrol/ios/Classes/PatrolIntegrationTestRunner.h b/packages/patrol/ios/Classes/PatrolIntegrationTestRunner.h index d7f6c0b05..172811a02 100644 --- a/packages/patrol/ios/Classes/PatrolIntegrationTestRunner.h +++ b/packages/patrol/ios/Classes/PatrolIntegrationTestRunner.h @@ -1,9 +1,9 @@ // This file is a one giant macro to make the setup as easy as possible for the developer. // To edit it: -// 1. Remove the trailing backslashes: $ sed 's/\\$//' ios/Classes/PatrolIntegrationTestRunner.h +// 1. Remove the trailing backslashes: $ sed 's/\\$//' PatrolIntegrationTestRunner.h // 2. Paste its contents into the RunnerUITests.m in the RunnerUITests target // 3. Make the changes, make sure it works -// 4. Re-add trailing backslashes: $ sed 's/$/\\/' ios/Classes/PatrolIntegrationTestRunner.h +// 4. Re-add trailing backslashes: $ sed 's/$/\\/' RunnerUITests.m // 5. Copy the contents from RunnerUITests.m back here // 6. Go back to using a macro in RunnerTests.m @@ -16,8 +16,20 @@ @implementation __test_class \ \ +(NSArray *)testInvocations { \ + __block NSMutableDictionary *callbacksState = NULL; \ + __block NSArray *dartTests = NULL; \ + __block BOOL appReady = NO; \ + \ /* Start native automation server */ \ - PatrolServer *server = [[PatrolServer alloc] init]; \ + PatrolServer *server = [[PatrolServer alloc] \ + initWithOnAppReadyCallback:^{ \ + appReady = YES; \ + } \ + onDartLifecycleCallbackExecuted:^(NSString *_Nonnull callbackName) { \ + /* callbacksState dictionary will have been already initialized when this callback is executed */ \ + NSLog(@"onLifecycleCallbackExecuted for %@", callbackName); \ + [callbacksState setObject:@YES forKey:callbackName]; \ + }]; \ \ NSError *_Nullable __autoreleasing *_Nullable err = NULL; \ [server startAndReturnError:err]; \ @@ -35,15 +47,41 @@ [systemAlerts.buttons[@"Allow"] tap]; \ } \ \ + /* MARK: Start initial run */ \ /* Run the app for the first time to gather Dart tests */ \ - [[[XCUIApplication alloc] init] launch]; \ + XCUIApplication *app = [[XCUIApplication alloc] init]; \ + NSDictionary *args = @{@"PATROL_INITIAL_RUN" : @"true"}; \ + app.launchEnvironment = args; \ + [app launch]; \ \ - /* Spin the runloop waiting until the app reports that it is ready to report Dart tests */ \ - while (!server.appReady) { \ + /* Spin the runloop waiting until the app reports that PatrolAppService is up */ \ + while (!appReady) { \ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; \ } \ \ - __block NSArray *dartTests = NULL; \ + /* MARK: List Dart lifecycle callbacks */ \ + \ + [appServiceClient listDartLifecycleCallbacksWithCompletion:^(NSArray *_Nullable setUpAlls, \ + NSArray *_Nullable tearDownAlls, \ + NSError *_Nullable err) { \ + if (err != NULL) { \ + NSLog(@"listDartLifecycleCallbacks(): failed, err: %@", err); \ + } \ + \ + callbacksState = [[NSMutableDictionary alloc] init]; \ + for (NSString * setUpAll in setUpAlls) { \ + [callbacksState setObject:@NO forKey:setUpAll]; \ + } \ + }]; \ + \ + /* Spin the runloop waiting until the app reports the Dart lifecycle callbacks it contains */ \ + while (!callbacksState) { \ + [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; \ + } \ + NSLog(@"Got %lu Dart lifecycle callbacks: %@", callbacksState.count, callbacksState); \ + \ + /* MARK: List Dart tests */ \ + \ [appServiceClient listDartTestsWithCompletion:^(NSArray *_Nullable tests, NSError *_Nullable err) { \ if (err != NULL) { \ NSLog(@"listDartTests(): failed, err: %@", err); \ @@ -56,12 +94,14 @@ while (!dartTests) { \ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; \ } \ - \ NSLog(@"Got %lu Dart tests: %@", dartTests.count, dartTests); \ \ + /* MARK: Create tests at runtime */ \ + \ NSMutableArray *invocations = [[NSMutableArray alloc] init]; \ \ /** \ + * \ * Once Dart tests are available, we: \ * \ * Step 1. Dynamically add test case methods that request execution of an individual Dart test file. \ @@ -73,25 +113,63 @@ /* Step 1 - dynamically create test cases */ \ \ IMP implementation = imp_implementationWithBlock(^(id _self) { \ - [[[XCUIApplication alloc] init] launch]; \ + /* Reset server's appReady state, because new app process will be started */ \ + appReady = NO; \ \ - __block ObjCRunDartTestResponse *response = NULL; \ - [appServiceClient runDartTestWithName:dartTest \ - completion:^(ObjCRunDartTestResponse *_Nullable r, NSError *_Nullable err) { \ - if (err != NULL) { \ - NSLog(@"runDartTestWithName(%@): failed, err: %@", dartTest, err); \ - } \ + XCUIApplication *app = [[XCUIApplication alloc] init]; \ + NSDictionary *args = @{@"PATROL_INITIAL_RUN" : @"false"}; \ + [app setLaunchEnvironment:args]; \ + [app launch]; \ + \ + /* Spin the runloop waiting until the app reports that PatrolAppService is up */ \ + while (!appReady) { \ + [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; \ + } \ \ - response = r; \ - }]; \ + __block BOOL callbacksSet = NO; \ + [appServiceClient \ + setDartLifecycleCallbacksState:callbacksState \ + completion:^(NSError *_Nullable err) { \ + if (err != NULL) { \ + NSLog(@"setDartLifecycleCallbacksState(): call failed, err: %@", err); \ + } \ \ - /* Wait until Dart test finishes */ \ - while (!response) { \ + NSLog(@"setDartLifecycleCallbacksState(): call succeeded"); \ + callbacksSet = YES; \ + }]; \ + \ + /*Wait until lifecycle callbacks are set*/ \ + while (!callbacksSet) { \ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; \ } \ \ - XCTAssertTrue(response.passed, @"%@", response.details); \ + __block ObjCRunDartTestResponse *response = NULL; \ + __block NSError *error; \ + [appServiceClient runDartTest:dartTest \ + completion:^(ObjCRunDartTestResponse *_Nullable r, NSError *_Nullable err) { \ + NSString *status; \ + if (err != NULL) { \ + error = err; \ + status = @"CRASHED"; \ + } else { \ + response = r; \ + status = response.passed ? @"PASSED" : @"FAILED"; \ + } \ + \ + NSLog(@"runDartTest(\"%@\"): call finished, test result: %@", dartTest, status); \ + }]; \ + \ + /* Wait until Dart test finishes (either fails or passes) or crashes */ \ + while (!response && !error) { \ + [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:1.0]]; \ + } \ + \ + BOOL passed = response ? response.passed : NO; \ + NSString *details = response ? response.details : @"(no details - app likely crashed)"; \ + \ + XCTAssertTrue(passed, @"%@", details); \ }); \ + \ NSString *selectorName = [PatrolUtils createMethodNameFromPatrolGeneratedGroup:dartTest]; \ SEL selector = NSSelectorFromString(selectorName); \ class_addMethod(self, selector, implementation, "v@:"); \ diff --git a/packages/patrol/ios/Classes/SwiftPatrolPlugin.swift b/packages/patrol/ios/Classes/SwiftPatrolPlugin.swift index c07ab9da1..0e99b7fff 100644 --- a/packages/patrol/ios/Classes/SwiftPatrolPlugin.swift +++ b/packages/patrol/ios/Classes/SwiftPatrolPlugin.swift @@ -2,10 +2,9 @@ import Flutter import UIKit let kChannelName = "pl.leancode.patrol/main" -let kMethodAllTestsFinished = "allTestsFinished" -let kErrorCreateChannelFailed = "create_channel_failed" -let kErrorCreateChannelFailedMsg = "Failed to create GRPC channel" +let kErrorInvalidValue = "invalid value" +let kErrorInvalidValueMsg = "isInitialRun env var is not a bool" /// A Flutter plugin that was responsible for communicating the test results back /// to iOS XCUITest. @@ -23,6 +22,22 @@ public class SwiftPatrolPlugin: NSObject, FlutterPlugin { } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - result(FlutterMethodNotImplemented) + switch call.method { + case "isInitialRun": + let rawInitialRun = ProcessInfo.processInfo.environment["PATROL_INITIAL_RUN"] + let initialRun = Bool(rawInitialRun ?? "invalid") + if initialRun == nil { + result( + FlutterError( + code: kErrorInvalidValue, + message: "PATROL_INITIAL_RUN value is invalid: \(String(describing: rawInitialRun))", + details: nil) + ) + } else { + result(initialRun) + } + default: + result(FlutterMethodNotImplemented) + } } } diff --git a/packages/patrol/lib/src/binding.dart b/packages/patrol/lib/src/binding.dart index a64eb18d1..e366dd8a9 100644 --- a/packages/patrol/lib/src/binding.dart +++ b/packages/patrol/lib/src/binding.dart @@ -6,8 +6,6 @@ import 'package:integration_test/integration_test.dart'; import 'package:patrol/patrol.dart'; import 'package:patrol/src/global_state.dart' as global_state; -import 'constants.dart' as constants; - const _success = 'success'; void _defaultPrintLogger(String message) { @@ -35,20 +33,20 @@ class PatrolBinding extends IntegrationTestWidgetsFlutterBinding { /// Creates a new [PatrolBinding]. /// /// You most likely don't want to call it yourself. - PatrolBinding() { + PatrolBinding(this.patrolAppService, this.nativeAutomator) { logger('created'); final oldTestExceptionReporter = reportTestException; reportTestException = (details, testDescription) { final currentDartTest = _currentDartTest; if (currentDartTest != null) { - assert(!constants.hotRestartEnabled); + assert(!global_state.hotRestartEnabled); _testResults[currentDartTest] = Failure(testDescription, '$details'); } oldTestExceptionReporter(details, testDescription); }; setUp(() { - if (constants.hotRestartEnabled) { + if (global_state.hotRestartEnabled) { // Sending results ends the test, which we don't want for Hot Restart return; } @@ -63,11 +61,17 @@ class PatrolBinding extends IntegrationTestWidgetsFlutterBinding { }); tearDown(() async { - if (constants.hotRestartEnabled) { + if (global_state.hotRestartEnabled) { // Sending results ends the test, which we don't want for Hot Restart return; } + if (await global_state.isInitialRun) { + // If this is the initial run, then no test has been requested to + // execute. Return to avoid blocking on didRequestTestExecution below. + return; + } + final testName = global_state.currentTestIndividualName; final isTestExplorer = testName == 'patrol_test_explorer'; if (isTestExplorer) { @@ -81,7 +85,8 @@ class PatrolBinding extends IntegrationTestWidgetsFlutterBinding { logger('tearDown(): test "$dartTestName": "$result"'); }); - final requestedDartTest = await patrolAppService.testExecutionRequested; + final requestedDartTest = await patrolAppService.didRequestTestExecution; + if (requestedDartTest == _currentDartTest) { logger( 'tearDown(): finished test "$_currentDartTest". Will report its status back to the native side', @@ -108,9 +113,12 @@ class PatrolBinding extends IntegrationTestWidgetsFlutterBinding { /// if necessary. /// /// This method is idempotent. - factory PatrolBinding.ensureInitialized() { + factory PatrolBinding.ensureInitialized( + PatrolAppService patrolAppService, + NativeAutomator nativeAutomator, + ) { if (_instance == null) { - PatrolBinding(); + PatrolBinding(patrolAppService, nativeAutomator); } return _instance!; } @@ -123,7 +131,11 @@ class PatrolBinding extends IntegrationTestWidgetsFlutterBinding { /// /// It's only for test reporting purposes and should not be used for anything /// else. - late PatrolAppService patrolAppService; + final PatrolAppService patrolAppService; + + /// The [NativeAutomator] used by this binding to interact with the native + /// side. + final NativeAutomator nativeAutomator; /// The singleton instance of this object. /// @@ -170,12 +182,12 @@ class PatrolBinding extends IntegrationTestWidgetsFlutterBinding { @override void attachRootWidget(Widget rootWidget) { assert( - (_currentDartTest != null) != (constants.hotRestartEnabled), + (_currentDartTest != null) != (global_state.hotRestartEnabled), '_currentDartTest can be null if and only if Hot Restart is enabled', ); const testLabelEnabled = bool.fromEnvironment('PATROL_TEST_LABEL_ENABLED'); - if (!testLabelEnabled || constants.hotRestartEnabled) { + if (!testLabelEnabled || global_state.hotRestartEnabled) { super.attachRootWidget(RepaintBoundary(child: rootWidget)); } else { super.attachRootWidget( diff --git a/packages/patrol/lib/src/common.dart b/packages/patrol/lib/src/common.dart index b6b479e00..4ed80dc7a 100644 --- a/packages/patrol/lib/src/common.dart +++ b/packages/patrol/lib/src/common.dart @@ -6,6 +6,7 @@ import 'package:integration_test/integration_test.dart'; import 'package:meta/meta.dart'; import 'package:patrol/src/binding.dart'; import 'package:patrol/src/global_state.dart' as global_state; +import 'package:patrol/src/logs.dart'; import 'package:patrol/src/native/contracts/contracts.dart'; import 'package:patrol/src/native/native.dart'; import 'package:patrol_finders/patrol_finders.dart' as finders; @@ -14,7 +15,6 @@ import 'package:test_api/src/backend/group.dart'; // ignore: implementation_imports import 'package:test_api/src/backend/test.dart'; -import 'constants.dart' as constants; import 'custom_finders/patrol_integration_tester.dart'; /// Signature for callback to [patrolTest]. @@ -23,6 +23,16 @@ typedef PatrolTesterCallback = Future Function(PatrolIntegrationTester $); /// A modification of [setUp] that works with Patrol's native automation. void patrolSetUp(Future Function() body) { setUp(() async { + if (global_state.hotRestartEnabled) { + await body(); + return; + } + + if (await global_state.isInitialRun) { + // Skip calling body if we're in test discovery phase + return; + } + final currentTest = global_state.currentTestFullName; final requestedToExecute = await PatrolBinding.instance.patrolAppService @@ -37,6 +47,16 @@ void patrolSetUp(Future Function() body) { /// A modification of [tearDown] that works with Patrol's native automation. void patrolTearDown(Future Function() body) { tearDown(() async { + if (global_state.hotRestartEnabled) { + await body(); + return; + } + + if (await global_state.isInitialRun) { + // Skip calling body if we're in test discovery phase + return; + } + final currentTest = global_state.currentTestFullName; final requestedToExecute = await PatrolBinding.instance.patrolAppService @@ -48,7 +68,55 @@ void patrolTearDown(Future Function() body) { }); } -/// Like [testWidgets], but with support for Patrol custom finders. +/// A modification of [setUpAll] that works with Patrol's native automation. +/// +/// It keeps track of calls made to setUpAll. +void patrolSetUpAll(Future Function() body) { + setUpAll(() async { + final patrolAppService = PatrolBinding.instance.patrolAppService; + final parentGroupsName = global_state.currentGroupFullName; + final setUpAllName = patrolAppService.addSetUpAll(parentGroupsName); + + if (await global_state.isInitialRun) { + // Skip calling body if we're in test discovery phase + patrolDebug( + 'skipping setUpAll "$setUpAllName" because we are in the initial run', + ); + return; + } + + patrolDebug('Waiting for lifecycle callbacks state...'); + final callbacksState = + await patrolAppService.didReceiveLifecycleCallbacksState; + + assert( + callbacksState[setUpAllName] != null, + 'setUpAll "$setUpAllName" was not registered in PatrolAppService. This looks very nasty.', + ); + + if (callbacksState[setUpAllName] ?? false) { + // Skip calling body if this setUpAll was already executed + patrolDebug('skipping setUpAll "$setUpAllName" because it already ran'); + return; + } + + final requestedTest = await patrolAppService.didRequestTestExecution; + + // Skip calling if parentGroupName is not a substring of requestedTestName + if (!requestedTest.startsWith(parentGroupsName)) { + // This is not exhaustive. + return; + } + + // Mark this setUpAll as executed + final nativeAutomator = PatrolBinding.instance.nativeAutomator; + await nativeAutomator.markLifecycleCallbackExecuted(setUpAllName); + + await body(); + }); +} + +/// Like [testWidgets], but with support for Patrol custom fiders. /// /// To customize the Patrol-specific configuration, set [config]. /// @@ -84,8 +152,6 @@ void patrolTest( LiveTestWidgetsFlutterBindingFramePolicy framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fadePointers, }) { - NativeAutomator? automator; - if (!nativeAutomation) { debugPrint(''' ╔════════════════════════════════════════════════════════════════════════════════════╗ @@ -100,8 +166,6 @@ void patrolTest( if (nativeAutomation) { switch (bindingType) { case BindingType.patrol: - automator = NativeAutomator(config: nativeAutomatorConfig); - // PatrolBinding is initialized in the generated test bundle file. PatrolBinding.instance.framePolicy = framePolicy; break; @@ -123,7 +187,17 @@ void patrolTest( variant: variant, tags: tags, (widgetTester) async { - if (!constants.hotRestartEnabled) { + if (!global_state.hotRestartEnabled) { + if (await global_state.isInitialRun) { + patrolDebug( + 'skippng test "${global_state.currentTestFullName}" because this is the initial run', + ); + // Fall through tests during the initial run that discovers tests. + // + // This is required to be able to find all setUpAll callbacks. + return; + } + // If Patrol's native automation feature is enabled, then this test will // be executed only if the native side requested it to be executed. // Otherwise, it returns early. @@ -136,6 +210,7 @@ void patrolTest( return; } } + if (!kIsWeb && io.Platform.isIOS) { widgetTester.binding.platformDispatcher.onSemanticsEnabledChanged = () { // This callback is empty on purpose. It's a workaround for tests @@ -144,11 +219,11 @@ void patrolTest( // See https://github.com/leancodepl/patrol/issues/1474 }; } - await automator?.configure(); + await PatrolBinding.instance.nativeAutomator.configure(); final patrolTester = PatrolIntegrationTester( tester: widgetTester, - nativeAutomator: automator, + nativeAutomator: PatrolBinding.instance.nativeAutomator, config: config, ); await callback(patrolTester); diff --git a/packages/patrol/lib/src/constants.dart b/packages/patrol/lib/src/constants.dart deleted file mode 100644 index 01440e6ce..000000000 --- a/packages/patrol/lib/src/constants.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:meta/meta.dart'; - -/// Whether Hot Restart is enabled. -@internal -const bool hotRestartEnabled = bool.fromEnvironment('PATROL_HOT_RESTART'); diff --git a/packages/patrol/lib/src/global_state.dart b/packages/patrol/lib/src/global_state.dart index 318574d3e..a4479ce1a 100644 --- a/packages/patrol/lib/src/global_state.dart +++ b/packages/patrol/lib/src/global_state.dart @@ -1,3 +1,5 @@ +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart'; // ignore: implementation_imports import 'package:test_api/src/backend/invoker.dart'; @@ -6,6 +8,10 @@ import 'package:test_api/src/backend/invoker.dart'; /// See https://github.com/leancodepl/patrol/issues/1725 const maxTestLength = 190; +/// Whether Hot Restart is enabled. +@internal +const bool hotRestartEnabled = bool.fromEnvironment('PATROL_HOT_RESTART'); + /// This file wraps the [Invoker] API, which is internal to package:test. We /// want to minimize the usage of internal APIs to a minimum. @@ -24,7 +30,16 @@ String get currentTestFullName { return nameCandidate; } -/// Returns the individual name of the current test. Omits all ancestor groups. +/// Returns the name of the current group. +/// +/// Includes all ancestor groups. +String get currentGroupFullName { + return Invoker.current!.liveTest.groups.last.name; +} + +/// Returns the individual name of the current test. +/// +/// Omits all ancestor groups. String get currentTestIndividualName { return Invoker.current!.liveTest.individualName; } @@ -33,3 +48,11 @@ String get currentTestIndividualName { bool get isCurrentTestPassing { return Invoker.current!.liveTest.state.result.isPassing; } + +const _channel = MethodChannel('pl.leancode.patrol/main'); + +/// Returns whether this is the first run of the app under test during which +/// test discovery happens. +Future get isInitialRun async { + return await _channel.invokeMethod('isInitialRun') as bool; +} diff --git a/packages/patrol/lib/src/logs.dart b/packages/patrol/lib/src/logs.dart new file mode 100644 index 000000000..474b7d31f --- /dev/null +++ b/packages/patrol/lib/src/logs.dart @@ -0,0 +1,14 @@ +import 'package:meta/meta.dart'; + +final _runKey = Object().hashCode.toRadixString(16).padLeft(4, '0'); + +/// Logs a message with a tag that's constant in the single app process +/// instance. +/// +/// Helps to differentiate same logs that happen in different app process runs. +@internal +void patrolDebug(String message) { + // TODO: Enable only if debug dart define is passed + // ignore: avoid_print + print('PATROL_DEBUG($_runKey): $message'); +} diff --git a/packages/patrol/lib/src/native/contracts/contracts.dart b/packages/patrol/lib/src/native/contracts/contracts.dart index 27fb69c01..6bfbb5770 100644 --- a/packages/patrol/lib/src/native/contracts/contracts.dart +++ b/packages/patrol/lib/src/native/contracts/contracts.dart @@ -48,6 +48,18 @@ enum SetLocationAccuracyRequestLocationAccuracy { fine } +@JsonSerializable() +class Empty with EquatableMixin { + Empty(); + + factory Empty.fromJson(Map json) => _$EmptyFromJson(json); + + Map toJson() => _$EmptyToJson(this); + + @override + List get props => const []; +} + @JsonSerializable() class DartGroupEntry with EquatableMixin { DartGroupEntry({ @@ -92,6 +104,31 @@ class ListDartTestsResponse with EquatableMixin { ]; } +@JsonSerializable() +class ListDartLifecycleCallbacksResponse with EquatableMixin { + ListDartLifecycleCallbacksResponse({ + required this.setUpAlls, + required this.tearDownAlls, + }); + + factory ListDartLifecycleCallbacksResponse.fromJson( + Map json, + ) => + _$ListDartLifecycleCallbacksResponseFromJson(json); + + final List setUpAlls; + final List tearDownAlls; + + Map toJson() => + _$ListDartLifecycleCallbacksResponseToJson(this); + + @override + List get props => [ + setUpAlls, + tearDownAlls, + ]; +} + @JsonSerializable() class RunDartTestRequest with EquatableMixin { RunDartTestRequest({ @@ -133,6 +170,28 @@ class RunDartTestResponse with EquatableMixin { ]; } +@JsonSerializable() +class SetLifecycleCallbacksStateRequest with EquatableMixin { + SetLifecycleCallbacksStateRequest({ + required this.state, + }); + + factory SetLifecycleCallbacksStateRequest.fromJson( + Map json, + ) => + _$SetLifecycleCallbacksStateRequestFromJson(json); + + final Map state; + + Map toJson() => + _$SetLifecycleCallbacksStateRequestToJson(this); + + @override + List get props => [ + state, + ]; +} + @JsonSerializable() class ConfigureRequest with EquatableMixin { ConfigureRequest({ @@ -603,3 +662,25 @@ class SetLocationAccuracyRequest with EquatableMixin { locationAccuracy, ]; } + +@JsonSerializable() +class MarkLifecycleCallbackExecutedRequest with EquatableMixin { + MarkLifecycleCallbackExecutedRequest({ + required this.name, + }); + + factory MarkLifecycleCallbackExecutedRequest.fromJson( + Map json, + ) => + _$MarkLifecycleCallbackExecutedRequestFromJson(json); + + final String name; + + Map toJson() => + _$MarkLifecycleCallbackExecutedRequestToJson(this); + + @override + List get props => [ + name, + ]; +} diff --git a/packages/patrol/lib/src/native/contracts/contracts.g.dart b/packages/patrol/lib/src/native/contracts/contracts.g.dart index 02a6c767f..7fec943ac 100644 --- a/packages/patrol/lib/src/native/contracts/contracts.g.dart +++ b/packages/patrol/lib/src/native/contracts/contracts.g.dart @@ -6,6 +6,10 @@ part of 'contracts.dart'; // JsonSerializableGenerator // ************************************************************************** +Empty _$EmptyFromJson(Map json) => Empty(); + +Map _$EmptyToJson(Empty instance) => {}; + DartGroupEntry _$DartGroupEntryFromJson(Map json) => DartGroupEntry( name: json['name'] as String, @@ -39,6 +43,23 @@ Map _$ListDartTestsResponseToJson( 'group': instance.group, }; +ListDartLifecycleCallbacksResponse _$ListDartLifecycleCallbacksResponseFromJson( + Map json) => + ListDartLifecycleCallbacksResponse( + setUpAlls: + (json['setUpAlls'] as List).map((e) => e as String).toList(), + tearDownAlls: (json['tearDownAlls'] as List) + .map((e) => e as String) + .toList(), + ); + +Map _$ListDartLifecycleCallbacksResponseToJson( + ListDartLifecycleCallbacksResponse instance) => + { + 'setUpAlls': instance.setUpAlls, + 'tearDownAlls': instance.tearDownAlls, + }; + RunDartTestRequest _$RunDartTestRequestFromJson(Map json) => RunDartTestRequest( name: json['name'] as String, @@ -68,6 +89,18 @@ const _$RunDartTestResponseResultEnumMap = { RunDartTestResponseResult.failure: 'failure', }; +SetLifecycleCallbacksStateRequest _$SetLifecycleCallbacksStateRequestFromJson( + Map json) => + SetLifecycleCallbacksStateRequest( + state: Map.from(json['state'] as Map), + ); + +Map _$SetLifecycleCallbacksStateRequestToJson( + SetLifecycleCallbacksStateRequest instance) => + { + 'state': instance.state, + }; + ConfigureRequest _$ConfigureRequestFromJson(Map json) => ConfigureRequest( findTimeoutMillis: json['findTimeoutMillis'] as int, @@ -374,3 +407,15 @@ const _$SetLocationAccuracyRequestLocationAccuracyEnumMap = { SetLocationAccuracyRequestLocationAccuracy.coarse: 'coarse', SetLocationAccuracyRequestLocationAccuracy.fine: 'fine', }; + +MarkLifecycleCallbackExecutedRequest + _$MarkLifecycleCallbackExecutedRequestFromJson(Map json) => + MarkLifecycleCallbackExecutedRequest( + name: json['name'] as String, + ); + +Map _$MarkLifecycleCallbackExecutedRequestToJson( + MarkLifecycleCallbackExecutedRequest instance) => + { + 'name': instance.name, + }; diff --git a/packages/patrol/lib/src/native/contracts/native_automator_client.dart b/packages/patrol/lib/src/native/contracts/native_automator_client.dart index ae1c10d6a..70a245d87 100644 --- a/packages/patrol/lib/src/native/contracts/native_automator_client.dart +++ b/packages/patrol/lib/src/native/contracts/native_automator_client.dart @@ -294,6 +294,15 @@ class NativeAutomatorClient { ); } + Future markLifecycleCallbackExecuted( + MarkLifecycleCallbackExecutedRequest request, + ) { + return _sendRequest( + 'markLifecycleCallbackExecuted', + request.toJson(), + ); + } + Future> _sendRequest( String requestName, [ Map? request, diff --git a/packages/patrol/lib/src/native/contracts/patrol_app_service_server.dart b/packages/patrol/lib/src/native/contracts/patrol_app_service_server.dart index dca57118d..55b631b60 100644 --- a/packages/patrol/lib/src/native/contracts/patrol_app_service_server.dart +++ b/packages/patrol/lib/src/native/contracts/patrol_app_service_server.dart @@ -16,6 +16,22 @@ abstract class PatrolAppServiceServer { if ('listDartTests' == request.url.path) { final result = await listDartTests(); + final body = jsonEncode(result.toJson()); + return Response.ok(body); + } else if ('listDartLifecycleCallbacks' == request.url.path) { + final result = await listDartLifecycleCallbacks(); + + final body = jsonEncode(result.toJson()); + return Response.ok(body); + } else if ('setLifecycleCallbacksState' == request.url.path) { + final stringContent = await request.readAsString(utf8); + final json = jsonDecode(stringContent); + final requestObj = SetLifecycleCallbacksStateRequest.fromJson( + json as Map, + ); + + final result = await setLifecycleCallbacksState(requestObj); + final body = jsonEncode(result.toJson()); return Response.ok(body); } else if ('runDartTest' == request.url.path) { @@ -34,5 +50,9 @@ abstract class PatrolAppServiceServer { } Future listDartTests(); + Future listDartLifecycleCallbacks(); + Future setLifecycleCallbacksState( + SetLifecycleCallbacksStateRequest request, + ); Future runDartTest(RunDartTestRequest request); } diff --git a/packages/patrol/lib/src/native/native_automator.dart b/packages/patrol/lib/src/native/native_automator.dart index cde6bfaf5..493890a85 100644 --- a/packages/patrol/lib/src/native/native_automator.dart +++ b/packages/patrol/lib/src/native/native_automator.dart @@ -769,7 +769,7 @@ class NativeAutomator { ); } - /// Tells the AndroidJUnitRunner that PatrolAppService is ready to answer + /// Tells the native test runner that PatrolAppService is ready to answer /// requests about the structure of Dart tests. @internal Future markPatrolAppServiceReady() async { @@ -778,4 +778,17 @@ class NativeAutomator { _client.markPatrolAppServiceReady, ); } + + /// Tells the native test runner that callback [name] has been executed. + /// + /// The native test runner persists that information. + @internal + Future markLifecycleCallbackExecuted(String name) async { + await _wrapRequest( + 'markLifecycleCallbackExecuted', + () => _client.markLifecycleCallbackExecuted( + MarkLifecycleCallbackExecutedRequest(name: name), + ), + ); + } } diff --git a/packages/patrol/lib/src/native/patrol_app_service.dart b/packages/patrol/lib/src/native/patrol_app_service.dart index f67806ff7..eb8a83f31 100644 --- a/packages/patrol/lib/src/native/patrol_app_service.dart +++ b/packages/patrol/lib/src/native/patrol_app_service.dart @@ -10,6 +10,8 @@ import 'package:patrol/src/native/contracts/contracts.dart'; import 'package:patrol/src/native/contracts/patrol_app_service_server.dart'; import 'package:shelf/shelf.dart' as shelf; import 'package:shelf/shelf_io.dart' as shelf_io; +// ignore: implementation_imports +import 'package:test_api/src/backend/live_test.dart'; const _port = 8082; const _idleTimeout = Duration(hours: 2); @@ -43,34 +45,78 @@ Future runAppService(PatrolAppService service) async { ); } -/// Implements a stateful gRPC service for querying and executing Dart tests. +/// Implements a stateful HTTP service for querying and executing Dart tests. /// /// This is an internal class and you don't want to use it. It's public so that /// the generated code can access it. class PatrolAppService extends PatrolAppServiceServer { /// Creates a new [PatrolAppService]. - PatrolAppService({required this.topLevelDartTestGroup}); + PatrolAppService(); /// The ambient test group that wraps all the other groups and tests in the /// bundled Dart test file. - final DartGroupEntry topLevelDartTestGroup; + DartGroupEntry? topLevelDartTestGroup; + + /// The names of all setUpAll callbacks. + /// + /// setUpAlls, unlike setUps, aren't executed in the [LiveTest] context. + /// Because of this, we can't depend on the [LiveTest]'s name, so we identify + /// them by the parent group ID instead. + /// + /// This is a list of groups containing setUpAllCallbacks with an index + /// appended. + List setUpAlls = []; + + final _didReceiveLifecycleCallbacksState = Completer>(); + + /// A completer that completes when the native side sets the state of + /// lifecycle callbacks. + /// + /// Completes with a map that knows which lifecycle callbacks have been + /// already executed. + Future> get didReceiveLifecycleCallbacksState { + return _didReceiveLifecycleCallbacksState.future; + } /// A completer that completes with the name of the Dart test file that was /// requested to execute by the native side. - final _testExecutionRequested = Completer(); + final _didRequestTestExecution = Completer(); /// A future that completes with the name of the Dart test file that was /// requested to execute by the native side. - Future get testExecutionRequested => _testExecutionRequested.future; + Future get didRequestTestExecution => _didRequestTestExecution.future; - final _testExecutionCompleted = Completer<_TestExecutionResult>(); + final _didCompleteTestExecution = Completer<_TestExecutionResult>(); /// A future that completes when the Dart test file (whose execution was /// requested by the native side) completes. /// /// Returns true if the test passed, false otherwise. - Future<_TestExecutionResult> get testExecutionCompleted { - return _testExecutionCompleted.future; + Future<_TestExecutionResult> get didCompleteTestExecution { + return _didCompleteTestExecution.future; + } + + /// Adds a setUpAll callback to the list of all setUpAll callbacks. + /// + /// Returns the name under which this setUpAll callback was added. + String addSetUpAll(String group) { + // Not optimal, but good enough for now. + + // Go over all groups, checking if the group is already in the list. + var groupIndex = 0; + for (final setUpAll in setUpAlls) { + final parts = setUpAll.split(' '); + final groupName = parts.sublist(0, parts.length - 1).join(' '); + + if (groupName == group) { + groupIndex++; + } + } + + final name = '$group $groupIndex'; + + setUpAlls.add(name); + return name; } /// Marks [dartFileName] as completed with the given [passed] status. @@ -83,19 +129,20 @@ class PatrolAppService extends PatrolAppServiceServer { required String? details, }) async { print('PatrolAppService.markDartTestAsCompleted(): $dartFileName'); + assert( - _testExecutionRequested.isCompleted, + _didRequestTestExecution.isCompleted, 'Tried to mark a test as completed, but no tests were requested to run', ); - final requestedDartTestName = await testExecutionRequested; + final requestedDartTestName = await didRequestTestExecution; assert( requestedDartTestName == dartFileName, 'Tried to mark test $dartFileName as completed, but the test ' 'that was most recently requested to run was $requestedDartTestName', ); - _testExecutionCompleted.complete( + _didCompleteTestExecution.complete( _TestExecutionResult(passed: passed, details: details), ); } @@ -112,7 +159,7 @@ class PatrolAppService extends PatrolAppServiceServer { Future waitForExecutionRequest(String dartTest) async { print('PatrolAppService: registered "$dartTest"'); - final requestedDartTest = await testExecutionRequested; + final requestedDartTest = await didRequestTestExecution; if (requestedDartTest != dartTest) { // If the requested Dart test is not the one we're waiting for now, it // means that dartTest was already executed. Return false so that callers @@ -133,18 +180,26 @@ class PatrolAppService extends PatrolAppServiceServer { @override Future listDartTests() async { print('PatrolAppService.listDartTests() called'); + + final topLevelDartTestGroup = this.topLevelDartTestGroup; + if (topLevelDartTestGroup == null) { + throw StateError( + 'PatrolAppService.listDartTests(): tests not discovered yet', + ); + } + return ListDartTestsResponse(group: topLevelDartTestGroup); } @override Future runDartTest(RunDartTestRequest request) async { - assert(_testExecutionCompleted.isCompleted == false); - // patrolTest() always calls this method. - print('PatrolAppService.runDartTest(${request.name}) called'); - _testExecutionRequested.complete(request.name); - final testExecutionResult = await testExecutionCompleted; + assert(_didCompleteTestExecution.isCompleted == false); + + _didRequestTestExecution.complete(request.name); + + final testExecutionResult = await didCompleteTestExecution; return RunDartTestResponse( result: testExecutionResult.passed ? RunDartTestResponseResult.success @@ -152,4 +207,32 @@ class PatrolAppService extends PatrolAppServiceServer { details: testExecutionResult.details, ); } + + @override + Future + listDartLifecycleCallbacks() async { + print('PatrolAppService.listDartLifecycleCallbacks() called'); + + assert(_didRequestTestExecution.isCompleted == false); + assert(_didCompleteTestExecution.isCompleted == false); + assert(_didReceiveLifecycleCallbacksState.isCompleted == false); + + return ListDartLifecycleCallbacksResponse( + setUpAlls: setUpAlls, + tearDownAlls: [], + ); + } + + @override + Future setLifecycleCallbacksState( + SetLifecycleCallbacksStateRequest request, + ) async { + print('PatrolAppService.setLifecycleCallbacksState() called'); + assert(_didRequestTestExecution.isCompleted == false); + assert(_didCompleteTestExecution.isCompleted == false); + assert(_didReceiveLifecycleCallbacksState.isCompleted == false); + + _didReceiveLifecycleCallbacksState.complete(request.state); + return Empty(); + } } diff --git a/packages/patrol_cli/lib/src/test_bundler.dart b/packages/patrol_cli/lib/src/test_bundler.dart index 472eb66b9..656a15e86 100644 --- a/packages/patrol_cli/lib/src/test_bundler.dart +++ b/packages/patrol_cli/lib/src/test_bundler.dart @@ -28,6 +28,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:patrol/patrol.dart'; +import 'package:patrol/src/global_state.dart' as global_state; import 'package:patrol/src/native/contracts/contracts.dart'; import 'package:test_api/src/backend/invoker.dart'; @@ -70,8 +71,14 @@ Future main() async { final nativeAutomator = NativeAutomator(config: NativeAutomatorConfig()); await nativeAutomator.initialize(); - final binding = PatrolBinding.ensureInitialized(); - final testExplorationCompleter = Completer(); + + final appService = PatrolAppService(); + await runAppService(appService); + + PatrolBinding.ensureInitialized(appService, nativeAutomator); + + final didExploreTests = Completer(); + final didExploreLifecycleCallbacks = Completer(); // A special test to expore the hierarchy of groups and tests. This is a hack // around https://github.com/dart-lang/test/issues/1998. @@ -83,7 +90,7 @@ Future main() async { // to group() below. final topLevelGroup = Invoker.current!.liveTest.groups.first; final dartTestGroup = createDartTestGroup(topLevelGroup); - testExplorationCompleter.complete(dartTestGroup); + didExploreTests.complete(dartTestGroup); print('patrol_test_explorer: obtained Dart-side test hierarchy:'); printGroupStructure(dartTestGroup); }); @@ -92,17 +99,25 @@ Future main() async { ${generateGroupsCode(testFilePaths).split('\n').map((e) => ' $e').join('\n')} // END: GENERATED TEST GROUPS - final dartTestGroup = await testExplorationCompleter.future; - final appService = PatrolAppService(topLevelDartTestGroup: dartTestGroup); - binding.patrolAppService = appService; - await runAppService(appService); + // An additional callback to discover setUpAlls. + tearDownAll(() async { + if (await global_state.isInitialRun) { + didExploreLifecycleCallbacks.complete(); + } + }); + + appService.topLevelDartTestGroup = await didExploreTests.future; + + if (await global_state.isInitialRun) { + await didExploreLifecycleCallbacks.future; + } // Until now, the native test runner was waiting for us, the Dart side, to // come alive. Now that we did, let's tell it that we're ready to be asked // about Dart tests. await nativeAutomator.markPatrolAppServiceReady(); - await appService.testExecutionCompleted; + await appService.didCompleteTestExecution; } '''; @@ -138,7 +153,11 @@ ${generateImports([testFilePath])} Future main() async { final nativeAutomator = NativeAutomator(config: NativeAutomatorConfig()); await nativeAutomator.initialize(); - PatrolBinding.ensureInitialized(); + + final appService = PatrolAppService(); + await runAppService(appService); + + PatrolBinding.ensureInitialized(appService, nativeAutomator); // START: GENERATED TEST GROUPS ${generateGroupsCode([testFilePath]).split('\n').map((e) => ' $e').join('\n')} diff --git a/packages/patrol_cli/lib/src/test_runner.dart b/packages/patrol_cli/lib/src/test_runner.dart index cf96acd44..74e3df8d5 100644 --- a/packages/patrol_cli/lib/src/test_runner.dart +++ b/packages/patrol_cli/lib/src/test_runner.dart @@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart'; import 'package:path/path.dart' show basename; import 'package:patrol_cli/src/devices.dart'; -// TODO(bartekpacia): Lots of this code is not needed after #1004 is done. +// TODO: Lots of this code is not needed after #1004 is done. enum TargetRunStatus { failedToBuild, failedToExecute, passed, canceled } diff --git a/packages/patrol_gen/lib/src/generators/android/android_contracts_generator.dart b/packages/patrol_gen/lib/src/generators/android/android_contracts_generator.dart index c2a1ca671..efbfabd1c 100644 --- a/packages/patrol_gen/lib/src/generators/android/android_contracts_generator.dart +++ b/packages/patrol_gen/lib/src/generators/android/android_contracts_generator.dart @@ -39,9 +39,14 @@ package ${config.package}; String _createMessage(Message message) { final fields = message.fields.map((e) { final optional = e.isOptional ? '? = null' : ''; - return e.isList - ? ' val ${e.name}: List<${_transformType(e.type)}>$optional' - : ' val ${e.name}: ${_transformType(e.type)}$optional'; + return switch (e.type) { + MapFieldType(keyType: final keyType, valueType: final valueType) => + ' val ${e.name}: Map<${_transformType(keyType)}, ${_transformType(valueType)}>$optional', + ListFieldType(type: final type) => + ' val ${e.name}: List<${_transformType(type)}>$optional', + OrdinaryFieldType(type: final type) => + ' val ${e.name}: ${_transformType(type)}$optional', + }; }).join(',\n'); final dataKeyword = fields.isNotEmpty ? 'data ' : ''; diff --git a/packages/patrol_gen/lib/src/generators/android/android_http4k_client_generator.dart b/packages/patrol_gen/lib/src/generators/android/android_http4k_client_generator.dart index d4ce57e3a..e1e42b9c0 100644 --- a/packages/patrol_gen/lib/src/generators/android/android_http4k_client_generator.dart +++ b/packages/patrol_gen/lib/src/generators/android/android_http4k_client_generator.dart @@ -98,7 +98,7 @@ $endpoints val response = performRequest("${endpoint.name}"$serializeParameter) return json.fromJson(response, Contracts.${endpoint.response!.name}::class.java)''' : ''' - return performRequest("${endpoint.name}"$serializeParameter)'''; + performRequest("${endpoint.name}"$serializeParameter)'''; return ''' fun ${endpoint.name}($parameterDef)$returnDef { diff --git a/packages/patrol_gen/lib/src/generators/dart/dart_contracts_generator.dart b/packages/patrol_gen/lib/src/generators/dart/dart_contracts_generator.dart index 7a467ea6b..5898293f9 100644 --- a/packages/patrol_gen/lib/src/generators/dart/dart_contracts_generator.dart +++ b/packages/patrol_gen/lib/src/generators/dart/dart_contracts_generator.dart @@ -53,9 +53,14 @@ enum ${enumDefinition.name} { String? _createMessage(Message message) { final fieldsContent = message.fields .map( - (f) => f.isList - ? 'final List<${f.type}>${f.isOptional ? '?' : ''} ${f.name};' - : 'final ${f.type}${f.isOptional ? '?' : ''} ${f.name};', + (f) => switch (f.type) { + ListFieldType(type: final type) => + 'final List<$type>${f.isOptional ? '?' : ''} ${f.name};', + MapFieldType(keyType: final keyType, valueType: final valueType) => + 'final Map<$keyType,$valueType>${f.isOptional ? '?' : ''} ${f.name};', + OrdinaryFieldType(type: final type) => + 'final $type${f.isOptional ? '?' : ''} ${f.name};' + }, ) .join('\n'); diff --git a/packages/patrol_gen/lib/src/generators/dart/dart_shelf_server_generator.dart b/packages/patrol_gen/lib/src/generators/dart/dart_shelf_server_generator.dart index a4a06da53..861816b9a 100644 --- a/packages/patrol_gen/lib/src/generators/dart/dart_shelf_server_generator.dart +++ b/packages/patrol_gen/lib/src/generators/dart/dart_shelf_server_generator.dart @@ -39,7 +39,6 @@ import '${path.basename(config.contractsFilename)}'; String _generateHandlerCalls(Service service) { return service.endpoints.map((e) { var requestDeserialization = ''; - var responseSerialization = ''; if (e.request != null) { requestDeserialization = ''' @@ -49,16 +48,19 @@ final requestObj = ${e.request!.name}.fromJson(json as Map); '''; } - final handlerCall = e.request != null - ? 'final result = await ${e.name}(requestObj);' - : 'final result = await ${e.name}();'; - + var handlerCall = e.request != null + ? 'await ${e.name}(requestObj);' + : 'await ${e.name}();'; if (e.response != null) { - responseSerialization = ''' -final body = jsonEncode(result.toJson()); -return Response.ok(body);'''; + handlerCall = 'final result = $handlerCall'; } + final responseSerialization = e.response != null + ? ''' +final body = jsonEncode(result.toJson()); +return Response.ok(body);''' + : "return Response.ok('');"; + final elseKeyword = e == service.endpoints.first ? '' : 'else'; return ''' diff --git a/packages/patrol_gen/lib/src/generators/ios/ios_contracts_generator.dart b/packages/patrol_gen/lib/src/generators/ios/ios_contracts_generator.dart index d6288532d..1f1fd9392 100644 --- a/packages/patrol_gen/lib/src/generators/ios/ios_contracts_generator.dart +++ b/packages/patrol_gen/lib/src/generators/ios/ios_contracts_generator.dart @@ -34,9 +34,14 @@ class IOSContractsGenerator { String _createMessage(Message message) { final fields = message.fields.map((e) { final optional = e.isOptional ? '?' : ''; - return e.isList - ? ' var ${e.name}: [${_transformType(e.type)}]$optional' - : ' var ${e.name}: ${_transformType(e.type)}$optional'; + return switch (e.type) { + MapFieldType(keyType: final keyType, valueType: final valueType) => + ' var ${e.name}: [${_transformType(keyType)}: ${_transformType(valueType)}]$optional', + ListFieldType(type: final type) => + ' var ${e.name}: [${_transformType(type)}]$optional', + OrdinaryFieldType(type: final type) => + ' var ${e.name}: ${_transformType(type)}$optional', + }; }).join('\n'); return ''' diff --git a/packages/patrol_gen/lib/src/generators/ios/ios_url_session_client_generator.dart b/packages/patrol_gen/lib/src/generators/ios/ios_url_session_client_generator.dart index 037b5ee18..69aacd46d 100644 --- a/packages/patrol_gen/lib/src/generators/ios/ios_url_session_client_generator.dart +++ b/packages/patrol_gen/lib/src/generators/ios/ios_url_session_client_generator.dart @@ -48,8 +48,39 @@ class ${service.name}Client { $endpoints - private func performRequest( + private func performRequestWithResult( requestName: String, body: Data? = nil, completion: @escaping (Result) -> Void + ) { + performRequest(requestName: requestName, body: body) { result in + switch result { + case .success(let data): + do { + let object = try JSONDecoder().decode(TResult.self, from: data) + completion(.success(object)) + } catch let err { + completion(.failure(err)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + private func performRequestWithEmptyResult( + requestName: String, body: Data? = nil, completion: @escaping (Error?) -> Void + ) { + performRequest(requestName: requestName, body: body) { result in + switch result { + case .success(_): + completion(nil) + case .failure(let error): + completion(error) + } + } + } + + private func performRequest( + requestName: String, body: Data? = nil, completion: @escaping (Result) -> Void ) { let url = URL(string: "$url")! @@ -66,12 +97,7 @@ $endpoints session.dataTask(with: request) { data, response, error in if (response as? HTTPURLResponse)?.statusCode == 200 { - do { - let object = try JSONDecoder().decode(TResult.self, from: data!) - completion(.success(object)) - } catch let err { - completion(.failure(err)) - } + completion(.success(data!)) } else { let message = "$exceptionMessage" @@ -89,7 +115,7 @@ $endpoints final completionDef = endpoint.response != null ? 'completion: @escaping (Result<${endpoint.response!.name}, Error>) -> Void' - : 'completion: @escaping (Result) -> Void'; + : 'completion: @escaping (Error?) -> Void'; final parameters = endpoint.request != null ? '$requestDef, $completionDef' @@ -98,18 +124,25 @@ $endpoints final arguments = [ 'requestName: "${endpoint.name}"', if (endpoint.request != null) 'body: body', - 'completion: completion' + 'completion: completion', ].join(', '); + final performRequest = endpoint.response != null + ? 'performRequestWithResult' + : 'performRequestWithEmptyResult'; + final failureCompletion = endpoint.response != null + ? 'completion(.failure(err))' + : 'completion(err)'; + final bodyCode = endpoint.request != null ? ''' do { let body = try JSONEncoder().encode(request) - performRequest($arguments) + $performRequest($arguments) } catch let err { - completion(.failure(err)) + $failureCompletion }''' - : ' performRequest($arguments)'; + : ' $performRequest($arguments)'; return ''' func ${endpoint.name}($parameters) { diff --git a/packages/patrol_gen/lib/src/resolve_schema.dart b/packages/patrol_gen/lib/src/resolve_schema.dart index ad1b515c0..0be68dd0d 100644 --- a/packages/patrol_gen/lib/src/resolve_schema.dart +++ b/packages/patrol_gen/lib/src/resolve_schema.dart @@ -106,20 +106,28 @@ Message _createMessage(ClassDeclaration declaration) { final isOptional = type.question != null; final fieldName = e.variables.first.name.lexeme; - if (type.type?.isDartCoreList ?? false) { + if (type.type?.isDartCoreMap ?? false) { + final arguments = type.typeArguments!.arguments; + return MessageField( + isOptional: isOptional, + name: fieldName, + type: MapFieldType( + keyType: (arguments[0] as NamedType).name2.lexeme, + valueType: (arguments[1] as NamedType).name2.lexeme, + ), + ); + } else if (type.type?.isDartCoreList ?? false) { final genericType = type.typeArguments!.arguments.first as NamedType; return MessageField( isOptional: isOptional, name: fieldName, - type: genericType.name2.lexeme, - isList: true, + type: ListFieldType(type: genericType.name2.lexeme), ); } else { return MessageField( isOptional: isOptional, name: fieldName, - type: type.name2.lexeme, - isList: false, + type: OrdinaryFieldType(type: type.name2.lexeme), ); } } else { diff --git a/packages/patrol_gen/lib/src/schema.dart b/packages/patrol_gen/lib/src/schema.dart index 7d40d3179..8aab84578 100644 --- a/packages/patrol_gen/lib/src/schema.dart +++ b/packages/patrol_gen/lib/src/schema.dart @@ -5,18 +5,37 @@ class Enum { final List fields; } +sealed class MessageFieldType {} + +class OrdinaryFieldType implements MessageFieldType { + const OrdinaryFieldType({required this.type}); + + final String type; +} + +class ListFieldType implements MessageFieldType { + const ListFieldType({required this.type}); + + final String type; +} + +class MapFieldType implements MessageFieldType { + const MapFieldType({required this.keyType, required this.valueType}); + + final String keyType; + final String valueType; +} + class MessageField { const MessageField({ required this.name, required this.type, required this.isOptional, - required this.isList, }); final bool isOptional; - final bool isList; final String name; - final String type; + final MessageFieldType type; } class Message { diff --git a/packages/patrol_gen/pubspec.lock b/packages/patrol_gen/pubspec.lock index a7f24b26e..b7dda4027 100644 --- a/packages/patrol_gen/pubspec.lock +++ b/packages/patrol_gen/pubspec.lock @@ -170,7 +170,7 @@ packages: source: hosted version: "1.3.2" watcher: - dependency: transitive + dependency: "direct overridden" description: name: watcher sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" diff --git a/packages/patrol_gen/pubspec.yaml b/packages/patrol_gen/pubspec.yaml index 363972ed6..ee58d0387 100644 --- a/packages/patrol_gen/pubspec.yaml +++ b/packages/patrol_gen/pubspec.yaml @@ -11,10 +11,13 @@ environment: sdk: '>=3.0.0 <4.0.0' dependencies: - analyzer: ^6.1.0 + analyzer: ^6.2.0 dart_style: ^2.3.2 meta: ^1.7.0 path: ^1.8.2 - + dev_dependencies: leancode_lint: ^3.0.0 + +dependency_overrides: + watcher: ^1.1.0 diff --git a/schema.dart b/schema.dart index d117a8707..68ff1d99c 100644 --- a/schema.dart +++ b/schema.dart @@ -7,6 +7,9 @@ // - Generic types (IOSServer, IOSClient, AndroidServer, AndroidClient, DartServer, DartClient) // control where we need clients and servers +// void doesn't work +class Empty {} + class DartGroupEntry { late String name; late GroupEntryType type; @@ -19,6 +22,11 @@ class ListDartTestsResponse { late DartGroupEntry group; } +class ListDartLifecycleCallbacksResponse { + late List setUpAlls; + late List tearDownAlls; +} + enum RunDartTestResponseResult { success, skipped, @@ -34,8 +42,14 @@ class RunDartTestResponse { String? details; } +class SetLifecycleCallbacksStateRequest { + late Map state; +} + abstract class PatrolAppService { ListDartTestsResponse listDartTests(); + ListDartLifecycleCallbacksResponse listDartLifecycleCallbacks(); + Empty setLifecycleCallbacksState(SetLifecycleCallbacksStateRequest request); RunDartTestResponse runDartTest(RunDartTestRequest request); } @@ -165,6 +179,10 @@ class SetLocationAccuracyRequest { late SetLocationAccuracyRequestLocationAccuracy locationAccuracy; } +class MarkLifecycleCallbackExecutedRequest { + late String name; +} + abstract class NativeAutomator { void initialize(); void configure(ConfigureRequest request); @@ -213,6 +231,8 @@ abstract class NativeAutomator { // other void debug(); -// TODO(bartekpacia): Move this RPC into a new PatrolNativeTestService service because it doesn't fit here +// TODO: Move these RPCc into a new service (PatrolNativeTestService) because it doesn't fit here void markPatrolAppServiceReady(); + void markLifecycleCallbackExecuted( + MarkLifecycleCallbackExecutedRequest request); }