// 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. // @dart = 2.6 import 'dart:io' as io; import 'dart:convert'; import 'dart:math'; import 'package:image/image.dart'; import 'package:path/path.dart' as path; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' as wip; import 'package:yaml/yaml.dart'; import 'common.dart'; import 'environment.dart'; import 'safari_installation.dart'; import 'utils.dart'; /// [ScreenshotManager] implementation for Chrome. /// /// This manager can be used for both macOS and Linux. // TODO: https://github.com/flutter/flutter/issues/65673 class ChromeScreenshotManager extends ScreenshotManager { String get filenameSuffix => ''; /// Capture a screenshot of the web content. /// /// Uses Webkit Inspection Protocol server's `captureScreenshot` API. /// /// [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. Future capture(Rectangle region) async { final wip.ChromeConnection chromeConnection = wip.ChromeConnection('localhost', kDevtoolsPort); final wip.ChromeTab chromeTab = await chromeConnection.getTab( (wip.ChromeTab chromeTab) => chromeTab.url.contains('localhost')); final wip.WipConnection wipConnection = await chromeTab.connect(); Map captureScreenshotParameters = null; if (region != null) { captureScreenshotParameters = { 'format': 'png', 'clip': { 'x': region.left, 'y': region.top, 'width': region.width, 'height': region.height, 'scale': // This is NOT the DPI of the page, instead it's the "zoom level". 1, }, }; } // Setting hardware-independent screen parameters: // https://chromedevtools.github.io/devtools-protocol/tot/Emulation await wipConnection .sendCommand('Emulation.setDeviceMetricsOverride', { 'width': kMaxScreenshotWidth, 'height': kMaxScreenshotHeight, 'deviceScaleFactor': 1, 'mobile': false, }); final wip.WipResponse response = await wipConnection.sendCommand( 'Page.captureScreenshot', captureScreenshotParameters); final Image screenshot = decodePng(base64.decode(response.result['data'] as String)); return screenshot; } } /// [ScreenshotManager] implementation for Safari. /// /// This manager will only be created/used for macOS. class IosSafariScreenshotManager extends ScreenshotManager { String get filenameSuffix => '.iOS_Safari'; IosSafariScreenshotManager() { final YamlMap browserLock = BrowserLock.instance.configuration; _heightOfHeader = browserLock['ios-safari']['heightOfHeader'] as int; _heightOfFooter = browserLock['ios-safari']['heightOfFooter'] as int; _scaleFactor = browserLock['ios-safari']['scaleFactor'] as double; /// 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)]. 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. 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). 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. Future capture(Rectangle region) async { final String filename = 'screenshot${_fileNameCounter}.png'; _fileNameCounter++; await IosSafariArgParser.instance.iosSimulator.takeScreenshot( filename, environment.webUiSimulatorScreenshotsDirectory); final io.File file = io.File(path.join( environment.webUiSimulatorScreenshotsDirectory.path, filename)); List 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 Rectangle 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]. Rectangle _scaleScreenshotRegion(Rectangle region) { return Rectangle( region.left * _scaleFactor, region.top * _scaleFactor, region.width * _scaleFactor, region.height * _scaleFactor, ); } } const String _kBrowserChrome = 'chrome'; const String _kBrowserIOSSafari = 'ios-safari'; typedef ScreenshotManagerFactory = ScreenshotManager Function(); /// Abstract class for taking screenshots in one of the browsers. abstract class ScreenshotManager { static final Map _browserFactories = { _kBrowserChrome: () => ChromeScreenshotManager(), _kBrowserIOSSafari: () => IosSafariScreenshotManager(), }; static bool isBrowserSupported(String browser) => _browserFactories.containsKey(browser); static ScreenshotManager choose(String browser) { if (isBrowserSupported(browser)) { return _browserFactories[browser](); } throw StateError('Screenshot tests are only supported on Chrome and on ' 'iOS Safari'); } /// Capture a screenshot. /// /// Please read the details for the implementing classes. Future capture(Rectangle region); /// Suffix to be added to the end of the filename. /// /// Example file names: /// - Chrome, no-suffix: backdrop_filter_clip_moved.actual.png /// - iOS Safari: backdrop_filter_clip_moved.actual.iOS_Safari.png String get filenameSuffix; }