diff --git a/packages/ada_chat_flutter/CHANGELOG.md b/packages/ada_chat_flutter/CHANGELOG.md index de5e9a0f..575f8dfb 100644 --- a/packages/ada_chat_flutter/CHANGELOG.md +++ b/packages/ada_chat_flutter/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.1 + +- Refactor. Add more tests. + ## 1.1.0 - Block Ada chat button on selected pages. diff --git a/packages/ada_chat_flutter/lib/src/ada_web_view.dart b/packages/ada_chat_flutter/lib/src/ada_web_view.dart index 33d8ec37..5bb48e1d 100644 --- a/packages/ada_chat_flutter/lib/src/ada_web_view.dart +++ b/packages/ada_chat_flutter/lib/src/ada_web_view.dart @@ -100,8 +100,9 @@ class AdaWebView extends StatefulWidget { final void Function(String request, String response)? onLoadingError; @override - State createState() => _AdaWebViewState(); + State createState() => AdaWebViewState(); + // coverage:ignore-start @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -135,9 +136,11 @@ class AdaWebView extends StatefulWidget { properties.add(DoubleProperty('rolloutOverride', rolloutOverride)); properties.add(DiagnosticsProperty('testMode', testMode)); } +// coverage:ignore-end } -class _AdaWebViewState extends State { +@visibleForTesting +class AdaWebViewState extends State { late final WebViewController _controller; @override @@ -175,10 +178,10 @@ class _AdaWebViewState extends State { onUrlChange: _onUrlChange, onProgress: _onProgress, onPageStarted: _onPageStarted, - onPageFinished: _onPageFinished, + onPageFinished: onPageFinished, onHttpError: _onHttpError, onWebResourceError: _onWebResourceError, - onNavigationRequest: _onNavigationRequest, + onNavigationRequest: onNavigationRequest, ), ); @@ -188,12 +191,13 @@ class _AdaWebViewState extends State { void _onConsoleMessage(JavaScriptConsoleMessage message) => widget.onConsoleMessage?.call(message.level.toString(), message.message); - FutureOr _onNavigationRequest(NavigationRequest request) { + @visibleForTesting + FutureOr onNavigationRequest(NavigationRequest request) { final uri = Uri.parse(request.url); log('AdaWebView:onNavigationRequest: ' 'url=${uri.toString()}, isMainFrame=${request.isMainFrame}'); - if (_isAdaChatLink(uri) || _isAdaSupportLink(uri) || _isBlankPage(uri)) { + if (isInternalAdaUrl(uri, widget.embedUri, widget.handle)) { return NavigationDecision.navigate; } @@ -212,12 +216,6 @@ class _AdaWebViewState extends State { return NavigationDecision.prevent; } - bool _isBlankPage(Uri uri) => uri.toString() == 'about:blank'; - - bool _isAdaSupportLink(Uri uri) => uri.host == '${widget.handle}.ada.support'; - - bool _isAdaChatLink(Uri uri) => uri == widget.embedUri; - void _onWebResourceError(WebResourceError error) => log('AdaWebView:onWebResourceError: ' 'errorCode=${error.errorCode}, ' @@ -225,12 +223,11 @@ class _AdaWebViewState extends State { void _onHttpError(HttpResponseError error) => widget.onLoadingError?.call( error.request?.uri.toString() ?? '', - 'uri=${error.response?.uri}, ' - 'statusCode=${error.response?.statusCode}, ' - 'headers=${error.response?.headers}, ', + 'statusCode=${error.response?.statusCode}', ); - void _onPageFinished(String url) { + @visibleForTesting + void onPageFinished(String url) { log('AdaWebView:onPageFinished: url=$url'); Future.delayed(Duration.zero, () async { @@ -375,12 +372,4 @@ console.log("adaSettings: " + JSON.stringify(window.adaSettings)); }, ); } - - dynamic jsonStrToMap(String message) { - if (message.isEmpty) { - return {}; - } - - return jsonDecode(message); - } } diff --git a/packages/ada_chat_flutter/lib/src/customized_web_view.dart b/packages/ada_chat_flutter/lib/src/customized_web_view.dart index 76492fe8..c7aec9bb 100644 --- a/packages/ada_chat_flutter/lib/src/customized_web_view.dart +++ b/packages/ada_chat_flutter/lib/src/customized_web_view.dart @@ -16,10 +16,11 @@ class CustomizedWebView extends StatefulWidget { final BrowserSettings? browserSettings; @override - State createState() => _CustomizedWebViewState(); + State createState() => CustomizedWebViewState(); } -class _CustomizedWebViewState extends State { +@visibleForTesting +class CustomizedWebViewState extends State { late final WebViewController _webViewController = WebViewController(); late final AdaButtonHide _adaButtonHide = AdaButtonHide( webViewController: _webViewController, @@ -37,9 +38,9 @@ class _CustomizedWebViewState extends State { ..setNavigationDelegate( NavigationDelegate( onUrlChange: _onUrlChange, - onProgress: _onProgress, + onProgress: onProgress, onPageStarted: _onPageStarted, - onPageFinished: _onPageFinished, + onPageFinished: onPageFinished, onNavigationRequest: _onNavigationRequest, ), ) @@ -56,7 +57,8 @@ class _CustomizedWebViewState extends State { void _onUrlChange(change) => log('CustomizedWebView:onUrlChange: url=${change.url}'); - Future _onPageFinished(String url) async { + @visibleForTesting + Future onPageFinished(String url) async { log('CustomizedWebView:onPageFinished: url=$url'); final pageController = widget.browserSettings?.control; @@ -85,7 +87,8 @@ class _CustomizedWebViewState extends State { await _adaButtonHide.maybeHideButton(url); } - void _onProgress(int progress) { + @visibleForTesting + void onProgress(int progress) { log('CustomizedWebView:onProgress: progress=$progress'); final pageController = widget.browserSettings?.control; diff --git a/packages/ada_chat_flutter/lib/src/utils.dart b/packages/ada_chat_flutter/lib/src/utils.dart index c0b8197e..027e8108 100644 --- a/packages/ada_chat_flutter/lib/src/utils.dart +++ b/packages/ada_chat_flutter/lib/src/utils.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; String get getOsName { @@ -9,3 +11,23 @@ String get getOsName { return 'N/A'; } } + +bool isInternalAdaUrl(Uri uri, Uri embedUri, String handle) => + isAdaChatLink(uri, embedUri) || + isAdaSupportLink(uri, handle) || + isBlankPage(uri); + +bool isBlankPage(Uri uri) => uri.toString() == 'about:blank'; + +bool isAdaSupportLink(Uri uri, String handle) => + uri.host == '$handle.ada.support'; + +bool isAdaChatLink(Uri uri, Uri embedUri) => uri == embedUri; + +dynamic jsonStrToMap(String message) { + if (message.isEmpty) { + return {}; + } + + return json.decode(message); +} diff --git a/packages/ada_chat_flutter/pubspec.yaml b/packages/ada_chat_flutter/pubspec.yaml index 693f12fc..a05c50e5 100644 --- a/packages/ada_chat_flutter/pubspec.yaml +++ b/packages/ada_chat_flutter/pubspec.yaml @@ -1,6 +1,6 @@ name: ada_chat_flutter description: Flutter implementation of Ada chat -version: 1.1.0 +version: 1.1.1 environment: sdk: '>=3.4.3 <4.0.0' @@ -17,6 +17,7 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.3 + webview_flutter_platform_interface: ^2.10.0 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/packages/ada_chat_flutter/test/src/ada_web_view_test.dart b/packages/ada_chat_flutter/test/src/ada_web_view_test.dart new file mode 100644 index 00000000..cbb693f0 --- /dev/null +++ b/packages/ada_chat_flutter/test/src/ada_web_view_test.dart @@ -0,0 +1,150 @@ +import 'package:ada_chat_flutter/src/ada_controller.dart'; +import 'package:ada_chat_flutter/src/ada_web_view.dart'; +import 'package:ada_chat_flutter/src/browser_settings.dart'; +import 'package:ada_chat_flutter/src/customized_web_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../webview_mocks.dart'; + +class _MockAdaController extends Mock implements AdaController {} + +const _title = '_title'; +const _regexp = '_regexp'; +const _handle = '_handle'; +const _embedUri = 'https://example.com/embed.html'; + +final _browserSettings = BrowserSettings( + pageBuilder: ( + context, + browser, + controller, + ) => + Column( + children: [ + Text(_title), + browser, + ], + ), + adaHideUrls: [ + RegExp(_regexp), + ], +); + +void main() { + late _MockAdaController mockAdaController; + + setUp(() { + WebViewPlatform.instance = FakeWebViewPlatform(); + + mockAdaController = _MockAdaController(); + + when(() => mockAdaController.start()).thenAnswer((_) async {}); + }); + + group('AdaWebView tests - ', () { + testWidgets( + 'WHEN AdaWebView is pumped ' + 'THEN should show correct widgets', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: _buildAdaWebView(mockAdaController), + ), + ); + + expect( + webViewCalls, + equals(['loadRequest: uri=$_embedUri']), + ); + }, + ); + + testWidgets( + 'GIVEN the widget is pumped ' + 'WHEN onPageFinished is called ' + 'THEN should init and start Ada with correct params', + (WidgetTester tester) async { + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: _buildAdaWebView(mockAdaController), + ), + ); + + final state = tester.state(find.byType(AdaWebView)); + + state.onPageFinished(_embedUri); + await Future.delayed(Duration.zero); + + verify(() => mockAdaController.start()).called(1); + expect( + webViewCalls, + contains('loadRequest: uri=https://example.com/embed.html'), + ); + expect( + webViewCalls, + contains('addJavaScriptChannel: name=onLoaded'), + ); + expect( + webViewCalls, + anyElement(contains('"metaFields":{"key":"value"')), + ); + expect( + webViewCalls, + anyElement( + contains('{"handle":"$_handle","language":"en","cluster":' + 'null,"domain":null,"hideMask":false'), + ), + ); + }); + }, + ); + + testWidgets( + 'GIVEN the widget is pumped ' + 'WHEN onNavigationRequest is called for external page ' + 'THEN should show customized webview', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: _buildAdaWebView(mockAdaController), + ), + ); + + final state = tester.state(find.byType(AdaWebView)); + + state.onNavigationRequest( + NavigationRequest( + url: 'https://external-page.com/index.html', + isMainFrame: true, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text(_title), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => + w is CustomizedWebView && w.browserSettings == _browserSettings, + ), + findsOneWidget, + ); + }, + ); + }); +} + +Widget _buildAdaWebView(_MockAdaController mockAdaController) { + return AdaWebView( + embedUri: Uri.parse(_embedUri), + handle: _handle, + controller: mockAdaController, + rolloutOverride: 1, + language: 'en', + metaFields: const {'key': 'value'}, + browserSettings: _browserSettings, + ); +} diff --git a/packages/ada_chat_flutter/test/src/customized_web_view_test.dart b/packages/ada_chat_flutter/test/src/customized_web_view_test.dart new file mode 100644 index 00000000..2e263e15 --- /dev/null +++ b/packages/ada_chat_flutter/test/src/customized_web_view_test.dart @@ -0,0 +1,163 @@ +import 'package:ada_chat_flutter/src/browser_settings.dart'; +import 'package:ada_chat_flutter/src/customized_web_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +import '../webview_mocks.dart'; + +const _uri = 'https://example.com/page.html'; +const _title = '_title'; +const _regexp = '_regexp'; + +const _keyLeft = Key('left'); +const _keyRight = Key('right'); +const _keyReload = Key('reload'); + +void main() { + setUp(() { + WebViewPlatform.instance = FakeWebViewPlatform(); + }); + + group('CustomizedWebView tests - ', () { + testWidgets( + 'WHEN the widget is pumped ' + 'THEN should load the provided URL', + (WidgetTester tester) async { + await _pumpWidget(tester); + + expect( + webViewCalls, + equals(['loadRequest: uri=$_uri']), + ); + }, + ); + + testWidgets( + 'WHEN the widget is pumped ' + 'THEN should contain correct widgets', + (WidgetTester tester) async { + await _pumpWidget(tester); + + expect(find.byType(WebViewWidget), findsOneWidget); + expect(find.text(_title), findsOneWidget); + expect(find.byKey(_keyLeft), findsOneWidget); + expect(find.byKey(_keyRight), findsOneWidget); + expect(find.byKey(_keyReload), findsOneWidget); + }, + ); + + testWidgets( + 'GIVEN the widget is pumped ' + 'WHEN page controller methods called ' + 'THEN should call correct webview controller methods', + (WidgetTester tester) async { + await _pumpWidget(tester); + + await _initAndGetWidgetState(tester); + + await tester.tap(find.byKey(_keyLeft)); + await tester.pumpAndSettle(); + + expect( + webViewCalls, + equals(['loadRequest: uri=$_uri', 'goBack']), + ); + + await tester.tap(find.byKey(_keyRight)); + await tester.pumpAndSettle(); + + expect( + webViewCalls, + equals(['loadRequest: uri=$_uri', 'goBack', 'goForward']), + ); + + await tester.tap(find.byKey(_keyReload)); + await tester.pumpAndSettle(); + + expect( + webViewCalls, + equals(['loadRequest: uri=$_uri', 'goBack', 'goForward', 'reload']), + ); + }, + ); + + testWidgets( + 'GIVEN the widget is pumped ' + 'WHEN progress is changed ' + 'THEN should rebuild screen with updated value', + (WidgetTester tester) async { + await _pumpWidget(tester); + + final state = await _initAndGetWidgetState(tester); + + state.onProgress(50); + await tester.pumpAndSettle(); + + expect(find.text('50 %'), findsOneWidget); + + state.onProgress(100); + await tester.pumpAndSettle(); + + expect(find.text('100 %'), findsOneWidget); + }, + ); + }); +} + +Future _initAndGetWidgetState( + WidgetTester tester) async { + final state = + tester.state(find.byType(CustomizedWebView)); + await state.onPageFinished(_uri); + return state; +} + +Future _pumpWidget(WidgetTester tester) => tester.pumpWidget( + MaterialApp( + home: CustomizedWebView( + url: Uri.parse(_uri), + browserSettings: BrowserSettings( + pageBuilder: ( + context, + browser, + controller, + ) => + Column( + children: [ + Row( + children: [ + IconButton( + key: _keyLeft, + icon: Icon(Icons.keyboard_arrow_left), + onPressed: () => controller.goBack(), + ), + IconButton( + key: _keyRight, + icon: Icon(Icons.keyboard_arrow_right), + onPressed: () => controller.goForward(), + ), + Expanded(child: Text(_title)), + IconButton( + key: _keyReload, + icon: Icon(Icons.refresh), + onPressed: () => controller.reload(), + ), + ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Text('${controller.progress} %'); + }, + ), + ], + ), + browser, + ], + ), + adaHideUrls: [ + RegExp(_regexp), + ], + ), + ), + ), + ); diff --git a/packages/ada_chat_flutter/test/src/utils_test.dart b/packages/ada_chat_flutter/test/src/utils_test.dart index 248fa672..dc0014f3 100644 --- a/packages/ada_chat_flutter/test/src/utils_test.dart +++ b/packages/ada_chat_flutter/test/src/utils_test.dart @@ -31,5 +31,82 @@ void main() { expect(getOsName, equals('N/A')); }); }); + + group('isBlankPage tests - ', () { + test('isBlankPage returns true for about:blank', () { + expect(isBlankPage(Uri.parse('about:blank')), isTrue); + }); + + test('isBlankPage returns false for other URLs', () { + expect(isBlankPage(Uri.parse('https://example.com')), isFalse); + }); + }); + + group('isAdaSupportLink tests - ', () { + const handle = 'test-handle'; + + test('isAdaSupportLink returns true for valid Ada support links', () { + expect( + isAdaSupportLink( + Uri.parse('https://test-handle.ada.support'), handle), + isTrue); + }); + + test('isAdaSupportLink returns false for invalid Ada support links', () { + expect(isAdaSupportLink(Uri.parse('https://example.com'), handle), + isFalse); + }); + }); + + group('isAdaChatLink tests - ', () { + final embedUri = Uri.parse('https://example.com/embed.html'); + + test('isAdaChatLink returns true for matching embed URIs', () { + expect(isAdaChatLink(embedUri, embedUri), isTrue); + }); + + test('isAdaChatLink returns false for non-matching embed URIs', () { + expect( + isAdaChatLink(Uri.parse('https://example.com'), embedUri), isFalse); + }); + }); + + group('isInternalAdaUrl tests - ', () { + final embedUri = Uri.parse('https://example.com/embed.html'); + const handle = 'test-handle'; + + test('isInternalAdaUrl returns true for valid internal Ada URLs', () { + expect(isInternalAdaUrl(embedUri, embedUri, handle), isTrue); + expect( + isInternalAdaUrl( + Uri.parse('https://test-handle.ada.support'), embedUri, handle), + isTrue); + expect(isInternalAdaUrl(Uri.parse('about:blank'), embedUri, handle), + isTrue); + }); + + test('isInternalAdaUrl returns false for invalid internal Ada URLs', () { + expect( + isInternalAdaUrl( + Uri.parse('https://example.com'), embedUri, handle), + isFalse); + }); + }); + + group('jsonStrToMap', () { + test('returns empty map for empty string', () { + expect(jsonStrToMap(''), isEmpty); + expect(jsonStrToMap(''), isA>()); + }); + + test('decodes valid JSON string to map', () { + const jsonString = '{"key1": "value1", "key2": 123}'; + final result = jsonStrToMap(jsonString); + + expect(result, isA>()); + expect(result['key1'], equals('value1')); + expect(result['key2'], equals(123)); + }); + }); }); } diff --git a/packages/ada_chat_flutter/test/webview_mocks.dart b/packages/ada_chat_flutter/test/webview_mocks.dart new file mode 100644 index 00000000..5ba64e0b --- /dev/null +++ b/packages/ada_chat_flutter/test/webview_mocks.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:webview_flutter_platform_interface/src/platform_navigation_delegate.dart'; +import 'package:webview_flutter_platform_interface/src/platform_webview_controller.dart'; +import 'package:webview_flutter_platform_interface/src/platform_webview_cookie_manager.dart'; +import 'package:webview_flutter_platform_interface/src/platform_webview_widget.dart'; +import 'package:webview_flutter_platform_interface/src/types/load_request_params.dart'; + +final webViewCalls = []; + +class _FakePlatformWebViewCookieManager extends PlatformWebViewCookieManager { + _FakePlatformWebViewCookieManager( + super.params, + ) : super.implementation(); + + @override + Future clearCookies() async { + return false; + } + + @override + Future setCookie(WebViewCookie cookie) async {} +} + +class _FakeWebViewController extends PlatformWebViewController { + _FakeWebViewController(super.params) : super.implementation(); + + @override + Future addJavaScriptChannel( + JavaScriptChannelParams javaScriptChannelParams, + ) async => + webViewCalls.add('addJavaScriptChannel: ' + 'name=${javaScriptChannelParams.name}'); + + @override + Future canGoBack() async { + return true; + } + + @override + Future canGoForward() async { + return true; + } + + @override + Future clearCache() async {} + + @override + Future clearLocalStorage() async {} + + @override + Future currentUrl() async { + return 'https://example.com/page.html'; + } + + @override + Future enableZoom(bool enabled) async {} + + @override + Future getScrollPosition() async { + return Offset(0, 0); + } + + @override + Future getTitle() async { + return null; + } + + @override + Future getUserAgent() async { + return null; + } + + @override + Future goBack() async => webViewCalls.add('goBack'); + + @override + Future goForward() async => webViewCalls.add('goForward'); + + @override + Future loadFile(String absoluteFilePath) async {} + + @override + Future loadFlutterAsset(String key) async {} + + @override + Future loadHtmlString(String html, {String? baseUrl}) async {} + + @override + Future loadRequest(LoadRequestParams params) async => + webViewCalls.add('loadRequest: uri=${params.uri}'); + + @override + PlatformWebViewControllerCreationParams get params => + PlatformWebViewControllerCreationParams(); + + @override + Future reload() async => webViewCalls.add('reload'); + + @override + Future removeJavaScriptChannel(String javaScriptChannelName) async {} + + @override + Future runJavaScript(String javaScript) async => webViewCalls.add( + 'runJavaScript: javaScript=$javaScript', + ); + + @override + Future runJavaScriptReturningResult(String javaScript) async { + webViewCalls.add('runJavaScriptReturningResult: javaScript=$javaScript'); + return '{}'; + } + + @override + Future scrollBy(int x, int y) async {} + + @override + Future scrollTo(int x, int y) async {} + + @override + Future setBackgroundColor(Color color) async {} + + @override + Future setJavaScriptMode(JavaScriptMode javaScriptMode) async {} + + @override + Future setOnConsoleMessage( + void Function(JavaScriptConsoleMessage consoleMessage) onConsoleMessage, + ) async {} + + @override + Future setOnPlatformPermissionRequest( + void Function(PlatformWebViewPermissionRequest request) onPermissionRequest, + ) async {} + + @override + Future setPlatformNavigationDelegate( + PlatformNavigationDelegate handler, + ) async {} + + @override + Future setUserAgent(String? userAgent) async {} +} + +class _FakeWebViewWidget extends PlatformWebViewWidget { + _FakeWebViewWidget(super.params) : super.implementation(); + + @override + Widget build(BuildContext context) { + return Container(); + } +} + +class _FakePlatformNavigationDelegate extends PlatformNavigationDelegate { + _FakePlatformNavigationDelegate(super.params) : super.implementation(); + + @override + Future setOnPageStarted(PageEventCallback onPageStarted) async {} + + @override + Future setOnNavigationRequest( + NavigationRequestCallback onNavigationRequest, + ) async {} + + @override + Future setOnPageFinished(PageEventCallback onPageFinished) async {} + + @override + Future setOnProgress(ProgressCallback onProgress) async {} + + @override + Future setOnWebResourceError( + WebResourceErrorCallback onWebResourceError) async {} + + @override + Future setOnUrlChange(UrlChangeCallback onUrlChange) async {} + + @override + Future setOnHttpAuthRequest( + HttpAuthRequestCallback onHttpAuthRequest) async {} + + @override + Future setOnHttpError(HttpResponseErrorCallback onHttpError) async {} +} + +class FakeWebViewPlatform extends WebViewPlatform { + FakeWebViewPlatform() : super() { + webViewCalls.clear(); + } + + @override + PlatformWebViewCookieManager createPlatformCookieManager( + PlatformWebViewCookieManagerCreationParams params, + ) { + return _FakePlatformWebViewCookieManager(params); + } + + @override + PlatformNavigationDelegate createPlatformNavigationDelegate( + PlatformNavigationDelegateCreationParams params, + ) { + return _FakePlatformNavigationDelegate(params); + } + + @override + PlatformWebViewWidget createPlatformWebViewWidget( + PlatformWebViewWidgetCreationParams params, + ) { + return _FakeWebViewWidget(params); + } + + @override + PlatformWebViewController createPlatformWebViewController( + PlatformWebViewControllerCreationParams params, + ) { + return _FakeWebViewController(params); + } +}