From 6162c41d1b4748391a62a5778af67f151bb2a81b Mon Sep 17 00:00:00 2001 From: Mouad Debbar Date: Thu, 9 Jan 2020 14:53:03 -0800 Subject: [PATCH] [web] Tests for browser history implementation (#15324) --- lib/web_ui/lib/src/engine/test_embedding.dart | 33 ++- lib/web_ui/test/engine/history_test.dart | 263 ++++++++++++++++++ lib/web_ui/test/spy.dart | 69 +++++ 3 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 lib/web_ui/test/engine/history_test.dart create mode 100644 lib/web_ui/test/spy.dart diff --git a/lib/web_ui/lib/src/engine/test_embedding.dart b/lib/web_ui/lib/src/engine/test_embedding.dart index fc5f25a4e..b7e29ce73 100644 --- a/lib/web_ui/lib/src/engine/test_embedding.dart +++ b/lib/web_ui/lib/src/engine/test_embedding.dart @@ -6,12 +6,12 @@ part of engine; const bool _debugLogHistoryActions = false; -class _HistoryEntry { +class TestHistoryEntry { final dynamic state; final String title; final String url; - const _HistoryEntry(this.state, this.title, this.url); + const TestHistoryEntry(this.state, this.title, this.url); @override String toString() { @@ -25,24 +25,30 @@ class _HistoryEntry { /// It keeps a list of history entries and event listeners in memory and /// manipulates them in order to achieve the desired functionality. class TestLocationStrategy extends LocationStrategy { - /// Passing a [defaultRouteName] will make the app start at that route. The - /// way it does it is by using it as a path on the first history entry. - TestLocationStrategy([String defaultRouteName = '']) + /// Creates a instance of [TestLocationStrategy] with an empty string as the + /// path. + factory TestLocationStrategy() => TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '')); + + /// Creates an instance of [TestLocationStrategy] and populates it with a list + /// that has [initialEntry] as the only item. + TestLocationStrategy.fromEntry(TestHistoryEntry initialEntry) : _currentEntryIndex = 0, - history = <_HistoryEntry>[_HistoryEntry(null, null, defaultRouteName)]; + history = [initialEntry]; @override String get path => ensureLeading(currentEntry.url, '/'); int _currentEntryIndex; - final List<_HistoryEntry> history; + int get currentEntryIndex => _currentEntryIndex; + + final List history; - _HistoryEntry get currentEntry { + TestHistoryEntry get currentEntry { assert(withinAppHistory); return history[_currentEntryIndex]; } - set currentEntry(_HistoryEntry entry) { + set currentEntry(TestHistoryEntry entry) { assert(withinAppHistory); history[_currentEntryIndex] = entry; } @@ -62,7 +68,7 @@ class TestLocationStrategy extends LocationStrategy { // If the user goes A -> B -> C -> D, then goes back to B and pushes a new // entry called E, we should end up with: A -> B -> E in the history list. history.removeRange(_currentEntryIndex, history.length); - history.add(_HistoryEntry(state, title, url)); + history.add(TestHistoryEntry(state, title, url)); if (_debugLogHistoryActions) { print('$runtimeType.pushState(...) -> $this'); @@ -72,7 +78,10 @@ class TestLocationStrategy extends LocationStrategy { @override void replaceState(dynamic state, String title, String url) { assert(withinAppHistory); - currentEntry = _HistoryEntry(state, title, url); + if (url == null || url == '') { + url = currentEntry.url; + } + currentEntry = TestHistoryEntry(state, title, url); if (_debugLogHistoryActions) { print('$runtimeType.replaceState(...) -> $this'); @@ -149,7 +158,7 @@ class TestLocationStrategy extends LocationStrategy { String toString() { final List lines = List(history.length); for (int i = 0; i < history.length; i++) { - final _HistoryEntry entry = history[i]; + final TestHistoryEntry entry = history[i]; lines[i] = _currentEntryIndex == i ? '* $entry' : ' $entry'; } return '$runtimeType: [\n${lines.join('\n')}\n]'; diff --git a/lib/web_ui/test/engine/history_test.dart b/lib/web_ui/test/engine/history_test.dart new file mode 100644 index 000000000..c4e1a58be --- /dev/null +++ b/lib/web_ui/test/engine/history_test.dart @@ -0,0 +1,263 @@ +// 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:typed_data'; + +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; + +import '../spy.dart'; + +TestLocationStrategy _strategy; +TestLocationStrategy get strategy => _strategy; +set strategy(TestLocationStrategy newStrategy) { + window.locationStrategy = _strategy = newStrategy; +} + +const Map originState = {'origin': true}; +const Map flutterState = {'flutter': true}; + +const MethodCodec codec = JSONMethodCodec(); + +void emptyCallback(ByteData date) {} + +void main() { + group('BrowserHistory', () { + final PlatformMessagesSpy spy = PlatformMessagesSpy(); + + setUp(() { + spy.setUp(); + }); + + tearDown(() { + spy.tearDown(); + strategy = null; + }); + + test('basic setup works', () { + strategy = TestLocationStrategy.fromEntry( + TestHistoryEntry('initial state', null, '/initial')); + + // There should be two entries: origin and flutter. + expect(strategy.history, hasLength(2)); + + // The origin entry is setup but its path should remain unchanged. + final TestHistoryEntry originEntry = strategy.history[0]; + expect(originEntry.state, originState); + expect(originEntry.url, '/initial'); + + // The flutter entry is pushed and its path should be derived from the + // origin entry. + final TestHistoryEntry flutterEntry = strategy.history[1]; + expect(flutterEntry.state, flutterState); + expect(flutterEntry.url, '/initial'); + + // The flutter entry is the current entry. + expect(strategy.currentEntry, flutterEntry); + }); + + test('browser back button pops routes correctly', () async { + strategy = + TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')); + + // Initially, we should be on the flutter entry. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/home'); + + pushRoute('/page1'); + // The number of entries shouldn't change. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntryIndex, 1); + // But the url of the current entry (flutter entry) should be updated. + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/page1'); + + // No platform messages have been sent so far. + expect(spy.messages, isEmpty); + // Clicking back should take us to page1. + await strategy.back(); + // First, the framework should've received a `popRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'popRoute'); + expect(spy.messages[0].methodArguments, isNull); + // We still have 2 entries. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntryIndex, 1); + // The url of the current entry (flutter entry) should go back to /home. + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/home'); + }); + + test('multiple browser back clicks', () async { + strategy = + TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')); + + pushRoute('/page1'); + pushRoute('/page2'); + + // Make sure we are on page2. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntryIndex, 1); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/page2'); + + // Back to page1. + await strategy.back(); + // 1. The engine sends a `popRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'popRoute'); + expect(spy.messages[0].methodArguments, isNull); + spy.messages.clear(); + // 2. The framework sends a `routePopped` platform message. + popRoute('/page1'); + // 3. The history state should reflect that /page1 is currently active. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntryIndex, 1); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/page1'); + + // Back to home. + await strategy.back(); + // 1. The engine sends a `popRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'popRoute'); + expect(spy.messages[0].methodArguments, isNull); + spy.messages.clear(); + // 2. The framework sends a `routePopped` platform message. + popRoute('/home'); + // 3. The history state should reflect that /page1 is currently active. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntryIndex, 1); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/home'); + + // The next browser back will exit the app. + await strategy.back(); + // 1. The engine sends a `popRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'popRoute'); + expect(spy.messages[0].methodArguments, isNull); + spy.messages.clear(); + // 2. The framework sends a `SystemNavigator.pop` platform message + // because there are no more routes to pop. + await systemNavigatorPop(); + // 3. The active entry doesn't belong to our history anymore because we + // navigated past it. + expect(strategy.currentEntryIndex, -1); + }); + + test('handle user-provided url', () async { + strategy = + TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')); + + await _strategy.simulateUserTypingUrl('/page3'); + // This delay is necessary to wait for [BrowserHistory] because it + // performs a `back` operation which results in a new event loop. + await Future.delayed(Duration.zero); + // 1. The engine sends a `pushRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'pushRoute'); + expect(spy.messages[0].methodArguments, '/page3'); + spy.messages.clear(); + // 2. The framework sends a `routePushed` platform message. + pushRoute('/page3'); + // 3. The history state should reflect that /page3 is currently active. + expect(strategy.history, hasLength(3)); + expect(strategy.currentEntryIndex, 1); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/page3'); + + // Back to home. + await strategy.back(); + // 1. The engine sends a `popRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'popRoute'); + expect(spy.messages[0].methodArguments, isNull); + spy.messages.clear(); + // 2. The framework sends a `routePopped` platform message. + popRoute('/home'); + // 3. The history state should reflect that /page1 is currently active. + expect(strategy.history, hasLength(2)); + expect(strategy.currentEntryIndex, 1); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/home'); + }); + + test('user types unknown url', () async { + strategy = + TestLocationStrategy.fromEntry(TestHistoryEntry(null, null, '/home')); + + await _strategy.simulateUserTypingUrl('/unknown'); + // This delay is necessary to wait for [BrowserHistory] because it + // performs a `back` operation which results in a new event loop. + await Future.delayed(Duration.zero); + // 1. The engine sends a `pushRoute` platform message. + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/navigation'); + expect(spy.messages[0].methodName, 'pushRoute'); + expect(spy.messages[0].methodArguments, '/unknown'); + spy.messages.clear(); + // 2. The framework doesn't recognize the route name and ignores it. + // 3. The history state should reflect that /home is currently active. + expect(strategy.history, hasLength(3)); + expect(strategy.currentEntryIndex, 1); + expect(strategy.currentEntry.state, flutterState); + expect(strategy.currentEntry.url, '/home'); + }); + }); +} + +void pushRoute(String routeName) { + window.sendPlatformMessage( + 'flutter/navigation', + codec.encodeMethodCall(MethodCall( + 'routePushed', + {'previousRouteName': '/foo', 'routeName': routeName}, + )), + emptyCallback, + ); +} + +void replaceRoute(String routeName) { + window.sendPlatformMessage( + 'flutter/navigation', + codec.encodeMethodCall(MethodCall( + 'routeReplaced', + {'previousRouteName': '/foo', 'routeName': routeName}, + )), + emptyCallback, + ); +} + +void popRoute(String previousRouteName) { + window.sendPlatformMessage( + 'flutter/navigation', + codec.encodeMethodCall(MethodCall( + 'routePopped', + { + 'previousRouteName': previousRouteName, + 'routeName': '/foo' + }, + )), + emptyCallback, + ); +} + +Future systemNavigatorPop() { + final Completer completer = Completer(); + window.sendPlatformMessage( + 'flutter/platform', + codec.encodeMethodCall(MethodCall('SystemNavigator.pop')), + (_) => completer.complete(), + ); + return completer.future; +} diff --git a/lib/web_ui/test/spy.dart b/lib/web_ui/test/spy.dart new file mode 100644 index 000000000..c45b0d17d --- /dev/null +++ b/lib/web_ui/test/spy.dart @@ -0,0 +1,69 @@ +// 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:typed_data'; + +import 'package:ui/src/engine.dart' hide window; +import 'package:ui/ui.dart'; + +/// Encapsulates the info of a platform message that was intercepted by +/// [PlatformMessagesSpy]. +class PlatformMessage { + PlatformMessage(this.channel, this.methodCall); + + /// The name of the channel on which the message was sent. + final String channel; + + /// The [MethodCall] instance that was sent in the platform message. + final MethodCall methodCall; + + /// Shorthand for getting the name of the method call. + String get methodName => methodCall.method; + + /// Shorthand for getting the arguments of the method call. + String get methodArguments => methodCall.arguments; +} + +/// Intercepts platform messages sent from the engine to the framework. +/// +/// It holds all intercepted platform messages in a [messages] list that can +/// be inspected in tests. +class PlatformMessagesSpy { + PlatformMessageCallback _callback; + PlatformMessageCallback _backup; + + bool get _isActive => _callback != null; + + /// List of intercepted messages since the last [setUp] call. + final List messages = []; + + /// Start spying on platform messages. + /// + /// This is typically called inside a test's `setUp` callback. + void setUp() { + assert(!_isActive); + _callback = (String channel, ByteData data, + PlatformMessageResponseCallback callback) { + messages.add(PlatformMessage( + channel, + const JSONMethodCodec().decodeMethodCall(data), + )); + }; + + _backup = window.onPlatformMessage; + window.onPlatformMessage = _callback; + } + + /// Stop spying on platform messages and clear all intercepted messages. + /// + /// Make sure this is called after each test that uses [PlatformMessagesSpy]. + void tearDown() { + assert(_isActive); + // Make sure [window.onPlatformMessage] wasn't tampered with. + assert(window.onPlatformMessage == _callback); + _callback = null; + messages.clear(); + window.onPlatformMessage = _backup; + } +} -- GitLab