From 30c1e1a183f6863d85416b6c03041d89699c03b9 Mon Sep 17 00:00:00 2001 From: Yelaman Yelmuratov Date: Sat, 29 Jun 2024 20:54:17 +0500 Subject: [PATCH] @ F r integrated Richard's solution (force) --- .gitignore | 1 + lib/src/widget/src/common.dart | 17 + lib/src/widget/src/get_widget_names.dart | 166 ++++++ lib/src/widget/src/git_diffs.dart | 36 ++ lib/src/widget/src/src.dart | 230 ++++++++ .../collect_widgets_meta_data.dart | 549 ++++++++++++++++++ .../widget/src/widget_meta/expect_meta.dart | 18 + .../src/widget_meta/load_string_en.dart | 22 + .../widget/src/widget_meta/matcher_types.dart | 38 ++ .../src/widget_meta/register_types.dart | 6 + .../widget/src/widget_meta/widget_meta.dart | 161 +++++ .../widget_meta/widget_tester_extension.dart | 99 ++++ 12 files changed, 1343 insertions(+) create mode 100644 lib/src/widget/src/common.dart create mode 100644 lib/src/widget/src/get_widget_names.dart create mode 100644 lib/src/widget/src/git_diffs.dart create mode 100644 lib/src/widget/src/src.dart create mode 100644 lib/src/widget/src/widget_meta/collect_widgets_meta_data.dart create mode 100644 lib/src/widget/src/widget_meta/expect_meta.dart create mode 100644 lib/src/widget/src/widget_meta/load_string_en.dart create mode 100644 lib/src/widget/src/widget_meta/matcher_types.dart create mode 100644 lib/src/widget/src/widget_meta/register_types.dart create mode 100644 lib/src/widget/src/widget_meta/widget_meta.dart create mode 100644 lib/src/widget/src/widget_meta/widget_tester_extension.dart diff --git a/.gitignore b/.gitignore index 0055b6e..f340fa0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,6 @@ generated_* # Other files .old.dart +.build .DS_Store diff --git a/lib/src/widget/src/common.dart b/lib/src/widget/src/common.dart new file mode 100644 index 0000000..9986f13 --- /dev/null +++ b/lib/src/widget/src/common.dart @@ -0,0 +1,17 @@ +import 'dart:io'; + +const topBar = '▶▶▶▶'; +const bottomBar = '◀◀◀◀'; + +/// [String] extension +extension StringApprovedExtension on String { + /// git diff complains when file doesn't end in newline. This getter ensures a string does. + String get endWithNewline => endsWith('\n') ? this : '$this\n'; +} + +extension DirectoryApprovedExtension on Directory { + Set filesWithExtension(String extension) { + final fileSystemEntities = listSync().where((entity) => entity is File && entity.path.endsWith(extension)); + return fileSystemEntities.whereType().toSet(); + } +} diff --git a/lib/src/widget/src/get_widget_names.dart b/lib/src/widget/src/get_widget_names.dart new file mode 100644 index 0000000..40801c6 --- /dev/null +++ b/lib/src/widget/src/get_widget_names.dart @@ -0,0 +1,166 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/context_builder.dart'; +import 'package:analyzer/dart/analysis/context_locator.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:approval_tests/src/widget/src/common.dart'; + +final _widgetNamesDir = Directory('./test/approved'); +final _widgetNamesPath = '${_widgetNamesDir.path}/class_names.txt'; + +Future> getWidgetNames() async { + final resultCompleter = Completer>(); + + await isWidgetNamesFileFresh().then((isFileFresh) { + if (isFileFresh) { + readWidgetsFile(_widgetNamesPath).then((widgetNames) { + resultCompleter.complete(widgetNames); + }); + } else { + final libPath = '${Directory.current.absolute.path}/lib'; + stdout.write("package:approved: searching for class names in $libPath..."); + extractWidgetNames(libPath).then((widgetsList) { + if (!_widgetNamesDir.existsSync()) { + _widgetNamesDir.createSync(); + } + _widgetNamesDir.createSync(); + final widgetsFile = File(_widgetNamesPath); + widgetsFile.createSync(); + const header = ''' +# This file was autogenerated by package:approved. Please do not edit. +# Below is a list of class found in the project /lib folder.'''; + final widgetNamesString = widgetsList.join('\n').endWithNewline; + widgetsFile.writeAsStringSync('$header\n$widgetNamesString'); + stdout.write('done\n'); + resultCompleter.complete(widgetsList); + }); + } + }); + + return resultCompleter.future; +} + +String loadWidgetNames() { + late String result; + final widgetNamesFile = File(_widgetNamesPath); + if (widgetNamesFile.existsSync()) { + result = widgetNamesFile.readAsStringSync(); + } + return result; +} + +/// Crawls the project and extracts widget names. +Future> extractWidgetNames(String libPath) async { + final completer = Completer>(); + + final contextLocator = ContextLocator(); + final contextRoots = contextLocator.locateRoots(includedPaths: [libPath]); + + final contextBuilder = ContextBuilder(); + await getFlutterSdkPath().then((path) { + final dartStr = '$path/bin/cache/dart-sdk'; + final analysisContext = contextBuilder.createContext( + contextRoot: contextRoots.first, + sdkPath: dartStr, + ); + + final classNames = {}; + + // Traverse all files in the lib folder + final libDirectory = Directory(libPath); + final dartFiles = libDirectory.listSync(recursive: true).where( + (file) => + file.path.endsWith('.dart') && !file.path.contains('.g.dart') && !file.path.contains('.freezed.dart'), + ); + + for (final file in dartFiles) { + final analysisSession = analysisContext.currentSession; + final parsedResult = analysisSession.getParsedUnit(file.path) as ParsedUnitResult; + + for (final compilationUnitMember in parsedResult.unit.declarations) { + if (compilationUnitMember is ClassDeclaration) { + final String name = compilationUnitMember.name.value().toString(); + if (!name.startsWith('_')) { + classNames.add(name); + } + } + } + } + + completer.complete(classNames); + }); + + return completer.future; +} + +/// Get the path to the Flutter SDK +Future getFlutterSdkPath() async { + final completer = Completer(); + + await Process.run('flutter', ['--version', '--machine']).then((result) { + if (result.exitCode != 0) { + throw Exception('Failed to run flutter command: ${result.stderr}'); + } + + final jsonData = jsonDecode(result.stdout.toString()) as Map; + + completer.complete(jsonData['flutterRoot'].toString()); + }); + + return completer.future; +} + +Future> readWidgetsFile(String filePath) async { + final completer = Completer>(); + final File file = File(filePath); + + await file.readAsString().then((text) { + // Split by lines + final linesList = text.split('\n'); + // Remove empty lines and comments + final linesSet = linesList.where((line) => line.isNotEmpty && !line.startsWith('#')).toSet(); + completer.complete(linesSet); + }); + + return completer.future; +} + +Future isWidgetNamesFileFresh() async { + final resultCompleter = Completer(); + + final libDirectory = Directory('lib'); + + await findNewestDartFileTimestamp(libDirectory).then((dateTime) { + final widgetNamesFile = File(_widgetNamesPath); + if (dateTime != null && widgetNamesFile.existsSync() && widgetNamesFile.lastModifiedSync().isAfter(dateTime)) { + resultCompleter.complete(true); + } else { + resultCompleter.complete(false); + } + }); + + return resultCompleter.future; +} + +Future findNewestDartFileTimestamp(Directory dir) async { + DateTime? newestTimestamp; + + if (!await dir.exists()) { + return null; + } + + await for (final FileSystemEntity entity in dir.list(recursive: true)) { + if (entity is File && entity.path.endsWith('.dart')) { + final DateTime lastModified = await entity.lastModified(); + + if (newestTimestamp == null || lastModified.isAfter(newestTimestamp)) { + newestTimestamp = lastModified; + } + } + } + + return newestTimestamp; +} diff --git a/lib/src/widget/src/git_diffs.dart b/lib/src/widget/src/git_diffs.dart new file mode 100644 index 0000000..2b281c4 --- /dev/null +++ b/lib/src/widget/src/git_diffs.dart @@ -0,0 +1,36 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; + +import 'package:approval_tests/src/widget/src/common.dart'; + +void printGitDiffs(String testDescription, String differences) { + print(topBar); + print("Results of git diff during approvalTest('$testDescription'):"); + print(differences.trim()); + print(bottomBar); +} + +/// return the diff of two files +String gitDiffFiles(File path0, FileSystemEntity path1) { + final processResult = Process.runSync('git', ['diff', '--no-index', path0.path, path1.path]); + + final stdoutString = processResult.stdout as String; + final stderrString = processResult.stderr as String; + + final processString = stdoutString.isNotEmpty || stderrString.isNotEmpty ? stdoutString : ''; + + return _stripGitDiff(processString); +} + +/// Format the git --diff if superfluous text +String _stripGitDiff(String multiLineString) { + bool startsWithAny(String line, List prefixes) => prefixes.any((prefix) => line.startsWith(prefix)); + + final List lines = multiLineString.split('\n'); + final List filteredLines = lines.where((line) => !startsWithAny(line, ['diff', 'index', '@@'])).toList(); + + final String result = filteredLines.join('\n'); + + return result; +} diff --git a/lib/src/widget/src/src.dart b/lib/src/widget/src/src.dart new file mode 100644 index 0000000..b5f3c48 --- /dev/null +++ b/lib/src/widget/src/src.dart @@ -0,0 +1,230 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:io'; + +import 'package:approval_tests/src/widget/src/common.dart'; +import 'package:approval_tests/src/widget/src/get_widget_names.dart'; +import 'package:approval_tests/src/widget/src/git_diffs.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/collect_widgets_meta_data.dart' as wmd; +import 'package:flutter_test/flutter_test.dart'; + +Set? _widgetNames; +const _approvedExtension = 'approved.txt'; +const _unapprovedExtension = 'unapproved.txt'; +final _executedApprovedFullPaths = {}; +bool _allTestsPassed = true; + +class Approved { + /// Initializes the approval test by building a database of project classes. + /// + /// Typically called from within flutter_tests function 'setUpAll' + static Future> setUpAll() async { + final completer = Completer>(); + await getWidgetNames().then((value) { + _widgetNames = value; + completer.complete(value); + }); + return completer.future; + } + + /// Performs checks after testing is complete. + /// + /// Checks performed + /// - If all tests passed, it confirms there are no unnecessary .approved.txt or .unapproved.txt files hanging around. + static Future tearDownAll() async { + if (!_allTestsPassed) return; + + final testPath = _testFilePath(); + final testDirectory = Directory(testPath); + final approvedFullPaths = testDirectory.filesWithExtension('.$_approvedExtension').map((file) => file.path).toSet(); + final unapprovedFullPaths = + testDirectory.filesWithExtension('.$_unapprovedExtension').map((file) => file.path).toSet(); + + for (final approvedFullPath in _executedApprovedFullPaths) { + if (approvedFullPaths.contains(approvedFullPath)) { + approvedFullPaths.remove(approvedFullPath); + } + final unapprovedFullPath = approvedFullPath.replaceAll(_approvedExtension, _unapprovedExtension); + if (unapprovedFullPaths.contains(unapprovedFullPath)) { + unapprovedFullPaths.remove(unapprovedFullPath); + } + } + + if (approvedFullPaths.isNotEmpty || approvedFullPaths.isNotEmpty) { + print(''' +topBar + The files listed below were generated by approvalTest but are no longer used:\n'''); + for (final approvedFullPath in approvedFullPaths) { + print(' $approvedFullPath'); + } + for (final unapprovedFullPath in unapprovedFullPaths) { + print(' $unapprovedFullPath'); + } + print(bottomBar); + } + + final completer = Completer(); + await getWidgetNames().then((value) { + _widgetNames = value; + completer.complete(null); + }); + return completer.future; + } + + static Set? get widgetNames => _widgetNames; +} + +Future approvalTest( + String testDescription, + String textForReview, +) async { + try { + final String outputPath = _testFilePath(); + + final approvedFullPath = '$outputPath/$testDescription.$_approvedExtension'; + final currentFullPath = '$outputPath/$testDescription.$_unapprovedExtension'; + String approvedText = ''; + + if (_executedApprovedFullPaths.contains(approvedFullPath)) { + _allTestsPassed = false; + throw Exception(''' +$topBar + An approvalTest with description '$testDescription' was already created in path '$outputPath'. + Try adding a unique description to approvedTest. E.g., + + await tester.approvalTest('my unique description'); +$bottomBar'''); + } + + _executedApprovedFullPaths.add(approvedFullPath); + + final approvedFile = File(approvedFullPath); + final currentFile = File(currentFullPath); + + if (approvedFile.existsSync()) { + approvedText = approvedFile.readAsStringSync(); + } else { + approvedFile.writeAsStringSync(approvedText.endWithNewline); + } + + if (approvedText == textForReview.endWithNewline) { + if (currentFile.existsSync()) { + currentFile.deleteSync(); + } + } else { + currentFile.writeAsStringSync(textForReview.endWithNewline); + + final differences = gitDiffFiles(approvedFile, currentFile); + + if (differences.isNotEmpty) { + _allTestsPassed = false; + printGitDiffs(testDescription, differences); + throw Exception("Approval test '$testDescription' failed. The file diff is listed above."); + } + } + } catch (e) { + print(e.toString()); + rethrow; + } +} + +/// [_globalApprovalTest] resolves the name conflict with [WidgetTester.approvalTest] +Future Function(String, String) _globalApprovalTest = approvalTest; + +extension WidgetTesterApprovedExtension on WidgetTester { + /// Returns the meta data for the widgets for comparison during the approval test + Future get widgetsString async { + final completer = Completer(); + assert(_widgetNames != null, ''' +$topBar + Looks like Approved.initialize() was not called before running an approvalTest. Typically, + this issue is solved by calling Approved.initialize() from within setUpAll: + + void setUpAll(() async { + await Approved.initialize(); + }); +$bottomBar'''); + + await wmd + .collectWidgetsMetaData( + this, + outputMeta: true, + verbose: false, + widgetNames: Approved.widgetNames, + ) + .then((stringList) { + completer.complete(stringList.join('\n')); + }); + + return completer.future; + } + + /// Performs an approval test. + /// + /// [description] is the name of the test. It is appended to the description in [Tester]. + /// [textForReview] is the meta data text used in the approval test. + Future approvalTest([String? description, String? textForReview]) async { + final resultCompleter = Completer(); + final widgetsMetaCompleter = Completer(); + final String updatedTestDescription = description == null ? testDescription : '$testDescription $description'; + + // Get the test path before the stack gets too deep. + _testFilePath(); + + // If no text passed, then get the widget meta from the widget tree + if (textForReview == null) { + await widgetsString.then((value) { + widgetsMetaCompleter.complete(value); + }); + } else { + widgetsMetaCompleter.complete(textForReview); + } + await widgetsMetaCompleter.future.then((value) { + resultCompleter.complete(_globalApprovalTest(updatedTestDescription, value)); + }); + return resultCompleter.future; + } + + /// Output expect statements to the console. + Future printExpects() => wmd.printExpects(this); +} + +/// Typically, .approved.txt files are stored alongside the flutter test file. However, there may be edge cases +/// where the path to the test cannot be determined because the stack is too deep. If so, create a local path for +/// storing .approved.txt +String _previousTestFilePath = './test/approved'; + +/// The path to the consumer's '..._test.dart' file that is executing the test +/// +/// Search the stacktrace from the calling ..._test.dart. If the file is not found, a previous path is used. +/// (This should never happen, but the logic is here just in case to prevent files written to the root directory.) +String _testFilePath() { + String? result; + + final stackTrace = StackTrace.current; + final lines = stackTrace.toString().split('\n'); + final pathLine = lines.firstWhere((line) => line.contains('_test.dart'), orElse: () => ''); + + if (pathLine.isNotEmpty) { + final match = RegExp(r'\(file:\/\/(.*\/)').firstMatch(pathLine); + if (match != null && match.groupCount > 0) { + result = Uri.parse(match.group(1)!).toFilePath(); + result = result.endsWith('/') ? result.substring(0, result.length - 1) : result; + } + } + + // If result is null, then "..._test.dart" filename not found, likely because stack was too deep, so use previous path. + if (result == null) { + result = _previousTestFilePath; + // make sure the path exists (e.g., could be "./approved") + final dir = Directory(result); + if (!dir.existsSync()) { + dir.createSync(); + } + } else { + _previousTestFilePath = result; + } + + return result; +} diff --git a/lib/src/widget/src/widget_meta/collect_widgets_meta_data.dart b/lib/src/widget/src/widget_meta/collect_widgets_meta_data.dart new file mode 100644 index 0000000..c2f1993 --- /dev/null +++ b/lib/src/widget/src/widget_meta/collect_widgets_meta_data.dart @@ -0,0 +1,549 @@ +import 'package:approval_tests/src/widget/src/widget_meta/expect_meta.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/load_string_en.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/matcher_types.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/register_types.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/widget_meta.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Set registeredNames = {}; + +const String instructions = '/// Replace your call to generateExpects with the code below.'; +List _previousWidgetMetas = []; +List _previousExpectStrings = []; +bool _isEnStringReverseLookupLoaded = false; +bool _isCommonTypesLoaded = false; +Map> _enStringReverseLookup = >{}; + +/// Output widget tests to the console. +/// +/// [widgetTypes] is a set of custom classes to generate expects for. +/// [pathToStrings] is the path to a json file containing string constants. E.g., generated by the intl package. +/// [silent] determines whether to suppress output to console. +/// [compareWithPrevious] determines whether only changed tests from the previous run are displayed +/// [verbose] determines whether the cut-and-paste tip is shown (should be false inside generated widgetTests) +Future printExpects( + WidgetTester tester, { + Set? widgetTypes, + Set? widgetNames, + String? pathToStrings, + bool silent = false, + bool verbose = true, + bool compareWithPrevious = true, +}) async { + final text = await collectWidgetsMetaData( + tester, + widgetTypes: widgetTypes, + widgetNames: widgetNames, + pathToStrings: pathToStrings, + silent: silent, + verbose: verbose, + compareWithPrevious: compareWithPrevious, + ); + + _outputText(text); +} + +Future _loadEnStringReverseLookupIfNecessary(String path) async { + if (!_isEnStringReverseLookupLoaded) { + _enStringReverseLookup = await loadEnStringReverseLookup(path); + } +} + +Future _loadCommonTypesIfNecessary(Set commonTypes) async { + if (!_isCommonTypesLoaded) { + registerTypes(commonTypes); + _isCommonTypesLoaded = true; + } +} + +/// Manually adds text to the reverse lookup map (instead of loading from file). +/// [markEnStringFileAsLoaded] as true blocks loads from file. This is primarily for testing. +Future addTextToIntlReverseLookup({ + required String stringId, + required String stringContent, + bool markEnStringFileAsLoaded = true, +}) async { + if (markEnStringFileAsLoaded) { + _isEnStringReverseLookupLoaded = true; + } + + addToReverseLookup( + reverseLookupMap: _enStringReverseLookup, + stringId: stringId, + stringContent: stringContent, + ); +} + +/// Outputs widget meta data as an expect statement or as text. +/// +/// [tester] is the tester passed from a flutter test. E.g., from within [testWidget] +/// [widgetTypes] is a set of widget types to match and include in a test. +/// [widgetNames] is a set of widget names to search for. (Specify [widgetTypes] or [widgetNames] but not both. +/// [pathToStrings] is the path to a json file containing string constants. E.g., generated by the intl package. +/// (This feature is currently not supported, but will be reinstated if requested.) +/// [silent ] is true to suppress output to console and file (should be false inside generated widgetTests). +/// [verbose] shows explanatory text that explains meta data. +/// [compareWithPrevious ] is true to compare the current states with previous to display diffs. +/// [outputExpects] is true to print expect statements to console. +/// [outputMeta] is true to output data to an approval-test file. +Future> collectWidgetsMetaData( + WidgetTester tester, { + Set? widgetTypes, + Set? widgetNames, + String? pathToStrings, + bool silent = false, + bool verbose = true, + bool compareWithPrevious = true, + bool? outputExpects, + bool? outputMeta, +}) async { + assert(outputExpects == null || outputMeta == null); + + registeredNames = widgetNames ?? {}; + + if (pathToStrings != null) { + await _loadEnStringReverseLookupIfNecessary(pathToStrings); + } + if (widgetTypes != null) { + await _loadCommonTypesIfNecessary(widgetTypes); + } + + final text = []; + + if (!compareWithPrevious) { + _previousWidgetMetas = []; + _previousExpectStrings = []; + } + + final widgets = _getWidgetsForExpects(tester, widgetNames ?? {}); + + if (widgets.isEmpty) { + text.add('No widgets found for approval testing.'); + } else { + text.addAll( + await _generateExpectsForWidgets( + widgets, + tester: tester, + verbose: verbose, + silent: silent, + outputType: (outputMeta ?? false) ? OutputType.widgetMeta : OutputType.expects, + ), + ); + } + + return text; +} + +Future> _generateExpectsForWidgets( + List widgets, { + required WidgetTester tester, + required bool verbose, + required bool silent, + required OutputType outputType, +}) async { + final text = []; + + final currentWidgetMetas = _widgetMetasFromWidgets(widgets); + final deltaWidgetMetas = _getDeltaWidgetMetas(currentWidgetMetas, _previousWidgetMetas); + currentWidgetMetas.addAll(deltaWidgetMetas); + final currentExpectStrings = _outputStringsFromWidgetMetas(currentWidgetMetas, outputType, verbose); + final deltaExpectStrings = _getDeltaExpectStrings(currentExpectStrings, _previousExpectStrings); + + if (!silent) { + if (deltaExpectStrings.isEmpty) { + if (_previousWidgetMetas.isEmpty) { + text.add('/// No widget with keys or custom types found to test'); + } else { + text.add("/// No changes to widget with keys or custom types since the prior call to 'generateExpects'"); + } + } else { + if (verbose && outputType == OutputType.expects) { + text.add(instructions); + } + text.addAll(deltaExpectStrings); + } + } + + _previousWidgetMetas = currentWidgetMetas; + _previousExpectStrings = currentExpectStrings; + + return text; +} + +List _getDeltaExpectStrings(List currentExpectStrings, List previousExpectStrings) { + final deltaExpectStrings = currentExpectStrings.where((item) => !previousExpectStrings.contains(item)).toList(); + + return deltaExpectStrings; +} + +/// Convert Widgets into [WidgetMeta]s +/// +/// Result contains no duplicates (because duplicate [WidgetMeta]s result in duplicate generated tests +List _widgetMetasFromWidgets(List widgets) { + final widgetMetas = []; + + for (final widget in widgets) { + // Ignore widgets Flutter adds with prefixes (e.g., "[key <") + if (widget.key == null || _isProperlyFormattedKey(widget)) { + final widgetMeta = WidgetMeta(widget: widget); + if (!widgetMetas.contains(widgetMeta)) { + widgetMetas.add(widgetMeta); + } + } + } + + return widgetMetas; +} + +bool _isProperlyFormattedKey(Widget widget) => widget.key.toString().indexOf('[<') == 0; + +/// The order to output expects +enum _ExpectTypeOrder { + noText, + intlText, + nonIntlText, +} + +enum OutputType { + expects, + widgetMeta; +} + +/// Generates expect() strings from [WidgetMeta]s. Sorts strings in order of [_ExpectTypeOrder] +List _outputStringsFromWidgetMetas( + List widgetMetas, + OutputType outputType, + bool verbose, +) { + final expectMetas = []; + final result = []; + + for (final widgetMeta in widgetMetas) { + final expectMetaFromWidgetMeta = _expectMetaFromWidgetMeta(widgetMeta); + expectMetas.add(expectMetaFromWidgetMeta); + } + + int sortOrder(ExpectMeta expectMeta) { + _ExpectTypeOrder result = _ExpectTypeOrder.noText; + if (expectMeta.widgetMeta.hasText) { + result = _ExpectTypeOrder.intlText; + if (!expectMeta.isIntl) { + result = _ExpectTypeOrder.nonIntlText; + } + } + return result.index; + } + + expectMetas.sort((a, b) => sortOrder(a).compareTo(sortOrder(b))); + + bool generatedNonIntlTextComment = false; + + for (final expectMeta in expectMetas) { + if (!generatedNonIntlTextComment && sortOrder(expectMeta) == 2) { + generatedNonIntlTextComment = true; + if (verbose) { + result.add('\t// No reverse lookup found for the text in the expect statements below'); + } + } + + late final List expectStringsFromWidgetMeta; + if (outputType == OutputType.expects) { + expectStringsFromWidgetMeta = _expectStringsFromExpectMeta(expectMeta); + } else if (outputType == OutputType.widgetMeta) { + expectStringsFromWidgetMeta = _metaStringsFromExpectMeta(expectMeta); + } + + result.addAll(expectStringsFromWidgetMeta); + } + + return result; +} + +void _outputText(List strings) { + for (final expectString in strings) { + debugPrint('\t$expectString'); + } +} + +List _getDeltaWidgetMetas(List currentWidgetMetas, List previousWidgetMetas) { + final deltaPreviousWidgetMetas = previousWidgetMetas.where((item) => !currentWidgetMetas.contains(item)).toList(); + + // Matchers may have changed for the previous tests (e.g., findsOneWidget may now be findNothing), so update + final updatedDeltaPreviousWidgetMetas = + deltaPreviousWidgetMetas.map((widgetMeta) => WidgetMeta(widget: widgetMeta.widget)).toList(); + + return updatedDeltaPreviousWidgetMetas; +} + +/// Get all the widgets of interest for testing (e.g., has keys, has text, is registered) +/// +/// Traverses the widget testing tree to build a list of widgets for testing. +/// +/// The returned list is in no particular order. +List _getWidgetsForExpects( + WidgetTester tester, + Set widgetNames, +) { + final widgets = []; + + bool isEmptyTextWidget(Widget widget) { + final bool result; + if (widget is Text && (widget.data == null || widget.data == '')) { + result = true; + } else { + result = false; + } + return result; + } + + bool isWidgetForExpect(Widget widget) { + final bool result; + if (isEmptyTextWidget(widget)) { + result = false; + } else { + result = (widget.key != null && (widget.key.toString().isCustomString || widget.key.toString().isEnumString)) || + registeredTypes.contains(widget.runtimeType) || + widgetNames.contains(widget.runtimeType.toString()) || + WidgetMeta.isTextEnabled(widget); + } + return result; + } + + for (final widget in tester.allWidgets) { + if (isWidgetForExpect(widget)) { + widgets.add(widget); + } + } + + return widgets; +} + +List _expectStringsFromExpectMeta(ExpectMeta expectMeta) { + final expects = []; + + // Number of attributes (e.g., Type, key, text) to match in expect + final int attributesToMatchCount = (expectMeta.widgetMeta.keyString.isNotEmpty ? 1 : 0) + + (expectMeta.widgetMeta.widgetText.isNotEmpty ? 1 : 0) + + (expectMeta.widgetMeta.isWidgetTypeRegistered ? 1 : 0); + + if (attributesToMatchCount >= 1) { + if (_haveEnString(expectMeta.widgetMeta.widgetText) || attributesToMatchCount >= 2) { + expects.addAll(_generateExpectWidgets(expectMeta.widgetMeta, attributesToMatchCount)); + } else { + expects.add(_generateExpect(expectMeta.widgetMeta)); + } + } + + return expects; +} + +List _metaStringsFromExpectMeta(ExpectMeta expectMeta) { + final expects = []; + + expects.add(_generateWidgetMeta(expectMeta.widgetMeta)); + + return expects; +} + +String _generateExpect(WidgetMeta widgetMeta) { + late final String generatedExpect; + + if (widgetMeta.keyString.isNotEmpty) { + if (widgetMeta.keyType == KeyType.enumValue) { + generatedExpect = + '\texpect(find.byKey(const ValueKey(${widgetMeta.keyString})), ${widgetMeta.matcherType.matcherName});'; + } else if (widgetMeta.keyType == KeyType.stringValueKey) { + generatedExpect = '\texpect(find.byKey(${widgetMeta.keyString}), ${widgetMeta.matcherType.matcherName});'; + } else { + throw Exception('Unexpected keyType'); + } + } else if (widgetMeta.widgetText.isNotEmpty) { + generatedExpect = "\texpect(find.text('${widgetMeta.widgetText}'), ${widgetMeta.matcherType.matcherName});"; + } else if (widgetMeta.isWidgetTypeRegistered) { + generatedExpect = '\texpect(find.byType(${widgetMeta.widgetType}), ${widgetMeta.matcherType.matcherName});'; + } else { + generatedExpect = '(Internal error. Expect not generated.)'; + } + + return generatedExpect; +} + +ExpectMeta _expectMetaFromWidgetMeta(WidgetMeta widgetMeta) { + final expectMeta = ExpectMeta(widgetMeta: widgetMeta); + + if (widgetMeta.widgetText.isNotEmpty) { + if (_haveEnString(widgetMeta.widgetText)) { + expectMeta.intlKeys = _enStringReverseLookup[widgetMeta.widgetText]; + } + } + + return expectMeta; +} + +List _generateExpectWidgets( + WidgetMeta widgetMeta, + int attributesToMatch, +) { + final buffer = StringBuffer(); + const intlPlaceHolder = '__INTL_PLACE_HOLDER__'; + List? intlKeys; + int attributesWrittenToBuffer = 0; + + void addTextAttributeToBuffer() { + if (_haveEnString(widgetMeta.widgetText)) { + intlKeys = _enStringReverseLookup[widgetMeta.widgetText]; + if (intlKeys != null) { + buffer.write("intl: (s) => s.$intlPlaceHolder"); + } + } else { + buffer.write("text: '${widgetMeta.widgetText}'"); + } + } + + void addTypeAttributeToBuffer() { + buffer.write('widgetType: ${widgetMeta.widgetType}'); + } + + void addKeyAttributeToBuffer() { + buffer.write("key: ${widgetMeta.keyString}"); + } + + void addMatcherAttributeToBuffer() { + buffer.write(', matcher: ${widgetMeta.matcherType.matcherName},'); + } + + bool haveMoreAttributesToProcess() => ++attributesWrittenToBuffer < attributesToMatch; + + buffer.write('\ttester.expectWidget('); + + if (widgetMeta.widgetText.isNotEmpty) { + addTextAttributeToBuffer(); + if (haveMoreAttributesToProcess()) { + buffer.write(', '); + } + } + + if (widgetMeta.isWidgetTypeRegistered) { + addTypeAttributeToBuffer(); + if (haveMoreAttributesToProcess()) { + buffer.write(', '); + } + } + + if (widgetMeta.keyString.isNotEmpty) { + addKeyAttributeToBuffer(); + } + + if (widgetMeta.matcherType.matcher != findsOneWidget) { + addMatcherAttributeToBuffer(); + } + + buffer.write(');'); + + final result = []; + + if (intlKeys == null) { + result.add(buffer.toString()); + } else { + final bufferString = buffer.toString(); + if (intlKeys!.length > 1) { + result.add('\t// Multiple matches for "${widgetMeta.widgetText}" in string_en.json. Pick one.'); + } + for (final intlKey in intlKeys!) { + result.add(bufferString.replaceAll(intlPlaceHolder, intlKey)); + } + if (intlKeys!.length > 1) { + result.add('\t// (End of matches)'); + } + } + + return result; +} + +String _generateWidgetMeta( + WidgetMeta widgetMeta, +) { + final buffer = StringBuffer(); + bool isFirstAttribute = true; + + void addCommaIfNecessary() { + if (isFirstAttribute) { + isFirstAttribute = false; + } else { + buffer.write(', '); + } + } + + void addTextAttributeToBuffer() { + addCommaIfNecessary(); + buffer.write("text: '${widgetMeta.widgetText}'"); + } + + void addKeyAttributeToBuffer() { + addCommaIfNecessary(); + buffer.write("key: ${widgetMeta.keyString}"); + } + + void addMatcherAttributeToBuffer() { + addCommaIfNecessary(); + buffer.write('count: '); + if (widgetMeta.matcherType == MatcherTypes.findsNothing) { + buffer.write('0'); + } else if (widgetMeta.matcherType == MatcherTypes.findsOneWidget) { + buffer.write('1'); + } else if (widgetMeta.matcherType == MatcherTypes.findsWidgets) { + buffer.write('many'); + } + } + + buffer.write('${widgetMeta.widgetType}: {'); + + if (widgetMeta.keyString.isNotEmpty) { + addKeyAttributeToBuffer(); + } + + if (widgetMeta.widgetText.isNotEmpty) { + addTextAttributeToBuffer(); + } + + addMatcherAttributeToBuffer(); + + buffer.write('}'); + + final result = buffer.toString(); + + return result; +} + +bool _haveEnString(dynamic key) => _enStringReverseLookup.containsKey(key); + +/// Meta data for gestures +/// Per EWP-1519, this is a work in progress +class _GestureMeta { + _GestureMeta(this.keyword, this.gestureCallbackName); + + String keyword; + String gestureCallbackName; + + static List<_GestureMeta> get all => <_GestureMeta>[ + _GestureMeta('button', 'onTap'), + _GestureMeta('toggle', 'onTap'), + ]; +} + +/// Get the gesture associated with the widget key name. E.g., [keyName] containing "button" returns "tap" +String? getGesture(String keyName) { + String? gestureName; + + if (keyName.isNotEmpty) { + final keyNameLowerCase = keyName.toLowerCase(); + for (final gestureMeta in _GestureMeta.all) { + if (keyNameLowerCase.contains(gestureMeta.keyword)) { + gestureName = gestureMeta.gestureCallbackName; + } + } + } + + return gestureName; +} diff --git a/lib/src/widget/src/widget_meta/expect_meta.dart b/lib/src/widget/src/widget_meta/expect_meta.dart new file mode 100644 index 0000000..382ed71 --- /dev/null +++ b/lib/src/widget/src/widget_meta/expect_meta.dart @@ -0,0 +1,18 @@ +import 'package:approval_tests/src/widget/src/widget_meta/widget_meta.dart'; + +/// Meta data for generating an expect() statement +class ExpectMeta { + ExpectMeta({required this.widgetMeta}); + + /// Keys found in string_en.json + List? intlKeys; + + /// The [WidgetMeta] instance associated with this instance (1-to-1) + final WidgetMeta widgetMeta; + + /// True if entry in string_en.json found + bool get isIntl => intlKeys != null; + + /// True if multiple entries in string_en.json found + bool get hasMultipleIntlKeys => isIntl && intlKeys!.length > 1; +} diff --git a/lib/src/widget/src/widget_meta/load_string_en.dart b/lib/src/widget/src/widget_meta/load_string_en.dart new file mode 100644 index 0000000..24956a2 --- /dev/null +++ b/lib/src/widget/src/widget_meta/load_string_en.dart @@ -0,0 +1,22 @@ +Future>> loadEnStringReverseLookup(String path) async => >{}; + +void addToReverseLookup({ + required Map> reverseLookupMap, + required String stringId, + required String stringContent, +}) { + // Swap key/value so that text keys its original ID + if (!reverseLookupMap.containsKey(stringContent)) { + reverseLookupMap[stringContent] = []; + } + reverseLookupMap[stringContent]?.add(stringId); + + // Add an all-caps text for the special case when code further modifies text with .toUpperCase() + final valueUpperCase = stringContent.toUpperCase(); + if (stringContent != valueUpperCase) { + if (!reverseLookupMap.containsKey(valueUpperCase)) { + reverseLookupMap[valueUpperCase] = []; + } + reverseLookupMap[valueUpperCase]?.add('$stringId.toUpperCase()'); + } +} diff --git a/lib/src/widget/src/widget_meta/matcher_types.dart b/lib/src/widget/src/widget_meta/matcher_types.dart new file mode 100644 index 0000000..cff9d4b --- /dev/null +++ b/lib/src/widget/src/widget_meta/matcher_types.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// Types of matchers used in 'expect' +enum MatcherTypes { + findsNothing, + findsOneWidget, + findsWidgets, + unknown, +} + +extension MatcherTypesExtension on MatcherTypes { + Matcher get matcher { + switch (this) { + case MatcherTypes.findsNothing: + return findsNothing; + case MatcherTypes.findsOneWidget: + return findsOneWidget; + case MatcherTypes.findsWidgets: + return findsWidgets; + default: + return findsNothing; + } + } + + String get matcherName { + switch (this) { + case MatcherTypes.findsNothing: + return 'findsNothing'; + case MatcherTypes.findsOneWidget: + return 'findsOneWidget'; + case MatcherTypes.findsWidgets: + return 'findsWidgets'; + case MatcherTypes.unknown: + default: + return '(Unknown)'; + } + } +} diff --git a/lib/src/widget/src/widget_meta/register_types.dart b/lib/src/widget/src/widget_meta/register_types.dart new file mode 100644 index 0000000..b802345 --- /dev/null +++ b/lib/src/widget/src/widget_meta/register_types.dart @@ -0,0 +1,6 @@ +Set registeredTypes = {}; + +/// Register class types for tests with Find.byType +void registerTypes(Set classTypes) { + registeredTypes.addAll(classTypes); +} diff --git a/lib/src/widget/src/widget_meta/widget_meta.dart b/lib/src/widget/src/widget_meta/widget_meta.dart new file mode 100644 index 0000000..17c486b --- /dev/null +++ b/lib/src/widget/src/widget_meta/widget_meta.dart @@ -0,0 +1,161 @@ +import 'package:approval_tests/src/widget/src/widget_meta/collect_widgets_meta_data.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/matcher_types.dart'; +import 'package:approval_tests/src/widget/src/widget_meta/register_types.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Meta data for widget selected for inclusion in tests +class WidgetMeta { + WidgetMeta({ + required this.widget, + }) { + _updateWidgetKey(); + widgetType = widget.runtimeType; + isWidgetTypeRegistered = registeredTypes.contains(widgetType) || registeredNames.contains(widgetType.toString()); + _updateWidgetText(); + _updateMatcher(); + + assert(keyString.isNotEmpty || isWidgetTypeRegistered || widgetText.isNotEmpty, 'WidgetMeta widget is invalid'); + } + + @override + bool operator ==(Object other) { + if (other is WidgetMeta) { + return keyString == other.keyString && widgetText == other.widgetText && widgetType == other.widgetType; + } else { + return false; + } + } + + @override + int get hashCode => widget.hashCode; + + final Widget widget; + late KeyType keyType; + + /// These fields are used repeatedly so their values are cached + late MatcherTypes matcherType; + + /// Text value of the key scraped from the widget tree and trimmed + late final String keyString; + + /// Text value of the widget if a Text widget or similar. E.g., Text(); + late final String widgetText; + + /// The type of widget + late final Type widgetType; + + /// True if user registered this widget type + late final bool isWidgetTypeRegistered; + + /// If widget has text, get it + void _updateWidgetText() { + if (widget is Text) { + final text = widget as Text; + widgetText = text.data ?? ''; + } else if (widget is TextSpan) { + final text = widget as TextSpan; + widgetText = text.text ?? ''; + } else { + widgetText = ''; + } + } + + bool get hasText => isTextEnabled(widget); + + static bool isTextEnabled(Widget widget) { + final runtimeType = widget.runtimeType; + return runtimeType == Text || runtimeType == TextSpan; + } + + /// Perform a test on the widget and store its result + void _updateMatcher() { + matcherType = MatcherTypes.unknown; + + for (final currentMatcherType in MatcherTypes.values) { + try { + expect( + find.byWidgetPredicate( + (w) => + (keyString.isEmpty || w.key == widget.key!) && + (widgetText.isEmpty || w is Text && w.data == widgetText) && + (!isWidgetTypeRegistered || w.runtimeType == widgetType), + ), + currentMatcherType.matcher, + ); + + // if here, expect didn't throw, so we have our matcher type + matcherType = currentMatcherType; + break; + } catch (e) { + // Do nothing. Ignore tests that fail + } + } + } + + /// Parse the string key back into its keysClass.keyName format + /// + /// If there is 1 word in the widgetKey, it's a an enum key (MyEnum.keyName). + /// If there are 2 words in the widgetKey, it's a field key (keyClass.keyName). + /// If there are 3 words, it's a function name (keyClass.keyName(index)). + /// + /// Widget keys without the Enzo '__' delimiter return an empty string. + /// + /// Note that flutter adds a prefix ('[<') and suffix ('>]') to keys that must be removed. + void _updateWidgetKey() { + if (widget.key == null) { + keyString = ''; + } else { + final originalWidgetKey = widget.key.toString(); + if (_isWidgetKeyProperlyFormatted(originalWidgetKey)) { + final strippedWidgetKey = originalWidgetKey.replaceAll("'", ''); + final startIndex = strippedWidgetKey.indexOf('[<'); + final endIndex = strippedWidgetKey.indexOf('>]'); + final trimmedWidgetKey = strippedWidgetKey.substring(startIndex + 2, endIndex); + final words = trimmedWidgetKey.split(RegExp("__|_")); + words.removeWhere((word) => word == ''); + + if (words.length == 1) { + final word = words[0]; + if (word.contains('.')) { + keyType = KeyType.enumValue; + keyString = word; + } else { + keyType = KeyType.stringValueKey; + keyString = "'$word'"; + } + } else if (words.length == 2) { + keyType = KeyType.stringValueKey; + keyString = '${words[0]}.${words[1]}'; + } else if (words.length == 3) { + keyType = KeyType.functionValueKey; + keyString = '${words[0]}.${words[1]}(${words[2]})'; + } else { + /// If here, must be an unsupported key. Do nothing + keyType = KeyType.unknown; + keyString = ''; + } + } else { + keyString = ''; + } + } + } + + bool _isWidgetKeyProperlyFormatted(String originalWidgetKey) => + (originalWidgetKey.isCustomString || originalWidgetKey.isEnumString || originalWidgetKey.isValueKeyString) && + originalWidgetKey.contains('[<') && + originalWidgetKey.contains('>]'); +} + +extension KeyString on String { + bool get isCustomString => this.contains('__'); + bool get isEnumString => this.contains('_') == false && this.contains('.') == true; + bool get isValueKeyString => this.startsWith('[<') && this.endsWith('>]'); +} + +enum KeyType { + enumValue, // String represents an enum, NOT a ValueKey() + stringValueKey, // String represents a ValueKey() + functionValueKey, // String represents a ValueKey() + unknown; +} diff --git a/lib/src/widget/src/widget_meta/widget_tester_extension.dart b/lib/src/widget/src/widget_meta/widget_tester_extension.dart new file mode 100644 index 0000000..ab936b3 --- /dev/null +++ b/lib/src/widget/src/widget_meta/widget_tester_extension.dart @@ -0,0 +1,99 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Holds the value of the class that has members that are strings. Typically, this is the S class in i18n.dart +/// of the intl package. However, it can by any class of the structure: +/// +/// class MyTextHolder { +/// static String title => 'The Book of Avocados'; +/// static String body => 'Once upon a time there was an avocado.... +/// } +/// +dynamic _s; + +extension WidgetTesterExtension on WidgetTester { + /// Set a class that has members that are strings (see [_s] for more detail) + static set s(dynamic value) => _s = value; + + /// Get the class that has members that are strings (see [_s] for more detail) + static dynamic get s => _s; + + String _getStringFromFinder( + Finder finder, + String Function(dynamic s) intl, + ) => + intl(_s); + + /// Returns a [Finder] for Widgets that match one or more parameters + /// + /// [intl] receives the `intl` package `S` object and returns the String to find. + /// [text] is a String to find. + /// [widgetType] is the Type of widget to find. + /// [key] is the key to find. + /// + /// You can pass no String, [intl], or [text], but not both. + /// If [key] and [widgetType] are BOTH null, [widgetType] is assumed to be Text + Finder findBy({ + String Function(dynamic s)? intl, + String? text, + Type? widgetType, + Key? key, + }) { + assert(intl == null || text == null); + + final Type? soughtType = key == null && widgetType == null ? Text : widgetType; + + late Finder finder; + + if (key != null) { + finder = find.byKey(key); + if (soughtType == Text && (intl != null || text != null)) { + final String widgetText = text ?? _getStringFromFinder(finder, intl!); + expect(find.text(widgetText).evaluate(), finder.evaluate()); + } + } else { + finder = find.byType(soughtType!); + if (intl != null || text != null) { + final String widgetText = text ?? _getStringFromFinder(finder, intl!); + if (soughtType == Text) { + finder = find.text(widgetText); + } + } + } + + return finder; + } + + /// See [findBy] for param descriptions. + /// + /// Usage: + /// + /// expectWidget(intl: (s) => s.someText, key: MyWidgetKeys.someText) + /// + void expectWidget({ + String Function(dynamic s)? intl, + String? text, + Type? widgetType, + Key? key, + Matcher matcher = findsOneWidget, + }) { + final Finder finder = findBy(intl: intl, text: text, widgetType: widgetType, key: key); + expect(finder, matcher); + } + + /// See [findBy] for param descriptions. + Future tapWidget({ + required String Function(dynamic s) intl, + String? text, + Type? widgetType, + Key? key, + bool shouldPumpAndSettle = true, + }) async { + final Finder finder = findBy(intl: intl, text: text, widgetType: widgetType, key: key); + expect(finder, findsOneWidget); + await tap(finder); + if (shouldPumpAndSettle) { + await pumpAndSettle(); + } + } +}