Bug 1900009 - Dispatch DOMPossibleUsernameInputAdded event for valid text and email inputs. r=dimi,edgar

Differential Revision: https://phabricator.services.mozilla.com/D214821
This commit is contained in:
Ryan Safaeian
2024-07-12 17:38:48 +00:00
parent 52f40ac5fd
commit ccd363c347
12 changed files with 290 additions and 132 deletions

View File

@@ -164,7 +164,7 @@ NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED_0(HTMLFormElement,
void HTMLFormElement::AsyncEventRunning(AsyncEventDispatcher* aEvent) { void HTMLFormElement::AsyncEventRunning(AsyncEventDispatcher* aEvent) {
if (aEvent->mEventType == u"DOMFormHasPassword"_ns) { if (aEvent->mEventType == u"DOMFormHasPassword"_ns) {
mHasPendingPasswordEvent = false; mHasPendingPasswordEvent = false;
} else if (aEvent->mEventType == u"DOMFormHasPossibleUsername"_ns) { } else if (aEvent->mEventType == u"DOMPossibleUsernameInputAdded"_ns) {
mHasPendingPossibleUsernameEvent = false; mHasPendingPossibleUsernameEvent = false;
} }
} }

View File

@@ -57,7 +57,8 @@ class HTMLFormElement final : public nsGenericHTMLElement {
/** Whether we already dispatched a DOMFormHasPassword event or not */ /** Whether we already dispatched a DOMFormHasPassword event or not */
bool mHasPendingPasswordEvent = false; bool mHasPendingPasswordEvent = false;
/** Whether we already dispatched a DOMFormHasPossibleUsername event or not */ /** Whether we already dispatched a DOMPossibleUsernameInputAdded event or not
*/
bool mHasPendingPossibleUsernameEvent = false; bool mHasPendingPossibleUsernameEvent = false;
// nsIContent // nsIContent

View File

@@ -4415,7 +4415,7 @@ void HTMLInputElement::MaybeDispatchLoginManagerEvents(HTMLFormElement* aForm) {
} }
nsString eventType; nsString eventType;
Element* target = nullptr; EventTarget* target = nullptr;
if (mType == FormControlType::InputPassword) { if (mType == FormControlType::InputPassword) {
// Don't fire another event if we have a pending event. // Don't fire another event if we have a pending event.
@@ -4426,28 +4426,40 @@ void HTMLInputElement::MaybeDispatchLoginManagerEvents(HTMLFormElement* aForm) {
// TODO(Bug 1864404): Use one event for formless and form inputs. // TODO(Bug 1864404): Use one event for formless and form inputs.
eventType = aForm ? u"DOMFormHasPassword"_ns : u"DOMInputPasswordAdded"_ns; eventType = aForm ? u"DOMFormHasPassword"_ns : u"DOMInputPasswordAdded"_ns;
target = aForm ? static_cast<Element*>(aForm) : this;
if (aForm) { if (aForm) {
target = aForm;
aForm->mHasPendingPasswordEvent = true; aForm->mHasPendingPasswordEvent = true;
} else {
target = this;
} }
} else if (mType == FormControlType::InputEmail || } else if (mType == FormControlType::InputEmail ||
mType == FormControlType::InputText) { mType == FormControlType::InputText) {
// Don't fire a username event if: // Don't fire a username event if:
// - <input> is not part of a form
// - we have a pending event // - we have a pending event
// - username only forms are not supported // - username only forms are not supported
if (!aForm || aForm->mHasPendingPossibleUsernameEvent || // fire event if we have a username field without a form with the
!StaticPrefs::signon_usernameOnlyForm_enabled()) { // autcomplete value of username
if (!StaticPrefs::signon_usernameOnlyForm_enabled()) {
return; return;
} }
eventType = u"DOMFormHasPossibleUsername"_ns; if (aForm) {
target = aForm; if (aForm->mHasPendingPossibleUsernameEvent) {
return;
}
aForm->mHasPendingPossibleUsernameEvent = true; aForm->mHasPendingPossibleUsernameEvent = true;
target = aForm;
} else {
nsAutoString autocompleteValue;
GetAutocomplete(autocompleteValue);
if (!autocompleteValue.EqualsASCII("username")) {
return;
}
target = GetComposedDoc();
}
eventType = u"DOMPossibleUsernameInputAdded"_ns;
} else { } else {
return; return;
} }

View File

@@ -49,18 +49,33 @@ export const LoginFormFactory = {
let formLike = lazy.FormLikeFactory.createFromForm(aForm); let formLike = lazy.FormLikeFactory.createFromForm(aForm);
formLike.action = lazy.LoginHelper.getFormActionOrigin(aForm); formLike.action = lazy.LoginHelper.getFormActionOrigin(aForm);
let rootElementsSet = this.getRootElementsWeakSetForDocument( this._addLoginFormToRootElementsSet(formLike);
formLike.ownerDocument
); return formLike;
rootElementsSet.add(formLike.rootElement); },
/**
* Create a LoginForm object from the HTMLHtmlElement that is the root of the document
*
* Currently all <input> not in a <form> are one LoginForm but this
* shouldn't be relied upon as the heuristics may change to detect multiple
* "forms" (e.g. registration and login) on one page with a <form>.
*
* @param {HTMLHtmlElement} aDocumentRoot
* @return {LoginForm}
* @throws Error if aDocumentRoot isn't an HTMLHtmlElement
*/
createFromDocumentRoot(aDocumentRoot) {
const formLike = lazy.FormLikeFactory.createFromDocumentRoot(aDocumentRoot);
formLike.action = lazy.LoginHelper.getLoginOrigin(aDocumentRoot.baseURI);
lazy.log.debug( lazy.log.debug(
"adding", "Created non-form LoginForm for rootElement:",
formLike.rootElement, aDocumentRoot
"to root elements for",
formLike.ownerDocument
); );
this._loginFormsByRootElement.set(formLike.rootElement, formLike); this._addLoginFormToRootElementsSet(formLike);
return formLike; return formLike;
}, },
@@ -113,19 +128,7 @@ export const LoginFormFactory = {
aField.ownerDocument.documentElement aField.ownerDocument.documentElement
); );
let rootElementsSet = this.getRootElementsWeakSetForDocument( this._addLoginFormToRootElementsSet(formLike);
formLike.ownerDocument
);
rootElementsSet.add(formLike.rootElement);
lazy.log.debug(
"adding",
formLike.rootElement,
"to root elements for",
formLike.ownerDocument
);
this._loginFormsByRootElement.set(formLike.rootElement, formLike);
return formLike; return formLike;
}, },
@@ -145,4 +148,19 @@ export const LoginFormFactory = {
setForRootElement(aRootElement, aLoginForm) { setForRootElement(aRootElement, aLoginForm) {
return this._loginFormsByRootElement.set(aRootElement, aLoginForm); return this._loginFormsByRootElement.set(aRootElement, aLoginForm);
}, },
_addLoginFormToRootElementsSet(formLike) {
let rootElementsSet = this.getRootElementsWeakSetForDocument(
formLike.ownerDocument
);
rootElementsSet.add(formLike.rootElement);
lazy.log.debug(
"adding",
formLike.rootElement,
"to root elements for",
formLike.ownerDocument
);
this._loginFormsByRootElement.set(formLike.rootElement, formLike);
},
}; };

