Skip to content

Commit d52e341

Browse files
committed
[red] Guard Android Blockly lifecycle
Signed-off-by: Viwat Vchirawongkwin <viwat.v@chula.ac.th>
1 parent 0b6b6b6 commit d52e341

12 files changed

Lines changed: 1154 additions & 45 deletions

‎app/integration_test/blockly_webview_suite.dart‎

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
77
import 'package:flutter/services.dart';
88
import 'package:flutter_riverpod/flutter_riverpod.dart';
99
import 'package:flutter_test/flutter_test.dart';
10+
import 'package:webview_flutter/webview_flutter.dart';
1011

1112
import 'package:pyble/app/providers.dart';
1213
import 'package:pyble/blocks/blocks.dart';
@@ -281,6 +282,12 @@ Future<void> _pumpUntil(
281282
);
282283
}
283284

285+
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
286+
await tester.ensureVisible(finder);
287+
await tester.pumpAndSettle();
288+
await tester.tap(finder);
289+
}
290+
284291
Widget _testApp(ProviderContainer container, {required bool showBlocks}) {
285292
return UncontrolledProviderScope(
286293
container: container,
@@ -313,6 +320,11 @@ void registerBlocklyWebViewIntegrationTests() {
313320

314321
controller.receiveBridgeMessage(_seedSnapshot());
315322

323+
// Android creates the real platform view lazily after the native Flutter
324+
// surface is attached. Reproduce that production lifecycle instead of
325+
// making a WebView the integration runner's first rendered frame.
326+
await tester.pumpWidget(_testApp(container, showBlocks: false));
327+
await tester.pumpAndSettle();
316328
await tester.pumpWidget(_testApp(container, showBlocks: true));
317329

318330
await _pumpUntil(
@@ -364,6 +376,82 @@ void registerBlocklyWebViewIntegrationTests() {
364376
);
365377
}
366378

379+
// Reload the same platform view, not merely its Flutter wrapper. The
380+
// retained graph must restore through a fresh page generation, and an
381+
// exact duplicate readiness event must remain host-control traffic.
382+
final WebViewWidget webView = tester.widget<WebViewWidget>(
383+
find.byType(WebViewWidget),
384+
);
385+
final WebViewController liveWebViewController =
386+
WebViewController.fromPlatform(webView.platform.params.controller);
387+
final int beforeReloadRevision = container
388+
.read(blocksDocumentProvider)
389+
.program!
390+
.revision;
391+
await liveWebViewController.reload();
392+
await _pumpUntil(
393+
tester,
394+
() {
395+
final BlocksDocument state = container.read(blocksDocumentProvider);
396+
return state.status == BlocksStatus.ready &&
397+
state.error == null &&
398+
state.program != null &&
399+
state.program!.revision > beforeReloadRevision &&
400+
state.program!.source == _expectedGpioSource &&
401+
controller.hasActiveReadyHost;
402+
},
403+
reason:
404+
'the same WebView did not restore through a fresh page generation',
405+
);
406+
final int afterReloadRevision = container
407+
.read(blocksDocumentProvider)
408+
.program!
409+
.revision;
410+
final String supersededSnapshot = jsonEncode(<String, Object?>{
411+
'version': 1,
412+
'type': 'snapshot',
413+
'hostEpoch': 1,
414+
'revision': afterReloadRevision + 100,
415+
'source': 'print("superseded page")\n',
416+
'workspace': <String, Object?>{
417+
'blocks': <String, Object?>{
418+
'languageVersion': 0,
419+
'blocks': <Object?>[],
420+
},
421+
},
422+
});
423+
final String supersededError = jsonEncode(<String, Object?>{
424+
'version': 1,
425+
'type': 'error',
426+
'hostEpoch': 1,
427+
'message': 'superseded page failed',
428+
});
429+
await liveWebViewController.runJavaScript(
430+
'window.PybleBlocks.postMessage(${jsonEncode(supersededSnapshot)});'
431+
'window.PybleBlocks.postMessage(${jsonEncode(supersededError)});',
432+
);
433+
await tester.pump(const Duration(milliseconds: 300));
434+
final BlocksDocument afterSupersededTraffic = container.read(
435+
blocksDocumentProvider,
436+
);
437+
expect(afterSupersededTraffic.status, BlocksStatus.ready);
438+
expect(afterSupersededTraffic.error, isNull);
439+
expect(afterSupersededTraffic.workspaceError, isNull);
440+
expect(afterSupersededTraffic.program?.revision, afterReloadRevision);
441+
expect(afterSupersededTraffic.program?.source, _expectedGpioSource);
442+
443+
await liveWebViewController.runJavaScript(
444+
"window.PybleBlocks.postMessage("
445+
"'{\"version\":1,\"type\":\"hostReady\"}');",
446+
);
447+
await tester.pump(const Duration(milliseconds: 300));
448+
final BlocksDocument afterDuplicateReady = container.read(
449+
blocksDocumentProvider,
450+
);
451+
expect(afterDuplicateReady.status, BlocksStatus.ready);
452+
expect(afterDuplicateReady.error, isNull);
453+
expect(afterDuplicateReady.program?.revision, afterReloadRevision);
454+
367455
await tester.tap(find.byKey(kBlocksPreviewButtonKey));
368456
await tester.pumpAndSettle();
369457
final Finder previewDialog = find.byType(AlertDialog);
@@ -549,13 +637,26 @@ void registerBlocklyWebViewIntegrationTests() {
549637
() => find.byKey(kBlocksExamplesCatalogKey).evaluate().isNotEmpty,
550638
reason: 'the bundled beginner example chooser did not open',
551639
);
640+
expect(
641+
find.textContaining("print('Hello, PyBLE!')"),
642+
findsNothing,
643+
reason: 'opening the chooser must not generate through the WebView',
644+
);
645+
await _tapVisible(tester, find.byKey(kBlocksExamplePreviewButtonKey));
552646
await _pumpUntil(
553647
tester,
554648
() =>
555649
find.textContaining("print('Hello, PyBLE!')").evaluate().isNotEmpty,
556650
reason:
557651
'the real scratch Blockly workspace did not generate the Hello preview',
558652
);
653+
await tester.tap(
654+
find.descendant(
655+
of: find.byType(AlertDialog),
656+
matching: find.text('Close'),
657+
),
658+
);
659+
await tester.pumpAndSettle();
559660
final BlocksDocument afterHelloPreview = container.read(
560661
blocksDocumentProvider,
561662
);
@@ -576,6 +677,13 @@ void registerBlocklyWebViewIntegrationTests() {
576677
find.widgetWithText(TextField, 'NeoPixel data GPIO'),
577678
'48',
578679
);
680+
await tester.pumpAndSettle();
681+
expect(
682+
find.textContaining('from neopixel import NeoPixel'),
683+
findsNothing,
684+
reason: 'GPIO editing must not generate through the WebView',
685+
);
686+
await _tapVisible(tester, find.byKey(kBlocksExamplePreviewButtonKey));
579687
await _pumpUntil(
580688
tester,
581689
() =>
@@ -588,6 +696,13 @@ void registerBlocklyWebViewIntegrationTests() {
588696
'the real scratch Blockly workspace did not generate the standard '
589697
'NeoPixel API from the user-selected GPIO',
590698
);
699+
await tester.tap(
700+
find.descendant(
701+
of: find.byType(AlertDialog),
702+
matching: find.text('Close'),
703+
),
704+
);
705+
await tester.pumpAndSettle();
591706
final BlocksDocument afterNeoPixelPreview = container.read(
592707
blocksDocumentProvider,
593708
);
@@ -633,16 +748,31 @@ pixels.write()
633748
);
634749
await tester.pumpAndSettle();
635750
await tester.enterText(find.widgetWithText(TextField, 'LED GPIO'), '17');
751+
await tester.pumpAndSettle();
752+
expect(
753+
find.textContaining('Pin(17, Pin.OUT'),
754+
findsNothing,
755+
reason: 'GPIO editing must remain local until an explicit action',
756+
);
757+
await _tapVisible(tester, find.byKey(kBlocksExamplePreviewButtonKey));
636758
await _pumpUntil(
637759
tester,
638760
() => find.textContaining('Pin(17, Pin.OUT').evaluate().isNotEmpty,
639761
reason:
640762
'the real scratch Blockly workspace did not materialize the selected LED GPIO',
641763
);
764+
await tester.tap(
765+
find.descendant(
766+
of: find.byType(AlertDialog),
767+
matching: find.text('Close'),
768+
),
769+
);
770+
await tester.pumpAndSettle();
642771
// A real Android IME can still cover the example action after enterText,
643772
// even when ensureVisible has scrolled its RenderBox into the viewport.
644-
// Close it before asserting hit-testability so the integration gate tests
645-
// the action rather than emulator keyboard-animation timing.
773+
// Close it after the explicit preview before asserting hit-testability,
774+
// so the integration gate tests the action rather than emulator
775+
// keyboard-animation timing.
646776
FocusManager.instance.primaryFocus?.unfocus();
647777
await SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
648778
await tester.pumpAndSettle();
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-License-Identifier: MIT
2+
// Part of PyBLE (https://pyble.dev) — see /LICENSE.
3+
4+
import 'dart:io';
5+
6+
import 'package:flutter_test/flutter_test.dart';
7+
8+
import '../support/repo_paths.dart';
9+
10+
void main() {
11+
test('Android integration tests use an isolated application ID', () {
12+
final String appRoot = appPackageRoot().path;
13+
final String gradle = File(
14+
'$appRoot/android/app/build.gradle.kts',
15+
).readAsStringSync();
16+
final File manifest = File(
17+
'$appRoot/android/app/src/integration/AndroidManifest.xml',
18+
);
19+
final String ci = File(
20+
'$appRoot/../.github/workflows/ci.yml',
21+
).readAsStringSync();
22+
final String contributing = File(
23+
'$appRoot/../CONTRIBUTING.md',
24+
).readAsStringSync();
25+
26+
expect(gradle, contains('flavorDimensions += "purpose"'));
27+
expect(gradle, contains('create("production")'));
28+
expect(gradle, contains('create("integration")'));
29+
expect(gradle, contains('applicationIdSuffix = ".integrationtest"'));
30+
expect(manifest.existsSync(), isTrue);
31+
expect(manifest.readAsStringSync(), contains('PyBLE Integration Test'));
32+
33+
final RegExpMatch? integrationPrebuild = RegExp(
34+
r'- name: Prebuild Android integration application.*?'
35+
r'(?=\n - name:)',
36+
dotAll: true,
37+
).firstMatch(ci);
38+
expect(integrationPrebuild, isNotNull);
39+
expect(integrationPrebuild!.group(0), contains('--flavor integration'));
40+
expect(integrationPrebuild.group(0), contains('app-integration-debug.apk'));
41+
42+
final RegExpMatch? androidIntegrationStep = RegExp(
43+
r'- name: Real About \+ Blockly integration \(Android API 34\).*?'
44+
r'(?=\n - name:)',
45+
dotAll: true,
46+
).firstMatch(ci);
47+
expect(androidIntegrationStep, isNotNull);
48+
expect(
49+
androidIntegrationStep!.group(0),
50+
contains('--flavor integration'),
51+
reason:
52+
'device tests must never install or uninstall the production app ID',
53+
);
54+
expect(
55+
androidIntegrationStep.group(0),
56+
contains('app-integration-debug.apk'),
57+
);
58+
59+
final RegExpMatch? productionReleaseStep = RegExp(
60+
r'- name: Production release APK build.*?'
61+
r'(?=\n - name:)',
62+
dotAll: true,
63+
).firstMatch(ci);
64+
expect(productionReleaseStep, isNotNull);
65+
expect(
66+
productionReleaseStep!.group(0),
67+
contains(
68+
'flutter build apk --release --flavor production '
69+
'--target lib/main.dart',
70+
),
71+
reason:
72+
'CI must compile the normal artifact after host tests regenerate '
73+
'the dev-plugin registrant',
74+
);
75+
expect(
76+
productionReleaseStep.group(0),
77+
contains("package: name='dev.pyble.pyble'"),
78+
);
79+
80+
expect(
81+
contributing,
82+
contains('flutter run --flavor production --target lib/main.dart'),
83+
);
84+
expect(contributing, isNot(contains('flutter run -d <device>')));
85+
expect(
86+
contributing,
87+
contains(
88+
'flutter build apk --release --flavor production '
89+
'--target lib/main.dart',
90+
),
91+
);
92+
expect(contributing, contains('--flavor integration'));
93+
expect(
94+
gradle,
95+
contains('flutter run --release --flavor production'),
96+
reason: 'the signing comment must not advertise an ambiguous command',
97+
);
98+
});
99+
}

0 commit comments

Comments
 (0)