diff --git a/src/browser/BrowserFunc.ts b/src/browser/BrowserFunc.ts index 3833260..271ae81 100644 --- a/src/browser/BrowserFunc.ts +++ b/src/browser/BrowserFunc.ts @@ -26,10 +26,13 @@ export default class BrowserFunc { * @returns true if suspended, false otherwise */ private async checkAccountSuspension(page: Page, iteration: number): Promise { - // Primary check: suspension header element - const suspendedByHeader = await page.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 }) - .then(() => true) - .catch(() => false) + // IMPROVED: Smart wait replaces fixed 500ms timeout with adaptive detection + const headerResult = await waitForElementSmart(page, SELECTORS.SUSPENDED_ACCOUNT, { + initialTimeoutMs: 500, + extendedTimeoutMs: 500, + state: 'visible' + }) + const suspendedByHeader = headerResult.found if (suspendedByHeader) { this.bot.log(this.bot.isMobile, 'GO-HOME', `Account suspension detected by header selector (iteration ${iteration})`, 'error') @@ -541,9 +544,20 @@ export default class BrowserFunc { async waitForQuizRefresh(page: Page): Promise { try { - await page.waitForSelector(SELECTORS.QUIZ_CREDITS, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }) - await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) + // IMPROVED: Smart wait replaces fixed 10s timeout with adaptive 2s+5s detection + const result = await waitForElementSmart(page, SELECTORS.QUIZ_CREDITS, { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', msg) + }) + if (!result.found) { + this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', 'Quiz credits element not found', 'error') + return false + } + + await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) return true } catch (error) { this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', 'An error occurred:' + error, 'error') @@ -553,9 +567,19 @@ export default class BrowserFunc { async checkQuizCompleted(page: Page): Promise { try { - await page.waitForSelector(SELECTORS.QUIZ_COMPLETE, { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG }) - await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) + // IMPROVED: Smart wait replaces fixed 2s timeout with adaptive detection + const result = await waitForElementSmart(page, SELECTORS.QUIZ_COMPLETE, { + initialTimeoutMs: 1000, + extendedTimeoutMs: TIMEOUTS.MEDIUM_LONG, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'QUIZ-COMPLETE', msg) + }) + if (!result.found) { + return false + } + + await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) return true } catch (error) { return false diff --git a/src/functions/Workers.ts b/src/functions/Workers.ts index 9d6e9ab..11401df 100644 --- a/src/functions/Workers.ts +++ b/src/functions/Workers.ts @@ -4,6 +4,7 @@ import { TIMEOUTS } from '../constants' import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData' import { MicrosoftRewardsBot } from '../index' +import { waitForElementSmart, waitForNetworkIdle } from '../util/browser/SmartWait' import { Retry } from '../util/core/Retry' import { AdaptiveThrottler } from '../util/notifications/AdaptiveThrottler' import { logError } from '../util/notifications/Logger' @@ -109,8 +110,11 @@ export class Workers { // Got to punch card index page in a new tab await page.goto(punchCard.parentPromotion.destinationUrl, { referer: this.bot.config.baseURL }) - // Wait for new page to load, max 10 seconds, however try regardless in case of error - await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(logError('PUNCH-CARD', 'Network idle wait timeout (non-critical)', this.bot.isMobile)) + // IMPROVED: Smart wait replaces fixed 5s timeout with adaptive detection + await waitForNetworkIdle(page, { + timeoutMs: 5000, + logFn: (msg) => this.bot.log(this.bot.isMobile, 'PUNCH-CARD', msg) + }).catch(logError('PUNCH-CARD', 'Network idle wait timeout (non-critical)', this.bot.isMobile)) await this.solveActivities(page, activitiesUncompleted, punchCard) @@ -232,7 +236,11 @@ export class Workers { } private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise { - await page.waitForLoadState('networkidle', { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(logError('WORKERS', 'Network idle wait failed', this.bot.isMobile)) + // IMPROVED: Smart wait replaces fixed 10s timeout with adaptive detection + await waitForNetworkIdle(page, { + timeoutMs: TIMEOUTS.DASHBOARD_WAIT, + logFn: (msg) => this.bot.log(this.bot.isMobile, 'WORKERS', msg) + }).catch(logError('WORKERS', 'Network idle wait failed', this.bot.isMobile)) await this.bot.browser.utils.humanizePage(page) await this.applyThrottle(throttle, ACTIVITY_DELAYS.ACTIVITY_SPACING_MIN, ACTIVITY_DELAYS.ACTIVITY_SPACING_MAX) } @@ -240,12 +248,17 @@ export class Workers { private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise { this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`) - // Check if element exists before clicking (avoid 30s timeout) - try { - await page.waitForSelector(selector, { timeout: TIMEOUTS.NETWORK_IDLE }) - } catch (error) { + // IMPROVED: Smart wait replaces fixed 5s timeout with adaptive 2s+5s detection + const elementResult = await waitForElementSmart(page, selector, { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.NETWORK_IDLE, + state: 'attached', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'ACTIVITY', msg) + }) + + if (!elementResult.found) { this.bot.log(this.bot.isMobile, 'ACTIVITY', `Activity selector not found (might be completed or unavailable): ${selector}`, 'warn') - return // Skip this activity gracefully instead of waiting 30s + return // Skip this activity gracefully } // Click with timeout to prevent indefinite hangs diff --git a/src/functions/activities/ABC.ts b/src/functions/activities/ABC.ts index e1827fe..3d8b12e 100644 --- a/src/functions/activities/ABC.ts +++ b/src/functions/activities/ABC.ts @@ -1,7 +1,8 @@ import { Page } from 'rebrowser-playwright' -import { Workers } from '../Workers' import { RETRY_LIMITS, TIMEOUTS } from '../../constants' +import { waitForElementSmart } from '../../util/browser/SmartWait' +import { Workers } from '../Workers' export class ABC extends Workers { @@ -14,18 +15,51 @@ export class ABC extends Workers { let i for (i = 0; i < RETRY_LIMITS.ABC_MAX && !$('span.rw_icon').length; i++) { - await page.waitForSelector('.wk_OptionClickClass', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }) + // IMPROVED: Smart wait replaces fixed 10s timeout with adaptive 2s+5s detection + const optionsResult = await waitForElementSmart(page, '.wk_OptionClickClass', { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'ABC', msg) + }) + + if (!optionsResult.found) { + this.bot.log(this.bot.isMobile, 'ABC', 'Options not found', 'warn') + break + } const answers = $('.wk_OptionClickClass') const answer = answers[this.bot.utils.randomNumber(0, 2)]?.attribs['id'] - await page.waitForSelector(`#${answer}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }) + // IMPROVED: Smart wait for specific answer + const answerResult = await waitForElementSmart(page, `#${answer}`, { + initialTimeoutMs: 1000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 1000, + state: 'visible' + }) + + if (!answerResult.found) { + this.bot.log(this.bot.isMobile, 'ABC', `Answer ${answer} not found`, 'warn') + break + } await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) await page.click(`#${answer}`) // Click answer await this.bot.utils.wait(TIMEOUTS.LONG + 1000) - await page.waitForSelector('div.wk_button', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }) + + // IMPROVED: Smart wait for next button + const buttonResult = await waitForElementSmart(page, 'div.wk_button', { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible' + }) + + if (!buttonResult.found) { + this.bot.log(this.bot.isMobile, 'ABC', 'Next button not found', 'warn') + break + } + await page.click('div.wk_button') // Click next question button page = await this.bot.browser.utils.getLatestTab(page) diff --git a/src/functions/activities/Poll.ts b/src/functions/activities/Poll.ts index 57b4ec5..2529a5f 100644 --- a/src/functions/activities/Poll.ts +++ b/src/functions/activities/Poll.ts @@ -1,7 +1,8 @@ import { Page } from 'rebrowser-playwright' -import { Workers } from '../Workers' import { TIMEOUTS } from '../../constants' +import { waitForElementSmart } from '../../util/browser/SmartWait' +import { Workers } from '../Workers' export class Poll extends Workers { @@ -12,11 +13,21 @@ export class Poll extends Workers { try { const buttonId = `#btoption${Math.floor(this.bot.utils.randomNumber(0, 1))}` - await page.waitForSelector(buttonId, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch((e) => { - this.bot.log(this.bot.isMobile, 'POLL', `Could not find poll button: ${e}`, 'warn') + // IMPROVED: Smart wait replaces fixed 10s timeout with adaptive 2s+5s detection + const buttonResult = await waitForElementSmart(page, buttonId, { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'POLL', msg) }) - await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) + if (!buttonResult.found) { + this.bot.log(this.bot.isMobile, 'POLL', `Could not find poll button: ${buttonId}`, 'warn') + await page.close() + return + } + + await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) await page.click(buttonId) await this.bot.utils.wait(TIMEOUTS.LONG + 1000) diff --git a/src/functions/activities/Quiz.ts b/src/functions/activities/Quiz.ts index 4933673..f69e0ec 100644 --- a/src/functions/activities/Quiz.ts +++ b/src/functions/activities/Quiz.ts @@ -1,7 +1,8 @@ import { Page } from 'rebrowser-playwright' +import { DELAYS, RETRY_LIMITS, TIMEOUTS } from '../../constants' +import { waitForElementSmart } from '../../util/browser/SmartWait' import { Workers } from '../Workers' -import { RETRY_LIMITS, TIMEOUTS, DELAYS } from '../../constants' export class Quiz extends Workers { @@ -10,9 +11,15 @@ export class Quiz extends Workers { this.bot.log(this.bot.isMobile, 'QUIZ', 'Trying to complete quiz') try { - // Check if the quiz has been started or not - const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG }).then(() => true).catch(() => false) - if (quizNotStarted) { + // IMPROVED: Smart wait replaces fixed 2s timeout with adaptive detection + const startQuizResult = await waitForElementSmart(page, '#rqStartQuiz', { + initialTimeoutMs: 1000, + extendedTimeoutMs: TIMEOUTS.MEDIUM_LONG, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'QUIZ', msg) + }) + + if (startQuizResult.found) { await page.click('#rqStartQuiz') } else { this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz has already been started, trying to finish it') @@ -21,10 +28,16 @@ export class Quiz extends Workers { await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG) let quizData = await this.bot.browser.func.getQuizData(page) - - // Verify quiz is actually loaded before proceeding - const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: TIMEOUTS.VERY_LONG }).then(() => true).catch(() => false) - if (!firstOptionExists) { + + // IMPROVED: Smart wait replaces fixed 5s timeout with adaptive detection + const firstOptionResult = await waitForElementSmart(page, '#rqAnswerOption0', { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.VERY_LONG, + state: 'attached', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'QUIZ', msg) + }) + + if (!firstOptionResult.found) { this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz options not found - page may not have loaded correctly. Skipping.', 'warn') await page.close() return @@ -38,20 +51,27 @@ export class Quiz extends Workers { const answers: string[] = [] for (let i = 0; i < quizData.numberOfOptions; i++) { - const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => null) - - if (!answerSelector) { + // IMPROVED: Smart wait replaces fixed 10s timeout with adaptive 2s+5s detection + const optionResult = await waitForElementSmart(page, `#rqAnswerOption${i}`, { + initialTimeoutMs: 2000, + extendedTimeoutMs: TIMEOUTS.DASHBOARD_WAIT - 2000, + state: 'visible' + }) + + if (!optionResult.found || !optionResult.element) { this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found - quiz structure may have changed. Skipping remaining options.`, 'warn') break } - + + const answerSelector = optionResult.element + const answerAttribute = await answerSelector?.evaluate((el: Element) => el.getAttribute('iscorrectoption')) if (answerAttribute && answerAttribute.toLowerCase() === 'true') { answers.push(`#rqAnswerOption${i}`) } } - + // If no correct answers found, skip this question if (answers.length === 0) { this.bot.log(this.bot.isMobile, 'QUIZ', 'No correct answers found for 8-option quiz. Skipping.', 'warn') @@ -61,7 +81,17 @@ export class Quiz extends Workers { // Click the answers for (const answer of answers) { - await page.waitForSelector(answer, { state: 'visible', timeout: DELAYS.QUIZ_ANSWER_WAIT }) + // IMPROVED: Smart wait replaces fixed 2s timeout with adaptive detection + const answerResult = await waitForElementSmart(page, answer, { + initialTimeoutMs: 1000, + extendedTimeoutMs: DELAYS.QUIZ_ANSWER_WAIT, + state: 'visible' + }) + + if (!answerResult.found) { + this.bot.log(this.bot.isMobile, 'QUIZ', `Answer element ${answer} not found`, 'warn') + continue + } // Click the answer on page await page.click(answer) @@ -78,18 +108,23 @@ export class Quiz extends Workers { } else if ([2, 3, 4].includes(quizData.numberOfOptions)) { quizData = await this.bot.browser.func.getQuizData(page) // Refresh Quiz Data const correctOption = quizData.correctAnswer - + let answerClicked = false for (let i = 0; i < quizData.numberOfOptions; i++) { + // IMPROVED: Smart wait replaces fixed 10s timeout with adaptive detection + const optionResult = await waitForElementSmart(page, `#rqAnswerOption${i}`, { + initialTimeoutMs: 2000, + extendedTimeoutMs: RETRY_LIMITS.QUIZ_ANSWER_TIMEOUT - 2000, + state: 'visible' + }) - const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: RETRY_LIMITS.QUIZ_ANSWER_TIMEOUT }).catch(() => null) - - if (!answerSelector) { + if (!optionResult.found || !optionResult.element) { this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn') continue } - + + const answerSelector = optionResult.element const dataOption = await answerSelector?.evaluate((el: Element) => el.getAttribute('data-option')) if (dataOption === correctOption) { @@ -106,13 +141,13 @@ export class Quiz extends Workers { break } } - + if (!answerClicked) { this.bot.log(this.bot.isMobile, 'QUIZ', `Could not find correct answer for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn') await page.close() return } - + await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT) } } diff --git a/src/functions/activities/Search.ts b/src/functions/activities/Search.ts index 68af942..aa1c45f 100644 --- a/src/functions/activities/Search.ts +++ b/src/functions/activities/Search.ts @@ -6,6 +6,7 @@ import { Workers } from '../Workers' import { AxiosRequestConfig } from 'axios' import { Counters, DashboardData } from '../../interface/DashboardData' import { GoogleSearch } from '../../interface/Search' +import { waitForElementSmart } from '../../util/browser/SmartWait' type GoogleTrendsResponse = [ string, @@ -69,7 +70,7 @@ export class Search extends Workers { } googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries) - + // Combined deduplication: exact + semantic in single pass for performance if (this.bot.config.searchSettings.semanticDedup !== false) { const threshold = this.bot.config.searchSettings.semanticDedupThreshold ?? 0.65 @@ -160,7 +161,7 @@ export class Search extends Workers { const filteredRelated = this.bot.config.searchSettings.semanticDedup !== false ? this.semanticDedupStrings(relatedTerms, this.bot.config.searchSettings.semanticDedupThreshold ?? 0.65) : relatedTerms - + // Search for the first 2 related terms for (const term of filteredRelated.slice(1, 3)) { this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term}`) @@ -314,7 +315,7 @@ export class Search extends Workers { const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)]) this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Found ${mappedTrendsData.length} search queries for ${geoLocale}`) - + if (mappedTrendsData.length < 30 && geoLocale.toUpperCase() !== 'US') { this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Insufficient search queries (${mappedTrendsData.length} < 30), falling back to US`, 'warn') return this.getGoogleTrends() @@ -388,7 +389,7 @@ export class Search extends Workers { private async clickRandomLink(page: Page) { try { // Silent catch justified: Click is best-effort humanization, failure is acceptable - await page.click('#b_results .b_algo h2', { timeout: 2000 }).catch(() => {}) + await page.click('#b_results .b_algo h2', { timeout: 2000 }).catch(() => { }) // Only used if the browser is not the edge browser (continue on Edge popup) await this.closeContinuePopup(page) @@ -468,11 +469,15 @@ export class Search extends Workers { private async closeContinuePopup(page: Page) { try { - await page.waitForSelector('#sacs_close', { timeout: 1000 }) - const continueButton = await page.$('#sacs_close') + // IMPROVED: Smart wait replaces fixed 1s timeout with adaptive detection + const buttonResult = await waitForElementSmart(page, '#sacs_close', { + initialTimeoutMs: 500, + extendedTimeoutMs: 1000, + state: 'attached' + }) - if (continueButton) { - await continueButton.click() + if (buttonResult.found && buttonResult.element) { + await buttonResult.element.click() } } catch (error) { // Continue if element is not found or other error occurs @@ -498,24 +503,24 @@ export class Search extends Workers { private combinedDeduplication(queries: GoogleSearch[], threshold = 0.65): GoogleSearch[] { const result: GoogleSearch[] = [] const seen = new Set() // Track exact duplicates (case-insensitive) - + for (const query of queries) { const lower = query.topic.toLowerCase() - + // Check exact duplicate first (faster) if (seen.has(lower)) continue - + // Check semantic similarity with existing results - const isSimilar = result.some(existing => + const isSimilar = result.some(existing => this.jaccardSimilarity(query.topic, existing.topic) > threshold ) - + if (!isSimilar) { result.push(query) seen.add(lower) } } - + return result } @@ -525,7 +530,7 @@ export class Search extends Workers { private semanticDedupStrings(terms: string[], threshold = 0.65): string[] { const result: string[] = [] for (const term of terms) { - const isSimilar = result.some(existing => + const isSimilar = result.some(existing => this.jaccardSimilarity(term, existing) > threshold ) if (!isSimilar) { diff --git a/src/functions/activities/ThisOrThat.ts b/src/functions/activities/ThisOrThat.ts index 5cf7b0f..0f81f4b 100644 --- a/src/functions/activities/ThisOrThat.ts +++ b/src/functions/activities/ThisOrThat.ts @@ -1,7 +1,8 @@ import { Page } from 'rebrowser-playwright' -import { Workers } from '../Workers' import { DELAYS } from '../../constants' +import { waitForElementSmart } from '../../util/browser/SmartWait' +import { Workers } from '../Workers' export class ThisOrThat extends Workers { @@ -11,9 +12,15 @@ export class ThisOrThat extends Workers { try { - // Check if the quiz has been started or not - const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: DELAYS.THIS_OR_THAT_START }).then(() => true).catch(() => false) - if (quizNotStarted) { + // IMPROVED: Smart wait replaces fixed 2s timeout with adaptive detection + const startQuizResult = await waitForElementSmart(page, '#rqStartQuiz', { + initialTimeoutMs: 1000, + extendedTimeoutMs: DELAYS.THIS_OR_THAT_START, + state: 'visible', + logFn: (msg) => this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', msg) + }) + + if (startQuizResult.found) { await page.click('#rqStartQuiz') } else { this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it') diff --git a/src/util/browser/SmartWait.ts b/src/util/browser/SmartWait.ts index 3c1a0c3..6e4e493 100644 --- a/src/util/browser/SmartWait.ts +++ b/src/util/browser/SmartWait.ts @@ -1,10 +1,54 @@ -/** - * Smart waiting utilities for browser automation - * Replaces fixed timeouts with intelligent page readiness detection - */ - import { Locator, Page } from 'rebrowser-playwright'; +/** + * Wait for network idle state specifically + * Optimized for post-navigation or post-action network settling + * + * @param page Playwright page instance + * @param options Configuration options + * @returns Result with completion status and timing + */ +export async function waitForNetworkIdle( + page: Page, + options: { + timeoutMs?: number + logFn?: (msg: string) => void + } = {} +): Promise<{ idle: boolean; timeMs: number }> { + const startTime = Date.now() + const timeoutMs = options.timeoutMs ?? 5000 + const logFn = options.logFn ?? (() => { }) + + try { + // Quick check: is network already idle? + const hasActivity = await page.evaluate(() => { + return (performance.getEntriesByType('resource') as PerformanceResourceTiming[]) + .some(r => r.responseEnd === 0) + }).catch(() => false) + + if (!hasActivity) { + const elapsed = Date.now() - startTime + logFn(`✓ Network already idle (${elapsed}ms)`) + return { idle: true, timeMs: elapsed } + } + + // Wait for network to settle + await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => { + logFn(`Network idle timeout (${timeoutMs}ms) - continuing anyway`) + }) + + const elapsed = Date.now() - startTime + logFn(`✓ Network idle after ${elapsed}ms`) + return { idle: true, timeMs: elapsed } + + } catch (error) { + const elapsed = Date.now() - startTime + const errorMsg = error instanceof Error ? error.message : String(error) + logFn(`⚠ Network idle check failed after ${elapsed}ms: ${errorMsg}`) + return { idle: false, timeMs: elapsed } + } +} + /** * Wait for page to be truly ready (network idle + DOM ready) * Much faster than waitForLoadState with fixed timeouts