Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve hybrid form UX #248

Merged
merged 17 commits into from
Jan 19, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 145 additions & 52 deletions dist/autofill-debug.js

Large diffs are not rendered by default.

197 changes: 145 additions & 52 deletions dist/autofill.js

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions integration-test/helpers/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,9 @@ export const constants = {
cardNumber: true
},
email: true
},
iconMatchers: {
key: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4w',
dax: 'data:image/svg+xml;base64,PHN2ZyBmaWxsPSJub25lIiBo'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ Is it worth importing the actual string here than duplicating part of it?

Copy link
Member Author

@GioSensation GioSensation Jan 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I tried that I think that there was a build error because the tests use require and the rest of the code uses import. I couldn't figure it out quickly and just moved on. If you or @shakyShane have suggestions, please let me know and I'll be happy to improve it, otherwise I'd leave it be.

}
}
18 changes: 17 additions & 1 deletion integration-test/helpers/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,27 @@ export function loginPage (page, server, opts = {}) {
expect(styles1).toContain('data:image/svg+xml;base64,')
expect(styles2).toContain('data:image/svg+xml;base64,')
},
async emailFieldShowsDax () {
// don't make assertions until the element is both found + has a none-empty 'style' attribute
await page.waitForFunction(() => Boolean(document.querySelector('#email')?.getAttribute('style')))
const emailStyle = await page.locator('#email').getAttribute('style')
expect(emailStyle).toContain(constants.iconMatchers.dax)
},
async emailHasDaxPasswordNoIcon () {
await this.emailFieldShowsDax()
const passwordStyle = await page.locator('#password').getAttribute('style')
expect(passwordStyle || '').not.toContain('data:image/svg+xml;base64,')
GioSensation marked this conversation as resolved.
Show resolved Hide resolved
},
async emailHasDaxPasswordHasKey () {
await this.emailFieldShowsDax()
const passwordStyle = await page.locator('#password').getAttribute('style')
expect(passwordStyle || '').toContain(constants.iconMatchers.key)
},
GioSensation marked this conversation as resolved.
Show resolved Hide resolved
async onlyPasswordFieldHasIcon () {
const styles1 = await page.locator('#email').getAttribute('style')
const styles2 = await page.locator('#password').getAttribute('style')
expect(styles1 || '').not.toContain('data:image/svg+xml;base64,')
expect(styles2 || '').toContain('data:image/svg+xml;base64,')
expect(styles2 || '').toContain(constants.iconMatchers.key)
},
/**
* @param {string} username
Expand Down
20 changes: 20 additions & 0 deletions integration-test/tests/login-form.macos.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,26 @@ test.describe('Auto-fill a login form on macOS', () => {
await login.clickIntoUsernameInput()
await login.fieldsDoNotContainIcons()
})
test('I should see Dax if Email Protection is enabled', async ({page}) => {
await forwardConsoleMessages(page)
await createWebkitMocks()
.withAvailableInputTypes({
credentials: {username: false, password: false},
email: true
})
.withPersonalEmail(personalAddress)
.withPrivateEmail('random123@duck.com')
.applyTo(page)

await createAutofillScript()
.replaceAll(macosContentScopeReplacements())
.platform('macos')
.applyTo(page)

const login = loginPage(page, server)
await login.navigate()
await login.emailHasDaxPasswordNoIcon()
})
})
})

Expand Down
23 changes: 19 additions & 4 deletions src/DeviceInterface/InterfacePrototype.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class InterfacePrototype {
/** @type {import("../../packages/device-api").DeviceApi} */
deviceApi;

/** @type {boolean} */
alreadyInitialized;