View File

@@ -771,21 +771,21 @@ export class LoginFormState {
* 2. Only contains one input field whose type is username compatible. * 2. Only contains one input field whose type is username compatible.
* 3. The username compatible input field looks like a username field * 3. The username compatible input field looks like a username field
* or the form itself looks like a sign-in or sign-up form. * or the form itself looks like a sign-in or sign-up form.
* Additionally, if an input is formless and its autocomplete attribute is
* set to 'username' (this check is done in the DOM to avoid firing excessive events),
* we construct a FormLike object using this input and perform the same logic
* described above to determine if the new FormLike object is username-only.
* *
* @param {Element} formElement * @param {FormLike} form
* the form to check. * the form to check.
* @param {Object} recipe=null * @param {Object} recipe=null
* A relevant field override recipe to use. * A relevant field override recipe to use.
* @returns {Element} The username field or null (if the form is not a * @returns {Element} The username field or null (if the form is not a
* username-only form). * username-only form).
*/ */
getUsernameFieldFromUsernameOnlyForm(formElement, recipe = null) { getUsernameFieldFromUsernameOnlyForm(form, recipe = null) {
if (!HTMLFormElement.isInstance(formElement)) {
return null;
}
let candidate = null; let candidate = null;
for (let element of formElement.elements) { for (let element of form.elements) {
// We are looking for a username-only form, so if there is a password // We are looking for a username-only form, so if there is a password
// field in the form, this is NOT a username-only form. // field in the form, this is NOT a username-only form.
if (element.hasBeenTypePassword) { if (element.hasBeenTypePassword) {
@@ -811,10 +811,9 @@ export class LoginFormState {
} }
candidate = element; candidate = element;
} }
if ( if (
candidate && candidate &&
this.#isProbablyAUsernameLoginForm(formElement, candidate) this.#isProbablyAUsernameLoginForm(form.rootElement, candidate)
) { ) {
return candidate; return candidate;
} }
@@ -1109,7 +1108,7 @@ export class LoginFormState {
} }
usernameField = this.getUsernameFieldFromUsernameOnlyForm( usernameField = this.getUsernameFieldFromUsernameOnlyForm(
form.rootElement, form,
fieldOverrideRecipe fieldOverrideRecipe
); );
@@ -1525,8 +1524,8 @@ export class LoginManagerChild extends JSWindowActorChild {
lazy.InsecurePasswordUtils.reportInsecurePasswords(formLike); lazy.InsecurePasswordUtils.reportInsecurePasswords(formLike);
break; break;
} }
case "DOMFormHasPossibleUsername": { case "DOMPossibleUsernameInputAdded": {
this.#onDOMFormHasPossibleUsername(event); this.#onDOMPossibleUsernameInputAdded(event);
break; break;
} }
case "DOMInputPasswordAdded": { case "DOMInputPasswordAdded": {
@@ -1797,19 +1796,31 @@ export class LoginManagerChild extends JSWindowActorChild {
this._fetchLoginsFromParentAndFillForm(formLike); this._fetchLoginsFromParentAndFillForm(formLike);
} }
#onDOMFormHasPossibleUsername(event) { #onDOMPossibleUsernameInputAdded(event) {
if (!event.isTrusted) { if (!event.isTrusted) {
return; return;
} }
const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet(); const isPrimaryPasswordSet = this.#getIsPrimaryPasswordSet();
let document = event.target.ownerDocument;
let document;
let formLike;
if (HTMLFormElement.isInstance(event.target)) {
document = event.target.ownerDocument;
formLike = lazy.LoginFormFactory.createFromForm(event.target);
} else {
document = event.target;
formLike = lazy.LoginFormFactory.createFromDocumentRoot(
document.documentElement
);
}
lazy.log( lazy.log(
`#onDOMFormHasPossibleUsername: visibilityState: ${document.visibilityState}, isPrimaryPasswordSet: ${isPrimaryPasswordSet}.` `#onDomPossibleUsernameInputAdded: visibilityState: ${document.visibilityState}, isPrimaryPasswordSet: ${isPrimaryPasswordSet}.`
); );
// For simplicity, the result of the telemetry is stacked. This means if a // For simplicity, the result of the telemetry is stacked. This means if a
// document receives two `DOMFormHasPossibleEvent`, we add one counter to both // document receives two `DOMPossibleUsernameInputAdded`, we add one counter to both
// bucket 1 & 2. // bucket 1 & 2.
let docState = this.stateForDocument(document); let docState = this.stateForDocument(document);
@@ -1823,19 +1834,16 @@ export class LoginManagerChild extends JSWindowActorChild {
} }
if (document.visibilityState == "visible" || isPrimaryPasswordSet) { if (document.visibilityState == "visible" || isPrimaryPasswordSet) {
this._processDOMFormHasPossibleUsernameEvent(event); this._processDOMPossibleUsernameInputAddedEvent(formLike);
} else { } else {
// wait until the document becomes visible before handling this event // wait until the document becomes visible before handling this event
this._deferHandlingEventUntilDocumentVisible(event, document, () => { this._deferHandlingEventUntilDocumentVisible(event, document, () => {
this._processDOMFormHasPossibleUsernameEvent(event); this._processDOMPossibleUsernameInputAddedEvent(formLike);
}); });
} }
} }
_processDOMFormHasPossibleUsernameEvent(event) { _processDOMPossibleUsernameInputAddedEvent(formLike) {
let form = event.target;
let formLike = lazy.LoginFormFactory.createFromForm(form);
// If the form contains a passoword field, `getUsernameFieldFromUsernameOnlyForm` returns // If the form contains a passoword field, `getUsernameFieldFromUsernameOnlyForm` returns
// null, so we don't trigger autofill for those forms here. In this function, // null, so we don't trigger autofill for those forms here. In this function,
// we only care about username-only forms. For forms contain a password, they'll be handled // we only care about username-only forms. For forms contain a password, they'll be handled
@@ -1844,8 +1852,12 @@ export class LoginManagerChild extends JSWindowActorChild {
// We specifically set the recipe to empty here to avoid loading site recipes during page loads. // We specifically set the recipe to empty here to avoid loading site recipes during page loads.
// This is okay because if we end up finding a username-only form that should be ignore by // This is okay because if we end up finding a username-only form that should be ignore by
// the site recipe, the form will be skipped while autofilling later. // the site recipe, the form will be skipped while autofilling later.
let docState = this.stateForDocument(form.ownerDocument); let docState = this.stateForDocument(formLike.ownerDocument);
let usernameField = docState.getUsernameFieldFromUsernameOnlyForm(form, {}); let usernameField = docState.getUsernameFieldFromUsernameOnlyForm(
formLike,
{}
);
if (usernameField) { if (usernameField) {
// Autofill the username-only form. // Autofill the username-only form.
lazy.log("A username-only form is found."); lazy.log("A username-only form is found.");

View File

@@ -6,7 +6,7 @@ const ids = {
INPUT_TYPE: "", INPUT_TYPE: "",
}; };
function task({ contentIds, expected }) { function task({ contentIds, expected, hasForm = true }) {
let resolve; let resolve;
let promise = new Promise(r => { let promise = new Promise(r => {
resolve = r; resolve = r;
@@ -27,14 +27,17 @@ function task({ contentIds, expected }) {
removeEventListener("load", tabLoad, true); removeEventListener("load", tabLoad, true);
gDoc = content.document; gDoc = content.document;
gDoc.addEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); gDoc.addEventListener(
addEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); "DOMPossibleUsernameInputAdded",
unexpectedContentEvent
);
addEventListener("DOMPossibleUsernameInputAdded", unexpectedContentEvent);
gDoc.defaultView.setTimeout(test_inputAdd, 0); gDoc.defaultView.setTimeout(test_inputAdd, 0);
} }
function test_inputAdd() { function test_inputAdd() {
if (expected) { if (expected) {
addEventListener("DOMFormHasPossibleUsername", test_inputAddHandler, { addEventListener("DOMPossibleUsernameInputAdded", test_inputAddHandler, {
once: true, once: true,
capture: true, capture: true,
}); });
@@ -45,26 +48,46 @@ function task({ contentIds, expected }) {
input.setAttribute("type", contentIds.INPUT_TYPE); input.setAttribute("type", contentIds.INPUT_TYPE);
input.setAttribute("id", contentIds.INPUT_ID); input.setAttribute("id", contentIds.INPUT_ID);
input.setAttribute("data-test", "unique-attribute"); input.setAttribute("data-test", "unique-attribute");
if (hasForm) {
gDoc.getElementById(contentIds.FORM1_ID).appendChild(input); gDoc.getElementById(contentIds.FORM1_ID).appendChild(input);
} else {
input.setAttribute("autocomplete", "username");
gDoc.querySelector("body").appendChild(input);
}
} }
function test_inputAddHandler(evt) { function test_inputAddHandler(evt) {
if (expected) { if (expected) {
evt.stopPropagation(); evt.stopPropagation();
if (hasForm) {
Assert.equal( Assert.equal(
evt.target.id, evt.target.id,
contentIds.FORM1_ID, contentIds.FORM1_ID,
evt.type + evt.type + " event targets correct username element"
" event targets correct form element (added possible username element)" );
} else {
Assert.ok(
HTMLDocument.isInstance(evt.target),
evt.type + " event targets document"
); );
} }
gDoc.defaultView.setTimeout(test_inputChangeForm, 0); }
let nextTask;
if (hasForm) {
nextTask = test_inputChangeForm;
} else if (!hasForm && contentIds.INPUT_TYPE !== "text") {
nextTask = test_inputChangesType;
} else {
nextTask = finish;
}
gDoc.defaultView.setTimeout(nextTask, 0);
} }
function test_inputChangeForm() { function test_inputChangeForm() {
if (expected) { if (expected) {
addEventListener( addEventListener(
"DOMFormHasPossibleUsername", "DOMPossibleUsernameInputAdded",
test_inputChangeFormHandler, test_inputChangeFormHandler,
{ once: true, capture: true } { once: true, capture: true }
); );
@@ -81,7 +104,7 @@ function task({ contentIds, expected }) {
Assert.equal( Assert.equal(
evt.target.id, evt.target.id,
contentIds.FORM2_ID, contentIds.FORM2_ID,
evt.type + " event targets correct form element (changed form)" evt.type + " event targets correct username element"
); );
} }
// TODO(Bug 1864405): Refactor this test to not expect a DOM event // TODO(Bug 1864405): Refactor this test to not expect a DOM event
@@ -96,7 +119,7 @@ function task({ contentIds, expected }) {
function test_inputChangesType() { function test_inputChangesType() {
if (expected) { if (expected) {
addEventListener( addEventListener(
"DOMFormHasPossibleUsername", "DOMPossibleUsernameInputAdded",
test_inputChangesTypeHandler, test_inputChangesTypeHandler,
{ once: true, capture: true } { once: true, capture: true }
); );
@@ -110,19 +133,29 @@ function task({ contentIds, expected }) {
function test_inputChangesTypeHandler(evt) { function test_inputChangesTypeHandler(evt) {
if (expected) { if (expected) {
evt.stopPropagation(); evt.stopPropagation();
if (hasForm) {
Assert.equal( Assert.equal(
evt.target.id, evt.target.id,
contentIds.FORM1_ID, contentIds.FORM1_ID,
evt.type + " event targets correct form element (changed type)" evt.type + " event targets correct input element (changed type)"
); );
} else {
Assert.ok(
HTMLDocument.isInstance(evt.target),
evt.type + " event targets document"
);
}
} }
gDoc.defaultView.setTimeout(finish, 0); gDoc.defaultView.setTimeout(finish, 0);
} }
function finish() { function finish() {
removeEventListener("DOMFormHasPossibleUsername", unexpectedContentEvent); removeEventListener(
"DOMPossibleUsernameInputAdded",
unexpectedContentEvent
);
gDoc.removeEventListener( gDoc.removeEventListener(
"DOMFormHasPossibleUsername", "DOMPossibleUsernameInputAdded",
unexpectedContentEvent unexpectedContentEvent
); );
resolve(); resolve();
@@ -148,20 +181,20 @@ add_task(async function test_disconnectedInputs() {
); );
}; };
addEventListener("DOMFormHasPossibleUsername", unexpectedEvent); addEventListener("DOMPossibleUsernameInputAdded", unexpectedEvent);
const form = content.document.createElement("form"); const form = content.document.createElement("form");
const textInput = content.document.createElement("input"); const textInput = content.document.createElement("input");
textInput.setAttribute("type", "text"); textInput.setAttribute("type", "text");
form.appendChild(textInput); form.appendChild(textInput);
// Delay the execution for a bit to allow time for any asynchronously // Delay the execution for a bit to allow time for any asynchronously
// dispatched 'DOMFormHasPossibleUsername' events to be processed. // dispatched 'DOMPossibleUsernameInputAdded' events to be processed.
// This is necessary because such events might not be triggered immediately, // This is necessary because such events might not be triggered immediately,
// and we want to ensure that if they are dispatched, they are captured // and we want to ensure that if they are dispatched, they are captured
// before we remove the event listener. // before we remove the event listener.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
removeEventListener("DOMFormHasPossibleUsername", unexpectedEvent); removeEventListener("DOMPossibleUsernameInputAdded", unexpectedEvent);
}); });
Assert.ok(true, "Test completed"); Assert.ok(true, "Test completed");
@@ -195,6 +228,30 @@ add_task(async function test_usernameOnlyForm() {
} }
}); });
add_task(async function test_formlessUsernameInput() {
for (let type of ["text", "email"]) {
let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));
ids.INPUT_TYPE = type;
let promise = ContentTask.spawn(
tab.linkedBrowser,
{ contentIds: ids, expected: true, hasForm: false },
task
);
BrowserTestUtils.startLoadingURIString(
tab.linkedBrowser,
`data:text/html;charset=utf-8,
<html><body>
<input id="${ids.CHANGE_INPUT_ID}" autocomplete="username">
</body></html>`
);
await promise;
Assert.ok(true, "Test completed");
gBrowser.removeCurrentTab();
}
});
add_task(async function test_nonSupportedInputType() { add_task(async function test_nonSupportedInputType() {
for (let type of ["url", "tel", "number"]) { for (let type of ["url", "tel", "number"]) {
let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser)); let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));

View File

@@ -421,10 +421,10 @@ async function checkUnmodifiedFormInFrame(bc, formNum) {
return SpecialPowers.spawn(bc, [formNum], formNumF => { return SpecialPowers.spawn(bc, [formNum], formNumF => {
let form = this.content.document.getElementById(`form${formNumF}`); let form = this.content.document.getElementById(`form${formNumF}`);
ok(form, "Locating form " + formNumF); ok(form, "Locating form " + formNumF);
const elements =
form.nodeName === "form" ? form.elements : form.querySelectorAll("input");
for (var i = 0; i < form.elements.length; i++) { for (const ele of elements) {
var ele = form.elements[i];
// No point in checking form submit/reset buttons. // No point in checking form submit/reset buttons.
if (ele.type == "submit" || ele.type == "reset") { if (ele.type == "submit" || ele.type == "reset") {
continue; continue;
@@ -466,10 +466,15 @@ async function checkLoginFormInFrameWithElementValues(
let numToCheck = arguments.length - 1; let numToCheck = arguments.length - 1;
const elements =
form.nodeName === "form"
? form.elements
: form.querySelectorAll("input");
if (!numToCheck--) { if (!numToCheck--) {
return; return;
} }
e = form.elements[0]; e = elements[0];
if (val1F == null) { if (val1F == null) {
is( is(
e.value, e.value,
@@ -488,7 +493,7 @@ async function checkLoginFormInFrameWithElementValues(
return; return;
} }
e = form.elements[1]; e = elements[1];
if (val2F == null) { if (val2F == null) {
is( is(
e.value, e.value,
@@ -506,7 +511,7 @@ async function checkLoginFormInFrameWithElementValues(
if (!numToCheck--) { if (!numToCheck--) {
return; return;
} }
e = form.elements[2]; e = elements[2];
if (val3F == null) { if (val3F == null) {
is( is(
e.value, e.value,

View File

@@ -13,7 +13,8 @@ Test autofill on username-form
<script> <script>
add_setup(async () => { add_setup(async () => {
await setStoredLoginsAsync( await setStoredLoginsAsync(
[window.location.origin, "https://autofill", null, "user1", "pass1"] [window.location.origin, "https://autofill", null, "user1", "pass1"],
[window.location.origin, "http://mochi.test", null, "user2", "pass1"]
); );
}); });
@@ -28,10 +29,9 @@ add_task(async function test_autofill_username_only_form() {
let win = window.open("about:blank"); let win = window.open("about:blank");
SimpleTest.registerCleanupFunction(() => win.close()); SimpleTest.registerCleanupFunction(() => win.close());
// 5 out of the 7 forms should be autofilled // 6 out of the 10 forms should be autofilled
await loadFormIntoWindow(window.location.origin, ` await loadFormIntoWindow(window.location.origin, `
<!-- no password field, 1 username field --> <!-- no password field, 1 username field --> <form id='form1' action='https://autofill'> 1
<form id='form1' action='https://autofill'> 1
<input type='text' name='uname' autocomplete='username' value=''> <input type='text' name='uname' autocomplete='username' value=''>
<button type='submit'>Submit</button> <button type='submit'>Submit</button>
@@ -85,7 +85,24 @@ add_task(async function test_autofill_username_only_form() {
<button type='submit'>Submit</button> <button type='submit'>Submit</button>
<button type='reset'> Reset </button> <button type='reset'> Reset </button>
</form>`, win, 5); </form>
<!-- no form, one text input field (without autocomplete == 'username'), should be ignored -->
<div id="form8"> 8
<input id='un1' name='shouldnotfill' placeholder=username value= ''>
</div>
<!-- no password field, 1 username field without autocomplete-->
<form id='form9' action='https://autofill'> 9
<input type="text" id="username" name='uname' value=''>
</form>
<!-- no form, two text input fields with autcomplete == 'username', should be ignored -->
<div id='form10'> 10
<input name='shouldnotfill' autocomplete=username value = ''>
<input name='shouldnotfill' autocomplete=username value = ''>
</div>`, win, 6);
await checkLoginFormInFrameWithElementValues(win, 1, "user1"); await checkLoginFormInFrameWithElementValues(win, 1, "user1");
await checkLoginFormInFrameWithElementValues(win, 2, "someuser"); await checkLoginFormInFrameWithElementValues(win, 2, "someuser");
@@ -94,7 +111,21 @@ add_task(async function test_autofill_username_only_form() {
await checkUnmodifiedFormInFrame(win, 5); await checkUnmodifiedFormInFrame(win, 5);
await checkUnmodifiedFormInFrame(win, 6); await checkUnmodifiedFormInFrame(win, 6);
await checkUnmodifiedFormInFrame(win, 7); await checkUnmodifiedFormInFrame(win, 7);
await checkUnmodifiedFormInFrame(win, 8);
await checkLoginFormInFrameWithElementValues(win, 9, "user1");
await checkUnmodifiedFormInFrame(win, 10);
// We must close and reopen the window since multiple formless inputs in one document are grouped together,
// which won't trigger autofill.
win.close()
win = window.open("about:blank");
await loadFormIntoWindow(window.location.origin, `
<!-- no form, 1 username field with autocomplete, without a value set -->
<div id='form1'> 1
<input type='text' name='uname' autocomplete='username' value=''>
</div>`, win, 1);
await checkLoginFormInFrameWithElementValues(win, 1, "user2");
await resetRecipes(); await resetRecipes();
}); });
</script> </script>

View File

@@ -102,7 +102,7 @@ const TESTCASES = [
{ {
description: "1 username field outside of a <form>", description: "1 username field outside of a <form>",
document: `<input id="un1" autocomplete=username>`, document: `<input id="un1" autocomplete=username>`,
returnedFieldIDs: [null, null, null], returnedFieldIDs: ["un1", null, null],
}, },
{ {
description: "username with type=user", description: "username with type=user",

View File

@@ -8,6 +8,10 @@ const { LoginManagerChild } = ChromeUtils.importESModule(
"resource://gre/modules/LoginManagerChild.sys.mjs" "resource://gre/modules/LoginManagerChild.sys.mjs"
); );
const { FormLikeFactory } = ChromeUtils.importESModule(
"resource://gre/modules/FormLikeFactory.sys.mjs"
);
// expectation[0] tests cases when a form doesn't have a sign-in keyword. // expectation[0] tests cases when a form doesn't have a sign-in keyword.
// expectation[1] tests cases when a form has a sign-in keyword. // expectation[1] tests cases when a form has a sign-in keyword.
const TESTCASES = [ const TESTCASES = [
@@ -162,10 +166,11 @@ for (let tc of TESTCASES) {
form.setAttribute("name", "login"); form.setAttribute("name", "login");
} }
const formLike = FormLikeFactory.createFromForm(form);
const lmc = new LoginManagerChild(); const lmc = new LoginManagerChild();
const docState = lmc.stateForDocument(form.ownerDocument); const docState = lmc.stateForDocument(form.ownerDocument);
const element = docState.getUsernameFieldFromUsernameOnlyForm( const element = docState.getUsernameFieldFromUsernameOnlyForm(
form, formLike,
testcase.fieldOverrideRecipe testcase.fieldOverrideRecipe
); );
Assert.strictEqual( Assert.strictEqual(

View File

@@ -379,7 +379,7 @@ let JSWINDOWACTORS = {
"form-submission-detected": { createActor: false }, "form-submission-detected": { createActor: false },
"before-form-submission": { createActor: false }, "before-form-submission": { createActor: false },
DOMFormHasPassword: {}, DOMFormHasPassword: {},
DOMFormHasPossibleUsername: {}, DOMPossibleUsernameInputAdded: {},
DOMInputPasswordAdded: {}, DOMInputPasswordAdded: {},
}, },
}, },

View File

@@ -36,6 +36,53 @@ export let FormLikeFactory = {
return formLike; return formLike;
}, },
/**
* Create a FormLike object from an HTMLHtmlElement that is the root of the document
*
* Currently all <input> not in a <form> are one LoginForm but this
* shouldn't be relied upon as the heuristics may change to detect multiple
* "forms" (e.g. registration and login) on one page with a <form>.
*
* @param {HTMLHtmlElement} aDocumentRoot
* @return {FormLike}
* @throws Error if aDocumentRoot isn't an HTMLHtmlElement
*/
createFromDocumentRoot(aDocumentRoot) {
if (!HTMLHtmlElement.isInstance(aDocumentRoot)) {
throw new Error(
"createFromDocumentRoot: aDocumentRoot must be an HTMLHtmlElement"
);
}
let formLike = {
action: aDocumentRoot.baseURI,
autocomplete: "on",
ownerDocument: aDocumentRoot.ownerDocument,
rootElement: aDocumentRoot,
};
// FormLikes can be created when fields are inserted into the DOM. When
// many, many fields are inserted one after the other, we create many
// FormLikes, and computing the elements list becomes more and more
// expensive. Making the elements list lazy means that it'll only
// be computed when it's eventually needed (if ever).
ChromeUtils.defineLazyGetter(formLike, "elements", function () {
let elements = [];
for (let el of aDocumentRoot.querySelectorAll("input, select")) {
// Exclude elements inside the rootElement that are already in a <form> as
// they will be handled by their own FormLike.
if (!el.form) {
elements.push(el);
}
}
return elements;
});
this._addToJSONProperty(formLike);
return formLike;
},
/** /**
* Create a FormLike object from an <input>/<select> in a document. * Create a FormLike object from an <input>/<select> in a document.
* *
@@ -61,40 +108,10 @@ export let FormLikeFactory = {
throw new Error("createFromField requires a field in a document"); throw new Error("createFromField requires a field in a document");
} }
let rootElement = this.findRootForField(aField); const rootElement = this.findRootForField(aField);
if (HTMLFormElement.isInstance(rootElement)) { return HTMLFormElement.isInstance(rootElement)
return this.createFromForm(rootElement); ? this.createFromForm(rootElement)
} : this.createFromDocumentRoot(rootElement);
let doc = aField.ownerDocument;
let formLike = {
action: doc.baseURI,
autocomplete: "on",
ownerDocument: doc,
rootElement,
};
// FormLikes can be created when fields are inserted into the DOM. When
// many, many fields are inserted one after the other, we create many
// FormLikes, and computing the elements list becomes more and more
// expensive. Making the elements list lazy means that it'll only
// be computed when it's eventually needed (if ever).
ChromeUtils.defineLazyGetter(formLike, "elements", function () {
let elements = [];
for (let el of this.rootElement.querySelectorAll("input, select")) {
// Exclude elements inside the rootElement that are already in a <form> as
// they will be handled by their own FormLike.
if (!el.form) {
elements.push(el);
}
}
return elements;
});
this._addToJSONProperty(formLike);
return formLike;
}, },
/** /**