Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

### Bug fixes

* Fix `flet test` failing when the app under test starts slowly, as on the first run after a build: the test now waits until the app finishes initializing its first page ([#6993](https://github.com/flet-dev/flet/issues/6993), [#6995](https://github.com/flet-dev/flet/pull/6995)) by @ndonkoHenri.
* Fix `use_effect()` losing setup-returned cleanup callbacks after a component re-renders, so they run before dependency-triggered setup and on unmount; cancel pending async setup tasks on unmount ([#6963](https://github.com/flet-dev/flet/issues/6963), [#6983](https://github.com/flet-dev/flet/pull/6983)) by @mangod12.
* iOS apps built with `flet build ipa` no longer fail App Store Connect validation with ITMS-90683 (missing `NSPhotoLibraryUsageDescription` and `NSCameraUsageDescription` purpose strings): the `file_picker` plugin is updated to 13.x, which picks photos with the system photo picker and no longer links DKImagePickerController's camera and photo-library code. `device_info_plus`, `package_info_plus` and `share_plus` move to their next major versions with it (`win32` 6). `FilePicker.save_file()` on desktop now creates the chosen file (empty when no `src_bytes` are given) before returning its path by @FeodorFitsner.
* Fix `Tester.find_by_key()` not finding `Tab` and `PopupMenuItem` controls by their keys ([#6965](https://github.com/flet-dev/flet/issues/6965), [#6976](https://github.com/flet-dev/flet/pull/6976)) by @RezaShamsipour.
Expand Down
56 changes: 56 additions & 0 deletions packages/flet_integration_test/lib/src/app_startup.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import 'dart:async';

import 'package:flet/flet.dart';
import 'package:flutter_test/flutter_test.dart';

/// Pumps frames until the on-device app has loaded its first page.
///
/// The embedded Python app can take several seconds to start, for example on
/// the first launch after a build, when antivirus software scans the bundle,
/// so a fixed number of frames is not enough. Throws when the app reports a
/// startup error or has not loaded within [timeout].
Future<void> waitForAppLoaded(WidgetTester tester,
{Duration timeout = const Duration(minutes: 2)}) async {
final deadline = tester.binding.clock.now().add(timeout);
while (true) {
await tester.pump(const Duration(milliseconds: 100));
final backend = _findFletBackend(tester);
if (backend != null && !backend.isLoading) {
if (backend.error.isNotEmpty) {
throw Exception("The app failed to start: ${backend.error}");
}
// The registration response clears isLoading before Python runs main().
// The test_ready page patch follows main's initial UI updates.
if (backend.page.internals?["test_ready"] == true) {
return;
}
}
if (tester.binding.clock.now().isAfter(deadline)) {
throw TimeoutException(
"The app did not load its first page within $timeout.");
}
}
}

/// Returns the [FletBackend] of the first [FletApp], or `null` before the app
/// has built one.
///
/// The backend is provided below [FletApp], so it is looked up from the
/// app's descendants; the provider's own element can't see it.
FletBackend? _findFletBackend(WidgetTester tester) {
final apps = find.byType(FletApp);
if (apps.evaluate().isEmpty) {
return null;
}
final elements = find
.descendant(of: apps.first, matching: find.byWidgetPredicate((_) => true))
.evaluate();
for (final element in elements) {
try {
return FletBackend.of(element);
} catch (_) {
continue;
}
}
return null;
}
9 changes: 4 additions & 5 deletions packages/flet_integration_test/lib/src/device_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

import 'app_startup.dart';
import 'remote_widget_tester.dart';

/// Device-mode integration test entry point for an app built with `flet build`.
Expand All @@ -30,12 +31,10 @@ void runFletDeviceTest({required void Function(List<String>) appMain}) {
}

// 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.
// pump frames here, in the test body, until it has loaded its first
// page, so no remote command arrives before the app's UI exists.
appMain(const []);
for (var i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 100));
}
await waitForAppLoaded(tester);

// Connect the remote tester last; the Python RemoteTester server unblocks
// once connected and drives the app from here on.
Expand Down
94 changes: 94 additions & 0 deletions packages/flet_integration_test/test/app_startup_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'dart:async';

import 'package:flet/flet.dart';
import 'package:flet_integration_test/src/app_startup.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

// Keep the real FletApp/provider tree, with a small page renderer that avoids
// platform services and renders when the page patch arrives.
class _PageExtension extends FletExtension {
@override
Widget? createWidget(Key? key, Control control) {
if (control.type != 'Page') return null;
return ListenableBuilder(
listenable: control,
builder: (_, __) => Text(control.getString('test_text', '')!),
);
}
}

Future<FletBackend> _mountApp(WidgetTester tester) async {
late FletBackend backend;
await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: FletApp(
pageUrl: 'mock',
assetsDir: '',
extensions: [_PageExtension()],
onBackendCreated: (created) => backend = created,
),
));
return backend;
}

void main() {
testWidgets('registration alone does not finish the startup wait',
(tester) async {
final backend = await _mountApp(tester);
backend.isLoading = false; // Registration arrives before main().
Timer(const Duration(milliseconds: 500), () {
backend.page.update({
'test_text': 'Ready',
'_internals': {'test_ready': true},
}, shouldNotify: true);
});

await waitForAppLoaded(tester);

// The wait must also pump the frame containing main's final page patch.
expect(find.text('Ready'), findsOneWidget);
});

testWidgets('an intentionally empty app can finish loading', (tester) async {
final backend = await _mountApp(tester);
backend.isLoading = false;
backend.page.update({
'_internals': {'test_ready': true},
}, shouldNotify: true);

await waitForAppLoaded(tester);
});

testWidgets('errors after registration fail instead of connecting the tester',
(tester) async {
final backend = await _mountApp(tester);
backend.isLoading = false;
Timer(const Duration(milliseconds: 300), () {
backend.error = 'Initialization failed';
});

await expectLater(
() => waitForAppLoaded(tester),
throwsA(isA<Exception>().having((e) => e.toString(), 'message',
contains('Initialization failed'))));
});

testWidgets('a registered app that never initializes times out',
(tester) async {
final backend = await _mountApp(tester);
backend.isLoading = false;

await expectLater(
() => waitForAppLoaded(tester, timeout: const Duration(seconds: 1)),
throwsA(isA<TimeoutException>()),
);
});

testWidgets('a missing FletApp times out', (tester) async {
await expectLater(
() => waitForAppLoaded(tester, timeout: const Duration(seconds: 1)),
throwsA(isA<TimeoutException>()),
);
});
}
11 changes: 11 additions & 0 deletions sdk/python/packages/flet/src/flet/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,14 @@ async def on_session_created(session: Session):
session: Active page session.
"""

def mark_test_ready():
# Registration precedes main(). Send readiness in a page patch only
# after main's initial updates, including for an intentionally empty
# app. Generator apps are ready after their first yielded update.
if session.page.test and not session.page._internals.get("test_ready"):
session.page._internals["test_ready"] = True
session.page.update()

logger.info("App session started")
try:
assert main is not None
Expand All @@ -385,15 +393,18 @@ async def on_session_created(session: Session):
elif inspect.isasyncgenfunction(main):
async for _ in main(session.page):
await session.after_event(session.page)
mark_test_ready()

elif inspect.isgeneratorfunction(main):
for _ in main(session.page):
await session.after_event(session.page)
mark_test_ready()
else:
# run synchronously
main(session.page)

await session.after_event(session.page)
mark_test_ready()

except Exception as e:
logger.error("Unhandled error in main() handler", exc_info=True)
Expand Down
117 changes: 117 additions & 0 deletions sdk/python/packages/flet/tests/test_app_test_ready.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import asyncio
import importlib

import msgpack
import pytest

import flet as ft
from flet.messaging.connection import Connection
from flet.messaging.protocol import MessageAction, configure_encode_object_for_msgpack
from flet.messaging.session import Session
from flet.pubsub.pubsub_hub import PubSubHub

_on_session_created = importlib.import_module("flet.app").__get_on_session_created


class _RecordingConnection(Connection):
def __init__(self):
super().__init__()
self.pubsubhub = PubSubHub()
self.messages = []

def send_message(self, message):
self.messages.append(
msgpack.packb(
[message.action, message.body],
default=configure_encode_object_for_msgpack(ft.BaseControl),
)
)


def _session(test=True):
conn = _RecordingConnection()
session = Session(conn)
session.page.test = test
# Match registration: snapshot the page before main() runs.
msgpack.packb(
session.get_page_patch(),
default=configure_encode_object_for_msgpack(ft.BaseControl),
)
return session, conn


@pytest.mark.asyncio
async def test_readiness_follows_delayed_main_and_its_ui_updates():
session, conn = _session()
started = asyncio.Event()
release = asyncio.Event()

async def main(page):
page.add(ft.Text("Loading"))
started.set()
await release.wait()
page.controls = [ft.Text("Ready")]
page.update()

task = asyncio.create_task(_on_session_created(main)(session))
try:
await started.wait()
assert not session.page._internals.get("test_ready")
assert conn.messages
assert all(b"test_ready" not in m for m in conn.messages)
finally:
release.set()
await task

assert session.page._internals["test_ready"] is True
assert b"Ready" in conn.messages[-2]
assert b"test_ready" in conn.messages[-1]


@pytest.mark.asyncio
@pytest.mark.parametrize("test", [True, False])
async def test_empty_main_is_ready_only_in_test_mode(test):
session, conn = _session(test=test)
await _on_session_created(lambda page: None)(session)

assert bool(session.page._internals.get("test_ready")) is test
assert any(b"test_ready" in m for m in conn.messages) is test


@pytest.mark.asyncio
async def test_main_failure_reports_error_without_readiness():
session, conn = _session()

async def main(page):
raise RuntimeError("Initialization failed")

await _on_session_created(main)(session)

assert not session.page._internals.get("test_ready")
action, body = msgpack.unpackb(conn.messages[-1], strict_map_key=False)
assert action == MessageAction.SESSION_CRASHED.value
assert "Initialization failed" in body["message"]


@pytest.mark.asyncio
@pytest.mark.parametrize("async_generator", [True, False])
async def test_generator_main_is_ready_after_first_yield(async_generator):
session, conn = _session()
ready_during_iteration = []

async def async_main(page):
page.controls.append(ft.Text("Ready"))
yield
ready_during_iteration.append(page._internals.get("test_ready"))
yield

def sync_main(page):
page.controls.append(ft.Text("Ready"))
yield
ready_during_iteration.append(page._internals.get("test_ready"))
yield

await _on_session_created(async_main if async_generator else sync_main)(session)

assert ready_during_iteration == [True]
assert sum(b"test_ready" in m for m in conn.messages) == 1
Loading