/**
* @param {GlobalConfig} config
* @param {import("../../packages/device-api").DeviceApi} deviceApi
Expand All @@ -78,6 +81,7 @@ class InterfacePrototype {
this.scanner = createScanner(this, {
initialDelay: this.initialSetupDelayMs
})
this.alreadyInitialized = false
}

/**
Expand Down Expand Up @@ -236,6 +240,10 @@ class InterfacePrototype {
}

async startInit () {
if (this.alreadyInitialized) return

this.alreadyInitialized = true
GioSensation marked this conversation as resolved.
Show resolved Hide resolved

await this.refreshSettings()

this.addDeviceListeners()
Expand Down Expand Up @@ -294,12 +302,19 @@ class InterfacePrototype {
async init () {
const isEnabled = await this.isEnabled()
if (!isEnabled) return

const handler = async () => {
if (document.readyState === 'complete') {
await this.startInit()
window.removeEventListener('load', handler)
document.removeEventListener('readystatechange', handler)
GioSensation marked this conversation as resolved.
Show resolved Hide resolved
}
}
if (document.readyState === 'complete') {
await this.startInit()
} else {
window.addEventListener('load', () => {
this.startInit()
})
window.addEventListener('load', handler)
document.addEventListener('readystatechange', handler)
}
}

Expand Down Expand Up @@ -576,7 +591,7 @@ class InterfacePrototype {
this.removeTooltip()
}
// Redecorate fields according to the new types
this.scanner.forms.forEach(form => form.redecorateAllInputs())
this.scanner.forms.forEach(form => form.recategorizeAllInputs())
} catch (e) {
if (this.globalConfig.isDDGTestMode) {
console.log('isDDGTestMode: providerStatusUpdated error: ❌', e)
Expand Down
30 changes: 28 additions & 2 deletions src/Form/Form.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from './formatters.js'

import {constants} from '../constants.js'
const {ATTR_AUTOFILL} = constants
const {ATTR_AUTOFILL, ATTR_INPUT_TYPE} = constants

class Form {
/** @type {import("../Form/matching").Matching} */
Expand All @@ -47,6 +47,7 @@ class Form {
this.formAnalyzer = new FormAnalyzer(form, input, matching)
this.isLogin = this.formAnalyzer.isLogin
this.isSignup = this.formAnalyzer.isSignup
this.isHybrid = this.formAnalyzer.isHybrid
this.device = deviceInterface

/** @type Record<'all' | SupportedMainTypes, Set> */
Expand Down Expand Up @@ -225,6 +226,25 @@ class Form {
}
})
}

/**
* Removes all scoring attributes from the inputs and deletes them from memory
*/
forgetAllInputs () {
this.execOnInputs((input) => {
input.removeAttribute(ATTR_INPUT_TYPE)
})
Object.values(this.inputs).forEach((inputSet) => inputSet.clear())
}

