1// Copyright 2016 The Chromium 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#include "base/compiler_specific.h"
6#include "base/memory/ptr_util.h"
7#include "base/strings/string_number_conversions.h"
8#include "base/strings/sys_string_conversions.h"
9#include "base/strings/utf_string_conversions.h"
10#import "base/test/ios/wait_util.h"
11#import "ios/web/navigation/navigation_item_impl.h"
12#import "ios/web/public/navigation/navigation_item.h"
13#import "ios/web/public/navigation/navigation_manager.h"
14#import "ios/web/public/test/web_view_interaction_test_util.h"
15#import "ios/web/public/web_client.h"
16#import "ios/web/public/web_state.h"
17#import "ios/web/test/web_int_test.h"
18#include "net/test/embedded_test_server/embedded_test_server.h"
19#include "testing/gtest/include/gtest/gtest.h"
20#include "testing/gtest_mac.h"
21#include "url/url_canon.h"
22
23#if !defined(__has_feature) || !__has_feature(objc_arc)
24#error "This file requires ARC support."
25#endif
26
27using base::ASCIIToUTF16;
28
29namespace {
30
31// URL for the test window.location test file.  The page at this URL contains
32// several buttons that trigger window.location commands.  The page supports
33// several JavaScript functions:
34// - updateUrlToLoadText(), which takes a URL and updates a div on the page to
35//   contain that text.  This URL is used as the parameter for window.location
36//   function calls triggered by button taps.
37// - getUrl(), which returns the URL that was set via updateUrlToLoadText().
38// - isOnLoadTextVisible(), which returns whether a placeholder string is
39//   present on the page.  This string is added to the page in the onload event
40//   and is removed once a button is tapped.  Verifying that the onload text is
41//   visible after tapping a button is equivalent to checking that a load has
42//   occurred as the result of the button tap.
43const char kHistoryStateOperationsTestUrl[] = "/state_operations.html";
44
45// Button IDs used in the window.location test page.
46const char kPushStateId[] = "push-state";
47const char kReplaceStateId[] = "replace-state";
48
49// JavaScript functions on the history state test page.
50NSString* const kUpdateStateParamsScriptFormat =
51    @"updateStateParams('%s', '%s', '%s')";
52NSString* const kOnLoadCheckScript = @"isOnLoadPlaceholderTextVisible()";
53NSString* const kNoOpCheckScript = @"isNoOpPlaceholderTextVisible()";
54
55// Wait timeout for state updates.
56const NSTimeInterval kWaitForStateUpdateTimeout = 5.0;
57
58}  // namespace
59
60// Test fixture for integration tests involving html5 window.history state
61// operations.
62class HistoryStateOperationsTest : public web::WebIntTest {
63 protected:
64  void SetUp() override {
65    web::WebIntTest::SetUp();
66
67    // Load the history state test page.
68    test_server_ = std::make_unique<net::EmbeddedTestServer>();
69    test_server_->ServeFilesFromSourceDirectory(
70        base::FilePath("ios/testing/data/http_server_files/"));
71    ASSERT_TRUE(test_server_->Start());
72
73    state_operations_url_ =
74        test_server_->GetURL(kHistoryStateOperationsTestUrl);
75    ASSERT_TRUE(LoadUrl(state_operations_url()));
76  }
77
78  // The URL of the window.location test page.
79  const GURL& state_operations_url() { return state_operations_url_; }
80
81  // Reloads the page and waits for the load to finish.
82  bool Reload() WARN_UNUSED_RESULT {
83    return ExecuteBlockAndWaitForLoad(GetLastCommittedItem()->GetURL(), ^{
84      // TODO(crbug.com/677364): Use NavigationManager::Reload() once it no
85      // longer requires a web delegate.
86      web_state()->ExecuteJavaScript(ASCIIToUTF16("window.location.reload()"));
87    });
88  }
89
90  // Sets the parameters to use for state operations on the test page.  This
91  // function executes a script that populates JavaScript values on the test
92  // page.  When the "push-state" or "replace-state" buttons are tapped, these
93  // parameters will be passed to their corresponding JavaScript function calls.
94  void SetStateParams(const std::string& state_object,
95                      const std::string& title,
96                      const GURL& url) {
97    ASSERT_EQ(state_operations_url(), GetLastCommittedItem()->GetURL());
98    std::string url_spec = url.possibly_invalid_spec();
99    NSString* set_params_script = [NSString
100        stringWithFormat:kUpdateStateParamsScriptFormat, state_object.c_str(),
101                         title.c_str(), url_spec.c_str()];
102    ExecuteJavaScript(set_params_script);
103  }
104
105  // Returns the state object returned by JavaScript.
106  std::string GetJavaScriptState() {
107    return base::SysNSStringToUTF8(ExecuteJavaScript(@"window.history.state"));
108  }
109
110  // Executes JavaScript to check whether the onload text is visible.
111  bool IsOnLoadTextVisible() {
112    return [ExecuteJavaScript(kOnLoadCheckScript) boolValue];
113  }
114
115  // Executes JavaScript to check whether the no-op text is visible.
116  bool IsNoOpTextVisible() {
117    return [ExecuteJavaScript(kNoOpCheckScript) boolValue];
118  }
119
120  // Waits for the NoOp text to be visible.
121  void WaitForNoOpText() {
122    BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
123        base::test::ios::kWaitForJSCompletionTimeout, ^{
124          return IsNoOpTextVisible();
125        });
126    EXPECT_TRUE(completed) << "NoOp text failed to be visible.";
127  }
128
129  std::unique_ptr<net::EmbeddedTestServer> test_server_;
130
131 private:
132  GURL state_operations_url_;
133};
134
135// Tests that calling window.history.pushState() is a no-op for unresolvable
136// URLs.
137TEST_F(HistoryStateOperationsTest, NoOpPushUnresolvable) {
138  // Perform a window.history.pushState() with an unresolvable URL.  This will
139  // clear the OnLoad and NoOp text, so checking below that the NoOp text is
140  // displayed and the OnLoad text is empty ensures that no navigation occurred
141  // as the result of the pushState() call.
142  std::string empty_state;
143  std::string empty_title;
144  GURL unresolvable_url("http://www.google.invalid");
145  SetStateParams(empty_state, empty_title, unresolvable_url);
146  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kPushStateId));
147  WaitForNoOpText();
148}
149
150// Tests that calling window.history.replaceState() is a no-op for unresolvable
151// URLs.
152TEST_F(HistoryStateOperationsTest, NoOpReplaceUnresolvable) {
153  // Perform a window.history.replaceState() with an unresolvable URL.  This
154  // will clear the OnLoad and NoOp text, so checking below that the NoOp text
155  // is displayed and the OnLoad text is empty ensures that no navigation
156  // occurred as the result of the pushState() call.
157  std::string empty_state;
158  std::string empty_title;
159  GURL unresolvable_url("http://www.google.invalid");
160  SetStateParams(empty_state, empty_title, unresolvable_url);
161  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
162  WaitForNoOpText();
163}
164
165// Tests that calling window.history.pushState() is a no-op for URLs with a
166// different scheme.
167TEST_F(HistoryStateOperationsTest, NoOpPushDifferentScheme) {
168  // Perform a window.history.pushState() with a URL with a different scheme.
169  // This will clear the OnLoad and NoOp text, so checking below that the NoOp
170  // text is displayed and the OnLoad text is empty ensures that no navigation
171  // occurred as the result of the pushState() call.
172  std::string empty_state;
173  std::string empty_title;
174  GURL different_scheme_url("https://google.com");
175  ASSERT_TRUE(IsOnLoadTextVisible());
176  SetStateParams(empty_state, empty_title, different_scheme_url);
177  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kPushStateId));
178  WaitForNoOpText();
179}
180
181// Tests that calling window.history.replaceState() is a no-op for URLs with a
182// different scheme.
183TEST_F(HistoryStateOperationsTest, NoOpRelaceDifferentScheme) {
184  // Perform a window.history.replaceState() with a URL with a different scheme.
185  // This will clear the OnLoad and NoOp text, so checking below that the NoOp
186  // text is displayed and the OnLoad text is empty ensures that no navigation
187  // occurred as the result of the pushState() call.
188  std::string empty_state;
189  std::string empty_title;
190  GURL different_scheme_url("https://google.com");
191  ASSERT_TRUE(IsOnLoadTextVisible());
192  SetStateParams(empty_state, empty_title, different_scheme_url);
193  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
194  WaitForNoOpText();
195}
196
197// Tests that calling window.history.pushState() is a no-op for URLs with a
198// origin differing from that of the current page.
199TEST_F(HistoryStateOperationsTest, NoOpPushDifferentOrigin) {
200  // Perform a window.history.pushState() with a URL with a different origin.
201  // This will clear the OnLoad and NoOp text, so checking below that the NoOp
202  // text is displayed and the OnLoad text is empty ensures that no navigation
203  // occurred as the result of the pushState() call.
204  std::string empty_state;
205  std::string empty_title;
206  std::string new_port_string = base::NumberToString(test_server_->port() + 1);
207  url::Replacements<char> port_replacement;
208  port_replacement.SetPort(new_port_string.c_str(),
209                           url::Component(0, new_port_string.length()));
210  GURL different_origin_url =
211      state_operations_url().ReplaceComponents(port_replacement);
212  ASSERT_TRUE(IsOnLoadTextVisible());
213  SetStateParams(empty_state, empty_title, different_origin_url);
214  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kPushStateId));
215  WaitForNoOpText();
216}
217
218// Tests that calling window.history.replaceState() is a no-op for URLs with a
219// origin differing from that of the current page.
220TEST_F(HistoryStateOperationsTest, NoOpReplaceDifferentOrigin) {
221  // Perform a window.history.replaceState() with a URL with a different origin.
222  // This will clear the OnLoad and NoOp text, so checking below that the NoOp
223  // text is displayed and the OnLoad text is empty ensures that no navigation
224  // occurred as the result of the pushState() call.
225  std::string empty_state;
226  std::string empty_title;
227  std::string new_port_string = base::NumberToString(test_server_->port() + 1);
228  url::Replacements<char> port_replacement;
229  port_replacement.SetPort(new_port_string.c_str(),
230                           url::Component(0, new_port_string.length()));
231  GURL different_origin_url =
232      state_operations_url().ReplaceComponents(port_replacement);
233  ASSERT_TRUE(IsOnLoadTextVisible());
234  SetStateParams(empty_state, empty_title, different_origin_url);
235  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
236  WaitForNoOpText();
237}
238
239// Tests that calling window.history.replaceState() with only a new title
240// successfully replaces the current NavigationItem's title.
241// TODO(crbug.com/677356): Enable this test once the NavigationItem's title is
242// updated from within the web layer.
243TEST_F(HistoryStateOperationsTest, DISABLED_TitleReplacement) {
244  // Navigate to about:blank then navigate back to the test page.  The created
245  // NavigationItem can be used later to verify that the title is replaced
246  // rather than pushed.
247  GURL about_blank("about:blank");
248  ASSERT_TRUE(LoadUrl(about_blank));
249  web::NavigationItem* about_blank_item = GetLastCommittedItem();
250  EXPECT_TRUE(ExecuteBlockAndWaitForLoad(state_operations_url(), ^{
251    navigation_manager()->GoBack();
252  }));
253  EXPECT_EQ(state_operations_url(), GetLastCommittedItem()->GetURL());
254  // Set up the state parameters and tap the replace state button.
255  std::string empty_state;
256  std::string new_title("NEW TITLE");
257  GURL empty_url;
258  SetStateParams(empty_state, new_title, empty_url);
259  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
260  // Wait for the title to be reflected in the NavigationItem.
261  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
262      kWaitForStateUpdateTimeout, ^{
263        return GetLastCommittedItem()->GetTitle() == ASCIIToUTF16(new_title);
264      });
265  EXPECT_TRUE(completed) << "Failed to validate NavigationItem title.";
266  // Verify that the forward navigation was not pruned.
267  EXPECT_EQ(GetIndexOfNavigationItem(GetLastCommittedItem()) + 1,
268            GetIndexOfNavigationItem(about_blank_item));
269}
270
271// Tests that calling window.history.replaceState() with a new state object
272// replaces the state object for the current NavigationItem.
273TEST_F(HistoryStateOperationsTest, StateReplacement) {
274  // Navigate to about:blank then navigate back to the test page.  The created
275  // NavigationItem can be used later to verify that the state is replaced
276  // rather than pushed.
277  GURL about_blank("about:blank");
278  ASSERT_TRUE(LoadUrl(about_blank));
279  web::NavigationItem* about_blank_item = GetLastCommittedItem();
280  EXPECT_TRUE(ExecuteBlockAndWaitForLoad(state_operations_url(), ^{
281    navigation_manager()->GoBack();
282  }));
283  ASSERT_EQ(state_operations_url(), GetLastCommittedItem()->GetURL());
284  // Set up the state parameters and tap the replace state button.
285  std::string new_state("STATE OBJECT");
286  std::string empty_title;
287  GURL empty_url;
288  SetStateParams(new_state, empty_title, empty_url);
289  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
290  // Verify that the state is reflected in the JavaScript context.
291  BOOL verify_java_script_context_completed =
292      base::test::ios::WaitUntilConditionOrTimeout(
293          base::test::ios::kWaitForJSCompletionTimeout, ^{
294            return GetJavaScriptState() == new_state;
295          });
296  EXPECT_TRUE(verify_java_script_context_completed)
297      << "Failed to validate JavaScript state.";
298  // Verify that the state is reflected in the latest NavigationItem.
299  std::string serialized_state("\"STATE OBJECT\"");
300  BOOL verify_navigation_item_completed =
301      base::test::ios::WaitUntilConditionOrTimeout(
302          kWaitForStateUpdateTimeout, ^{
303            web::NavigationItemImpl* item =
304                static_cast<web::NavigationItemImpl*>(GetLastCommittedItem());
305            std::string item_state =
306                base::SysNSStringToUTF8(item->GetSerializedStateObject());
307            return item_state == serialized_state;
308          });
309  EXPECT_TRUE(verify_navigation_item_completed)
310      << "Failed to validate NavigationItem state.";
311  // Verify that the forward navigation was not pruned.
312  EXPECT_EQ(GetIndexOfNavigationItem(GetLastCommittedItem()) + 1,
313            GetIndexOfNavigationItem(about_blank_item));
314}
315
316// Tests that the state object is reset to the correct value after reloading a
317// page whose state has been replaced.
318#if TARGET_IPHONE_SIMULATOR
319#define MAYBE_StateReplacementReload StateReplacementReload
320#else
321#define MAYBE_StateReplacementReload DISABLED_StateReplacementReload
322#endif
323// TODO(crbug.com/720381): Enable this test on device.
324TEST_F(HistoryStateOperationsTest, MAYBE_StateReplacementReload) {
325  // Set up the state parameters and tap the replace state button.
326  std::string new_state("STATE OBJECT");
327  std::string empty_title;
328  GURL empty_url;
329  SetStateParams(new_state, empty_title, empty_url);
330  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
331  // Reload the page and check that the state object is present.
332  EXPECT_TRUE(Reload());
333  ASSERT_TRUE(IsOnLoadTextVisible());
334  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
335      base::test::ios::kWaitForJSCompletionTimeout, ^{
336        return GetJavaScriptState() == new_state;
337      });
338  EXPECT_TRUE(completed) << "Failed to validate JavaScript state.";
339}
340
341// Tests that the state object is correctly set for a page after a back/forward
342// navigation.
343TEST_F(HistoryStateOperationsTest, StateReplacementBackForward) {
344  // Navigate to about:blank then navigate back to the test page.  The created
345  // NavigationItem can be used later to verify that the state is replaced
346  // rather than pushed.
347  GURL about_blank("about:blank");
348  ASSERT_TRUE(LoadUrl(about_blank));
349  ASSERT_TRUE(ExecuteBlockAndWaitForLoad(state_operations_url(), ^{
350    navigation_manager()->GoBack();
351  }));
352  ASSERT_EQ(state_operations_url(), GetLastCommittedItem()->GetURL());
353  // Set up the state parameters and tap the replace state button.
354  std::string new_state("STATE OBJECT");
355  std::string empty_title("");
356  GURL empty_url("");
357  SetStateParams(new_state, empty_title, empty_url);
358  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
359  // Go forward and back, then check that the state object is present.
360  ASSERT_TRUE(ExecuteBlockAndWaitForLoad(about_blank, ^{
361    navigation_manager()->GoForward();
362  }));
363  ASSERT_TRUE(ExecuteBlockAndWaitForLoad(state_operations_url(), ^{
364    navigation_manager()->GoBack();
365  }));
366
367  // WebKit doesn't trigger onload on back. WKBasedNavigationManager inherits
368  // this behavior.
369  WaitForNoOpText();
370
371  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
372      base::test::ios::kWaitForJSCompletionTimeout, ^{
373        return GetJavaScriptState() == new_state;
374      });
375  EXPECT_TRUE(completed) << "Failed to validate JavaScript state.";
376}
377
378// Tests that calling window.history.pushState() creates a new NavigationItem
379// and prunes trailing items.
380TEST_F(HistoryStateOperationsTest, PushState) {
381  // Navigate to about:blank then navigate back to the test page.  The created
382  // NavigationItem can be used later to verify that the state is replaced
383  // rather than pushed.
384  GURL about_blank("about:blank");
385  ASSERT_TRUE(LoadUrl(about_blank));
386  web::NavigationItem* about_blank_item = GetLastCommittedItem();
387  ASSERT_TRUE(ExecuteBlockAndWaitForLoad(state_operations_url(), ^{
388    navigation_manager()->GoBack();
389  }));
390  ASSERT_EQ(state_operations_url(), GetLastCommittedItem()->GetURL());
391  web::NavigationItem* non_pushed_item = GetLastCommittedItem();
392  // Set up the state parameters and tap the replace state button.
393  std::string empty_state;
394  std::string empty_title;
395  GURL new_url = state_operations_url().Resolve("path");
396  SetStateParams(empty_state, empty_title, new_url);
397  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kPushStateId));
398  // Verify that the url with the path is pushed.
399  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
400      kWaitForStateUpdateTimeout, ^{
401        return GetLastCommittedItem()->GetURL() == new_url;
402      });
403  EXPECT_TRUE(completed) << "Failed to validate current url.";
404  // Verify that a new NavigationItem was created and that the forward item was
405  // pruned.
406  EXPECT_EQ(GetIndexOfNavigationItem(non_pushed_item) + 1,
407            GetIndexOfNavigationItem(GetLastCommittedItem()));
408  EXPECT_EQ(NSNotFound, GetIndexOfNavigationItem(about_blank_item));
409}
410
411// Tests that performing a replaceState() on a page created with a POST request
412// resets the page to a GET request.
413TEST_F(HistoryStateOperationsTest, ReplaceStatePostRequest) {
414  // Add POST data to the current NavigationItem.
415  NSData* post_data = [NSData data];
416  static_cast<web::NavigationItemImpl*>(GetLastCommittedItem())
417      ->SetPostData(post_data);
418  ASSERT_TRUE(GetLastCommittedItem()->HasPostData());
419  // Set up the state parameters and tap the replace state button.
420  std::string new_state("STATE OBJECT");
421  std::string empty_title;
422  GURL new_url = state_operations_url().Resolve("path");
423  SetStateParams(new_state, empty_title, new_url);
424  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
425  // Verify that url has been replaced.
426  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
427      kWaitForStateUpdateTimeout, ^{
428        return GetLastCommittedItem()->GetURL() == new_url;
429      });
430  EXPECT_TRUE(completed) << "Failed to validate current url.";
431  // Verify that the NavigationItem no longer has POST data.
432  EXPECT_FALSE(GetLastCommittedItem()->HasPostData());
433}
434
435// Tests that performing a replaceState() on a page where only the URL fragment
436// is updated does not trigger a hashchange event.
437TEST_F(HistoryStateOperationsTest, ReplaceStateNoHashChangeEvent) {
438  // Set up the state parameters and tap the replace state button.
439  std::string empty_state;
440  std::string empty_title;
441  GURL new_url = state_operations_url().Resolve("#hash");
442  SetStateParams(empty_state, empty_title, new_url);
443  ASSERT_TRUE(web::test::TapWebViewElementWithId(web_state(), kReplaceStateId));
444  // Verify that url has been replaced.
445  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
446      kWaitForStateUpdateTimeout, ^{
447        return GetLastCommittedItem()->GetURL() == new_url;
448      });
449  EXPECT_TRUE(completed) << "Failed to validate current url.";
450  // Verify that the hashchange event was not fired.
451  EXPECT_FALSE(static_cast<web::NavigationItemImpl*>(GetLastCommittedItem())
452                   ->IsCreatedFromHashChange());
453}
454
455// Regression test for crbug.com/788464.
456TEST_F(HistoryStateOperationsTest, ReplaceStateThenReload) {
457  GURL url = test_server_->GetURL("/onload_replacestate_reload.html");
458  ASSERT_TRUE(LoadUrl(url));
459  GURL new_url = test_server_->GetURL("/pony.html");
460  BOOL completed = base::test::ios::WaitUntilConditionOrTimeout(
461      kWaitForStateUpdateTimeout, ^{
462        return GetLastCommittedItem()->GetURL() == new_url;
463      });
464  EXPECT_TRUE(completed);
465}
466