feat: Implement smart waiting utilities for improved page readiness and element detection

- Added `waitForPageReady` and `waitForElementSmart` functions to replace fixed timeouts with intelligent checks.
- Updated various parts of the codebase to utilize the new smart wait functions, enhancing performance and reliability.
- Improved logging for page readiness and element detection.
- Refactored login and browser functions to reduce unnecessary waits and enhance user experience.
- Fixed selector for MORE_ACTIVITIES to avoid strict mode violations.
- Added unit tests for smart wait utilities to ensure functionality and performance.
This commit is contained in:
2025-11-11 14:20:37 +01:00
parent 4d9ad85682
commit 53fe16b1cc
8 changed files with 711 additions and 121 deletions

View File

@@ -0,0 +1,287 @@
/**
* Smart waiting utilities for browser automation
* Replaces fixed timeouts with intelligent page readiness detection
*/
import { Locator, Page } from 'rebrowser-playwright';
/**
* Wait for page to be truly ready (network idle + DOM ready)
* Much faster than waitForLoadState with fixed timeouts
*/
export async function waitForPageReady(
page: Page,
options: {
networkIdleMs?: number
logFn?: (msg: string) => void
} = {}
): Promise<{ ready: boolean; timeMs: number }> {
const startTime = Date.now()
const networkIdleMs = options.networkIdleMs ?? 500 // Network quiet for 500ms
const logFn = options.logFn ?? (() => { })
try {
// Step 1: Wait for DOM ready (fast)
await page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {
logFn('DOM load timeout, continuing...')
})
// Step 2: Check if already at network idle (most common case)
const hasNetworkActivity = await page.evaluate(() => {
return (performance.getEntriesByType('resource') as PerformanceResourceTiming[])
.some(r => r.responseEnd === 0)
}).catch(() => false)
if (!hasNetworkActivity) {
const elapsed = Date.now() - startTime
logFn(`✓ Page ready immediately (${elapsed}ms)`)
return { ready: true, timeMs: elapsed }
}
// Step 3: Wait for network idle with adaptive polling
await page.waitForLoadState('networkidle', { timeout: networkIdleMs }).catch(() => {
logFn('Network idle timeout (expected), page may still be usable')
})
const elapsed = Date.now() - startTime
logFn(`✓ Page ready after ${elapsed}ms`)
return { ready: true, timeMs: elapsed }
} catch (error) {
const elapsed = Date.now() - startTime
const errorMsg = error instanceof Error ? error.message : String(error)
logFn(`⚠ Page readiness check incomplete after ${elapsed}ms: ${errorMsg}`)
// Return success anyway if we waited reasonably
return { ready: elapsed > 1000, timeMs: elapsed }
}
}
/**
* Smart element waiting with adaptive timeout
* Checks element presence quickly, then extends timeout only if needed
*/
export async function waitForElementSmart(
page: Page,
selector: string,
options: {
initialTimeoutMs?: number
extendedTimeoutMs?: number
state?: 'attached' | 'detached' | 'visible' | 'hidden'
logFn?: (msg: string) => void
} = {}
): Promise<{ found: boolean; timeMs: number; element: Locator | null }> {
const startTime = Date.now()
const initialTimeoutMs = options.initialTimeoutMs ?? 2000 // Quick first check
const extendedTimeoutMs = options.extendedTimeoutMs ?? 5000 // Extended if needed
const state = options.state ?? 'attached'
const logFn = options.logFn ?? (() => { })
try {
// Fast path: element already present
const element = page.locator(selector)
await element.waitFor({ state, timeout: initialTimeoutMs })
const elapsed = Date.now() - startTime
logFn(`✓ Element found quickly (${elapsed}ms)`)
return { found: true, timeMs: elapsed, element }
} catch (firstError) {
// Element not found quickly - try extended wait
logFn('Element not immediate, extending timeout...')
try {
const element = page.locator(selector)
await element.waitFor({ state, timeout: extendedTimeoutMs })
const elapsed = Date.now() - startTime
logFn(`✓ Element found after extended wait (${elapsed}ms)`)
return { found: true, timeMs: elapsed, element }
} catch (extendedError) {
const elapsed = Date.now() - startTime
const errorMsg = extendedError instanceof Error ? extendedError.message : String(extendedError)
logFn(`✗ Element not found after ${elapsed}ms: ${errorMsg}`)
return { found: false, timeMs: elapsed, element: null }
}
}
}
/**
* Wait for navigation to complete intelligently
* Uses URL change + DOM ready instead of fixed timeouts
*/
export async function waitForNavigationSmart(
page: Page,
options: {
expectedUrl?: string | RegExp
maxWaitMs?: number
logFn?: (msg: string) => void
} = {}
): Promise<{ completed: boolean; timeMs: number; url: string }> {
const startTime = Date.now()
const maxWaitMs = options.maxWaitMs ?? 15000
const logFn = options.logFn ?? (() => { })
try {
// Wait for URL to change (if we expect it to)
if (options.expectedUrl) {
const urlPattern = typeof options.expectedUrl === 'string'
? new RegExp(options.expectedUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
: options.expectedUrl
let urlChanged = false
const checkInterval = 100
const maxChecks = maxWaitMs / checkInterval
for (let i = 0; i < maxChecks; i++) {
const currentUrl = page.url()
if (urlPattern.test(currentUrl)) {
urlChanged = true
logFn(`✓ URL changed to expected pattern (${Date.now() - startTime}ms)`)
break
}
await page.waitForTimeout(checkInterval)
}
if (!urlChanged) {
const elapsed = Date.now() - startTime
logFn(`⚠ URL did not match expected pattern after ${elapsed}ms`)
return { completed: false, timeMs: elapsed, url: page.url() }
}
}
// Wait for page to be ready after navigation
const readyResult = await waitForPageReady(page, {
logFn
})
const elapsed = Date.now() - startTime
return { completed: readyResult.ready, timeMs: elapsed, url: page.url() }
} catch (error) {
const elapsed = Date.now() - startTime
const errorMsg = error instanceof Error ? error.message : String(error)
logFn(`✗ Navigation wait failed after ${elapsed}ms: ${errorMsg}`)
return { completed: false, timeMs: elapsed, url: page.url() }
}
}
/**
* Click element with smart waiting (wait for element + click + verify action)
*/
export async function clickElementSmart(
page: Page,
selector: string,
options: {
waitBeforeClick?: number
waitAfterClick?: number
verifyDisappeared?: boolean
maxWaitMs?: number
logFn?: (msg: string) => void
} = {}
): Promise<{ success: boolean; timeMs: number }> {
const startTime = Date.now()
const waitBeforeClick = options.waitBeforeClick ?? 100
const waitAfterClick = options.waitAfterClick ?? 500
const logFn = options.logFn ?? (() => { })
try {
// Wait for element to be clickable
const elementResult = await waitForElementSmart(page, selector, {
state: 'visible',
initialTimeoutMs: options.maxWaitMs ? Math.floor(options.maxWaitMs * 0.4) : 2000,
extendedTimeoutMs: options.maxWaitMs ? Math.floor(options.maxWaitMs * 0.6) : 5000,
logFn
})
if (!elementResult.found || !elementResult.element) {
return { success: false, timeMs: Date.now() - startTime }
}
// Small delay for stability
if (waitBeforeClick > 0) {
await page.waitForTimeout(waitBeforeClick)
}
// Click the element
await elementResult.element.click()
logFn('✓ Clicked element')
// Wait for action to process
if (waitAfterClick > 0) {
await page.waitForTimeout(waitAfterClick)
}
// Verify element disappeared (optional)
if (options.verifyDisappeared) {
const disappeared = await page.locator(selector).isVisible()
.then(() => false)
.catch(() => true)
if (disappeared) {
logFn('✓ Element disappeared after click (expected)')
}
}
const elapsed = Date.now() - startTime
return { success: true, timeMs: elapsed }
} catch (error) {
const elapsed = Date.now() - startTime
const errorMsg = error instanceof Error ? error.message : String(error)
logFn(`✗ Click failed after ${elapsed}ms: ${errorMsg}`)
return { success: false, timeMs: elapsed }
}
}
/**
* Type text into input field with smart waiting
*/
export async function typeIntoFieldSmart(
page: Page,
selector: string,
text: string,
options: {
clearFirst?: boolean
delay?: number
maxWaitMs?: number
logFn?: (msg: string) => void
} = {}
): Promise<{ success: boolean; timeMs: number }> {
const startTime = Date.now()
const delay = options.delay ?? 20
const logFn = options.logFn ?? (() => { })
try {
// Wait for input field
const elementResult = await waitForElementSmart(page, selector, {
state: 'visible',
initialTimeoutMs: options.maxWaitMs ? Math.floor(options.maxWaitMs * 0.4) : 2000,
extendedTimeoutMs: options.maxWaitMs ? Math.floor(options.maxWaitMs * 0.6) : 5000,
logFn
})
if (!elementResult.found || !elementResult.element) {
return { success: false, timeMs: Date.now() - startTime }
}
// Clear field if requested
if (options.clearFirst) {
await elementResult.element.clear()
}
// Type text with delay
await elementResult.element.type(text, { delay })
logFn('✓ Typed into field')
const elapsed = Date.now() - startTime
return { success: true, timeMs: elapsed }
} catch (error) {
const elapsed = Date.now() - startTime
const errorMsg = error instanceof Error ? error.message : String(error)
logFn(`✗ Type failed after ${elapsed}ms: ${errorMsg}`)
return { success: false, timeMs: elapsed }
}
}