| // Copyright 2013 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:io' as io; |
| import 'dart:math' as math; |
| |
| import 'package:image/image.dart'; |
| import 'package:path/path.dart' as path; |
| import 'package:test_api/src/backend/runtime.dart'; |
| |
| import 'browser.dart'; |
| import 'browser_lock.dart'; |
| import 'environment.dart'; |
| import 'safari_installation.dart'; |
| import 'utils.dart'; |
| |
| /// Provides an environment for the mobile variant of Safari running in an iOS |
| /// simulator. |
| class SafariIosEnvironment implements BrowserEnvironment { |
| @override |
| Browser launchBrowserInstance(Uri url, {bool debug = false}) { |
| return SafariIos(url); |
| } |
| |
| @override |
| Runtime get packageTestRuntime => Runtime.safari; |
| |
| @override |
| Future<void> prepare() async { |
| await initIosSimulator(); |
| } |
| |
| @override |
| ScreenshotManager? getScreenshotManager() { |
| return SafariIosScreenshotManager(); |
| } |
| |
| @override |
| String get packageTestConfigurationYamlFile => 'dart_test_safari.yaml'; |
| } |
| |
| /// Runs an instance of Safari for iOS (i.e. mobile Safari). |
| /// |
| /// Most of the communication with the browser is expected to happen via HTTP, |
| /// so this exposes a bare-bones API. The browser starts as soon as the class is |
| /// constructed, and is killed when [close] is called. |
| /// |
| /// Any errors starting or running the process are reported through [onExit]. |
| class SafariIos extends Browser { |
| @override |
| final String name = 'Safari iOS'; |
| |
| /// Starts a new instance of Safari open to the given [url], which may be a |
| /// [Uri]. |
| factory SafariIos(Uri url) { |
| return SafariIos._(() async { |
| // iOS-Safari |
| // Uses `xcrun simctl`. It is a command line utility to control the |
| // Simulator. For more details on interacting with the simulator: |
| // https://developer.apple.com/library/archive/documentation/IDEs/Conceptual/iOS_Simulator_Guide/InteractingwiththeiOSSimulator/InteractingwiththeiOSSimulator.html |
| final io.Process process = await io.Process.start('xcrun', <String>[ |
| 'simctl', |
| 'openurl', // Opens the url on Safari installed on the simulator. |
| 'booted', // The simulator is already booted. |
| url.toString(), |
| ]); |
| |
| return process; |
| }); |
| } |
| |
| SafariIos._(Future<io.Process> Function() startBrowser) : super(startBrowser); |
| } |
| |
| /// [ScreenshotManager] implementation for Safari. |
| /// |
| /// This manager will only be created/used for macOS. |
| class SafariIosScreenshotManager extends ScreenshotManager { |
| @override |
| String get filenameSuffix => '.iOS_Safari'; |
| |
| SafariIosScreenshotManager() { |
| final SafariIosLock lock = browserLock.safariIosLock; |
| _heightOfHeader = lock.heightOfHeader; |
| _heightOfFooter = lock.heightOfFooter; |
| _scaleFactor = lock.scaleFactor; |
| |
| /// Create the directory to use for taking screenshots, if it does not |
| /// exists. |
| if (!environment.webUiSimulatorScreenshotsDirectory.existsSync()) { |
| environment.webUiSimulatorScreenshotsDirectory.createSync(); |
| } |
| // Temporary directories are deleted in the clenaup phase of after `felt` |
| // runs the tests. |
| temporaryDirectories.add(environment.webUiSimulatorScreenshotsDirectory); |
| } |
| |
| /// This scale factor is used to enlarge/shrink the screenshot region |
| /// sent from the tests. |
| /// For more details see [_scaleScreenshotRegion(region)]. |
| late final double _scaleFactor; |
| |
| /// Height of the part to crop from the top of the image. |
| /// |
| /// `xcrun simctl` command takes the screenshot of the entire simulator. We |
| /// are cropping top bit from screenshot, otherwise due to the clock on top of |
| /// the screen, the screenshot will differ between each run. |
| /// Note that this gap can change per phone and per iOS version. For more |
| /// details refer to `browser_lock.yaml` file. |
| late final int _heightOfHeader; |
| |
| /// Height of the part to crop from the bottom of the image. |
| /// |
| /// This area is the footer navigation bar of the phone, it is not the area |
| /// used by tests (which is inside the browser). |
| late final int _heightOfFooter; |
| |
| /// Used as a suffix for the temporary file names used for screenshots. |
| int _fileNameCounter = 0; |
| |
| /// Capture a screenshot of entire simulator. |
| /// |
| /// Example screenshot with dimensions: W x H. |
| /// |
| /// <---------- W -------------> |
| /// _____________________________ |
| /// | Phone Top bar (clock etc.) | Ʌ |
| /// |_____________________________| | |
| /// | Broswer search bar | | |
| /// |_____________________________| | |
| /// | Web page content | | |
| /// | | | |
| /// | | |
| /// | | H |
| /// | | |
| /// | | | |
| /// | | | |
| /// | | | |
| /// | | | |
| /// |_____________________________| | |
| /// | Phone footer bar | | |
| /// |_____________________________| V |
| /// |
| /// After taking the screenshot, the image is cropped as heigh as |
| /// [_heightOfHeader] and [_heightOfFooter] from the top and bottom parts |
| /// consecutively. Hence web content has the dimensions: |
| /// |
| /// W x (H - [_heightOfHeader] - [_heightOfFooter]) |
| /// |
| /// [region] is used to decide which part of the web content will be used in |
| /// test image. It includes starting coordinate x,y as well as height and |
| /// width of the area to capture. |
| /// |
| /// Uses simulator tool `xcrun simctl`'s 'screenshot' command. |
| @override |
| Future<Image> capture(math.Rectangle<num>? region) async { |
| final String filename = 'screenshot$_fileNameCounter.png'; |
| _fileNameCounter++; |
| |
| await iosSimulator.takeScreenshot( |
| filename, environment.webUiSimulatorScreenshotsDirectory, |
| ); |
| |
| final io.File file = io.File(path.join( |
| environment.webUiSimulatorScreenshotsDirectory.path, filename)); |
| List<int> imageBytes; |
| if (!file.existsSync()) { |
| throw Exception('Failed to read the screenshot ' |
| 'screenshot$_fileNameCounter.png.'); |
| } |
| imageBytes = await file.readAsBytes(); |
| file.deleteSync(); |
| |
| final Image screenshot = decodePng(imageBytes)!; |
| // Create an image with no footer and header. The _heightOfHeader, |
| // _heightOfFooter values are already in real coordinates therefore |
| // they don't need to be scaled. |
| final Image content = copyCrop( |
| screenshot, |
| 0, |
| _heightOfHeader, |
| screenshot.width, |
| screenshot.height - _heightOfFooter - _heightOfHeader, |
| ); |
| |
| if (region == null) { |
| return content; |
| } else { |
| final math.Rectangle<num> scaledRegion = _scaleScreenshotRegion(region); |
| return copyCrop( |
| content, |
| scaledRegion.left.toInt(), |
| scaledRegion.top.toInt(), |
| scaledRegion.width.toInt(), |
| scaledRegion.height.toInt(), |
| ); |
| } |
| } |
| |
| /// Perform a linear transform on the screenshot region to convert its |
| /// dimensions from linear coordinated to coordinated on the phone screen. |
| /// This uniform/isotropic scaling is done using [_scaleFactor]. |
| math.Rectangle<num> _scaleScreenshotRegion(math.Rectangle<num> region) { |
| return math.Rectangle<num>( |
| region.left * _scaleFactor, |
| region.top * _scaleFactor, |
| region.width * _scaleFactor, |
| region.height * _scaleFactor, |
| ); |
| } |
| } |