diff --git a/.fvmrc b/.fvmrc index d703088100..de1495ac7b 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.44.3" + "flutter": "3.44.4" } diff --git a/.github/workflows/flet-test.yml b/.github/workflows/flet-test.yml new file mode 100644 index 0000000000..7062eca6a7 --- /dev/null +++ b/.github/workflows/flet-test.yml @@ -0,0 +1,213 @@ +name: Flet Test (on-device) + +# Runs `flet test` end-to-end on every target: provisions a per-platform test +# host (build pipeline in test_mode), launches the built Counter app with embedded +# Python, and drives it over the socket tester channel. Unlike +# macos-integration-tests.yml (host-mode screenshots) and flet-build-test.yml +# (compile only), this actually runs the app on the device/emulator/simulator. + +on: + push: + branches-ignore: + - main + paths: + - '.github/workflows/flet-test.yml' + - '.fvmrc' + - 'packages/flet/**' + - 'packages/flet_integration_test/**' + - 'sdk/python/packages/flet/**' + - 'sdk/python/packages/flet-cli/**' + - 'sdk/python/examples/apps/flet_test_counter/**' + pull_request: + paths: + - '.github/workflows/flet-test.yml' + - '.fvmrc' + - 'packages/flet/**' + - 'packages/flet_integration_test/**' + - 'sdk/python/packages/flet/**' + - 'sdk/python/packages/flet-cli/**' + - 'sdk/python/examples/apps/flet_test_counter/**' + workflow_dispatch: + +# Ensure only one run per branch (PR or push), cancel older ones +concurrency: + group: flet-test-${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true + +env: + ROOT: "${{ github.workspace }}" + SDK_PYTHON: "${{ github.workspace }}/sdk/python" + SCRIPTS: "${{ github.workspace }}/.github/scripts" + APP_DIR: "sdk/python/examples/apps/flet_test_counter" + + # Host venv (runs flet-cli + pytest). The *embedded* app runtime is built + # against the matrix python version via `--python-version`; the host stays on + # 3.13 for stable test-dependency wheels (numpy/pillow/scikit-image). + UV_PYTHON: "3.13" + PYTHONUTF8: 1 + + # https://flet.dev/docs/reference/environment-variables + FLET_CLI_NO_RICH_OUTPUT: 1 + # Use the Flutter the setup action puts on PATH, not fvm. + FLET_TEST_DISABLE_FVM: 1 + +jobs: + flet-test: + name: ${{ matrix.platform }} (py${{ matrix.python_version }}) + runs-on: ${{ matrix.runner }} + # The embedded runtime is built against the matrix python version; the test + # also asserts the running app reports it (EXPECTED_PYTHON_VERSION). + env: + PYTHON_VERSION: ${{ matrix.python_version }} + EXPECTED_PYTHON_VERSION: ${{ matrix.python_version }} + # Cap the job so a wedged emulator/simulator (which can hang the on-device + # test indefinitely) auto-cancels instead of burning a full runner slot. + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + python_version: ["3.12", "3.13", "3.14"] + platform: [macos, ios, windows, linux, linux-arm64, android] + include: + - platform: macos + runner: macos-26 + test_cmd: uv run flet test macos --python-version ${PYTHON_VERSION} --yes -v + + - platform: ios + runner: macos-26 + test_cmd: uv run flet test ios -d "$IOS_UDID" --python-version ${PYTHON_VERSION} --yes -v + + - platform: windows + runner: windows-2025-vs2026 + test_cmd: uv run flet test windows --python-version ${PYTHON_VERSION} --yes -v + + - platform: linux + runner: ubuntu-latest + test_cmd: xvfb-run -a uv run flet test linux --python-version ${PYTHON_VERSION} --yes -v + + # arm64 Linux: no kuhnroyal/Flutter action (Flutter ships no prebuilt + # arm64 Linux SDK) — `flet test` installs Flutter itself via a git + # clone of the SDK, exercising flet-cli's arm64 Linux install path. + - platform: linux-arm64 + runner: ubuntu-24.04-arm + test_cmd: xvfb-run -a uv run flet test linux --python-version ${PYTHON_VERSION} --yes -v + + - platform: android + runner: ubuntu-latest + # Run command lives in the emulator-runner step below. + test_cmd: "" + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Setup uv + uses: astral-sh/setup-uv@v8.2.0 + + - name: Patch versions + shell: bash + run: | + source "${SCRIPTS}/update_build_version.sh" + source "${SCRIPTS}/common.sh" + patch_python_package_versions + + # arm64 Linux gets no prebuilt Flutter SDK — `flet test` installs it. + - name: Setup Flutter + if: matrix.platform != 'linux-arm64' + uses: kuhnroyal/flutter-fvm-config-action/setup@v3 + with: + path: '.fvmrc' + cache: true + + - name: Show tool versions + shell: bash + run: | + uv --version + uv run --project sdk/python/packages/flet-cli python --version + flutter --version || echo "Flutter not on PATH yet (flet test will install it)." + + # -------- Linux: build deps + virtual display (x64 and arm64) -------- + - name: Install Linux dependencies + if: startsWith(matrix.platform, 'linux') + shell: bash + run: | + sudo apt-get update --allow-releaseinfo-change + LINUX_DEPS="$(uv run --project sdk/python/packages/flet-cli flet --version --json | jq -r '.linux_dependencies | join(" ")')" + # xvfb has no GPU; the Flutter GTK app needs Mesa's software GL + # (llvmpipe) or it crashes on GL context creation (exit 79). + sudo apt-get install -y --no-install-recommends \ + $LINUX_DEPS xvfb libgl1-mesa-dri + sudo apt-get clean + + # -------- iOS: boot a simulator, capture its UDID -------- + - name: Boot iOS simulator + if: matrix.platform == 'ios' + shell: bash + run: | + UDID=$(xcrun simctl list devices available -j \ + | jq -r '[.devices[][] | select(.name | startswith("iPhone"))][0].udid') + if [ -z "$UDID" ] || [ "$UDID" = "null" ]; then + echo "No available iPhone simulator found" >&2 + xcrun simctl list devices available + exit 1 + fi + echo "Using iOS simulator $UDID" + xcrun simctl boot "$UDID" || true + xcrun simctl bootstatus "$UDID" -b + echo "IOS_UDID=$UDID" >> "$GITHUB_ENV" + + # -------- Android: enable KVM for a fast emulator -------- + - name: Enable KVM + if: matrix.platform == 'android' + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # -------- Run: desktop + iOS (non-Android) -------- + - name: Run flet test (${{ matrix.platform }}) + if: matrix.platform != 'android' + shell: bash + working-directory: ${{ env.APP_DIR }} + # Force Mesa software GL on Linux (xvfb has no GPU); harmless elsewhere. + env: + LIBGL_ALWAYS_SOFTWARE: "true" + GALLIUM_DRIVER: llvmpipe + run: ${{ matrix.test_cmd }} + + # -------- Run: Android (inside the emulator) -------- + - name: Run flet test (android) + if: matrix.platform == 'android' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + arch: x86_64 + target: google_apis + force-avd-creation: true + disable-animations: true + emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + working-directory: ${{ env.APP_DIR }} + # Single folded line => one shell, so the test command is the last + # statement and ITS exit code is the job's (no false green). A trap + # writes a filtered device log (embedded Python + native crashes only, + # everything else silenced) to a file on exit — uploaded as an + # artifact below instead of flooding the console. + script: >- + adb logcat -G 16M || true ; + adb logcat -c || true ; + trap 'adb logcat -d -v time flet.python:V python:V AndroidRuntime:E DEBUG:F libc:F "*:S" > "$RUNNER_TEMP/android-logcat.txt" 2>&1 || true' EXIT ; + uv run flet test android -d emulator-5554 --python-version ${PYTHON_VERSION} --yes -v + + # Upload the device log as an artifact (don't stream it to the console). + - name: Upload android logcat + if: always() && matrix.platform == 'android' + uses: actions/upload-artifact@v4 + with: + name: android-logcat + path: ${{ runner.temp }}/android-logcat.txt + if-no-files-found: ignore diff --git a/client/integration_test/app_test.dart b/client/integration_test/app_test.dart index 9659913c55..252ec74407 100644 --- a/client/integration_test/app_test.dart +++ b/client/integration_test/app_test.dart @@ -1,45 +1,7 @@ -import 'dart:io'; - +import 'package:flet_integration_test/flet_integration_test.dart'; import 'package:flet_client/main.dart' as app; -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(); - - group('end-to-end test', () { - testWidgets('test app', (tester) async { - var dir = Directory.current.path; - debugPrint("Current dir: $dir"); - - app.tester = FlutterWidgetTester(tester, binding); - - List args = []; - const fletTestAppUrl = String.fromEnvironment("FLET_TEST_APP_URL"); - if (fletTestAppUrl != "") { - args.add(fletTestAppUrl); - } - - const fletTestPidFile = String.fromEnvironment("FLET_TEST_PID_FILE_PATH"); - if (fletTestPidFile != "") { - args.add(fletTestPidFile); - } - - const fletTestAssetsDir = String.fromEnvironment("FLET_TEST_ASSETS_DIR"); - if (fletTestAssetsDir != "") { - args.add(fletTestAssetsDir); - } - - 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(); - }); - }); -} +void main() => runFletHostTest( + appMain: app.main, + assignTester: (t) => app.tester = t, + ); diff --git a/client/linux/my_application.cc b/client/linux/my_application.cc index 7bc5ee4d3d..730d2d6d14 100644 --- a/client/linux/my_application.cc +++ b/client/linux/my_application.cc @@ -59,6 +59,11 @@ static void my_application_activate(GApplication* application) { gtk_widget_show(GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + // Realize the Flutter view while the top-level window is still hidden; this + // lets Flutter render its first frames and lets integration tests attach + // before Dart decides whether the app window should be shown. + gtk_widget_realize(GTK_WIDGET(view)); + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); gtk_widget_grab_focus(GTK_WIDGET(view)); diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 6de1369774..0ba147dc8d 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -99,10 +99,21 @@ dependency_overrides: path: ../packages/flet screen_retriever: 0.2.1 # this one migrated to SPM, but 0.2.0 is used by some other packages + screen_brightness: 2.1.7 + # screen_brightness_macos 2.1.3 ("Fix: swift package manager warning") ships a + # Package.swift declaring macOS 10.11, below FlutterFramework's 10.15 floor, + # which breaks Swift Package Manager resolution. Pin the last good impl until + # upstream fixes it: https://github.com/aaassseee/screen_brightness/issues/99 + screen_brightness_macos: 2.1.2 + dev_dependencies: flutter_test: sdk: flutter + # Flet integration-test driver (used by integration_test/app_test.dart). + 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 # activated in the `analysis_options.yaml` file located at the root of your diff --git a/packages/flet/lib/src/flet_backend.dart b/packages/flet/lib/src/flet_backend.dart index 92cf5446a2..0785a63782 100644 --- a/packages/flet/lib/src/flet_backend.dart +++ b/packages/flet/lib/src/flet_backend.dart @@ -152,7 +152,7 @@ class FletBackend extends ChangeNotifier { "web": kIsWeb, "debug": kDebugMode, "wasm": const bool.fromEnvironment('dart.tool.dart2wasm'), - "test": tester != null, + "test": tester != null || const bool.fromEnvironment("FLET_TEST"), "multi_view": multiView, "pyodide": isPyodideMode(), "window": { diff --git a/packages/flet/lib/src/utils.dart b/packages/flet/lib/src/utils.dart index 5926764776..880e85a1b7 100644 --- a/packages/flet/lib/src/utils.dart +++ b/packages/flet/lib/src/utils.dart @@ -6,7 +6,10 @@ import 'package:window_manager/window_manager.dart'; import 'utils/platform.dart'; -Future setupDesktop({bool hideWindowOnStart = false}) async { +Future setupDesktop({ + bool hideWindowOnStart = false, + bool waitUntilReadyToShow = true, +}) async { if (isDesktopPlatform()) { WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); @@ -16,6 +19,10 @@ Future setupDesktop({bool hideWindowOnStart = false}) async { debugPrint("hideWindowOnStart: $hideWindowOnStart"); debugPrint("hideWindowOnStartEnv: $hideWindowOnStartEnv"); + if (!waitUntilReadyToShow) { + return; + } + await windowManager.waitUntilReadyToShow(null, () async { if (hideWindowOnStartEnv == null && !hideWindowOnStart) { await windowManager.show(); 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/analysis_options.yaml b/packages/flet_integration_test/analysis_options.yaml new file mode 100644 index 0000000000..a5744c1cfb --- /dev/null +++ b/packages/flet_integration_test/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options 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..eb0e7ce1f7 --- /dev/null +++ b/packages/flet_integration_test/lib/flet_integration_test.dart @@ -0,0 +1,14 @@ +/// Flet integration-testing driver. +/// +/// This is a separate package (not part of `package:flet`) because it depends +/// on the dev-only `flutter_test` and `integration_test` packages, which must +/// never enter a normal Flet app's runtime dependency graph. Depend on it only +/// from a Flutter integration test (under `integration_test/`), where those +/// packages are available. +library flet_integration_test; + +export 'src/device_test.dart'; +export 'src/flutter_test_finder.dart'; +export 'src/flutter_tester.dart'; +export 'src/host_test.dart'; +export 'src/remote_widget_tester.dart'; diff --git a/packages/flet_integration_test/lib/src/device_test.dart b/packages/flet_integration_test/lib/src/device_test.dart new file mode 100644 index 0000000000..f39c5ffcd1 --- /dev/null +++ b/packages/flet_integration_test/lib/src/device_test.dart @@ -0,0 +1,52 @@ +// ignore_for_file: depend_on_referenced_packages +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'remote_widget_tester.dart'; + +/// Device-mode integration test entry point for an app built with `flet build`. +/// +/// The app under test runs on-device with embedded Python over the in-process +/// dart_bridge transport (via [appMain]). A [RemoteWidgetTester] connects over a +/// raw socket to the Python `RemoteTester` server (`FLET_TEST_SERVER_URL`) and +/// drives the integration-test `WidgetTester` — an independent channel that does +/// not touch Flet's own transport and adds no widget to the tree. +void runFletDeviceTest({required void Function(List) appMain}) { + var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('end-to-end test', () { + testWidgets('test app', (tester) async { + const serverUrl = String.fromEnvironment("FLET_TEST_SERVER_URL"); + if (serverUrl.isEmpty) { + throw Exception("FLET_TEST_SERVER_URL dart-define is required."); + } + + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + await binding.setSurfaceSize(const Size(1280, 720)); + addTearDown(() => binding.setSurfaceSize(null)); + } + + // Launch the on-device app (no args => production/dart_bridge mode) and + // pump frames here, in the test body, so it starts and renders its first + // UI before any remote command arrives. + appMain(const []); + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + // Connect the remote tester last; the Python RemoteTester server unblocks + // once connected and drives the app from here on. + debugPrint("Connecting to remote tester at $serverUrl"); + final widgetTester = await RemoteWidgetTester.connect( + tester: tester, + binding: binding, + serverUri: Uri.parse(serverUrl), + ); + + await widgetTester.waitForTeardown(); + }); + }); +} 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..ee356ef421 100644 --- a/client/integration_test/flutter_test_finder.dart +++ b/packages/flet_integration_test/lib/src/flutter_test_finder.dart @@ -1,6 +1,8 @@ -import 'package:flet/flet.dart'; +// ignore_for_file: depend_on_referenced_packages import 'package:flutter_test/flutter_test.dart'; +import 'package:flet/flet.dart'; + class FlutterTestFinder extends TestFinder { final Finder finder; diff --git a/client/integration_test/flutter_tester.dart b/packages/flet_integration_test/lib/src/flutter_tester.dart similarity index 89% rename from client/integration_test/flutter_tester.dart rename to packages/flet_integration_test/lib/src/flutter_tester.dart index 49bb544e4d..d94f55ebb3 100644 --- a/client/integration_test/flutter_tester.dart +++ b/packages/flet_integration_test/lib/src/flutter_tester.dart @@ -1,11 +1,13 @@ +// ignore_for_file: depend_on_referenced_packages import 'dart:async'; -import 'package:flet/flet.dart'; -import 'package:flutter/gestures.dart'; + import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:flet/flet.dart'; import 'flutter_test_finder.dart'; class FlutterWidgetTester implements Tester { @@ -17,6 +19,11 @@ class FlutterWidgetTester implements Tester { FlutterWidgetTester(this._tester, this._binding); + /// The integration-test binding, exposed for subclasses (e.g. + /// `RemoteWidgetTester`). + @protected + IntegrationTestWidgetsFlutterBinding get binding => _binding; + @override Future pumpAndSettle({Duration? duration}) async { await lock.acquire(); @@ -101,14 +108,15 @@ class FlutterWidgetTester implements Tester { } @override - Future tapAt(Offset offset) => - _tester.tapAt(offset); + Future tapAt(Offset offset) => _tester.tapAt(offset); @override - Future mouseClickAt(Offset offset) => _mouseClickAt(offset, kPrimaryButton); + Future mouseClickAt(Offset offset) => + _mouseClickAt(offset, kPrimaryButton); @override - Future mouseDoubleClickAt(Offset offset) => _mouseDoubleClickAt(offset); + Future mouseDoubleClickAt(Offset offset) => + _mouseDoubleClickAt(offset); @override Future rightMouseClickAt(Offset offset) => @@ -130,8 +138,7 @@ class FlutterWidgetTester implements Tester { _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, @@ -184,7 +191,11 @@ class FlutterWidgetTester implements Tester { } @override - void teardown() => _teardown.complete(); + void teardown() { + if (!_teardown.isCompleted) { + _teardown.complete(); + } + } @override Future waitForTeardown() => _teardown.future; diff --git a/packages/flet_integration_test/lib/src/frame_decoder.dart b/packages/flet_integration_test/lib/src/frame_decoder.dart new file mode 100644 index 0000000000..2dff2704eb --- /dev/null +++ b/packages/flet_integration_test/lib/src/frame_decoder.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'dart:typed_data'; + +/// Decodes a byte stream of length-prefixed frames into individual frame +/// payloads. Each frame is a big-endian uint32 length followed by that many +/// payload bytes. More robust than a newline delimiter — payloads may contain +/// arbitrary bytes (e.g. base64 screenshots) without escaping. +class FrameDecoder extends StreamTransformerBase { + final int maxFrameLength; + + const FrameDecoder({this.maxFrameLength = 64 * 1024 * 1024}); + + /// Encodes [payload] as a length-prefixed frame. + static Uint8List encode(List payload) { + final frame = Uint8List(4 + payload.length); + ByteData.view(frame.buffer).setUint32(0, payload.length, Endian.big); + frame.setRange(4, frame.length, payload); + return frame; + } + + @override + Stream bind(Stream stream) { + var buffer = Uint8List(0); + late StreamController controller; + StreamSubscription? subscription; + + void onChunk(Uint8List chunk) { + final merged = Uint8List(buffer.length + chunk.length) + ..setRange(0, buffer.length, buffer) + ..setRange(buffer.length, buffer.length + chunk.length, chunk); + buffer = merged; + + while (buffer.length >= 4) { + final length = + ByteData.sublistView(buffer, 0, 4).getUint32(0, Endian.big); + if (length > maxFrameLength) { + controller.addError( + StateError("Frame length $length exceeds limit $maxFrameLength."), + ); + subscription?.cancel(); + return; + } + if (buffer.length < 4 + length) break; + controller.add(Uint8List.fromList(buffer.sublist(4, 4 + length))); + buffer = Uint8List.fromList(buffer.sublist(4 + length)); + } + } + + controller = StreamController( + onListen: () { + subscription = stream.listen( + onChunk, + onError: controller.addError, + onDone: controller.close, + cancelOnError: false, + ); + }, + onPause: () => subscription?.pause(), + onResume: () => subscription?.resume(), + onCancel: () => subscription?.cancel(), + ); + + return controller.stream; + } +} diff --git a/packages/flet_integration_test/lib/src/host_test.dart b/packages/flet_integration_test/lib/src/host_test.dart new file mode 100644 index 0000000000..9efe3294a9 --- /dev/null +++ b/packages/flet_integration_test/lib/src/host_test.dart @@ -0,0 +1,61 @@ +// ignore_for_file: depend_on_referenced_packages + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:flet/flet.dart'; +import 'flutter_tester.dart'; + +/// Runs an integration test in "host" mode: the Flet app under test runs in a +/// separate Python (pytest) process which also acts as the Flet server, and the +/// Flutter app connects back to it over a single transport (TCP/HTTP/UDS). The +/// [Tester] rides that same connection. +/// +/// This is the mechanism used by the Flet dev "client" shell and the Flet +/// team's white-box tests. For testing an app built with `flet build` (embedded +/// Python over dart_bridge), use `runFletDeviceTest` instead. +/// +/// [appMain] is the host app's `main(args)` entry point. [assignTester] is +/// called with the constructed [Tester] before `appMain` runs so the host can +/// wire it into its `FletApp(tester: ...)`. +void runFletHostTest({ + required void Function(List) appMain, + required void Function(Tester) assignTester, +}) { + var binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('end-to-end test', () { + testWidgets('test app', (tester) async { + var dir = Directory.current.path; + debugPrint("Current dir: $dir"); + + var fletTester = FlutterWidgetTester(tester, binding); + assignTester(fletTester); + + List args = []; + const fletTestAppUrl = String.fromEnvironment("FLET_TEST_APP_URL"); + if (fletTestAppUrl != "") { + args.add(fletTestAppUrl); + } + + const fletTestPidFile = String.fromEnvironment("FLET_TEST_PID_FILE_PATH"); + if (fletTestPidFile != "") { + args.add(fletTestPidFile); + } + + const fletTestAssetsDir = String.fromEnvironment("FLET_TEST_ASSETS_DIR"); + if (fletTestAssetsDir != "") { + args.add(fletTestAssetsDir); + } + + appMain(args); + + await Future.delayed(const Duration(milliseconds: 500)); + await fletTester.pump(duration: const Duration(seconds: 1)); + await fletTester.pumpAndSettle( + duration: const Duration(milliseconds: 100)); + await fletTester.waitForTeardown(); + }); + }); +} 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..03cbbb9471 --- /dev/null +++ b/packages/flet_integration_test/lib/src/remote_widget_tester.dart @@ -0,0 +1,278 @@ +// ignore_for_file: depend_on_referenced_packages +// ignore_for_file: non_const_argument_for_const_parameter +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:flet/flet.dart'; +import 'flutter_test_finder.dart'; +import 'flutter_tester.dart'; +import 'frame_decoder.dart'; + +/// Drives [FlutterWidgetTester] over an independent length-prefixed JSON +/// protocol on a raw TCP socket — completely separate from Flet's own transport. +/// +/// The on-device app under test runs normally (embedded Python over +/// dart_bridge); this socket connects out to the Python `RemoteTester` server +/// and is the only channel that drives the integration-test `WidgetTester`. No +/// `FletApp`/`FletBackend` is added to the widget tree, so the app settles +/// normally and `WidgetTester.pump`/`pumpAndSettle` behave as usual. +class RemoteWidgetTester extends FlutterWidgetTester { + final Socket _socket; + final Map _finders = {}; + final Completer _connectionClosed = Completer(); + Future _commandQueue = Future.value(); + StreamSubscription? _subscription; + bool _teardownRequested = false; + + RemoteWidgetTester._( + super.tester, + super.binding, + this._socket, + ) { + _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 FrameDecoder()); + _subscription = stream.listen(null, onError: (error, stackTrace) { + if (!_connectionClosed.isCompleted) { + _connectionClosed.completeError(error, stackTrace); + } + _closeSilently(); + }, onDone: () { + _closeSilently(); + if (!_connectionClosed.isCompleted) { + _connectionClosed.complete(); + } + }, cancelOnError: true); + // Serialize commands by chaining them onto a single queue. Do NOT pause the + // subscription while a command runs: pausing also stops the socket being + // serviced for writes, so `_sendResponse`'s flush would deadlock (the + // response only transmits when an unrelated incoming byte wakes the link). + _subscription!.onData((frame) { + _commandQueue = _commandQueue.then((_) => _processFrame(frame)); + }); + } + + Future _processFrame(Uint8List frame) async { + final dynamic decoded = jsonDecode(utf8.decode(frame)); + 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); + await _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) { + await _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, index) => tap(finder, index)); + return _ok(); + case "long_press": + await _withFinder( + params, (finder, index) => longPress(finder, index)); + return _ok(); + case "enter_text": + await _withFinder(params, + (finder, index) => enterText(finder, index, params["text"] as String)); + return _ok(); + case "mouse_hover": + await _withFinder( + params, (finder, index) => mouseHover(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( + Map params, + Future Function(FlutterTestFinder finder, int index) action, + ) async { + final id = params["finder_id"]; + final index = (params["finder_index"] as int?) ?? 0; + final finder = _finders[id]; + if (finder == null) { + throw Exception("Finder with id $id is not registered."); + } + await action(finder, index); + } + + Duration? _parseDuration(dynamic value) { + if (value == null) return null; + if (value is int) return Duration(milliseconds: value); + if (value is double) return Duration(milliseconds: value.round()); + if (value is Map) { + final ms = value["milliseconds"] ?? value["ms"]; + if (ms is num) return Duration(milliseconds: ms.round()); + } + return null; + } + + 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) { + final v = (value is Map) ? value["value"] : value; + // Preserve the concrete value type so the constructed ValueKey matches + // the one the rendered control assigned — ValueKey's `==` is runtimeType + // strict (ValueKey('x') != ValueKey('x')). + return switch (v) { + String s => ValueKey(s), + int i => ValueKey(i), + double d => ValueKey(d), + bool b => ValueKey(b), + _ => ValueKey(v), + }; + } + + Future _sendResponse( + dynamic id, { + dynamic result, + String? error, + String? stack, + }) async { + 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(FrameDecoder.encode(utf8.encode(encoded))); + await _socket.flush(); + } + + void _closeSilently() { + _subscription?.cancel(); + _triggerTeardown(); + if (!_connectionClosed.isCompleted) { + _connectionClosed.complete(); + } + } + + void _triggerTeardown() { + if (_teardownRequested) { + return; + } + _teardownRequested = true; + super.teardown(); + } + + @override + void teardown() => _triggerTeardown(); + + /// Blocks until Python calls teardown (or the connection drops). Commands are + /// handled in the socket subscription; the test body just parks here, exactly + /// like the host-mode FlutterWidgetTester. + @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..6ab6cb745b --- /dev/null +++ b/packages/flet_integration_test/pubspec.yaml @@ -0,0 +1,38 @@ +name: flet_integration_test +description: Driver for running Flet app integration tests on-device. Internal Flet tooling, not published to pub.dev. +homepage: https://flet.dev +repository: https://github.com/flet-dev/flet/tree/main/packages/flet_integration_test +version: 0.86.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + # flet is depended on by VERSION (not path) so that when this package is + # consumed — as a path dep in repo dev or a git dep in an end user's test host + # — flet resolves to the SAME source as the app's flet (the app's + # dependency_overrides win for the whole graph). A path here would pin a second + # flet source and conflict with the app's pub.dev flet. + flet: ^0.86.0 + # integration_test (on-device test binding) and flutter_test (WidgetTester) are + # regular dependencies, not dev: this is a test-helper package, so consumers + # that depend on it get both transitively for `flutter test`. + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + +dev_dependencies: + flutter_lints: ^3.0.1 + +# Only used when this package is the root (standalone analyze / repo dev): build +# the driver against the in-repo flet rather than a published version. Ignored +# when this package is consumed as a dependency. +dependency_overrides: + flet: + path: ../flet + +flutter: diff --git a/sdk/python/_typos.toml b/sdk/python/_typos.toml index 8092cfbc0d..05346d3b4f 100644 --- a/sdk/python/_typos.toml +++ b/sdk/python/_typos.toml @@ -9,5 +9,8 @@ AACHE = "AACHE" ROUTEROS = "ROUTEROS" # OpenType variable font axis tag for width wdth = "wdth" +# iOS Unique Device IDentifier (simctl `.udid` JSON field / shell var names) +UDID = "UDID" +udid = "udid" # Python package name (Mozilla CA bundle) certifi = "certifi" diff --git a/sdk/python/examples/apps/flet_test_counter/pyproject.toml b/sdk/python/examples/apps/flet_test_counter/pyproject.toml new file mode 100644 index 0000000000..e589264c34 --- /dev/null +++ b/sdk/python/examples/apps/flet_test_counter/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "flet_test_counter" +version = "0.1.0" +description = "Counter app used by the flet-test CI workflow to exercise `flet test` on-device." +requires-python = ">=3.10" +authors = [{ name = "Appveyor Systems Inc.", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = [ + "flet-cli", + "flet-desktop", + # Integration testing with `flet test` / pytest. The `test` extra brings in + # pytest, pytest-asyncio and the screenshot-comparison dependencies used by + # flet.testing. + "flet[test]", +] + +[tool.uv.sources] +flet = { path = "../../../packages/flet", editable = true } +flet-cli = { path = "../../../packages/flet-cli", editable = true } +flet-desktop = { path = "../../../packages/flet-desktop", editable = true } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +# Docs: https://flet.dev/docs/publish/ +[tool.flet] +product = "Flet Test Counter" +artifact = "flet-test-counter" +project = "flet_test_counter" +company = "Flet" +org = "com.flet" +copyright = "Copyright (C) 2026 by Flet" + +[tool.flet.app] +path = "src" + +# Embedded Python flet = in-repo source (not pub.dev). +[tool.flet.dev_packages] +flet = "../../../packages/flet" + +# Dart flet = in-repo source (relative to the provisioned build/flutter dir). +[tool.flet.flutter.pubspec.dependency_overrides] +flet = { path = "../../../../../../../packages/flet" } diff --git a/sdk/python/examples/apps/flet_test_counter/src/main.py b/sdk/python/examples/apps/flet_test_counter/src/main.py new file mode 100644 index 0000000000..4ece41a18e --- /dev/null +++ b/sdk/python/examples/apps/flet_test_counter/src/main.py @@ -0,0 +1,42 @@ +import platform + +import flet as ft + + +def main(page: ft.Page): + counter = ft.Text("0", size=50) + version = ft.Text(f"Python {platform.python_version()}", key="python_version") + + def increment(e): + counter.value = str(int(counter.value) + 1) + counter.update() + + def decrement(e): + counter.value = str(int(counter.value) - 1) + counter.update() + + page.add( + ft.SafeArea( + content=ft.Column( + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + version, + ft.Row( + alignment=ft.MainAxisAlignment.CENTER, + controls=[ + ft.IconButton( + ft.Icons.REMOVE, key="decrement", on_click=decrement + ), + counter, + ft.IconButton( + ft.Icons.ADD, key="increment", on_click=increment + ), + ], + ), + ], + ) + ) + ) + + +ft.run(main) diff --git a/sdk/python/examples/apps/flet_test_counter/tests/test_main.py b/sdk/python/examples/apps/flet_test_counter/tests/test_main.py new file mode 100644 index 0000000000..7d36d9da25 --- /dev/null +++ b/sdk/python/examples/apps/flet_test_counter/tests/test_main.py @@ -0,0 +1,35 @@ +import os +import re + +import flet.testing as ftt + + +async def test_counter(flet_app: ftt.FletTestApp): + tester = flet_app.tester + + await tester.pump_and_settle() + + # The app displays the embedded Python version it was built against. CI pins + # it per matrix leg (EXPECTED_PYTHON_VERSION, e.g. "3.14"); assert the app + # reports that major.minor. Without the env var (e.g. a local run) just + # assert some version is shown. + expected = os.getenv("EXPECTED_PYTHON_VERSION") + if expected: + pattern = rf"Python {re.escape(expected)}\." + else: + pattern = r"Python \d+\.\d+\.\d+" + assert (await tester.find_by_text_containing(pattern)).count == 1 + + # Initial state + assert (await tester.find_by_text("0")).count == 1 + + # Increment once + await tester.tap(await tester.find_by_key("increment")) + await tester.pump_and_settle() + assert (await tester.find_by_text("1")).count == 1 + + # Decrement twice -> -1 + await tester.tap(await tester.find_by_key("decrement")) + await tester.tap(await tester.find_by_key("decrement")) + await tester.pump_and_settle() + assert (await tester.find_by_text("-1")).count == 1 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 08a7e511b3..07f1c360e7 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -14,6 +14,7 @@ import flet_cli.commands.publish import flet_cli.commands.run import flet_cli.commands.serve +import flet_cli.commands.test from flet_cli.utils.linux_deps import linux_dependencies @@ -113,6 +114,7 @@ def get_parser() -> argparse.ArgumentParser: flet_cli.commands.build.Command.register_to(sp, "build") flet_cli.commands.clean.Command.register_to(sp, "clean") flet_cli.commands.debug.Command.register_to(sp, "debug") + flet_cli.commands.test.Command.register_to(sp, "test") 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") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 30078da471..8805a0eaa7 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -893,6 +893,13 @@ def setup_template_data(self): or self.get_pyproject("project.name") or self.python_app_path.name ) + # Under integration test, `flutter test -d ` launches the built + # binary by the project name (the Flutter pubspec `name`), but the + # Windows/Linux runner sets the executable's OUTPUT_NAME to artifact_name. + # When they differ (e.g. `artifact = "my-app"` vs project `my_app`) the + # test host can't find the binary. Pin them equal in test mode. + if getattr(self, "test_mode", False): + artifact_name = project_name product_name = ( self.options.product_name or self.get_pyproject("tool.flet.product") @@ -1260,6 +1267,7 @@ def _xml_attr_value(v): self.target_platform in ["ipa"] and not ios_provisioning_profile and not self.debug_platform + and not getattr(self, "test_mode", False) ): console.print( Panel( @@ -1306,6 +1314,10 @@ def _xml_attr_value(v): "pyodide_version": self.python_release.pyodide, "base_url": f"/{base_url}/" if base_url else "/", "split_per_abi": split_per_abi, + # Enabled by `flet test` to scaffold integration-test wiring + # (integration_test/ + flutter_test dev deps). Default False so + # normal `flet build`/`flet debug` output is unaffected. + "test_mode": getattr(self, "test_mode", False), "project_name": project_name, "project_name_slug": project_name_slug, "artifact_name": artifact_name, @@ -1537,6 +1549,8 @@ def create_flutter_project(self, second_pass=False): self.cleanup(1, f"{e}") # For local development, override flet dependency with path + repo_root = None + pubspec = None if is_local_dev: repo_root = flet.version.find_repo_root(Path(__file__).resolve().parent) if repo_root: @@ -1546,7 +1560,36 @@ def create_flutter_project(self, second_pass=False): pubspec.setdefault("dependency_overrides", {})["flet"] = { "path": flet_pkg_path } - self.save_yaml(self.pubspec_path, pubspec) + + # In test mode, inject the integration-test driver (and flutter_test) + # as dev dependencies. They are intentionally NOT in the template + # pubspec: that keeps it valid YAML for the release patch tooling and + # ensures a normal `flet build` never pulls them. flet_integration_test + # is publish_to:none, so for local dev it resolves to the in-repo + # package by path, and for an end user it is a git dependency pinned to + # this flet version's tag. + if getattr(self, "test_mode", False): + if pubspec is None: + pubspec = self.load_yaml(self.pubspec_path) + dev_deps = pubspec.setdefault("dev_dependencies", {}) + dev_deps["flutter_test"] = {"sdk": "flutter"} + if is_local_dev and repo_root: + fit_pkg_path = str(repo_root / "packages" / "flet_integration_test") + dev_deps["flet_integration_test"] = {"path": fit_pkg_path} + pubspec.setdefault("dependency_overrides", {})[ + "flet_integration_test" + ] = {"path": fit_pkg_path} + else: + dev_deps["flet_integration_test"] = { + "git": { + "url": "https://github.com/flet-dev/flet.git", + "ref": f"v{flet.version.flet_version}", + "path": "packages/flet_integration_test", + } + } + + if pubspec is not None: + self.save_yaml(self.pubspec_path, pubspec) pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") @@ -2436,6 +2479,60 @@ def run_flutter(self): self._run_flutter_command() + def _serious_python_build_env(self) -> dict: + """ + serious_python environment for the platform NATIVE build (the Gradle / + CMake / podspec steps run by `flutter build`). + + These tell the native build where the `package` step staged the app and + site-packages and which embedded Python runtime to bundle. `flet build` + applies them via `_run_flutter_command`; `flet test` applies the SAME set + to the `flutter test` it spawns (see test.py `_flutter_path_env`) so both + bundle an identical app. In particular, without `SERIOUS_PYTHON_APP` the + Android `packageApp` Gradle task early-returns and a stale `app.zip` (e.g. + an old-Python `main.pyc`) survives in the APK — `ImportError: bad magic + number`. Built defensively so it is safe to call before the full build + pipeline has populated every attribute. + """ + + env: dict = {} + python_release = getattr(self, "python_release", None) + if python_release is not None: + # Only the short version is passed; serious_python derives the rest + # from its committed manifest snapshot. + env["SERIOUS_PYTHON_VERSION"] = python_release.short + + build_dir = getattr(self, "build_dir", None) + package_platform = getattr(self, "package_platform", None) + if build_dir is not None and package_platform != "Emscripten": + env["SERIOUS_PYTHON_SITE_PACKAGES"] = str(build_dir / "site-packages") + # app staging dir: read by the platform native build (CMake / podspec + # / Android Gradle) at `flutter build` time to place the unpacked app + # into the bundle. + env["SERIOUS_PYTHON_APP"] = str(build_dir / "python-app") + + # Swift Package Manager (darwin): export the cache-bust key the package + # step computed so the plugin's Package.swift re-resolves when the staged + # native set changes (SwiftPM caches its graph on manifest text + env). + if ( + build_dir is not None + and package_platform in ("iOS", "Darwin") + and self._darwin_spm_active() + ): + spm_key_file = build_dir / ".serious_python_spm_key" + if spm_key_file.exists(): + env["SP_NATIVE_SET"] = spm_key_file.read_text().strip() + + # Path-hungry packages to ship extracted to disk: consumed by the + # serious_python_android Gradle split during `flutter build`. + if package_platform == "Android" and getattr( + self, "android_extract_packages", None + ): + env["SERIOUS_PYTHON_ANDROID_EXTRACT_PACKAGES"] = ",".join( + self.android_extract_packages + ) + return env + def _run_flutter_command(self): """ Build final Flutter CLI command, configure environment, and run it. @@ -2457,36 +2554,10 @@ def _run_flutter_command(self): ] ) - # Only the short version is passed; serious_python derives the rest - # from its committed manifest snapshot. - build_env = { - "SERIOUS_PYTHON_VERSION": self.python_release.short, - } - - # site-packages variable - if self.package_platform != "Emscripten": - build_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( - self.build_dir / "site-packages" - ) - # app staging dir: read by the platform native build (CMake / - # podspec / Android Gradle) at `flutter build` time to place the - # unpacked app into the bundle. - build_env["SERIOUS_PYTHON_APP"] = str(self.build_dir / "python-app") - - # Swift Package Manager (darwin): export the cache-bust key the package - # step computed so the plugin's Package.swift re-resolves when the staged - # native set changes (SwiftPM caches its graph on manifest text + env). - if self._darwin_spm_active(): - spm_key_file = self.build_dir / ".serious_python_spm_key" - if spm_key_file.exists(): - build_env["SP_NATIVE_SET"] = spm_key_file.read_text().strip() - - # Path-hungry packages to ship extracted to disk: consumed by the - # serious_python_android Gradle split during `flutter build`. - if self.package_platform == "Android" and self.android_extract_packages: - build_env["SERIOUS_PYTHON_ANDROID_EXTRACT_PACKAGES"] = ",".join( - self.android_extract_packages - ) + # serious_python env for the native build, shared verbatim with `flet + # test` (which spawns its own `flutter test`) so both bundle an identical + # app — see `_serious_python_build_env`. + build_env = self._serious_python_build_env() if self.package_platform == "Emscripten" and not self.template_data["no_wasm"]: build_args.append("--wasm") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py index 9af5bef439..7349f2ddee 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py @@ -162,3 +162,7 @@ def handle(self, options: argparse.Namespace) -> None: else "" ) console.print(f"flet run {app_dir}\n") + + # print testing step + console.print("[cyan]Run the integration tests:[/cyan]\n") + console.print(f"flet test {app_dir}\n") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py new file mode 100644 index 0000000000..accaadb764 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py @@ -0,0 +1,292 @@ +import argparse +import os +import platform +import subprocess +import sys +from pathlib import Path +from typing import Optional + +from rich.console import Group +from rich.live import Live + +from flet_cli.commands.build_base import BaseBuildCommand, console + +# Maps the user-facing test platform to the build target_platform used to +# provision the Flutter test host, plus the default device id for desktop. +TEST_PLATFORMS = { + "windows": {"target_platform": "windows", "device_id": "windows"}, + "macos": {"target_platform": "macos", "device_id": "macos"}, + "linux": {"target_platform": "linux", "device_id": "linux"}, + "ios": {"target_platform": "ipa", "device_id": None}, + "android": {"target_platform": "apk", "device_id": None}, +} + + +def _default_desktop_platform() -> str: + name = platform.system().lower() + return "macos" if name == "darwin" else name # "windows" / "linux" + + +def _provision_steps(cmd: "BaseBuildCommand") -> Path: + """ + Drive the shared `flet build` provisioning pipeline (in `test_mode`) to + materialize a Flutter test host with the app's Python embedded as + `app/app.zip` and the `integration_test/` driver. Release build, icons, + splash and output-copy steps are intentionally skipped — `flutter test` + compiles its own debug binary. Returns the provisioned Flutter project dir. + """ + cmd.test_mode = True + cmd.initialize_command() + cmd.validate_target_platform() + cmd.validate_entry_point() + cmd.setup_template_data() + cmd.create_flutter_project() + cmd.package_python_app() + cmd.register_flutter_extensions() + if cmd.create_flutter_project(second_pass=True): + cmd.update_flutter_dependencies() + return cmd.flutter_dir + + +# Env vars set by `flet test` (and `provision_test_host`) for the pytest +# subprocess. `flutter test integration_test` (spawned by FletTestApp) runs the +# platform's native build, whose serious_python build phase bundles the staged +# app + site-packages — without these vars the embedded Python can't import its +# dependencies (e.g. ModuleNotFoundError: certifi) and, on Android, the +# `packageApp` Gradle task no-ops so a stale `app.zip` (old-Python `main.pyc`) +# survives in the APK (ImportError: bad magic number). `flet build`/`flet debug` +# set the SAME serious_python vars for their flutter build via +# `_serious_python_build_env` (build_base.py `_run_flutter_command`); we reuse it +# here so the two paths can't drift. +_TEST_ENV_KEYS = ( + "PATH", + "FLET_TEST_DISABLE_FVM", + "FLET_TEST_FLUTTER_EXE", + "SERIOUS_PYTHON_VERSION", + "SERIOUS_PYTHON_SITE_PACKAGES", + "SERIOUS_PYTHON_APP", + "SERIOUS_PYTHON_ANDROID_EXTRACT_PACKAGES", + "SP_NATIVE_SET", + "SERIOUS_PYTHON_FLUTTER_PACKAGES", +) + + +def _flutter_path_env(cmd: "BaseBuildCommand") -> dict: + """ + Build an environment for the pytest subprocess so the on-device test run + (`flutter test integration_test`, spawned by FletTestApp) finds the same + Flutter SDK we provisioned with (without `fvm`) and so the native build + bundles the app's site-packages. + """ + env = {**os.environ, **cmd.env} + if cmd.flutter_exe: + flutter_bin = str(Path(cmd.flutter_exe).parent) + env["PATH"] = os.pathsep.join([flutter_bin, env.get("PATH", "")]) + # Hand the resolved Flutter executable (e.g. `flutter.bat` on Windows) to + # FletTestApp: it spawns `flutter test` with create_subprocess_exec, which + # on Windows can't resolve a bare "flutter" (no PATHEXT lookup). + env["FLET_TEST_FLUTTER_EXE"] = str(cmd.flutter_exe) + env["FLET_TEST_DISABLE_FVM"] = "1" + # Same serious_python env `flet build` hands its native build, so the app + # `flutter test` builds is bundled identically (incl. SERIOUS_PYTHON_APP → + # a fresh app.zip with a matching-Python main.pyc). + env.update(cmd._serious_python_build_env()) + if getattr(cmd, "flutter_packages_temp_dir", None) is not None: + env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str(cmd.flutter_packages_temp_dir) + return env + + +class Command(BaseBuildCommand): + """ + Run Flet integration tests for an app. + + Provisions a Flutter test host from the app (the same pipeline as + `flet build`, in test mode) so the app runs on-device with embedded Python, + then runs pytest. Tests in the `tests/` directory drive the app through the + `flet_app` fixture (find controls by key, tap, take/assert screenshots). + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + self.test_platform_name: Optional[str] = None + self.device_id: Optional[str] = None + self.tests_dir = "tests" + self.update_goldens = False + self.pytest_args: list[str] = [] + self.flutter_test_host: Optional[str] = None + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + # `platform` is a positional, like `flet debug`. Register it first, then + # the inherited build args (which add the `python_app_path` positional), + # then our trailing `pytest_args` REMAINDER positional. + parser.add_argument( + "platform", + type=str.lower, + nargs="?", + choices=["macos", "linux", "windows", "ios", "android"], + help="The target platform to run the tests on " + "(defaults to the current desktop platform).", + ) + super().add_arguments(parser) + parser.add_argument( + "--device-id", + "-d", + dest="device_id", + help="Device ID to run the tests on (for iOS and Android).", + ) + parser.add_argument( + "--tests-dir", + dest="tests_dir", + default="tests", + help="Directory with the integration tests, relative to the app " + "directory (default: tests).", + ) + parser.add_argument( + "--update-goldens", + "-u", + dest="update_goldens", + action="store_true", + default=False, + help="Capture/update golden screenshots instead of comparing.", + ) + parser.add_argument( + "--flutter-test-host", + dest="flutter_test_host", + default=None, + help="Use an already-provisioned Flutter test host directory " + "instead of building one (e.g. a CI-cached host).", + ) + parser.add_argument( + "-k", + dest="pytest_keyword", + default=None, + help="Only run tests matching the given pytest keyword expression " + "(passed through to pytest -k).", + ) + + def handle(self, options: argparse.Namespace) -> None: + super().handle(options) + # `flet test` never produces a packaged artifact; build output dir is + # irrelevant (mirrors `flet debug`). + self.options.output_dir = None + + self.test_platform_name = options.platform or _default_desktop_platform() + self.target_platform = TEST_PLATFORMS[self.test_platform_name][ + "target_platform" + ] + self.device_id = ( + options.device_id or TEST_PLATFORMS[self.test_platform_name]["device_id"] + ) + self.tests_dir = options.tests_dir + self.update_goldens = options.update_goldens + self.flutter_test_host = options.flutter_test_host + + self.pytest_args = [] + if options.pytest_keyword: + self.pytest_args += ["-k", options.pytest_keyword] + + if self.test_platform_name in ("android", "ios") and not self.device_id: + console.print( + f"[red]A device id is required for {self.test_platform_name}. " + "Pass it with --device-id (use `flet devices` to list " + "connected devices).[/red]" + ) + sys.exit(1) + + if self.flutter_test_host: + flutter_dir = Path(self.flutter_test_host).resolve() + # Still need python_app_path/env for pytest; run a light init. + self.test_mode = True + self.status = console.status( + "[bold blue]Preparing tests...", spinner="bouncingBall" + ) + with Live(Group(self.status, self.progress), console=console) as self.live: + self.initialize_command() + self.validate_entry_point() + else: + self.status = console.status( + f"[bold blue]Provisioning {self.target_platform} test host...", + spinner="bouncingBall", + ) + with Live(Group(self.status, self.progress), console=console) as self.live: + flutter_dir = _provision_steps(self) + self.update_status("[bold blue]Test host ready. Starting tests...") + + exit_code = self._run_pytest(flutter_dir) + sys.exit(exit_code) + + def _run_pytest(self, flutter_dir: Path) -> int: + assert self.python_app_path + env = _flutter_path_env(self) + env["FLET_TEST_DEVICE_MODE"] = "1" + env["FLET_TEST_FLUTTER_APP_DIR"] = str(flutter_dir) + env["FLET_TEST_PLATFORM"] = self.test_platform_name or "" + if self.device_id: + env["FLET_TEST_DEVICE"] = self.device_id + if self.update_goldens: + env["FLET_TEST_GOLDEN"] = "1" + + pytest_args = list(self.pytest_args) + if self.verbose > 0: + # Stream the Flutter test process output (compilation/launch + # progress) and don't let pytest capture it. + env["FLET_TEST_VERBOSE"] = "1" + pytest_args += ["-s"] + + tests_path = Path(self.python_app_path) / self.tests_dir + args = [sys.executable, "-m", "pytest", str(tests_path), *pytest_args] + console.log(f"Running tests: {' '.join(args)}") + return subprocess.run(args, cwd=str(self.python_app_path), env=env).returncode + + +def provision_test_host( + project_dir: str, + platform_name: Optional[str] = None, + device_id: Optional[str] = None, + verbose: int = 0, +) -> Path: + """ + Provision (or reuse the cached) Flutter test host for the app at + `project_dir` and return its directory. Also wires up the current process + environment (PATH to the resolved Flutter SDK, FLET_TEST_DISABLE_FVM) so a + subsequent `flutter test` launched by FletTestApp works. + + Called by the pytest plugin so that `uv run pytest` works without first + running `flet test`. Cached by the build pipeline's input hash, so warm + runs are fast. + """ + parser = argparse.ArgumentParser() + cmd = Command(parser) + plat = platform_name or _default_desktop_platform() + + argv = [plat] + if device_id: + argv += ["-d", device_id] + options = parser.parse_args(argv) + options.python_app_path = str(Path(project_dir).resolve()) + options.output_dir = None + options.verbose = verbose + options.assume_yes = True + + cmd.options = options + cmd.no_rich_output = cmd.no_rich_output or options.no_rich_output + cmd.verbose = verbose + cmd.assume_yes = True + cmd.test_platform_name = plat + cmd.target_platform = TEST_PLATFORMS[plat]["target_platform"] + + cmd.status = console.status( + f"[bold blue]Provisioning {cmd.target_platform} test host...", + spinner="bouncingBall", + ) + with Live(Group(cmd.status, cmd.progress), console=console) as cmd.live: + flutter_dir = _provision_steps(cmd) + + # Make the SDK discoverable for the FletTestApp-spawned `flutter test` and + # propagate the SERIOUS_PYTHON_* vars so the native build bundles + # site-packages into the app. + env = _flutter_path_env(cmd) + for key in _TEST_ENV_KEYS: + if key in env: + os.environ[key] = env[key] + return flutter_dir diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/test_host.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/test_host.py new file mode 100644 index 0000000000..aa21af73d9 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/test_host.py @@ -0,0 +1,11 @@ +""" +Provisioning entry point for the Flet integration-test host. + +`provision_test_host` is defined in `flet_cli.commands.test` (alongside the +`flet test` command) and re-exported here so the flet pytest plugin can import +it from a stable, command-agnostic module without pulling in argparse wiring. +""" + +from flet_cli.commands.test import provision_test_host + +__all__ = ["provision_test_host"] diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py index 92e143d548..8d8c1394d9 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py @@ -1,6 +1,7 @@ import os import platform import shutil +import subprocess from pathlib import Path from typing import Optional @@ -9,6 +10,16 @@ from flet_cli.utils.distros import download_with_progress, extract_with_progress +FLUTTER_GIT_URL = "https://github.com/flutter/flutter.git" + + +def is_arm64_linux() -> bool: + """Whether the host is arm64 Linux, which has no prebuilt Flutter SDK.""" + return platform.system() == "Linux" and platform.machine().lower() in ( + "aarch64", + "arm64", + ) + def get_flutter_url(version): """Determines the Flutter archive URL based on the platform.""" @@ -65,26 +76,45 @@ def install_flutter(version, log, progress: Optional[Progress] = None): home_dir = Path.home() if not os.path.exists(install_dir): - url = get_flutter_url(version) - archive_name = os.path.basename(url) - archive_path = os.path.join(home_dir, archive_name) - - log(f"Downloading Flutter {version} from {url}...") - download_with_progress(url, archive_path, progress=progress) - - log(f"Extracting Flutter to {install_dir}...") - temp_extract_dir = os.path.join(home_dir, "flutter", f"{version}_temp") - os.makedirs(temp_extract_dir, exist_ok=True) - - extract_with_progress(archive_path, temp_extract_dir, progress=progress) - - # Move extracted 'flutter' directory contents to final destination - flutter_root = os.path.join(temp_extract_dir, "flutter") - shutil.move(flutter_root, install_dir) - - # Clean up - os.remove(archive_path) - shutil.rmtree(temp_extract_dir) + if is_arm64_linux(): + # Flutter publishes no prebuilt arm64 Linux SDK (its releases are + # x64-only), so clone the SDK at the version tag, then precache its + # engine artifacts. The prebuilt archives ship these under + # `bin/cache` (incl. the `sky_engine` package that `dart run` / + # `flutter build` resolve); a bare clone does not, so without the + # precache pub solving fails with "could not find package sky_engine". + # The downloaded artifacts are arch-appropriate (mirrors fvm/git). + os.makedirs(os.path.dirname(install_dir), exist_ok=True) + log(f"Cloning Flutter {version} for arm64 Linux from {FLUTTER_GIT_URL}...") + subprocess.run( + ["git", "clone", "--depth", "1", "--branch", version] + + [FLUTTER_GIT_URL, install_dir], + check=True, + ) + log(f"Precaching Flutter {version} engine artifacts...") + flutter_exe = os.path.join(install_dir, "bin", "flutter") + subprocess.run([flutter_exe, "precache", "--linux"], check=True) + else: + url = get_flutter_url(version) + archive_name = os.path.basename(url) + archive_path = os.path.join(home_dir, archive_name) + + log(f"Downloading Flutter {version} from {url}...") + download_with_progress(url, archive_path, progress=progress) + + log(f"Extracting Flutter to {install_dir}...") + temp_extract_dir = os.path.join(home_dir, "flutter", f"{version}_temp") + os.makedirs(temp_extract_dir, exist_ok=True) + + extract_with_progress(archive_path, temp_extract_dir, progress=progress) + + # Move extracted 'flutter' directory contents to final destination + flutter_root = os.path.join(temp_extract_dir, "flutter") + shutil.move(flutter_root, install_dir) + + # Clean up + os.remove(archive_path) + shutil.rmtree(temp_extract_dir) log(f"Flutter {version} installed at {install_dir}.") return install_dir diff --git a/sdk/python/packages/flet/pyproject.toml b/sdk/python/packages/flet/pyproject.toml index 55539efe6e..9fb0df02f5 100644 --- a/sdk/python/packages/flet/pyproject.toml +++ b/sdk/python/packages/flet/pyproject.toml @@ -32,10 +32,23 @@ desktop = [ "flet-desktop", ] web = ["flet-web"] +# Dependencies needed to run integration tests written with `flet.testing` +# (the `flet test` command / the pytest plugin). Pillow/numpy/scikit-image +# back screenshot and GIF comparison. +test = [ + "pytest >=7.2.0", + "pytest-asyncio >=1.1.0", + "numpy >=2.2.0", + "pillow >=10.3.0", + "scikit-image >=0.25.2", +] [project.scripts] flet = "flet.cli:main" +[project.entry-points.pytest11] +flet = "flet.pytest_plugin" + [dependency-groups] extensions = [ "flet-ads", diff --git a/sdk/python/packages/flet/src/flet/pytest_plugin.py b/sdk/python/packages/flet/src/flet/pytest_plugin.py new file mode 100644 index 0000000000..9ef8afa2b5 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/pytest_plugin.py @@ -0,0 +1,147 @@ +""" +Pytest plugin that exposes Flet integration-testing fixtures. + +Registered as a ``pytest11`` entry point in flet's ``pyproject.toml`` so that any +test under a Flet app's ``tests/`` directory gets the ``flet_app`` / +``flet_app_function`` fixtures with zero conftest boilerplate. + +Because a ``pytest11`` plugin is auto-loaded in *every* pytest run wherever +``flet`` is installed, this module must stay lightweight: it lives at the flet +top level (not under ``flet.testing``) so loading it does not pull in +``FletTestApp`` (numpy/Pillow/scikit-image). It imports nothing heavy at module +load time — ``flet.testing`` and ``flet_cli`` are imported lazily inside the +fixtures. ``pytest_asyncio`` is imported defensively — if it is missing, the +plugin loads as a no-op so it never breaks unrelated projects' pytest runs. +""" + +import os +from pathlib import Path + +try: + import pytest_asyncio + + _HAS_ASYNCIO = True +except ImportError: # pragma: no cover - plugin degrades to a no-op + _HAS_ASYNCIO = False + + +def _is_device_mode() -> bool: + # Device mode (app under test runs on-device with embedded Python) is the + # default for `flet test` / `uv run pytest`. The Flet repo's own host-mode + # tests opt out by setting FLET_TEST_DEVICE_MODE=0 (and override these + # fixtures from their conftest anyway). + return os.environ.get("FLET_TEST_DEVICE_MODE", "1").lower() not in ( + "0", + "false", + "no", + "", + ) + + +def _import_app_main(app_path: str, module_name: str): + """Import the user's app `main` callable from `app_path/.py`.""" + import importlib + import sys + + app_path = str(Path(app_path).resolve()) + if app_path not in sys.path: + sys.path.insert(0, app_path) + module = importlib.import_module(module_name) + return module.main + + +def _resolve_flutter_test_host(device_mode: bool): + """Return the Flutter test host dir, provisioning it on demand if needed.""" + host = os.environ.get("FLET_TEST_FLUTTER_APP_DIR") + if host: + return host + if not device_mode: + raise RuntimeError( + "FLET_TEST_FLUTTER_APP_DIR is not set. Run tests with `flet test`, " + "or set the environment variable to a Flutter test host directory." + ) + # Lazily provision a built test host via flet-cli (cached by input hash). + # The target platform/device come from FLET_TEST_PLATFORM / FLET_TEST_DEVICE + # so `uv run pytest` can target a mobile emulator/device too (defaults to the + # current desktop platform when unset). + try: + from flet_cli.commands.test_host import provision_test_host + except ImportError as e: + raise RuntimeError( + "Provisioning a Flutter test host requires flet-cli. Install it " + "with `pip install flet-cli` or run your tests with `flet test`." + ) from e + return str( + provision_test_host( + project_dir=os.getcwd(), + platform_name=os.environ.get("FLET_TEST_PLATFORM"), + device_id=os.environ.get("FLET_TEST_DEVICE"), + ) + ) + + +def _create_flet_app(request): + import flet.testing as ftt + + params = getattr(request, "param", {}) + device_mode = _is_device_mode() + flutter_app_dir = _resolve_flutter_test_host(device_mode) + + flet_app_main = params.get("flet_app_main") + if not device_mode and flet_app_main is None: + app_path = os.environ.get("FLET_TEST_APP_PATH") + if app_path: + module_name = os.environ.get("FLET_TEST_APP_MODULE", "main") + flet_app_main = _import_app_main(app_path, module_name) + + assets_dir = params.get("assets_dir") or os.environ.get("FLET_TEST_ASSETS_DIR") + + return ftt.FletTestApp( + flutter_app_dir=flutter_app_dir, + device_mode=device_mode, + flet_app_main=flet_app_main, + test_path=request.fspath, + skip_pump_and_settle=params.get("skip_pump_and_settle", False), + assets_dir=assets_dir, + ) + + +if _HAS_ASYNCIO: + + @pytest_asyncio.fixture + async def flet_app(request): + """ + Function-scoped Flet app fixture: each test gets a fresh, isolated app. + + Does not bind `ft.context.page`. Use `flet_app.tester` to drive the UI. + Function scope keeps the fixture on the same event loop as the test (the + default with `asyncio_mode = "auto"`), which is required so the tester + transport is serviced while the test awaits. + """ + app = _create_flet_app(request) + await app.start() + yield app + await app.teardown() + + @pytest_asyncio.fixture(scope="function") + async def flet_app_function(request): + """ + Function-scoped Flet app fixture. + + Binds and resets `ft.context.page` per test (host mode). In device mode + the bound page is the tester session, not the on-device app page. + """ + from flet.controls.context import _context_page, context + + app = _create_flet_app(request) + await app.start() + + token = _context_page.set(app.page) + context.reset_auto_update() + + try: + yield app + finally: + _context_page.reset(token) + context.disable_components_mode() + await app.teardown() 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 8777d98855..e229d23136 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 @@ -16,6 +16,7 @@ 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 @@ -114,6 +115,14 @@ class FletTestApp: skip_pump_and_settle: If `True`, the initial `pump_and_settle` after app start is skipped. + device_mode: + If `True`, the app under test runs on-device with embedded Python + (over dart_bridge), as produced by `flet build`. This session then + hosts only the `Tester` over a dedicated channel and does not run + `flet_app_main`. If `False` (default), the app runs here in-process + (host mode) against the dev `client` shell. + Env override: `FLET_TEST_DEVICE_MODE=1`. + Environment Variables: - `FLET_TEST_PLATFORM`: Overrides `test_platform`. - `FLET_TEST_DEVICE`: Overrides `test_device`. @@ -140,6 +149,7 @@ def __init__( use_http: bool = False, disable_fvm: bool = False, skip_pump_and_settle: bool = False, + device_mode: bool = False, ): self.test_platform = os.getenv("FLET_TEST_PLATFORM", test_platform) self.test_device = os.getenv("FLET_TEST_DEVICE", test_device) @@ -157,6 +167,7 @@ def __init__( ) self.__disable_fvm = get_bool_env_var("FLET_TEST_DISABLE_FVM") or disable_fvm self.__use_http = get_bool_env_var("FLET_TEST_USE_HTTP") or use_http + self.__device_mode = get_bool_env_var("FLET_TEST_DEVICE_MODE") or device_mode self.__test_path = test_path self.__flet_app_main = flet_app_main self.__skip_pump_and_settle = skip_pump_and_settle @@ -165,7 +176,7 @@ 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: Union[Tester, RemoteTester, None] = None @property def page(self) -> ft.Page: @@ -177,10 +188,11 @@ def page(self) -> ft.Page: return self.__page @property - def tester(self) -> Tester: + def tester(self) -> Union[Tester, RemoteTester]: """ - Returns an instance of `Tester` class that programmatically \ - interacts with page controls and the test environment. + Returns the tester that programmatically interacts with page controls \ + and the test environment. In device mode this is a `RemoteTester` \ + driving the app over a socket. """ if self.__tester is None: raise RuntimeError("tester is not initialized") @@ -205,10 +217,14 @@ async def main(page: ft.Page): page.theme_mode = ft.ThemeMode.LIGHT page.update() - if inspect.iscoroutinefunction(self.__flet_app_main): - await self.__flet_app_main(page) - elif callable(self.__flet_app_main): - self.__flet_app_main(page) + # In device mode the app under test runs on-device (embedded Python + # over dart_bridge); this session is tester-only and must NOT run + # the user's app. In host mode the app runs here in-process. + if not self.__device_mode: + if inspect.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.__pump_and_settle_with_timeout("start") ready.set() @@ -216,23 +232,44 @@ async def main(page: ft.Page): 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 + if self.__device_mode: + # Device mode: the app runs on-device (embedded Python over + # dart_bridge). This process only hosts the independent RemoteTester + # socket that drives the on-device WidgetTester — no Flet server, + # no app run here. + remote = RemoteTester() + self.__tcp_port = await remote.start(host="127.0.0.1", port=self.__tcp_port) + self.__tester = remote + print(f"Started remote tester on 127.0.0.1:{self.__tcp_port}") + else: + 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") + print("Started Flet app") + # Stream the Flutter test process output to the console when verbose + # (set by `flet test -v`) or when debug logging is on; otherwise hide it. stdout = asyncio.subprocess.DEVNULL stderr = asyncio.subprocess.DEVNULL - if logging.getLogger().getEffectiveLevel() == logging.DEBUG: + if ( + get_bool_env_var("FLET_TEST_VERBOSE") + or logging.getLogger().getEffectiveLevel() == logging.DEBUG + ): stdout = None stderr = None - flutter_args = ["fvm", "flutter", "test", "integration_test"] + # The resolved Flutter executable (full path, `flutter.bat` on Windows) + # is passed by `flet test`; fall back to a bare "flutter" on PATH. + flutter_exe = os.getenv("FLET_TEST_FLUTTER_EXE", "flutter") + flutter_args = ["fvm", flutter_exe, "test", self.__flutter_test_target()] if self.__disable_fvm: flutter_args.pop(0) @@ -254,15 +291,27 @@ async def main(page: ft.Page): 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.__device_mode: + # The app under test runs on-device over dart_bridge and ships its + # own assets in app.zip. The RemoteWidgetTester connects to our + # RemoteTester server over an independent socket. FLET_TEST=true lets + # the embedded app know it is running under test (page.test == True). + server_url = f"tcp://{tcp_addr}:{self.__tcp_port}" + flutter_args += [ + f"--dart-define=FLET_TEST_SERVER_URL={server_url}", + "--dart-define=FLET_TEST=true", + ] + else: + 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}" + ] self.__flutter_process = await asyncio.create_subprocess_exec( *flutter_args, @@ -272,9 +321,14 @@ async def main(page: ft.Page): ) print("Started Flutter test process.") - print("Waiting for a Flet client to connect...") + print("Waiting for the Flutter app to connect...") - while not ready.is_set(): + def connected() -> bool: + if self.__device_mode: + return self.__tester is not None and self.__tester.is_connected() + return ready.is_set() + + while not connected(): await asyncio.sleep(0.2) if self.__flutter_process.returncode is not None: raise RuntimeError( @@ -282,6 +336,27 @@ async def main(page: ft.Page): f"{self.__flutter_process.returncode}" ) + def __flutter_test_target(self) -> str: + # In device mode the driver (`integration_test/app_test.dart`) is + # generated from the template; validate it exists and is non-empty so a + # missing/empty driver surfaces as a clear error instead of a confusing + # "No tests were found" from `flutter test`. The directory target is + # used either way. + if self.__device_mode: + app_test_path = ( + Path(self.__flutter_app_dir) / "integration_test" / "app_test.dart" + ) + if not app_test_path.is_file(): + raise RuntimeError( + f"Flutter integration test driver was not generated: " + f"{app_test_path}" + ) + if not app_test_path.read_text(encoding="utf-8").strip(): + raise RuntimeError( + f"Flutter integration test driver is empty: {app_test_path}" + ) + return "integration_test" + async def teardown(self): """ Teardown Flutter integration test process. @@ -291,11 +366,13 @@ async def teardown(self): except (RuntimeError, TimeoutError) as e: print(f"Tester teardown failed: {e}") + flutter_returncode: Optional[int] = None if self.__flutter_process: print("\nWaiting for Flutter test process to exit...") try: await asyncio.wait_for(self.__flutter_process.wait(), timeout=10) - print("Flutter test process has exited.") + flutter_returncode = self.__flutter_process.returncode + print(f"Flutter test process has exited (code {flutter_returncode}).") except asyncio.TimeoutError: print("Flutter test process did not exit in time, terminating it...") self.__flutter_process.terminate() @@ -306,6 +383,20 @@ async def teardown(self): print("Force killing Flutter test process...") self.__flutter_process.kill() + # Stop the RemoteTester socket server (device mode). + if isinstance(self.__tester, RemoteTester): + await self.__tester.stop() + + # The host-side commands can all succeed while the on-device Flutter + # integration test itself fails (e.g. a widget exception fails the + # `testWidgets` body even though our find/tap assertions passed). Surface + # that as a test failure — otherwise the run is falsely green. + if flutter_returncode is not None and flutter_returncode != 0: + raise RuntimeError( + f"Flutter integration test process failed with exit code " + f"{flutter_returncode}. See the Flutter test output above." + ) + def resize_page(self, width: float, height: float): """ Resizes the page window to the specified width and height. @@ -523,7 +614,7 @@ def create_gif( same directory as the source frames. frames: Iterable of PNG-encoded frame bytes to use directly, in the order they should appear in the animation. Typically paired - with :meth:`Page.take_animation`. + with :meth:`~flet.BasePage.take_animation`. duration: Frame duration in milliseconds. Either a single `int` applied to every frame, or a sequence of `int` with one entry per frame. Pass the same list used for 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..0670e27c19 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/testing/remote_tester.py @@ -0,0 +1,282 @@ +import asyncio +import base64 +import contextlib +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 server that drives the Flutter integration-test ``WidgetTester`` over an + independent line-delimited JSON protocol (separate from Flet's own + transport). The on-device app's ``RemoteWidgetTester`` connects to this + server and executes the commands. + """ + + 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 a connection from the Flutter app. + + 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 + reader = self._reader + max_frame_length = 64 * 1024 * 1024 + while True: + try: + header = await reader.readexactly(4) + except asyncio.IncompleteReadError: + break + length = int.from_bytes(header, "big") + if length > max_frame_length: + raise ValueError("Incoming frame exceeds allowed size.") + try: + payload = await reader.readexactly(length) + except asyncio.IncompleteReadError: + break + message = json.loads(payload.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.""" + # Tear down the active client connection first. `_read_loop` blocks on + # `readexactly` until it sees EOF, and the on-device app's socket close + # does not always deliver that to us (observed on Linux), so without this + # `Server.wait_closed()` below would hang forever waiting for the still + # running `_handle_client`. Cancelling the read task lets `_handle_client` + # finish; the timeout is a final safety net. + if self._reader_task is not None and not self._reader_task.done(): + self._reader_task.cancel() + if self._writer is not None: + with contextlib.suppress(Exception): + self._writer.close() + if self._server is not None: + self._server.close() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._server.wait_closed(), timeout=5) + 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, + timeout: Optional[float] = 60, + ): + 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: dict[str, Any] = {"id": request_id, "method": method} + if params: + payload["params"] = params + + async with self._send_lock: + assert self._writer is not None + data = json.dumps(payload).encode("utf-8") + self._writer.write(len(data).to_bytes(4, "big") + data) + await self._writer.drain() + + try: + if timeout is not None: + return await asyncio.wait_for(future, timeout=timeout) + return await future + except asyncio.TimeoutError: + self._pending.pop(request_id, None) + raise TimeoutError( + f"Timeout waiting for remote tester method {method}({params})" + ) from None + + 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", _finder_params(finder)) + + async def long_press(self, finder: Finder): + await self._invoke("long_press", _finder_params(finder)) + + async def enter_text(self, finder: Finder, text: str): + await self._invoke("enter_text", {**_finder_params(finder), "text": text}) + + async def mouse_hover(self, finder: Finder): + await self._invoke("mouse_hover", _finder_params(finder)) + + async def teardown(self, timeout: Optional[float] = None): + 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 _finder_params(finder: Finder) -> dict[str, Any]: + return {"finder_id": finder.id, "finder_index": finder.index} + + +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) and not isinstance(duration, type): + 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) and not isinstance(value, type): + 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 diff --git a/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/pyproject.toml b/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/pyproject.toml index 28ce4eda91..09a0e12e24 100644 --- a/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/pyproject.toml +++ b/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/pyproject.toml @@ -16,8 +16,15 @@ dev = [ "flet-cli>={{cookiecutter.flet_version}}", "flet-desktop>={{cookiecutter.flet_version}}", "flet-web>={{cookiecutter.flet_version}}", + # Integration testing with `flet test` / pytest. The `test` extra brings in + # pytest, pytest-asyncio and the screenshot-comparison dependencies. + "flet[test]>={{cookiecutter.flet_version}}", ] +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + [tool.flet] {%- if cookiecutter.platform == "linux" %} desktop_flavor = "light" # Change to "full" to use flet-audio and flet-video diff --git a/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/src/main.py b/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/src/main.py index f8dda08180..9e936e3966 100644 --- a/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/src/main.py +++ b/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/src/main.py @@ -9,7 +9,7 @@ def increment_click(e): counter.value = str(counter.data) page.floating_action_button = ft.FloatingActionButton( - icon=ft.Icons.ADD, on_click=increment_click + icon=ft.Icons.ADD, key="increment", on_click=increment_click ) page.add( ft.SafeArea( diff --git a/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/tests/test_main.py b/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/tests/test_main.py new file mode 100644 index 0000000000..858e8fef00 --- /dev/null +++ b/sdk/python/templates/app/app/{{cookiecutter.out_dir}}/tests/test_main.py @@ -0,0 +1,22 @@ +import flet.testing as ftt + + +async def test_increment(flet_app: ftt.FletTestApp): + """Counter sample: tap the FAB and assert the counter goes 0 -> 1. + + The `flet_app` fixture is provided automatically by the flet pytest plugin. + Run with `flet test` (or `uv run pytest`) from the project directory. + """ + tester = flet_app.tester + + await tester.pump_and_settle() + + # Initial state + assert (await tester.find_by_text("0")).count == 1 + + # Tap the increment button (found by its key) and let the UI settle + await tester.tap(await tester.find_by_key("increment")) + await tester.pump_and_settle() + + # New state + assert (await tester.find_by_text("1")).count == 1 diff --git a/sdk/python/templates/build/cookiecutter.json b/sdk/python/templates/build/cookiecutter.json index 6d93f619e5..a2f0345032 100644 --- a/sdk/python/templates/build/cookiecutter.json +++ b/sdk/python/templates/build/cookiecutter.json @@ -27,6 +27,7 @@ "pwa_background_color": "#FFFFFF", "pwa_theme_color": "#0175C2", "split_per_abi": false, + "test_mode": false, "no_cdn": false, "no_wasm": false, "pyodide_version": "", diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/integration_test/app_test.dart b/sdk/python/templates/build/{{cookiecutter.out_dir}}/integration_test/app_test.dart new file mode 100644 index 0000000000..9a7fa7fd77 --- /dev/null +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/integration_test/app_test.dart @@ -0,0 +1,8 @@ +{% if cookiecutter.test_mode %}import 'package:flet_integration_test/flet_integration_test.dart'; +import 'package:{{ cookiecutter.project_name }}/main.dart' as app; + +// Device-mode integration test entry point. The app under test runs on-device +// with embedded Python over dart_bridge; a RemoteWidgetTester drives it over a +// raw socket connected to the pytest RemoteTester server (FLET_TEST_SERVER_URL). +void main() => runFletDeviceTest(appMain: app.main); +{% endif %} \ No newline at end of file diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/main.dart b/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/main.dart index a0099ee989..fdd77c643d 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/main.dart +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/lib/main.dart @@ -56,6 +56,35 @@ void main(List args) async { ext.ensureInitialized(); } + if (const bool.fromEnvironment("FLET_TEST")) { + // Under integration test (`flet test`), `BootHost` (a StatefulWidget whose + // initState awaits prepareApp() then setState()s) deadlocks the + // WidgetTester: `tester.pump()` blocks waiting for a frame that never + // arrives during this boot. Use the simpler FutureBuilder boot path (no + // animated boot-screen overlay), which the tester drives cleanly. The app + // itself — embedded Python over dart_bridge, FletApp — is identical. + runApp(FutureBuilder( + future: prepareApp(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const SizedBox.shrink(); + } + if (kIsWeb || (isDesktopPlatform() && _args.isNotEmpty)) { + return FletApp( + pageUrl: pageUrl, + assetsDir: assetsDir, + bootScreenName: bootScreenName, + bootScreenOptions: bootScreenOptions, + bootStatus: _bootStatus, + extensions: extensions, + ); + } + return _ProdApp(args: args); + }, + )); + return; + } + runApp(BootHost(args: args)); } @@ -261,7 +290,15 @@ Future prepareApp() async { _args.remove("--debug"); } - await setupDesktop(hideWindowOnStart: hideWindowOnStart); + // Linux desktop integration tests run under xvfb; waiting for the native + // ready-to-show callback can keep WidgetTester from reaching the remote + // tester connection. + await setupDesktop( + hideWindowOnStart: hideWindowOnStart, + waitUntilReadyToShow: + !(const bool.fromEnvironment("FLET_TEST") && + defaultTargetPlatform == TargetPlatform.linux), + ); if (kIsWeb) { // web mode - connect via HTTP @@ -362,4 +399,3 @@ Future runPythonApp(List args) async { args: args, ); } - diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc b/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc index 633f33c6dc..7b0dd02c37 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/linux/my_application.cc @@ -57,6 +57,11 @@ static void my_application_activate(GApplication* application) { gtk_widget_show(GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + // Realize the Flutter view while the top-level window is still hidden; this + // lets Flutter render its first frames and lets integration tests attach + // before Dart decides whether the app window should be shown. + gtk_widget_realize(GTK_WIDGET(view)); + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); gtk_widget_grab_focus(GTK_WIDGET(view)); diff --git a/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml b/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml index 02e6177fa9..2a8e481d37 100644 --- a/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml +++ b/sdk/python/templates/build/{{cookiecutter.out_dir}}/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: flet: path: ../../../../../packages/flet - serious_python: 4.1.0 + serious_python: 4.1.1 # MsgPack codec used by the dart_bridge FletBackendChannel implementation # in lib/main.dart — matches the wire format flet's existing socket diff --git a/website/docs/cli/flet-test.md b/website/docs/cli/flet-test.md new file mode 100644 index 0000000000..2a478b2003 --- /dev/null +++ b/website/docs/cli/flet-test.md @@ -0,0 +1,7 @@ +--- +title: "flet test" +--- + +import CliTest from '@site/.crocodocs/cli-test.mdx'; + + diff --git a/website/docs/getting-started/integration-testing.md b/website/docs/getting-started/integration-testing.md new file mode 100644 index 0000000000..e155ac104b --- /dev/null +++ b/website/docs/getting-started/integration-testing.md @@ -0,0 +1,404 @@ +--- +title: "Integration testing" +--- + +import TabItem from '@theme/TabItem'; +import Tabs from '@theme/Tabs'; + +Flet lets you write **integration tests** for your app and run them with the +[`flet test`](../cli/flet-test.md) command. Tests drive your app the same way a +user would — finding controls by key or text, tapping buttons, entering text and +asserting the resulting UI — while the app runs **on the target device** exactly +as it ships: a built monolithic app with embedded Python. + +Tests are written with [`pytest`](https://docs.pytest.org), so everything you +already know about pytest (fixtures, parametrization, markers, `-k` filtering) +just works. + +:::note[Prerequisites] +`flet test` builds and runs your app the same way [`flet build`](../cli/flet-build.md) +does, so the [Flutter SDK and build prerequisites](../publish/index.md) must be +installed. The first run provisions a test host (and downloads the SDK if +needed), which is slow; subsequent runs are cached and fast. +::: + +## Where tests live + +Put your tests in a `tests/` directory at the **root of your app** — a sibling of +`src/`, not inside it (`src/` is what gets packaged into the on-device app; your +test code stays on the host and drives the app): + +``` +my_app/ +├── pyproject.toml +├── src/ +│ └── main.py # your app +└── tests/ + └── test_main.py # your tests +``` + +A new app created with [`flet create`](../cli/flet-create.md) already includes a +`tests/` folder with a sample test and the required pytest configuration in +`pyproject.toml`: + +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +## Enabling tests in an existing app + +If your app wasn't created with `flet create`, enable testing by editing its +`pyproject.toml`. + +**1. Add the test dependencies** to your development dependencies. The +`flet[test]` extra brings in `pytest`, `pytest-asyncio` and the +screenshot-comparison libraries: + + + +```toml +[dependency-groups] +dev = [ + # ...your existing dev dependencies... + "flet[test]", +] +``` + + +```toml +[project.optional-dependencies] +dev = [ + # ...your existing dev dependencies... + "flet[test]", +] +``` + + + +**2. Configure pytest.** `asyncio_mode = "auto"` is **required** — it runs each +async test on the same event loop as the `flet_app` fixture: + +```toml +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +**3. Create a `tests/` directory** and add your first test (see +[Writing a test](#writing-a-test) below). + +## Writing a test + +Test functions are `async` and receive the [`flet_app`][flet.testing.FletTestApp] +fixture, which starts your app and exposes a [tester][flet.testing.Tester] to +drive it. Each test gets a **fresh app instance**, so tests are independent and +can run in any order. + +Here is the counter sample (`tests/test_main.py`) that `flet create` generates: + +```python title="tests/test_main.py" +import flet.testing as ftt + + +async def test_increment(flet_app: ftt.FletTestApp): + tester = flet_app.tester + + await tester.pump_and_settle() + + # Initial state + assert (await tester.find_by_text("0")).count == 1 + + # Tap the increment button (found by its key) and let the UI update + await tester.tap(await tester.find_by_key("increment")) + await tester.pump_and_settle() + + # New state + assert (await tester.find_by_text("1")).count == 1 +``` + +The matching app gives the button a `key` so the test can find it reliably: + +```python title="src/main.py" +import flet as ft + + +def main(page: ft.Page): + counter = ft.Text("0", size=50, data=0) + + def increment_click(e): + counter.data += 1 + counter.value = str(counter.data) + + page.floating_action_button = ft.FloatingActionButton( + icon=ft.Icons.ADD, key="increment", on_click=increment_click + ) + page.add( + ft.SafeArea( + expand=True, + content=ft.Container(content=counter, alignment=ft.Alignment.CENTER), + ) + ) + + +ft.run(main) +``` + +:::tip +Give controls you want to test a stable [`key`](../cookbook/control-refs.md) and +find them with [`find_by_key()`][flet.testing.Tester.find_by_key]. It's more +robust than matching on text, which can change with localization or formatting. +::: + +## The tester API + +[`flet_app.tester`][flet.testing.FletTestApp.tester] finds controls and drives +interactions. **Finder** methods return a [`Finder`][flet.testing.Finder]; +**action** methods take a `Finder`; and **pump** methods let the UI advance. All +methods are awaitable. + +### Finding controls + +| Method | Finds controls by | +| --- | --- | +| [`find_by_key(key)`][flet.testing.Tester.find_by_key] | their `key` | +| [`find_by_text(text)`][flet.testing.Tester.find_by_text] | exact text | +| [`find_by_text_containing(pattern)`][flet.testing.Tester.find_by_text_containing] | a regular-expression match on text | +| [`find_by_icon(icon)`][flet.testing.Tester.find_by_icon] | their icon (e.g. `ft.Icons.ADD`) | +| [`find_by_tooltip(value)`][flet.testing.Tester.find_by_tooltip] | tooltip text | + +A [`Finder`][flet.testing.Finder] reports how many controls matched (via +[`count`][flet.testing.Finder.count]) and lets you pick one with +[`first`][flet.testing.Finder.first], [`last`][flet.testing.Finder.last] or +[`at()`][flet.testing.Finder.at]: + +```python +finder = await tester.find_by_text("Item") +assert finder.count == 3 # number of matches +await tester.tap(finder.first) # first match +await tester.tap(finder.last) # last match +await tester.tap(finder.at(1)) # match at index 1 +``` + +### Interacting + +| Method | Action | +| --- | --- | +| [`tap(finder)`][flet.testing.Tester.tap] | tap a control | +| [`long_press(finder)`][flet.testing.Tester.long_press] | long-press a control | +| [`enter_text(finder, text)`][flet.testing.Tester.enter_text] | type text into a field | +| [`mouse_hover(finder)`][flet.testing.Tester.mouse_hover] | hover the mouse over a control | + +### Pumping + +The UI doesn't update instantly after an interaction. Call +[`pump_and_settle()`][flet.testing.Tester.pump_and_settle] to let the app process +events and render the result before asserting: + +```python +await tester.tap(await tester.find_by_key("submit")) +await tester.pump_and_settle() +assert (await tester.find_by_text("Done")).count == 1 +``` + +Use [`pump(duration=...)`][flet.testing.Tester.pump] to advance by a fixed amount +when you don't want to wait for everything to settle. + +## Screenshot testing + +On **Android and iOS** you can capture a full-screen screenshot of the running +app and compare it against a committed *golden* (reference) image — useful for +catching visual regressions. Full-screen capture is a device feature, so this is +**not available on desktop**. + +[`tester.take_screenshot(name)`][flet.testing.Tester.take_screenshot] captures +the screen as PNG bytes, and +[`flet_app.assert_screenshot(name, bytes)`][flet.testing.FletTestApp.assert_screenshot] +compares them against the golden image, failing the test if they differ beyond a +similarity threshold (≈99% by default): + +```python +async def test_home_screen(flet_app: ftt.FletTestApp): + tester = flet_app.tester + await tester.pump_and_settle() + + flet_app.assert_screenshot("home", await tester.take_screenshot("home")) +``` + +Golden images are **platform-specific** and stored next to your tests, under +`tests/golden///.png` — commit them to your repository. + +To record the goldens the first time (or update them after an intentional UI +change), run with `-u` (`--update-goldens`). This writes the captured screenshots +as the new reference **instead of** comparing: + + + +```bash +uv run flet test android --device-id emulator-5554 -u +``` + + +```bash +flet test android --device-id emulator-5554 -u +``` + + + +:::tip +Render each screenshot on the same device/emulator you record its golden on — +different screen sizes and densities produce different pixels. +::: + +## Running tests + +### On desktop + +From your app directory, run [`flet test`](../cli/flet-test.md). With no +arguments it targets the **current desktop** platform: + + + +```bash +uv run flet test +``` + + +```bash +flet test +``` + + + +### On a mobile emulator/simulator or device + +First, make sure a device is running. Use +[`flet emulators`](../cli/flet-emulators.md) to list available emulators and +start one, then [`flet devices`](../cli/flet-devices.md) to get the id of a +running device: + + + +```bash +uv run flet emulators # list available emulators +uv run flet emulators start # start an emulator +uv run flet devices # list running devices and their ids +``` + + +```bash +flet emulators # list available emulators +flet emulators start # start an emulator +flet devices # list running devices and their ids +``` + + + +Then pass the platform as the first argument and the device id with +`--device-id` (`-d`): + + + +```bash +uv run flet test android --device-id emulator-5554 +uv run flet test ios --device-id +``` + + +```bash +flet test android --device-id emulator-5554 +flet test ios --device-id +``` + + + +### Useful options + +| Option | Description | +| --- | --- | +| `[platform]` | `macos`, `linux`, `windows`, `ios`, `android` (defaults to the current desktop) | +| `-d`, `--device-id` | Target device/emulator id (required for `ios`/`android`) | +| `-k ` | Only run tests matching a pytest keyword expression | +| `--tests-dir ` | Directory containing the tests (default: `tests`) | +| `-v` | Verbose — stream the underlying Flutter build/launch output | + +### Running specific tests + +Use `-k` to run only the tests matching a pytest keyword expression — handy while +iterating on a single test: + + + +```bash +uv run flet test -k test_screenshot +``` + + +```bash +flet test -k test_screenshot +``` + + + +`-k` accepts the full pytest expression syntax, e.g. `-k screenshot`, +`-k "increment or screenshot"`, or `-k "not slow"`. + +When [running with pytest directly](#running-with-pytest-directly) you can also +select a test by its node id: + +```bash +uv run pytest tests/test_main.py::test_increment # a single test +uv run pytest tests/test_main.py # one file +``` + +### Running with pytest directly + +Because tests are plain pytest, you can also run them with `pytest`. The Flet +pytest plugin provisions the test host on demand, so this works without running +`flet test` first (it targets the current desktop platform): + + + +```bash +uv run pytest +``` + + +```bash +pytest +``` + + + +To see the live build/launch output (and the app's `debugPrint`s) while running +under pytest, enable CLI logging at debug level: + +```bash +uv run pytest -s -o log_cli=true -o log_cli_level=DEBUG +``` + +`pytest` has no options of its own for the device target or goldens, so the +`flet test` options map to environment variables: + +| `flet test` option | Environment variable | +| --- | --- | +| `[platform]` | `FLET_TEST_PLATFORM` (e.g. `ios`, `android`) | +| `-d`, `--device-id` | `FLET_TEST_DEVICE` | +| `-u`, `--update-goldens` | `FLET_TEST_GOLDEN=1` | + +```bash +# run on an iOS simulator and (re)record golden screenshots +FLET_TEST_PLATFORM=ios FLET_TEST_DEVICE= FLET_TEST_GOLDEN=1 uv run pytest +``` + +When unset, `pytest` targets the current desktop platform and compares (rather +than records) screenshots. + +:::note[How it works] +`flet test` provisions a Flutter test host from your app (the same pipeline as +`flet build`), embeds your Python code, and runs it on the device. The test code +runs on your computer and drives the on-device app over an independent channel — +so you're testing your app exactly as it ships, including the embedded Python +runtime, not a simulated approximation. +::: diff --git a/website/docs/types/finder.md b/website/docs/types/finder.md deleted file mode 100644 index 8446c3b26f..0000000000 --- a/website/docs/types/finder.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Finder" ---- - -::: flet.testing.finder.Finder diff --git a/website/docs/types/flettestapp.md b/website/docs/types/flettestapp.md deleted file mode 100644 index 93170351a6..0000000000 --- a/website/docs/types/flettestapp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "FletTestApp" ---- - -::: flet.testing.flet_test_app.FletTestApp diff --git a/website/docs/types/tester.md b/website/docs/types/tester.md deleted file mode 100644 index 415180a981..0000000000 --- a/website/docs/types/tester.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Tester" ---- - -::: flet.testing.tester.Tester diff --git a/website/docs/types/testing/disposalmode.md b/website/docs/types/testing/disposalmode.md new file mode 100644 index 0000000000..64747d6b1e --- /dev/null +++ b/website/docs/types/testing/disposalmode.md @@ -0,0 +1,7 @@ +--- +title: "DisposalMode" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/testing/finder.md b/website/docs/types/testing/finder.md new file mode 100644 index 0000000000..71df9954c4 --- /dev/null +++ b/website/docs/types/testing/finder.md @@ -0,0 +1,7 @@ +--- +title: "Finder" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/testing/flettestapp.md b/website/docs/types/testing/flettestapp.md new file mode 100644 index 0000000000..9957c97933 --- /dev/null +++ b/website/docs/types/testing/flettestapp.md @@ -0,0 +1,7 @@ +--- +title: "FletTestApp" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/types/testing/tester.md b/website/docs/types/testing/tester.md new file mode 100644 index 0000000000..2d9835a48b --- /dev/null +++ b/website/docs/types/testing/tester.md @@ -0,0 +1,7 @@ +--- +title: "Tester" +--- + +import {ClassAll} from '@site/src/components/crocodocs'; + + diff --git a/website/docs/updates/breaking-changes/data-channel-protocol-upgrade.md b/website/docs/updates/breaking-changes/data-channel-protocol-upgrade.md new file mode 100644 index 0000000000..588165486f --- /dev/null +++ b/website/docs/updates/breaking-changes/data-channel-protocol-upgrade.md @@ -0,0 +1,111 @@ +--- +title: "Flet protocol framing upgraded for DataChannel support" +--- + +# Flet protocol framing upgraded for DataChannel support + +:::note +This guide is accurate as of Flet 0.86.0. Later releases might add new APIs or +additional migration paths. + +The [breaking changes and deprecations index](.) lists the guides created for each release. +::: + +## Summary + +Flet 0.86.0 introduces dedicated **data channels** for widgets that need to +move bulk binary data (image frames, audio buffers, ML tensors) between Dart +and Python without going through the MsgPack control protocol. + +Enabling this required a one-time upgrade of the Flet protocol's wire format: + +- **Stream-oriented transports** (`flet run` dev mode over UDS / TCP) now use + **4-byte little-endian length-prefixed framing**. The previous streaming + `msgpack.Unpacker.feed` decode is gone. +- **Every transport** (sockets, WebSocket, `dart_bridge` FFI, Pyodide + `postMessage`) now puts a **1-byte type discriminator** at the head of each + packet: + - `0x00` — MsgPack-encoded Flet control frame (widget state, events) — same + contents as before, just with a type byte in front. + - `0x01` — raw DataChannel frame (`[channel_id:u32 LE][payload]`). + +The new format is **not backwards-compatible**: a Flet 0.85 client cannot talk +to a Flet 0.86 server, and vice versa. The `flet-cli` dev server and the +in-process Python runtime were upgraded together in this release. + +## Background + +Flet's previous wire format on UDS / TCP relied on `msgpack.Unpacker.feed` to +re-assemble messages from arbitrary byte-stream chunks. That works for +MsgPack-only traffic but doesn't compose with a second logical stream of raw +bytes — you can't tell a partial MsgPack value from the start of a raw frame +without an out-of-band marker. + +Adding the 1-byte type byte + 4-byte length prefix solves this in the smallest +possible way: + +- Receivers read `[length][type][...]`, fan out by type byte, no streaming + decoder state to maintain. +- Message-oriented transports (WebSocket, `postMessage`, `dart_bridge`) drop + the length prefix since each message is already one packet; they keep the + type byte. +- Per-frame overhead is 5 bytes (message-oriented) or 9 bytes (stream-oriented) + — under 1% at any payload size that motivates a data channel. + +## Migration guide + +### Most users — nothing to change + +If you only use `flet build` artifacts or run `flet run` with the matching +`flet` package version (the default install pulls both at once), there's +nothing to do. The CLI and the runtime are version-locked. + +### Users running `flet-cli` and the `flet` Python package from different installs + +Make sure both come from the same release. The common gotcha is a global +`flet` install plus a project-local `flet` in `.venv` at different versions +— upgrade or pin both to ≥ `0.86.0` (or both to ≤ `0.85.x`). + +A version mismatch will surface as a connection failure during `flet run` +with a parse error in the dev-server log. + +### Users with custom backends or sidecars speaking the Flet protocol + +The wire format changed. Update your encoder/decoder: + +- **Inbound on a stream transport** (UDS / TCP): read 4 bytes (length, u32 LE), + read that many bytes, the first byte is the type discriminator, the rest is + the payload. +- **Inbound on a message transport** (WebSocket, `postMessage`): each + message's first byte is the type discriminator, the rest is the payload. +- **Outbound**: same shape, mirrored. + +Treat `0x01` (raw DataChannel) frames as opaque if you don't use data channels +— forward them along or drop them, never feed them to your MsgPack decoder. + +### Code that depended on `StreamingMsgpackDeserializer` + +The Dart-side class `package:flet/src/transport/streaming_msgpack_deserializer.dart` +is removed. There are no public consumers — it was an internal helper for +stream-transport framing that's no longer needed now that every packet is +length-delimited. If you imported it (you shouldn't have), decode each inbound +buffer with one-shot `msgpack.deserialize(bytes)` instead. + +### Custom widgets that override `MatplotlibChartCanvas` + +`MatplotlibChartCanvas` now transports its `apply_full` / `apply_diff` / +`clear` frames through a [DataChannel] rather than `_invoke_method` arguments. +The Python-facing method signatures are unchanged (`apply_full(image_bytes: +bytes)`, etc.), and they remain the documented API. But if you subclassed the +canvas and overrode the Dart-side `_invokeMethod` handler, that override no +longer fires — the Dart side now consumes a 1-byte opcode + PNG payload from +the channel directly. + +## Timeline + +- Changed in: `0.86.0` + +## References + +- API: `flet.DataChannel` (Python) and `FletBackend.openDataChannel` (Dart) — see the module docstrings in `flet/data_channel.py`; dedicated reference pages will be added in a follow-up. +- Release notes: [Flet 0.86.0](../release-notes.md) diff --git a/website/docs/updates/breaking-changes/default-bundled-python-3-14.md b/website/docs/updates/breaking-changes/default-bundled-python-3-14.md new file mode 100644 index 0000000000..c9a8d8cff3 --- /dev/null +++ b/website/docs/updates/breaking-changes/default-bundled-python-3-14.md @@ -0,0 +1,89 @@ +--- +title: "Default bundled Python version is now 3.14" +--- + +# Default bundled Python version is now 3.14 + +:::note +This guide is accurate as of Flet 0.86.0. Later releases might add new APIs or +additional migration paths. + +The [breaking changes and deprecations index](.) lists the guides created for each release. +::: + +## Summary + +Flet 0.86.0 introduces multi-version bundled CPython support to `flet build` +and `flet publish`. The bundled Python interpreter is now selected per build +(see [Choosing a Python version](/docs/publish#choosing-a-python-version)) and +the **default is the latest supported stable** — currently CPython 3.14. +Earlier Flet releases implicitly bundled CPython 3.12 via the single-version +`serious_python` 1.x. + +Apps that depend on Python packages whose pre-built wheels aren't yet +available for 3.14 (typically packages with C/Rust extensions that haven't +caught up to the new ABI) need to pin a previous Python version. + +## Background + +Multi-version Python support landed in +[#6577](https://github.com/flet-dev/flet/pull/6577) and is tracked by the +central registry in +[`flet_cli/utils/python_versions.py`](https://github.com/flet-dev/flet/blob/main/sdk/python/packages/flet-cli/src/flet_cli/utils/python_versions.py). +The supported short versions are `3.12`, `3.13`, and `3.14` (each pinned to a +specific CPython patch + Pyodide release on the registry side). + +The default flips to the latest stable so new projects get the most recent +Python by default. Existing projects without a pin start picking it up too on +their next build. + +## Migration guide + +You can pin a different Python version in three equivalent ways. Use whichever +fits your project layout. + +### Pin in `pyproject.toml` (recommended) + +Add or update `[project].requires-python`: + +```toml +[project] +requires-python = ">=3.12,<3.13" +``` + +`flet build` parses the specifier and picks the **highest supported short +version** that satisfies it. So `>=3.12,<3.13` resolves to 3.12, +`>=3.13,<3.14` resolves to 3.13, and `>=3.14` resolves to 3.14 (the default). + +### Pin via the CLI flag + +Pass `--python-version` on every invocation: + +```bash +flet build apk --python-version 3.12 +flet publish --python-version 3.12 +``` + +The CLI flag overrides anything in `pyproject.toml`. + +### Pin via environment variable + +Export `SERIOUS_PYTHON_VERSION` in the shell that runs `flet build`: + +```bash +export SERIOUS_PYTHON_VERSION=3.12 +flet build apk +``` + +Useful in CI pipelines where you don't want to thread the flag through every +job. + +## Timeline + +- Changed in: `0.86.0` + +## References + +- API documentation: [Choosing a Python version](/docs/publish#choosing-a-python-version) +- Issues and PRs: [#6577](https://github.com/flet-dev/flet/pull/6577) +- Release notes: [Flet 0.86.0](../release-notes.md) diff --git a/website/docs/updates/breaking-changes/removed-pyodide-version-export.md b/website/docs/updates/breaking-changes/removed-pyodide-version-export.md new file mode 100644 index 0000000000..ec6e96f870 --- /dev/null +++ b/website/docs/updates/breaking-changes/removed-pyodide-version-export.md @@ -0,0 +1,78 @@ +--- +title: "flet.version.pyodide_version and PYODIDE_VERSION removed" +--- + +# `flet.version.pyodide_version` and `PYODIDE_VERSION` removed + +:::note +This guide is accurate as of Flet 0.86.0. Later releases might add new APIs or +additional migration paths. + +The [breaking changes and deprecations index](.) lists the guides created for each release. +::: + +## Summary + +Flet 0.86.0 removed the module-level `flet.version.pyodide_version` attribute +and the matching `PYODIDE_VERSION` constant. The single fixed-string export no +longer makes sense now that Pyodide is selected **per build** as part of the +multi-version bundled Python support — the bundled release depends on which +Python version your app pins, not a global default. + +## Background + +Earlier Flet releases used a single Pyodide pin in `flet.version` to drive the +`flet --version` output and let tooling read which Pyodide release `flet build +web` would bundle. With +[multi-version Python support](/docs/publish#choosing-a-python-version) the +mapping is now Python-version-aware (`3.12 → 0.27.7`, `3.13 → 0.29.4`, +`3.14 → 314.0.0`). It is loaded on demand from +[python-build's date-keyed `manifest.json`](https://github.com/flet-dev/python-build) +— the single source of truth shared with serious_python — via +[`flet_cli/utils/python_versions.py`](https://github.com/flet-dev/flet/blob/main/sdk/python/packages/flet-cli/src/flet_cli/utils/python_versions.py), +so it is no longer hand-maintained. + +## Migration guide + +If you imported the constant somewhere — typically from `flet.version` — +switch to looking the release up from the new registry. + +Code before migration: + +```python +import flet.version + +print(f"Bundled Pyodide: {flet.version.pyodide_version}") +``` + +Code after migration: + +```python +from flet_cli.utils.python_versions import get_supported_python_versions + +for release in get_supported_python_versions(): + print(f" {release.short} → Pyodide {release.pyodide}") +``` + +If you only need the version that `flet build` is about to bundle, resolve +the Python release the same way the CLI does: + +```python +from flet_cli.utils.python_versions import ( + get_default_python_version, + get_release, +) + +release = get_release(get_default_python_version()) +print(f"Default Pyodide: {release.pyodide}") +``` + +## Timeline + +- Removed in: `0.86.0` + +## References + +- API documentation: [Choosing a Python version](/docs/publish#choosing-a-python-version) +- Issues and PRs: [#6577](https://github.com/flet-dev/flet/pull/6577) +- Release notes: [Flet 0.86.0](../release-notes.md) diff --git a/website/sidebars.yml b/website/sidebars.yml index 80f75e725c..7d32fe2aeb 100644 --- a/website/sidebars.yml +++ b/website/sidebars.yml @@ -6,6 +6,7 @@ docs: - getting-started/installation.md - getting-started/create-flet-app.md - getting-started/running-app.md + - getting-started/integration-testing.md - getting-started/testing-on-mobile.md Tutorials: - Calculator: tutorials/calculator.md @@ -378,6 +379,11 @@ docs: GitHubOAuthProvider: types/auth/githuboauthprovider.md GoogleOAuthProvider: types/auth/googleoauthprovider.md - types/auth/user.md + Testing: + - types/testing/flettestapp.md + - types/testing/tester.md + - types/testing/finder.md + - types/testing/disposalmode.md Base Controls: - controls/adaptivecontrol.md - controls/basecontrol.md @@ -493,9 +499,7 @@ docs: - types/duration.md - types/filepickerfile.md - types/filepickeruploadfile.md - - types/finder.md - types/flip.md - - types/flettestapp.md - Geolocator: - services/geolocator/types/foregroundnotificationconfiguration.md - services/geolocator/types/geolocatorandroidconfiguration.md @@ -597,7 +601,6 @@ docs: - types/size.md - types/strutstyle.md - types/templateroute.md - - types/tester.md - types/textdecoration.md - types/textselection.md - types/textspan.md @@ -903,6 +906,7 @@ docs: flet publish: cli/flet-publish.md flet run: cli/flet-run.md flet serve: cli/flet-serve.md + flet test: cli/flet-test.md Python packages built in Flet: reference/binary-packages-android-ios.md Environment Variables: reference/environment-variables.md