blob: 610c57def11efa250b5a9c32f55d213076ebeab0 [file] [log] [blame]
// Copyright 2019 The Fuchsia 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:convert';
import 'dart:io';
import 'dart:math';
// ignore_for_file: import_of_legacy_library_into_null_safe
import 'package:fidl_fuchsia_input/fidl_async.dart';
import 'package:fidl_fuchsia_ui_input3/fidl_async.dart' hide KeyEvent;
import 'package:flutter_driver/flutter_driver.dart';
import 'package:flutter_driver_sl4f/flutter_driver_sl4f.dart';
import 'package:image/image.dart' hide Point;
import 'package:sl4f/sl4f.dart';
import 'package:test/test.dart';
const simpleBrowserUrl =
'fuchsia-pkg://fuchsia.com/simple-browser#meta/simple-browser.cmx';
const terminalUrl = 'fuchsia-pkg://fuchsia.com/terminal#meta/terminal.cmx';
const stashCtlUrl = 'fuchsia-pkg://fuchsia.com/stash_ctl#meta/stash_ctl.cmx';
const kLoginInspectSelector =
'core/session-manager/session\\:session/workstation_session/login_shell';
const kErmineInspectSelector =
'core/session-manager/session\\:session/workstation_session/login_shell/ermine_shell';
// USB HID code for ENTER key.
// See <https://www.usb.org/sites/default/files/documents/hut1_12v2.pdf>
const kEnterKey = 40;
const kBackspaceKey = 0x2a;
const waitForTimeout = Duration(seconds: 30);
/// Defines a completion function that can be waited on with a timout.
typedef WaitForCompletion<T> = Future<T> Function();
/// Defines a test utility class to drive Ermine during integration test using
/// Flutter Driver. This utility will grow with more convenience methods in the
/// future useful for testing.
class ErmineDriver {
/// The instance of [Sl4f] used to connect to Ermine flutter app.
final Sl4f sl4f;
final Component _component;
FlutterDriver? _driver;
FlutterDriver? _login;
final FlutterDriverConnector _connector;
/// Constructor.
ErmineDriver(this.sl4f)
: _connector = FlutterDriverConnector(sl4f),
_component = Component(sl4f);
/// The instance of [FlutterDriver] that is connected to Ermine flutter app.
FlutterDriver get driver => _driver!;
/// The instance of [FlutterDriver] that is connected to Login flutter app.
FlutterDriver get login => _login!;
/// The instance of [Component] that is connected to the DUT.
Component get component => _component;
/// Set up the test environment for Ermine.
///
/// This restarts the workstation session and connects to the running instance
/// of Ermine using FlutterDriver.
Future<void> setUp() async {
// Restart the workstation session.
// TODO(fxb/87746): Temporarily remove to see if it causes the issue.
// final result = await sl4f.ssh.run('session_control restart');
// if (result.exitCode != 0) {
// fail('failed to restart workstation session.');
// }
// Initialize Ermine's flutter driver and web driver connectors.
await _connector.initialize();
print('Flutter driver connector initialized');
// Connect to login shell's flutter driver.
_login = await connectToFlutterDriver('login', kLoginInspectSelector,
(snapshot) => snapshot['ready'] == true);
if (_login == null) {
fail('Unable to connect to login shell.');
}
print('Connected to login shell');
// Now connect to ermine.
_driver = await authenticate();
print('Driver is connected to Ermine');
}
/// Closes [FlutterDriverConnector] and performs cleanup.
Future<void> tearDown() async {
final result = await sl4f.ssh.run('session_control restart');
if (result.exitCode != 0) {
fail('failed to restart workstation session: ${result.exitCode},'
' stderr: ${result.stderr}, stdout: ${result.stdout}');
}
await _driver?.close();
await _connector.tearDown();
}
/// Launch a component given its [componentUrl].
Future<bool> launch(String componentUrl,
{Duration timeout = waitForTimeout}) async {
final result = await sl4f.ssh.run('session_control add $componentUrl');
if (result.exitCode != 0) {
fail('failed to launch component: $componentUrl.');
}
final running = await isRunning(componentUrl, timeout: timeout);
if (!running) {
fail('Timed out waiting to launch $componentUrl');
}
return running;
}
/// Launch a component given its name from the App Launcher.
Future<void> launchFromAppLauncher(String title) async {
// Bring up the App Launcher overlay.
await driver.requestData('launcher');
await driver.waitUntilNoTransientCallbacks();
await waitForOverlays();
final titleFinder = find.descendant(
of: find.byType('AppLauncher'),
matching: find.text(title),
);
await driver.tap(titleFinder);
await driver.waitUntilNoTransientCallbacks();
}
/// Returns true if a component is running.
Future<bool> isRunning(String componentUrl,
{Duration timeout = waitForTimeout}) async {
return waitFor(() async {
return (await component.list())
.where((e) => e.contains(componentUrl))
.isNotEmpty;
}, timeout: timeout);
}
/// Returns true if a component is stopped.
Future<bool> isStopped(String componentUrl,
{Duration timeout = waitForTimeout}) async {
return waitFor(() async {
return (await component.list())
.where((e) => e.contains(componentUrl))
.isEmpty;
}, timeout: timeout);
}
/// Waits until the overlays are visible, timesout otherwise.
Future<bool> waitForOverlays() {
return waitFor(() async {
final data = await snapshot;
return data.overlaysVisible;
});
}
/// Got to the Overview screen.
Future<void> gotoOverview() async {
await _driver!.requestData('overview');
await _driver!.waitUntilNoTransientCallbacks(timeout: Duration(seconds: 2));
await _driver!.waitFor(find.byValueKey('overview'));
}
/// Enters text into Ask bar.
/// Optionally clear existing content and goto Ask in Overview.
Future<void> enterTextInAsk(
String text, {
bool clear = true,
bool gotoOverview = false,
}) async {
final input = Input(sl4f);
if (gotoOverview) {
await this.gotoOverview();
} else {
// Invoke Ask using keyboard shortcut.
await twoKeyShortcut(Key.leftAlt, Key.space);
await driver.waitFor(find.byType('Ask'));
}
if (clear) {
await driver.requestData('clear');
await driver.waitUntilNoTransientCallbacks();
// Add a space and delete using backspace to resolve auto-complete.
await input.text(' ');
await input.keyPress(kBackspaceKey);
await driver.waitUntilNoTransientCallbacks();
}
await input.text(text);
// Verify text was injected into flutter widgets.
await driver.waitUntilNoTransientCallbacks();
await driver.waitFor(find.text(text));
final askResult = await driver.getText(find.descendant(
of: find.byType('AskTextField'),
matching: find.text(text),
));
expect(askResult, text);
}
/// Tap the location given by [offset] in screen co-ordinates.
///
/// Normalize to screen size of 1000x1000 expected by [Input.tap].
Future<void> tap(DriverOffset offset, {bool normalize = true}) async {
var point = Point<int>(offset.dx.toInt(), offset.dy.toInt());
if (normalize) {
// Get the size of screen by getting the size of the App widget.
final screen = await driver.getBottomRight(find.byType('App'));
point = Point<int>(
(offset.dx * 1000) ~/ screen.dx,
(offset.dy * 1000) ~/ screen.dy,
);
}
final input = Input(sl4f);
await input.tap(point);
}
/// Invoke a two key keyboard shortcut.
Future<void> twoKeyShortcut(Key modifier, Key key) async {
const key1Press = Duration(milliseconds: 100);
const key2Press = Duration(milliseconds: 200);
const key2Release = Duration(milliseconds: 400);
const key1Release = Duration(milliseconds: 600);
final input = Input(sl4f);
await input.keyEvents([
KeyEvent(modifier, key1Press, KeyEventType.pressed),
KeyEvent(key, key2Press, KeyEventType.pressed),
KeyEvent(key, key2Release, KeyEventType.released),
KeyEvent(modifier, key1Release, KeyEventType.released),
]);
await driver.waitUntilNoTransientCallbacks();
}
/// Invoke a three key keyboard shortcut.
Future<void> threeKeyShortcut(Key modifier1, Key modifier2, Key key) async {
const key1Press = Duration(milliseconds: 100);
const key2Press = Duration(milliseconds: 200);
const key3Press = Duration(milliseconds: 300);
const key3Release = Duration(milliseconds: 500);
const key2Release = Duration(milliseconds: 600);
const key1Release = Duration(milliseconds: 700);
final input = Input(sl4f);
await input.keyEvents([
KeyEvent(modifier1, key1Press, KeyEventType.pressed),
KeyEvent(modifier2, key2Press, KeyEventType.pressed),
KeyEvent(key, key3Press, KeyEventType.pressed),
KeyEvent(key, key3Release, KeyEventType.released),
KeyEvent(modifier2, key2Release, KeyEventType.released),
KeyEvent(modifier1, key1Release, KeyEventType.released),
]);
await driver.waitUntilNoTransientCallbacks();
}
/// Launches a simple browser and returns a [FlutterDriver] connected to it.
Future<FlutterDriver> launchSimpleBrowser() async {
expect(await launch(simpleBrowserUrl), isTrue);
print('Launched a browser');
// Initializes the browser's flutter driver connector.
final browserConnector = FlutterDriverConnector(sl4f);
await browserConnector.initialize();
print('Initialized a flutter driver connector for the browser.');
// Checks if Simple Browser is running.
// TODO(fxb/66577): Get the last isolate once it's supported by
// [FlutterDriverConnector] in flutter_driver_sl4f.dart
final browserIsolate = await browserConnector.isolate('simple-browser');
// ignore: unnecessary_null_comparison
if (browserIsolate == null) {
fail('couldn\'t find simple browser.');
}
print('Checked that the browser is running.');
// Connects to the browser.
// TODO(fxb/66577): Get the driver of the last isolate once it's supported by
// [FlutterDriverConnector] in flutter_driver_sl4f.dart
final browserDriver = await browserConnector.driverForIsolateBySelector(
'simple-browser', 'simple-browser.cmx');
// ignore: unnecessary_null_comparison
if (browserDriver == null) {
fail('unable to connect to simple browser.');
}
print('Connected the browser to a flutter driver.');
return browserDriver;
}
/// Launches a simple browser and sets up options for test convenience.
///
/// Opens another new tab as soon as the browser is launched, unless you set
/// [openNewTab] to false. Contrarily, set [fullscreen] to true if you want
/// the browser to expand its size to full-screen upon its launch.
/// Also, you can set the text entry emulation of the browser's flutter driver
/// using [enableTextEntryEmulation], which has false by default.
Future<FlutterDriver> launchAndWaitForSimpleBrowser({
bool openNewTab = true,
bool enableTextEntryEmulation = false,
}) async {
final browserDriver = await launchSimpleBrowser();
// Set the flutter driver's text entry emulation.
await browserDriver.setTextEntryEmulation(
enabled: enableTextEntryEmulation);
print('Text entry emulation is enabled for the browser.');
// Opens another tab other than the tab opened on browser's launch,
// if required.
if (openNewTab) {
final addTab = find.byValueKey('new_tab');
await browserDriver.waitFor(addTab);
await browserDriver.tap(addTab);
await browserDriver.waitFor(find.text('NEW TAB'),
timeout: Duration(seconds: 10));
print('Opened a new tab');
} else {
await browserDriver.waitFor(find.text(' SEARCH'),
timeout: Duration(seconds: 10));
print('The first tab is ready.');
}
await browserDriver.waitUntilFirstFrameRasterized();
await browserDriver.waitUntilNoTransientCallbacks();
print('No further transient callbacks.');
return browserDriver;
}
Future<Rectangle> getViewRect(String viewUrl,
[Duration timeout = waitForTimeout]) async {
final view = await waitForView(viewUrl, timeout: timeout);
return view.viewport;
}
/// Finds the first launched component given its [viewUrl] and returns it's
/// Inspect data. Waits for [timeout] duration for view to launch. If
/// [testForFocus] is true, waits for focused signal.
Future<ViewSnapshot> waitForView(String viewUrl,
{bool testForFocus = false, Duration timeout = waitForTimeout}) async {
return waitFor(() async {
final views = await launchedViews(filterByUrl: viewUrl);
if (views.isEmpty) {
return null;
}
if (testForFocus) {
return views.first.focused ? views.first : null;
}
return views.first;
}, timeout: timeout);
}
Future<bool> waitForViewAbsent(String viewUrl,
[Duration timeout = waitForTimeout]) async {
return waitFor(() async {
final views = await launchedViews(filterByUrl: viewUrl);
return views.isEmpty;
});
}
Future<Map<String, dynamic>> inspectSnapshot(
String componentSelector, {
Duration timeout = waitForTimeout,
bool predicate(Map<String, dynamic> snapshot)?,
}) {
return waitFor(() async {
final snapshot = await Inspect(sl4f).snapshotRoot(componentSelector);
// Supply a default predicate that simply returns true.
predicate ??= (_) => true;
// ignore: unnecessary_null_comparison
return snapshot == null || snapshot.isEmpty || !predicate!(snapshot)
? null
: snapshot;
}, timeout: timeout);
}
/// Returns the current shell snapshot from inspect data.
Future<ShellSnapshot> get snapshot async {
final data = await driver.requestData('inspect');
return ShellSnapshot(json.decode(data));
}
/// Returns the last keyboard shortcut action received by ermine shell.
Future<String> get lastAction async => (await snapshot).lastAction;
/// Waits for last action to match the supplied value.
Future<bool> waitForAction(String action,
{Duration timeout = waitForTimeout}) async {
return waitFor(() async {
return (await lastAction) == action;
}, timeout: timeout);
}
/// Returns the list of launched views from inspect data.
Future<List<ViewSnapshot>> get views async => (await snapshot).views;
/// Returns [Inspect] data for all launched views.
Future<List<ViewSnapshot>> launchedViews({String? filterByUrl}) async {
final allViews = await views;
return filterByUrl == null
? allViews
: allViews.where((view) => view.url == filterByUrl).toList();
}
/// Take a screenshot of a View given its screen co-ordinates.
Future<Image> screenshot(Rectangle rect) async {
final scenic = Scenic(sl4f);
final image = await scenic.takeScreenshot(dumpName: 'ermine');
return copyCrop(
image,
rect.left.toInt(),
rect.top.toInt(),
rect.width.toInt(),
rect.height.toInt(),
);
}
/// Returns a histogram, i.e. occurences of colors, in an image.
/// [Color] is encoded as 0xAABBGGRR.
Map<int, int> histogram(Image image) {
final colors = <int, int>{};
for (int j = 0; j < image.height; j++) {
for (int i = 0; i < image.width; i++) {
final color = image.getPixel(i, j);
colors[color] = (colors[color] ?? 0) + 1;
}
}
return colors;
}
/// Returns the difference rate between two same-sized images.
/// The range is from 0 to 1, and the closer to 0 the rate is, the more
/// identical the two images.
double screenshotsDiff(Image a, Image b) {
expect(
a.data.length,
b.data.length,
reason: 'The resolution of two images are different',
);
var diff = 0;
for (var i = 0; i < a.data.length; i++) {
if (a.data[i] != b.data[i]) {
diff++;
}
}
final diffRate = (diff / a.data.length);
return diffRate;
}
/// Saves a screenshot of a View as a png image file.
///
/// Mainly used to create initial golden images for image diff tests.
/// To do this, call it in your `test()` before writing your image-diff test.
/// For example,
/// ```
/// test('Image diff test' () async {
/// ErmineDriver ermine = ErmineDriver(sl4f);
/// await ermine.launch(componentUrl);
/// final viewRect = await ermine.getViewRect(componentUrl);
/// final screenshot = await ermine.screenshot(viewRect);
///
/// ermine.saveImageAs(screenshot, 'screenshot.png');
/// });
/// ```
/// You will be able to find the output files under //out/default/ on your host
/// machine once you run the test successfully. If they look as you want,
/// move them under //src/experiences/tests/e2e/test/scuba_goldens so that
/// you can use them as your golden images. Once you have them there and in
/// BUILD, you are good to write your image diff test using [screenshot] and
/// [goldenDiff] and remove this method call.
///
/// Note that due to the size limit of the data used for the communication
/// between sl4f and the host, the screenshots over 4MB in size will be
/// cropped out to 1536x864 (fxb/70233).
void saveImageAs(Image image, String file) async {
final fileName = _sanitizeGoldenFileName(file);
File(fileName).writeAsBytesSync(encodePng(image));
}
/// Returns the difference rate between an image and the correspondant golden
/// image stored in the host. The range is from 0 to 1, and the closer to 0
/// the rate is, the more identical the two images.
double goldenDiff(Image image, String golden) {
final goldenFileName = _sanitizeGoldenFileName(golden);
final goldenFilePath = 'dartlang/scuba_goldens/$goldenFileName';
final goldenFile = File(goldenFilePath);
expect(goldenFile.existsSync(), isTrue,
reason: 'No such file or directory: $goldenFilePath');
final goldenImage = decodePng(goldenFile.readAsBytesSync());
if (goldenImage == null) {
return 1;
}
if (image.length != golden.length) {
final resizedImage = copyResize(
image,
width: goldenImage.width,
height: goldenImage.height,
);
return screenshotsDiff(resizedImage, goldenImage);
}
return screenshotsDiff(image, goldenImage);
}
String _sanitizeGoldenFileName(String file) {
if (file.contains('.')) {
final splits = file.split('.');
expect(splits.length, 2,
reason: 'The golden file name can contain only one dot(.) '
'for its extension.');
final fileType = splits.last;
expect(fileType.toLowerCase(), 'png',
reason: 'The file type should be png');
return file;
} else {
return '$file.png';
}
}
/// A helper function to wait for completion of a computation within timeout.
/// The computation is repeated until it returns a boolean true or non-null
/// result or the timeout expires. It throws a [TimeoutException] if the
/// timeout expires.
Future<T> waitFor<T>(WaitForCompletion<T?> completion,
{Duration timeout = waitForTimeout}) async {
final end = DateTime.now().add(timeout);
T? result;
while (DateTime.now().isBefore(end)) {
result = await completion();
if (result == null || result is bool && result == false) {
// Add a delay so as not to spam the system.
await Future.delayed(Duration(seconds: 1));
continue;
}
return result;
}
// We ran out of time.
throw TimeoutException('waitFor timeout expired', timeout);
}
/// Connects to [FlutterDriver] for isolate [name] with [selector];
Future<FlutterDriver> connectToFlutterDriver(String name, String selector,
[bool predicate(Map<String, dynamic> snapshot)?]) async {
// Connect to Login flutter app.
print('Connecting to $name flutter isolate');
await inspectSnapshot(selector, predicate: predicate);
final driver = await _connector.driverForIsolateBySelector(name, selector);
// Wait for shell to draw first frame.
await driver.waitUntilFirstFrameRasterized();
print('The first frame has been rasterized');
// Wait until rendering stabilizes and animations settle.
await driver.waitUntilNoTransientCallbacks();
print('No further transient callbacks. $name is ready.');
return driver;
}
/// If the workstation build is enabled for authentication through the login
/// shell, go through the create password or login flow.
///
/// Returns [true] is successfully performed authentication flows.
Future<FlutterDriver> authenticate() async {
// Check if login shell's inspect data is published and is 'ready'.
final snapshot = await inspectSnapshot(kLoginInspectSelector,
predicate: (snapshot) => snapshot['ready'] == true);
// Check if OOBE is skipped or shown.
if (snapshot['launchOOBE'] == true) {
// Check if auth status is authenticated. If no, start auth flow.
if (snapshot['authenticated'] != true) {
// Check if create password is visible.
print('Performing authentication...');
const testPassword = '11223344';
if (snapshot['screen'] == 'password') {
print('Creating password...');
// Tap on the first password text field.
var passwordTextField = find.byValueKey('password1');
await enterText(testPassword,
driver: login, textField: passwordTextField);
print('password 1 done');
// Tap on the second password text field.
passwordTextField = find.byValueKey('password2');
await enterText(testPassword,
driver: login, textField: passwordTextField);
print('password 2 done');
// Tap the Set Password button and wait for auth to succeed.
await login.tap(find.byValueKey('setPassword'));
await login.waitForAbsent(find.byType('Password'),
timeout: Duration(minutes: 2));
print('Password set');
// Tap through the 'Start Workstation' screen.
await login.tap(find.byValueKey('startWorkstation'));
await login.waitForAbsent(find.byType('Ready'));
} else {
print('Adding login credentials...');
// Tap on the password text field.
var passwordTextField = find.byValueKey('password');
await enterText(testPassword,
driver: login, textField: passwordTextField);
print('password entered');
// Tap the Login button and wait for auth to succeed.
await login.tap(find.byValueKey('login'));
await login.waitForAbsent(find.byType('Login'),
timeout: Duration(minutes: 2));
print('password entered');
}
}
}
print('Starting ermine shell');
await inspectSnapshot(kLoginInspectSelector,
predicate: (snapshot) => snapshot['ermineReady'] == true);
// We should land on the Ermine shell.
await login.waitUntilNoTransientCallbacks();
await login.waitFor(find.byType('ErmineApp'));
return connectToFlutterDriver('ermine', kErmineInspectSelector);
}
/// Performs a logout followed by login authentication flow.
///
/// This is done in one flow to ensure ermine's flutter [_driver] is valid at
/// the end of the flow.
Future<void> logoutAndLogin() async {
// Ensure that ermine shell is running.
await login.waitFor(find.byType('ErmineApp'));
await inspectSnapshot(kErmineInspectSelector);
await driver.waitUntilNoTransientCallbacks();
// Send logout action
await driver.requestData('logout');
await driver.waitUntilNoTransientCallbacks();
// Tap 'Log out' button to confirm logout dialog, but don't wait for it to
// complete because ermine is killed.
// ignore: unawaited_futures
driver.tap(find.text('LOGOUT')).catchError((_) {});
// ignore: unawaited_futures
driver.close();
// If launchOOBE is true, wait for the login screen.
final snapshot = await inspectSnapshot(kLoginInspectSelector,
predicate: (snapshot) => snapshot['ready'] == true);
if (snapshot['launchOOBE'] == true) {
await login.waitUntilNoTransientCallbacks();
await login.waitFor(find.byType('Login'));
}
// Now log back in and set the new flutter driver connection to ermine.
_driver = await authenticate();
}
/// Enters text using [Input] to [textField] using [driver].
Future<void> enterText(
String text, {
required FlutterDriver driver,
required SerializableFinder textField,
}) async {
final input = Input(sl4f);
// Tap on the text field.
await driver.tap(textField);
await input.text(text);
// Wait for input to make to the text field.
await waitFor(() async {
return (await driver.getText(textField)) == text;
});
}
}
/// Holds Ermine shell state which is derived from inspect data.
class ShellSnapshot {
final Map<String, dynamic> inspectData;
ShellSnapshot(this.inspectData);
int get numViews => inspectData['numViews'] ?? 0;
bool get appBarVisible => inspectData['appBarVisible'] == true;
bool get sideBarVisible => inspectData['sideBarVisible'] == true;
bool get overlaysVisible => inspectData['overlaysVisible'] == true;
String get lastAction => inspectData['lastAction'] ?? '';
bool get darkMode => inspectData['darkMode'] ?? true;
ViewSnapshot? get activeView =>
numViews > 0 ? views[inspectData['activeView'] ?? 0] : null;
List<ViewSnapshot> get views {
final result = <ViewSnapshot>[];
for (int i = 0; i < numViews; i++) {
result.add(ViewSnapshot(inspectData['view-$i']));
}
return result;
}
}
/// Holds a launched view's state which is derived from inspect data.
class ViewSnapshot {
final Map<String, dynamic> inspectData;
ViewSnapshot(this.inspectData);
bool get focused => inspectData['focused'] == true;
String get title => inspectData['title'] ?? '';
String get url => inspectData['url'] ?? '';
Rectangle get viewport {
final viewRect = inspectData['viewportLTRB'];
if (viewRect != null) {
return Rectangle.fromPoints(
Point(viewRect[0], viewRect[1]),
Point(viewRect[2], viewRect[3]),
);
}
return Rectangle(0, 0, 0, 0);
}
}