diff --git a/actions/log_action/analysis_options.yaml b/actions/log_action/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/actions/log_action/analysis_options.yaml +++ b/actions/log_action/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/actions/log_action/pubspec.yaml b/actions/log_action/pubspec.yaml index b94bbbe0..024534b3 100644 --- a/actions/log_action/pubspec.yaml +++ b/actions/log_action/pubspec.yaml @@ -16,4 +16,4 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ">=0.3.0 <2.0.0" - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/bricks/fluttium_launcher/hooks/analysis_options.yaml b/bricks/fluttium_launcher/hooks/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/bricks/fluttium_launcher/hooks/analysis_options.yaml +++ b/bricks/fluttium_launcher/hooks/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/bricks/fluttium_launcher/hooks/pubspec.yaml b/bricks/fluttium_launcher/hooks/pubspec.yaml index 4b0da4b4..20bfbf13 100644 --- a/bricks/fluttium_launcher/hooks/pubspec.yaml +++ b/bricks/fluttium_launcher/hooks/pubspec.yaml @@ -9,4 +9,4 @@ dependencies: dev_dependencies: test: ^1.19.2 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/bricks/fluttium_test_runner/__brick__/lib/fluttium_test_runner.dart b/bricks/fluttium_test_runner/__brick__/lib/fluttium_test_runner.dart index 5a50fc08..406bdbc6 100644 --- a/bricks/fluttium_test_runner/__brick__/lib/fluttium_test_runner.dart +++ b/bricks/fluttium_test_runner/__brick__/lib/fluttium_test_runner.dart @@ -4,18 +4,9 @@ import 'package:fluttium_interfaces/fluttium_interfaces.dart'; {{#actions}} import 'package:{{name.snakeCase()}}/{{name.snakeCase()}}.dart' as {{name.snakeCase()}};{{/actions}} -Future run(WidgetsBinding binding) async { +Future run(WidgetsBinding binding) async { final registry = Registry();{{#actions}} {{name.snakeCase()}}.register(registry);{{/actions}} - final tester = Tester(binding, registry); - await tester.ready(); - - final actions = await tester.convert([{{#steps}} - UserFlowStep.fromJson({{{step}}}),{{/steps}} - ]); - - for (final action in actions) { - await action(); - } + return Tester(binding, registry); } diff --git a/bricks/fluttium_test_runner/hooks/analysis_options.yaml b/bricks/fluttium_test_runner/hooks/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/bricks/fluttium_test_runner/hooks/analysis_options.yaml +++ b/bricks/fluttium_test_runner/hooks/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/bricks/fluttium_test_runner/hooks/pubspec.yaml b/bricks/fluttium_test_runner/hooks/pubspec.yaml index 4b0da4b4..20bfbf13 100644 --- a/bricks/fluttium_test_runner/hooks/pubspec.yaml +++ b/bricks/fluttium_test_runner/hooks/pubspec.yaml @@ -9,4 +9,4 @@ dependencies: dev_dependencies: test: ^1.19.2 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/docs/docs/getting-started/initializing-fluttium.md b/docs/docs/getting-started/initializing-fluttium.md index 50118b93..74701f69 100644 --- a/docs/docs/getting-started/initializing-fluttium.md +++ b/docs/docs/getting-started/initializing-fluttium.md @@ -18,7 +18,7 @@ Once we run `fluttium init`, we should have a `fluttium.yaml` that looks like: # The following defines the environment for your Fluttium project. It includes # the version of Fluttium that the project requires. environment: - fluttium: '>=0.1.0 <0.2.0' + fluttium: '>=0.1.0 <1.0.0' # The driver can be configured with default values. Uncomment the following # lines to automatically run Fluttium using a different flavor and dart-defines. diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index d767e5d3..fa798a83 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -1,4 +1,4 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml linter: rules: public_member_api_docs: false diff --git a/example/flows/counter_flow.yaml b/example/flows/counter_flow.yaml index 90764e85..4446ac1a 100644 --- a/example/flows/counter_flow.yaml +++ b/example/flows/counter_flow.yaml @@ -2,18 +2,23 @@ description: Testing the counter page --- # Validate that the app page is displayed - expectEnvironmentText: +- takeScreenshot: "screenshots/app_page.png" # Go to the counter page - pressOn: "Counter" # Validate that the counter page is displayed and the counter is 0 - expectVisible: "Counter" +- takeScreenshot: "screenshots/counter_page.png" - expectVisible: 0 # Increment and decrement the counter - pressOn: "Increment" +- takeScreenshot: "screenshots/counter_1.png" - expectVisible: 1 - pressOn: "Increment" - expectVisible: 2 +- takeScreenshot: "screenshots/counter_2.png" - pressOn: "Decrement" - expectVisible: 1 +- takeScreenshot: "screenshots/counter_1_again.png" # Return to the app page - pressOn: "Back" - expectEnvironmentText: diff --git a/example/flows/progress_flow.yaml b/example/flows/progress_flow.yaml index 3880a7a2..2203580e 100644 --- a/example/flows/progress_flow.yaml +++ b/example/flows/progress_flow.yaml @@ -11,6 +11,7 @@ description: Testing the progress page # Start the progress - pressOn: "Start" - expectVisible: "Progress: \\d+%" +- takeScreenshot: "screenshots/progress.png" - expectVisible: "Done" # Return to the app page - pressOn: "Back" diff --git a/example/flows/simple_menu_flow.yaml b/example/flows/simple_menu_flow.yaml index 8eec27e6..12fc1354 100644 --- a/example/flows/simple_menu_flow.yaml +++ b/example/flows/simple_menu_flow.yaml @@ -6,6 +6,9 @@ description: Testing the simple menu page - pressOn: "Simple Menu" # Validate that the simple menu page is displayed and the menu button is visible - expectVisible: "Simple Menu" +- generateSemanticsTree: + path: "semantics_tree.json" + prettify: true - expectVisible: "Show Menu" # Press the button using a long press - longPressOn: "Show Menu" diff --git a/example/lib/simple_menu/view/simple_menu.dart b/example/lib/simple_menu/view/simple_menu.dart index e993d2f8..4834ad6d 100644 --- a/example/lib/simple_menu/view/simple_menu.dart +++ b/example/lib/simple_menu/view/simple_menu.dart @@ -36,7 +36,7 @@ class _SimpleMenuPageState extends State { child: Row( children: [Text('Menu Item 1')], ), - ) + ), ], context: context, position: _getRelativeRect(widgetKey), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 757fad5d..7dc8a7fb 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -17,7 +17,7 @@ dev_dependencies: flutter_test: sdk: flutter mocktail: ^1.0.0 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 flutter: uses-material-design: true diff --git a/packages/fluttium/analysis_options.yaml b/packages/fluttium/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/packages/fluttium/analysis_options.yaml +++ b/packages/fluttium/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/fluttium/lib/src/actions/clear_text.dart b/packages/fluttium/lib/src/actions/clear_text.dart index 28a643e1..81a44192 100644 --- a/packages/fluttium/lib/src/actions/clear_text.dart +++ b/packages/fluttium/lib/src/actions/clear_text.dart @@ -58,7 +58,7 @@ class ClearText extends Action { SystemChannels.textInput.codec.encodeMethodCall( const MethodCall('TextInputClient.performSelectors', [ -1, - ['deleteBackward:'] + ['deleteBackward:'], ]), ), ); diff --git a/packages/fluttium/lib/src/actions/scroll.dart b/packages/fluttium/lib/src/actions/scroll.dart index ab2b5996..b1959c5b 100644 --- a/packages/fluttium/lib/src/actions/scroll.dart +++ b/packages/fluttium/lib/src/actions/scroll.dart @@ -52,16 +52,12 @@ class Scroll extends Action { switch (direction) { case AxisDirection.up: scrollDelta = Offset(0, -speed); - break; case AxisDirection.right: scrollDelta = Offset(speed, 0); - break; case AxisDirection.down: scrollDelta = Offset(0, speed); - break; case AxisDirection.left: scrollDelta = Offset(-speed, 0); - break; } final end = clock.now().add(timeout); diff --git a/packages/fluttium/lib/src/registry.dart b/packages/fluttium/lib/src/registry.dart index c6c46094..3c29a0dd 100644 --- a/packages/fluttium/lib/src/registry.dart +++ b/packages/fluttium/lib/src/registry.dart @@ -8,21 +8,22 @@ import 'package:fluttium/fluttium.dart'; /// The registry of all the actions a [Tester] can perform. /// {@endtemplate} class Registry { - final Map _actions = { - 'tapOn': ActionRegistration(PressOn.new, shortHand: #text), - 'inputText': ActionRegistration(WriteText.new, shortHand: #text), - // TODO(wolfen): deprecate the above action keys - - 'pressOn': ActionRegistration(PressOn.new, shortHand: #text), - 'longPressOn': ActionRegistration(LongPressOn.new, shortHand: #text), - 'clearText': ActionRegistration(ClearText.new, shortHand: #characters), - 'writeText': ActionRegistration(WriteText.new, shortHand: #text), - 'expectVisible': ActionRegistration(ExpectVisible.new, shortHand: #text), - 'expectNotVisible': - ActionRegistration(ExpectNotVisible.new, shortHand: #text), - 'takeScreenshot': ActionRegistration(TakeScreenshot.new, shortHand: #path), - 'wait': ActionRegistration(Wait.new, shortHand: #milliseconds), - 'scroll': ActionRegistration( + /// {@macro registry} + Registry() { + registerAction('pressOn', PressOn.new, shortHandIs: #text); + registerAction('longPressOn', LongPressOn.new, shortHandIs: #text); + registerAction('clearText', ClearText.new, shortHandIs: #characters); + registerAction('writeText', WriteText.new, shortHandIs: #text); + registerAction('expectVisible', ExpectVisible.new, shortHandIs: #text); + registerAction( + 'expectNotVisible', + ExpectNotVisible.new, + shortHandIs: #text, + ); + registerAction('takeScreenshot', TakeScreenshot.new, shortHandIs: #path); + registerAction('wait', Wait.new, shortHandIs: #milliseconds); + registerAction( + 'scroll', ({ required String within, required String until, @@ -38,10 +39,11 @@ class Registry { timeout: timeout, ), aliases: const [ - Alias(['in'], #within) + Alias(['in'], #within), ], - ), - 'swipe': ActionRegistration( + ); + registerAction( + 'swipe', ({ required String within, required String until, @@ -57,10 +59,12 @@ class Registry { timeout: timeout, ), aliases: const [ - Alias(['in'], #within) + Alias(['in'], #within), ], - ), - }; + ); + } + + final Map _actions = {}; /// Map of all the action that are registered. UnmodifiableMapView get actions => diff --git a/packages/fluttium/lib/src/tester.dart b/packages/fluttium/lib/src/tester.dart index 34e76814..7d0bcb4f 100644 --- a/packages/fluttium/lib/src/tester.dart +++ b/packages/fluttium/lib/src/tester.dart @@ -1,21 +1,69 @@ +import 'dart:convert'; +import 'dart:developer'; + import 'package:clock/clock.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' hide Action; import 'package:fluttium/fluttium.dart'; -import 'package:fluttium_interfaces/fluttium_interfaces.dart'; -import 'package:fluttium_protocol/fluttium_protocol.dart'; /// {@template tester} /// The tester that is used to execute the actions in a flow file. /// {@endtemplate} class Tester { /// {@macro tester} - Tester(this._binding, this._registry, {Emitter? emitter}) - : _emitter = emitter ?? Emitter(), - _semanticsHandle = _binding.ensureSemantics(); + Tester(this._binding, this._registry) + : _semanticsHandle = _binding.ensureSemantics() { + registerExtension( + 'ext.fluttium.ready', + (method, parameters) async { + try { + // await ready(); + return ServiceExtensionResponse.result( + json.encode({'ready': true}), + ); + } catch (err) { + return ServiceExtensionResponse.result( + json.encode({'ready': false, 'reason': err}), + ); + } + }, + ); - final Emitter _emitter; + registerExtension( + 'ext.fluttium.getActionDescription', + (method, parameters) async { + final action = _registry.getAction( + parameters['name']!, + json.decode(parameters['arguments']!), + ); + return ServiceExtensionResponse.result( + json.encode({'description': action.description()}), + ); + }, + ); + + registerExtension( + 'ext.fluttium.executeAction', + (method, parameters) async { + final action = _registry.getAction( + parameters['name']!, + json.decode(parameters['arguments']!), + ); + try { + _storedFiles.clear(); + final result = await action.execute(this); + return ServiceExtensionResponse.result( + json.encode({'success': result, 'files': _storedFiles}), + ); + } catch (err) { + return ServiceExtensionResponse.result( + json.encode({'success': false, 'reason': err}), + ); + } + }, + ); + } final WidgetsBinding _binding; @@ -23,45 +71,20 @@ class Tester { final Registry _registry; + final Map _storedFiles = {}; + + /// The files that were stored in the current action. + Map get storedFiles => Map.unmodifiable(_storedFiles); + SemanticsOwner get _semanticsOwner => _binding.pipelineOwner.semanticsOwner!; /// The current screen's media query information. MediaQueryData get mediaQuery => MediaQueryData.fromView(_binding.platformDispatcher.views.first); - /// Converts the [steps] into a list of executable actions. - Future Function()>> convert( - List steps, - ) async { - return Future.wait( - steps.map((step) async { - try { - final action = _registry.getAction(step.actionName, step.arguments); - final actionRepresentation = action.description(); - await _emitter.announce(actionRepresentation); - - return () async { - try { - await _emitter.start(actionRepresentation); - if (await action.execute(this)) { - return _emitter.done(actionRepresentation); - } - return _emitter.fail(actionRepresentation); - } catch (err) { - return _emitter.fail(actionRepresentation, reason: '$err'); - } - }; - } catch (err) { - await _emitter.fatal('$err'); - rethrow; - } - }).toList(), - ); - } - /// Store binary data with the given [fileName]. - Future storeFile(String fileName, Uint8List bytes) async { - await _emitter.store(fileName, bytes); + Future storeFile(String fileName, List bytes) async { + _storedFiles[fileName] = base64.encode(bytes); } /// Dispatch an event to the targets found by a hit test on its position. @@ -169,8 +192,7 @@ class Tester { /// Wait for the semantics tree to be fully build. Future ready() async { - while (_binding.pipelineOwner.semanticsOwner == null || - _binding.pipelineOwner.semanticsOwner!.rootSemanticsNode == null) { + while (_binding.pipelineOwner.semanticsOwner?.rootSemanticsNode == null) { await _binding.endOfFrame; } } diff --git a/packages/fluttium/pubspec.yaml b/packages/fluttium/pubspec.yaml index 28570eb1..953273be 100644 --- a/packages/fluttium/pubspec.yaml +++ b/packages/fluttium/pubspec.yaml @@ -15,11 +15,10 @@ dependencies: flutter: sdk: flutter fluttium_interfaces: ^0.1.0 - fluttium_protocol: ^0.1.0 dev_dependencies: fake_async: ^1.3.1 flutter_test: sdk: flutter mocktail: ^0.3.0 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/packages/fluttium/pubspec_overrides.yaml b/packages/fluttium/pubspec_overrides.yaml index 43c34737..188d90d4 100644 --- a/packages/fluttium/pubspec_overrides.yaml +++ b/packages/fluttium/pubspec_overrides.yaml @@ -1,5 +1,3 @@ dependency_overrides: - fluttium_protocol: - path: ../fluttium_protocol fluttium_interfaces: path: ../fluttium_interfaces diff --git a/packages/fluttium/test/helpers/matchers/is_delete_backwards.dart b/packages/fluttium/test/helpers/matchers/is_delete_backwards.dart index 94d93abe..1a625bcf 100644 --- a/packages/fluttium/test/helpers/matchers/is_delete_backwards.dart +++ b/packages/fluttium/test/helpers/matchers/is_delete_backwards.dart @@ -6,7 +6,7 @@ Matcher get isDeleteBackward { 'method': 'TextInputClient.performSelectors', 'args': [ -1, - ['deleteBackward:'] + ['deleteBackward:'], ], }); diff --git a/packages/fluttium/test/helpers/matchers/is_text.dart b/packages/fluttium/test/helpers/matchers/is_text.dart index 627c2a70..ce93e9c0 100644 --- a/packages/fluttium/test/helpers/matchers/is_text.dart +++ b/packages/fluttium/test/helpers/matchers/is_text.dart @@ -9,7 +9,7 @@ Matcher isText(String text) { TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), - ).toJSON() + ).toJSON(), ], }); diff --git a/packages/fluttium/test/src/registry_test.dart b/packages/fluttium/test/src/registry_test.dart index 8a3b65a8..b27de996 100644 --- a/packages/fluttium/test/src/registry_test.dart +++ b/packages/fluttium/test/src/registry_test.dart @@ -65,7 +65,7 @@ void main() { 'action', _TestActionWithArguments.new, aliases: [ - Alias(['withKey'], #key) + Alias(['withKey'], #key), ], ); @@ -73,7 +73,7 @@ void main() { expect( registry.actions['action']!.aliases, equals([ - Alias(['withKey'], #key) + Alias(['withKey'], #key), ]), ); }); @@ -130,7 +130,7 @@ void main() { 'action', _TestActionWithArguments.new, aliases: [ - Alias(['withKey'], #key) + Alias(['withKey'], #key), ], ); diff --git a/packages/fluttium/test/src/tester_test.dart b/packages/fluttium/test/src/tester_test.dart index bbb21c38..0e547d54 100644 --- a/packages/fluttium/test/src/tester_test.dart +++ b/packages/fluttium/test/src/tester_test.dart @@ -8,8 +8,6 @@ import 'package:flutter/rendering.dart' hide ViewConfiguration; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttium/fluttium.dart'; -import 'package:fluttium_interfaces/fluttium_interfaces.dart'; -import 'package:fluttium_protocol/fluttium_protocol.dart'; import 'package:mocktail/mocktail.dart'; import '../helpers/helpers.dart'; @@ -20,8 +18,6 @@ class _MockWidgetBinding extends Mock implements WidgetsBinding {} class _MockSemanticsHandle extends Mock implements SemanticsHandle {} -class _MockEmitter extends Mock implements Emitter {} - class _MockRegistry extends Mock implements Registry {} class _MockChannelBuffers extends Mock implements ChannelBuffers {} @@ -46,7 +42,6 @@ void main() { late Action action; late WidgetsBinding binding; late SemanticsHandle semanticsHandle; - late Emitter emitter; late Registry registry; late ChannelBuffers channelBuffers; @@ -54,7 +49,6 @@ void main() { action = _MockAction(); binding = _MockWidgetBinding(); semanticsHandle = _MockSemanticsHandle(); - emitter = _MockEmitter(); registry = _MockRegistry(); channelBuffers = _MockChannelBuffers(); @@ -63,17 +57,11 @@ void main() { when(semanticsHandle.dispose).thenAnswer((_) {}); when(() => registry.getAction(any(), any())).thenReturn(action); - when(() => emitter.start(any())).thenAnswer((_) async {}); - when( - () => emitter.fail(any(), reason: any(named: 'reason')), - ).thenAnswer((_) async {}); - when(() => emitter.done(any())).thenAnswer((_) async {}); - when(() => emitter.fatal(any())).thenAnswer((_) async {}); when(() => binding.platformDispatcher) .thenReturn(PlatformDispatcher.instance); - tester = Tester(binding, registry, emitter: emitter); + tester = Tester(binding, registry); }); setUpAll(() { @@ -96,111 +84,10 @@ void main() { expect(mediaQuery.viewInsets, equals(EdgeInsets.zero)); }); - group('convert', () { - late List steps; - - setUp(() { - when(() => emitter.announce(any())).thenAnswer((_) async {}); - - steps = [ - UserFlowStep('actionName', arguments: 'actionData'), - ]; - }); - - test('retrieves and execute action correctly', () async { - when(() => action.execute(any())).thenAnswer((_) async => true); - when(action.description).thenReturn('action'); - - final actions = await tester.convert(steps); - for (final action in actions) { - await action(); - } - - verify(() => emitter.announce('action')).called(1); - - verify(() => emitter.start(any(that: equals('action')))).called(1); - verify( - () => registry.getAction( - any(that: equals('actionName')), - any(that: equals('actionData')), - ), - ).called(1); - verify(() => action.execute(any(that: equals(tester)))).called(1); - verify(() => emitter.done(any(that: equals('action')))).called(1); - }); - - test('fails if action is not found', () async { - when(() => registry.getAction(any(), any())).thenAnswer((_) { - throw Exception('Action not found'); - }); - - await expectLater( - () => tester.convert(steps), - throwsException, - ); - - verify( - () => emitter.fatal(any(that: equals('Exception: Action not found'))), - ).called(1); - verifyNever(() => emitter.announce('action')); - }); - - test('fails if action execution throws', () async { - when(() => action.execute(any())) - .thenAnswer((_) async => throw Exception('Action failed')); - when(action.description).thenReturn('action'); - - final actions = await tester.convert(steps); - for (final action in actions) { - await action(); - } - - verify(() => emitter.announce('action')).called(1); - - verify(() => emitter.start(any(that: equals('action')))).called(1); - verify( - () => emitter.fail( - any(that: equals('action')), - reason: any( - named: 'reason', - that: equals('Exception: Action failed'), - ), - ), - ).called(1); - }); - - test('fails if action execution returns false', () async { - when(() => action.execute(any())).thenAnswer((_) async => false); - when(action.description).thenReturn('action'); - - final actions = await tester.convert(steps); - for (final action in actions) { - await action(); - } - - verify(() => emitter.announce('action')).called(1); - - verify(() => emitter.start(any(that: equals('action')))).called(1); - verify( - () => emitter.fail( - any(that: equals('action')), - reason: any(named: 'reason'), - ), - ).called(1); - }); - }); - test('storeFile', () async { - when(() => emitter.store(any(), any())).thenAnswer((_) async {}); + await tester.storeFile('fileName', [1, 2, 3]); - await tester.storeFile('fileName', Uint8List(0)); - - verify( - () => emitter.store( - any(that: equals('fileName')), - any(that: equals(Uint8List(0))), - ), - ).called(1); + expect(tester.storedFiles, equals({'fileName': 'AQID'})); }); test('emitPointerEvent', () async { diff --git a/packages/fluttium_cli/analysis_options.yaml b/packages/fluttium_cli/analysis_options.yaml index 0d7e2dc7..fd080551 100644 --- a/packages/fluttium_cli/analysis_options.yaml +++ b/packages/fluttium_cli/analysis_options.yaml @@ -1,4 +1,4 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml analyzer: exclude: - "test/.test_coverage.dart" diff --git a/packages/fluttium_cli/lib/src/command_runner.dart b/packages/fluttium_cli/lib/src/command_runner.dart index cdd38001..7894fc5b 100644 --- a/packages/fluttium_cli/lib/src/command_runner.dart +++ b/packages/fluttium_cli/lib/src/command_runner.dart @@ -157,11 +157,11 @@ Run ${lightCyan.wrap('$executableName update')} to update''', final flutterVersion = RegExp('Flutter (.*?) ').firstMatch(result)!.group(1)!; if (!Version.parse(flutterVersion) - .allowsAny(FluttiumDriver.flutterVersionConstraint)) { + .allowsAny(HostDriver.flutterVersionConstraint)) { _logger.err( ''' Version solving failed: - The Fluttium CLI uses "${FluttiumDriver.flutterVersionConstraint}" as the version constraint for Flutter. + The Fluttium CLI uses "${HostDriver.flutterVersionConstraint}" as the version constraint for Flutter. The current Flutter version is "$flutterVersion" which is not supported by Fluttium. Either update Flutter to a compatible version supported by the CLI or update the CLI to a compatible version of Flutter.''', diff --git a/packages/fluttium_cli/lib/src/commands/init_command.dart b/packages/fluttium_cli/lib/src/commands/init_command.dart index 88f1d180..01ca146d 100644 --- a/packages/fluttium_cli/lib/src/commands/init_command.dart +++ b/packages/fluttium_cli/lib/src/commands/init_command.dart @@ -60,7 +60,7 @@ class _FluttiumYamlGenerator extends MasonGenerator { # The following defines the environment for your Fluttium project. It includes # the version of Fluttium that the project requires. environment: - fluttium: "${FluttiumDriver.fluttiumVersionConstraint}" + fluttium: "${HostDriver.fluttiumVersionConstraint}" # The driver can be configured with default values. Uncomment the following # lines to automatically run Fluttium using a different flavor and dart-defines. diff --git a/packages/fluttium_cli/lib/src/commands/new_command/new_bundle_command.dart b/packages/fluttium_cli/lib/src/commands/new_command/new_bundle_command.dart index fabd2383..0a0244ea 100644 --- a/packages/fluttium_cli/lib/src/commands/new_command/new_bundle_command.dart +++ b/packages/fluttium_cli/lib/src/commands/new_command/new_bundle_command.dart @@ -54,7 +54,6 @@ class NewBundleCommand extends Command { help: props.description, defaultsTo: props.defaultValue as String?, ); - break; } } } diff --git a/packages/fluttium_cli/lib/src/commands/new_command/new_command.dart b/packages/fluttium_cli/lib/src/commands/new_command/new_command.dart index 0bdf68e8..cce3a03d 100644 --- a/packages/fluttium_cli/lib/src/commands/new_command/new_command.dart +++ b/packages/fluttium_cli/lib/src/commands/new_command/new_command.dart @@ -31,7 +31,7 @@ class NewCommand extends Command { generatorFromBundle: generatorFromBundle, generatorFromBrick: generatorFromBrick, defaultVars: { - 'fluttiumVersion': FluttiumDriver.fluttiumVersionConstraint.min, + 'fluttiumVersion': HostDriver.fluttiumVersionConstraint.min, }, ), ); diff --git a/packages/fluttium_cli/lib/src/commands/test_command/reporters/compact_reporter.dart b/packages/fluttium_cli/lib/src/commands/test_command/reporters/compact_reporter.dart index 7333906a..5b74f254 100644 --- a/packages/fluttium_cli/lib/src/commands/test_command/reporters/compact_reporter.dart +++ b/packages/fluttium_cli/lib/src/commands/test_command/reporters/compact_reporter.dart @@ -18,7 +18,7 @@ class CompactReporter extends Reporter { bool failed = false; @override - void report(List steps) { + void report(List steps) { final currentStep = steps.lastWhere( (step) => step.status != StepStatus.initial, orElse: () => steps.first, diff --git a/packages/fluttium_cli/lib/src/commands/test_command/reporters/expanded_reporter.dart b/packages/fluttium_cli/lib/src/commands/test_command/reporters/expanded_reporter.dart index 8a1d018c..f5f318d6 100644 --- a/packages/fluttium_cli/lib/src/commands/test_command/reporters/expanded_reporter.dart +++ b/packages/fluttium_cli/lib/src/commands/test_command/reporters/expanded_reporter.dart @@ -11,10 +11,10 @@ class ExpandedReporter extends Reporter { bool failed = false; - StepState? previousStep; + UserFlowStepState? previousStep; @override - void report(List steps) { + void report(List steps) { if (!_stopwatch.isRunning) { _stopwatch.start(); } diff --git a/packages/fluttium_cli/lib/src/commands/test_command/reporters/pretty_reporter.dart b/packages/fluttium_cli/lib/src/commands/test_command/reporters/pretty_reporter.dart index 42b386db..66f95bf6 100644 --- a/packages/fluttium_cli/lib/src/commands/test_command/reporters/pretty_reporter.dart +++ b/packages/fluttium_cli/lib/src/commands/test_command/reporters/pretty_reporter.dart @@ -36,7 +36,7 @@ class PrettyReporter extends Reporter { late final bool _lineMode; @override - void report(List steps) { + void report(List steps) { // Reset the cursor to the top of the screen and clear the screen. logger.info(''' \u001b[0;0H\u001b[0J @@ -48,16 +48,12 @@ class PrettyReporter extends Reporter { switch (step.status) { case StepStatus.initial: logger.info(' 🔲 ${step.description}'); - break; case StepStatus.running: logger.info(' ⏳ ${step.description}'); - break; case StepStatus.done: logger.info(' ✅ ${step.description}'); - break; case StepStatus.failed: logger.info(' ❌ ${step.description}'); - break; } } diff --git a/packages/fluttium_cli/lib/src/commands/test_command/reporters/reporter.dart b/packages/fluttium_cli/lib/src/commands/test_command/reporters/reporter.dart index 2b06a6ba..852e6424 100644 --- a/packages/fluttium_cli/lib/src/commands/test_command/reporters/reporter.dart +++ b/packages/fluttium_cli/lib/src/commands/test_command/reporters/reporter.dart @@ -13,7 +13,7 @@ abstract class Reporter { final bool watch; - void report(List steps); + void report(List steps); void done() {} diff --git a/packages/fluttium_cli/lib/src/commands/test_command/test_command.dart b/packages/fluttium_cli/lib/src/commands/test_command/test_command.dart index 6c4d03bf..57c75b5d 100644 --- a/packages/fluttium_cli/lib/src/commands/test_command/test_command.dart +++ b/packages/fluttium_cli/lib/src/commands/test_command/test_command.dart @@ -49,7 +49,7 @@ class TestCommand extends Command { FluttiumDriverCreator? driver, }) : _logger = logger, _process = processManager ?? const LocalProcessManager(), - _driver = driver ?? FluttiumDriver.new { + _driver = driver ?? HostDriver.new { argParser ..addFlag( 'watch', @@ -228,18 +228,6 @@ Multiple defines can be passed by repeating "--dart-define" multiple times.''', }).toList(); } - List _storeFiles(List steps) { - final step = steps.lastWhereOrNull((e) => e.status == StepStatus.done); - if (step == null) return steps; - for (final file in step.files.entries) { - _logger.detail('Writing ${file.value.length} bytes to "${file.key}"'); - File(file.key) - ..createSync(recursive: true) - ..writeAsBytesSync(file.value); - } - return steps; - } - @override Future run() async { final userFlowFile = _userFlowFile; @@ -269,17 +257,17 @@ Multiple defines can be passed by repeating "--dart-define" multiple times.''', } else { fluttium = FluttiumYaml( environment: FluttiumEnvironment( - fluttium: FluttiumDriver.fluttiumVersionConstraint, + fluttium: HostDriver.fluttiumVersionConstraint, ), ); } if (!fluttium.environment.fluttium - .allowsAny(FluttiumDriver.fluttiumVersionConstraint)) { + .allowsAny(HostDriver.fluttiumVersionConstraint)) { _logger.err( ''' Version solving failed: - The Fluttium CLI uses "${FluttiumDriver.fluttiumVersionConstraint}" as the version constraint. + The Fluttium CLI uses "${HostDriver.fluttiumVersionConstraint}" as the version constraint. The current project uses "${fluttium.environment.fluttium}" as defined in the fluttium.yaml. Either adjust the constraint in the Fluttium configuration or update the CLI to a compatible version.''', @@ -330,16 +318,20 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to rethrow; } - final steps = []; - driver.steps - .map((s) => (steps..clear())..addAll(s)) - .map(_storeFiles) - .listen( + final steps = []; + driver.steps.map((s) => (steps..clear())..addAll(s)).listen( reporter.report, onDone: reporter.done, onError: reporter.error, ); + driver.files.listen((file) { + _logger.detail('Writing ${file.data.length} bytes to "${file.path}"'); + File(file.path) + ..createSync(recursive: true) + ..writeAsBytesSync(file.data); + }); + await driver.run(watch: watch); if (!steps.every((s) => s.status == StepStatus.done) || steps.isEmpty) { diff --git a/packages/fluttium_cli/pubspec.yaml b/packages/fluttium_cli/pubspec.yaml index 30e6471a..c6fcd3db 100644 --- a/packages/fluttium_cli/pubspec.yaml +++ b/packages/fluttium_cli/pubspec.yaml @@ -10,24 +10,24 @@ environment: sdk: ">=3.0.0 <4.0.0" dependencies: - args: ^2.4.0 - cli_completion: ^0.3.0 + args: ^2.4.2 + cli_completion: ^0.4.0 collection: ^1.17.1 fluttium_driver: ^0.1.3 fluttium_interfaces: ^0.1.0 - mason: ^0.1.0-dev.50 + mason: ^0.1.0-dev.51 meta: ^1.8.0 path: ^1.8.3 - process: ^4.2.4 - pub_updater: ">=0.2.4 <0.4.0" + process: ^5.0.1 + pub_updater: ^0.4.0 dev_dependencies: - build_runner: ^2.3.3 + build_runner: ^2.4.7 build_verify: ^3.1.0 build_version: ^2.1.1 - mocktail: ">=0.3.0 <2.0.0" + mocktail: ^1.0.1 test: ^1.23.1 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 executables: fluttium: diff --git a/packages/fluttium_cli/test/helpers/with_runner.dart b/packages/fluttium_cli/test/helpers/with_runner.dart index 412a9bf2..979e0ce4 100644 --- a/packages/fluttium_cli/test/helpers/with_runner.dart +++ b/packages/fluttium_cli/test/helpers/with_runner.dart @@ -53,7 +53,7 @@ void Function() withRunner( 0, ExitCode.success.code, ''' -Flutter ${FluttiumDriver.flutterVersionConstraint.min} • channel stable • https://github.com/flutter/flutter.git +Flutter ${HostDriver.flutterVersionConstraint.min} • channel stable • https://github.com/flutter/flutter.git Framework • revision AAAAAAAAAA (0 days ago) • 9999-12-31 00:00:00 -0700 Engine • revision AAAAAAAAAA Tools • Dart 0.0.0 • DevTools 0.0.0 diff --git a/packages/fluttium_cli/test/src/command_runner_test.dart b/packages/fluttium_cli/test/src/command_runner_test.dart index 11009052..9e321222 100644 --- a/packages/fluttium_cli/test/src/command_runner_test.dart +++ b/packages/fluttium_cli/test/src/command_runner_test.dart @@ -31,7 +31,7 @@ void main() { late Logger logger; late FluttiumCommandRunner commandRunner; late ProcessManager processManager; - var flutterVersion = FluttiumDriver.flutterVersionConstraint.min.toString(); + var flutterVersion = HostDriver.flutterVersionConstraint.min.toString(); setUp(() { pubUpdater = MockPubUpdater(); @@ -202,7 +202,7 @@ Tools • Dart 0.0.0 • DevTools 0.0.0 verify( () => logger.err(''' Version solving failed: - The Fluttium CLI uses "${FluttiumDriver.flutterVersionConstraint}" as the version constraint for Flutter. + The Fluttium CLI uses "${HostDriver.flutterVersionConstraint}" as the version constraint for Flutter. The current Flutter version is "$flutterVersion" which is not supported by Fluttium. Either update Flutter to a compatible version supported by the CLI or update the CLI to a compatible version of Flutter.'''), diff --git a/packages/fluttium_cli/test/src/commands/test_command/test_command_test.dart b/packages/fluttium_cli/test/src/commands/test_command/test_command_test.dart index 89f43c16..c4849b31 100644 --- a/packages/fluttium_cli/test/src/commands/test_command/test_command_test.dart +++ b/packages/fluttium_cli/test/src/commands/test_command/test_command_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: prefer_const_constructors + import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -15,7 +17,7 @@ import 'package:test/test.dart'; import '../../../helpers/helpers.dart'; -const expectedUsage = [ +List expectedUsage = [ // ignore: no_adjacent_strings_in_list 'Run a user flow test.\n' '\n' @@ -84,9 +86,9 @@ void main() { late File pubspecFile; late File fluttiumFile; late File targetFile; - late StreamController> stepStateController; + late StreamController> stepStateController; + late StreamController fileController; late Stdin stdin; - late File testFile; late Completer runCompleter; setUpAll(() { @@ -105,6 +107,9 @@ void main() { stepStateController = StreamController(); when(() => driver.steps).thenAnswer((_) => stepStateController.stream); + fileController = StreamController(); + when(() => driver.files).thenAnswer((_) => fileController.stream); + logger = _MockLogger(); retrievingDevices = _MockProgress(); @@ -174,11 +179,6 @@ environment: when(() => targetFile.existsSync()).thenReturn(true); when(() => targetFile.path).thenReturn('project_directory/lib/main.dart'); - testFile = _MockFile(); - when(() => testFile.createSync(recursive: any(named: 'recursive'))) - .thenAnswer((_) {}); - when(() => testFile.writeAsBytesSync(any())).thenAnswer((_) {}); - stdin = _MockStdin(); when(() => stdin.hasTerminal).thenReturn(true); when(() => stdin.echoMode).thenReturn(true); @@ -214,10 +214,7 @@ environment: return fluttiumFile; } else if (path.endsWith('main.dart')) { return targetFile; - } else if (path.endsWith('test_file')) { - return testFile; - } - if (customFiles.containsKey(path)) { + } else if (customFiles.containsKey(path)) { return customFiles[path]!; } throw UnimplementedError(path); @@ -242,8 +239,10 @@ environment: await runWithMocks(() async { final future = command.run(); - final step = - StepState('Expect visible "text"', status: StepStatus.done); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + status: StepStatus.done, + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -266,8 +265,10 @@ environment: await runWithMocks(() async { final future = command.run(); - final step = - StepState('Expect visible "text"', status: StepStatus.done); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + status: StepStatus.done, + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -289,7 +290,9 @@ environment: await runWithMocks(() async { final future = command.run(); - final step = StepState('Expect visible "text"'); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -323,7 +326,9 @@ environment: await runWithMocks(() async { final future = command.run(); - final step = StepState('Expect visible "text"'); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -399,7 +404,7 @@ environment: that: equals( ''' Version solving failed: - The Fluttium CLI uses "${FluttiumDriver.fluttiumVersionConstraint}" as the version constraint. + The Fluttium CLI uses "${HostDriver.fluttiumVersionConstraint}" as the version constraint. The current project uses ">=999.999.998 <999.999.999" as defined in the fluttium.yaml. Either adjust the constraint in the Fluttium configuration or update the CLI to a compatible version.''', @@ -466,12 +471,12 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to test('validates all platforms', () async { for (final platform in [ - const MapEntry('web', 'web'), - const MapEntry('macos', 'darwin'), - const MapEntry('android', 'android'), - const MapEntry('ios', 'ios'), - const MapEntry('windows', 'windows'), - const MapEntry('linux', 'linux'), + MapEntry('web', 'web'), + MapEntry('macos', 'darwin'), + MapEntry('android', 'android'), + MapEntry('ios', 'ios'), + MapEntry('windows', 'windows'), + MapEntry('linux', 'linux'), ]) { flutterDevicesResult = ProcessResult( 0, @@ -489,6 +494,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to // Recreate the controller stepStateController = StreamController(); + fileController = StreamController(); // Recreate the completer. runCompleter = Completer(); @@ -504,8 +510,10 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to () async { final future = command.run(); - final step = - StepState('Expect visible "text"', status: StepStatus.done); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + status: StepStatus.done, + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -563,8 +571,10 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step = - StepState('Expect visible "text"', status: StepStatus.done); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + status: StepStatus.done, + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -640,8 +650,10 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to final future = command.run(); - final step = - StepState('Expect visible "text"', status: StepStatus.done); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + status: StepStatus.done, + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -677,8 +689,10 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step = - StepState('Expect visible "text"', status: StepStatus.done); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + status: StepStatus.done, + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -746,8 +760,14 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to () async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final fileStep = StepState('Storing file: "file.txt"'); + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final fileStep = UserFlowStepState( + UserFlowStep('storeFile', arguments: 'file.txt'), + description: 'Storing file: "file.txt"', + ); stepStateController.add([step1, fileStep]); await Future.delayed(Duration.zero); @@ -766,17 +786,16 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), - fileStep.copyWith( - status: StepStatus.done, - files: { - 'file.txt': [1, 2, 3] - }, - ) + fileStep.copyWith(status: StepStatus.done), ]); await Future.delayed(Duration.zero); verify(() => logger.info(' ✅ Expect visible "text"')).called(1); verify(() => logger.info(' ✅ Storing file: "file.txt"')).called(1); + + fileController.add(StoredFile('file.txt', [1, 2, 3])); + await Future.delayed(Duration.zero); + verify(() => logger.detail('Writing 3 bytes to "file.txt"')) .called(1); verify( @@ -812,9 +831,18 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final step2 = StepState('Expect not visible "text"'); - final step3 = StepState('Tap on "text"'); + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final step2 = UserFlowStepState( + UserFlowStep('expectNotVisible', arguments: 'text'), + description: 'Expect not visible "text"', + ); + final step3 = UserFlowStepState( + UserFlowStep('tapOn', arguments: 'text'), + description: 'Tap on "text"', + ); stepStateController.add([step1, step2, step3]); await Future.delayed(Duration.zero); @@ -823,8 +851,11 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to verify(() => logger.info(' 🔲 Expect not visible "text"')).called(1); verify(() => logger.info(' 🔲 Tap on "text"')).called(1); - stepStateController - .add([step1.copyWith(status: StepStatus.running), step2, step3]); + stepStateController.add([ + step1.copyWith(status: StepStatus.running), + step2, + step3, + ]); await Future.delayed(Duration.zero); verify(() => logger.info(' ⏳ Expect visible "text"')).called(1); @@ -834,7 +865,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.running), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -845,7 +876,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.failed), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -886,9 +917,18 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final step2 = StepState('Expect not visible "text"'); - final step3 = StepState('Tap on "text"'); + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final step2 = UserFlowStepState( + UserFlowStep('expectNotVisible', arguments: 'text'), + description: 'Expect not visible "text"', + ); + final step3 = UserFlowStepState( + UserFlowStep('tapOn', arguments: 'text'), + description: 'Tap on "text"', + ); stepStateController.add([step1, step2, step3]); await Future.delayed(Duration.zero); @@ -911,7 +951,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.running), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -923,7 +963,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.done), - step3.copyWith(status: StepStatus.done) + step3.copyWith(status: StepStatus.done), ]); await Future.delayed(Duration.zero); @@ -945,9 +985,18 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final step2 = StepState('Expect not visible "text"'); - final step3 = StepState('Tap on "text"'); + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final step2 = UserFlowStepState( + UserFlowStep('expectNotVisible', arguments: 'text'), + description: 'Expect not visible "text"', + ); + final step3 = UserFlowStepState( + UserFlowStep('tapOn', arguments: 'text'), + description: 'Tap on "text"', + ); stepStateController.add([step1, step2, step3]); await Future.delayed(Duration.zero); @@ -970,7 +1019,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.running), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -982,7 +1031,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.failed), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -1006,7 +1055,10 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step = StepState('Expect visible "text"'); + final step = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); stepStateController.add([step]); await Future.delayed(Duration.zero); @@ -1065,10 +1117,18 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final step2 = StepState('Expect not visible "text"'); - final step3 = StepState('Tap on "text"'); - + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final step2 = UserFlowStepState( + UserFlowStep('expectNotVisible', arguments: 'text'), + description: 'Expect not visible "text"', + ); + final step3 = UserFlowStepState( + UserFlowStep('tapOn', arguments: 'text'), + description: 'Tap on "text"', + ); stepStateController.add([step1, step2, step3]); await Future.delayed(Duration.zero); @@ -1087,7 +1147,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.running), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -1118,10 +1178,18 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final step2 = StepState('Expect not visible "text"'); - final step3 = StepState('Tap on "text"'); - + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final step2 = UserFlowStepState( + UserFlowStep('expectNotVisible', arguments: 'text'), + description: 'Expect not visible "text"', + ); + final step3 = UserFlowStepState( + UserFlowStep('tapOn', arguments: 'text'), + description: 'Tap on "text"', + ); stepStateController.add([step1, step2, step3]); await Future.delayed(Duration.zero); @@ -1132,7 +1200,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.running), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -1143,7 +1211,7 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to stepStateController.add([ step1.copyWith(status: StepStatus.done), step2.copyWith(status: StepStatus.failed), - step3 + step3, ]); await Future.delayed(Duration.zero); @@ -1220,9 +1288,18 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to await runWithMocks(() async { final future = command.run(); - final step1 = StepState('Expect visible "text"'); - final step2 = StepState('Expect not visible "text"'); - final step3 = StepState('Tap on "text"'); + final step1 = UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'text'), + description: 'Expect visible "text"', + ); + final step2 = UserFlowStepState( + UserFlowStep('expectNotVisible', arguments: 'text'), + description: 'Expect not visible "text"', + ); + final step3 = UserFlowStepState( + UserFlowStep('tapOn', arguments: 'text'), + description: 'Tap on "text"', + ); stepStateController.add([step1, step2, step3]); await Future.delayed(Duration.zero); @@ -1232,27 +1309,12 @@ Either adjust the constraint in the Fluttium configuration or update the CLI to ).called(1); stepStateController.add([ - step1.copyWith( - status: StepStatus.done, - files: { - 'test_file': [1, 2, 3] - }, - ), + step1.copyWith(status: StepStatus.done), step2, - step3 + step3, ]); await Future.delayed(Duration.zero); - verify( - () => testFile.createSync( - recursive: any(named: 'recursive', that: isTrue), - ), - ).called(1); - - verify( - () => testFile.writeAsBytesSync(any(that: equals([1, 2, 3]))), - ).called(1); - await stepStateController.close(); runCompleter.complete(); expect(await future, equals(ExitCode.tempFail.code)); diff --git a/packages/fluttium_cli/test/src/json_decode_safely_test.dart b/packages/fluttium_cli/test/src/json_decode_safely_test.dart index 3685b588..43abdca2 100644 --- a/packages/fluttium_cli/test/src/json_decode_safely_test.dart +++ b/packages/fluttium_cli/test/src/json_decode_safely_test.dart @@ -66,7 +66,7 @@ Some output 'b': { 'd': 5, 'e': 6, - 'f': [7, 8, 9] + 'f': [7, 8, 9], }, 'c': 4, }), diff --git a/packages/fluttium_driver/.gitignore b/packages/fluttium_driver/.gitignore index 526da158..351189b9 100644 --- a/packages/fluttium_driver/.gitignore +++ b/packages/fluttium_driver/.gitignore @@ -4,4 +4,6 @@ .dart_tool/ .packages build/ -pubspec.lock \ No newline at end of file +pubspec.lock + +screenshots/ \ No newline at end of file diff --git a/packages/fluttium_driver/analysis_options.yaml b/packages/fluttium_driver/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/packages/fluttium_driver/analysis_options.yaml +++ b/packages/fluttium_driver/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/fluttium_driver/example/main.dart b/packages/fluttium_driver/example/main.dart index c6948fbf..f2875a0b 100644 --- a/packages/fluttium_driver/example/main.dart +++ b/packages/fluttium_driver/example/main.dart @@ -5,7 +5,7 @@ import 'package:fluttium_interfaces/fluttium_interfaces.dart'; import 'package:mason/mason.dart' hide GitPath; Future main() async { - final driver = FluttiumDriver( + final driver = HostDriver( logger: Logger(level: Level.verbose), configuration: const DriverConfiguration( flavor: 'development', @@ -18,7 +18,7 @@ Future main() async { ), }, projectDirectory: Directory('../../example'), - userFlowFile: File('../../example/flows/progress_flow.yaml'), + userFlowFile: File('../../example/flows/counter_flow.yaml'), ); final stepsSubscription = driver.steps.listen( @@ -30,18 +30,14 @@ Future main() async { switch (step.status) { case StepStatus.initial: stdout.writeln(' ⚪️ ${step.description}'); - break; case StepStatus.running: stdout.writeln(' 🟡 ${step.description}'); - break; case StepStatus.done: stdout.writeln(' 🟢 ${step.description}'); - break; case StepStatus.failed: stdout.writeln( ' 🔴 ${step.description} - reason: ${step.failReason}', ); - break; } } }, @@ -56,6 +52,12 @@ Future main() async { final processSubscription = ProcessSignal.sigint.watch().listen((_) => driver.quit()); + driver.files.listen((file) { + File(file.path) + ..createSync(recursive: true) + ..writeAsBytesSync(file.data); + }); + await driver.run(); await stepsSubscription.cancel(); await processSubscription.cancel(); diff --git a/packages/fluttium_driver/lib/fluttium_driver.dart b/packages/fluttium_driver/lib/fluttium_driver.dart index 5912258f..ceed363a 100644 --- a/packages/fluttium_driver/lib/fluttium_driver.dart +++ b/packages/fluttium_driver/lib/fluttium_driver.dart @@ -1,6 +1,7 @@ /// The driver behind Fluttium user flow tests library fluttium_driver; +export 'src/drivers/drivers.dart'; export 'src/exceptions/exceptions.dart'; -export 'src/fluttium_driver.dart'; -export 'src/step_state.dart'; +export 'src/stored_file.dart'; +export 'src/user_flow_step_state.dart'; diff --git a/packages/fluttium_driver/lib/src/bundles/fluttium_test_runner_bundle.dart b/packages/fluttium_driver/lib/src/bundles/fluttium_test_runner_bundle.dart index 7881e96f..0ace9c03 100644 --- a/packages/fluttium_driver/lib/src/bundles/fluttium_test_runner_bundle.dart +++ b/packages/fluttium_driver/lib/src/bundles/fluttium_test_runner_bundle.dart @@ -8,7 +8,7 @@ final fluttiumTestRunnerBundle = MasonBundle.fromJson({ { "path": "lib/fluttium_test_runner.dart", "data": - "aW1wb3J0ICdwYWNrYWdlOmZsdXR0ZXIvd2lkZ2V0cy5kYXJ0JyBoaWRlIEFjdGlvbjsKaW1wb3J0ICdwYWNrYWdlOmZsdXR0aXVtL2ZsdXR0aXVtLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6Zmx1dHRpdW1faW50ZXJmYWNlcy9mbHV0dGl1bV9pbnRlcmZhY2VzLmRhcnQnOwp7eyNhY3Rpb25zfX0KaW1wb3J0ICdwYWNrYWdlOnt7bmFtZS5zbmFrZUNhc2UoKX19L3t7bmFtZS5zbmFrZUNhc2UoKX19LmRhcnQnIGFzIHt7bmFtZS5zbmFrZUNhc2UoKX19O3t7L2FjdGlvbnN9fQoKRnV0dXJlPHZvaWQ+IHJ1bihXaWRnZXRzQmluZGluZyBiaW5kaW5nKSBhc3luYyB7CiAgZmluYWwgcmVnaXN0cnkgPSBSZWdpc3RyeSgpO3t7I2FjdGlvbnN9fQogIHt7bmFtZS5zbmFrZUNhc2UoKX19LnJlZ2lzdGVyKHJlZ2lzdHJ5KTt7ey9hY3Rpb25zfX0KCiAgZmluYWwgdGVzdGVyID0gVGVzdGVyKGJpbmRpbmcsIHJlZ2lzdHJ5KTsKICBhd2FpdCB0ZXN0ZXIucmVhZHkoKTsKCiAgZmluYWwgYWN0aW9ucyA9IGF3YWl0IHRlc3Rlci5jb252ZXJ0KFt7eyNzdGVwc319CiAgICBVc2VyRmxvd1N0ZXAuZnJvbUpzb24oe3t7c3RlcH19fSkse3svc3RlcHN9fQogIF0pOwoKICBmb3IgKGZpbmFsIGFjdGlvbiBpbiBhY3Rpb25zKSB7CiAgICBhd2FpdCBhY3Rpb24oKTsKICB9Cn0K", + "aW1wb3J0ICdwYWNrYWdlOmZsdXR0ZXIvd2lkZ2V0cy5kYXJ0JyBoaWRlIEFjdGlvbjsKaW1wb3J0ICdwYWNrYWdlOmZsdXR0aXVtL2ZsdXR0aXVtLmRhcnQnOwppbXBvcnQgJ3BhY2thZ2U6Zmx1dHRpdW1faW50ZXJmYWNlcy9mbHV0dGl1bV9pbnRlcmZhY2VzLmRhcnQnOwp7eyNhY3Rpb25zfX0KaW1wb3J0ICdwYWNrYWdlOnt7bmFtZS5zbmFrZUNhc2UoKX19L3t7bmFtZS5zbmFrZUNhc2UoKX19LmRhcnQnIGFzIHt7bmFtZS5zbmFrZUNhc2UoKX19O3t7L2FjdGlvbnN9fQoKRnV0dXJlPFRlc3Rlcj4gcnVuKFdpZGdldHNCaW5kaW5nIGJpbmRpbmcpIGFzeW5jIHsKICBmaW5hbCByZWdpc3RyeSA9IFJlZ2lzdHJ5KCk7e3sjYWN0aW9uc319CiAge3tuYW1lLnNuYWtlQ2FzZSgpfX0ucmVnaXN0ZXIocmVnaXN0cnkpO3t7L2FjdGlvbnN9fQoKICByZXR1cm4gVGVzdGVyKGJpbmRpbmcsIHJlZ2lzdHJ5KTsKfQo=", "type": "text" }, { diff --git a/packages/fluttium_driver/lib/src/drivers/drivers.dart b/packages/fluttium_driver/lib/src/drivers/drivers.dart new file mode 100644 index 00000000..e927f8fc --- /dev/null +++ b/packages/fluttium_driver/lib/src/drivers/drivers.dart @@ -0,0 +1,2 @@ +export 'fluttium_driver.dart'; +export 'host_driver.dart'; diff --git a/packages/fluttium_driver/lib/src/drivers/fluttium_driver.dart b/packages/fluttium_driver/lib/src/drivers/fluttium_driver.dart new file mode 100644 index 00000000..9d70e849 --- /dev/null +++ b/packages/fluttium_driver/lib/src/drivers/fluttium_driver.dart @@ -0,0 +1,267 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:flutter_daemon/flutter_daemon.dart'; +import 'package:fluttium_driver/fluttium_driver.dart'; +import 'package:fluttium_interfaces/fluttium_interfaces.dart'; +import 'package:meta/meta.dart'; + +/// {@template fluttium_driver} +/// A driver for executing Fluttium flow tests. +/// {@endtemplate} +abstract class FluttiumDriver { + /// {@macro fluttium_driver} + FluttiumDriver({ + required this.configuration, + required this.actions, + required this.userFlow, + }) : _stepStateController = StreamController.broadcast(), + _filesController = StreamController.broadcast(), + _stepStates = userFlow.steps.map(UserFlowStepState.new).toList(); + + /// The configuration for the driver. + final DriverConfiguration configuration; + + /// The actions to install to run the flow. + final Map actions; + + /// The user flow that is being run. + late UserFlowYaml userFlow; + + /// Stream of the steps in the user flow. + /// + /// The steps are emitted as a list of [UserFlowStepState]s representing the + /// current state of those steps, the list is ordered by the order of + /// execution. + late final Stream> steps = + _stepStateController.stream; + final StreamController> _stepStateController; + final List _stepStates; + + /// Stream of files that should be stored. + late final Stream files = _filesController.stream; + final StreamController _filesController; + + FlutterDaemon? _daemon; + FlutterApplication? _application; + + var _restarting = false; + + /// Run the driver. + /// + /// This will setup the driver generated code, generate the runner, and start + /// the runner with the given user flow. + /// + /// This will return a [Future] that completes when the driver is done, + /// either by completing the user flow, application was closed, [quit] was + /// called, or an error occurred. + /// + /// To listen to the steps in the user flow, use the [steps] stream. + Future run({bool watch = false}) async { + await onRun(watch: watch); + + _daemon = await getFlutterDaemon(); + _application = await getFlutterApplication(_daemon!); + if (_application == null) return _daemon!.dispose(); + + await _executeSteps(); + + // If all steps were done, or if a step failed, stop the process unless + // we're in watch mode. + if (!watch && + (_stepStates.every((e) => e.status == StepStatus.done) || + _stepStates.any((e) => e.status == StepStatus.failed))) { + return quit(); + } + + // Wait for Daemon to finish. + await _daemon?.finished; + + await quit(); + } + + /// Called right before the driver starts to run. + @protected + Future onRun({required bool watch}) async {} + + /// Restart the runner and the driver. + Future restart() async { + if (_restarting || _daemon == null) return; + _restarting = true; + + await onRestart(); + + // Tell the daemon to restart the runner. + await _application?.restart(); + _restarting = false; + + await _executeSteps(); + } + + /// Called right before the test runner gets restarted. + @protected + Future onRestart() async {} + + /// Close the runner and it's driver. + Future quit() async { + await onQuit(); + + // Close the step state controller. + await _stepStateController.close(); + + // Tell the daemon to stop the runner. + if (!(_daemon?.isFinished ?? true)) { + await _application?.stop(); + } + await _daemon?.dispose(); + } + + /// Called right before the driver starts to quit. + @protected + Future onQuit() async {} + + /// Returns the [FlutterDaemon] that will be used to start the test runner + /// application. + /// + /// Each driver has to provide its own daemon implementation. + @protected + Future getFlutterDaemon(); + + /// Returns the [FlutterApplication] that will serve as the test runner + /// application. + /// + /// Each driver has to provide its own application implementation, which + /// should be provided through the daemon to allow [FluttiumDriver] to + /// correctly dispose of both the daemon and application when so required. + @protected + Future getFlutterApplication(FlutterDaemon daemon); + + Future _isReady() async { + // The service extensions might not be setup yet, so we wait at most 30 + // seconds and constantly retry to determine if it is setup. + final timeout = clock.now().add(const Duration(seconds: 30)); + + Future ready() async { + final response = await _application!.callServiceExtension( + 'ext.fluttium.ready', + ); + + if (response.hasError || response.result!['ready'] == false) { + if (clock.now().isBefore(timeout)) { + return Future.delayed(const Duration(microseconds: 500), ready); + } + + throw FluttiumFailedToGetReady( + response.error ?? + response.result?['reason'] as String? ?? + 'Unknown reason', + ); + } + } + + return ready(); + } + + Future _executeSteps() async { + _stepStates + ..clear() + ..addAll(userFlow.steps.map(UserFlowStepState.new)); + + await _isReady(); + + // Get all action descriptions and announce them. + for (var i = 0; i < _stepStates.length; i++) { + final response = await _application!.callServiceExtension( + 'ext.fluttium.getActionDescription', + params: { + 'name': _stepStates[i].step.actionName, + 'arguments': json.encode(_stepStates[i].step.arguments), + }, + ); + if (response.hasError) { + throw FluttiumFatalStepFail(_stepStates[i], response.error!); + } + + _stepStates[i] = _stepStates[i].copyWith( + description: response.result!['description'] as String, + ); + } + _stepStateController.add(_stepStates); + + for (var i = 0; i < _stepStates.length; i++) { + _stepStates[i] = _stepStates[i].copyWith(status: StepStatus.running); + _stepStateController.add(_stepStates); + final response = await _application!.callServiceExtension( + 'ext.fluttium.executeAction', + params: { + 'name': _stepStates[i].step.actionName, + 'arguments': json.encode(_stepStates[i].step.arguments), + }, + ); + + final hasError = + response.hasError || response.result!['success'] == false; + + if (hasError) { + _stepStates[i] = _stepStates[i].copyWith( + status: StepStatus.failed, + failReason: response.error ?? response.result!['reason'] as String?, + ); + } else { + final files = response.result!['files'] as Map; + if (files.isNotEmpty) { + for (final key in files.keys) { + _filesController.add( + StoredFile(key, base64.decode(files[key]! as String)), + ); + } + } + _stepStates[i] = _stepStates[i].copyWith( + status: StepStatus.done, + // ignore: deprecated_member_use_from_same_package + files: files.map((k, v) => MapEntry(k, base64.decode(v as String))), + ); + } + _stepStateController.add(_stepStates); + + // We had an error, do not continue. + if (hasError) break; + } + } +} + +/// {@template fluttium_failed_to_get_ready} +/// Thrown when the [FluttiumDriver] does not get a ready event from the +/// [FlutterApplication] on time. +/// {@endtemplate} +class FluttiumFailedToGetReady implements Exception { + /// {@macro fluttium_failed_to_get_ready} + FluttiumFailedToGetReady(this.reason); + + /// Reason for failure. + final String reason; + + @override + String toString() => 'Fluttium failed to get ready: $reason'; +} + +/// {@template fluttium_fatal_step_fail} +/// Thrown when the [FluttiumDriver] detect a fatal failure, this is different +/// from a step that fails normally. +/// {@endtemplate} +class FluttiumFatalStepFail implements Exception { + /// {@macro fluttium_fatal_step_fail} + const FluttiumFatalStepFail(this.state, this.reason); + + /// The state at which the step was at. + final UserFlowStepState state; + + /// The reason for the fatal failure. + final String reason; + + @override + String toString() { + return 'Fluttium fatally failed step "${state.description}": $reason'; + } +} diff --git a/packages/fluttium_driver/lib/src/drivers/host_driver.dart b/packages/fluttium_driver/lib/src/drivers/host_driver.dart new file mode 100644 index 00000000..57ef41ee --- /dev/null +++ b/packages/fluttium_driver/lib/src/drivers/host_driver.dart @@ -0,0 +1,323 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_daemon/flutter_daemon.dart'; +import 'package:fluttium_driver/fluttium_driver.dart'; +import 'package:fluttium_driver/src/bundles/bundles.dart'; +import 'package:fluttium_interfaces/fluttium_interfaces.dart'; +import 'package:mason/mason.dart' hide canonicalize; +import 'package:meta/meta.dart'; +import 'package:path/path.dart'; +import 'package:process/process.dart'; +import 'package:watcher/watcher.dart'; +import 'package:yaml/yaml.dart'; + +/// Returns the [MasonGenerator] to use. +typedef GeneratorBuilder = FutureOr Function( + MasonBundle specification, +); + +/// Builder for a [DirectoryWatcher]. +typedef DirectoryWatcherBuilder = DirectoryWatcher Function( + String path, { + Duration? pollingDelay, +}); + +/// Builder for a [FileWatcher]. +typedef FileWatcherBuilder = FileWatcher Function( + String path, { + Duration? pollingDelay, +}); + +/// {@template host_driver} +/// A driver for executing Fluttium flow tests from the host to a flutter +/// application. +/// {@endtemplate} +class HostDriver extends FluttiumDriver { + /// {@macro host_driver} + HostDriver({ + required super.configuration, + required super.actions, + required this.projectDirectory, + required this.userFlowFile, + ProcessManager? processManager, + Logger? logger, + @visibleForTesting GeneratorBuilder? generator, + @visibleForTesting DirectoryWatcherBuilder? directoryWatcher, + @visibleForTesting FileWatcherBuilder? fileWatcher, + }) : _logger = logger ?? Logger(), + _processManager = processManager ?? const LocalProcessManager(), + _generatorBuilder = generator ?? MasonGenerator.fromBundle, + _directoryWatcher = directoryWatcher ?? DirectoryWatcher.new, + _fileWatcher = fileWatcher ?? FileWatcher.new, + assert(userFlowFile.existsSync(), 'userFlowFile does not exist'), + super(userFlow: UserFlowYaml.fromData(userFlowFile.readAsStringSync())); + + /// The directory of the project. + final Directory projectDirectory; + + /// The user flow file to run. + final File userFlowFile; + + final Logger _logger; + + final ProcessManager _processManager; + + final GeneratorBuilder _generatorBuilder; + + late final MasonGenerator _testRunnerGenerator; + late final Directory _testRunnerDirectory; + + late final MasonGenerator _launcherGenerator; + late final File _launcherFile; + + final DirectoryWatcherBuilder _directoryWatcher; + final FileWatcherBuilder _fileWatcher; + final List> _subscriptions = []; + + @override + Future getFlutterDaemon() async { + return FlutterDaemon(processManager: _processManager); + } + + @override + Future getFlutterApplication( + FlutterDaemon daemon, + ) async { + final launchingTestRunner = _logger.progress('Launching the test runner'); + try { + final application = await daemon.run( + arguments: [ + _launcherFile.absolute.path, + if (configuration.deviceId != null) ...[ + '-d', + configuration.deviceId!, + ], + if (configuration.flavor != null) ...[ + '--flavor', + configuration.flavor!, + ], + ...configuration.dartDefines.expand((e) => ['--dart-define', e]), + ], + workingDirectory: projectDirectory.path, + ); + + launchingTestRunner.complete(); + + return application; + } catch (err) { + launchingTestRunner.fail('Failed to start test driver'); + _logger.err(err.toString()); + return null; + } + } + + @override + Future run({bool watch = false}) async { + try { + return await super.run(watch: watch); + } on FluttiumFailedToGetReady catch (err) { + _logger.err('Failed to get ready: ${err.reason}'); + } on FluttiumFatalStepFail catch (err) { + _logger.err('Detected fatal failure on executing steps: ${err.reason}'); + } + } + + @override + Future onRun({required bool watch}) async { + await _setupGeneratedCode(); + + if (watch) { + // Watch the project directory for changes and hot restart the + // runner when changes are detected. + _subscriptions + ..add( + _directoryWatcher(projectDirectory.path).events.listen( + (event) { + if (!event.path.endsWith('.dart')) return; + restart(); + }, + onError: (Object err) { + if (err is FileSystemException && + err.message.contains('Failed to watch')) { + return _logger.detail(err.toString()); + } + // ignore: only_throw_errors + throw err; + }, + ), + ) + // Watch the user flow file for changes and restart the driver + // when changes are detected. + ..add( + _fileWatcher(userFlowFile.path).events.listen((event) => restart()), + ); + } + } + + @override + Future onRestart() async { + // Regenerate the test runner. + await _generateTestRunner(); + } + + @override + Future quit() async { + await super.quit(); + + // Cleanup the generated files. + await _cleanupGeneratedCode(); + } + + @override + Future onQuit() async { + // Cancel all the subscriptions. + await Future.wait(_subscriptions.map((e) => e.cancel())); + _subscriptions.clear(); + } + + Future _setupGeneratedCode() async { + // Setup the test runner. + final settingUpTestRunner = _logger.progress('Setting up the test runner'); + _testRunnerGenerator = await _generatorBuilder(fluttiumTestRunnerBundle); + _testRunnerDirectory = Directory.systemTemp.createTempSync('fluttium_'); + settingUpTestRunner.complete(); + + // Setup the launcher. + final settingUpLauncher = _logger.progress('Setting up the launcher'); + final projectData = loadYaml( + File(join(projectDirectory.path, 'pubspec.yaml')).readAsStringSync(), + ) as YamlMap; + + _launcherGenerator = await _generatorBuilder(fluttiumLauncherBundle); + final launcherVars = { + 'runner_id': basename(_testRunnerDirectory.path), + 'project_name': projectData['name'], + 'target': configuration.target + .replaceFirst('${projectDirectory.path}/', '') + .replaceFirst('lib/', ''), + 'runner_path': _testRunnerDirectory.path, + }; + settingUpLauncher.complete(); + + // Generate the test runner project. + await _generateTestRunner(runPubGet: true); + + // Install the test runner into the project. + await _launcherGenerator.hooks.preGen( + workingDirectory: projectDirectory.path, + vars: launcherVars, + ); + + // Generate the launcher file. + final files = await _launcherGenerator.generate( + DirectoryGeneratorTarget(projectDirectory), + vars: launcherVars, + logger: _logger, + fileConflictResolution: FileConflictResolution.overwrite, + ); + _launcherFile = File(files.first.path); + } + + Future _cleanupGeneratedCode() async { + // Remove the test runner project. + await _launcherGenerator.hooks.postGen( + workingDirectory: projectDirectory.path, + ); + + // Remove the launcher file if it exists. + if (_launcherFile.existsSync()) { + _launcherFile.deleteSync(); + } + + // Remove the test runner if it exists. + if (_testRunnerDirectory.existsSync()) { + _testRunnerDirectory.deleteSync(recursive: true); + } + } + + Future _generateTestRunner({bool runPubGet = false}) async { + userFlow = UserFlowYaml.fromData(userFlowFile.readAsStringSync()); + await _testRunnerGenerator.generate( + DirectoryGeneratorTarget(_testRunnerDirectory), + vars: { + 'actions': actions.entries + .map( + (entry) => { + 'name': entry.key, + 'source': entry.value.source(projectDirectory), + }, + ) + .toList(), + 'steps': userFlow.steps + .map((step) => {'step': json.encode(step.toJson())}) + .toList(), + }, + logger: _logger, + fileConflictResolution: FileConflictResolution.overwrite, + ); + + if (runPubGet) { + await _testRunnerGenerator.hooks.postGen( + workingDirectory: _testRunnerDirectory.path, + ); + } + } + + /// The current Fluttium version constraint that is required for [HostDriver] + /// to work. + static VersionRange get fluttiumVersionConstraint { + final content = utf8.decode( + base64.decode( + fluttiumTestRunnerBundle.files + .firstWhere((e) => e.path == 'pubspec.yaml') + .data, + ), + ); + + final version = RegExp(' fluttium: "(.*?)"{').firstMatch(content)!; + return VersionConstraint.parse(version.group(1)!) as VersionRange; + } + + /// The current Flutter version constraint that is required for [HostDriver] + /// to work. + static VersionRange get flutterVersionConstraint { + final content = utf8.decode( + base64.decode( + fluttiumTestRunnerBundle.files + .firstWhere((e) => e.path == 'pubspec.yaml') + .data, + ), + ); + + final version = RegExp(' flutter: "(.*?)"\n').firstMatch(content)!; + return VersionConstraint.parse(version.group(1)!) as VersionRange; + } +} + +extension on ActionLocation { + String source(Directory relativeDirectory) { + if (hosted != null) { + return ''' + + hosted: ${hosted!.url} + version: ${hosted!.version}'''; + } else if (git != null) { + if (git!.ref == null && git!.path == null) { + return git!.url; + } + return ''' + + git: + url: ${git!.url} + ref: ${git!.ref} + path: ${git!.path}'''; + } + + // Else it is a path location + return ''' + + path: ${canonicalize(join(relativeDirectory.absolute.path, path))}'''; + } +} diff --git a/packages/fluttium_driver/lib/src/exceptions/fatal_driver_exception.dart b/packages/fluttium_driver/lib/src/exceptions/fatal_driver_exception.dart index 808fd8c6..1cf6bcfb 100644 --- a/packages/fluttium_driver/lib/src/exceptions/fatal_driver_exception.dart +++ b/packages/fluttium_driver/lib/src/exceptions/fatal_driver_exception.dart @@ -1,9 +1,11 @@ /// {@template fatal_driver_exception} /// Exception thrown when there is a fatal exception /// {@endtemplate} +@Deprecated('No longer being thrown by the driver') class FatalDriverException implements Exception { /// {@macro fatal_driver_exception} - FatalDriverException(this.reason); + @Deprecated('No longer being thrown by the driver') + const FatalDriverException(this.reason); /// The reason of the exception. final String reason; diff --git a/packages/fluttium_driver/lib/src/fluttium_driver.dart b/packages/fluttium_driver/lib/src/fluttium_driver.dart deleted file mode 100644 index 0bf46393..00000000 --- a/packages/fluttium_driver/lib/src/fluttium_driver.dart +++ /dev/null @@ -1,465 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:fluttium_driver/fluttium_driver.dart'; -import 'package:fluttium_driver/src/bundles/bundles.dart'; -import 'package:fluttium_interfaces/fluttium_interfaces.dart'; -import 'package:fluttium_protocol/fluttium_protocol.dart'; -import 'package:mason/mason.dart' hide canonicalize; -import 'package:meta/meta.dart'; -import 'package:path/path.dart'; -import 'package:process/process.dart'; -import 'package:watcher/watcher.dart'; -import 'package:yaml/yaml.dart'; - -/// Returns the [MasonGenerator] to use. -typedef GeneratorBuilder = FutureOr Function( - MasonBundle specification, -); - -/// Builder for a [DirectoryWatcher]. -typedef DirectoryWatcherBuilder = DirectoryWatcher Function( - String path, { - Duration? pollingDelay, -}); - -/// Builder for a [FileWatcher]. -typedef FileWatcherBuilder = FileWatcher Function( - String path, { - Duration? pollingDelay, -}); - -/// {@template fluttium_driver} -/// A driver for executing Fluttium flow tests. -/// {@endtemplate} -class FluttiumDriver { - /// {@macro fluttium_driver} - FluttiumDriver({ - required this.configuration, - required this.actions, - required this.projectDirectory, - required this.userFlowFile, - Logger? logger, - ProcessManager? processManager, - @visibleForTesting GeneratorBuilder? generator, - @visibleForTesting DirectoryWatcherBuilder? directoryWatcher, - @visibleForTesting FileWatcherBuilder? fileWatcher, - }) : _logger = logger ?? Logger(), - _processManager = processManager ?? const LocalProcessManager(), - _generatorBuilder = generator ?? MasonGenerator.fromBundle, - _directoryWatcher = directoryWatcher ?? DirectoryWatcher.new, - _fileWatcher = fileWatcher ?? FileWatcher.new, - _stepStateController = StreamController>.broadcast(), - assert(userFlowFile.existsSync(), 'userFlowFile does not exist') { - userFlow = UserFlowYaml.fromData(userFlowFile.readAsStringSync()); - } - - /// The configuration for the driver. - final DriverConfiguration configuration; - - /// The actions to install to run the flow. - final Map actions; - - /// The directory of the project. - final Directory projectDirectory; - - /// The user flow file to run. - final File userFlowFile; - - /// The user flow that is being run. - late UserFlowYaml userFlow; - - /// Stream of the steps in the user flow. - /// - /// The steps are emitted as a list of [StepState]s representing the current - /// state of those steps, the list is ordered by the order of execution. - late final Stream> steps = _stepStateController.stream; - final StreamController> _stepStateController; - final List _stepStates = []; - - final Logger _logger; - - final ProcessManager _processManager; - Process? _process; - var _didAttach = false; - late final Listener _listener; - - final GeneratorBuilder _generatorBuilder; - - late final MasonGenerator _testRunnerGenerator; - late final Directory _testRunnerDirectory; - - late final MasonGenerator _launcherGenerator; - late final File _launcherFile; - - final DirectoryWatcherBuilder _directoryWatcher; - final FileWatcherBuilder _fileWatcher; - final List> _subscriptions = []; - - var _restarting = false; - - var _fatalError = false; - - /// Run the driver. - /// - /// This will setup the driver generated code, generate the runner, and start - /// the runner with the given user flow. - /// - /// This will return a [Future] that completes when the driver is done, - /// either by completing the user flow, application was closed, [quit] was - /// called, or an error occurred. - /// - /// To listen to the steps in the user flow, use the [steps] stream. - Future run({bool watch = false}) async { - await _setupGeneratedCode(); - await _launchTestRunner(); - - _subscriptions.add( - _listener.messages.listen((message) async { - // Skip everything if a fatal error had occurred. - if (_fatalError) return; - - if (!_didAttach) { - _didAttach = true; - _launchingTestRunner?.complete(); - - if (watch) { - // Watch the project directory for changes and hot restart the - // runner when changes are detected. - _subscriptions - ..add( - _directoryWatcher(projectDirectory.path).events.listen( - (event) { - if (!event.path.endsWith('.dart')) return; - restart(); - }, - onError: (Object err) { - if (err is FileSystemException && - err.message.contains('Failed to watch')) { - return _logger.detail(err.toString()); - } - // ignore: only_throw_errors - throw err; - }, - ), - ) - // Watch the user flow file for changes and restart the driver - // when changes are detected. - ..add( - _fileWatcher(userFlowFile.path) - .events - .listen((event) => restart()), - ); - } - } - - switch (message.type) { - case MessageType.fatal: - _fatalError = true; - break; - case MessageType.announce: - _stepStates.add(StepState(message.data as String)); - break; - case MessageType.start: - final index = _stepStates.indexWhere( - (state) => state.status == StepStatus.initial, - ); - _stepStates[index] = _stepStates[index].copyWith( - status: StepStatus.running, - ); - break; - case MessageType.done: - final index = _stepStates.indexWhere( - (state) => state.status == StepStatus.running, - ); - _stepStates[index] = _stepStates[index].copyWith( - status: StepStatus.done, - ); - break; - case MessageType.fail: - final data = message.data as List; - final reason = data.last as String; - - final index = _stepStates.indexWhere( - (state) => state.status == StepStatus.running, - ); - _stepStates[index] = _stepStates[index].copyWith( - status: StepStatus.failed, - failReason: reason, - ); - break; - case MessageType.store: - final data = message.data as List; - final fileName = data.first as String; - final fileData = (data.last as List).cast(); - - final index = _stepStates.indexWhere( - (state) => state.status == StepStatus.running, - ); - _stepStates[index] = _stepStates[index].copyWith( - files: {..._stepStates[index].files, fileName: fileData}, - ); - break; - } - - // Don't do anything past this point if the runner is still announcing. - if (message.type == MessageType.announce) return; - _stepStateController.add(_stepStates); - - // If it was a fatal message, emit that as an error AFTER we emitted - // the step states. - if (message.type == MessageType.fatal) { - _stepStateController.addError( - FatalDriverException(message.data as String), - ); - } - _restarting = false; - - // If all steps were done, or if a step failed, stop the process unless - // we're in watch mode. - if (!watch && - (_stepStates.every((e) => e.status == StepStatus.done) || - _stepStates.any((e) => e.status == StepStatus.failed))) { - await quit(); - } - }), - ); - - // Wait for the process to exit, and then clean up the project. - await _process?.exitCode; - - // Cancel all the subscriptions. - await Future.wait(_subscriptions.map((e) => e.cancel())); - _subscriptions.clear(); - - // Close the step state controller. - await _stepStateController.close(); - - // Close the message listener. - await _listener.close(); - - // Cleanup the generated files. - await _cleanupGeneratedCode(); - - _process?.kill(); - } - - /// Restart the runner and the driver. - Future restart() async { - if (_restarting || _process == null) return; - _restarting = true; - - // Set the status of all steps to initial, we already know the steps so - // there is no need to wait with telling the listener. - _stepStateController.add( - _stepStates.map((e) => StepState(e.description)).toList(), - ); - - // Clear all the states after the listener has been notified, it will - // automatically be filled up by the runner. - _stepStates.clear(); - - // Regenerate the test runner. - await _generateTestRunner(); - - // Tell the runner to restart. - _process?.stdin.write('R'); - } - - /// Quit the runner and it's driver. - Future quit() async { - // Tell the runner to quit. - _process?.stdin.write('q'); - } - - Future _setupGeneratedCode() async { - // Setup the test runner. - final settingUpTestRunner = _logger.progress('Setting up the test runner'); - _testRunnerGenerator = await _generatorBuilder(fluttiumTestRunnerBundle); - _testRunnerDirectory = Directory.systemTemp.createTempSync('fluttium_'); - settingUpTestRunner.complete(); - - // Setup the launcher. - final settingUpLauncher = _logger.progress('Setting up the launcher'); - final projectData = loadYaml( - File(join(projectDirectory.path, 'pubspec.yaml')).readAsStringSync(), - ) as YamlMap; - - _launcherGenerator = await _generatorBuilder(fluttiumLauncherBundle); - final launcherVars = { - 'runner_id': basename(_testRunnerDirectory.path), - 'project_name': projectData['name'], - 'target': configuration.target - .replaceFirst('${projectDirectory.path}/', '') - .replaceFirst('lib/', ''), - 'runner_path': _testRunnerDirectory.path, - }; - settingUpLauncher.complete(); - - // Generate the test runner project. - await _generateTestRunner(runPubGet: true); - - // Install the test runner into the project. - await _launcherGenerator.hooks.preGen( - workingDirectory: projectDirectory.path, - vars: launcherVars, - ); - - // Generate the launcher file. - final files = await _launcherGenerator.generate( - DirectoryGeneratorTarget(projectDirectory), - vars: launcherVars, - logger: _logger, - fileConflictResolution: FileConflictResolution.overwrite, - ); - _launcherFile = File(files.first.path); - } - - Future _cleanupGeneratedCode() async { - // Remove the test runner project. - await _launcherGenerator.hooks.postGen( - workingDirectory: projectDirectory.path, - ); - - // Remove the launcher file if it exists. - if (_launcherFile.existsSync()) { - _launcherFile.deleteSync(); - } - - // Remove the test runner if it exists. - if (_testRunnerDirectory.existsSync()) { - _testRunnerDirectory.deleteSync(recursive: true); - } - } - - Future _generateTestRunner({bool runPubGet = false}) async { - userFlow = UserFlowYaml.fromData(userFlowFile.readAsStringSync()); - await _testRunnerGenerator.generate( - DirectoryGeneratorTarget(_testRunnerDirectory), - vars: { - 'actions': actions.entries - .map( - (entry) => { - 'name': entry.key, - 'source': entry.value.source(projectDirectory), - }, - ) - .toList(), - 'steps': userFlow.steps - .map((step) => {'step': json.encode(step.toJson())}) - .toList(), - }, - logger: _logger, - fileConflictResolution: FileConflictResolution.overwrite, - ); - - if (runPubGet) { - await _testRunnerGenerator.hooks.postGen( - workingDirectory: _testRunnerDirectory.path, - ); - } - } - - Progress? _launchingTestRunner; - - Future _launchTestRunner() async { - final commandArgs = [ - 'flutter', - 'run', - _launcherFile.absolute.path, - if (configuration.deviceId != null) ...['-d', configuration.deviceId!], - if (configuration.flavor != null) ...['--flavor', configuration.flavor!], - ...configuration.dartDefines.expand((e) => ['--dart-define', e]), - ]; - _logger.detail('Running command: ${commandArgs.join(' ')}'); - - _launchingTestRunner = _logger.progress('Launching the test runner'); - _process = await _processManager.start( - commandArgs, - runInShell: true, - workingDirectory: projectDirectory.path, - ); - - _listener = Listener( - _process!.stdout.map((event) { - final regex = RegExp( - r'^[I\/]*flutter[\s*\(\s*\d+\)]*: ', - multiLine: true, - ); - final data = utf8.decode(event); - _logger.detail('driver: $data'); - return utf8.encode(data.replaceAll(regex, '')); - }), - ); - - final errorBuffer = StringBuffer(); - _subscriptions.add( - _process!.stderr.transform(utf8.decoder).listen( - errorBuffer.write, - onDone: () { - // If it exited without correctly attaching to the application, we - // output the errors. - if (!_didAttach) { - _launchingTestRunner?.fail('Failed to start test driver'); - _logger.err(errorBuffer.toString()); - } - }, - ), - ); - } - - /// The current Fluttium version constraint that the driver needs to work. - static VersionRange get fluttiumVersionConstraint { - final content = utf8.decode( - base64.decode( - fluttiumTestRunnerBundle.files - .firstWhere((e) => e.path == 'pubspec.yaml') - .data, - ), - ); - - final version = RegExp(' fluttium: "(.*?)"{').firstMatch(content)!; - return VersionConstraint.parse(version.group(1)!) as VersionRange; - } - - /// The current Fluttium version constraint that the driver needs to work. - static VersionRange get flutterVersionConstraint { - final content = utf8.decode( - base64.decode( - fluttiumTestRunnerBundle.files - .firstWhere((e) => e.path == 'pubspec.yaml') - .data, - ), - ); - - final version = RegExp(' flutter: "(.*?)"\n').firstMatch(content)!; - return VersionConstraint.parse(version.group(1)!) as VersionRange; - } -} - -extension on ActionLocation { - String source(Directory relativeDirectory) { - if (hosted != null) { - return ''' - - hosted: ${hosted!.url} - version: ${hosted!.version}'''; - } else if (git != null) { - if (git!.ref == null && git!.path == null) { - return git!.url; - } - return ''' - - git: - url: ${git!.url} - ref: ${git!.ref} - path: ${git!.path}'''; - } - - // Else it is a path location - return ''' - - path: ${canonicalize(join(relativeDirectory.absolute.path, path))}'''; - } -} diff --git a/packages/fluttium_driver/lib/src/step_state.dart b/packages/fluttium_driver/lib/src/step_state.dart deleted file mode 100644 index c3cd13d4..00000000 --- a/packages/fluttium_driver/lib/src/step_state.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// {@template step_state} -/// The state of a step in the user flow. -/// {@endtemplate} -class StepState extends Equatable { - /// {@macro step_state} - StepState( - this.description, { - this.status = StepStatus.initial, - Map> files = const {}, - this.failReason, - }) : files = Map.unmodifiable(files); - - /// The readable description of the step. - final String description; - - /// The current status of the step. - final StepStatus status; - - /// If [status] is [StepStatus.failed] this will contain the reason. - final String? failReason; - - /// A list of files that were stored by the step - final Map> files; - - @override - List get props => [description, status, failReason, files]; - - /// Copy this [StepState] with optional parameters. - StepState copyWith({ - StepStatus? status, - Map>? files, - String? failReason, - }) { - return StepState( - description, - status: status ?? this.status, - files: files ?? this.files, - failReason: failReason ?? this.failReason, - ); - } -} - -/// The status of a step in the user flow. -enum StepStatus { - /// The step has not been executed yet. - initial, - - /// The step is currently executing. - running, - - /// The step has been executed successfully. - done, - - /// The step has failed. - failed, -} diff --git a/packages/fluttium_driver/lib/src/stored_file.dart b/packages/fluttium_driver/lib/src/stored_file.dart new file mode 100644 index 00000000..eaba414d --- /dev/null +++ b/packages/fluttium_driver/lib/src/stored_file.dart @@ -0,0 +1,13 @@ +/// {@template stored_file} +/// A file that should be stored at the given [path]. +/// {@endtemplate} +class StoredFile { + /// {@macro stored_file} + const StoredFile(this.path, this.data); + + /// Path where to store it. + final String path; + + /// The content of the file in binary. + final List data; +} diff --git a/packages/fluttium_driver/lib/src/user_flow_step_state.dart b/packages/fluttium_driver/lib/src/user_flow_step_state.dart new file mode 100644 index 00000000..fb7db6ce --- /dev/null +++ b/packages/fluttium_driver/lib/src/user_flow_step_state.dart @@ -0,0 +1,83 @@ +import 'package:equatable/equatable.dart'; +import 'package:fluttium_interfaces/fluttium_interfaces.dart'; + +/// {@macro user_flow_step_state} +@Deprecated('Use UserFlowStepState instead') +typedef StepState = UserFlowStepState; + +/// {@template user_flow_step_state} +/// The state of a step in the user flow. +/// {@endtemplate} +class UserFlowStepState extends Equatable { + /// {@macro user_flow_step_state} + UserFlowStepState( + this.step, { + this.status = StepStatus.initial, + this.description = '', + @Deprecated( + 'Use the FluttiumDriver.files stream to watch for files to store', + ) + Map> files = const {}, + this.failReason, + // ignore: deprecated_member_use_from_same_package + }) : files = Map.unmodifiable(files); + + /// The step for which this state is. + final UserFlowStep step; + + /// The readable description of the step. + final String description; + + /// The current status of the step. + final StepStatus status; + + /// If [status] is [StepStatus.failed] this will contain the reason. + final String? failReason; + + /// A list of files that were stored by the step + @Deprecated('Use the FluttiumDriver.files stream to watch for files to store') + final Map> files; + + @override + List get props => [ + step, + description, + status, + failReason, + // ignore: deprecated_member_use_from_same_package + files, + ]; + + /// Copy this [UserFlowStepState] with optional parameters. + UserFlowStepState copyWith({ + StepStatus? status, + String? description, + @Deprecated( + 'Use the FluttiumDriver.files stream to watch for files to store', + ) + Map>? files, + String? failReason, + }) { + return UserFlowStepState( + step, + status: status ?? this.status, + description: description ?? this.description, + failReason: failReason ?? this.failReason, + ); + } +} + +/// The status of a step in the user flow. +enum StepStatus { + /// The step has not been executed yet. + initial, + + /// The step is currently executing. + running, + + /// The step has been executed successfully. + done, + + /// The step has failed. + failed, +} diff --git a/packages/fluttium_driver/pubspec.yaml b/packages/fluttium_driver/pubspec.yaml index 1c9f94ff..20855531 100644 --- a/packages/fluttium_driver/pubspec.yaml +++ b/packages/fluttium_driver/pubspec.yaml @@ -10,17 +10,19 @@ environment: sdk: ">=3.0.0 <4.0.0" dependencies: + clock: ^1.1.1 equatable: ^2.0.5 + flutter_daemon: ^1.0.1 fluttium_interfaces: ^0.1.0 - fluttium_protocol: ^0.1.0 - mason: ^0.1.0-dev.50 + mason: ^0.1.0-dev.51 meta: ^1.8.0 path: ^1.8.3 - process: ^4.2.4 - watcher: ^1.0.2 - yaml: ^3.1.1 + process: ^5.0.1 + watcher: ^1.1.0 + yaml: ^3.1.2 dev_dependencies: + fake_async: ^1.3.1 mocktail: ">=0.3.0 <2.0.0" test: ^1.23.1 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/packages/fluttium_driver/pubspec_overrides.yaml b/packages/fluttium_driver/pubspec_overrides.yaml index 562e8d83..188d90d4 100644 --- a/packages/fluttium_driver/pubspec_overrides.yaml +++ b/packages/fluttium_driver/pubspec_overrides.yaml @@ -1,5 +1,3 @@ dependency_overrides: fluttium_interfaces: path: ../fluttium_interfaces - fluttium_protocol: - path: ../fluttium_protocol diff --git a/packages/fluttium_driver/test/src/drivers/fluttium_driver_test.dart b/packages/fluttium_driver/test/src/drivers/fluttium_driver_test.dart new file mode 100644 index 00000000..94d21daa --- /dev/null +++ b/packages/fluttium_driver/test/src/drivers/fluttium_driver_test.dart @@ -0,0 +1,451 @@ +// ignore_for_file: prefer_const_constructors, prefer_const_literals_to_create_immutables + +import 'dart:async'; +import 'dart:convert'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_daemon/flutter_daemon.dart'; +import 'package:fluttium_driver/fluttium_driver.dart'; +import 'package:fluttium_interfaces/fluttium_interfaces.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class _MockFlutterDaemon extends Mock implements FlutterDaemon {} + +class _MockFlutterApplication extends Mock implements FlutterApplication {} + +class _TestDriver extends FluttiumDriver { + _TestDriver(this.daemon, {required super.userFlow}) + : super( + actions: {}, + configuration: const DriverConfiguration(), + ); + + final FlutterDaemon daemon; + + bool runCalled = false; + + bool quitCalled = false; + + bool restartCalled = false; + + @override + Future onRun({required bool watch}) async => runCalled = true; + + @override + Future onQuit() async => quitCalled = true; + + @override + Future onRestart() async => restartCalled = true; + + @override + Future getFlutterApplication(FlutterDaemon daemon) { + return daemon.run(arguments: []); + } + + @override + Future getFlutterDaemon() async => daemon; +} + +void main() { + group('$FluttiumDriver', () { + late FlutterDaemon daemon; + late Completer daemonIsFinished; + late FlutterApplication application; + late Completer isReady; + late Completer startExecuting; + late String? executionError; + late String? getError; + late Map files; + late _TestDriver driver; + + void continueExecuting() => startExecuting.complete(); + + setUp(() { + daemon = _MockFlutterDaemon(); + when(() => daemon.run(arguments: any(named: 'arguments'))).thenAnswer( + (_) async => application, + ); + + daemonIsFinished = Completer(); + when(() => daemon.finished).thenAnswer((_) => daemonIsFinished.future); + when(() => daemon.isFinished).thenAnswer( + (_) => daemonIsFinished.isCompleted, + ); + + when(() => daemon.dispose()).thenAnswer((_) async {}); + + application = _MockFlutterApplication(); + + isReady = Completer(); + when( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.ready')), + params: any(named: 'params'), + ), + ).thenAnswer((_) async { + return AppCallServiceExtensionResponse.fromJSON({ + 'id': 0, + 'result': {'ready': isReady.future}, + 'error': null, + }); + }); + + when( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.getActionDescription')), + params: any(named: 'params'), + ), + ).thenAnswer((invocation) async { + final params = + invocation.namedArguments[#params] as Map; + + return AppCallServiceExtensionResponse.fromJSON({ + 'id': 0, + 'result': {'description': params['name']}, + 'error': getError, + }); + }); + + startExecuting = Completer(); + when( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.executeAction')), + params: any(named: 'params'), + ), + ).thenAnswer((_) async { + await startExecuting.future; + + return AppCallServiceExtensionResponse.fromJSON({ + 'id': 0, + 'result': { + 'success': executionError == null, + 'files': files, + }, + 'error': executionError, + }); + }); + + when(() => application.stop()).thenAnswer((_) async { + return AppStopResponse.fromJSON({ + 'id': 0, + 'result': true, + 'error': null, + }); + }); + + when(() => application.restart()).thenAnswer((_) async { + return AppRestartResponse.fromJSON({ + 'id': 0, + 'result': { + 'code': 0, + 'message': '', + }, + 'error': null, + }); + }); + + executionError = null; + getError = null; + files = {}; + driver = _TestDriver( + daemon, + userFlow: UserFlowYaml( + description: 'description', + steps: [ + UserFlowStep('expectVisible', arguments: 'Text'), + UserFlowStep('pressOn', arguments: 'Text'), + ], + ), + ); + }); + + test('can run a flow test', () async { + final future = driver.run(); + isReady.complete(true); + + expect(driver.runCalled, isTrue); + + final initialSteps = await driver.steps + .where((event) => event.every((e) => e.description != '')) + .first; + + expect( + initialSteps, + equals([ + UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + description: 'expectVisible', + status: StepStatus.running, + ), + UserFlowStepState( + UserFlowStep('pressOn', arguments: 'Text'), + description: 'pressOn', + ), + ]), + ); + + final foundFile = driver.files.first; + + files['test.file'] = base64.encode(utf8.encode('test')); + + continueExecuting(); + + final finishedSteps = await driver.steps + .where((event) => event.every((e) => e.status == StepStatus.done)) + .first; + + expect( + finishedSteps, + equals([ + UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + description: 'expectVisible', + status: StepStatus.done, + ), + UserFlowStepState( + UserFlowStep('pressOn', arguments: 'Text'), + description: 'pressOn', + status: StepStatus.done, + ), + ]), + ); + + expect( + await foundFile, + isA() + .having((f) => f.path, 'path', equals('test.file')) + .having((f) => f.data, 'data', equals(utf8.encode('test'))), + ); + + await future; + + expect(driver.quitCalled, isTrue); + + verify( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.ready')), + params: any(named: 'params', that: equals({})), + ), + ).called(equals(1)); + + verify( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.getActionDescription')), + params: any( + named: 'params', + that: equals({ + 'name': isA(), + 'arguments': isA(), + }), + ), + ), + ).called(equals(2)); + }); + + group('throws $FluttiumFailedToGetReady', () { + late String? readyReason; + late String? error; + + setUp(() { + readyReason = null; + error = null; + + when( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.ready')), + params: any(named: 'params'), + ), + ).thenAnswer((_) async { + return AppCallServiceExtensionResponse.fromJSON({ + 'id': 0, + 'result': {'ready': false, 'reason': readyReason}, + 'error': error, + }); + }); + }); + + Matcher failedToGetReady(String reason) { + return isA().having( + (e) => e.reason, + 'reason', + reason, + ); + } + + test('with a non-ready reason', () { + readyReason = 'readyReason'; + + fakeAsync((async) { + final future = driver.run(); + expectLater(future, throwsA(failedToGetReady('readyReason'))); + async.elapse(Duration(seconds: 31)); + }); + }); + + test('when there is an error', () { + error = 'errorReason'; + + fakeAsync((async) { + final future = driver.run(); + expectLater(future, throwsA(failedToGetReady('errorReason'))); + async.elapse(Duration(seconds: 31)); + }); + }); + + test('when it times out without a reason', () { + fakeAsync((async) { + final future = driver.run(); + expectLater(future, throwsA(failedToGetReady('Unknown reason'))); + async.elapse(Duration(seconds: 31)); + }); + }); + }); + + test('throws $FluttiumFatalStepFail if a step fails unexpectedly', + () async { + // Set the getDescription error. + getError = 'failed'; + + final future = driver.run(); + + await expectLater( + future, + throwsA( + isA() + .having((e) => e.reason, 'reason', 'failed') + .having( + (e) => e.state, + 'state', + UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + ), + ), + ), + ); + }); + + test('quit if a step state failed', () async { + final future = driver.run(); + isReady.complete(true); + + // Set the execution error. + executionError = 'failed'; + continueExecuting(); + + final failedSteps = await driver.steps + .where((event) => event.any((e) => e.status == StepStatus.failed)) + .first; + + expect( + failedSteps, + equals([ + UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + description: 'expectVisible', + status: StepStatus.failed, + failReason: 'failed', + ), + UserFlowStepState( + UserFlowStep('pressOn', arguments: 'Text'), + description: 'pressOn', + ), + ]), + ); + + await future; + + expect(driver.quitCalled, isTrue); + + verify(() => application.stop()).called(equals(1)); + verify(() => daemon.dispose()).called(equals(1)); + }); + + group('watch', () { + test('quit after driver finishes in watch mode', () async { + final future = driver.run(watch: true); + isReady.complete(true); + + // Wait until the steps are done. + continueExecuting(); + await driver.steps + .where((event) => event.every((e) => e.status == StepStatus.done)) + .first; + + // Should not be called because it is in watch mode + expect(driver.quitCalled, isFalse); + + // Tell the daemon we are finished, the future should now complete. + daemonIsFinished.complete(true); + await future; + + expect(driver.quitCalled, isTrue); + + // Should never be called as the daemon was already finished. + verifyNever(() => application.stop()); + verify(() => daemon.dispose()).called(equals(1)); + }); + + test('restart driver while in watch mode', () async { + final future = driver.run(watch: true); + isReady.complete(true); + + // Wait until the steps are done. + continueExecuting(); + await driver.steps + .where((event) => event.every((e) => e.status == StepStatus.done)) + .first; + + expect(driver.restartCalled, isFalse); + await driver.restart(); + verify(() => application.restart()).called(equals(1)); + expect(driver.restartCalled, isTrue); + + // Tell the daemon we are finished, the future should now complete. + daemonIsFinished.complete(true); + await future; + + expect(driver.quitCalled, isTrue); + verify(() => daemon.dispose()).called(equals(1)); + + // Because we restart it should have been called 4 times instead of two. + verify( + () => application.callServiceExtension( + any(that: equals('ext.fluttium.getActionDescription')), + params: any( + named: 'params', + that: equals({ + 'name': isA(), + 'arguments': isA(), + }), + ), + ), + ).called(equals(4)); + }); + }); + }); + + group('$FluttiumFailedToGetReady', () { + test('toString', () { + expect( + FluttiumFailedToGetReady('reason').toString(), + equals('Fluttium failed to get ready: reason'), + ); + }); + }); + + group('$FluttiumFatalStepFail', () { + test('toString', () { + expect( + FluttiumFatalStepFail( + UserFlowStepState( + UserFlowStep('expectVisible', arguments: 'Text'), + description: 'expectVisible', + ), + 'reason', + ).toString(), + equals('Fluttium fatally failed step "expectVisible": reason'), + ); + }); + }); +} diff --git a/packages/fluttium_driver/test/src/exceptions/fatal_driver_exception_test.dart b/packages/fluttium_driver/test/src/exceptions/fatal_driver_exception_test.dart index b6d81d0c..2392d962 100644 --- a/packages/fluttium_driver/test/src/exceptions/fatal_driver_exception_test.dart +++ b/packages/fluttium_driver/test/src/exceptions/fatal_driver_exception_test.dart @@ -1,3 +1,6 @@ +// ignore_for_file: deprecated_member_use_from_same_package, +// ignore_for_file: prefer_const_constructors + import 'package:fluttium_driver/fluttium_driver.dart'; import 'package:test/test.dart'; @@ -6,5 +9,12 @@ void main() { test('can be created', () { expect(FatalDriverException(''), isNotNull); }); + + test('toString', () { + expect( + FatalDriverException('').toString(), + equals('A fatal exception happened on the driver: '), + ); + }); }); } diff --git a/packages/fluttium_driver/test/src/fluttium_driver_test.dart b/packages/fluttium_driver/test/src/fluttium_driver_test.dart deleted file mode 100644 index e1d9b933..00000000 --- a/packages/fluttium_driver/test/src/fluttium_driver_test.dart +++ /dev/null @@ -1,807 +0,0 @@ -// ignore_for_file: prefer_const_constructors - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:fluttium_driver/fluttium_driver.dart'; -import 'package:fluttium_driver/src/bundles/bundles.dart'; -import 'package:fluttium_interfaces/fluttium_interfaces.dart'; -import 'package:fluttium_protocol/fluttium_protocol.dart'; -import 'package:mason/mason.dart' hide GitPath; -import 'package:mocktail/mocktail.dart'; -import 'package:process/process.dart'; -import 'package:test/test.dart'; -import 'package:watcher/watcher.dart'; - -class _FakeDirectoryGeneratorTarget extends Fake - implements DirectoryGeneratorTarget {} - -class _MockLogger extends Mock implements Logger {} - -class _MockProgress extends Mock implements Progress {} - -class _MockProcessManager extends Mock implements ProcessManager {} - -class _MockProcess extends Mock implements Process {} - -class _MockFile extends Mock implements File {} - -class _MockDirectory extends Mock implements Directory {} - -class _MockMasonGenerator extends Mock implements MasonGenerator {} - -class _MockGeneratorHooks extends Mock implements GeneratorHooks {} - -class _MockIOSink extends Mock implements IOSink {} - -class _MockDirectoryWatcher extends Mock implements DirectoryWatcher {} - -class _MockFileWatcher extends Mock implements FileWatcher {} - -void main() { - group('FluttiumDriver', () { - late Logger logger; - late Progress settingUpTestRunner; - late Progress settingUpLauncher; - late Progress launchingTestRunner; - - late GeneratorBuilder generatorBuilder; - late MasonGenerator testRunnerGenerator; - late GeneratorHooks testRunnerGeneratorHooks; - late MasonGenerator launcherGenerator; - late GeneratorHooks launcherGeneratorHooks; - - late Directory tempDirectory; - late Directory testRunnerDirectory; - late File userFlowFile; - late Directory projectDirectory; - late File launcherFile; - late File pubspecFile; - - late ProcessManager processManager; - late Process process; - late IOSink processSink; - late StreamController> stdoutController; - late StreamController> stderrController; - late Completer processExitCode; - - setUpAll(() { - registerFallbackValue(_FakeDirectoryGeneratorTarget()); - }); - - setUp(() { - // Setting up logger - logger = _MockLogger(); - settingUpTestRunner = _MockProgress(); - when( - () => logger.progress(any(that: equals('Setting up the test runner'))), - ).thenReturn(settingUpTestRunner); - settingUpLauncher = _MockProgress(); - when( - () => logger.progress(any(that: equals('Setting up the launcher'))), - ).thenReturn(settingUpLauncher); - launchingTestRunner = _MockProgress(); - when( - () => logger.progress(any(that: equals('Launching the test runner'))), - ).thenReturn(launchingTestRunner); - - // Setting up mason - testRunnerGenerator = _MockMasonGenerator(); - when( - () => testRunnerGenerator.generate( - any(), - vars: any(named: 'vars'), - logger: any(named: 'logger'), - fileConflictResolution: any(named: 'fileConflictResolution'), - ), - ).thenAnswer((_) async => []); - - testRunnerGeneratorHooks = _MockGeneratorHooks(); - when( - () => testRunnerGeneratorHooks.postGen( - workingDirectory: any(named: 'workingDirectory'), - ), - ).thenAnswer((_) async {}); - when(() => testRunnerGenerator.hooks) - .thenReturn(testRunnerGeneratorHooks); - - launcherGenerator = _MockMasonGenerator(); - when( - () => launcherGenerator.generate( - any(), - vars: any(named: 'vars'), - logger: any(named: 'logger'), - fileConflictResolution: any(named: 'fileConflictResolution'), - ), - ).thenAnswer( - (_) async => [ - GeneratedFile.created( - path: '/project_directory/.fluttium_test_launcher.dart', - ) - ], - ); - launcherGeneratorHooks = _MockGeneratorHooks(); - when( - () => launcherGeneratorHooks.preGen( - workingDirectory: any(named: 'workingDirectory'), - vars: any(named: 'vars'), - ), - ).thenAnswer((_) async {}); - when( - () => launcherGeneratorHooks.postGen( - workingDirectory: any(named: 'workingDirectory'), - ), - ).thenAnswer((_) async {}); - when(() => launcherGenerator.hooks).thenReturn(launcherGeneratorHooks); - - generatorBuilder = (MasonBundle bundle) { - if (bundle == fluttiumLauncherBundle) return launcherGenerator; - return testRunnerGenerator; - }; - - // Setting up directories - testRunnerDirectory = _MockDirectory(); - when(() => testRunnerDirectory.path).thenReturn('/tmp/fluttium_xxxxxxx'); - tempDirectory = _MockDirectory(); - when( - () => tempDirectory.createTempSync(any(that: equals('fluttium_'))), - ).thenReturn(testRunnerDirectory); - when(() => testRunnerDirectory.existsSync()).thenReturn(true); - when( - () => testRunnerDirectory.deleteSync( - recursive: any(named: 'recursive'), - ), - ).thenAnswer((_) {}); - - userFlowFile = _MockFile(); - when(() => userFlowFile.existsSync()).thenReturn(true); - when(() => userFlowFile.readAsStringSync()).thenReturn(''' -description: test ---- -- pressOn: Text -- expectVisible: Text -'''); - when(() => userFlowFile.path).thenReturn('flow.yaml'); - - projectDirectory = _MockDirectory(); - when(() => projectDirectory.path).thenReturn('/project_directory'); - when(() => projectDirectory.uri) - .thenReturn(Uri.parse('/project_directory/')); - when(() => projectDirectory.absolute).thenReturn(projectDirectory); - launcherFile = _MockFile(); - when(() => launcherFile.absolute).thenReturn(launcherFile); - when(() => launcherFile.path) - .thenReturn('/project_directory/.fluttium_test_launcher.dart'); - when(() => launcherFile.existsSync()).thenReturn(true); - when(() => launcherFile.deleteSync()).thenAnswer((_) {}); - - pubspecFile = _MockFile(); - when(() => pubspecFile.path) - .thenReturn('/project_directory/pubspec.yaml'); - when(() => pubspecFile.readAsStringSync()).thenReturn(''' -name: project_name -'''); - - processManager = _MockProcessManager(); - - process = _MockProcess(); - processExitCode = Completer(); - when(() => process.exitCode).thenAnswer((_) => processExitCode.future); - - processSink = _MockIOSink(); - when(() => processSink.write(any(that: equals('q')))).thenAnswer((_) { - processExitCode.complete(ExitCode.success.code); - }); - when(() => process.stdin).thenReturn(processSink); - - stdoutController = StreamController>(); - when(() => process.stdout).thenAnswer((_) => stdoutController.stream); - stderrController = StreamController>(); - when(() => process.stderr).thenAnswer((_) => stderrController.stream); - when(() => process.kill()).thenReturn(true); - - when( - () => processManager.start( - any( - that: containsAllInOrder([ - 'flutter', - 'run', - '/project_directory/.fluttium_test_launcher.dart', - '-d', - 'deviceId' - ]), - ), - runInShell: any(named: 'runInShell'), - workingDirectory: any(named: 'workingDirectory'), - ), - ).thenAnswer((_) async => process); - }); - - FluttiumDriver createDriver({ - DirectoryWatcher? directoryWatcher, - FileWatcher? fileWatcher, - List dartDefines = const [], - Map actions = const {}, - }) { - return FluttiumDriver( - configuration: DriverConfiguration( - deviceId: 'deviceId', - dartDefines: dartDefines, - ), - actions: actions, - projectDirectory: Directory('/project_directory'), - userFlowFile: File('flow.yaml'), - logger: logger, - processManager: processManager, - generator: generatorBuilder, - directoryWatcher: directoryWatcher != null - ? (path, {Duration? pollingDelay}) => directoryWatcher - : null, - fileWatcher: fileWatcher != null - ? (path, {Duration? pollingDelay}) => fileWatcher - : null, - ); - } - - test('can be instantiated', () { - IOOverrides.runZoned( - () { - expect( - FluttiumDriver( - configuration: DriverConfiguration(), - actions: {}, - projectDirectory: projectDirectory, - userFlowFile: userFlowFile, - ), - isNotNull, - ); - }, - createFile: (path) { - if (path == 'flow.yaml') return userFlowFile; - throw UnimplementedError(path); - }, - ); - }); - - Future runWithMocks(Future Function() callback) async { - await IOOverrides.runZoned( - callback, - createFile: (path) { - if (path == 'flow.yaml') { - return userFlowFile; - } else if (path == - '/project_directory/.fluttium_test_launcher.dart') { - return launcherFile; - } else if (path == '/project_directory/pubspec.yaml') { - return pubspecFile; - } - throw UnimplementedError(path); - }, - createDirectory: (path) { - if (path == '/temp/test') { - return testRunnerDirectory; - } else if (path == '/project_directory') { - return projectDirectory; - } - throw UnimplementedError(path); - }, - getSystemTempDirectory: () => tempDirectory, - ); - } - - test('can run a flow test', () async { - await runWithMocks(() async { - var testStepStates = []; - final driver = createDriver( - actions: { - 'hosted_action': ActionLocation( - hosted: HostedPath( - url: 'https://pub.dev/packages/hosted_action', - version: VersionConstraint.parse('^1.2.3'), - ), - ), - 'git_action_simple': ActionLocation( - git: GitPath( - url: 'git@github.com/wolfenrain/git_action_simple', - ), - ), - 'git_action_advanced': ActionLocation( - git: GitPath( - url: 'git@github.com/wolfenrain/git_action_advanced', - ref: 'dev', - path: 'packages/advanced', - ), - ), - 'path_action': ActionLocation(path: './path_action'), - }, - )..steps.listen((steps) => testStepStates = steps); - verify(() => userFlowFile.readAsStringSync()).called(1); - - final future = driver.run(); - - // Wait for the process to start. - await Future.delayed(Duration.zero); - - // Check if the start of the generated code is working correctly. - verify( - () => - logger.progress(any(that: equals('Setting up the test runner'))), - ).called(1); - verify( - () => tempDirectory.createTempSync(any(that: equals('fluttium_'))), - ).called(1); - verify(() => settingUpTestRunner.complete()).called(1); - verify( - () => logger.progress(any(that: equals('Setting up the launcher'))), - ).called(1); - verify(() => pubspecFile.readAsStringSync()).called(1); - verify(() => settingUpLauncher.complete()).called(1); - - // Check if the test runner generation is working correctly - verify(() => userFlowFile.readAsStringSync()).called(1); - verify( - () => testRunnerGenerator.generate( - any(), - vars: any( - named: 'vars', - that: equals({ - 'actions': [ - { - 'name': 'hosted_action', - 'source': ''' - - hosted: https://pub.dev/packages/hosted_action - version: ^1.2.3''' - }, - { - 'name': 'git_action_simple', - 'source': 'git@github.com/wolfenrain/git_action_simple' - }, - { - 'name': 'git_action_advanced', - 'source': ''' - - git: - url: git@github.com/wolfenrain/git_action_advanced - ref: dev - path: packages/advanced''' - }, - { - 'name': 'path_action', - 'source': ''' - - path: /project_directory/path_action''' - } - ], - 'steps': [ - { - 'step': json.encode({'pressOn': 'Text'}) - }, - { - 'step': json.encode({'expectVisible': 'Text'}) - } - ], - }), - ), - logger: any(named: 'logger'), - fileConflictResolution: any( - named: 'fileConflictResolution', - that: equals(FileConflictResolution.overwrite), - ), - ), - ).called(1); - verify( - () => testRunnerGeneratorHooks.postGen( - workingDirectory: any( - named: 'workingDirectory', - that: equals('/tmp/fluttium_xxxxxxx'), - ), - ), - ).called(1); - - // Verify that the rest of the test runner generation is working. - verify( - () => launcherGeneratorHooks.preGen( - workingDirectory: any( - named: 'workingDirectory', - that: equals('/project_directory'), - ), - vars: any( - named: 'vars', - that: equals({ - 'runner_id': 'fluttium_xxxxxxx', - 'project_name': 'project_name', - 'target': 'main.dart', - 'runner_path': '/tmp/fluttium_xxxxxxx', - }), - ), - ), - ).called(1); - verify( - () => launcherGenerator.generate( - any(), - vars: any( - named: 'vars', - that: equals({ - 'runner_id': 'fluttium_xxxxxxx', - 'project_name': 'project_name', - 'target': 'main.dart', - 'runner_path': '/tmp/fluttium_xxxxxxx', - }), - ), - logger: any(named: 'logger'), - fileConflictResolution: any(named: 'fileConflictResolution'), - ), - ).called(1); - - // Verifying that the launching works correctly. - verify( - () => logger.detail( - any( - that: equals( - 'Running command: flutter run /project_directory/.fluttium_test_launcher.dart -d deviceId', - ), - ), - ), - ).called(1); - verify( - () => logger.progress(any(that: equals('Launching the test runner'))), - ).called(1); - verify( - () => processManager.start( - any( - that: equals([ - 'flutter', - 'run', - '/project_directory/.fluttium_test_launcher.dart', - '-d', - 'deviceId' - ]), - ), - runInShell: any(named: 'runInShell', that: isTrue), - workingDirectory: any( - named: 'workingDirectory', - that: equals('/project_directory'), - ), - ), - ).called(1); - - // Trigger the attach by sending the first announce. - stdoutController - ..addAll(MessageType.announce.toData('stepName1')) - ..addAll(MessageType.announce.toData('stepName2')); - await Future.delayed(Duration.zero); - verify(() => launchingTestRunner.complete()).called(1); - - // Finish the process by starting and finishing a step. - stdoutController - ..addAll(MessageType.start.toData('stepName1')) - ..addAll(MessageType.store.toData('stepName1')) - ..addAll(MessageType.done.toData('stepName1')) - ..addAll(MessageType.start.toData('stepName2')) - ..addAll(MessageType.fail.toData('stepName2')); - - // Wait for the messages to be consumed. - await Future.delayed(Duration.zero); - - // Verify that the code clean is working correctly. - verify( - () => launcherGeneratorHooks.postGen( - workingDirectory: any( - named: 'workingDirectory', - that: equals('/project_directory'), - ), - ), - ).called(1); - verify(() => launcherFile.existsSync()).called(1); - verify(() => launcherFile.deleteSync()).called(1); - verify(() => testRunnerDirectory.existsSync()).called(1); - verify( - () => testRunnerDirectory.deleteSync( - recursive: any(named: 'recursive', that: isTrue), - ), - ).called(1); - - await future; - - expect( - testStepStates, - equals([ - StepState( - 'stepName1', - status: StepStatus.done, - files: const { - 'fileName': [1, 2, 3] - }, - ), - StepState( - 'stepName2', - status: StepStatus.failed, - failReason: 'reason', - ), - ]), - ); - }); - }); - - test('fails to start driver if error occurred in building', () async { - await runWithMocks(() async { - var testStepStates = []; - final driver = createDriver() - ..steps.listen((steps) => testStepStates = steps); - verify(() => userFlowFile.readAsStringSync()).called(1); - - final future = driver.run(); - - // Wait for the process to start. - await Future.delayed(Duration.zero); - - // Write an error to stderr - stderrController.add(utf8.encode('fake failure')); - await Future.delayed(Duration.zero); - - // Close process and stderr controller. - await stderrController.close(); - processExitCode.complete(ExitCode.unavailable.code); - - await Future.delayed(Duration.zero); - verifyNever(() => launchingTestRunner.complete()); - verify( - () => launchingTestRunner - .fail(any(that: equals('Failed to start test driver'))), - ).called(1); - verify(() => logger.err(any(that: equals('fake failure')))).called(1); - - await future; - - expect(testStepStates, equals([])); - }); - }); - - test('propagates fatal exceptions', () async { - await runWithMocks(() async { - var testStepStates = []; - final driver = createDriver() - ..steps.listen( - (steps) => testStepStates = steps, - onError: (Object err) { - expect(err, isA()); - expect( - '$err', - equals( - 'A fatal exception happened on the driver: fatal reason', - ), - ); - }, - ); - - final future = driver.run(); - // Wait for the process to start. - await Future.delayed(Duration.zero); - - // Trigger the attach by sending the first announce. - stdoutController.addAll(MessageType.announce.toData('stepName')); - await Future.delayed(Duration.zero); - - // Trigger a fatal exception from the emitter. - stdoutController.addAll(MessageType.fatal.toData('stepName')); - await Future.delayed(Duration.zero); - - // Should be ignored because we had a fatal exception. - stdoutController.addAll(MessageType.announce.toData('anotherSTep')); - await Future.delayed(Duration.zero); - - processExitCode.complete(ExitCode.success.code); - await future; - - expect(testStepStates, equals([StepState('stepName')])); - }); - }); - - group('watch mode', () { - late DirectoryWatcher directoryWatcher; - late StreamController watchEventController; - late FileWatcher fileWatcher; - late StreamController fileWatchEventController; - - setUp(() { - directoryWatcher = _MockDirectoryWatcher(); - watchEventController = StreamController(); - when(() => directoryWatcher.events) - .thenAnswer((_) => watchEventController.stream); - - fileWatcher = _MockFileWatcher(); - fileWatchEventController = StreamController(); - when(() => fileWatcher.events) - .thenAnswer((_) => fileWatchEventController.stream); - }); - - test('restart if a project file changes', () async { - await runWithMocks(() async { - var testStepStates = []; - final driver = createDriver( - directoryWatcher: directoryWatcher, - fileWatcher: fileWatcher, - )..steps.listen((steps) => testStepStates = steps); - - final future = driver.run(watch: true); - // Wait for the process to start. - await Future.delayed(Duration.zero); - - // Trigger the attach by sending the first announce. - stdoutController.addAll(MessageType.announce.toData('stepName')); - await Future.delayed(Duration.zero); - - // Trigger a file change - watchEventController.add( - WatchEvent(ChangeType.MODIFY, 'project_directory/lib/main.dart'), - ); - await Future.delayed(Duration.zero); - - verify(() => process.stdin.write(any(that: equals('R')))).called(1); - - processExitCode.complete(ExitCode.success.code); - await future; - - expect(testStepStates, equals([StepState('stepName')])); - }); - }); - - test('logs an error if it temporary cant watch a file', () async { - await runWithMocks(() async { - var testStepStates = []; - final driver = createDriver( - directoryWatcher: directoryWatcher, - fileWatcher: fileWatcher, - )..steps.listen((steps) => testStepStates = steps); - - final future = driver.run(watch: true); - // Wait for the process to start. - await Future.delayed(Duration.zero); - - // Trigger the attach by sending the first announce. - stdoutController.addAll(MessageType.announce.toData('stepName')); - await Future.delayed(Duration.zero); - - // Trigger a file system exception. - watchEventController.addError(FileSystemException('Failed to watch')); - await Future.delayed(Duration.zero); - - verify( - () => logger.detail( - any( - that: equals( - "FileSystemException: Failed to watch, path = ''", - ), - ), - ), - ).called(1); - verifyNever(() => process.stdin.write(any(that: equals('R')))); - - processExitCode.complete(ExitCode.success.code); - await future; - - expect(testStepStates, equals([])); - }); - }); - - test('regenerate test runner if the flow file change', () async { - await runWithMocks(() async { - var testStepStates = []; - final driver = createDriver( - directoryWatcher: directoryWatcher, - fileWatcher: fileWatcher, - )..steps.listen((steps) => testStepStates = steps); - - final future = driver.run(watch: true); - // Wait for the process to start. - await Future.delayed(Duration.zero); - - verify(() => userFlowFile.readAsStringSync()).called(2); - - // Trigger the attach by sending the first announce. - stdoutController.addAll(MessageType.announce.toData('stepName')); - await Future.delayed(Duration.zero); - - verifyNever(() => userFlowFile.readAsStringSync()); - fileWatchEventController.add( - WatchEvent(ChangeType.MODIFY, 'flow.yaml'), - ); - await Future.delayed(Duration.zero); - verify(() => userFlowFile.readAsStringSync()).called(1); - verify(() => process.stdin.write(any(that: equals('R')))).called(1); - - processExitCode.complete(ExitCode.success.code); - await future; - - expect(testStepStates, equals([StepState('stepName')])); - }); - }); - }); - - test('creates a Fluttium version constraints correctly', () { - expect( - FluttiumDriver.fluttiumVersionConstraint, - isA(), - ); - }); - - test('creates a Flutter version constraints correctly', () { - expect( - FluttiumDriver.flutterVersionConstraint, - isA(), - ); - }); - }); -} - -extension on MessageType { - Iterable> toData(String stepName) { - switch (this) { - case MessageType.fatal: - return [ - {'type': 'start'}, - { - 'type': 'data', - 'data': r'"{\"type\":\"fatal\",\"data\":\"\\\"fatal reason\\\"\"}"' - }, - {'type': 'done'} - ].map((data) => utf8.encode('${json.encode(data)}\n')); - case MessageType.announce: - return [ - {'type': 'start'}, - { - 'type': 'data', - 'data': - '"{\\"type\\":\\"announce\\",\\"data\\":\\"\\\\\\"$stepName\\\\\\"\\"}"' - }, - {'type': 'done'} - ].map((data) => utf8.encode('${json.encode(data)}\n')); - case MessageType.start: - return [ - {'type': 'start'}, - { - 'type': 'data', - 'data': - '"{\\"type\\":\\"start\\",\\"data\\":\\"\\\\\\"$stepName\\\\\\"\\"}"' - }, - {'type': 'done'}, - ].map((data) => utf8.encode('${json.encode(data)}\n')); - case MessageType.done: - return [ - {'type': 'start'}, - { - 'type': 'data', - 'data': - '"{\\"type\\":\\"done\\",\\"data\\":\\"\\\\\\"$stepName\\\\\\"\\"}"' - }, - {'type': 'done'}, - ].map((data) => utf8.encode('${json.encode(data)}\n')); - case MessageType.fail: - return [ - {'type': 'start'}, - { - 'type': 'data', - 'data': - '"{\\"type\\":\\"fail\\",\\"data\\":\\"[\\\\\\"$stepName\\\\\\",\\\\\\"reason\\\\\\"]\\"}"' - }, - {'type': 'done'}, - ].map((data) => utf8.encode('${json.encode(data)}\n')); - case MessageType.store: - return [ - {'type': 'start'}, - { - 'type': 'data', - 'data': - r'"{\"type\":\"store\",\"data\":\"[\\\"fileName\\\",[1,2,3]]\"}"' - }, - {'type': 'done'}, - ].map((data) => utf8.encode('${json.encode(data)}\n')); - } - } -} - -extension on StreamController> { - void addAll(Iterable> data) => data.forEach(add); -} diff --git a/packages/fluttium_driver/test/src/stored_file_test.dart b/packages/fluttium_driver/test/src/stored_file_test.dart new file mode 100644 index 00000000..06108a2d --- /dev/null +++ b/packages/fluttium_driver/test/src/stored_file_test.dart @@ -0,0 +1,13 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:fluttium_driver/fluttium_driver.dart'; +import 'package:test/test.dart'; + +void main() { + group('$StoredFile', () { + test('can be instantiated', () { + final storedFile = StoredFile('path', [1, 2, 3]); + expect(storedFile, isNotNull); + }); + }); +} diff --git a/packages/fluttium_driver/test/src/step_result_test.dart b/packages/fluttium_driver/test/src/user_flow_step_state_test.dart similarity index 59% rename from packages/fluttium_driver/test/src/step_result_test.dart rename to packages/fluttium_driver/test/src/user_flow_step_state_test.dart index a0612a40..1a13684c 100644 --- a/packages/fluttium_driver/test/src/step_result_test.dart +++ b/packages/fluttium_driver/test/src/user_flow_step_state_test.dart @@ -1,44 +1,38 @@ import 'package:fluttium_driver/fluttium_driver.dart'; +import 'package:fluttium_interfaces/fluttium_interfaces.dart'; import 'package:test/test.dart'; void main() { - group('StepState', () { + group('$UserFlowStepState', () { + const step = UserFlowStep('action', arguments: 'arguments'); + test('can be instantiated', () { - final state = StepState('description'); + final state = UserFlowStepState(step); - expect(state.description, equals('description')); + expect(state.description, equals('')); expect(state.status, equals(StepStatus.initial)); - expect(state.files, isEmpty); expect(state.failReason, isNull); }); test('copyWith', () { - final state = StepState('description'); + final state = UserFlowStepState(step); final copied = state.copyWith( + description: 'description', status: StepStatus.done, - files: { - 'file': [1, 2, 3] - }, failReason: 'failReason', ); expect(copied.description, equals('description')); expect(copied.status, equals(StepStatus.done)); - expect( - copied.files, - equals({ - 'file': [1, 2, 3] - }), - ); expect(copied.failReason, equals('failReason')); expect(state.copyWith(), equals(state)); }); test('equality', () { - final state = StepState('description'); - final otherState = StepState('description'); + final state = UserFlowStepState(step); + final otherState = UserFlowStepState(step); expect(state, equals(otherState)); }); diff --git a/packages/fluttium_interfaces/analysis_options.yaml b/packages/fluttium_interfaces/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/packages/fluttium_interfaces/analysis_options.yaml +++ b/packages/fluttium_interfaces/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/fluttium_interfaces/lib/src/fluttium/fluttium_yaml.dart b/packages/fluttium_interfaces/lib/src/fluttium/fluttium_yaml.dart index f3a75666..a955ec9b 100644 --- a/packages/fluttium_interfaces/lib/src/fluttium/fluttium_yaml.dart +++ b/packages/fluttium_interfaces/lib/src/fluttium/fluttium_yaml.dart @@ -31,7 +31,7 @@ class FluttiumYaml extends Equatable { actions: { for (final entry in (yaml['actions'] as Map? ?? {}).entries) - entry.key: ActionLocation.fromJson(entry.value) + entry.key: ActionLocation.fromJson(entry.value), }, driver: DriverConfiguration.fromJson( yaml['driver'] as Map? ?? {}, diff --git a/packages/fluttium_interfaces/lib/src/user_flow/user_flow_yaml.dart b/packages/fluttium_interfaces/lib/src/user_flow/user_flow_yaml.dart index 697e15f1..5f4ec30b 100644 --- a/packages/fluttium_interfaces/lib/src/user_flow/user_flow_yaml.dart +++ b/packages/fluttium_interfaces/lib/src/user_flow/user_flow_yaml.dart @@ -28,7 +28,7 @@ class UserFlowYaml extends Equatable { description: metaData['description'] as String? ?? '', steps: [ for (final step in stepData.cast>()) - UserFlowStep.fromJson(step) + UserFlowStep.fromJson(step), ], ); } diff --git a/packages/fluttium_interfaces/pubspec.yaml b/packages/fluttium_interfaces/pubspec.yaml index f6f8c804..c7411e1d 100644 --- a/packages/fluttium_interfaces/pubspec.yaml +++ b/packages/fluttium_interfaces/pubspec.yaml @@ -17,4 +17,4 @@ dependencies: dev_dependencies: mocktail: ">=0.3.0 <2.0.0" test: ^1.23.1 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/packages/fluttium_interfaces/test/src/fluttium/action_location_test.dart b/packages/fluttium_interfaces/test/src/fluttium/action_location_test.dart index 0ca7da43..cc091883 100644 --- a/packages/fluttium_interfaces/test/src/fluttium/action_location_test.dart +++ b/packages/fluttium_interfaces/test/src/fluttium/action_location_test.dart @@ -66,7 +66,7 @@ void main() { 'url': 'git@git.some.where/some/action.git', 'ref': 'main', 'path': 'some/path', - } + }, }); expect(location.hosted, isNull); diff --git a/packages/fluttium_interfaces/test/src/user_flow/user_flow_step_test.dart b/packages/fluttium_interfaces/test/src/user_flow/user_flow_step_test.dart index 49cd71c0..9cd1ef48 100644 --- a/packages/fluttium_interfaces/test/src/user_flow/user_flow_step_test.dart +++ b/packages/fluttium_interfaces/test/src/user_flow/user_flow_step_test.dart @@ -19,7 +19,7 @@ void main() { final step = UserFlowStep.fromJson(const { 'expectVisible': { 'text': 'findByText', - } + }, }); expect(step.actionName, equals('expectVisible')); diff --git a/packages/fluttium_protocol/analysis_options.yaml b/packages/fluttium_protocol/analysis_options.yaml index 84e34fba..799268d3 100644 --- a/packages/fluttium_protocol/analysis_options.yaml +++ b/packages/fluttium_protocol/analysis_options.yaml @@ -1 +1 @@ -include: package:very_good_analysis/analysis_options.4.0.0.yaml +include: package:very_good_analysis/analysis_options.5.1.0.yaml diff --git a/packages/fluttium_protocol/example/main.dart b/packages/fluttium_protocol/example/main.dart index 0d67567f..0c8af5a2 100644 --- a/packages/fluttium_protocol/example/main.dart +++ b/packages/fluttium_protocol/example/main.dart @@ -22,19 +22,19 @@ Stream> _fakeData() async* { {'type': 'start'}, { 'type': 'data', - 'data': r'"{\"type\":\"announce\",\"data\":\"\\\"stepName\\\"\"}"' + 'data': r'"{\"type\":\"announce\",\"data\":\"\\\"stepName\\\"\"}"', }, {'type': 'done'}, {'type': 'start'}, { 'type': 'data', - 'data': r'"{\"type\":\"start\",\"data\":\"\\\"stepName\\\"\"}"' + 'data': r'"{\"type\":\"start\",\"data\":\"\\\"stepName\\\"\"}"', }, {'type': 'done'}, {'type': 'start'}, { 'type': 'data', - 'data': r'"{\"type\":\"done\",\"data\":\"\\\"stepName\\\"\"}"' + 'data': r'"{\"type\":\"done\",\"data\":\"\\\"stepName\\\"\"}"', }, {'type': 'done'}, ]; diff --git a/packages/fluttium_protocol/lib/src/listener.dart b/packages/fluttium_protocol/lib/src/listener.dart index de899e0d..dd0ee302 100644 --- a/packages/fluttium_protocol/lib/src/listener.dart +++ b/packages/fluttium_protocol/lib/src/listener.dart @@ -40,11 +40,9 @@ class Listener { switch (chunk['type']) { case 'start': collectingMessageData = true; - break; case 'data': if (!collectingMessageData) return; buffer.write(json.decode(chunk['data'] as String)); - break; case 'done': if (!collectingMessageData) return; collectingMessageData = false; @@ -55,7 +53,6 @@ class Listener { ), ); buffer.clear(); - break; } } catch (_) {} }, diff --git a/packages/fluttium_protocol/pubspec.yaml b/packages/fluttium_protocol/pubspec.yaml index d0c33168..ed54cb6a 100644 --- a/packages/fluttium_protocol/pubspec.yaml +++ b/packages/fluttium_protocol/pubspec.yaml @@ -17,4 +17,4 @@ dependencies: dev_dependencies: mocktail: ">=0.3.0 <2.0.0" test: ^1.23.1 - very_good_analysis: ^4.0.0 + very_good_analysis: ^5.1.0 diff --git a/packages/fluttium_protocol/test/src/emitter_test.dart b/packages/fluttium_protocol/test/src/emitter_test.dart index 516d08fd..954e7bb5 100644 --- a/packages/fluttium_protocol/test/src/emitter_test.dart +++ b/packages/fluttium_protocol/test/src/emitter_test.dart @@ -18,7 +18,7 @@ void main() { equals([ '{"type":"start"}', r'{"type":"data","data":"\"{\\\"type\\\":\\\"announce\\\",\\\"data\\\":\\\"\\\\\\\"step\\\\\\\"\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); @@ -36,7 +36,7 @@ void main() { equals([ '{"type":"start"}', r'{"type":"data","data":"\"{\\\"type\\\":\\\"start\\\",\\\"data\\\":\\\"\\\\\\\"step\\\\\\\"\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); @@ -54,7 +54,7 @@ void main() { equals([ '{"type":"start"}', r'{"type":"data","data":"\"{\\\"type\\\":\\\"done\\\",\\\"data\\\":\\\"\\\\\\\"step\\\\\\\"\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); @@ -73,7 +73,7 @@ void main() { equals([ '{"type":"start"}', r'{"type":"data","data":"\"{\\\"type\\\":\\\"store\\\",\\\"data\\\":\\\"[\\\\\\\"fileName\\\\\\\",[]]\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); @@ -98,7 +98,7 @@ void main() { r'{"type":"data","data":"\"89,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160\""}', r'{"type":"data","data":"\",161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,1\""}', r'{"type":"data","data":"\"32,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231]]\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); @@ -117,7 +117,7 @@ void main() { equals([ '{"type":"start"}', r'{"type":"data","data":"\"{\\\"type\\\":\\\"fatal\\\",\\\"data\\\":\\\"\\\\\\\"reason\\\\\\\"\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); @@ -135,7 +135,7 @@ void main() { equals([ '{"type":"start"}', r'{"type":"data","data":"\"{\\\"type\\\":\\\"fail\\\",\\\"data\\\":\\\"[\\\\\\\"step\\\\\\\",\\\\\\\"reason\\\\\\\"]\\\"}\""}', - '{"type":"done"}' + '{"type":"done"}', ]), ); }); diff --git a/tools/release_ready.sh b/tools/release_ready.sh index c177b547..121993b0 100755 --- a/tools/release_ready.sh +++ b/tools/release_ready.sh @@ -54,7 +54,7 @@ sed -i '' "s/version: $package_version/version: $new_version/g" pubspec.yaml # Update dart file with new version. dart run build_runner build --delete-conflicting-outputs > /dev/null -if grep -q $new_version "CHANGELOG.md"; then +if grep -q "# $new_version\n" "CHANGELOG.md"; then echo "CHANGELOG already contains version $new_version." exit 1 fi