blob: 531e8bd19c80305ce828ed4024251805ae702b80 [file] [log] [blame]
// 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.
@TestOn('!safari')
// TODO(nurhan): https://github.com/flutter/flutter/issues/51169
import 'dart:async';
import 'dart:html' as html;
import 'package:test/bootstrap/browser.dart';
import 'package:test/test.dart';
import 'package:ui/src/engine.dart' show window;
import 'package:ui/src/engine/browser_detection.dart';
import 'package:ui/src/engine/navigation.dart';
import 'package:ui/src/engine/services.dart';
import 'package:ui/src/engine/test_embedding.dart';
import '../spy.dart';
Map<String, dynamic> _wrapOriginState(dynamic state) {
return <String, dynamic>{'origin': true, 'state': state};
}
Map<String, dynamic> _tagStateWithSerialCount(dynamic state, int serialCount) {
return <String, dynamic> {
'serialCount': serialCount,
'state': state,
};
}
const Map<String, bool> originState = <String, bool>{'origin': true};
const Map<String, bool> flutterState = <String, bool>{'flutter': true};
const MethodCodec codec = JSONMethodCodec();
void main() {
internalBootstrapBrowserTest(() => testMain);
}
void testMain() {
group('$SingleEntryBrowserHistory', () {
final PlatformMessagesSpy spy = PlatformMessagesSpy();
setUp(() async {
spy.setUp();
});
tearDown(() async {
spy.tearDown();
await window.resetHistory();
});
test('basic setup works', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry('initial state', null, '/initial'),
);
await window.debugInitializeHistory(strategy, useSingle: true);
// There should be two entries: origin and flutter.
expect(strategy.history, hasLength(2));
// The origin entry is set up but its path should remain unchanged.
final TestHistoryEntry originEntry = strategy.history[0];
expect(originEntry.state, _wrapOriginState('initial state'));
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);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('browser back button pops routes correctly', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry(null, null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: true);
// Initially, we should be on the flutter entry.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntry.state, flutterState);
expect(strategy.currentEntry.url, '/home');
await routeUpdated('/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.go(-1);
// 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');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('multiple browser back clicks', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry(null, null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: true);
await routeUpdated('/page1');
await routeUpdated('/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.go(-1);
// 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.
await routeUpdated('/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.go(-1);
// 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.
await routeUpdated('/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. We store the strategy locally
// because it will be remove from the browser history class once it exits
// the app.
final TestUrlStrategy originalStrategy = strategy;
await originalStrategy.go(-1);
// 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(originalStrategy.currentEntryIndex, -1);
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge ||
browserEngine == BrowserEngine.webkit);
test('handle user-provided url', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry(null, null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: true);
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<void>.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.
await routeUpdated('/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.go(-1);
// 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.
await routeUpdated('/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');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('user types unknown url', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry(null, null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: true);
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<void>.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');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
});
group('$MultiEntriesBrowserHistory', () {
final PlatformMessagesSpy spy = PlatformMessagesSpy();
setUp(() async {
spy.setUp();
});
tearDown(() async {
spy.tearDown();
await window.resetHistory();
});
test('basic setup works', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry('initial state', null, '/initial'),
);
await window.debugInitializeHistory(strategy, useSingle: false);
// There should be only one entry.
expect(strategy.history, hasLength(1));
// The origin entry is tagged and its path should remain unchanged.
final TestHistoryEntry taggedOriginEntry = strategy.history[0];
expect(taggedOriginEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(taggedOriginEntry.url, '/initial');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('browser back button push route information correctly', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry('initial state', null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: false);
// Initially, we should be on the flutter entry.
expect(strategy.history, hasLength(1));
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
await routeInformationUpdated('/page1', 'page1 state');
// Should have two history entries now.
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, _tagStateWithSerialCount('page1 state', 1));
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.go(-1);
// First, the framework should've received a `pushRouteInformation`
// platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/home',
'state': 'initial state',
});
// There are still two browser history entries, but we are back to the
// original state.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 0);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('multiple browser back clicks', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry('initial state', null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: false);
await routeInformationUpdated('/page1', 'page1 state');
await routeInformationUpdated('/page2', 'page2 state');
// Make sure we are on page2.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 2);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page2 state', 2));
expect(strategy.currentEntry.url, '/page2');
// Back to page1.
await strategy.go(-1);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page1',
'state': 'page1 state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page1 state', 1));
expect(strategy.currentEntry.url, '/page1');
// Back to home.
await strategy.go(-1);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/home',
'state': 'initial state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 0);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge ||
browserEngine == BrowserEngine.webkit);
test('handle user-provided url', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry('initial state', null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: false);
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<void>.delayed(Duration.zero);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page3',
'state': null,
});
spy.messages.clear();
// 2. The history state should reflect that /page3 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, _tagStateWithSerialCount(null, 1));
expect(strategy.currentEntry.url, '/page3');
// Back to home.
await strategy.go(-1);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/home',
'state': 'initial state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(2));
expect(strategy.currentEntryIndex, 0);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('initial state', 0));
expect(strategy.currentEntry.url, '/home');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
test('forward button works', () async {
final TestUrlStrategy strategy = TestUrlStrategy.fromEntry(
const TestHistoryEntry('initial state', null, '/home'),
);
await window.debugInitializeHistory(strategy, useSingle: false);
await routeInformationUpdated('/page1', 'page1 state');
await routeInformationUpdated('/page2', 'page2 state');
// Make sure we are on page2.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 2);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page2 state', 2));
expect(strategy.currentEntry.url, '/page2');
// Back to page1.
await strategy.go(-1);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page1',
'state': 'page1 state',
});
spy.messages.clear();
// 2. The history state should reflect that /page1 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 1);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page1 state', 1));
expect(strategy.currentEntry.url, '/page1');
// Forward to page2
await strategy.go(1);
// 1. The engine sends a `pushRouteInformation` platform message.
expect(spy.messages, hasLength(1));
expect(spy.messages[0].channel, 'flutter/navigation');
expect(spy.messages[0].methodName, 'pushRouteInformation');
expect(spy.messages[0].methodArguments, <dynamic, dynamic>{
'location': '/page2',
'state': 'page2 state',
});
spy.messages.clear();
// 2. The history state should reflect that /page2 is currently active.
expect(strategy.history, hasLength(3));
expect(strategy.currentEntryIndex, 2);
expect(strategy.currentEntry.state, _tagStateWithSerialCount('page2 state', 2));
expect(strategy.currentEntry.url, '/page2');
},
// TODO(nurhan): https://github.com/flutter/flutter/issues/50836
skip: browserEngine == BrowserEngine.edge);
});
group('$HashUrlStrategy', () {
late TestPlatformLocation location;
setUp(() {
location = TestPlatformLocation();
});
tearDown(() {
location = TestPlatformLocation();
});
test('leading slash is optional', () {
final HashUrlStrategy strategy = HashUrlStrategy(location);
location.hash = '#/';
expect(strategy.getPath(), '/');
location.hash = '#/foo';
expect(strategy.getPath(), '/foo');
location.hash = '#foo';
expect(strategy.getPath(), 'foo');
});
test('path should not be empty', () {
final HashUrlStrategy strategy = HashUrlStrategy(location);
location.hash = '';
expect(strategy.getPath(), '/');
location.hash = '#';
expect(strategy.getPath(), '/');
});
});
}
Future<void> routeUpdated(String routeName) {
final Completer<void> completer = Completer<void>();
window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(MethodCall(
'routeUpdated',
<String, dynamic>{'routeName': routeName},
)),
(_) => completer.complete(),
);
return completer.future;
}
Future<void> routeInformationUpdated(String location, dynamic state) {
final Completer<void> completer = Completer<void>();
window.sendPlatformMessage(
'flutter/navigation',
codec.encodeMethodCall(MethodCall(
'routeInformationUpdated',
<String, dynamic>{'location': location, 'state': state},
)),
(_) => completer.complete(),
);
return completer.future;
}
Future<void> systemNavigatorPop() {
final Completer<void> completer = Completer<void>();
window.sendPlatformMessage(
'flutter/platform',
codec.encodeMethodCall(const MethodCall('SystemNavigator.pop')),
(_) => completer.complete(),
);
return completer.future;
}
/// A mock implementation of [PlatformLocation] that doesn't access the browser.
class TestPlatformLocation extends PlatformLocation {
@override
String? hash;
@override
dynamic state;
@override
String get pathname => throw UnimplementedError();
@override
String get search => throw UnimplementedError();
@override
void addPopStateListener(html.EventListener fn) {
throw UnimplementedError();
}
@override
void removePopStateListener(html.EventListener fn) {
throw UnimplementedError();
}
@override
void pushState(dynamic state, String title, String url) {
throw UnimplementedError();
}
@override
void replaceState(dynamic state, String title, String url) {
throw UnimplementedError();
}
@override
void go(int count) {
throw UnimplementedError();
}
@override
String getBaseHref() => '/';
}