From 665b0bf935fbcd7cdd7b576969d56a0a6a0bfd29 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 4 Aug 2026 21:50:15 +0200 Subject: [PATCH] Stop two keyboard tests hanging, and make the CI mirror mirror CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures, both pre-existing and both invisible as failures — each reported only as "did not complete" after stalling the suite. app/test/keyboard_operability_test.dart awaited writeLog() straight from the test body. LogStorageService is backed by a FileDocumentStore there, so that awaits real dart:io, and the fake-async zone never completes it: the test hangs rather than fails. Seeding now happens inside runAsync alongside the pumping, which is the only zone where that write can resolve. The screen's own initState load needs the same real clock, and pumpAndSettle() cannot be used to wait for it — inside runAsync it waits on a frame scheduler this screen never reports as idle — so the helper pumps a bounded number of times instead. 473 app tests now pass in 14s. scripts/ci_mirror.sh ran bare pytest while python-tests.yml runs it under xvfb-run. Without a display the gate's real-Tk tests collapse: the focus ring has nothing to walk, so three TestRealFocusRing tests fail locally and pass in CI. The mirror was reporting red on a green tree, which is worse than not mirroring at all. It now uses the workflow's exact xvfb-run invocation and fails loudly if xvfb-run is missing. Neither change touches production code: the delete control and its id-gate were already correct, and the Python suite already passed under a display (609 passed, 100% coverage). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014dzgcZxmpS3VtmLci2sbxj --- app/test/keyboard_operability_test.dart | 42 +++++++++++++++---------- scripts/ci_mirror.sh | 15 ++++++++- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/app/test/keyboard_operability_test.dart b/app/test/keyboard_operability_test.dart index 88ba543..b8b0a7d 100644 --- a/app/test/keyboard_operability_test.dart +++ b/app/test/keyboard_operability_test.dart @@ -157,7 +157,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await LogStorageService.instance.writeLog({ + await _pumpHistoryScreen(tester, { '2026-07-30': [ const FoodEntry( id: 'has-id', @@ -173,14 +173,6 @@ void main() { ], }); - // HistoryScreen loads via a fire-and-forget Future in initState that the - // frame scheduler does not track, so settle inside runAsync. - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await Future.delayed(const Duration(milliseconds: 50)); - await tester.pumpAndSettle(); - }); - // Delete used to be bound only to onLongPress, which has no keyboard // equivalent whatsoever. expect( @@ -198,7 +190,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - await LogStorageService.instance.writeLog({ + await _pumpHistoryScreen(tester, { '2026-07-30': [ const FoodEntry( time: '2026-07-30T09:00:00+02:00', @@ -213,12 +205,6 @@ void main() { ], }); - await tester.runAsync(() async { - await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); - await Future.delayed(const Duration(milliseconds: 50)); - await tester.pumpAndSettle(); - }); - // Delete stays id-only to avoid ambiguous time+desc matches, so the // control must be absent rather than present-and-failing. expect(find.byTooltip('Delete entry'), findsNothing); @@ -228,3 +214,27 @@ void main() { /// A do-nothing month-navigation callback. void _noop() {} + +/// Seed [log] to disk, then pump [HistoryScreen] until its load has painted. +/// +/// Both halves must happen inside `runAsync`, and that is the whole point of +/// this helper. `LogStorageService` here is backed by a `FileDocumentStore`, +/// so `writeLog` awaits real `dart:io`; awaited from the test body it waits +/// on a `Future` the fake-async zone never completes, and the test hangs +/// rather than failing -- reported only as "did not complete" once the suite +/// times out. `HistoryScreen` then loads through a fire-and-forget `Future` +/// in `initState`, which needs the same real clock to resolve. +/// +/// The pumping is a bounded manual loop rather than `pumpAndSettle()`, which +/// inside `runAsync` waits on a frame scheduler this screen never reports as +/// idle -- another hang, with the same unhelpful symptom. +Future _pumpHistoryScreen(WidgetTester tester, DayLog log) async { + await tester.runAsync(() async { + await LogStorageService.instance.writeLog(log); + await tester.pumpWidget(const MaterialApp(home: HistoryScreen())); + for (var i = 0; i < 10; i++) { + await Future.delayed(const Duration(milliseconds: 20)); + await tester.pump(); + } + }); +} diff --git a/scripts/ci_mirror.sh b/scripts/ci_mirror.sh index 3639d7d..2be99e0 100755 --- a/scripts/ci_mirror.sh +++ b/scripts/ci_mirror.sh @@ -85,7 +85,20 @@ run_precommit_all_files() { run_pytest_clean_venv() { log "pytest in the clean venv (mirrors the Tests workflow)" - "$VENV_DIR/bin/python" -m pytest "$@" || fail "pytest (clean requirements.txt venv)" + # Under xvfb-run, exactly as python-tests.yml does. The gate's real-Tk + # tests walk a focus ring via tk_focusNext, which consults mapped-ness + # and real focus: on a developer's live display the window never takes + # focus and the ring collapses to one widget, so TestRealFocusRing fails + # here while passing in CI. Mirroring the workflow's display is the whole + # point of this script -- without it the mirror reports red on a green + # tree, which is worse than not mirroring at all. + if ! command -v xvfb-run >/dev/null 2>&1; then + fail "xvfb-run is not on PATH (install xorg-server-xvfb); the real-Tk +gate tests need the same virtual display the Tests workflow gives them" + fi + xvfb-run -a -s "-screen 0 1600x1200x24" \ + "$VENV_DIR/bin/python" -m pytest "$@" \ + || fail "pytest (clean requirements.txt venv)" } # Run flutter with the calling git hook's environment stripped.