diff --git a/.gitignore b/.gitignore index 1fd350a0..319ad5f7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ doc/api/ # screenshots example/ios/fastlane example/android/fastlane +**/**/*-diff.png # mac .DS_Store diff --git a/.travis.yml b/.travis.yml index 3ed3eb82..b38eecb7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -90,9 +90,8 @@ jobs: - pub global activate --source path . script: - - cd example; screenshots + - cd example; screenshots -c screenshots_CI.yaml # copy artifacts to cache for later deploy - - tar cvzf $HOME/screenshots/screenshots.tar.gz ios/fastlane/screenshots android/fastlane/metadata/android/*/images - zip -r $HOME/screenshots/screenshots.zip ios/fastlane/screenshots android/fastlane/metadata/android/*/images # deploy artifacts if tagged commit @@ -103,6 +102,5 @@ jobs: secure: wyPNNbjTFChWOGc/JiTpGhN490dRzz/qhU2T3CddZALjy4VN3LywennK3xnTOAq+FEYE9H/quP/SxkUX154al/lxeL6QuN5D0Ev2bL3lS9jyaoe0NOKx5GnNTzfv84taZPi768UF4rgYqzzdF8WJTCe0dlvDH7qKgH+dHIZGoB1dM/hhWMEXUv0uAZuFDkepxWHOLHsIABunkz428MEsSRCTdEWOsgdFiEl+DOC5ErmorgHazUWPpSwenz13kCLhU+wT2Fsek5tGBO6GT1Mvw8qrht3LUZBaBQJfx4yhdXQKtq0Dr+gI9a3sbF/3TKV0nRvDVA+KGmMLHT+fkRrz1xkGvrLnCDfkylDZlmn/IoQUkv4JwI+lJIXfUp40pMmSlFH1WKToWSjMsPSxv02fVYzxNZoxlno+qyKk4lfdROOSSYS5LjmMd+Lrvhmx7vNMCHl57fdXdKwgyJllxT/khMZTJv5IPQih1yi3m/hDw0s59IHYd22QHFoodcdAPy2xxeVh8VhzhucpesWAvoFZfgdTmPZXAzpMR4kEaeBb5f3Z/Eg3AypDPXg67kXwFqTRL+ZqDzOFynZYJML8RbsZd/nqU5TYc0Ocmh0YMA3v0Z43wuZMshXOXujl8z3zmnwzV/QmFP0U/phOGa9SmvKtRyGQoTGtIXoPWdXrRpgm3F4= file: - $HOME/screenshots/screenshots.zip - - $HOME/screenshots/screenshots.tar.gz on: tags: true diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bb151f..05eaa595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## 1.2.0 +- Added archive feature to collect screenshots of all runs for reporting, etc... #77 #81 +- Improved detection of adb path #79 + +## 1.1.1 +- Fixed localization issue in test #19, #20 +- Improved handling of locale for emulators and real devices + +## 1.1.0 +- Added record/compare feature to compare screenshots with previously recorded screenshots during a run. #65 + +## 1.0.2 +- Fixed bug with parsing ios simulator info #73 + ## 1.0.1 - Fixed pedantic lint errors @@ -46,7 +60,7 @@ - Bypasses changing locales if running in only one locale - Issues warning about running flutter driver in multiple locales - See issue: https://github.com/flutter/flutter/issues/27785 for details. + See issue: for details. ## 0.1.3 diff --git a/README.md b/README.md index 88215824..563b9760 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Select the devices you want to run on, using a convenient config file. _Screensh _Screenshots_ runs your tests on both iOS and Android in one run. (as opposed to making separate Snapshots and Screengrab runs) 1. One run for multiple locales -If your app supports multiple locales, _Screenshots_ will optionally set the locales listed in the config file before running each test (see Limitations below). +If your app supports multiple locales, _Screenshots_ will optionally set the locales listed in the config file before running each test. 1. One run for frames Optionally places images in device frames in same run. (as opposed to making separate FrameIt runs... which supports iOS only) @@ -86,6 +86,22 @@ Or, if using a config file other than the default 'screenshots.yaml': ```` $ screenshots -c ```` +Other options: +``` +$ screenshots -h +usage: screenshots [-h] [-c ] [-m ] + +sample usage: screenshots + +-c, --config= Path to config file. + (defaults to "screenshots.yaml") + +-m, --mode= If mode is recording, screenshots will be saved for later comparison. + If mode is archive, screenshots will be archived and cannot be uploaded via fastlane. + [normal (default), recording, comparison, archive] + +-h, --help Display this help information. +``` # Modifying your tests for _Screenshots_ A special function is provided in the _Screenshots_ package that is called by the test each time you want to capture a screenshot. @@ -103,11 +119,11 @@ To capture screenshots in your tests: ```` 2. Create the config at start of test ````dart - final config = Config().configInfo; + final configInfo = Config().configInfo; ```` 3. Throughout the test make calls to capture screenshots ````dart - await screenshot(driver, config, 'myscreenshot1'); + await screenshot(driver, configInfo, 'myscreenshot1'); ```` Note: make sure your screenshot names are unique across all your tests. @@ -169,6 +185,37 @@ Individual devices can be configured in `screenshots.yaml` by specifying per dev Note: images generated for those devices where framing is disabled are probably not suitable for upload, but can be used for local review. +# Record/Compare Mode +_Screenshots_ can be used to monitor any unexpected changes to the UI by comparing the new screenshots to previously recorded screenshots. Any differences will be highlighted in a 'diff' image for review. + +To use this feature: +1. Add the location of your recording directory to a `screenshots.yaml` + ```yaml + recording: /tmp/screenshots_record + ``` +1. Run a recording to capture your screenshots: + ``` + screenshots -m recording + ``` +1. Run subsequent _Screenshots_ with: + ``` + screenshots -m comparison + ``` + _Screenshots_ will compare the new screenshots with the recorded screenshots and generate a 'diff' image for each screenshot that does not compare. The diff image highlights the differences in red. + +# Archive Mode +To generate screenshots for local use, such as generating reports of changes to UI over time, etc... use 'archive' mode. + +To enable this mode: +1. Add the location of your archive directory to screenshots.yaml: + ```yaml + archive: /tmp/screenshots_archive + ``` +1. Run _Screenshots_ with: + ```` + $ screenshots -m archive + ```` + # Integration with Fastlane Since _Screenshots_ is intended to be used with Fastlane, after _Screenshots_ completes, the images can be found in your project at: ```` @@ -191,7 +238,7 @@ To change the devices to run your tests on, just change the list of devices in s Make sure each device you select has a supported screen and a corresponding attached device or installed emulator/simulator. To bypass -this requirement use `frame: false` for each related device in your +the supported screen requirement use `frame: false` for each related device in your screenshots.yaml. For each selected device: @@ -227,22 +274,6 @@ https://github.com/mmcc007/screenshots/releases/ * Running _Screenshots_ in the cloud is useful for automating the generation of your screenshots in a CI/CD environment. * Running _Screenshots_ on macOS in the cloud can be used to generate your screenshots when developing on Linux and/or Windows (if not using locally attached iOS devices). -# Limitations - -Due to a Flutter issue ([flutter/issues/27785](https://github.com/flutter/flutter/issues/27785)), running _Screenshots_ in multiple locales has limitations. - -To raise priority of this Flutter issue, so it will be fixed sooner rather than later, please give a thumbs-up on [flutter/issues/27785](https://github.com/flutter/flutter/issues/27785). - -Priority of this limitation in Flutter project: - -| Date | `flutter driver` | `internationalization` | `test` | -| --- | --- | --- | --- | -| 4/26/2019 | [#1](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22t%3A+flutter+driver%22+) | [#5](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22a%3A+internationalization%22+) | [#7](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22a%3A+tests%22+) | -| 5/25/2019 | [#1](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22t%3A+flutter+driver%22+) | [#3](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22a%3A+internationalization%22+) | [#6](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22a%3A+tests%22+) | -| 6/29/2019 | [#1](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22t%3A+flutter+driver%22+) | [#1](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22a%3A+internationalization%22+) | [#3](https://github.com/flutter/flutter/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22a%3A+tests%22+) | - -(This limitation is being tracked by _screenshots_ in [screenshots/issues/20](https://github.com/mmcc007/screenshots/issues/20)). - # Issues and Pull Requests [Issues](https://github.com/mmcc007/screenshots/issues) and [pull requests](https://github.com/mmcc007/screenshots/pulls) are welcome. diff --git a/bin/main.dart b/bin/main.dart index aee0a60d..3641324c 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -2,14 +2,17 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:screenshots/screenshots.dart'; +import 'package:path/path.dart' as path; -const usage = 'usage: screenshots [--help] [--config ]'; +const usage = + 'usage: screenshots [-h] [-c ] [-m ]'; const sampleUsage = 'sample usage: screenshots'; void main(List arguments) async { ArgResults argResults; final configArg = 'config'; + final modeArg = 'mode'; final helpArg = 'help'; final ArgParser argParser = ArgParser(allowTrailingOptions: false) ..addOption(configArg, @@ -17,14 +20,27 @@ void main(List arguments) async { defaultsTo: 'screenshots.yaml', help: 'Path to config file.', valueHelp: 'screenshots.yaml') + ..addOption(modeArg, + abbr: 'm', + defaultsTo: 'normal', + help: + 'If mode is recording, screenshots will be saved for later comparison. \nIf mode is archive, screenshots will be archived and cannot be uploaded via fastlane.', + allowed: ['normal', 'recording', 'comparison', 'archive'], + valueHelp: 'normal|recording|comparison|archive') ..addFlag(helpArg, - help: 'Display this help information.', negatable: false); + abbr: 'h', help: 'Display this help information.', negatable: false); try { argResults = argParser.parse(arguments); } on ArgParserException catch (e) { _handleError(argParser, e.toString()); } + // show help + if (argResults[helpArg]) { + _showUsage(argParser); + exit(0); + } + // confirm os switch (Platform.operatingSystem) { case 'windows': @@ -55,28 +71,15 @@ void main(List arguments) async { exit(1); } - // check adb is in path - if (!cmd('sh', ['-c', 'which adb && echo adb || echo not installed'], '.', - true) - .toString() - .contains('adb')) { - stderr.write( - '#############################################################\n'); - stderr.write("# 'adb' must be in the PATH to use Screenshots\n"); - stderr.write("# You can usually add it to the PATH using\n" - "# export PATH='~/Library/Android/sdk/platform-tools:\$PATH' \n"); - stderr.write( - '#############################################################\n'); - exit(1); - } + // check adb is found + getAdbPath(); // validate args - final file = File(argResults[configArg]); - if (!await file.exists()) { + if (!await File(argResults[configArg]).exists()) { _handleError(argParser, "File not found: ${argResults[configArg]}"); } - await run(argResults[configArg]); + await run(argResults[configArg], argResults[modeArg]); } void _handleError(ArgParser argParser, String msg) { @@ -90,3 +93,27 @@ void _showUsage(ArgParser argParser) { print(argParser.usage); exit(2); } + +/// Path to the `adb` executable. +String getAdbPath() { + final String androidHome = Platform.environment['ANDROID_HOME'] ?? + Platform.environment['ANDROID_SDK_ROOT']; + if (androidHome == null) { + throw 'The ANDROID_SDK_ROOT and ANDROID_HOME environment variables are ' + 'missing. At least one of these variables must point to the Android ' + 'SDK directory containing platform-tools.'; + } + final String adbPath = path.join(androidHome, 'platform-tools/adb'); + final absPath = path.absolute(adbPath); + if (!File(adbPath).existsSync()) { + stderr.write( + '#############################################################\n'); + stderr.write("# 'adb' must be in the PATH to use Screenshots\n"); + stderr.write("# You can usually add it to the PATH using\n" + "# export PATH='\$HOME/Library/Android/sdk/platform-tools:\$PATH' \n"); + stderr.write( + '#############################################################\n'); + exit(1); + } + return absPath; +} diff --git a/example/screenshots.yaml b/example/screenshots.yaml index c97ed96f..fa54a0bb 100644 --- a/example/screenshots.yaml +++ b/example/screenshots.yaml @@ -7,11 +7,8 @@ tests: staging: /tmp/screenshots # A list of locales supported in app -# Note: while support for multiple locales has been implemented in `screenshots`, -# non-default locales currently do not work due to flutter bug. -# See open issue: https://github.com/flutter/flutter/issues/27785 for details. locales: -# - fr-CA + - fr-CA - en-US # A list of devices to run tests on @@ -29,4 +26,14 @@ devices: orientation: LandscapeRight # Frame screenshots -frame: true \ No newline at end of file +frame: true + +# Run mode can be one of 'normal' (default), 'recording', 'comparison' or 'archive'. + +# If run mode is 'recording' or 'comparison', a directory is required for recorded images. +recording: /tmp/screenshots_record + +# If not intending to upload screenshots, images can be stored in an archive dir. +# This over-rides output to fastlane dirs. +# If run mode is 'archive', a directory is required for archived images. +archive: /tmp/screenshots_archive \ No newline at end of file diff --git a/example/screenshots_CI.yaml b/example/screenshots_CI.yaml new file mode 100644 index 00000000..8345aa4e --- /dev/null +++ b/example/screenshots_CI.yaml @@ -0,0 +1,24 @@ +# Screen capture tests +# Note: flutter driver expects a pair of files eg, main.dart and main_test.dart +tests: + - test_driver/main.dart + +# Interim location of screenshots from tests before processing +staging: /tmp/screenshots + +# A list of locales supported in app +locales: + - fr-CA + - en-US + +# A list of devices to run tests on +devices: + ios: + iPhone XS Max: + iPad Pro (12.9-inch) (2nd generation): + frame: false + android: +# Nexus 6P: + +# Frame screenshots +frame: true \ No newline at end of file diff --git a/example/test_driver/main.dart b/example/test_driver/main.dart index af2d453c..aa698f18 100644 --- a/example/test_driver/main.dart +++ b/example/test_driver/main.dart @@ -1,11 +1,28 @@ +import 'dart:async'; +import 'dart:convert' as c; +import 'dart:ui' as ui; + import 'package:example/main.dart'; import 'package:flutter/material.dart'; import 'package:flutter_driver/driver_extension.dart'; +import 'package:intl/intl.dart'; void main() { + final DataHandler handler = (_) async { + final localizations = + await ExampleLocalizations.load(Locale(ui.window.locale.languageCode)); + final response = { + 'counterIncrementButtonTooltip': + localizations.counterIncrementButtonTooltip, + 'counterText': localizations.counterText, + 'title': localizations.title, + 'locale': Intl.defaultLocale + }; + return Future.value(c.jsonEncode(response)); + }; // Enable integration testing with the Flutter Driver extension. // See https://flutter.io/testing/ for more info. - enableFlutterDriverExtension(); + enableFlutterDriverExtension(handler: handler); WidgetsApp.debugAllowBannerOverride = false; // remove debug banner runApp(MyApp()); } diff --git a/example/test_driver/main_test.dart b/example/test_driver/main_test.dart index 7eaa700b..04cc486e 100644 --- a/example/test_driver/main_test.dart +++ b/example/test_driver/main_test.dart @@ -7,15 +7,20 @@ import 'package:flutter_driver/flutter_driver.dart'; import 'package:screenshots/screenshots.dart'; import 'package:test/test.dart'; +import 'dart:convert' as c; void main() { group('end-to-end test', () { FlutterDriver driver; + Map localizations; final config = Config().configInfo; setUpAll(() async { // Connect to a running Flutter application instance. driver = await FlutterDriver.connect(); + // get the localizations for the current locale + localizations = c.jsonDecode(await driver.requestData(null)); + print('localizations=$localizations'); }); tearDownAll(() async { @@ -24,7 +29,8 @@ void main() { test('tap on the floating action button; verify counter', () async { // Finds the floating action button (fab) to tap on - SerializableFinder fab = find.byTooltip('Increment'); + SerializableFinder fab = + find.byTooltip(localizations['counterIncrementButtonTooltip']); // Wait for the floating action button to appear await driver.waitFor(fab); diff --git a/lib/src/archive.dart b/lib/src/archive.dart new file mode 100644 index 00000000..ac8aa95a --- /dev/null +++ b/lib/src/archive.dart @@ -0,0 +1,24 @@ +import 'globals.dart'; +import 'utils.dart' as utils; + +/// Archive screenshots generated by a run. +class Archive { + static final _timeStamp = getTimestamp(); + + final _stagingTestDir; + final archiveDirPrefix; + + Archive(String stagingDir, String archiveDir) + : _stagingTestDir = '$stagingDir/$kTestScreenshotsDir', + archiveDirPrefix = '$archiveDir/$_timeStamp'; + + String dstDir(DeviceType deviceType, String locale) => + '$archiveDirPrefix/${utils.getStringFromEnum(deviceType)}/$locale'; +} + +/// Generates timestamp as [DateTime] in milliseconds +DateTime getTimestamp() { + final timestamp = DateTime.fromMillisecondsSinceEpoch( + DateTime.now().millisecondsSinceEpoch); + return timestamp; +} diff --git a/lib/src/capture_screen.dart b/lib/src/capture_screen.dart index 6afe1232..0a33dbc5 100644 --- a/lib/src/capture_screen.dart +++ b/lib/src/capture_screen.dart @@ -1,17 +1,18 @@ import 'dart:async'; import 'dart:io'; -/// +import 'globals.dart'; + /// Called by integration test to capture images. -/// Future screenshot(final driver, Map config, String name, - {Duration timeout = const Duration(seconds: 30)}) async { + {Duration timeout = const Duration(seconds: 30), + bool silent = false}) async { // todo: auto-naming scheme - final stagingDir = config['staging'] + '/test'; await driver.waitUntilNoTransientCallbacks(timeout: timeout); - final List pixels = await driver.screenshot(); - final File file = - await File(stagingDir + '/' + name + '.png').create(recursive: true); + final pixels = await driver.screenshot(); + final testDir = '${config['staging']}/$kTestScreenshotsDir'; + final file = + await File('$testDir/$name.$kImageExtension').create(recursive: true); await file.writeAsBytes(pixels); - print('Screenshot $name created'); + if (!silent) print('Screenshot $name created'); } diff --git a/lib/src/config.dart b/lib/src/config.dart index 4b8d1324..3e5cc792 100644 --- a/lib/src/config.dart +++ b/lib/src/config.dart @@ -59,7 +59,7 @@ class Config { /// Check emulators and simulators are installed, devices attached, /// matching screen is available and tests exist. - @visibleForTesting + @visibleForTesting // config is exported in library Future validate( Screens screens, List allDevices, List allEmulators) async { final isDeviceAttached = (device) => device != null; @@ -114,19 +114,6 @@ class Config { } } - // Due to issue with locales, issue warning for multiple locales. - // https://github.com/flutter/flutter/issues/27785 - if (configInfo['locales'].length > 1) { - stdout.write('Warning: Flutter integration tests do not work in ' - 'multiple locals.\n'); - stdout.write(' See comment on issue:\n' - ' https://github.com/flutter/flutter/issues/27785#issue-408955077\n' - ' for details.\n' - ' and provide a thumbs-up on the comment to prioritize a fix for this issue!\n\n' - ' In the meantime, while waiting for a fix, only use the default locale\n' - ' for your location in screenshots.yaml\n\n'); - } - return true; } @@ -167,7 +154,8 @@ class Config { stdout.write( '\n Each device listed in screenshots.yaml with framing required must' '\n 1. have a supported screen' - '\n 2. have an attached device or an installed emulator/simulator.\n\n'); + '\n 2. have an attached device or an installed emulator/simulator.' + '\n To bypass requirement #1 add \'frame: false\' after device in screenshots.yaml\n\n'); } // check screen is available for device diff --git a/lib/src/fastlane.dart b/lib/src/fastlane.dart index 8361edec..73ac9410 100644 --- a/lib/src/fastlane.dart +++ b/lib/src/fastlane.dart @@ -1,68 +1,85 @@ import 'dart:async'; +import 'dart:io'; import 'screens.dart'; -import 'utils.dart' as utils; - +import 'package:path/path.dart' as p; import 'globals.dart'; + +/// clear configured fastlane directories. +Future clearFastlaneDirs(Map config, Screens screens, RunMode runMode) async { + if (config['devices']['android'] != null) { + for (String deviceName in config['devices']['android'].keys) { + for (final locale in config['locales']) { + await _clearFastlaneDir( + screens, deviceName, locale, DeviceType.android, runMode); + } + } + } + if (config['devices']['ios'] != null) { + for (String deviceName in config['devices']['ios'].keys) { + for (final locale in config['locales']) { + await _clearFastlaneDir( + screens, deviceName, locale, DeviceType.ios, runMode); + } + } + } +} + +/// Clear images destination. +Future _clearFastlaneDir(Screens screens, String deviceName, String locale, + DeviceType deviceType, RunMode runMode) async { + final Map screenProps = screens.screenProps(deviceName); + String androidModelType = getAndroidModelType(screenProps); + + final dirPath = getDirPath(deviceType, locale, androidModelType); + + print('Clearing images in $dirPath for \'$deviceName\'...'); + // delete images ending with .kImageExtension + // for compatibility with FrameIt + // (see https://github.com/mmcc007/screenshots/issues/61) + deleteMatchingFiles(dirPath, RegExp('$deviceName.*.$kImageExtension')); + if (runMode == RunMode.normal) { + im.deleteDiffs(dirPath); + } +} + // ios/fastlane/screenshots/en-US/*[iPad|iPhone]* // android/fastlane/metadata/android/en-US/images/phoneScreenshots // android/fastlane/metadata/android/en-US/images/tenInchScreenshots // android/fastlane/metadata/android/en-US/images/sevenInchScreenshots - -/// Generate fastlane paths for ios and android. -String fastlaneDir( - DeviceType deviceType, String locale, String androidDeviceType) { +/// Generate fastlane dir path for ios or android. +String getDirPath( + DeviceType deviceType, String locale, String androidModelType) { const androidPrefix = 'android/fastlane/metadata/android'; const iosPrefix = 'ios/fastlane/screenshots'; - String path; + String dirPath; switch (deviceType) { case DeviceType.android: - path = '$androidPrefix/$locale/images/${androidDeviceType}Screenshots'; + dirPath = '$androidPrefix/$locale/images/${androidModelType}Screenshots'; break; case DeviceType.ios: - path = '$iosPrefix/$locale'; + dirPath = '$iosPrefix/$locale'; } - return path; -} - -/// Clear image destination. -Future _clearFastlaneDir( - Screens screens, deviceName, locale, DeviceType deviceType) async { - const kImageSuffix = 'png'; - - final Map screenProps = screens.screenProps(deviceName); - String androidDeviceType = getAndroidDeviceType(screenProps); - - final dirPath = fastlaneDir(deviceType, locale, androidDeviceType); - - print('Clearing images in $dirPath for \'$deviceName\'...'); - // only delete images ending with .png - // for compatability with FrameIt - // (see https://github.com/mmcc007/screenshots/issues/61) - utils.clearFilesWithSuffix(dirPath, kImageSuffix); + return dirPath; } -String getAndroidDeviceType(Map screenProps) { +/// Get android model type (phone or tablet screen size). +String getAndroidModelType(Map screenProps) { String androidDeviceType; if (screenProps != null) androidDeviceType = screenProps['destName']; return androidDeviceType; } -/// clear configured fastlane directories. -Future clearFastlaneDirs(Map config, Screens screens) async { - if (config['devices']['android'] != null) { - for (String deviceName in config['devices']['android'].keys) { - for (final locale in config['locales']) { - await _clearFastlaneDir( - screens, deviceName, locale, DeviceType.android); - } - } - } - if (config['devices']['ios'] != null) { - for (String deviceName in config['devices']['ios'].keys) { - for (final locale in config['locales']) { - await _clearFastlaneDir(screens, deviceName, locale, DeviceType.ios); +/// Clears files matching a pattern in a directory. +/// Creates directory if none exists. +void deleteMatchingFiles(String dirPath, RegExp pattern) { + if (Directory(dirPath).existsSync()) { + Directory(dirPath).listSync().toList().forEach((e) { + if (pattern.hasMatch(p.basename(e.path))) { + File(e.path).delete(); } - } + }); + } else { + Directory(dirPath).createSync(recursive: true); } -} +} \ No newline at end of file diff --git a/lib/src/globals.dart b/lib/src/globals.dart index 5567c0b0..fef42534 100644 --- a/lib/src/globals.dart +++ b/lib/src/globals.dart @@ -1,8 +1,22 @@ +import 'image_magick.dart'; + /// default config file name const String kConfigFileName = 'screenshots.yaml'; /// screenshots environment file name const String kEnvFileName = 'env.json'; +/// Image extension +const kImageExtension = 'png'; + +/// Directory for capturing screenshots during a test +const kTestScreenshotsDir = 'test'; + /// Distinguish device OS. enum DeviceType { android, ios } + +/// Run mode +enum RunMode { normal, recording, comparison, archive } + +// singleton +ImageMagick get im => ImageMagick(); diff --git a/lib/src/image_magick.dart b/lib/src/image_magick.dart index 66766486..08ba106e 100644 --- a/lib/src/image_magick.dart +++ b/lib/src/image_magick.dart @@ -1,91 +1,136 @@ import 'dart:async'; +import 'dart:io'; import 'run.dart' as run; +import 'package:path/path.dart' as p; -const kThreshold = 0.76; +class ImageMagick { + static const _kThreshold = 0.76; + final diffSuffix = '-diff'; //const kThreshold = 0.5; -/// -/// ImageMagick calls. -/// -Future imagemagick(String command, Map options) async { - List cmdOptions; - switch (command) { - case 'overlay': - cmdOptions = [ - options['screenshotPath'], - options['statusbarPath'], - '-gravity', - 'north', - '-composite', - options['screenshotPath'], - ]; - break; - case 'append': - // convert -append screenshot_statusbar.png navbar.png final_screenshot.png - cmdOptions = [ - '-append', - options['screenshotPath'], - options['screenshotNavbarPath'], - options['screenshotPath'], - ]; - break; - case 'frame': + // singleton + static final ImageMagick _imageMagick = ImageMagick._internal(); + factory ImageMagick() { + return _imageMagick; + } + ImageMagick._internal(); + + /// + /// ImageMagick calls. + /// + Future convert(String command, Map options) async { + List cmdOptions; + switch (command) { + case 'overlay': + cmdOptions = [ + options['screenshotPath'], + options['statusbarPath'], + '-gravity', + 'north', + '-composite', + options['screenshotPath'], + ]; + break; + case 'append': + // convert -append screenshot_statusbar.png navbar.png final_screenshot.png + cmdOptions = [ + '-append', + options['screenshotPath'], + options['screenshotNavbarPath'], + options['screenshotPath'], + ]; + break; + case 'frame': // convert -size $size xc:skyblue \ // \( "$frameFile" -resize $resize \) -gravity center -composite \ // \( final_screenshot.png -resize $resize \) -gravity center -geometry -4-9 -composite \ // framed.png - cmdOptions = [ - '-size', - options['size'], - options['backgroundColor'], - '(', - options['screenshotPath'], - '-resize', - options['resize'], - ')', - '-gravity', - 'center', - '-geometry', - options['offset'], - '-composite', - '(', - options['framePath'], - '-resize', - options['resize'], - ')', - '-gravity', - 'center', - '-composite', - options['screenshotPath'] - ]; - break; - default: - throw 'unknown command: $command'; + cmdOptions = [ + '-size', + options['size'], + options['backgroundColor'], + '(', + options['screenshotPath'], + '-resize', + options['resize'], + ')', + '-gravity', + 'center', + '-geometry', + options['offset'], + '-composite', + '(', + options['framePath'], + '-resize', + options['resize'], + ')', + '-gravity', + 'center', + '-composite', + options['screenshotPath'] + ]; + break; + default: + throw 'unknown command: $command'; + } + run.cmd('convert', cmdOptions); } - run.cmd('convert', cmdOptions); -} -/// Checks if brightness of section of image exceeds a threshold -bool thresholdExceeded(String imagePath, String crop, - [double threshold = kThreshold]) { - //convert logo.png -crop $crop_size$offset +repage -colorspace gray -format "%[fx:(mean>$threshold)?1:0]" info: - final result = run.cmd( - 'convert', - [ - imagePath, - '-crop', - crop, - '+repage', - '-colorspace', - 'gray', - '-format', - '\'%[fx:(mean>$threshold)?1:0]\'', - 'info:' - ], - '.', - true); + /// Checks if brightness of section of image exceeds a threshold + bool thresholdExceeded(String imagePath, String crop, + [double threshold = _kThreshold]) { + //convert logo.png -crop $crop_size$offset +repage -colorspace gray -format "%[fx:(mean>$threshold)?1:0]" info: + final result = run.cmd( + 'convert', + [ + imagePath, + '-crop', + crop, + '+repage', + '-colorspace', + 'gray', + '-format', + '\'%[fx:(mean>$threshold)?1:0]\'', + 'info:' + ], + '.', + true); // print('result=$result'); - return result.contains('1'); // looks like there is some junk in string + return result.contains('1'); // looks like there is some junk in string + } + + bool compare(String comparisonImage, String recordedImage) { + final diffImage = getDiffName(comparisonImage); + try { + run.cmd( + 'compare', + ['-metric', 'mae', recordedImage, comparisonImage, diffImage], + '.', + true); + } catch (e) { + return false; + } + // delete no-diff diff + File(diffImage).deleteSync(); + return true; + } + + String getDiffName(String comparisonImage) { + final diffName = p.dirname(comparisonImage) + + '/' + + p.basenameWithoutExtension(comparisonImage) + + diffSuffix + + p.extension(comparisonImage); + return diffName; + } + + void deleteDiffs(String dirPath) { + Directory(dirPath) + .listSync() + .where((fileSysEntity) => + p.basename(fileSysEntity.path).contains(diffSuffix)) + .forEach((diffImage) => File(diffImage.path).deleteSync()); + } } diff --git a/lib/src/image_processor.dart b/lib/src/image_processor.dart index dc1518f0..b0093a5b 100644 --- a/lib/src/image_processor.dart +++ b/lib/src/image_processor.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:io'; +import 'package:meta/meta.dart'; + +import 'archive.dart'; import 'screens.dart'; import 'fastlane.dart' as fastlane; -import 'image_magick.dart' as im; import 'resources.dart' as resources; import 'utils.dart' as utils; import 'package:path/path.dart' as p; @@ -18,7 +20,9 @@ class ImageProcessor { final Screens _screens; final Map _config; - ImageProcessor(this._screens, this._config); + ImageProcessor(Screens screens, Map config) + : _screens = screens, + _config = config; /// Process screenshots. /// @@ -30,7 +34,8 @@ class ImageProcessor { /// If 'frame' in config file is true, screenshots are placed within image of device. /// /// After processing, screenshots are handed off for upload via fastlane. - void process(DeviceType deviceType, String deviceName, String locale) async { + Future process(DeviceType deviceType, String deviceName, String locale, + RunMode runMode, Archive archive) async { final Map screenProps = _screens.screenProps(deviceName); if (screenProps == null) { print('Warning: \'$deviceName\' images will not be processed'); @@ -44,7 +49,7 @@ class ImageProcessor { await resources.unpackImages(screenResources, staging); // add status and nav bar and frame for each screenshot - final screenshots = Directory('$staging/test').listSync(); + final screenshots = Directory('$staging/$kTestScreenshotsDir').listSync(); for (final screenshotPath in screenshots) { // add status bar for each screenshot // print('overlaying status bar over screenshot at $screenshotPath'); @@ -59,21 +64,78 @@ class ImageProcessor { // add frame if required if (isFrameRequired(_config, deviceType, deviceName)) { // print('placing $screenshotPath in frame'); - await frame(_config, screenProps, screenshotPath.path, deviceType); + await frame(_config, screenProps, screenshotPath.path, deviceType, runMode); } } } // move to final destination for upload to stores via fastlane - final srcDir = '${_config['staging']}/test'; - final androidDeviceType = fastlane.getAndroidDeviceType(screenProps); - final dstDir = fastlane.fastlaneDir(deviceType, locale, androidDeviceType); + final srcDir = '${_config['staging']}/$kTestScreenshotsDir'; + final androidModelType = fastlane.getAndroidModelType(screenProps); + String dstDir = fastlane.getDirPath(deviceType, locale, androidModelType); + runMode == RunMode.recording + ? dstDir = '${_config['recording']}/$dstDir' + : null; + runMode == RunMode.archive + ? dstDir = archive.dstDir(deviceType, locale) + : null; // prefix screenshots with name of device before moving // (useful for uploading to apple via fastlane) await utils.prefixFilesInDir(srcDir, '$deviceName-'); print('Moving screenshots to $dstDir'); utils.moveFiles(srcDir, dstDir); + + if (runMode == RunMode.comparison) { + final recordingDir = '${_config['recording']}/$dstDir'; + print( + 'Running comparison with recorded screenshots in $recordingDir ...'); + final failedCompare = + await compareImages(deviceName, recordingDir, dstDir); + if (failedCompare.isNotEmpty) { + showFailedCompare(failedCompare); + throw 'Error: comparison failed.'; + } + } + } + + @visibleForTesting + void showFailedCompare(Map failedCompare) { + stderr.writeln('Error: comparison failed:'); + + failedCompare.forEach((screenshotName, result) { + stderr.writeln( + 'Error: ${result['comparison']} is not equal to ${result['recording']}'); + stderr.writeln(' Differences can be found in ${result['diff']}'); + }); + } + + @visibleForTesting + Future compareImages( + String deviceName, String recordingDir, String comparisonDir) async { + Map failedCompare = {}; + final recordedImages = Directory(recordingDir).listSync(); + Directory(comparisonDir) + .listSync() + .where((screenshot) => + p.basename(screenshot.path).contains(deviceName) && + !p.basename(screenshot.path).contains(im.diffSuffix)) + .forEach((screenshot) { + final screenshotName = p.basename(screenshot.path); + final recordedImageEntity = recordedImages.firstWhere( + (image) => p.basename(image.path) == screenshotName, + orElse: () => + throw 'Error: screenshot $screenshotName not found in $recordingDir'); + + if (!im.compare(screenshot.path, recordedImageEntity.path)) { + failedCompare[screenshotName] = { + 'recording': recordedImageEntity.path, + 'comparison': screenshot.path, + 'diff': im.getDiffName(screenshot.path) + }; + } + }); + return failedCompare; } /// Overlay status bar over screenshot. @@ -103,7 +165,7 @@ class ImageProcessor { 'screenshotPath': screenshotPath, 'statusbarPath': statusbarPath, }; - await im.imagemagick('overlay', options); + await im.convert('overlay', options); } /// Append android navigation bar to screenshot. @@ -114,7 +176,7 @@ class ImageProcessor { 'screenshotPath': screenshotPath, 'screenshotNavbarPath': screenshotNavbarPath, }; - await im.imagemagick('append', options); + await im.convert('append', options); } /// Checks if frame is required for [deviceName]. @@ -139,7 +201,7 @@ class ImageProcessor { /// /// Resulting image is scaled to fit dimensions required by stores. void frame(Map config, Map screen, String screenshotPath, - DeviceType deviceType) async { + DeviceType deviceType, RunMode runMode) async { final Map resources = screen['resources']; final framePath = config['staging'] + '/' + resources['frame']; @@ -149,7 +211,7 @@ class ImageProcessor { // set the default background color String backgroundColor; - (deviceType == DeviceType.ios) + (deviceType == DeviceType.ios && runMode!=RunMode.archive) ? backgroundColor = kDefaultIosBackground : backgroundColor = kDefaultAndroidBackground; @@ -161,6 +223,6 @@ class ImageProcessor { 'screenshotPath': screenshotPath, 'backgroundColor': backgroundColor, }; - await im.imagemagick('frame', options); + await im.convert('frame', options); } } diff --git a/lib/src/run.dart b/lib/src/run.dart index 4f8aca28..7f5d9901 100644 --- a/lib/src/run.dart +++ b/lib/src/run.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'archive.dart'; import 'config.dart'; import 'daemon_client.dart'; import 'fastlane.dart' as fastlane; @@ -20,14 +21,16 @@ import 'utils.dart' as utils; /// 3. Process the screenshots including adding a frame if required. /// 4. Move processed screenshots to fastlane destination for upload to stores. /// 5. If not a real device, stop emulator/simulator. -Future run([String configPath = kConfigFileName]) async { +Future run( + [String configPath = kConfigFileName, String _runMode = 'normal']) async { + final runMode = utils.getRunModeEnum(_runMode); + final screens = Screens(); await screens.init(); // start flutter daemon print('Starting flutter daemon...'); final daemonClient = DaemonClient(); - daemonClient.verbose = true; await daemonClient.start; // get all attached devices and running emulators/simulators final devices = await daemonClient.devices; @@ -44,24 +47,38 @@ Future run([String configPath = kConfigFileName]) async { // init final stagingDir = configInfo['staging']; - await Directory(stagingDir + '/test').create(recursive: true); + await Directory(stagingDir + '/$kTestScreenshotsDir').create(recursive: true); await resources.unpackScripts(stagingDir); - await fastlane.clearFastlaneDirs(configInfo, screens); - final imageProcessor = ImageProcessor(screens, configInfo); - + final archiveDir = configInfo['archive']; + Archive archive = Archive(stagingDir, archiveDir); + if (archiveDir == null) { + await fastlane.clearFastlaneDirs(configInfo, screens, runMode); + } else { + print('Archiving screenshots to ${archive.archiveDirPrefix}...'); + } // run integration tests in each real device (or emulator/simulator) for // each locale and process screenshots await runTestsOnAll( - daemonClient, devices, emulators, config, screens, imageProcessor); + daemonClient, devices, emulators, config, screens, runMode, archive); // shutdown daemon await daemonClient.stop; print('\n\nScreen images are available in:'); - print(' ios/fastlane/screenshots'); - print(' android/fastlane/metadata/android'); - print('for upload to both Apple and Google consoles.'); - print('\nFor uploading and other automation options see:'); - print(' https://pub.dartlang.org/packages/fledge'); + if (runMode == RunMode.recording) { + final recordingDir = configInfo['recording']; + print(' $recordingDir/ios/fastlane/screenshots'); + print(' $recordingDir/android/fastlane/metadata/android'); + } else { + if (archiveDir == null) { + print(' ios/fastlane/screenshots'); + print(' android/fastlane/metadata/android'); + print('for upload to both Apple and Google consoles.'); + print('\nFor uploading and other automation options see:'); + print(' https://pub.dartlang.org/packages/fledge'); + } else { + print(' ${archive.archiveDirPrefix}'); + } + } print('\nscreenshots completed successfully.'); } @@ -73,12 +90,35 @@ Future run([String configPath = kConfigFileName]) async { /// Assumes the integration tests capture the screen shots into a known directory using /// provided [capture_screen.screenshot()]. Future runTestsOnAll(DaemonClient daemonClient, List devices, List emulators, - Config config, Screens screens, ImageProcessor imageProcessor) async { + Config config, Screens screens, RunMode runMode, Archive archive) async { final configInfo = config.configInfo; final locales = configInfo['locales']; final stagingDir = configInfo['staging']; final testPaths = configInfo['tests']; final configDeviceNames = utils.getAllConfiguredDeviceNames(configInfo); + final imageProcessor = ImageProcessor(screens, configInfo); + + final recordingDir = configInfo['recording']; + final archiveDir = configInfo['archive']; + switch (runMode) { + case RunMode.normal: + break; + case RunMode.recording: + recordingDir == null + ? throw 'Error: \'recording\' dir is not specified in screenshots.yaml' + : null; + break; + case RunMode.comparison: + runMode == RunMode.comparison && (!(await utils.isRecorded(recordingDir))) + ? throw 'Error: a recording must be run before a comparison' + : null; + break; + case RunMode.archive: + archiveDir == null + ? throw 'Error: \'archive\' dir is not specified in screenshots.yaml' + : null; + break; + } for (final configDeviceName in configDeviceNames) { // look for matching device first @@ -87,7 +127,7 @@ Future runTestsOnAll(DaemonClient daemonClient, List devices, List emulators, String deviceId; Map emulator; Map simulator; - bool pendingLocaleChange = false; + bool pendingIosLocaleChangeAtStart = false; if (device != null) { deviceId = device['id']; } else { @@ -105,11 +145,11 @@ Future runTestsOnAll(DaemonClient daemonClient, List devices, List emulators, utils.getIosSimulators(), configDeviceName); deviceId = simulator['udid']; // check if current device is pending a locale change - if (locales[0] == utils.iosSimulatorLocale(deviceId)) { + if (locales[0] == utils.getIosSimulatorLocale(deviceId)) { print('Starting $configDeviceName...'); startSimulator(deviceId); } else { - pendingLocaleChange = true; + pendingIosLocaleChangeAtStart = true; print('Not starting $configDeviceName due to pending locale change'); } } @@ -118,58 +158,53 @@ Future runTestsOnAll(DaemonClient daemonClient, List devices, List emulators, ? throw 'Error: device \'$configDeviceName\' not found' : null; - // Check for a running android device or emulator - bool isRunningAndroidDeviceOrEmulator(Map device, Map emulator) { - return (device != null && device['platform'] != 'ios') || - (device == null && emulator != null); - } - - // save original locale for reverting later if necessary - String origLocale; - if (isRunningAndroidDeviceOrEmulator(device, emulator)) { - origLocale = utils.androidDeviceLocale(deviceId); - } + final deviceType = getDeviceType(configInfo, configDeviceName); + // if device is real ios or android, cannot change locale + if (device != null && !device['emulator']) { + final defaultLocale = 'en-US'; // todo: need actual local + print('Warning: the locale of a real device cannot be changed.'); + await runProcessTests(config, screens, configDeviceName, defaultLocale, + deviceType, testPaths, deviceId, imageProcessor, runMode, archive); + } else { + // Check for a running android device or emulator + bool isRunningAndroidDeviceOrEmulator(Map device, Map emulator) { + return (device != null && device['platform'] != 'ios') || + (device == null && emulator != null); + } - for (final locale in locales) { - // set locale if android device or emulator + // save original locale for reverting later if necessary + String origAndroidLocale; if (isRunningAndroidDeviceOrEmulator(device, emulator)) { - await setAndroidLocale(deviceId, locale, configDeviceName); + origAndroidLocale = utils.getAndroidDeviceLocale(deviceId); } - // set locale if ios simulator - if ((device != null && - device['platform'] == 'ios' && - device['emulator'])) { - // an already running simulator - await setSimulatorLocale( - deviceId, configDeviceName, locale, stagingDir); - } else { - if (device == null && simulator != null) { - if (pendingLocaleChange) { - // a non-running simulator - await setSimulatorLocale( - deviceId, configDeviceName, locale, stagingDir, - running: false); - } else { - // a running simulator - await setSimulatorLocale( - deviceId, configDeviceName, locale, stagingDir); + for (final locale in locales) { + // set locale if android device or emulator + if (isRunningAndroidDeviceOrEmulator(device, emulator)) { + await setEmulatorLocale(deviceId, locale, configDeviceName); + } + // set locale if ios simulator + if ((device != null && + device['platform'] == 'ios' && + device['emulator'])) { + // an already running simulator + await setSimulatorLocale( + deviceId, configDeviceName, locale, stagingDir); + } else { + if (device == null && simulator != null) { + if (pendingIosLocaleChangeAtStart) { + // a non-running simulator + await setSimulatorLocale( + deviceId, configDeviceName, locale, stagingDir, + running: false); + pendingIosLocaleChangeAtStart = false; + } else { + // a running simulator + await setSimulatorLocale( + deviceId, configDeviceName, locale, stagingDir); + } } } - } - // issue locale warning if ios device - if ((device != null && - device['platform'] == 'ios' && - !device['emulator'])) { - // a running ios device - print('Warning: the locale of an ios device cannot be changed.'); - } - final deviceType = getDeviceType(configInfo, configDeviceName); - - // store env for later use by tests - // ignore: invalid_use_of_visible_for_testing_member - await config.storeEnv(screens, configDeviceName, locale, - utils.getStringFromEnum(deviceType)); // Change orientation if required final deviceOrientation = configInfo['devices'] @@ -199,20 +234,15 @@ Future runTestsOnAll(DaemonClient daemonClient, List devices, List emulators, } } - // run tests - for (final testPath in testPaths) { - print( - 'Running $testPath on \'$configDeviceName\' in locale $locale...'); - await utils.streamCmd('flutter', ['-d', deviceId, 'drive', testPath]); + // run tests and process images + await runProcessTests(config, screens, configDeviceName, locale, + deviceType, testPaths, deviceId, imageProcessor, runMode, archive); - // process screenshots - await imageProcessor.process(deviceType, configDeviceName, locale); - } } // if an emulator was started, revert locale if necessary and shut it down if (emulator != null) { - await setAndroidLocale(deviceId, origLocale, configDeviceName); + await setEmulatorLocale(deviceId, origAndroidLocale, configDeviceName); await shutdownAndroidEmulator(daemonClient, deviceId); } if (simulator != null) { @@ -222,6 +252,30 @@ Future runTestsOnAll(DaemonClient daemonClient, List devices, List emulators, } } +Future runProcessTests( + Config config, + Screens screens, + configDeviceName, + String locale, + DeviceType deviceType, + testPaths, + String deviceId, + ImageProcessor imageProcessor, + RunMode runMode, + Archive archive) async { + // store env for later use by tests + // ignore: invalid_use_of_visible_for_testing_member + await config.storeEnv( + screens, configDeviceName, locale, utils.getStringFromEnum(deviceType)); + for (final testPath in testPaths) { + print('Running $testPath on \'$configDeviceName\' in locale $locale...'); + await utils.streamCmd('flutter', ['-d', deviceId, 'drive', testPath]); + // process screenshots + await imageProcessor.process( + deviceType, configDeviceName, locale, runMode, archive); + } +} + void shutdownSimulator(String deviceId) { cmd('xcrun', ['simctl', 'shutdown', deviceId]); } @@ -273,22 +327,22 @@ Future setSimulatorLocale( String deviceId, String deviceName, String testLocale, stagingDir, {bool running = true}) async { // a running simulator - final deviceLocale = utils.iosSimulatorLocale(deviceId); + final deviceLocale = utils.getIosSimulatorLocale(deviceId); // print('simulator locale=$deviceLocale'); if (testLocale != deviceLocale) { if (running) shutdownSimulator(deviceId); print( 'Changing locale from $deviceLocale to $testLocale on \'$deviceName\'...'); await _changeSimulatorLocale(stagingDir, deviceId, testLocale); + print('Starting $deviceName...'); startSimulator(deviceId); } } -/// Set the locale for a real android device or a running emulator. -Future setAndroidLocale(String deviceId, testLocale, deviceName) async { - // a running android device or emulator - final deviceLocale = utils.androidDeviceLocale(deviceId); -// print('android device or emulator locale=$deviceLocale'); +/// Set the locale of a running emulator. +Future setEmulatorLocale(String deviceId, testLocale, deviceName) async { + final deviceLocale = utils.getAndroidDeviceLocale(deviceId); + print('emulator locale=$deviceLocale'); if (deviceLocale != null && deviceLocale != '' && deviceLocale != testLocale) { @@ -304,7 +358,7 @@ Future setAndroidLocale(String deviceId, testLocale, deviceName) async { /// Change local of real android device or running emulator. void changeAndroidLocale( String deviceId, String deviceLocale, String testLocale) { - if (cmd('adb', ['root'], '.', true) == + if (cmd('adb', ['-s', deviceId, 'root'], '.', true) == 'adbd cannot run as root in production builds\n') { stdout.write( 'Warning: locale will not be changed. Running in locale \'$deviceLocale\'.\n'); @@ -313,7 +367,6 @@ void changeAndroidLocale( stdout.write( ' https://stackoverflow.com/questions/43923996/adb-root-is-not-working-on-emulator/45668555#45668555 for details.\n'); } - _flutterDriverBugWarning(); // adb shell "setprop persist.sys.locale fr-CA; setprop ctl.restart zygote" cmd('adb', [ '-s', @@ -332,7 +385,6 @@ void changeAndroidLocale( /// Change locale of non-running simulator. Future _changeSimulatorLocale( String stagingDir, String name, String testLocale) async { - _flutterDriverBugWarning(); await utils.streamCmd('$stagingDir/resources/script/simulator-controller', [name, 'locale', testLocale]); } @@ -349,11 +401,6 @@ Future shutdownAndroidEmulator( return device['id']; } -void _flutterDriverBugWarning() { - stdout.write( - '\nWarning: running tests in a non-default locale will cause test to hang due to a bug in Flutter Driver (not related to \'screenshots\'). Modify your screenshots.yaml to use only the default locale for your location. For details of bug see comment at https://github.com/flutter/flutter/issues/27785#issue-408955077. Give comment a thumbs-up to get it fixed!\n\n'); -} - /// Start android emulator in a CI environment. Future _startAndroidEmulatorOnCI(String emulatorId, String stagingDir) async { // testing on CI/CD requires starting emulator in a specific way diff --git a/lib/src/utils.dart b/lib/src/utils.dart index fe2b1084..f92ecb00 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -4,33 +4,9 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:process/process.dart'; +import 'globals.dart'; import 'run.dart' as run; -/// Clear directory [dirPath]. -/// Create directory if none exists. -void clearDirectory(String dirPath) { - if (Directory(dirPath).existsSync()) { - Directory(dirPath).deleteSync(recursive: true); - } else { - Directory(dirPath).createSync(recursive: true); - } -} - -/// Clear files in a directory [dirPath] ending in [suffix] -/// Create directory if none exists. -void clearFilesWithSuffix(String dirPath, String suffix) { - // delete files with suffix - if (Directory(dirPath).existsSync()) { - Directory(dirPath).listSync().toList().forEach((e) { - if (p.extension(e.path) == suffix) { - File(e.path).delete(); - } - }); - } else { - Directory(dirPath).createSync(recursive: true); - } -} - /// Move files from [srcDir] to [dstDir]. /// /// If dstDir does not exist, it is created. @@ -96,10 +72,10 @@ Map transformIosSimulators(Map simsInfo) { Map simsInfoTransformed = {}; simsInfo.forEach((iOSName, sims) { - // print('iOSVersionName=$iOSVersionName'); // note: 'isAvailable' field does not appear consistently - // so using 'availability' instead - isSimAvailable(sim) => sim['availability'] == '(available)'; + // so using 'availability' as well + isSimAvailable(sim) => + sim['availability'] == '(available)' || sim['isAvailable'] == true; for (final sim in sims) { // skip if simulator unavailable if (!isSimAvailable(sim)) continue; @@ -191,16 +167,24 @@ T getEnumFromString(List values, String value) { } /// Returns locale of currently attached android device. -String androidDeviceLocale(String deviceId) { - final deviceLocale = run +String getAndroidDeviceLocale(String deviceId) { +// ro.product.locale is available on first boot but does not update, +// persist.sys.locale is empty on first boot but updates with locale changes + String deviceLocale = run .cmd('adb', ['-s', deviceId, 'shell', 'getprop persist.sys.locale'], '.', true) .trim(); + if (deviceLocale.isEmpty) { + deviceLocale = run + .cmd('adb', ['-s', deviceId, 'shell', 'getprop ro.product.locale'], '.', + true) + .trim(); + } return deviceLocale; } /// Returns locale of simulator with udid [udId]. -String iosSimulatorLocale(String udId) { +String getIosSimulatorLocale(String udId) { final env = Platform.environment; final settingsPath = '${env['HOME']}/Library/Developer/CoreSimulator/Devices/$udId/data/Library/Preferences/.GlobalPreferences.plist'; @@ -254,7 +238,12 @@ Future stopAndroidEmulator(String deviceId, String stagingDir) async { Future waitAndroidLocaleChange(String deviceId, String toLocale) async { final regExp = RegExp( 'ContactsProvider: Locale has changed from .* to \\[${toLocale.replaceFirst('-', '_')}\\]|ContactsDatabaseHelper: Switching to locale \\[${toLocale.replaceFirst('-', '_')}\\]'); - final line = await waitSysLogMsg(deviceId, regExp); +// final regExp = RegExp( +// 'ContactsProvider: Locale has changed from .* to \\[${toLocale.replaceFirst('-', '_')}\\]'); +// final regExp = RegExp( +// 'ContactsProvider: Locale has changed from .* to \\[${toLocale.replaceFirst('-', '_')}\\]|ContactsDatabaseHelper: Locale change completed'); + final line = + await waitSysLogMsg(deviceId, regExp, toLocale.replaceFirst('-', '_')); return line; } @@ -298,15 +287,22 @@ Map getDeviceFromId(List devices, String deviceId) { } /// Wait for message to appear in sys log and return first matching line -Future waitSysLogMsg(String deviceId, RegExp regExp) async { - run.cmd('adb', ['logcat', '-c']); +Future waitSysLogMsg( + String deviceId, RegExp regExp, String locale) async { + run.cmd('adb', ['-s', deviceId, 'logcat', '-c']); + await Future.delayed(Duration(milliseconds: 1000)); // wait for log to clear + // -b main ContactsDatabaseHelper:I '*:S' final delegate = await Process.start('adb', [ '-s', deviceId, 'logcat', - '*:F', + '-b', + 'main', + '*:S', + 'ContactsDatabaseHelper:I', 'ContactsProvider:I', - 'ContactsDatabaseHelper:I' + '-e', + locale ]); final process = ProcessWrapper(delegate); return await process.stdout @@ -325,8 +321,9 @@ Map findEmulator(List emulators, String emulatorName) { orElse: () => null); } -/// Run AppleScript -/// Requires permission on first run. -void runOsaScript(String script, List args) { - run.cmd('osascript', [script, ...args]); +RunMode getRunModeEnum(String runMode) { + return getEnumFromString(RunMode.values, runMode); } + +Future isRecorded(recordDir) async => + !(await Directory(recordDir).list().isEmpty); diff --git a/pubspec.yaml b/pubspec.yaml index afa8e92e..7c8eb32b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: screenshots description: Auto-generation of screenshots for Apple and Play Stores using emulators and simulators with support for locales. Compatible with fastlane for upload to both stores. -version: 1.0.1 +version: 1.2.0 homepage: https://github.com/mmcc007/screenshots author: Maurice McCabe diff --git a/test/common.dart b/test/common.dart index 73ac6343..2eb6e4c0 100644 --- a/test/common.dart +++ b/test/common.dart @@ -1,6 +1,22 @@ import 'dart:async'; +import 'dart:io'; +import 'package:meta/meta.dart'; import 'package:screenshots/src/run.dart' as run; +import 'package:path/path.dart' as p; + +/// Copy files from [srcDir] to [dstDir]. +/// If dstDir does not exist, it is created. +void copyFiles(String srcDir, String dstDir) { + if (!Directory(dstDir).existsSync()) { + Directory(dstDir).createSync(recursive: true); + } + Directory(srcDir).listSync().forEach((file) { + file.statSync().type == FileSystemEntityType.file + ? File(file.path).copy('$dstDir/${p.basename(file.path)}') + : throw 'Error: ${file.path} is not a file'; + }); +} /// Get device properties Map getDeviceProps(String deviceId) { @@ -86,9 +102,22 @@ Map diffMaps(Map orig, Map diff, {bool verbose = false}) { return diffs; } -T getEnumFromString(Iterable values, String value) { - return values.firstWhere((type) => type.toString().split(".").last == value, - orElse: () => null); +/// Returns a future that completes with a path suitable for ANDROID_HOME +/// or with null, if ANDROID_HOME cannot be found. +Future findAndroidHome() async { + final Iterable hits = grep( + 'ANDROID_HOME = ', + from: await run.cmd('flutter', ['doctor', '-v'], '.', true), + ); + if (hits.isEmpty) return null; + return hits.first.split('= ').last; +} + +/// Splits [from] into lines and selects those that contain [pattern]. +Iterable grep(Pattern pattern, {@required String from}) { + return from.split('\n').where((String line) { + return line.contains(pattern); + }); } ///// Wait for android emulator to stop. @@ -168,3 +197,13 @@ T getEnumFromString(Iterable values, String value) { // print( // 'locale changed during bootAnim change from $origBootAnimStatus to $bootAnimStatus'); //} + +///// Clear directory [dirPath]. +///// Create directory if none exists. +//void clearDirectory(String dirPath) { +// if (Directory(dirPath).existsSync()) { +// Directory(dirPath).deleteSync(recursive: true); +// } else { +// Directory(dirPath).createSync(recursive: true); +// } +//} diff --git a/test/daemon_test.dart b/test/daemon_test.dart index 926bc6ea..bcb4691e 100644 --- a/test/daemon_test.dart +++ b/test/daemon_test.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:path/path.dart'; import 'package:screenshots/src/daemon_client.dart'; import 'package:screenshots/src/fastlane.dart' as fastlane; -import 'package:screenshots/src/image_processor.dart'; +import 'package:screenshots/src/globals.dart'; import 'package:screenshots/src/resources.dart' as resources; import 'package:screenshots/src/run.dart' as run; import 'package:screenshots/src/screens.dart'; @@ -192,10 +192,9 @@ main() { // init final stagingDir = configInfo['staging']; - await Directory(stagingDir + '/test').create(recursive: true); + await Directory(stagingDir + '/$kTestScreenshotsDir').create(recursive: true); await resources.unpackScripts(stagingDir); - await fastlane.clearFastlaneDirs(configInfo, screens); - final imageProcessor = ImageProcessor(screens, configInfo); + await fastlane.clearFastlaneDirs(configInfo, screens, RunMode.normal); final daemonClient = DaemonClient(); await daemonClient.start; @@ -206,8 +205,8 @@ main() { final origDir = Directory.current; Directory.current = 'example'; - await run.runTestsOnAll( - daemonClient, devices, emulators, config, screens, imageProcessor); + await run.runTestsOnAll(daemonClient, devices, emulators, config, screens, + RunMode.normal, null); // allow other tests to continue Directory.current = origDir; }, timeout: Timeout(Duration(minutes: 4))); diff --git a/test/frame_test.dart b/test/frame_test.dart index a7a49fb7..18b92ea8 100644 --- a/test/frame_test.dart +++ b/test/frame_test.dart @@ -1,5 +1,5 @@ import 'package:screenshots/src/config.dart'; -import 'package:screenshots/src/image_magick.dart' as im; +import 'package:screenshots/src/globals.dart'; import 'package:screenshots/src/image_processor.dart'; import 'package:screenshots/src/resources.dart' as resources; import 'package:screenshots/src/screens.dart'; @@ -25,7 +25,7 @@ main() { 'statusbarPath': statusbarPath, }; print('options=$options'); - await im.imagemagick('overlay', options); + await im.convert('overlay', options); final screenshotNavbarPath = '${appConfig['staging']}/${ScreenResources['navbar']}'; @@ -34,7 +34,7 @@ main() { 'screenshotNavbarPath': screenshotNavbarPath, }; print('options=$options'); - await im.imagemagick('append', options); + await im.convert('append', options); final framePath = appConfig['staging'] + '/' + ScreenResources['frame']; final size = screen['size']; @@ -49,6 +49,6 @@ main() { 'backgroundColor': ImageProcessor.kDefaultAndroidBackground, }; print('options=$options'); - await im.imagemagick('frame', options); + await im.convert('frame', options); }); } diff --git a/test/process_images_test.dart b/test/process_images_test.dart index 8c34fac2..b45ccf2f 100644 --- a/test/process_images_test.dart +++ b/test/process_images_test.dart @@ -1,5 +1,5 @@ import 'package:screenshots/src/config.dart'; -import 'package:screenshots/src/image_magick.dart' as im; +import 'package:screenshots/src/globals.dart'; import 'package:screenshots/src/image_processor.dart'; import 'package:screenshots/src/resources.dart' as resources; import 'package:screenshots/src/screens.dart'; @@ -36,7 +36,7 @@ main() { 'statusbarPath': statusbarPath, }; // print('options=$options'); - await im.imagemagick('overlay', options); + await im.convert('overlay', options); final framePath = appConfig['staging'] + '/' + screenResources['frame']; final size = screen['size']; @@ -51,7 +51,7 @@ main() { 'backgroundColor': ImageProcessor.kDefaultAndroidBackground, }; // print('options=$options'); - await im.imagemagick('frame', options); + await im.convert('frame', options); } }); } diff --git a/test/regression/regression_test.dart b/test/regression/regression_test.dart index 23b9ab17..e8552e55 100644 --- a/test/regression/regression_test.dart +++ b/test/regression/regression_test.dart @@ -4,29 +4,18 @@ import 'package:screenshots/src/utils.dart' as utils; import 'package:test/test.dart'; void main() { - // issue #25 - test('test parsing of iOS device info returned by xcrun', () { + test('issue #25: test parsing of iOS device info returned by xcrun', () { + final expected = ''' + { + "availability": "(available)", + "state": "Shutdown", + "isAvailable": true, + "name": "iPhone X", + "udid": "5A15DEB4-24BB-49F4-BD9A-FAF0B761FB27", + "availabilityError": "" + } + '''; final deviceName = 'iPhone X'; - print( - 'getIosDevice=${utils.getHighestIosSimulator(utils.getIosSimulators(), deviceName)}'); - -// final deviceInfoRaw = ''' -//{ -// "devices" : { -// "com.apple.CoreSimulator.SimRuntime.tvOS-12-0" : [ -// { -// "availability" : "(unavailable, runtime profile not found)", -// "state" : "Shutdown", -// "isAvailable" : false, -// "name" : "iPhone X", -// "udid" : "9FF60BB2-D95F-4CC9-9333-7F0B51572AF3", -// "availabilityError" : "runtime profile not found" -// } -// ] -// } -//} -// '''; - final deviceInfoRaw = ''' { "devices" : { @@ -417,14 +406,7 @@ void main() { "udid" : "02802094-72CA-4F27-9214-0E0E9118F0C1", "availabilityError" : "" }, - { - "availability" : "(available)", - "state" : "Shutdown", - "isAvailable" : true, - "name" : "iPhone X", - "udid" : "5A15DEB4-24BB-49F4-BD9A-FAF0B761FB27", - "availabilityError" : "" - }, + $expected, { "availability" : "(available)", "state" : "Shutdown", @@ -835,18 +817,57 @@ void main() { } } '''; + print( + 'getIosDevice=${utils.getHighestIosSimulator(utils.getIosSimulators(), deviceName)}'); final deviceInfo = jsonDecode(deviceInfoRaw)['devices']; final iosDevices = utils.transformIosSimulators(deviceInfo); // final iosDevice = getHighestIosDevice(iosDevices, deviceName); // expect( // () => getHighestIosDevice(iosDevices, deviceName), throwsA(anything)); - expect(utils.getHighestIosSimulator(iosDevices, deviceName), jsonDecode('''{ - "availability": "(available)", - "state": "Shutdown", - "isAvailable": true, - "name": "iPhone X", - "udid": "5A15DEB4-24BB-49F4-BD9A-FAF0B761FB27", - "availabilityError": "" - }''')); + expect(utils.getHighestIosSimulator(iosDevices, deviceName), + jsonDecode(expected)); + }); + + test('issue #73: parse without availability', () { + final expected = ''' + { + "state" : "Shutdown", + "isAvailable" : true, + "name" : "iPhone Xs Max", + "udid" : "3AD11D72-B3FA-4E4C-94B3-E4E51C67250A" + } + '''; + final deviceName = 'iPhone Xs Max'; + final deviceInfoRaw = ''' +{ + "devices" : { + "com.apple.CoreSimulator.SimRuntime.iOS-12-0" : [ + + ], + "com.apple.CoreSimulator.SimRuntime.tvOS-12-2" : [ + + ], + "com.apple.CoreSimulator.SimRuntime.iOS-12-2" : [ + $expected + ], + "com.apple.CoreSimulator.SimRuntime.watchOS-5-2" : [ + + ], + "com.apple.CoreSimulator.SimRuntime.iOS-9-3" : [ + + ], + "com.apple.CoreSimulator.SimRuntime.iOS-12-1" : [ + + ], + "com.apple.CoreSimulator.SimRuntime.iOS-9-0" : [ + + ] + } +} + '''; + final deviceInfo = jsonDecode(deviceInfoRaw)['devices']; + final iosDevices = utils.transformIosSimulators(deviceInfo); + expect(utils.getHighestIosSimulator(iosDevices, deviceName), + jsonDecode(expected)); }); } diff --git a/test/resources/comparison/Nexus 6P-0.png b/test/resources/comparison/Nexus 6P-0.png new file mode 100644 index 00000000..0e7d2b84 Binary files /dev/null and b/test/resources/comparison/Nexus 6P-0.png differ diff --git a/test/resources/comparison/Nexus 6P-1.png b/test/resources/comparison/Nexus 6P-1.png new file mode 100644 index 00000000..1ffe34e4 Binary files /dev/null and b/test/resources/comparison/Nexus 6P-1.png differ diff --git a/test/resources/recording/Nexus 6P-0.png b/test/resources/recording/Nexus 6P-0.png new file mode 100644 index 00000000..d938e846 Binary files /dev/null and b/test/resources/recording/Nexus 6P-0.png differ diff --git a/test/resources/recording/Nexus 6P-1.png b/test/resources/recording/Nexus 6P-1.png new file mode 100644 index 00000000..cc45166b Binary files /dev/null and b/test/resources/recording/Nexus 6P-1.png differ diff --git a/test/resources/test/Nexus 6P-0.png b/test/resources/test/Nexus 6P-0.png new file mode 100644 index 00000000..0e7d2b84 Binary files /dev/null and b/test/resources/test/Nexus 6P-0.png differ diff --git a/test/resources/test/Nexus 6P-1.png b/test/resources/test/Nexus 6P-1.png new file mode 100644 index 00000000..1ffe34e4 Binary files /dev/null and b/test/resources/test/Nexus 6P-1.png differ diff --git a/test/screenshots_test.dart b/test/screenshots_test.dart index 96361105..8deef59c 100644 --- a/test/screenshots_test.dart +++ b/test/screenshots_test.dart @@ -2,19 +2,22 @@ import 'dart:convert'; import 'dart:io'; import 'package:process/process.dart'; +import 'package:screenshots/screenshots.dart'; import 'package:screenshots/src/config.dart'; import 'package:screenshots/src/daemon_client.dart'; import 'package:screenshots/src/globals.dart'; import 'package:screenshots/src/image_processor.dart'; import 'package:screenshots/src/orientation.dart' as orient; import 'package:screenshots/src/screens.dart'; -import 'package:screenshots/src/image_magick.dart' as im; import 'package:screenshots/src/resources.dart' as resources; import 'package:screenshots/src/run.dart' as run; import 'package:screenshots/src/utils.dart' as utils; import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; +import 'package:screenshots/src/fastlane.dart' as fastlane; +import 'package:path/path.dart' as p; +import '../bin/main.dart'; import 'common.dart'; void main() { @@ -73,7 +76,7 @@ void main() { 'screenshotPath': screenshotPath, 'statusbarPath': statusbarPath, }; - await im.imagemagick('overlay', options); + await im.convert('overlay', options); }); test('unpack screen resource images', () async { @@ -102,7 +105,7 @@ void main() { 'screenshotPath': screenshotPath, 'screenshotNavbarPath': screenshotNavbarPath, }; - await im.imagemagick('append', options); + await im.convert('append', options); }); test('frame screenshot', () async { @@ -126,7 +129,7 @@ void main() { 'screenshotPath': screenshotPath, 'backgroundColor': ImageProcessor.kDefaultAndroidBackground, }; - await im.imagemagick('frame', options); + await im.convert('frame', options); }); test('parse json xcrun simctl list devices', () { @@ -196,7 +199,8 @@ void main() { }); test('add prefix to files in directory', () async { - await utils.prefixFilesInDir('/tmp/screenshots/test', 'my_prefix'); + await utils.prefixFilesInDir( + '/tmp/screenshots/$kTestScreenshotsDir', 'my_prefix'); }); test('config guide', () async { @@ -268,10 +272,12 @@ void main() { await daemonClient.start; daemonClient.verbose = true; final deviceId = await daemonClient.launchEmulator(emulatorId); - print('emulator started'); + print('switching to $newLocale locale'); run.changeAndroidLocale(deviceId, deviceName, newLocale); // wait for locale to change await utils.waitAndroidLocaleChange(deviceId, newLocale); + // change back for repeated testing + print('switching to $origLocale locale'); run.changeAndroidLocale(deviceId, deviceName, origLocale); await utils.waitAndroidLocaleChange(deviceId, origLocale); expect(await run.shutdownAndroidEmulator(daemonClient, deviceId), deviceId); @@ -309,29 +315,6 @@ void main() { ProcessStartMode.detached); }); - test('delete all files with suffix', () async { - final dirPath = '/tmp/tmp'; - final files = ['image1.png', 'image2.png']; - final suffix = 'png'; - - utils.clearDirectory(dirPath); // creates empty directory - - // create files - files - .forEach((fileName) async => await File('$dirPath/$fileName').create()); - - // check created - files.forEach((fileName) async => - expect(await File('$dirPath/$fileName').exists(), true)); - - // delete files with suffix - utils.clearFilesWithSuffix(dirPath, suffix); - - // check deleted - files.forEach((fileName) async => - expect(await File('$dirPath/$fileName').exists(), false)); - }); - // reproduce https://github.com/flutter/flutter/issues/27785 // on android (hangs during test) // tested on android emulator in default locale (en-US) and it worked @@ -364,13 +347,13 @@ void main() { final deviceId = await daemonClient.launchEmulator(emulatorId); // change locale - await run.setAndroidLocale(deviceId, newLocale, deviceName); + await run.setEmulatorLocale(deviceId, newLocale, deviceName); // run test await utils.streamCmd('flutter', ['drive', testAppSrcPath], testAppDir); // stop emulator - await run.setAndroidLocale(deviceId, origLocale, deviceName); + await run.setEmulatorLocale(deviceId, origLocale, deviceName); expect(await run.shutdownAndroidEmulator(daemonClient, deviceId), deviceId); }, timeout: @@ -385,7 +368,7 @@ void main() { final daemonClient = DaemonClient(); await daemonClient.start; final deviceId = await daemonClient.launchEmulator(emulatorId); - final deviceLocale = utils.androidDeviceLocale(deviceId); + final deviceLocale = utils.getAndroidDeviceLocale(deviceId); expect(await run.shutdownAndroidEmulator(daemonClient, deviceId), deviceId); expect(deviceLocale, locale); @@ -432,7 +415,7 @@ void main() { test('get ios simulator locale', () async { final udId = '03D4FC12-3927-4C8B-A226-17DE34AE9C18'; - var locale = utils.iosSimulatorLocale(udId); + var locale = utils.getIosSimulatorLocale(udId); expect(locale, 'en-US'); }); @@ -541,7 +524,7 @@ devices: }); test('scan syslog for string', () async { -// final toLocale = 'en-US'; + final toLocale = 'en-US'; // final expected = // 'ContactsProvider: Locale has changed from [fr_CA] to [en_US]'; // final expected = RegExp('Locale has changed from'); @@ -550,7 +533,8 @@ devices: await daemonClient.start; final emulatorId = 'Nexus_6P_API_28'; final deviceId = await daemonClient.launchEmulator(emulatorId); - String actual = await utils.waitSysLogMsg(deviceId, expected); + String actual = await utils.waitSysLogMsg(deviceId, expected, toLocale); + print('actual=$actual'); expect(actual.contains(expected), isTrue); expect( await run.shutdownAndroidEmulator(daemonClient, deviceId), deviceId); @@ -570,6 +554,124 @@ devices: }); }); + group('recording, comparison', () { + test('recording mode', () async { + final origDir = Directory.current; + Directory.current = 'example'; + final configPath = 'screenshots.yaml'; + await run.run(configPath, utils.getStringFromEnum(RunMode.recording)); + final configInfo = Config(configPath: configPath).configInfo; + final recordingDir = configInfo['recording']; + expect(await utils.isRecorded(recordingDir), isTrue); + Directory.current = origDir; + }, timeout: Timeout(Duration(seconds: 180))); + + test('imagemagick compare', () { + final recordedImage0 = 'test/resources/recording/Nexus 6P-0.png'; + final comparisonImage0 = 'test/resources/comparison/Nexus 6P-0.png'; + final comparisonImage1 = 'test/resources/comparison/Nexus 6P-1.png'; + final goodPair = { + 'recorded': recordedImage0, + 'comparison': comparisonImage0 + }; + final badPair = { + 'recorded': recordedImage0, + 'comparison': comparisonImage1 + }; + final pairs = {'good': goodPair, 'bad': badPair}; + + pairs.forEach((behave, pair) { + final recordedImage = pair['recorded']; + final comparisonImage = pair['comparison']; + bool doCompare = im.compare(comparisonImage, recordedImage); + behave == 'good' ? expect(doCompare, true) : expect(doCompare, false); + File(im.getDiffName(comparisonImage)).deleteSync(); + }); + }); + + test('compare images in directories', () async { + final comparisonDir = 'test/resources/comparison'; + final recordingDir = 'test/resources/recording'; + final deviceName = 'Nexus 6P'; + final expected = { + 'Nexus 6P-1.png': { + 'recording': 'test/resources/recording/Nexus 6P-1.png', + 'comparison': 'test/resources/comparison/Nexus 6P-1.png', + 'diff': 'test/resources/comparison/Nexus 6P-1-diff.png' + } + }; + + final imageProcessor = ImageProcessor(null, null); + final failedCompare = await imageProcessor.compareImages( + deviceName, recordingDir, comparisonDir); + expect(failedCompare, expected); + // show diffs + if (failedCompare.isNotEmpty) { + imageProcessor.showFailedCompare(failedCompare); + } + }); + + test('comparison mode', () async { + final origDir = Directory.current; + Directory.current = 'example'; + final configPath = 'screenshots.yaml'; + final configInfo = Config(configPath: configPath).configInfo; + final recordingDir = configInfo['recording']; + expect(await utils.isRecorded(recordingDir), isTrue); + await run.run(configPath, utils.getStringFromEnum(RunMode.comparison)); + Directory.current = origDir; + }, timeout: Timeout(Duration(seconds: 180))); + + test('cleanup diffs at start of normal run', () { + final fastlaneDir = 'test/resources/comparison'; + Directory(fastlaneDir).listSync().forEach( + (fsEntity) => File(im.getDiffName(fsEntity.path)).createSync()); + expect( + Directory(fastlaneDir).listSync().where((fileSysEntity) => + p.basename(fileSysEntity.path).contains(im.diffSuffix)), + isNotEmpty); + im.deleteDiffs(fastlaneDir); + expect( + Directory(fastlaneDir).listSync().where((fileSysEntity) => + p.basename(fileSysEntity.path).contains(im.diffSuffix)), + isEmpty); + }); + }); + + group('archiving', () { + test('run with archiving enabled', () async { + final origDir = Directory.current; + Directory.current = 'example'; + final configPath = 'screenshots.yaml'; + await run.run(configPath, utils.getStringFromEnum(RunMode.archive)); + Directory.current = origDir; + }, timeout: Timeout(Duration(seconds: 180))); + }); + + group('fastlane dirs', () { + test('delete files matching a pattern', () { + final dirPath = 'test/resources/test'; + final deviceId = 'Nexus 6P'; + final pattern = RegExp('$deviceId.*.$kImageExtension'); + final filesPresent = (dirPath, pattern) => Directory(dirPath) + .listSync() + .toList() + .where((e) => pattern.hasMatch(p.basename(e.path))); + expect(filesPresent(dirPath, pattern).length, 2); + fastlane.deleteMatchingFiles(dirPath, pattern); + expect(filesPresent(dirPath, pattern), isEmpty); + // restore deleted files + run.cmd('git', ['checkout', dirPath]); + }); + }); + + group('adb path', () { + test('find adb path', () async { + final _adbPath = getAdbPath(); + print('adbPath=$_adbPath'); + }); + }); + group('manage device orientation', () { test('find ios simulator orientation', () async { final udId = '03D4FC12-3927-4C8B-A226-17DE34AE9C18'; @@ -594,7 +696,7 @@ devices: final scriptDir = 'lib/resources/script'; final simulatorName = 'iPhone 7 Plus'; final simulatorInfo = - utils.getHighestIosSimulator(utils.getIosSimulators(), simulatorName); + utils.getHighestIosSimulator(utils.getIosSimulators(), simulatorName); final deviceId = simulatorInfo['udid']; run.startSimulator(deviceId); final daemonClient = DaemonClient(); @@ -639,9 +741,9 @@ Future waitForEmulatorToStart( while (!started) { final devices = await daemonClient.devices; final device = devices.firstWhere( - (device) => device['name'] == simulatorName && device['emulator'], + (device) => device['name'] == simulatorName && device['emulator'], orElse: () => null); started = device != null; await Future.delayed(Duration(milliseconds: 1000)); } -} +} \ No newline at end of file diff --git a/test/screenshots_yaml_test.dart b/test/screenshots_yaml_test.dart index 03874f99..de9a172d 100644 --- a/test/screenshots_yaml_test.dart +++ b/test/screenshots_yaml_test.dart @@ -109,7 +109,7 @@ void main() { final Screens screens = Screens(); await screens.init(); final Map config = loadYaml(screenshotsYaml); - await fastlane.clearFastlaneDirs(config, screens); + await fastlane.clearFastlaneDirs(config, screens, RunMode.normal); }); test('check if frame is needed', () { diff --git a/test/statusbar_color_test.dart b/test/statusbar_color_test.dart index ee384ac4..4a3f1dbd 100644 --- a/test/statusbar_color_test.dart +++ b/test/statusbar_color_test.dart @@ -1,4 +1,4 @@ -import 'package:screenshots/src/image_magick.dart' as im; +import 'package:screenshots/src/globals.dart'; import 'package:test/test.dart'; main() {