1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'dart:async'; |
6 | import 'dart:js_interop'; |
7 | import 'dart:ui' as ui; |
8 | import 'dart:ui_web' ; |
9 | |
10 | import 'package:flutter/material.dart' ; |
11 | import 'package:flutter_test/flutter_test.dart' ; |
12 | import 'package:flutter_web_plugins/flutter_web_plugins.dart' ; |
13 | import 'package:integration_test/integration_test.dart' ; |
14 | import 'package:web/helpers.dart' ; |
15 | import 'package:web/web.dart' as web; |
16 | import 'package:web_e2e_tests/url_strategy_main.dart' as app; |
17 | |
18 | void main() { |
19 | IntegrationTestWidgetsFlutterBinding.ensureInitialized(); |
20 | |
21 | testWidgets('Can customize url strategy' , (WidgetTester tester) async { |
22 | final TestUrlStrategy strategy = TestUrlStrategy.fromEntry( |
23 | const TestHistoryEntry('initial state' , null, '/' ), |
24 | ); |
25 | setUrlStrategy(strategy); |
26 | |
27 | app.appRoutes = <String, WidgetBuilder>{ |
28 | '/' : (BuildContext context) => Container(), |
29 | '/foo' : (BuildContext context) => Container(), |
30 | }; |
31 | app.main(); |
32 | await tester.pumpAndSettle(); |
33 | |
34 | // checking whether the previously set strategy is properly preserved |
35 | expect(urlStrategy, strategy); |
36 | |
37 | expect(strategy.getPath(), '/' ); |
38 | |
39 | final NavigatorState navigator = app.navKey.currentState!; |
40 | navigator.pushNamed('/foo' ); |
41 | await tester.pump(); |
42 | expect(strategy.getPath(), '/foo' ); |
43 | }); |
44 | } |
45 | |
46 | /// This URL strategy mimics the browser's history as closely as possible |
47 | /// while doing it all in memory with no interaction with the browser. |
48 | /// |
49 | /// It keeps a list of history entries and event listeners in memory and |
50 | /// manipulates them in order to achieve the desired functionality. |
51 | class TestUrlStrategy extends UrlStrategy { |
52 | /// Creates an instance of [TestUrlStrategy] and populates it with a list |
53 | /// that has [initialEntry] as the only item. |
54 | TestUrlStrategy.fromEntry(TestHistoryEntry initialEntry) |
55 | : _currentEntryIndex = 0, |
56 | history = <TestHistoryEntry>[initialEntry]; |
57 | |
58 | @override |
59 | String getPath() => currentEntry.url; |
60 | |
61 | @override |
62 | Object? getState() => currentEntry.state; |
63 | |
64 | int _currentEntryIndex; |
65 | final List<TestHistoryEntry> history; |
66 | |
67 | TestHistoryEntry get currentEntry { |
68 | assert(withinAppHistory); |
69 | return history[_currentEntryIndex]; |
70 | } |
71 | |
72 | set currentEntry(TestHistoryEntry entry) { |
73 | assert(withinAppHistory); |
74 | history[_currentEntryIndex] = entry; |
75 | } |
76 | |
77 | /// Whether we are still within the history of the Flutter Web app. This |
78 | /// remains true until we go back in history beyond the entry where the app |
79 | /// started. |
80 | bool get withinAppHistory => _currentEntryIndex >= 0; |
81 | |
82 | @override |
83 | void pushState(dynamic state, String title, String url) { |
84 | assert(withinAppHistory); |
85 | _currentEntryIndex++; |
86 | // When pushing a new state, we need to remove all entries that exist after |
87 | // the current entry. |
88 | // |
89 | // If the user goes A -> B -> C -> D, then goes back to B and pushes a new |
90 | // entry called E, we should end up with: A -> B -> E in the history list. |
91 | history.removeRange(_currentEntryIndex, history.length); |
92 | history.add(TestHistoryEntry(state, title, url)); |
93 | } |
94 | |
95 | @override |
96 | void replaceState(dynamic state, String title, String url) { |
97 | assert(withinAppHistory); |
98 | if (url == '' ) { |
99 | url = currentEntry.url; |
100 | } |
101 | currentEntry = TestHistoryEntry(state, title, url); |
102 | } |
103 | |
104 | @override |
105 | Future<void> go(int count) { |
106 | assert(withinAppHistory); |
107 | // Browsers don't move in history immediately. They do it at the next |
108 | // event loop. So let's simulate that. |
109 | return _nextEventLoop(() { |
110 | _currentEntryIndex = _currentEntryIndex + count; |
111 | if (withinAppHistory) { |
112 | _firePopStateEvent(); |
113 | } |
114 | }); |
115 | } |
116 | |
117 | final List<PopStateListener> listeners = <PopStateListener>[]; |
118 | |
119 | @override |
120 | ui.VoidCallback addPopStateListener(PopStateListener fn) { |
121 | listeners.add(fn); |
122 | return () { |
123 | // Schedule a micro task here to avoid removing the listener during |
124 | // iteration in [_firePopStateEvent]. |
125 | scheduleMicrotask(() => listeners.remove(fn)); |
126 | }; |
127 | } |
128 | |
129 | /// Simulates the scheduling of a new event loop by creating a delayed future. |
130 | /// Details explained here: https://webdev.dartlang.org/articles/performance/event-loop |
131 | Future<void> _nextEventLoop(ui.VoidCallback callback) { |
132 | return Future<void>.delayed(Duration.zero).then((_) => callback()); |
133 | } |
134 | |
135 | /// Invokes all the attached event listeners in order of |
136 | /// attaching. This method should be called asynchronously to make it behave |
137 | /// like a real browser. |
138 | void _firePopStateEvent() { |
139 | assert(withinAppHistory); |
140 | final web.PopStateEvent event = web.PopStateEvent( |
141 | 'popstate' , |
142 | PopStateEventInit(state: currentEntry.state?.toJSBox), |
143 | ); |
144 | for (int i = 0; i < listeners.length; i++) { |
145 | listeners[i](event); |
146 | } |
147 | } |
148 | |
149 | @override |
150 | String prepareExternalUrl(String internalUrl) => internalUrl; |
151 | |
152 | @override |
153 | String toString() { |
154 | final List<String> lines = <String>[]; |
155 | for (int i = 0; i < history.length; i++) { |
156 | final TestHistoryEntry entry = history[i]; |
157 | lines.add(_currentEntryIndex == i ? '* $entry' : ' $entry' ); |
158 | } |
159 | return ' $runtimeType: [\n ${lines.join('\n' )}\n]' ; |
160 | } |
161 | } |
162 | |
163 | class TestHistoryEntry { |
164 | const TestHistoryEntry(this.state, this.title, this.url); |
165 | |
166 | final Object? state; |
167 | final String? title; |
168 | final String url; |
169 | |
170 | @override |
171 | String toString() { |
172 | return ' $runtimeType(state: $state, title:" $title", url:" $url")' ; |
173 | } |
174 | } |
175 | |