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