diff --git a/.gitignore b/.gitignore index 5c279906..05bea965 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ pubspec.lock !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +coverage diff --git a/lib/src/core/network/wiredash_api.dart b/lib/src/core/network/wiredash_api.dart index 4c6d5ab0..fa352a9a 100644 --- a/lib/src/core/network/wiredash_api.dart +++ b/lib/src/core/network/wiredash_api.dart @@ -64,19 +64,11 @@ class WiredashApi { final response = await _send(req); - if (response.statusCode == 401) { - throw UnauthenticatedWiredashApiException(response, _projectId, _secret); - } - - if (response.statusCode != 200) { - throw WiredashApiException( - message: '$type upload failed', - response: response, - ); + if (response.statusCode == 200) { + final map = jsonDecode(response.body) as Map; + return AttachmentId(map['id'] as String); } - - final map = jsonDecode(response.body) as Map; - return AttachmentId(map['id'] as String); + _parseResponseForErrors(response); } /// Reports a feedback @@ -97,13 +89,7 @@ class WiredashApi { // success 🎉 return; } - if (response.statusCode == 401) { - throw UnauthenticatedWiredashApiException(response, _projectId, _secret); - } - throw WiredashApiException( - message: 'submitting feedback failed', - response: response, - ); + _parseResponseForErrors(response); } Future sendNps(NpsRequestBody body) async { @@ -119,13 +105,17 @@ class WiredashApi { // success 🎉 return; } - if (response.statusCode == 401) { - throw UnauthenticatedWiredashApiException(response, _projectId, _secret); + _parseResponseForErrors(response); + } + + Future ping() async { + final uri = Uri.parse('$_host/ping'); + final Request request = Request('POST', uri); + final response = await _send(request); + if (response.statusCode == 200) { + return PingResponse(); } - throw WiredashApiException( - message: 'submitting nps failed', - response: response, - ); + _parseResponseForErrors(response); } /// Sends a [BaseRequest] after attaching HTTP headers @@ -138,6 +128,16 @@ class WiredashApi { final streamedResponse = await _httpClient.send(request); return Response.fromStream(streamedResponse); } + + Never _parseResponseForErrors(Response response) { + if (response.statusCode == 401) { + throw UnauthenticatedWiredashApiException(response, _projectId, _secret); + } + if (response.statusCode == 403) { + throw KillSwitchException(response: response); + } + throw WiredashApiException(response: response); + } } extension UploadScreenshotApi on WiredashApi { @@ -156,7 +156,7 @@ extension UploadScreenshotApi on WiredashApi { /// Generic error from the Wiredash API class WiredashApiException implements Exception { - WiredashApiException({this.message, this.response}); + const WiredashApiException({this.message, this.response}); String? get messageFromServer { try { @@ -189,6 +189,7 @@ class WiredashApiException implements Exception { String toString() { return 'WiredashApiException{' '"$message", ' + 'endpoint: ${response?.request?.url.path}, ' 'code: ${response?.statusCode}, ' 'resp: $messageFromServer' '}'; @@ -528,3 +529,17 @@ class NpsRequestBody { return body; } } + +class PingResponse { + // Nothing in here just yet but that will change in the future + PingResponse(); +} + +/// Backend returns an error which silences the SDK for one week +class KillSwitchException extends WiredashApiException { + const KillSwitchException({Response? response}) : super(response: response); + @override + String toString() { + return 'KillSwitchException{${response?.statusCode}, body: ${response?.body}}'; + } +} diff --git a/lib/src/core/services/services.dart b/lib/src/core/services/services.dart index 2318c343..66f662b2 100644 --- a/lib/src/core/services/services.dart +++ b/lib/src/core/services/services.dart @@ -9,7 +9,11 @@ import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wiredash/src/core/network/wiredash_api.dart'; import 'package:wiredash/src/core/options/wiredash_options_data.dart'; +import 'package:wiredash/src/core/project_credential_validator.dart'; import 'package:wiredash/src/core/services/streampod.dart'; +import 'package:wiredash/src/core/sync/ping_job.dart'; +import 'package:wiredash/src/core/sync/sync_engine.dart'; +import 'package:wiredash/src/core/sync/sync_feedback_job.dart'; import 'package:wiredash/src/core/widgets/backdrop/wiredash_backdrop.dart'; import 'package:wiredash/src/core/wiredash_model.dart'; import 'package:wiredash/src/core/wiredash_widget.dart'; @@ -60,10 +64,14 @@ class WiredashServices extends ChangeNotifier { WiredashApi get api => _locator.get(); + SyncEngine get syncEngine => _locator.get(); + DiscardFeedbackUseCase get discardFeedback => _locator.get(); DiscardNpsUseCase get discardNps => _locator.get(); + ProjectCredentialValidator get projectCredentialValidator => _locator.get(); + void updateWidget(Wiredash wiredashWidget) { inject((_) => wiredashWidget); } @@ -103,6 +111,9 @@ void _setupServices(WiredashServices sl) { ); sl.inject((_) => DeviceIdGenerator()); sl.inject((_) => BuildInfoManager()); + sl.inject( + (_) => const ProjectCredentialValidator(), + ); sl.inject( (_) => BackdropController(), dispose: (model) => model.dispose(), @@ -166,6 +177,29 @@ void _setupServices(WiredashServices sl) { }, ); + sl.inject( + (locator) { + final engine = SyncEngine(); + + engine.addJob( + 'ping', + PingJob( + api: locator.api, + sharedPreferencesProvider: SharedPreferences.getInstance, + ), + ); + engine.addJob( + 'feedback', + UploadPendingFeedbackJob( + feedbackSubmitter: locator.feedbackSubmitter, + ), + ); + + return engine; + }, + dispose: (engine) => engine.onWiredashDispose(), + ); + sl.inject((_) => DiscardFeedbackUseCase(sl)); sl.inject((_) => DiscardNpsUseCase(sl)); } diff --git a/lib/src/core/services/streampod.dart b/lib/src/core/services/streampod.dart index d8dd2964..577cb39a 100644 --- a/lib/src/core/services/streampod.dart +++ b/lib/src/core/services/streampod.dart @@ -5,14 +5,20 @@ class Locator { final Map _registry = {}; + bool _disposed = false; + void dispose() { for (final item in _registry.values) { item.dispose?.call(); } + _disposed = true; } /// Retrieve a instance of type [T] T get() { + if (_disposed) { + throw Exception('Locator is disposed'); + } final provider = _registry[T]; return provider!.instance as T; } @@ -22,6 +28,9 @@ class Locator { T Function(Locator, T oldInstance)? update, void Function(T)? dispose, }) { + if (_disposed) { + throw Exception('Locator is disposed'); + } late InstanceFactory provider; provider = InstanceFactory(this, create, update, () { final instance = provider._instance; diff --git a/lib/src/core/sync/ping_job.dart b/lib/src/core/sync/ping_job.dart new file mode 100644 index 00000000..53f5e3a7 --- /dev/null +++ b/lib/src/core/sync/ping_job.dart @@ -0,0 +1,101 @@ +import 'package:clock/clock.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wiredash/src/core/network/wiredash_api.dart'; +import 'package:wiredash/src/core/sync/sync_engine.dart'; + +class PingJob extends Job { + final WiredashApi api; + final Future Function() sharedPreferencesProvider; + + PingJob({ + required this.api, + required this.sharedPreferencesProvider, + }); + + static const lastSuccessfulPingKey = 'io.wiredash.last_successful_ping'; + static const silenceUntilKey = 'io.wiredash.silence_ping_until'; + + static const minPingGap = Duration(hours: 3); + static const killSwitchSilenceDuration = Duration(days: 7); + + @override + bool shouldExecute(SdkEvent event) { + return event == SdkEvent.appStart; + } + + @override + Future execute() async { + final now = clock.now(); + + if (await _isSilenced(now)) { + // Received kill switch message, don't ping at all + syncDebugPrint('Sdk silenced, preventing ping'); + return; + } + + final lastPing = await _getLastSuccessfulPing(); + if (lastPing != null && now.difference(lastPing) <= minPingGap) { + syncDebugPrint( + 'Not syncing because within minSyncGapWindow\n' + 'now: $now lastPing:$lastPing\n' + 'diff (${now.difference(lastPing)}) <= minSyncGap ($minPingGap)', + ); + // don't ping too often on app start, only once every minPingGap + return; + } + + try { + await api.ping(); + await _saveLastSuccessfulPing(now); + syncDebugPrint('ping'); + } on KillSwitchException catch (_) { + // Server explicitly asks the SDK to be silent + final earliestNextPing = now.add(killSwitchSilenceDuration); + await _silenceUntil(earliestNextPing); + syncDebugPrint('Silenced Wiredash until $earliestNextPing'); + } catch (e, stack) { + // Any other error, like network errors or server errors, silence the ping + // for a while, too + syncDebugPrint('Received an unknown error for ping'); + syncDebugPrint(e); + syncDebugPrint(stack); + } + } + + /// Silences the sdk, prevents automatic pings on app startup until the time is over + Future _silenceUntil(DateTime dateTime) async { + final preferences = await sharedPreferencesProvider(); + preferences.setInt(silenceUntilKey, dateTime.millisecondsSinceEpoch); + syncDebugPrint('Silenced Wiredash until $dateTime'); + } + + /// `true` when automatic pings should be prevented + Future _isSilenced(DateTime now) async { + final preferences = await sharedPreferencesProvider(); + + final int? millis = preferences.getInt(silenceUntilKey); + if (millis == null) { + return false; + } + final silencedUntil = DateTime.fromMillisecondsSinceEpoch(millis); + final silenced = silencedUntil.isAfter(now); + if (silenced) { + syncDebugPrint("Sdk is silenced until $silencedUntil (now $now)"); + } + return silenced; + } + + Future _getLastSuccessfulPing() async { + final preferences = await sharedPreferencesProvider(); + final lastPingInt = preferences.getInt(lastSuccessfulPingKey); + if (lastPingInt == null) { + return null; + } + return DateTime.fromMillisecondsSinceEpoch(lastPingInt); + } + + Future _saveLastSuccessfulPing(DateTime now) async { + final preferences = await sharedPreferencesProvider(); + await preferences.setInt(lastSuccessfulPingKey, now.millisecondsSinceEpoch); + } +} diff --git a/lib/src/core/sync/sync_engine.dart b/lib/src/core/sync/sync_engine.dart new file mode 100644 index 00000000..6bfd789c --- /dev/null +++ b/lib/src/core/sync/sync_engine.dart @@ -0,0 +1,136 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; + +const _kSyncDebugPrint = false; + +void syncDebugPrint(Object? message) { + if (_kSyncDebugPrint) { + debugPrint(message?.toString()); + } +} + +/// Events that are triggered by the user that can be used to trigger registered +/// [Job]s. +enum SdkEvent { + /// User launched the app that is wrapped in Wiredash + appStart, + + /// User opened the Wiredash UI + openedWiredash, + + /// User submitted feedback. It might not yet be delivered to the backend but the task is completed by the user + submittedFeedback, + + /// User submitted the NPS + submittedNps, +} + +/// Executes sync jobs with the network at certain times +/// +/// Add a new job with [addJob] and it will execute when your +/// [Job.shouldExecute] returns `true`. +class SyncEngine { + SyncEngine(); + + Timer? _initTimer; + + final Map _jobs = {}; + + bool get _mounted => _initTimer != null; + + /// Adds a job to be executed for certain [SdkEvent] events. + /// + /// See [removeJob] to remove the job. + void addJob( + String name, + Job job, + ) { + if (job._name != null) { + throw 'Job already has a name (${job._name}), cannot add it ($name) twice'; + } + job._name = name; + _jobs[name] = job; + syncDebugPrint('Added job $name (${job.runtimeType})'); + } + + /// Removes a jobs that was previously registered with [addJob]. + Job? removeJob(String name) { + final job = _jobs.remove(name); + if (job == null) { + return null; + } + job._name = null; + return job; + } + + /// Called when the SDK is initialized (by wrapping the app) + /// + /// Triggers [SdkEvent.appStart] after the app settled down. + Future onWiredashInit() async { + assert( + () { + if (_initTimer != null) { + debugPrint("Warning: called onWiredashInitialized multiple times"); + } + return true; + }(), + ); + + // _triggerEvent(SdkEvent.appStart); + // Delay app start a bit, so that Wiredash doesn't slow down the app start + _initTimer?.cancel(); + _initTimer = Timer(const Duration(seconds: 5), () { + _triggerEvent(SdkEvent.appStart); + }); + } + + /// Shuts down the sync engine because wiredash is not part of the widget tree + /// anymore + void onWiredashDispose() { + _initTimer?.cancel(); + _initTimer = null; + } + + Future onUserOpenedWiredash() async { + await _triggerEvent(SdkEvent.appStart); + } + + Future onSubmitFeedback() async { + await _triggerEvent(SdkEvent.submittedFeedback); + } + + Future onSubmitNPS() async { + await _triggerEvent(SdkEvent.submittedNps); + } + + /// Executes all jobs that are listening to the given event + Future _triggerEvent(SdkEvent event) async { + for (final job in _jobs.values) { + if (!_mounted) { + // stop sync operation, Wiredash was removed from the widget tree + syncDebugPrint('cancelling job execution for event $event'); + break; + } + try { + if (job.shouldExecute(event)) { + syncDebugPrint('Executing job ${job._name}'); + await job.execute(); + } + } catch (e, stack) { + debugPrint('Error executing job ${job._name}:\n$e\n$stack'); + } + } + } +} + +/// A job that will be executed by [SyncEngine] when [shouldExecute] matches a +/// triggered [SdkEvent] +abstract class Job { + String get name => _name ?? 'unnamed'; + String? _name; + + bool shouldExecute(SdkEvent event); + + Future execute(); +} diff --git a/lib/src/core/sync/sync_feedback_job.dart b/lib/src/core/sync/sync_feedback_job.dart new file mode 100644 index 00000000..c762b2aa --- /dev/null +++ b/lib/src/core/sync/sync_feedback_job.dart @@ -0,0 +1,30 @@ +import 'package:flutter/foundation.dart'; +import 'package:wiredash/src/core/sync/sync_engine.dart'; +import 'package:wiredash/src/feedback/_feedback.dart'; + +class UploadPendingFeedbackJob extends Job { + final FeedbackSubmitter feedbackSubmitter; + + UploadPendingFeedbackJob({ + required this.feedbackSubmitter, + }); + + @override + bool shouldExecute(SdkEvent event) { + return [SdkEvent.appStart].contains(event); + } + + @override + Future execute() async { + if (feedbackSubmitter is! RetryingFeedbackSubmitter) { + return; + } + + final submitter = feedbackSubmitter as RetryingFeedbackSubmitter; + await submitter.submitPendingFeedbackItems(); + + if (kDebugMode) { + await submitter.deletePendingFeedbacks(); + } + } +} diff --git a/lib/src/core/wiredash_model.dart b/lib/src/core/wiredash_model.dart index 7cbf2eef..0d9af790 100644 --- a/lib/src/core/wiredash_model.dart +++ b/lib/src/core/wiredash_model.dart @@ -95,6 +95,8 @@ class WiredashModel with ChangeNotifier { .map((element) => services.backdropController.hasState) .firstWhere((element) => element); + unawaited(services.syncEngine.onUserOpenedWiredash()); + await services.backdropController.animateToOpen(); } diff --git a/lib/src/core/wiredash_widget.dart b/lib/src/core/wiredash_widget.dart index d1bea2d4..e341a71f 100644 --- a/lib/src/core/wiredash_widget.dart +++ b/lib/src/core/wiredash_widget.dart @@ -2,13 +2,11 @@ import 'dart:async'; import 'dart:ui' as ui; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:wiredash/src/_wiredash_internal.dart'; import 'package:wiredash/src/_wiredash_ui.dart'; import 'package:wiredash/src/core/context_cache.dart'; import 'package:wiredash/src/core/options/wiredash_options.dart'; -import 'package:wiredash/src/core/project_credential_validator.dart'; import 'package:wiredash/src/core/support/back_button_interceptor.dart'; import 'package:wiredash/src/core/support/not_a_widgets_app.dart'; import 'package:wiredash/src/feedback/_feedback.dart'; @@ -153,7 +151,7 @@ class Wiredash extends StatefulWidget { class WiredashState extends State { final GlobalKey _appKey = GlobalKey(debugLabel: 'app'); - final WiredashServices _services = WiredashServices(); + final WiredashServices _services = _createServices(); late final WiredashBackButtonDispatcher _backButtonDispatcher; @@ -176,7 +174,7 @@ class WiredashState extends State { @override void initState() { super.initState(); - debugProjectCredentialValidator.validate( + _services.projectCredentialValidator.validate( projectId: widget.projectId, secret: widget.secret, ); @@ -185,22 +183,10 @@ class WiredashState extends State { _services.wiredashModel.addListener(_markNeedsBuild); _services.backdropController.addListener(_markNeedsBuild); - _submitTimer = - Timer(const Duration(seconds: 5), scheduleFeedbackSubmission); - _backButtonDispatcher = WiredashBackButtonDispatcher()..initialize(); - } - - /// Submits pending feedbacks on app start (slightly delayed) - void scheduleFeedbackSubmission() { - _submitTimer = null; - final submitter = _services.feedbackSubmitter; - if (submitter is RetryingFeedbackSubmitter) { - submitter.submitPendingFeedbackItems(); + // start the sync engine + unawaited(_services.syncEngine.onWiredashInit()); - if (kDebugMode) { - submitter.deletePendingFeedbacks(); - } - } + _backButtonDispatcher = WiredashBackButtonDispatcher()..initialize(); } void _markNeedsBuild() { @@ -220,7 +206,7 @@ class WiredashState extends State { @override void didUpdateWidget(Wiredash oldWidget) { super.didUpdateWidget(oldWidget); - debugProjectCredentialValidator.validate( + _services.projectCredentialValidator.validate( projectId: widget.projectId, secret: widget.secret, ); @@ -341,6 +327,18 @@ Locale get _defaultLocale { return locale ?? const Locale('en', 'US'); } +/// Can be used to inject mock services for testing @visibleForTesting -ProjectCredentialValidator debugProjectCredentialValidator = - const ProjectCredentialValidator(); +WiredashServices Function()? debugServicesCreator; + +WiredashServices _createServices() { + WiredashServices? services; + assert( + () { + services = debugServicesCreator?.call(); + return true; + }(), + ); + + return services ?? WiredashServices(); +} diff --git a/lib/src/feedback/data/persisted_feedback_item.dart b/lib/src/feedback/data/persisted_feedback_item.dart index b401d7a4..4d8e43ab 100644 --- a/lib/src/feedback/data/persisted_feedback_item.dart +++ b/lib/src/feedback/data/persisted_feedback_item.dart @@ -63,6 +63,7 @@ class PersistedFeedbackItem { @override int get hashCode => + // ignore: deprecated_member_use hashList(attachments) ^ buildInfo.hashCode ^ deviceId.hashCode ^ @@ -71,6 +72,7 @@ class PersistedFeedbackItem { userId.hashCode ^ sdkVersion.hashCode ^ deviceInfo.hashCode ^ + // ignore: deprecated_member_use hashList(labels) ^ appInfo.hashCode ^ const DeepCollectionEquality.unordered().hash(customMetaData); diff --git a/lib/src/feedback/feedback_model.dart b/lib/src/feedback/feedback_model.dart index a90d8b48..da61996c 100644 --- a/lib/src/feedback/feedback_model.dart +++ b/lib/src/feedback/feedback_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:wiredash/src/_wiredash_internal.dart'; @@ -330,6 +332,7 @@ class FeedbackModel extends ChangeNotifier2 { if (submission == SubmissionState.pending) { if (kDebugMode) print("Feedback is pending"); } + unawaited(_services.syncEngine.onSubmitFeedback()); _feedbackProcessed = true; notifyListeners(); } catch (e, stack) { diff --git a/lib/src/metadata/device_info/device_info.dart b/lib/src/metadata/device_info/device_info.dart index a16ddaa5..c4d78b60 100644 --- a/lib/src/metadata/device_info/device_info.dart +++ b/lib/src/metadata/device_info/device_info.dart @@ -184,6 +184,7 @@ class FlutterDeviceInfo { @override int get hashCode => platformLocale.hashCode ^ + // ignore: deprecated_member_use hashList(platformSupportedLocales) ^ padding.hashCode ^ physicalSize.hashCode ^ diff --git a/lib/src/nps/nps_model.dart b/lib/src/nps/nps_model.dart index f31abb1a..4a65aa97 100644 --- a/lib/src/nps/nps_model.dart +++ b/lib/src/nps/nps_model.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:wiredash/src/_wiredash_internal.dart'; import 'package:wiredash/src/core/version.dart'; @@ -54,6 +56,7 @@ class NpsModel extends ChangeNotifier2 { platformUserAgent: deviceInfo.userAgent, ); await _services.api.sendNps(body); + unawaited(_services.syncEngine.onSubmitNPS()); _closeDelay?.dispose(); _closeDelay = Delay(const Duration(seconds: 1)); await _closeDelay!.future; diff --git a/pubspec.yaml b/pubspec.yaml index e84d1a00..657df526 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: flutter: ">=2.8.0" dependencies: + clock: ^1.1.0 collection: ">=1.15.0 <2.0.0" file: ">=6.0.0-0 <7.0.0" flutter: diff --git a/test/feedback/data/pending_feedback_item_storage_test.dart b/test/feedback/data/pending_feedback_item_storage_test.dart index f12ad462..81e514c2 100644 --- a/test/feedback/data/pending_feedback_item_storage_test.dart +++ b/test/feedback/data/pending_feedback_item_storage_test.dart @@ -338,10 +338,13 @@ class InMemorySharedPreferences extends Fake implements SharedPreferences { final MethodInvocationCatcher setStringListInvocations = MethodInvocationCatcher('setStringList'); - @override Future setStringList(String key, List value) async { - await setStringListInvocations.addMethodCall(args: [key, value]); + final mockedReturnValue = + setStringListInvocations.addAsyncMethodCall(args: [key, value]); + if (mockedReturnValue != null) { + return mockedReturnValue.future; + } _store[key] = value; return true; } @@ -351,12 +354,62 @@ class InMemorySharedPreferences extends Fake implements SharedPreferences { @override List? getStringList(String key) { - final result = getStringListInvocations.addMethodCall(args: [key]); - if (result != null) { - return result as List?; + final mockedReturnValue = + getStringListInvocations.addMethodCall?>(args: [key]); + if (mockedReturnValue != null) { + return mockedReturnValue.value; } return _store[key] as List?; } + + final MethodInvocationCatcher setIntInvocations = + MethodInvocationCatcher('setInt'); + @override + Future setInt(String key, int value) async { + final mockedReturnValue = + setIntInvocations.addAsyncMethodCall(args: [key, value]); + if (mockedReturnValue != null) { + return mockedReturnValue.future; + } + _store[key] = value; + return true; + } + + final MethodInvocationCatcher getIntInvocations = + MethodInvocationCatcher('getInt'); + @override + int? getInt(String key) { + final mockedReturnValue = + getIntInvocations.addMethodCall(args: [key]); + if (mockedReturnValue != null) { + return mockedReturnValue.value; + } + return _store[key] as int?; + } + + final MethodInvocationCatcher setStringInvocations = + MethodInvocationCatcher('setString'); + @override + Future setString(String key, String value) async { + final mockedReturnValue = + setStringInvocations.addAsyncMethodCall(args: [key, value]); + if (mockedReturnValue != null) { + return mockedReturnValue.future; + } + _store[key] = value; + return true; + } + + final MethodInvocationCatcher getStringInvocations = + MethodInvocationCatcher('getString'); + @override + String? getString(String key) { + final mockedReturnValue = getStringInvocations.addMethodCall(args: [key]); + if (mockedReturnValue != null) { + return mockedReturnValue.value as String?; + } + return _store[key] as String?; + } } /// Creates string IDs that increment diff --git a/test/feedback/data/retrying_feedback_submitter_test.dart b/test/feedback/data/retrying_feedback_submitter_test.dart index bfe87cda..4ee7160a 100644 --- a/test/feedback/data/retrying_feedback_submitter_test.dart +++ b/test/feedback/data/retrying_feedback_submitter_test.dart @@ -199,7 +199,9 @@ void main() { mockApi.sendFeedbackInvocations.interceptor = (iv) { if (!firstFileSubmitted) { firstFileSubmitted = true; - throw WiredashApiException(message: "Something unexpected happened"); + throw const WiredashApiException( + message: "Something unexpected happened", + ); } return null /*void*/; }; diff --git a/test/sync/ping_job_test.dart b/test/sync/ping_job_test.dart new file mode 100644 index 00000000..22958474 --- /dev/null +++ b/test/sync/ping_job_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_async/fake_async.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:test/test.dart'; +import 'package:wiredash/src/core/network/wiredash_api.dart'; +import 'package:wiredash/src/core/sync/ping_job.dart'; + +import '../feedback/data/pending_feedback_item_storage_test.dart'; +import '../util/mock_api.dart'; + +const tenSeconds = Duration(seconds: 10); + +void main() { + group('Triggering ping', () { + late MockWiredashApi api; + late InMemorySharedPreferences prefs; + + Future prefsProvider() async => prefs; + + PingJob createPingJob() => PingJob( + api: api, + sharedPreferencesProvider: prefsProvider, + ); + + setUp(() { + api = MockWiredashApi(); + api.pingInvocations.interceptor = (invocation) async { + // 200 success + return PingResponse(); + }; + prefs = InMemorySharedPreferences(); + }); + + test('ping gets submitted', () { + fakeAsync((async) { + final pingJob = createPingJob(); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + }); + }); + + test('do not ping again within minPingGap window', () { + fakeAsync((async) { + final pingJob = createPingJob(); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + + async.elapse(PingJob.minPingGap - tenSeconds); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + }); + }); + + test('ping after minPingGap window', () { + fakeAsync((async) { + final pingJob = createPingJob(); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + + async.elapse(PingJob.minPingGap); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 2); + }); + }); + + test('silence for 1w after KillSwitchException', () { + fakeAsync((async) { + api.pingInvocations.interceptor = (invocation) { + throw const KillSwitchException(); + }; + final pingJob = createPingJob(); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + + async.elapse(const Duration(days: 7) - tenSeconds); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + }); + }); + + test('ping after KillSwitchException resumes after 1w', () { + fakeAsync((async) { + api.pingInvocations.interceptor = (invocation) { + throw const KillSwitchException(); + }; + final pingJob = + PingJob(api: api, sharedPreferencesProvider: prefsProvider); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + + async.elapse(const Duration(days: 7)); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 2); + }); + }); + + test('any general Exception thrown by ping does not silence the job', () { + fakeAsync((async) { + api.pingInvocations.interceptor = (invocation) { + throw const SocketException('message'); + }; + final pingJob = + PingJob(api: api, sharedPreferencesProvider: prefsProvider); + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 1); + + pingJob.execute(); + async.flushTimers(); + expect(api.pingInvocations.count, 2); + }); + }); + }); +} diff --git a/test/sync/sync_engine_test.dart b/test/sync/sync_engine_test.dart new file mode 100644 index 00000000..e5007da4 --- /dev/null +++ b/test/sync/sync_engine_test.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:test/test.dart'; +import 'package:wiredash/src/core/sync/sync_engine.dart'; + +void main() { + group('sync engine', () { + test('onWiredashInit triggers SdkEvent.sppStart 5s after ', () { + fakeAsync((async) { + final syncEngine = SyncEngine(); + addTearDown(() => syncEngine.onWiredashDispose()); + + DateTime? lastExecution; + final testJob = TestJob( + trigger: [SdkEvent.appStart], + block: () { + lastExecution = clock.now(); + }, + ); + syncEngine.addJob('test', testJob); + + // After init + syncEngine.onWiredashInit(); + // Jobs listening to appStart are not triggered directly + async.elapse(const Duration(seconds: 4)); + expect(lastExecution, isNull); + + // but after 5s + async.elapse(const Duration(seconds: 1)); + expect(lastExecution, isNotNull); + }); + }); + + test('Removing a job does not execute it anymore', () async { + final syncEngine = SyncEngine(); + addTearDown(() => syncEngine.onWiredashDispose()); + + DateTime? lastExecution; + final testJob = TestJob( + trigger: [SdkEvent.appStart], + block: () { + lastExecution = clock.now(); + }, + ); + syncEngine.addJob('test', testJob); + + await syncEngine.onWiredashInit(); + expect(lastExecution, isNull); + final firstRun = lastExecution; + + final removed = syncEngine.removeJob('test'); + expect(removed, testJob); + + await syncEngine.onWiredashInit(); + // did not update, was not executed again + expect(lastExecution, firstRun); + }); + }); +} + +class TestJob extends Job { + final List trigger; + final FutureOr Function() block; + + TestJob({ + required this.trigger, + required this.block, + }); + + @override + Future execute() async { + await block(); + } + + @override + bool shouldExecute(SdkEvent event) { + return trigger.contains(event); + } +} diff --git a/test/util/invocation_catcher.dart b/test/util/invocation_catcher.dart index bed3b7e9..50c22305 100644 --- a/test/util/invocation_catcher.dart +++ b/test/util/invocation_catcher.dart @@ -22,7 +22,7 @@ class MethodInvocationCatcher { int get count => _invocations.length; - dynamic addMethodCall({ + MockedReturnValue? addMethodCall({ Map? namedArgs, List? args, }) { @@ -33,11 +33,34 @@ class MethodInvocationCatcher { ); _invocations.add(AssertableInvocation(iv)); if (interceptor != null) { - return interceptor!.call(iv); + return MockedReturnValue(interceptor!.call(iv) as R); } return null; } + AsyncMockedReturnValue? addAsyncMethodCall({ + Map? namedArgs, + List? args, + }) { + final iv = Invocation.method( + Symbol(methodName), + args, + namedArgs?.map((key, value) => MapEntry(Symbol(key), value)), + ); + _invocations.add(AssertableInvocation(iv)); + if (interceptor != null) { + final mocked = interceptor!.call(iv); + if (mocked is Future) { + return AsyncMockedReturnValue(mocked); + } else { + final result = mocked as R; + return AsyncMockedReturnValue(Future.sync(() => result)); + } + } + return null; + } + + /// Add an interceptor to get a callback when a method is called or return mock data to the caller dynamic Function(Invocation invocation)? interceptor; void verifyInvocationCount(int n) { @@ -57,6 +80,17 @@ class MethodInvocationCatcher { } } +class MockedReturnValue { + MockedReturnValue(this.value); + final T value; +} + +class AsyncMockedReturnValue { + AsyncMockedReturnValue(this.future); + final Future future; +} + +/// A invocation which can be used to assert specific values class AssertableInvocation { AssertableInvocation(this.original); @@ -91,5 +125,3 @@ class AssertableInvocation { ')'; } } - -class WithArgument {} diff --git a/test/util/mock_api.dart b/test/util/mock_api.dart index c4c96d4b..e2392ca0 100644 --- a/test/util/mock_api.dart +++ b/test/util/mock_api.dart @@ -25,7 +25,7 @@ class MockWiredashApi implements WiredashApi { @override Future sendFeedback(PersistedFeedbackItem feedback) async { - return await sendFeedbackInvocations.addMethodCall(args: [feedback]); + return await sendFeedbackInvocations.addMethodCall(args: [feedback])?.value; } final MethodInvocationCatcher uploadAttachmentInvocations = @@ -38,7 +38,8 @@ class MockWiredashApi implements WiredashApi { String? filename, MediaType? contentType, }) async { - final response = await uploadAttachmentInvocations.addMethodCall( + final mockedReturnValue = + uploadAttachmentInvocations.addAsyncMethodCall( namedArgs: { 'screenshot': screenshot, 'type': type, @@ -46,8 +47,8 @@ class MockWiredashApi implements WiredashApi { 'contentType': contentType, }, ); - if (response != null) { - return response as AttachmentId; + if (mockedReturnValue != null) { + return mockedReturnValue.future; } throw 'Not mocked'; } @@ -57,6 +58,19 @@ class MockWiredashApi implements WiredashApi { @override Future sendNps(NpsRequestBody body) async { - return await sendNpsInvocations.addMethodCall(args: [body]); + return await sendNpsInvocations.addAsyncMethodCall(args: [body])?.future; + } + + final MethodInvocationCatcher pingInvocations = + MethodInvocationCatcher('ping'); + + @override + Future ping() async { + final mockedReturnValue = + pingInvocations.addAsyncMethodCall(); + if (mockedReturnValue != null) { + return mockedReturnValue.future; + } + throw 'Not mocked'; } } diff --git a/test/util/robot.dart b/test/util/robot.dart index 30e63876..2bb69575 100644 --- a/test/util/robot.dart +++ b/test/util/robot.dart @@ -33,6 +33,10 @@ class WiredashTestRobot { channel.setMockMethodCallHandler((MethodCall methodCall) async { return '.'; }); + + debugServicesCreator = () => createMockServices(); + addTearDown(() => debugServicesCreator = null); + await tester.pumpWidget( Wiredash( projectId: 'test', @@ -322,6 +326,12 @@ class WiredashTestRobot { } } +WiredashServices createMockServices() { + final services = WiredashServices(); + services.inject((locator) => MockWiredashApi()); + return services; +} + class WiredashTestLocalizationDelegate extends LocalizationsDelegate { @override diff --git a/test/wiredash_widget_test.dart b/test/wiredash_widget_test.dart index d7ccfb67..433b6b41 100644 --- a/test/wiredash_widget_test.dart +++ b/test/wiredash_widget_test.dart @@ -4,15 +4,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wiredash/src/core/project_credential_validator.dart'; +import 'package:wiredash/src/core/services/services.dart'; import 'package:wiredash/src/core/wiredash_widget.dart'; import 'util/invocation_catcher.dart'; +import 'util/mock_api.dart'; import 'util/robot.dart'; void main() { group('Wiredash', () { setUp(() { SharedPreferences.setMockInitialValues({}); + debugServicesCreator = createMockServices; + addTearDown(() => debugServicesCreator = null); }); testWidgets('widget can be created', (tester) async { @@ -27,15 +31,53 @@ void main() { expect(find.byType(Wiredash), findsOneWidget); }); + testWidgets('readding Wiredash simply works and sends pings again', + (tester) async { + await tester.pumpWidget( + const Wiredash( + projectId: 'test', + secret: 'test', + // this widget never settles, allowing us to jump in the future + child: CircularProgressIndicator(), + ), + ); + await tester.pump(const Duration(seconds: 1)); + + final api1 = findWireadshServices.api as MockWiredashApi; + expect(api1.pingInvocations.count, 0); + await tester.pump(const Duration(seconds: 5)); + expect(api1.pingInvocations.count, 1); + + // remove wiredash + expect(find.byType(Wiredash), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + + // add it a second time + await tester.pumpWidget( + const Wiredash( + projectId: 'test', + secret: 'test', + child: SizedBox(), + ), + ); + await tester.pump(const Duration(seconds: 1)); + + final api2 = findWireadshServices.api as MockWiredashApi; + expect(api2.pingInvocations.count, 0); + await tester.pump(const Duration(seconds: 5)); + expect(api2.pingInvocations.count, 1); + }); + testWidgets( 'calls ProjectCredentialValidator.validate() initially', (tester) async { final _MockProjectCredentialValidator validator = _MockProjectCredentialValidator(); - debugProjectCredentialValidator = validator; - addTearDown(() { - debugProjectCredentialValidator = const ProjectCredentialValidator(); - }); + + debugServicesCreator = () => createMockServices() + ..inject((p0) => validator); + addTearDown(() => debugServicesCreator = null); await tester.pumpWidget( const Wiredash( @@ -76,11 +118,21 @@ class _MockProjectCredentialValidator extends Fake required String projectId, required String secret, }) async { - validateInvocations - .addMethodCall(namedArgs: {'projectId': projectId, 'secret': secret}); + return validateInvocations.addAsyncMethodCall( + namedArgs: { + 'projectId': projectId, + 'secret': secret, + }, + )?.future; } } +WiredashServices get findWireadshServices { + final found = find.byType(Wiredash).evaluate().first as StatefulElement; + final wiredashState = found.state as WiredashState; + return wiredashState.debugServices; +} + class _FakeApp extends StatefulWidget { const _FakeApp({Key? key}) : super(key: key);