diff --git a/injected/integration-test/autofill-password-import.spec.js b/injected/integration-test/autofill-password-import.spec.js index 3957d92cf..d6efb7220 100644 --- a/injected/integration-test/autofill-password-import.spec.js +++ b/injected/integration-test/autofill-password-import.spec.js @@ -1,26 +1,29 @@ -import { test } from '@playwright/test' +import { test, expect } from '@playwright/test' import { readFileSync } from 'fs' import { mockAndroidMessaging, wrapWebkitScripts } from '@duckduckgo/messaging/lib/test-utils.mjs' import { perPlatform } from './type-helpers.mjs' +import { OVERLAY_ID } from '../src/features/autofill-password-import' test('Password import feature', async ({ page }, testInfo) => { const passwordImportFeature = AutofillPasswordImportSpec.create(page, testInfo) await passwordImportFeature.enabled() await passwordImportFeature.navigate() - const didAnimatePasswordOptions = passwordImportFeature.waitForAnimation('a[aria-label="Password options"]') await passwordImportFeature.clickOnElement('Home page') - await didAnimatePasswordOptions + await passwordImportFeature.waitForAnimation() - const didAnimateSignin = passwordImportFeature.waitForAnimation('a[aria-label="Sign in"]') await passwordImportFeature.clickOnElement('Signin page') - await didAnimateSignin + await passwordImportFeature.waitForAnimation() - const didAnimateExport = passwordImportFeature.waitForAnimation('button[aria-label="Export"]') await passwordImportFeature.clickOnElement('Export page') - await didAnimateExport + await passwordImportFeature.waitForAnimation() + + // Test unsupported path + await passwordImportFeature.clickOnElement('Unsupported page') + const overlay = page.locator(`#${OVERLAY_ID}`) + await expect(overlay).not.toBeVisible() }) export class AutofillPasswordImportSpec { @@ -94,17 +97,10 @@ export class AutofillPasswordImportSpec { /** * Helper to assert that an element is animating - * @param {string} selector */ - async waitForAnimation (selector) { - const locator = this.page.locator(selector) - return await locator.evaluate((el) => { - if (el != null) { - return el.getAnimations().some((animation) => animation.playState === 'running') - } else { - return false - } - }, selector) + async waitForAnimation () { + const locator = this.page.locator(`#${OVERLAY_ID}`) + await expect(locator).toBeVisible() } /** diff --git a/injected/integration-test/test-pages/autofill-password-import/index.html b/injected/integration-test/test-pages/autofill-password-import/index.html index 10187c088..fc0799f81 100644 --- a/injected/integration-test/test-pages/autofill-password-import/index.html +++ b/injected/integration-test/test-pages/autofill-password-import/index.html @@ -32,17 +32,21 @@ Sign in
- Password options + PO
- + +
+
+
diff --git a/injected/src/features/autofill-password-import.js b/injected/src/features/autofill-password-import.js index 2b846cc8f..336684de0 100644 --- a/injected/src/features/autofill-password-import.js +++ b/injected/src/features/autofill-password-import.js @@ -1,8 +1,12 @@ import ContentFeature from '../content-feature' import { DDGProxy, DDGReflect, withExponentialBackoff } from '../utils' -const ANIMATION_DURATION_MS = 1000 -const ANIMATION_ITERATIONS = Infinity +export const ANIMATION_DURATION_MS = 1000 +export const ANIMATION_ITERATIONS = Infinity +export const BACKGROUND_COLOR_START = 'rgba(85, 127, 243, 0.10)' +export const BACKGROUND_COLOR_END = 'rgba(85, 127, 243, 0.25)' +export const OVERLAY_ID = 'ddg-password-import-overlay' +export const DELAY_BEFORE_ANIMATION = 300 /** * This feature is responsible for animating some buttons passwords.google.com, @@ -21,8 +25,14 @@ export default class AutofillPasswordImport extends ContentFeature { */ get settingsButtonStyle () { return { - scale: 1, - backgroundColor: 'rgba(0, 39, 142, 0.5)' + transform: { + start: 'scale(0.90)', + mid: 'scale(0.96)' + }, + zIndex: '984', + borderRadius: '100%', + offsetLeftEm: 0.02, + offsetTopEm: 0 } } @@ -31,8 +41,14 @@ export default class AutofillPasswordImport extends ContentFeature { */ get exportButtonStyle () { return { - scale: 1.01, - backgroundColor: 'rgba(0, 39, 142, 0.5)' + transform: { + start: 'scale(1)', + mid: 'scale(1.01)' + }, + zIndex: '984', + borderRadius: '100%', + offsetLeftEm: 0, + offsetTopEm: 0 } } @@ -41,15 +57,21 @@ export default class AutofillPasswordImport extends ContentFeature { */ get signInButtonStyle () { return { - scale: 1.5, - backgroundColor: 'rgba(0, 39, 142, 0.5)' + transform: { + start: 'scale(1)', + mid: 'scale(1.3, 1.5)' + }, + zIndex: '999', + borderRadius: '2px', + offsetLeftEm: 0, + offsetTopEm: -0.05 } } /** * Takes a path and returns the element and style to animate. * @param {string} path - * @returns {Promise<{element: HTMLElement|Element, style: any, shouldTap: boolean}|null>} + * @returns {Promise<{element: HTMLElement|Element, style: any, shouldTap: boolean, shouldWatchForRemoval: boolean}|null>} */ async getElementAndStyleFromPath (path) { if (path === '/') { @@ -58,7 +80,8 @@ export default class AutofillPasswordImport extends ContentFeature { ? { style: this.settingsButtonStyle, element, - shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false + shouldTap: this.#settingsButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: false } : null } else if (path === '/options') { @@ -67,7 +90,8 @@ export default class AutofillPasswordImport extends ContentFeature { ? { style: this.exportButtonStyle, element, - shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false + shouldTap: this.#exportButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: true } : null } else if (path === '/intro') { @@ -76,7 +100,8 @@ export default class AutofillPasswordImport extends ContentFeature { ? { style: this.signInButtonStyle, element, - shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false + shouldTap: this.#signInButtonSettings?.shouldAutotap ?? false, + shouldWatchForRemoval: false } : null } else { @@ -84,31 +109,107 @@ export default class AutofillPasswordImport extends ContentFeature { } } + /** + * Removes the overlay if it exists. + */ + removeOverlayIfNeeded () { + const existingOverlay = document.getElementById(OVERLAY_ID) + if (existingOverlay != null) { + existingOverlay.style.display = 'none' + existingOverlay.remove() + } + } + + /** + * Inserts an overlay element to animate, by adding a div to the body + * and styling it based on the found element. + * @param {HTMLElement|Element} mainElement + * @param {any} style + * @returns {HTMLElement|Element|null} + */ + insertOverlayElement (mainElement, style) { + this.removeOverlayIfNeeded() + + const overlay = document.createElement('div') + overlay.setAttribute('id', OVERLAY_ID) + const svgElement = mainElement.parentNode?.querySelector('svg') ?? mainElement.querySelector('svg') + + const isRound = style.borderRadius === '100%' + const elementToCenterOn = (isRound && svgElement != null) ? svgElement : mainElement + if (elementToCenterOn) { + const { top, left, width, height } = elementToCenterOn.getBoundingClientRect() + overlay.style.position = 'absolute' + + overlay.style.top = `calc(${top}px + ${window.scrollY}px - ${isRound ? height / 2 : 0}px - 1px - ${style.offsetTopEm}em)` + overlay.style.left = `calc(${left}px + ${window.scrollX}px - ${isRound ? width / 2 : 0}px - 1px - ${style.offsetLeftEm}em)` + + const mainElementRect = mainElement.getBoundingClientRect() + overlay.style.width = `${mainElementRect.width}px` + overlay.style.height = `${mainElementRect.height}px` + overlay.style.zIndex = style.zIndex + + // Ensure overlay is non-interactive + overlay.style.pointerEvents = 'none' + + // insert in document.body + document.body.appendChild(overlay) + return overlay + } else { + return null + } + + } + + /** + * Observes the removal of an element from the DOM. + * @param {HTMLElement|Element} element + * @param {any} onRemoveCallback + */ + observeElementRemoval (element, onRemoveCallback) { + // Set up the mutation observer + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + // Check if the element has been removed from its parent + if (mutation.type === 'childList' && !document.contains(element)) { + // Element has been removed + onRemoveCallback() + observer.disconnect() // Stop observing + } + }) + }) + + // Start observing the parent node for child list changes + observer.observe(document.body, { childList: true, subtree: true }) + } + /** * Moves the element into view and animates it. * @param {HTMLElement|Element} element * @param {any} style */ animateElement (element, style) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }) // Scroll into view - const keyframes = [ - { backgroundColor: 'rgba(0, 0, 255, 0)', offset: 0, borderRadius: '2px' }, // Start: transparent - { backgroundColor: style.backgroundColor, offset: 0.5, borderRadius: '2px', transform: `scale(${style.scale})` }, // Midpoint: blue with 50% opacity - { backgroundColor: 'rgba(0, 0, 255, 0)', borderRadius: '2px', offset: 1 } // End: transparent - ] - - // Define the animation options - const options = { - duration: ANIMATION_DURATION_MS, - iterations: ANIMATION_ITERATIONS + const overlay = this.insertOverlayElement(element, style) + if (overlay != null) { + overlay.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }) // Scroll into view + const keyframes = [ + { backgroundColor: BACKGROUND_COLOR_START, offset: 0, borderRadius: style.borderRadius, border: `1px solid ${BACKGROUND_COLOR_START}`, transform: style.transform.start }, // Start: 10% blue + { backgroundColor: BACKGROUND_COLOR_END, offset: 0.5, borderRadius: style.borderRadius, border: `1px solid ${BACKGROUND_COLOR_END}`, transform: style.transform.mid, transformOrigin: 'center' }, // Middle: 25% blue + { backgroundColor: BACKGROUND_COLOR_START, offset: 1, borderRadius: style.borderRadius, border: `1px solid ${BACKGROUND_COLOR_START}`, transform: style.transform.start } // End: 10% blue + ] + + // Define the animation options + const options = { + duration: ANIMATION_DURATION_MS, + iterations: ANIMATION_ITERATIONS + } + + // Apply the animation to the element + overlay.animate(keyframes, options) } - - // Apply the animation to the element - element.animate(keyframes, options) } autotapElement (element) { @@ -157,17 +258,36 @@ export default class AutofillPasswordImport extends ContentFeature { * @param {string} path */ async handleElementForPath (path) { + this.removeOverlayIfNeeded() const supportedPaths = [ this.#exportButtonSettings?.path, this.#settingsButtonSettings?.path, this.#signInButtonSettings?.path ] - if (supportedPaths.indexOf(path)) { + if (supportedPaths.includes(path)) { try { - const { element, style, shouldTap } = await this.getElementAndStyleFromPath(path) ?? {} + const { element, style, shouldTap, shouldWatchForRemoval } = await this.getElementAndStyleFromPath(path) ?? {} if (element != null) { - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - shouldTap ? this.autotapElement(element) : this.animateElement(element, style) + if (shouldTap) { + this.autotapElement(element) + } else { + const domLoaded = new Promise((resolve) => { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", resolve); + } else { + // @ts-expect-error - caller doesn't expect a value here + resolve() + } + }) + await domLoaded + this.animateElement(element, style) + } + if (shouldWatchForRemoval) { + // Sometimes navigation events are not triggered, then we need to watch for removal + this.observeElementRemoval(element, () => { + this.removeOverlayIfNeeded() + }) + } } } catch { console.error('password-import: handleElementForPath failed for path:', path)