blob: f3606e6781b8f439d9942b330533455f5efd65cb [file]
// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#import "components/autofill/ios/form_util/form_activity_tab_helper.h"
#import <optional>
#import "base/feature_list.h"
#import "base/strings/sys_string_conversions.h"
#import "base/test/bind.h"
#import "base/test/ios/wait_util.h"
#import "base/test/metrics/histogram_tester.h"
#import "base/test/with_feature_override.h"
#import "base/time/time.h"
#import "base/unguessable_token.h"
#import "components/autofill/core/common/autofill_features.h"
#import "components/autofill/core/common/form_data.h"
#import "components/autofill/core/common/form_field_data.h"
#import "components/autofill/core/common/unique_ids.h"
#import "components/autofill/ios/browser/autofill_java_script_feature.h"
#import "components/autofill/ios/browser/autofill_util.h"
#import "components/autofill/ios/common/features.h"
#import "components/autofill/ios/common/javascript_feature_util.h"
#import "components/autofill/ios/form_util/autofill_form_features_java_script_feature.h"
#import "components/autofill/ios/form_util/autofill_test_with_web_state.h"
#import "components/autofill/ios/form_util/form_activity_observer.h"
#import "components/autofill/ios/form_util/form_handlers_java_script_feature.h"
#import "components/autofill/ios/form_util/programmatic_form_submission_handler_java_script_feature.h"
#import "components/autofill/ios/form_util/renderer_id_test_util.h"
#import "components/autofill/ios/form_util/test_form_activity_observer.h"
#import "ios/web/public/js_messaging/java_script_feature.h"
#import "ios/web/public/js_messaging/web_frame.h"
#import "ios/web/public/js_messaging/web_frames_manager.h"
#import "ios/web/public/test/fakes/fake_web_client.h"
#import "ios/web/public/test/fakes/fake_web_state_observer_util.h"
#import "ios/web/public/test/js_test_util.h"
#import "ios/web/public/test/web_test_with_web_state.h"
namespace autofill {
namespace {
using base::test::WithFeatureOverride;
using base::test::ios::kWaitForJSCompletionTimeout;
using base::test::ios::WaitUntilConditionOrTimeout;
using test::kTrackFormMutationsDelayInMs;
using ::testing::Each;
using ::testing::ElementsAre;
using ::testing::Field;
using ::testing::IsEmpty;
using ::testing::IsTrue;
using ::testing::Not;
using ::testing::StrEq;
using ::testing::UnorderedElementsAre;
using ::testing::VariantWith;
using web::JavaScriptFeature;
using web::WebFrame;
// Default maximum length for text input fields defined by W3C.
constexpr uint64_t kTextInputFieldMaxLength = 524288;
// HTML containing one form with a text field and a submit button.
constexpr NSString* kTestHTMLForm = @"<form name='form-name'>"
"<input type='text' id='text'/>"
"<input type='submit' id='button'/>"
"</form>";
// HTML containing one form wrapping two iframe elements.
constexpr NSString* kTestHTMLFormWithIframes =
@"<form name='form-name'>"
"<iframe id='frame1' srcdoc='<form><input type=\"text\" name=\"username\" "
"id=\"username\"></form>'></iframe>"
"<iframe id='frame2' srcdoc='<form><input type=\"password\" "
"name=\"password\" id=\"password\"></form>'></iframe>"
"</form>";
// Returns the `FormData` representation of the form in `kTestHTMLForm`.
[[nodiscard]] FormData BuildTestFormData(std::string frame_id) {
std::optional<base::UnguessableToken> host_frame =
DeserializeJavaScriptFrameId(frame_id);
FormData test_form_data;
test_form_data.set_name(u"form-name");
test_form_data.set_url(GURL("https://chromium.test/"));
test_form_data.set_action(GURL("https://chromium.test/"));
test_form_data.set_name_attribute(u"form-name");
test_form_data.set_renderer_id(FormRendererId(1));
test_form_data.set_host_frame(LocalFrameToken(*host_frame));
FormFieldData test_field_data;
test_field_data.set_name(u"text");
test_field_data.set_form_control_type(FormControlType::kInputText);
test_field_data.set_host_frame(LocalFrameToken(*host_frame));
test_field_data.set_host_form_id(test_form_data.renderer_id());
test_field_data.set_renderer_id(FieldRendererId(2));
test_field_data.set_id_attribute(u"text");
// user_edited is true when the sources of inputs are not being tracked.
test_field_data.set_is_user_edited(true);
test_field_data.set_max_length(kTextInputFieldMaxLength);
test_form_data.set_fields({test_field_data});
return test_form_data;
}
} // namespace
// Tests fixture for autofill::FormActivityTabHelper class.
class FormActivityTabHelperTest : public AutofillTestWithWebState {
public:
FormActivityTabHelperTest()
: AutofillTestWithWebState(std::make_unique<web::FakeWebClient>()) {
web::FakeWebClient* web_client =
static_cast<web::FakeWebClient*>(GetWebClient());
std::vector<JavaScriptFeature*> features = {
FormHandlersJavaScriptFeature::GetInstance(),
AutofillJavaScriptFeature::GetInstance(),
ProgrammaticFormSubmissionHandlerJavaScriptFeature::GetInstance(),
};
web_client->SetJavaScriptFeatures(features);
}
void SetUp() override {
web::WebTestWithWebState::SetUp();
FormActivityTabHelper* tab_helper =
FormActivityTabHelper::GetOrCreateForWebState(web_state());
observer_ = std::make_unique<TestFormActivityObserver>(web_state());
tab_helper->AddObserver(observer_.get());
}
void TearDown() override {
FormActivityTabHelper* tab_helper =
FormActivityTabHelper::GetOrCreateForWebState(web_state());
tab_helper->RemoveObserver(observer_.get());
web::WebTestWithWebState::TearDown();
}
protected:
WebFrame* WaitForMainFrame(web::ContentWorld content_world) {
__block WebFrame* main_frame = nullptr;
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool {
web::WebFramesManager* frames_manager =
web_state()->GetWebFramesManager(content_world);
main_frame = frames_manager->GetMainWebFrame();
return main_frame != nullptr;
}));
return main_frame;
}
WebFrame* WaitForMainFrame() {
return WaitForMainFrame(ContentWorldForAutofillJavascriptFeatures());
}
// Verifies the form activity params received after a form mutation.
void ValidateParamsAfterFormChangedEvent(const FormActivityParams& params) {
FormActivityParams expected_activity_params;
expected_activity_params.frame_id = WaitForMainFrame()->GetFrameId();
expected_activity_params.is_main_frame = true;
expected_activity_params.type = "form_changed";
EXPECT_EQ(params, expected_activity_params);
}
base::HistogramTester histogram_tester_;
std::unique_ptr<TestFormActivityObserver> observer_;
};
// Tests that observer is called on form submission using submit control.
TEST_F(FormActivityTabHelperTest, TestObserverDocumentSubmitted) {
LoadHtml(kTestHTMLForm);
WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
ASSERT_FALSE(observer_->submit_document_info());
FormData test_form_data = BuildTestFormData(main_frame->GetFrameId());
ExecuteJavaScript(@"document.getElementById('button').click();");
ASSERT_TRUE(observer_->submit_document_info());
EXPECT_EQ(web_state(), observer_->submit_document_info()->web_state);
EXPECT_EQ(main_frame, observer_->submit_document_info()->sender_frame);
EXPECT_EQ(test_form_data, observer_->submit_document_info()->form_data);
EXPECT_FALSE(observer_->submit_document_info()->has_user_gesture);
histogram_tester_.ExpectUniqueSample(kProgrammaticFormSubmissionHistogram,
false, 1);
histogram_tester_.ExpectUniqueSample(kFormSubmissionOutcomeHistogram, 0, 1);
histogram_tester_.ExpectTotalCount(kInvalidSubmittedFormReasonHistogram, 0);
// Verify that there isn't any form activity metric recorded as the form
// submit signals aren't covered.
histogram_tester_.ExpectTotalCount("Autofill.iOS.FormActivity.DropCount", 0);
histogram_tester_.ExpectTotalCount("Autofill.iOS.FormActivity.SendCount", 0);
histogram_tester_.ExpectTotalCount("Autofill.iOS.FormActivity.SendRatio", 0);
}
// Tests that submit event from same-origin iframe correctly delivered to
// WebStateObserver.
TEST_F(FormActivityTabHelperTest, FormSubmittedFromSameOriginIFrame) {
LoadHtml(@"<iframe id='frame1'></iframe>");
ExecuteJavaScript(
@"document.getElementById('frame1').contentDocument.body.innerHTML = "
"'<form id=\"form1\">"
"<input type=\"password\" name=\"password\" id=\"id2\">"
"<input type=\"submit\" id=\"submit_input\"/>"
"</form>'");
ExecuteJavaScript(
@"document.getElementById('frame1').contentDocument.getElementById('"
@"submit_input').click();");
TestSubmitDocumentInfo* info = observer_->submit_document_info();
ASSERT_TRUE(info);
EXPECT_EQ(u"form1", info->form_data.name());
}
// Tests that observer is called on form activity (input event).
// TODO(crbug.com/40902648): Disabled test due to bot failure. Re-enable when
// fixed.
TEST_F(FormActivityTabHelperTest,
DISABLED_TestObserverFormActivityFrameMessaging) {
LoadHtml(@"<form name='form-name'>"
"<input type='input' name='field-name' id='fieldid'/>"
"</form>");
WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
ASSERT_FALSE(observer_->form_activity_info());
// First call will set document.activeElement (which is usually set by user
// action. Second call will trigger the message.
ExecuteJavaScript(@"document.getElementById('fieldid').focus();");
ASSERT_FALSE(observer_->form_activity_info());
ExecuteJavaScript(@"document.getElementById('fieldid').focus();");
ASSERT_TRUE(base::test::ios::WaitUntilConditionOrTimeout(
base::test::ios::kWaitForJSCompletionTimeout, ^bool {
return observer_->form_activity_info() != nullptr;
}));
EXPECT_EQ(web_state(), observer_->form_activity_info()->web_state);
EXPECT_EQ(main_frame, observer_->form_activity_info()->sender_frame);
EXPECT_EQ("form-name",
observer_->form_activity_info()->form_activity.form_name);
EXPECT_EQ("text", observer_->form_activity_info()->form_activity.field_type);
EXPECT_EQ("focus", observer_->form_activity_info()->form_activity.type);
EXPECT_EQ("", observer_->form_activity_info()->form_activity.value);
EXPECT_TRUE(observer_->form_activity_info()->form_activity.is_main_frame);
EXPECT_TRUE(observer_->form_activity_info()->form_activity.has_user_gesture);
}
// Tests that keyup event is not delivered to WebStateObserver if the element is
// not focused.
TEST_F(FormActivityTabHelperTest, KeyUpEventNotFocused) {
LoadHtml(@"<input id='test'/>");
ASSERT_FALSE(observer_->form_activity_info());
ExecuteJavaScript(@"var e = document.getElementById('test');"
"var ev = new KeyboardEvent('keyup', {bubbles:true});"
"e.dispatchEvent(ev);");
// Pump the run loop to get the renderer response.
WaitForBackgroundTasks();
TestFormActivityInfo* info = observer_->form_activity_info();
ASSERT_FALSE(info);
}
// Tests that focus event correctly delivered to WebStateObserver.
TEST_F(FormActivityTabHelperTest, FocusMainFrame) {
LoadHtml(@"<form>"
"<input type='text' name='username' id='id1'>"
"<input type='password' name='password' id='id2'>"
"</form>");
ASSERT_FALSE(observer_->form_activity_info());
ExecuteJavaScript(@"document.getElementById('id1').focus();");
TestFormActivityObserver* block_observer = observer_.get();
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return block_observer->form_activity_info() != nullptr;
}));
TestFormActivityInfo* info = observer_->form_activity_info();
ASSERT_TRUE(info);
EXPECT_EQ("focus", info->form_activity.type);
EXPECT_FALSE(info->form_activity.input_missing);
}
// Tests that focus event from same-origin iframe correctly delivered to
// WebStateObserver.
TEST_F(FormActivityTabHelperTest, FocusSameOriginIFrame) {
LoadHtml(@"<iframe id='frame1'></iframe>");
ExecuteJavaScript(
@"document.getElementById('frame1').contentDocument.body.innerHTML = "
"'<form>"
"<input type=\"text\" name=\"username\" id=\"id1\">"
"<input type=\"password\" name=\"password\" id=\"id2\">"
"</form>'");
ExecuteJavaScript(
@"document.getElementById('frame1').contentDocument.getElementById('id1')"
@".focus()");
TestFormActivityObserver* block_observer = observer_.get();
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
return block_observer->form_activity_info() != nullptr;
}));
TestFormActivityInfo* info = observer_->form_activity_info();
ASSERT_TRUE(info);
EXPECT_EQ("focus", info->form_activity.type);
EXPECT_FALSE(info->form_activity.input_missing);
}
// Tests that a new element that contains 'form' in the tag name does not
// trigger a form_changed event.
TEST_F(FormActivityTabHelperTest, AddCustomElement) {
LoadHtml(@"<body></body>");
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
TrackFormMutations(main_frame);
ExecuteJavaScript(@"var form = document.createElement('my-form');"
@"document.body.appendChild(form);");
// Check that no activity is observed upon JS completion.
TestFormActivityObserver* block_observer = observer_.get();
__block TestFormActivityInfo* info = nil;
EXPECT_FALSE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
info = block_observer->form_activity_info();
return info != nil;
}));
}
// Test fixture verifying the behavior of FormActivityTabHelper when handling
// form mutation events.
class FormMutationTest : public FormActivityTabHelperTest {
public:
void SetUp() override { FormActivityTabHelperTest::SetUp(); }
protected:
// Loads the specified HTML content, prepares for form mutation tracking.
void LoadHtmlForMutationTest(NSString* html) {
LoadHtml(html);
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
TrackFormMutations(main_frame);
// Force fetching forms to set the elements renderer IDs.
// Element IDs are set on demand, the first time they are queried for an
// element. Setting them here make the tests easier to maintain because the
// elements in a DOM will get the ID assigned in the order they appear in
// the document.
ASSERT_TRUE(SetUpUniqueIDs());
}
/**
* Removes the specified HTML element from the DOM and returns the
* corresponding form removal parameters.
* - `element_id` The ID of the HTML element to remove.
* Retruns an `std::optional` containing the `FormRemovalParams` if the
* removal was successful and the event was received within the timeout;
* otherwise, an empty `std::optional`.
*/
std::optional<FormRemovalParams> RemoveElement(NSString* element_id) {
ExecuteJavaScript(
[NSString stringWithFormat:@"document.getElementById('%@').remove();",
element_id]);
TestFormActivityObserver* block_observer = observer_.get();
__block TestFormRemovalInfo* info = nil;
// Wait for form removal message delivery.
bool form_removal_info_received = WaitUntilConditionOrTimeout(
base::Milliseconds(kTrackFormMutationsDelayInMs * 2), ^{
info = block_observer->form_removal_info();
return info != nil;
});
if (!form_removal_info_received) {
return std::nullopt;
}
web::WebFrame* main_frame = WaitForMainFrame();
CHECK(main_frame);
EXPECT_EQ(web_state(), info->web_state);
EXPECT_EQ(main_frame, info->sender_frame);
EXPECT_THAT(info->form_removal_params.frame_id,
StrEq(main_frame->GetFrameId()));
return info->form_removal_params;
}
// Forces fetching forms in the main frame which sets renderer IDs in the
// relevant forms and fields. IDs are set in the order the elements appear in
// the DOM tree.
bool SetUpUniqueIDs() {
WebFrame* main_frame = WaitForMainFrame();
if (!main_frame) {
return false;
}
__block bool finished = false;
AutofillJavaScriptFeature::GetInstance()->FetchForms(
main_frame, base::BindOnce(^(NSString* result) {
finished = true;
}));
return WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool {
return finished;
});
}
};
// Tests that observer is called on form removal.
TEST_F(FormMutationTest, PasswordFormRemovalRegistered) {
LoadHtmlForMutationTest(
@"<form name=\"form1\" id=\"form1\">"
"<input type=\"text\" name=\"username\" id=\"id1\">"
"<input type=\"password\" name=\"password\" id=\"id2\">"
"<input type=\"submit\" id=\"submit_input\"/>"
"</form>");
ASSERT_FALSE(observer_->form_removal_info());
std::optional<FormRemovalParams> form_removal_params =
RemoveElement(/*element_id=*/@"form1");
ASSERT_TRUE(form_removal_params);
EXPECT_THAT(form_removal_params.value().removed_forms,
ElementsAre(FormRendererId(1)));
EXPECT_THAT(form_removal_params.value().removed_unowned_fields, IsEmpty());
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.DropCount",
/*sample=*/0,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendCount",
/*sample=*/1,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendRatio",
/*sample=*/100,
/*expected_bucket_count=*/1);
// Validate that only one removal event is received.
ASSERT_FALSE(WaitUntilConditionOrTimeout(
base::Milliseconds(kTrackFormMutationsDelayInMs * 2), ^bool {
return observer_->number_of_events_received() > 1;
}));
}
// Tests that removing non-password form triggers
// 'form_removed" event.
TEST_F(FormMutationTest, RemoveNonPasswordForm) {
// Load html with one form.
LoadHtmlForMutationTest(@"<form id='form1'>"
"<input type='text'>"
"</form>");
std::optional<FormRemovalParams> form_removal_params =
RemoveElement(/*element_id=*/@"form1");
ASSERT_TRUE(form_removal_params);
EXPECT_THAT(form_removal_params.value().removed_forms,
ElementsAre(FormRendererId(1)));
EXPECT_THAT(form_removal_params.value().removed_unowned_fields, IsEmpty());
}
// Tests that removing multiple forms triggers
// 'form_removed" event.
TEST_F(FormMutationTest, RemoveMultipleForms) {
// Load html with multiple forms.
LoadHtmlForMutationTest(@"<div id='div'>"
"<form id='form1'>"
"<input type='password'>"
"</form>"
"<form id='form2'>"
"<input type='text'>"
"</form>"
"<form id='form3'>"
"<input type='email'>"
"</form>"
"</div>");
std::optional<FormRemovalParams> form_removal_params =
RemoveElement(/*element_id=*/@"div");
ASSERT_TRUE(form_removal_params);
const FormRendererId form1_id = FormRendererId(1);
const FormRendererId form2_id = FormRendererId(3);
const FormRendererId form3_id = FormRendererId(5);
EXPECT_THAT(form_removal_params.value().removed_forms,
UnorderedElementsAre(form1_id, form2_id, form3_id));
EXPECT_THAT(form_removal_params.value().removed_unowned_fields, IsEmpty());
}
// Tests that removing unowned password fields triggers 'password_form_removed"
// event.
TEST_F(FormMutationTest, RemoveFormlessPasswordFields) {
LoadHtmlForMutationTest(
@"<body><div>"
"<input type=\"password\" name=\"password\" id=\"pw\">"
"<input type=\"submit\" id=\"submit_input\"/>"
"</div></body>");
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
std::optional<FormRemovalParams> form_removal_params =
RemoveElement(/*element_id=*/@"pw");
ASSERT_TRUE(form_removal_params);
EXPECT_THAT(form_removal_params.value().removed_forms, IsEmpty());
EXPECT_THAT(form_removal_params.value().removed_unowned_fields,
ElementsAre(FieldRendererId(1)));
EXPECT_THAT(form_removal_params.value().frame_id, Not(IsEmpty()));
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.DropCount",
/*sample=*/0,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendCount",
/*sample=*/1,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendRatio",
/*sample=*/100,
/*expected_bucket_count=*/1);
// Validate that only one removal event is received.
ASSERT_FALSE(WaitUntilConditionOrTimeout(
base::Milliseconds(kTrackFormMutationsDelayInMs * 2), ^bool {
return observer_->number_of_events_received() > 1;
}));
}
// Tests that removing multiple forms and formless fields triggers
// 'form_removed" event.
TEST_F(FormMutationTest, RemoveMultipleFormsAndFormlessFields) {
// Load html with multiple forms and formless fields.
LoadHtmlForMutationTest(@"<div id='div'>"
"<form id='form1'>"
"<input type='password'/>"
"</form>"
"<form id='form2'>"
"<input type='text'/>"
"</form>"
"<form id='form3'>"
"<input type='email'/>"
"</form>"
"<input id='password' type='password'/>"
"<input id='text' type='text'/>"
"</div>");
std::optional<FormRemovalParams> form_removal_params =
RemoveElement(/*element_id=*/@"div");
ASSERT_TRUE(form_removal_params);
const FormRendererId form1_id = FormRendererId(1);
const FormRendererId form2_id = FormRendererId(3);
const FormRendererId form3_id = FormRendererId(5);
const FieldRendererId password_id = FieldRendererId(7);
const FieldRendererId text_id = FieldRendererId(8);
EXPECT_THAT(form_removal_params.value().removed_forms,
UnorderedElementsAre(form1_id, form2_id, form3_id));
EXPECT_THAT(form_removal_params.value().removed_unowned_fields,
UnorderedElementsAre(password_id, text_id));
}
// Tests that removing a form control element and adding a new one in the same
// mutations batch is notified with a message for each mutation, sent
// back-to-back.
TEST_F(FormMutationTest, RemovedAndAddedFormsRegistered) {
// Basic HTML page in which we add a HTML form.
NSString* const html = @"<html><body><form id=\"form1\">"
"<input type=\"password\"></form></body></html>";
LoadHtmlForMutationTest(html);
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
ASSERT_FALSE(observer_->form_removal_info());
ASSERT_FALSE(observer_->form_activity_info());
// Make a script to create a new form and replace the old form with it.
NSString* const replace_form_JS =
@"const newForm = document.createElement('form'); "
"newForm.id = 'form2'; "
"const oldForm = document.forms[0]; "
"oldForm.parentNode.replaceChild(newForm, oldForm);";
// Replace the form to trigger an added and a removed form mutation event
// batched together.
ExecuteJavaScript(replace_form_JS);
// Wait until the first message is received.
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool() {
return observer_->number_of_events_received() == 1;
}));
// The removed form message is always the first posted.
ASSERT_TRUE(observer_->form_removal_info());
FormRemovalParams form_removal_params =
observer_->form_removal_info()->form_removal_params;
EXPECT_THAT(form_removal_params.frame_id, Not(IsEmpty()));
EXPECT_THAT(form_removal_params.removed_unowned_fields, IsEmpty());
EXPECT_THAT(form_removal_params.removed_forms,
ElementsAre(FormRendererId(1)));
// Wait until the next message is received.
EXPECT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool() {
return observer_->number_of_events_received() == 2;
}));
ASSERT_TRUE(observer_->form_activity_info());
ValidateParamsAfterFormChangedEvent(
observer_->form_activity_info()->form_activity);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.DropCount",
/*sample=*/0,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendCount",
/*sample=*/2,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendRatio",
/*sample=*/100,
/*expected_bucket_count=*/1);
}
// Tests that messages that were batched and dropped are correctly recorded as
// such.
TEST_F(FormMutationTest, RemovedAndAddedFormsRegistered_WithDroppedMessages) {
// Basic HTML page with 2 password forms and one formless password form.
NSString* const html = @"<html><body><form id=\"form1\">"
"<input type=\"password\"></form>"
"<form id=\"form2\"><input type=\"password\"></form>"
"<input id=\"input1\" type=\"password\">"
"</body></html>";
LoadHtmlForMutationTest(html);
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
ASSERT_FALSE(observer_->form_removal_info());
ASSERT_FALSE(observer_->form_activity_info());
// Make a script that batches 2 messages and ignore all other cases once full.
NSString* const add_and_remove_form_JS =
@"const parentNode = document.forms[0].parentNode; "
// Add a generic form and remove a password form, both of which will be
// notified in the same batch.
"parentNode.appendChild(document.createElement('form')); "
"const form1 = document.getElementById('form1'); "
"form1.remove(); "
// Form transformations from here should be ignored.
// Add non-password form and remove it, 2 notifications dropped.
"parentNode.appendChild(document.createElement('form')).remove(); "
// Remove formless password input, 1 notification dropped.
"document.getElementById('input1').remove();"
// Remove password form, 1 notification dropped.
"document.getElementById('form2').remove();";
ExecuteJavaScript(add_and_remove_form_JS);
// Wait on all the messages in the batch.
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool {
return observer_->form_removal_info() != nullptr &&
observer_->form_activity_info() != nullptr;
}));
EXPECT_THAT(observer_->form_removal_info()->form_removal_params.removed_forms,
ElementsAre(FormRendererId(1)));
EXPECT_THAT(observer_->form_removal_info()
->form_removal_params.removed_unowned_fields,
IsEmpty());
ValidateParamsAfterFormChangedEvent(
observer_->form_activity_info()->form_activity);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.DropCount",
/*sample=*/4,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendCount",
/*sample=*/2,
/*expected_bucket_count=*/1);
histogram_tester_.ExpectUniqueSample("Autofill.iOS.FormActivity.SendRatio",
/*sample=*/33,
/*expected_bucket_count=*/1);
}
// Tests that removing input fields triggers the right events.
TEST_F(FormMutationTest, RemoveFormlessFields) {
LoadHtmlForMutationTest(@"<body><div id='div'>"
"<input type='password' id='password'/>"
"<input type='text' id='text'/>"
"<input type='submit' id='submit_input'/>"
"<input type='email' id='email'/>"
"<input type='tel' id='phone'/>"
"<input type='url' id='url'/>"
"<input type='number' id='number'/>"
"<input type='checkbox' id='checkbox' />"
"<input type='radio' id='radio'/>"
"<select id='select'>"
" <option value='v1'>v1</option>"
" <option value='v2'>v2</option>"
"</select>"
"<textarea id='textarea'/>"
"</div></body>");
std::optional<FormRemovalParams> form_removal_params =
RemoveElement(/*element_id=*/@"div");
ASSERT_TRUE(form_removal_params);
EXPECT_TRUE(form_removal_params.value().removed_forms.empty());
const FieldRendererId password_id = FieldRendererId(1);
const FieldRendererId text_id = FieldRendererId(2);
const FieldRendererId email_id = FieldRendererId(3);
const FieldRendererId phone_id = FieldRendererId(4);
const FieldRendererId url_id = FieldRendererId(5);
const FieldRendererId number_id = FieldRendererId(6);
const FieldRendererId checkbox_id = FieldRendererId(7);
const FieldRendererId radio_id = FieldRendererId(8);
const FieldRendererId select_id = FieldRendererId(9);
const FieldRendererId textarea_id = FieldRendererId(10);
EXPECT_THAT(form_removal_params.value().removed_forms, IsEmpty());
EXPECT_THAT(form_removal_params.value().removed_unowned_fields,
UnorderedElementsAre(password_id, text_id, email_id, phone_id,
url_id, number_id, checkbox_id, radio_id,
select_id, textarea_id));
}
// Tests that a new form triggers form_changed event.
TEST_F(FormMutationTest, AddForm) {
LoadHtmlForMutationTest(@"<body></body>");
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
ExecuteJavaScript(@"var form = document.createElement('form');"
@"document.body.appendChild(form);");
TestFormActivityObserver* block_observer = observer_.get();
__block TestFormActivityInfo* info = nil;
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^{
info = block_observer->form_activity_info();
return info != nil;
}));
ValidateParamsAfterFormChangedEvent(info->form_activity);
}
// Test fixture verifying the behavior of FormActivityTabHelper when handling
// mutations involving form control elements.
class FormMutationFormControlElements
: public FormActivityTabHelperTest,
public testing::WithParamInterface<std::string> {};
// Tests that adding a formless control element is notified as a form changed
// mutation.
TEST_P(FormMutationFormControlElements, AddedFormlessControlElement) {
// Basic HTML page in which we add HTML form control elements.
NSString* const html = @"<html><body></body></html>";
LoadHtml(html);
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
std::string element_tag = GetParam();
TrackFormMutations(main_frame);
// Add the element to the page.
NSString* const insert_element_JS = [NSString
stringWithFormat:@"const element = document.createElement('%@'); "
"document.body.append(element); ",
base::SysUTF8ToNSString(element_tag)];
ExecuteJavaScript(insert_element_JS);
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool() {
return observer_->form_activity_info() != nullptr;
}));
ValidateParamsAfterFormChangedEvent(
observer_->form_activity_info()->form_activity);
}
// Tests that adding a form control element is notified as a form changed
// mutation.
TEST_P(FormMutationFormControlElements, AddedFormControlElement) {
// Basic HTML page in which we add HTML form control elements.
NSString* const html = @"<html><body><form id='form'></form></body></html>";
LoadHtml(html);
web::WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
std::string element_tag = GetParam();
TrackFormMutations(main_frame);
// Add the element to the page.
NSString* const insert_element_JS = [NSString
stringWithFormat:@"const element = document.createElement('%@'); "
"document.getElementById('form').append(element); ",
base::SysUTF8ToNSString(element_tag)];
ExecuteJavaScript(insert_element_JS);
ASSERT_TRUE(WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool() {
return observer_->form_activity_info() != nullptr;
}));
ValidateParamsAfterFormChangedEvent(
observer_->form_activity_info()->form_activity);
}
INSTANTIATE_TEST_SUITE_P(
/* No InstantiationName */,
FormMutationFormControlElements,
::testing::Values("form", "input", "select", "option", "textarea"));
// Test fixture for testing programmatic form submissions with/without Autofill
// in the isolated world enabled.
// TODO(crbug.com/359538514): Remove this fixture and move test back to
// FormActivityTabHelperTest once isolated world for Autofill is launched.
class FormSubmittedHookTest : public FormActivityTabHelperTest {
public:
FormSubmittedHookTest() : FormActivityTabHelperTest() {
web::FakeWebClient* web_client =
static_cast<web::FakeWebClient*>(GetWebClient());
renderer_id_feature_ =
autofill::test::CreateRendererIdTestJavaScriptFeature();
web_client->SetJavaScriptFeatures({
FormHandlersJavaScriptFeature::GetInstance(),
AutofillJavaScriptFeature::GetInstance(),
ProgrammaticFormSubmissionHandlerJavaScriptFeature::GetInstance(),
renderer_id_feature_.get(),
});
}
protected:
// Runs the form extraction script and returns true if the script ran
// successfully.
bool FetchForms() {
WebFrame* main_frame = WaitForMainFrame();
if (!main_frame) {
return false;
}
__block bool finished = false;
AutofillJavaScriptFeature::GetInstance()->FetchForms(
main_frame, base::BindOnce(^(NSString* result) {
finished = true;
}));
return WaitUntilConditionOrTimeout(kWaitForJSCompletionTimeout, ^bool {
return finished;
});
}
private:
std::unique_ptr<web::JavaScriptFeature> renderer_id_feature_;
};
// Validate that programmatic form submissions are detected and sent to
// observers of the tab helper.
TEST_F(FormSubmittedHookTest, TestFormSubmittedHook) {
LoadHtml(kTestHTMLForm);
WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
AutofillFormFeaturesJavaScriptFeature::GetInstance()
->SetAutofillIsolatedContentWorld(main_frame, true);
web::test::ExecuteJavaScriptForFeature(
web_state(),
@"var form = document.forms[0];"
"__gCrWeb.getRegisteredApi('renderer_id_test').getFunction('"
"setUniqueIDIfNeeded')(form);"
"var input = document.getElementById('text');"
"__gCrWeb.getRegisteredApi('renderer_id_test').getFunction('"
"setUniqueIDIfNeeded')(input);",
AutofillJavaScriptFeature::GetInstance());
ASSERT_FALSE(observer_->submit_document_info());
// Make sure the script triggering the submission hooks runs in the page
// content world.
web::test::ExecuteJavaScriptForFeature(
web_state(), @"document.forms[0].submit();",
ProgrammaticFormSubmissionHandlerJavaScriptFeature::GetInstance());
FormData test_form_data = BuildTestFormData(main_frame->GetFrameId());
ASSERT_TRUE(observer_->submit_document_info());
EXPECT_EQ(main_frame, observer_->submit_document_info()->sender_frame);
EXPECT_EQ(test_form_data, observer_->submit_document_info()->form_data);
EXPECT_FALSE(observer_->submit_document_info()->has_user_gesture);
histogram_tester_.ExpectUniqueSample(kProgrammaticFormSubmissionHistogram,
true, 1);
// Verify that there isn't any form activity metric recorded as the form
// submit signals aren't covered.
histogram_tester_.ExpectTotalCount("Autofill.iOS.FormActivity.DropCount", 0);
histogram_tester_.ExpectTotalCount("Autofill.iOS.FormActivity.SendCount", 0);
histogram_tester_.ExpectTotalCount("Autofill.iOS.FormActivity.SendRatio", 0);
}
// Validate that programmatic form submissions are detected and sent to
// observers of the tab helper.
TEST_F(FormSubmittedHookTest, TestFormSubmittedHookAcrossIframes) {
LoadHtml(kTestHTMLFormWithIframes);
WebFrame* main_frame = WaitForMainFrame();
ASSERT_TRUE(main_frame);
// Set feature flags in both worlds.
AutofillFormFeaturesJavaScriptFeature::GetInstance()
->SetAutofillIsolatedContentWorld(main_frame, true);
AutofillFormFeaturesJavaScriptFeature::GetInstance()
->SetAutofillAcrossIframes(main_frame, true);
AutofillFormFeaturesJavaScriptFeature::GetInstance()
->SetAutofillIsolatedContentWorld(
WaitForMainFrame(web::ContentWorld::kPageContentWorld), true);
AutofillFormFeaturesJavaScriptFeature::GetInstance()
->SetAutofillAcrossIframes(
WaitForMainFrame(web::ContentWorld::kPageContentWorld), true);
// Trigger form extraction so child frame tokens are set in the isolated
// world. Page scripts will be able to access the tokens through the fallback
// stored in the DOM.
FetchForms();
ASSERT_FALSE(observer_->submit_document_info());
// Run script triggering the submission hooks in the page
// content world.
web::test::ExecuteJavaScriptForFeature(
web_state(), @"document.forms[0].submit();",
ProgrammaticFormSubmissionHandlerJavaScriptFeature::GetInstance());
ASSERT_TRUE(observer_->submit_document_info());
const FormData& submitted_form = observer_->submit_document_info()->form_data;
// Make sure child frames are populated.
EXPECT_THAT(submitted_form.child_frames(),
Each(Field(&FrameTokenWithPredecessor::token,
VariantWith<RemoteFrameToken>(IsTrue()))));
}
} // namespace autofill