/**
* Resets our input scoring and starts from scratch
*/
recategorizeAllInputs () {
this.removeAllDecorations()
this.forgetAllInputs()
this.categorizeInputs()
}
resetAllInputs () {
this.execOnInputs((input) => {
setValue(input, '', this.device.globalConfig)
Expand Down Expand Up @@ -311,7 +331,13 @@ class Form {

this.inputs.all.add(input)

this.matching.setInputType(input, this.form, { isLogin: this.isLogin })
const opts = {
isLogin: this.isLogin,
isHybrid: this.isHybrid,
hasCredentials: Boolean(this.device.settings.availableInputTypes.credentials?.username),
supportsIdentitiesAutofill: this.device.settings.featureToggles.inputType_identities
}
this.matching.setInputType(input, this.form, opts)

const mainInputType = getInputMainType(input)
this.inputs[mainInputType].add(input)
Expand Down
81 changes: 55 additions & 26 deletions src/Form/FormAnalyzer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { constants } from '../constants.js'
import { matchingConfiguration } from './matching-configuration.js'
import { getText, isLikelyASubmitButton } from '../autofill-utils.js'

const negativeRegex = new RegExp(/sign(ing)?.?in(?!g)|log.?in|unsubscri|(forgot(ten)?|reset) (your )?password|password (forgotten|lost)/i)
const positiveRegex = new RegExp(
const loginRegex = new RegExp(/sign(ing)?.?in(?!g)|log.?in|unsubscri|(forgot(ten)?|reset) (your )?password|password (forgotten|lost)/i)
const signupRegex = new RegExp(
GioSensation marked this conversation as resolved.
Show resolved Hide resolved
/sign(ing)?.?up|join|\bregist(er|ration)|newsletter|\bsubscri(be|ption)|contact|create|start|enroll|settings|preferences|profile|update|checkout|guest|purchase|buy|order|schedule|estimate|request|new.?customer|(confirm|retype|repeat) password|password confirm?/i
)
const conservativePositiveRegex = new RegExp(/sign.?up|join|register|enroll|newsletter|subscri(be|ption)|settings|preferences|profile|update/i)
const strictPositiveRegex = new RegExp(/sign.?up|join|register|enroll|settings|preferences|profile|update/i)
const conservativeSignupRegex = new RegExp(/sign.?up|join|register|enroll|newsletter|subscri(be|ption)|settings|preferences|profile|update/i)
const strictSignupRegex = new RegExp(/sign.?up|join|register|(create|new).+account|enroll|settings|preferences|profile|update/i)

class FormAnalyzer {
/** @type HTMLElement */
Expand All @@ -23,9 +23,23 @@ class FormAnalyzer {
constructor (form, input, matching) {
this.form = form
this.matching = matching || new Matching(matchingConfiguration)
/**
* The signal is a continuum where negative values imply login and positive imply signup
* @type {number}
*/
this.autofillSignal = 0
/**
* Collects the signals for debugging purposes
* @type {string[]}
*/
this.signals = []

/**
* A hybrid form can be either a login or a signup, the site uses a single form for both
* @type {boolean}
*/
this.isHybrid = false

// Avoid autofill on our signup page
if (window.location.href.match(/^https:\/\/(.+\.)?duckduckgo\.com\/email\/choose-address/i)) {
return this
Expand All @@ -37,27 +51,43 @@ class FormAnalyzer {
}

get isLogin () {
if (this.isHybrid) return false

return this.autofillSignal < 0
}

get isSignup () {
if (this.isHybrid) return false

return this.autofillSignal >= 0
}

/**
* Tilts the scoring towards Signup
* @param {number} strength
* @param {string} signal
* @returns {FormAnalyzer}
*/
increaseSignalBy (strength, signal) {
this.autofillSignal += strength
this.signals.push(`${signal}: +${strength}`)
return this
}

/**
* Tilts the scoring towards Login
* @param {number} strength
* @param {string} signal
* @returns {FormAnalyzer}
*/
GioSensation marked this conversation as resolved.
Show resolved Hide resolved
decreaseSignalBy (strength, signal) {
this.autofillSignal -= strength
this.signals.push(`${signal}: -${strength}`)
return this
}

/**
*
* Updates the Login<->Signup signal according to the provided parameters
* @param {object} p
* @param {string} p.string - The string to check
* @param {number} p.strength - Strength of the signal
Expand All @@ -75,24 +105,25 @@ class FormAnalyzer {
shouldCheckUnifiedForm = false,
shouldBeConservative = false
}) {
const matchesNegative = string === 'current-password' || negativeRegex.test(string)
const matchesLogin = string === 'current-password' || loginRegex.test(string)

// Check explicitly for unified login/signup forms. They should always be negative, so we increase signal
if (shouldCheckUnifiedForm && matchesNegative && strictPositiveRegex.test(string)) {
this.decreaseSignalBy(strength + 2, `Unified detected ${signalType}`)
if (shouldCheckUnifiedForm && matchesLogin && strictSignupRegex.test(string)) {
this.signals.push(`hybrid form: ${signalType}`)
this.isHybrid = true
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we detect that it's a hybrid form, short circuit things. Actually, I'm wondering if we should also stop scanning the form at this stage 🤔. Feedback welcome, but as things stand now once this is set we never un-set it so the rest of the form scanning doesn't have any effect.

return this
}

const positiveRegexToUse = shouldBeConservative ? conservativePositiveRegex : positiveRegex
const matchesPositive = string === 'new-password' || positiveRegexToUse.test(string)
const signupRegexToUse = shouldBeConservative ? conservativeSignupRegex : signupRegex
const matchesSignup = string === 'new-password' || signupRegexToUse.test(string)

// In some cases a login match means the login is somewhere else, i.e. when a link points outside
if (shouldFlip) {
if (matchesNegative) this.increaseSignalBy(strength, signalType)
if (matchesPositive) this.decreaseSignalBy(strength, signalType)
if (matchesLogin) this.increaseSignalBy(strength, signalType)
if (matchesSignup) this.decreaseSignalBy(strength, signalType)
} else {
if (matchesNegative) this.decreaseSignalBy(strength, signalType)
if (matchesPositive) this.increaseSignalBy(strength, signalType)
if (matchesLogin) this.decreaseSignalBy(strength, signalType)
if (matchesSignup) this.increaseSignalBy(strength, signalType)
}
return this
}
Expand All @@ -113,23 +144,21 @@ class FormAnalyzer {

evaluatePageTitle () {
const pageTitle = document.title
this.updateSignal({string: pageTitle, strength: 2, signalType: `page title: ${pageTitle}`})
this.updateSignal({string: pageTitle, strength: 2, signalType: `page title: ${pageTitle}`, shouldCheckUnifiedForm: true})
GioSensation marked this conversation as resolved.
Show resolved Hide resolved
}

evaluatePageHeadings () {
const headings = document.querySelectorAll('h1, h2, h3, [class*="title"], [id*="title"]')
if (headings) {
headings.forEach(({textContent}) => {
textContent = removeExcessWhitespace(textContent || '')
this.updateSignal({
string: textContent,
strength: 0.5,
signalType: `heading: ${textContent}`,
shouldCheckUnifiedForm: true,
shouldBeConservative: true
})
headings.forEach(({textContent}) => {
textContent = removeExcessWhitespace(textContent || '')
this.updateSignal({
string: textContent,
strength: 0.5,
signalType: `heading: ${textContent}`,
shouldCheckUnifiedForm: true,
shouldBeConservative: true
})
}
})
}

evaluatePage () {
Expand Down
6 changes: 5 additions & 1 deletion src/Form/input-classifiers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import InterfacePrototype from '../DeviceInterface/InterfacePrototype.js'

import testCases from './test-cases/index.js'
import {SUBMIT_BUTTON_SELECTOR} from './selectors-css.js'
import {createAvailableInputTypes} from '../../integration-test/helpers/utils.js'

/**
* @param {HTMLInputElement} el
Expand Down Expand Up @@ -160,7 +161,10 @@ describe.each(testCases)('Test $html fields', (testCase) => {
button._jsdomMockOffsetHeight = 50
})

const scanner = createScanner(InterfacePrototype.default())
const deviceInterface = InterfacePrototype.default()
const availableInputTypes = createAvailableInputTypes({credentials: {username: true, password: true}})
deviceInterface.settings.setAvailableInputTypes(availableInputTypes)
const scanner = createScanner(deviceInterface)
scanner.findEligibleInputs(document)

const detectedSubmitButtons = Array.from(scanner.forms.values()).map(form => form.submitButtons).flat()
Expand Down
6 changes: 3 additions & 3 deletions src/Form/inputTypeConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ const inputTypeConfig = {
}
return ''
},
shouldDecorate: async (input, {isLogin, device}) => {
shouldDecorate: async (input, {isLogin, isHybrid, device}) => {
// if we are on a 'login' page, check if we have data to autofill the field
if (isLogin) {
if (isLogin || isHybrid) {
return canBeAutofilled(input, device)
}

Expand Down Expand Up @@ -140,7 +140,7 @@ const getInputConfig = (input) => {

/**
* Retrieves configs from an input type
* @param {import('./matching').SupportedTypes | string} inputType
* @param {import('./matching').SupportedTypes} inputType
* @returns {InputTypeConfigs}
*/
const getInputConfigFromType = (inputType) => {
Expand Down
4 changes: 2 additions & 2 deletions src/Form/matching-configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ const matchingConfiguration = {
/** @type {DDGMatcherConfiguration} */
ddgMatcher: {
matchers: {
email: {match: '.mail\\b', skip: 'phone|name|reservation number', forceUnknown: 'search|filter|subject|title|\btab\b'},
email: {match: '.mail\\b|apple.?id', skip: 'phone|name|reservation number|code', forceUnknown: 'search|filter|subject|title|\btab\b'},
password: {match: 'password', forceUnknown: 'captcha|mfa|2fa|two factor'},
username: {match: '(user|account|apple|login|net)((.)?(name|id|login).?)?(.?(or|/).+)?$|benutzername', forceUnknown: 'search|policy'},
username: {match: '(user|account|login|net)((.)?(name|id|login).?)?(.?(or|/).+)?$|benutzername', forceUnknown: 'search|policy'},

// CC
cardName: {match: '(card.*name|name.*card)|(card.*holder|holder.*card)|(card.*owner|owner.*card)'},
Expand Down
Loading