diff --git a/client/integration_test/app_test.dart b/client/integration_test/app_test.dart index 9659913c55..5c795b7711 100644 --- a/client/integration_test/app_test.dart +++ b/client/integration_test/app_test.dart @@ -1,12 +1,11 @@ import 'dart:io'; import 'package:flet_client/main.dart' as app; +import 'package:flet_integration_test/flet_integration_test.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; -import 'flutter_tester.dart'; - void main() { var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -15,7 +14,20 @@ void main() { var dir = Directory.current.path; debugPrint("Current dir: $dir"); - app.tester = FlutterWidgetTester(tester, binding); + const testerServerUrl = String.fromEnvironment("FLET_TEST_SERVER_URL"); + FlutterWidgetTester? widgetTester; + + if (testerServerUrl.isNotEmpty) { + debugPrint("Connecting to remote tester at $testerServerUrl"); + widgetTester = await RemoteWidgetTester.connect( + tester: tester, + binding: binding, + serverUri: Uri.parse(testerServerUrl), + ); + } else { + widgetTester = FlutterWidgetTester(tester, binding); + app.tester = widgetTester; + } List args = []; const fletTestAppUrl = String.fromEnvironment("FLET_TEST_APP_URL"); @@ -35,11 +47,14 @@ void main() { app.main(args); - await Future.delayed(const Duration(milliseconds: 500)); - await app.tester?.pump(duration: const Duration(seconds: 1)); - await app.tester - ?.pumpAndSettle(duration: const Duration(milliseconds: 100)); - await app.tester?.waitForTeardown(); + if (testerServerUrl.isEmpty) { + await Future.delayed(const Duration(milliseconds: 500)); + await widgetTester?.pump(duration: const Duration(seconds: 1)); + await widgetTester?.pumpAndSettle( + duration: const Duration(milliseconds: 100), + ); + } + await widgetTester?.waitForTeardown(); }); }); } diff --git a/client/pubspec.lock b/client/pubspec.lock index 80f6d5aabd..592d8bdb97 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -313,6 +313,13 @@ packages: relative: true source: path version: "0.1.0" + flet_integration_test: + dependency: "direct dev" + description: + path: "../packages/flet_integration_test" + relative: true + source: path + version: "0.1.0" flet_lottie: dependency: "direct main" description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index ec66619c2c..1ce98838e7 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -86,6 +86,8 @@ dependency_overrides: dev_dependencies: flutter_test: sdk: flutter + flet_integration_test: + path: ../packages/flet_integration_test # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/packages/flet_integration_test/.gitignore b/packages/flet_integration_test/.gitignore new file mode 100644 index 0000000000..35ee281d14 --- /dev/null +++ b/packages/flet_integration_test/.gitignore @@ -0,0 +1,32 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies diff --git a/packages/flet_integration_test/lib/flet_integration_test.dart b/packages/flet_integration_test/lib/flet_integration_test.dart new file mode 100644 index 0000000000..bfcdcaa9a0 --- /dev/null +++ b/packages/flet_integration_test/lib/flet_integration_test.dart @@ -0,0 +1,3 @@ +export 'src/flutter_test_finder.dart'; +export 'src/flutter_tester.dart'; +export 'src/remote_widget_tester.dart'; diff --git a/packages/flet_integration_test/lib/src/chunked_line_decoder.dart b/packages/flet_integration_test/lib/src/chunked_line_decoder.dart new file mode 100644 index 0000000000..d62b382f25 --- /dev/null +++ b/packages/flet_integration_test/lib/src/chunked_line_decoder.dart @@ -0,0 +1,72 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +/// Stream transformer which decodes UTF-8 bytes into newline-delimited strings +/// while enforcing a maximum line length. +class ChunkedLineDecoder extends StreamTransformerBase { + final int maxLineLength; + + const ChunkedLineDecoder({this.maxLineLength = 16 * 1024 * 1024}); + + @override + Stream bind(Stream stream) { + final stringStream = stream.cast>().transform(utf8.decoder); + late StreamSubscription subscription; + late StreamController controller; + var pending = ""; + + void emitLine(String line) { + if (line.length > maxLineLength) { + throw StateError( + "Line length exceeds allowed limit of $maxLineLength characters.", + ); + } + controller.add(line); + } + + controller = StreamController( + onListen: () { + subscription = stringStream.listen( + (chunk) { + pending += chunk; + while (true) { + final newlineIndex = pending.indexOf('\n'); + if (newlineIndex == -1) { + if (pending.length > maxLineLength) { + controller.addError(StateError( + "Line length exceeds allowed limit of $maxLineLength characters.", + )); + subscription.cancel(); + } + break; + } + final line = pending.substring(0, newlineIndex); + emitLine(line); + pending = pending.substring(newlineIndex + 1); + } + }, + onError: controller.addError, + onDone: () { + if (pending.isNotEmpty) { + if (pending.length > maxLineLength) { + controller.addError(StateError( + "Line length exceeds allowed limit of $maxLineLength characters.", + )); + } else { + controller.add(pending); + } + } + controller.close(); + }, + cancelOnError: false, + ); + }, + onPause: () => subscription.pause(), + onResume: () => subscription.resume(), + onCancel: () => subscription.cancel(), + ); + + return controller.stream; + } +} diff --git a/client/integration_test/flutter_test_finder.dart b/packages/flet_integration_test/lib/src/flutter_test_finder.dart similarity index 85% rename from client/integration_test/flutter_test_finder.dart rename to packages/flet_integration_test/lib/src/flutter_test_finder.dart index 645852a63f..742249f959 100644 --- a/client/integration_test/flutter_test_finder.dart +++ b/packages/flet_integration_test/lib/src/flutter_test_finder.dart @@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; class FlutterTestFinder extends TestFinder { final Finder finder; - FlutterTestFinder(this.finder) : super(); + FlutterTestFinder(this.finder); @override int get count => finder.evaluate().length; diff --git a/client/integration_test/flutter_tester.dart b/packages/flet_integration_test/lib/src/flutter_tester.dart similarity index 93% rename from client/integration_test/flutter_tester.dart rename to packages/flet_integration_test/lib/src/flutter_tester.dart index 762ea1e4c5..ffef91a748 100644 --- a/client/integration_test/flutter_tester.dart +++ b/packages/flet_integration_test/lib/src/flutter_tester.dart @@ -18,6 +18,9 @@ class FlutterWidgetTester implements Tester { FlutterWidgetTester(this._tester, this._binding); + @protected + IntegrationTestWidgetsFlutterBinding get binding => _binding; + @override Future pumpAndSettle({Duration? duration}) async { await lock.acquire(); @@ -78,16 +81,14 @@ class FlutterWidgetTester implements Tester { _tester.tap((finder as FlutterTestFinder).raw.at(finderIndex)); @override - Future tapAt(Offset offset) => - _tester.tapAt(offset); + Future tapAt(Offset offset) => _tester.tapAt(offset); @override Future longPress(TestFinder finder, int finderIndex) => _tester.longPress((finder as FlutterTestFinder).raw.at(finderIndex)); @override - Future enterText( - TestFinder finder, int finderIndex, String text) => + Future enterText(TestFinder finder, int finderIndex, String text) => _tester.enterText( (finder as FlutterTestFinder).raw.at(finderIndex), text, diff --git a/packages/flet_integration_test/lib/src/remote_widget_tester.dart b/packages/flet_integration_test/lib/src/remote_widget_tester.dart new file mode 100644 index 0000000000..767a1c3d2b --- /dev/null +++ b/packages/flet_integration_test/lib/src/remote_widget_tester.dart @@ -0,0 +1,247 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flet/flet.dart'; +import 'package:flet_integration_test/src/chunked_line_decoder.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'flutter_test_finder.dart'; +import 'flutter_tester.dart'; + +class RemoteWidgetTester extends FlutterWidgetTester { + final Socket _socket; + final Map _finders = {}; + final Completer _connectionClosed = Completer(); + Future _commandQueue = Future.value(); + StreamSubscription? _subscription; + bool _teardownRequested = false; + + RemoteWidgetTester._( + WidgetTester tester, + IntegrationTestWidgetsFlutterBinding binding, + this._socket, + ) : super(tester, binding) { + _startListening(); + } + + static Future connect({ + required WidgetTester tester, + required IntegrationTestWidgetsFlutterBinding binding, + required Uri serverUri, + Duration timeout = const Duration(seconds: 10), + }) async { + if (!serverUri.hasPort) { + throw ArgumentError("Server URL must include a port: $serverUri"); + } + final host = serverUri.host.isEmpty ? "127.0.0.1" : serverUri.host; + final port = serverUri.port; + final socket = await Socket.connect(host, port, timeout: timeout); + socket.setOption(SocketOption.tcpNoDelay, true); + return RemoteWidgetTester._(tester, binding, socket); + } + + void _startListening() { + final stream = _socket + .transform(const ChunkedLineDecoder(maxLineLength: 16 * 1024 * 1024)); + _subscription = stream.listen(null, onError: (error, stackTrace) { + if (!_connectionClosed.isCompleted) { + _connectionClosed.completeError(error, stackTrace); + } + _closeSilently(); + }, onDone: () { + _closeSilently(); + if (!_connectionClosed.isCompleted) { + _connectionClosed.complete(); + } + }, cancelOnError: true); + _subscription!.onData((line) { + _subscription?.pause(); + final Future pending = _processLine(line); + _commandQueue = _commandQueue.then((_) => pending); + pending.whenComplete(() => _subscription?.resume()); + }); + } + + Future _processLine(String line) async { + final dynamic decoded = jsonDecode(line); + if (decoded is! Map) { + throw Exception("Invalid command payload: $decoded"); + } + final id = decoded["id"]; + final method = decoded["method"] as String?; + final params = (decoded["params"] as Map?) + ?.cast() ?? + const {}; + + if (id == null || method == null) { + throw Exception("Command must include both 'id' and 'method'."); + } + + try { + final response = await _handleCommand(method, params); + _sendResponse(id, result: response.result); + if (response.closeAfter) { + await _socket.flush(); + await _socket.close(); + await _subscription?.cancel(); + if (!_connectionClosed.isCompleted) { + _connectionClosed.complete(); + } + } + } catch (error, stackTrace) { + _sendResponse(id, error: "$error", stack: stackTrace.toString()); + } + } + + _CommandResponse _ok([dynamic result]) => + _CommandResponse(result, closeAfter: false); + + Future<_CommandResponse> _handleCommand( + String method, + Map params, + ) async { + switch (method) { + case "pump": + await pump(duration: parseDuration(params["duration"])); + return _ok(); + case "pump_and_settle": + await pumpAndSettle(duration: parseDuration(params["duration"])); + return _ok(); + case "find_by_text": + return _ok(_storeFinder(findByText(params["text"] as String))); + case "find_by_text_containing": + return _ok( + _storeFinder(findByTextContaining(params["pattern"] as String)), + ); + case "find_by_key": + return _ok(_storeFinder(findByKey(_parseKey(params["key"])))); + case "find_by_tooltip": + return _ok(_storeFinder(findByTooltip(params["value"] as String))); + case "find_by_icon": + return _ok(_storeFinder(findByIcon(_parseIcon(params["icon"])))); + case "take_screenshot": + final bytes = await takeScreenshot(params["name"] as String); + return _ok(base64Encode(bytes)); + case "tap": + await _withFinder(params["finder_id"], + (finder) => tap(finder, params["finder_index"])); + return _ok(); + case "long_press": + await _withFinder(params["finder_id"], + (finder) => longPress(finder, params["finder_index"])); + return _ok(); + case "enter_text": + await _withFinder( + params["finder_id"], + (finder) => enterText( + finder, params["finder_index"], params["text"] as String), + ); + return _ok(); + case "mouse_hover": + await _withFinder(params["finder_id"], + (finder) => mouseHover(finder, params["finder_index"])); + return _ok(); + case "teardown": + _triggerTeardown(); + return const _CommandResponse(null, closeAfter: true); + default: + throw Exception("Unknown Tester method: $method"); + } + } + + Map _storeFinder(TestFinder finder) { + final flutterFinder = finder as FlutterTestFinder; + _finders[flutterFinder.id] = flutterFinder; + return flutterFinder.toMap(); + } + + Future _withFinder( + dynamic id, + Future Function(FlutterTestFinder finder) action, + ) async { + final finder = _finders[id]; + if (finder == null) { + throw Exception("Finder with id $id is not registered."); + } + await action(finder); + } + + IconData _parseIcon(dynamic value) { + if (value is Map) { + final codePoint = value["code_point"] as int?; + if (codePoint == null) { + throw Exception("Icon payload must include 'code_point'."); + } + return IconData( + codePoint, + fontFamily: value["font_family"] as String?, + fontPackage: value["font_package"] as String?, + matchTextDirection: (value["match_text_direction"] as bool?) ?? false, + ); + } else if (value is int) { + return IconData(value, fontFamily: "MaterialIcons"); + } + throw Exception("Invalid icon format: $value"); + } + + Key _parseKey(dynamic value) { + if (value is Map) { + final keyValue = value["value"]; + return ValueKey(keyValue); + } + return ValueKey(value); + } + + void _sendResponse( + dynamic id, { + dynamic result, + String? error, + String? stack, + }) { + final payload = {"id": id}; + if (error != null) { + payload["error"] = error; + if (stack != null) { + payload["stack"] = stack; + } + } else { + payload["result"] = result; + } + final encoded = jsonEncode(payload); + _socket.add(utf8.encode("$encoded\n")); + } + + void _closeSilently() { + _subscription?.cancel(); + _triggerTeardown(); + if (!_connectionClosed.isCompleted) { + _connectionClosed.complete(); + } + } + + void _triggerTeardown() { + if (_teardownRequested) { + return; + } + _teardownRequested = true; + super.teardown(); + } + + @override + void teardown() => _triggerTeardown(); + + @override + Future waitForTeardown() async { + await _commandQueue; + await Future.wait([super.waitForTeardown(), _connectionClosed.future]); + } +} + +class _CommandResponse { + final dynamic result; + final bool closeAfter; + + const _CommandResponse(this.result, {required this.closeAfter}); +} diff --git a/packages/flet_integration_test/pubspec.yaml b/packages/flet_integration_test/pubspec.yaml new file mode 100644 index 0000000000..2f396b4c26 --- /dev/null +++ b/packages/flet_integration_test/pubspec.yaml @@ -0,0 +1,20 @@ +name: flet_integration_test +description: Internal utilities for driving Flet Flutter integration tests. +publish_to: none +version: 0.1.0 + +environment: + sdk: ">=3.4.0 <4.0.0" + flutter: ">=3.22.0" + +dependencies: + flutter: + sdk: flutter + flet: + path: ../flet + integration_test: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/sdk/python/packages/flet-cli/src/flet_cli/cli.py b/sdk/python/packages/flet-cli/src/flet_cli/cli.py index 65f3b05939..54d2ce89c4 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -1,5 +1,6 @@ import argparse import sys +from typing import Optional import flet.version import flet_cli.commands.build @@ -14,8 +15,11 @@ # Source https://stackoverflow.com/a/26379693 def set_default_subparser( - parser: argparse.ArgumentParser, name: str, args: list = None, index: int = 0 -): + parser: argparse.ArgumentParser, + name: str, + args: Optional[list[str]] = None, + index: int = 0, +) -> list[str]: """ Set a default subparser when no subparser is provided. This should be called after setting up the argument parser but before @@ -28,9 +32,12 @@ def set_default_subparser( inserted. """ + mutate_sys_argv = args is None + current_args = list(sys.argv[1:] if mutate_sys_argv else args) + # exit if help or version flags are present - if any(flag in sys.argv[1:] for flag in {"-h", "--help", "-V", "--version"}): - return + if any(flag in current_args for flag in {"-h", "--help", "-V", "--version"}): + return current_args # all subparser actions subparser_actions = [ @@ -45,26 +52,28 @@ def set_default_subparser( ] # if an existing subparser is provided, skip setting a default - if any(arg in subparser_names for arg in sys.argv[1:]): - return + if any(arg in subparser_names for arg in current_args): + return current_args # if the default subparser doesn't exist, register it in the first subparser action if (name not in subparser_names) and subparser_actions: subparser_actions[0].add_parser(name) # insert the default subparser into the appropriate argument list - if args is None: - if len(sys.argv) > 1: - sys.argv.insert(index, name) - else: - args.insert(index, name) + current_args.insert(index, name) + if mutate_sys_argv: + sys.argv = [sys.argv[0], *current_args] -def get_parser() -> argparse.ArgumentParser: - """Construct and return the CLI argument parser.""" - parser = argparse.ArgumentParser() + return current_args - # add version flag + +def main(): + sys.exit(run()) + + +def _create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() parser.add_argument( "--version", "-V", @@ -74,34 +83,39 @@ def get_parser() -> argparse.ArgumentParser: sp = parser.add_subparsers(dest="command") - # register subcommands flet_cli.commands.create.Command.register_to(sp, "create") flet_cli.commands.run.Command.register_to(sp, "run") flet_cli.commands.build.Command.register_to(sp, "build") flet_cli.commands.pack.Command.register_to(sp, "pack") flet_cli.commands.publish.Command.register_to(sp, "publish") flet_cli.commands.serve.Command.register_to(sp, "serve") - flet_cli.commands.doctor.Command.register_to(sp, "doctor") - - # set "run" as the default subparser - set_default_subparser(parser, name="run", index=1) + flet_cli.commands.doctor.Command.register_to( + sp, "doctor" + ) # Register the doctor command return parser -def main(): - parser = get_parser() +def run(args: Optional[list[str]] = None) -> int: + parser = _create_parser() + + argv = list(args) if args is not None else list(sys.argv[1:]) + + # set "run" as the default subparser + argv = set_default_subparser(parser, name="run", args=argv, index=0) # print usage/help if called without arguments - if len(sys.argv) == 1: + if not argv: parser.print_help(sys.stdout) - sys.exit(1) + return 1 # parse arguments - args = parser.parse_args() + namespace = parser.parse_args(argv) # execute command - args.handler(args) + namespace.handler(namespace) + + return 0 if __name__ == "__main__": diff --git a/sdk/python/packages/flet/src/flet/testing/__init__.py b/sdk/python/packages/flet/src/flet/testing/__init__.py index fa6a212e4c..fb8733255a 100644 --- a/sdk/python/packages/flet/src/flet/testing/__init__.py +++ b/sdk/python/packages/flet/src/flet/testing/__init__.py @@ -1,5 +1,6 @@ from flet.testing.finder import Finder -from flet.testing.flet_test_app import FletTestApp +from flet.testing.flet_test_app import FletTestApp, FletTestMode +from flet.testing.remote_tester import RemoteTester from flet.testing.tester import Tester -__all__ = ["Finder", "FletTestApp", "Tester"] +__all__ = ["Finder", "FletTestApp", "FletTestMode", "RemoteTester", "Tester"] diff --git a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py index b20e9c7744..2eafb4b386 100644 --- a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py +++ b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py @@ -4,9 +4,10 @@ import platform import tempfile from collections.abc import Iterable +from enum import Enum from io import BytesIO from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Union import numpy as np from PIL import Image @@ -14,11 +15,27 @@ import flet as ft from flet.controls.control import Control +from flet.testing.remote_tester import RemoteTester from flet.testing.tester import Tester from flet.utils.network import get_free_tcp_port from flet.utils.platform_utils import get_bool_env_var -__all__ = ["FletTestApp"] +__all__ = ["FletTestApp", "FletTestMode"] + + +class FletTestMode(Enum): + FLET = "flet" + REMOTE = "remote" + + @classmethod + def from_value(cls, value: Union["FletTestMode", str]) -> "FletTestMode": + if isinstance(value, cls): + return value + try: + return cls(value.lower()) + except ValueError as exc: + valid = ", ".join(mode.value for mode in cls) + raise ValueError(f"mode must be one of: {valid}") from exc class FletTestApp: @@ -80,6 +97,13 @@ class FletTestApp: If `True`, do not invoke `fvm` when running the Flutter test process. Env override: `FLET_TEST_DISABLE_FVM=1`. + mode: + Testing mode selector. `FletTestMode.FLET` (default) launches the + Python Flet app and communicates over the Flet protocol. + `FletTestMode.REMOTE` skips the Flet app and drives the Flutter + test harness through the remote tester bridge. Env override: + `FLET_TEST_MODE`. + skip_pump_and_settle: If `True`, the initial `pump_and_settle` after app start is skipped. @@ -92,6 +116,8 @@ class FletTestApp: Overrides `screenshots_similarity_threshold`. - `FLET_TEST_USE_HTTP`: Enables HTTP transport when set to `1`. - `FLET_TEST_DISABLE_FVM`: Disables `fvm` usage when set to `1`. + - `FLET_TEST_MODE`: Overrides :param:`mode`. + - `FLET_TEST_SERVER_HOST`: Host interface to bind the remote tester server to. """ def __init__( @@ -108,6 +134,7 @@ def __init__( screenshots_similarity_threshold: float = 99.0, use_http: bool = False, disable_fvm: bool = False, + mode: FletTestMode = FletTestMode.FLET, skip_pump_and_settle: bool = False, ): self.test_platform = os.getenv("FLET_TEST_PLATFORM", test_platform) @@ -125,6 +152,10 @@ def __init__( ) ) self.__disable_fvm = get_bool_env_var("FLET_TEST_DISABLE_FVM") or disable_fvm + self.__mode = FletTestMode.from_value(mode) + env_mode = os.getenv("FLET_TEST_MODE") + if env_mode: + self.__mode = FletTestMode.from_value(env_mode) self.__use_http = get_bool_env_var("FLET_TEST_USE_HTTP") or use_http self.__test_path = test_path self.__flet_app_main = flet_app_main @@ -134,7 +165,8 @@ def __init__( self.__tcp_port = tcp_port self.__flutter_process: Optional[asyncio.subprocess.Process] = None self.__page = None - self.__tester: Tester | None = None + self.__tester: Optional[Union[Tester, RemoteTester]] = None + self.__remote_server_host = os.getenv("FLET_TEST_SERVER_HOST", "127.0.0.1") @property def page(self) -> ft.Page: @@ -142,14 +174,15 @@ def page(self) -> ft.Page: Returns an instance of Flet's app [`Page`][flet.]. """ if self.__page is None: - raise RuntimeError("page is not initialized") + raise RuntimeError( + "page is not initialized (available only when running in 'flet' mode)" + ) return self.__page @property - def tester(self) -> Tester: + def tester(self) -> Union[Tester, RemoteTester]: """ - Returns an instance of [`Tester`][flet.testing.] class - that programmatically interacts with page controls and the test environment. + Returns the active tester implementation. """ if self.__tester is None: raise RuntimeError("tester is not initialized") @@ -160,34 +193,49 @@ async def start(self): Starts Flet app and Flutter integration test process. """ - ready = asyncio.Event() - - async def main(page: ft.Page): - self.__page = page - self.__tester = Tester() - page.theme_mode = ft.ThemeMode.LIGHT - page.update() - - if asyncio.iscoroutinefunction(self.__flet_app_main): - await self.__flet_app_main(page) - elif callable(self.__flet_app_main): - self.__flet_app_main(page) - if not self.__skip_pump_and_settle: - await self.__tester.pump_and_settle() - ready.set() - - if not self.__tcp_port: - self.__tcp_port = get_free_tcp_port() - - if self.__use_http: - os.environ["FLET_FORCE_WEB_SERVER"] = "true" - - asyncio.create_task( - ft.run_async( - main, port=self.__tcp_port, assets_dir=str(self.__assets_dir), view=None + ready: Optional[asyncio.Event] = None + + if self.__mode is FletTestMode.FLET: + ready = asyncio.Event() + + async def main(page: ft.Page): + self.__page = page + self.__tester = Tester() + page.theme_mode = ft.ThemeMode.LIGHT + page.update() + if asyncio.iscoroutinefunction(self.__flet_app_main): + await self.__flet_app_main(page) + elif callable(self.__flet_app_main): + self.__flet_app_main(page) + if not self.__skip_pump_and_settle: + await self.__tester.pump_and_settle() + ready.set() + + if not self.__tcp_port: + self.__tcp_port = get_free_tcp_port() + + if self.__use_http: + os.environ["FLET_FORCE_WEB_SERVER"] = "true" + + asyncio.create_task( + ft.run_async( + main, + port=self.__tcp_port, + assets_dir=str(self.__assets_dir), + view=None, + ) + ) + print("Started Flet app") + else: + remote_tester = RemoteTester() + self.__tcp_port = await remote_tester.start( + host=self.__remote_server_host, port=self.__tcp_port + ) + self.__tester = remote_tester + print( + "Started remote tester bridge " + f"on {self.__remote_server_host}:{self.__tcp_port}" ) - ) - print("Started Flet app") stdout = asyncio.subprocess.DEVNULL stderr = asyncio.subprocess.DEVNULL @@ -211,21 +259,25 @@ async def main(page: ft.Page): self.test_device = self.test_platform tcp_addr = "10.0.2.2" if self.test_platform == "android" else "127.0.0.1" - protocol = "http" if self.__use_http else "tcp" if self.test_device: flutter_args += ["-d", self.test_device] - app_url = f"{protocol}://{tcp_addr}:{self.__tcp_port}" - flutter_args += [f"--dart-define=FLET_TEST_APP_URL={app_url}"] - - if not self.__use_http: - temp_path = Path(tempfile.gettempdir()) / "flet_app_pid.txt" - flutter_args += [f"--dart-define=FLET_TEST_PID_FILE_PATH={temp_path}"] - if self.__assets_dir: - flutter_args += [ - f"--dart-define=FLET_TEST_ASSETS_DIR={self.__assets_dir}" - ] + if self.__mode is FletTestMode.FLET: + protocol = "http" if self.__use_http else "tcp" + app_url = f"{protocol}://{tcp_addr}:{self.__tcp_port}" + flutter_args += [f"--dart-define=FLET_TEST_APP_URL={app_url}"] + + if not self.__use_http: + temp_path = Path(tempfile.gettempdir()) / "flet_app_pid.txt" + flutter_args += [f"--dart-define=FLET_TEST_PID_FILE_PATH={temp_path}"] + if self.__assets_dir: + flutter_args += [ + f"--dart-define=FLET_TEST_ASSETS_DIR={self.__assets_dir}" + ] + else: + server_url = f"tcp://{tcp_addr}:{self.__tcp_port}" + flutter_args += [f"--dart-define=FLET_TEST_SERVER_URL={server_url}"] self.__flutter_process = await asyncio.create_subprocess_exec( *flutter_args, @@ -235,9 +287,20 @@ async def main(page: ft.Page): ) print("Started Flutter test process.") - print("Waiting for a Flet client to connect...") - - while not ready.is_set(): + if self.__mode is FletTestMode.FLET: + print("Waiting for a Flet client to connect...") + else: + print("Waiting for Flutter app to connect to remote tester...") + + while True: + if self.__mode is FletTestMode.FLET: + assert ready is not None + if ready.is_set(): + break + else: + assert isinstance(self.__tester, RemoteTester) + if self.__tester.is_connected(): + break await asyncio.sleep(0.2) if self.__flutter_process.returncode is not None: raise RuntimeError( @@ -250,7 +313,11 @@ async def teardown(self): Teardown Flutter integration test process. """ - await self.tester.teardown() + try: + await self.tester.teardown() + finally: + if isinstance(self.__tester, RemoteTester): + await self.__tester.stop() if self.__flutter_process: print("\nWaiting for Flutter test process to exit...") diff --git a/sdk/python/packages/flet/src/flet/testing/remote_tester.py b/sdk/python/packages/flet/src/flet/testing/remote_tester.py new file mode 100644 index 0000000000..4f56ad1891 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/testing/remote_tester.py @@ -0,0 +1,260 @@ +import asyncio +import base64 +import json +from dataclasses import asdict, is_dataclass +from typing import Any, Optional + +from flet.controls.duration import DurationValue +from flet.controls.keys import KeyValue +from flet.controls.types import IconData +from flet.testing.finder import Finder + +__all__ = ["RemoteTester", "RemoteTesterError"] + + +class RemoteTesterError(RuntimeError): + """Error returned from a remote tester invocation.""" + + def __init__(self, message: str, stack: Optional[str] = None): + super().__init__(message) + self.stack = stack + + +class RemoteTester: + """ + TCP-based tester implementation that talks directly to + the Flutter integration test harness. + """ + + def __init__(self): + self._server: Optional[asyncio.AbstractServer] = None + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._connected = asyncio.Event() + self._closed = asyncio.Event() + self._closed.set() + self._pending: dict[int, asyncio.Future[Any]] = {} + self._request_id = 0 + self._reader_task: Optional[asyncio.Task[Any]] = None + self._send_lock = asyncio.Lock() + self.host = "127.0.0.1" + self.port: Optional[int] = None + + async def start(self, host: str = "127.0.0.1", port: Optional[int] = None) -> int: + """ + Starts the TCP server accepting connections from Flutter. + + Args: + host: Host interface to bind to. Defaults to ``127.0.0.1``. + port: Optional port to bind to. A random free port is used when + not specified. + + Returns: + Bound TCP port. + """ + if self._server is not None: + raise RuntimeError("RemoteTester server is already running.") + + self.host = host + self._server = await asyncio.start_server(self._handle_client, host, port) + sock = self._server.sockets[0] + self.port = sock.getsockname()[1] + return self.port + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ): + if self._reader is not None: + writer.close() + await writer.wait_closed() + return + + self._reader = reader + self._writer = writer + self._closed.clear() + self._connected.set() + self._reader_task = asyncio.create_task(self._read_loop()) + + try: + await self._reader_task + finally: + self._cleanup_connection() + + async def _read_loop(self): + assert self._reader is not None + buffer = bytearray() + max_line_length = 16 * 1024 * 1024 + while True: + chunk = await self._reader.read(4096) + if not chunk: + if buffer: + message = json.loads(buffer.decode("utf-8")) + self._dispatch_message(message) + break + buffer.extend(chunk) + while True: + newline_index = buffer.find(b"\n") + if newline_index == -1: + if len(buffer) > max_line_length: + raise ValueError("Incoming message exceeds allowed size.") + break + line = buffer[:newline_index] + del buffer[: newline_index + 1] + message = json.loads(line.decode("utf-8")) + self._dispatch_message(message) + + def _dispatch_message(self, message: Any): + request_id = message.get("id") + future = self._pending.pop(request_id, None) + if future is None: + return + if "error" in message: + future.set_exception( + RemoteTesterError(message["error"], message.get("stack")) + ) + else: + future.set_result(message.get("result")) + + def _cleanup_connection(self): + for future in self._pending.values(): + if not future.done(): + future.set_exception( + ConnectionError("Remote tester connection was closed.") + ) + self._pending.clear() + self._reader = None + self._writer = None + self._reader_task = None + self._connected.clear() + self._closed.set() + + async def stop(self): + """ + Stops the TCP server and releases any bound resources. + """ + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + async def _ensure_connected(self): + await self._connected.wait() + if self._writer is None: + raise ConnectionError("Remote tester is not connected.") + + async def _invoke(self, method: str, params: Optional[dict[str, Any]] = None): + await self._ensure_connected() + self._request_id += 1 + request_id = self._request_id + + loop = asyncio.get_running_loop() + future: asyncio.Future[Any] = loop.create_future() + self._pending[request_id] = future + + payload = {"id": request_id, "method": method} + if params: + payload["params"] = params + + async with self._send_lock: + assert self._writer is not None + self._writer.write(json.dumps(payload).encode("utf-8") + b"\n") + await self._writer.drain() + + return await future + + async def pump(self, duration: Optional[DurationValue] = None): + await self._invoke( + "pump", _with_optional("duration", _serialize_duration(duration)) + ) + + async def pump_and_settle(self, duration: Optional[DurationValue] = None): + await self._invoke( + "pump_and_settle", + _with_optional("duration", _serialize_duration(duration)), + ) + + async def find_by_text(self, text: str) -> Finder: + finder = await self._invoke("find_by_text", {"text": text}) + return Finder(**finder) + + async def find_by_text_containing(self, pattern: str) -> Finder: + finder = await self._invoke("find_by_text_containing", {"pattern": pattern}) + return Finder(**finder) + + async def find_by_key(self, key: KeyValue) -> Finder: + finder = await self._invoke("find_by_key", {"key": _serialize_key(key)}) + return Finder(**finder) + + async def find_by_tooltip(self, value: str) -> Finder: + finder = await self._invoke("find_by_tooltip", {"value": value}) + return Finder(**finder) + + async def find_by_icon(self, icon: IconData) -> Finder: + finder = await self._invoke("find_by_icon", {"icon": _serialize_icon(icon)}) + return Finder(**finder) + + async def take_screenshot(self, name: str) -> bytes: + data = await self._invoke("take_screenshot", {"name": name}) + return base64.b64decode(data) + + async def tap(self, finder: Finder): + await self._invoke("tap", {"id": finder.id}) + + async def long_press(self, finder: Finder): + await self._invoke("long_press", {"id": finder.id}) + + async def enter_text(self, finder: Finder, text: str): + await self._invoke("enter_text", {"id": finder.id, "text": text}) + + async def mouse_hover(self, finder: Finder): + await self._invoke("mouse_hover", {"id": finder.id}) + + async def teardown(self): + if not self.is_connected(): + return + try: + await self._invoke("teardown") + finally: + await self.wait_closed() + + async def wait_closed(self): + await self._closed.wait() + + def is_connected(self) -> bool: + return self._connected.is_set() + + async def wait_for_connection(self, timeout: Optional[float] = None): + if timeout is not None: + await asyncio.wait_for(self._connected.wait(), timeout) + else: + await self._connected.wait() + + +def _with_optional(key: str, value: Any) -> dict[str, Any]: + return {key: value} if value is not None else {} + + +def _serialize_duration(duration: Optional[DurationValue]) -> Any: + if duration is None: + return None + if is_dataclass(duration): + return asdict(duration) + if isinstance(duration, (int, float)): + return int(duration) + return duration + + +def _serialize_key(value: KeyValue) -> Any: + if value is None: + return None + if is_dataclass(value): + return asdict(value) + return value + + +def _serialize_icon(icon: IconData) -> Any: + if icon is None: + return None + if hasattr(icon, "value"): + return int(icon) + return icon