diff --git a/CHANGELOG.md b/CHANGELOG.md index 0163fa19..1023875a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## [unreleased] + +* Updated Chrome driver version to 132 for screenshot tests. + ## [4.0.0] (November 14, 2025) * Updated Chrome driver version to 132. diff --git a/config/wdio.conf.js b/config/wdio.conf.js index 0ecaa5ac..2b702784 100644 --- a/config/wdio.conf.js +++ b/config/wdio.conf.js @@ -15,13 +15,13 @@ export const configure = (options) => { if (!process.env.CHROME_DRIVER) { // TODO: Update this version when chromedriver version in CI/CD is updated - process.env.CHROME_DRIVER = base === 'screenshot' ? '120.0.6099.109' : '132.0.6834.159'; + process.env.CHROME_DRIVER = '132.0.6834.159'; console.log('Chrome Driver Version : ' + process.env.CHROME_DRIVER); } return Object.assign( - opts, + {}, { path: '/', // @@ -87,25 +87,27 @@ export const configure = (options) => { We need to specify a browser version that matches chromedriver version running in CI/CD environment to ensure testing accuracy. */ browserVersion: process.env.CHROME_DRIVER, - 'goog:chromeOptions': visibleBrowser ? + 'goog:chromeOptions': { args: [ + '--disable-infobars', + '--disable-search-engine-choice-screen', + '--disable-notifications', + '--disable-popup-blocking', '--disable-lcd-text', '--force-device-scale-factor=1', '--start-maximized', - '--start-fullscreen', '--disable-gpu', - '--window-size=1920,1080' - ] - } : { - args: [ - '--disable-lcd-text', - '--force-device-scale-factor=1', - '--start-maximized', - '--start-fullscreen', - '--headless', - '--disable-gpu', - '--window-size=1920,1080' + '--window-size=1920,1080', + // Critical for Chrome 132 in Jenkins/Linux + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-setuid-sandbox', + // Performance optimizations for Chrome 132 + '--disable-features=VizDisplayCompositor', + '--disable-features=IsolateOrigins,site-per-process', + '--js-flags=--max-old-space-size=512', + ...(visibleBrowser ? [] : ['--headless=new']) ] }, webSocketUrl: false, // disables BiDi, forces classic mode @@ -135,21 +137,23 @@ export const configure = (options) => { baseUrl: 'http://localhost:4567', // // Default timeout for all waitFor* commands. - waitforTimeout: 10000, + waitforTimeout: 45000, // // Default timeout in milliseconds for request // if Selenium Grid doesn't send response - connectionRetryTimeout: 120000, + connectionRetryTimeout: 240000, // // Default request retries count - connectionRetryCount: 3, + connectionRetryCount: 5, // Ignore deprecation warnings deprecationWarnings: false, // // Default timeouts // timeouts: { - script: 60000 // extend script timeout to 60s just in case + script: 600000, + pageLoad: 600000, + implicit: 20000 }, // // Initialize the browser instance with a WebdriverIO plugin. The object should have the @@ -200,7 +204,8 @@ export const configure = (options) => { // See the full list at http://mochajs.org/ mochaOpts: { ui: 'bdd', - timeout: 60 * 60 * 1000 + timeout: 60 * 60 * 1000, + retries: 2 // Retry failed tests up to 2 times (important for timeout recovery) }, /** * Gets executed before test execution begins. At this point you can access to all global @@ -209,14 +214,32 @@ export const configure = (options) => { before: async function () { global.wdioExpect = global.expect; // in Chrome 132, the browser window size takes into account also the address bar and tab area - await browser.maximizeWindow(); - let browserHeight = base === 'screenshot' ? 1080 : 1272; - await browser.setWindowSize(1920, browserHeight); + await browser.setWindowSize(1920, 1167); + + // Small pause to let window resize complete + await browser.pause(200); + + // Verify the window is ready + await browser.waitUntil( + async () => { + try { + const state = await browser.execute(() => document.readyState); + return state === 'complete'; + } catch (e) { + return false; + } + }, + { + timeout: 15000, + timeoutMsg: 'Page did not reach ready state' + } + ); if (options.before) { await options.before(); } } - } + }, + opts ); }; diff --git a/screenshot/utils/confHelpers.js b/screenshot/utils/confHelpers.js index b4930a88..b85c787b 100644 --- a/screenshot/utils/confHelpers.js +++ b/screenshot/utils/confHelpers.js @@ -52,6 +52,9 @@ function initFile (name, content) { } function onPrepare () { + global.sessionFailures = new Map(); + global.failedSessions = new Set(); + if (!fs.existsSync('tests/screenshot/dist/screenshots/reference')) { console.log('No reference screenshots found, creating new references!'); } @@ -62,7 +65,169 @@ function onPrepare () { return buildApps('screenshot'); } -function beforeTest (testData) { +/* Checks if a browser session is healthy. If not, it will attempt to recover. */ +async function checkSessionHealth () { + const sessionId = browser.sessionId; + + // Check if this session has been marked as dead + if (global.failedSessions && global.failedSessions.has(sessionId)) { + throw new Error('Session is marked as failed - skipping remaining tests'); + } + + // Skip health check if the session was just recovered + if (global.recentlyRecovered && global.recentlyRecovered.has(sessionId)) { + global.recentlyRecovered.delete(sessionId); + } else { + // Quick health check with a short timeout + try { + await Promise.race([ + browser.execute(() => true), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Health check timeout')), 3000) + ) + ]); + + // Success - reset failure counter + if (global.sessionFailures) { + global.sessionFailures.set(sessionId, 0); + } + } catch (e) { + // Track consecutive failures + const failures = (global.sessionFailures?.get(sessionId) || 0) + 1; + if (global.sessionFailures) { + global.sessionFailures.set(sessionId, failures); + } + + console.log(`Session ${sessionId} health check failed (failure ${failures}/3)`); + + // Only mark as dead after 3 consecutive failures + if (failures >= 3) { + console.log(`Session ${sessionId} has failed 3 times - marking as dead`); + if (global.failedSessions) { + global.failedSessions.add(sessionId); + } + throw new Error('Session health check failed - marking as dead'); + } + + // Try quick recovery for the first 2 failures + console.log(`Attempting quick recovery for session ${sessionId}...`); + try { + await browser.reloadSession(); + await browser.setWindowSize(1920, 1167); + console.log(`Session ${sessionId} recovered`); + } catch (recoveryError) { + console.log(`Recovery attempt failed, will retry next test`); + } + } + } +} + +async function cleanUpSessionHealthCheck (testData, error, passed) { + if (error) { + const isTimeout = error.message && + (error.message.includes('timeout') || + error.message.includes('aborted') || + error.message.includes('HEADERS_TIMEOUT') || + error.message.includes('ECONNREFUSED')); + + if (isTimeout) { + const sessionId = browser.sessionId; + + // Track consecutive failures + if (!global.sessionFailures) { + global.sessionFailures = new Map(); + } + + const failures = (global.sessionFailures.get(sessionId) || 0) + 1; + global.sessionFailures.set(sessionId, failures); + + console.log(`Timeout #${failures} in session ${sessionId} - test: "${testData.title}"`); + + // Circuit breaker: after 3 consecutive timeouts, kill the session + if (failures >= 3) { + console.log(`Session ${sessionId} has failed 3 times consecutively - marking as dead`); + + if (!global.failedSessions) { + global.failedSessions = new Set(); + } + global.failedSessions.add(sessionId); + + // Try to clean up with very short timeout, then give up + try { + await Promise.race([ + (async () => { + try { + await browser.execute(() => window.stop()); + } catch (e) { + // Ignore + } + await browser.deleteSession(); + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Cleanup timeout')), 2000) + ) + ]); + console.log('Session cleanup completed'); + } catch (e) { + console.log('Session cleanup timed out - session is dead'); + } + + return; // Don't try to recover + } + + // For first 2 failures, attempt recovery + console.log(`Attempting recovery for session ${sessionId} (attempt ${failures}/3)`); + + try { + // Try light recovery with timeout + await Promise.race([ + (async () => { + try { + await browser.execute(() => window.stop()); + } catch (e) { + // Ignore + } + await browser.deleteSession(); + await browser.reloadSession(); + await browser.setWindowSize(1920, 1167); + await browser.pause(1000); + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Recovery timeout')), 10000) + ) + ]); + + console.log('Session recovered successfully'); + + // Reset failure count on successful recovery + global.sessionFailures.set(sessionId, 0); + + // Mark that we just recovered - skip next health check + if (!global.recentlyRecovered) { + global.recentlyRecovered = new Set(); + } + global.recentlyRecovered.add(sessionId); + + } catch (recoveryError) { + console.error(`Session recovery failed: ${recoveryError.message}`); + } + } else { + const sessionId = browser.sessionId; + if (global.sessionFailures && global.sessionFailures.has(sessionId)) { + global.sessionFailures.set(sessionId, 0); + } + } + } else if (passed) { + const sessionId = browser.sessionId; + if (global.sessionFailures && global.sessionFailures.has(sessionId)) { + global.sessionFailures.set(sessionId, 0); + } + } +} + +async function beforeTest (testData) { + await checkSessionHealth(); + // If title doesn't have a '/', it's not a screenshot test, don't save if (testData && testData.title && testData.title.indexOf('/') > 0) { const filename = generateReferenceName({test: testData}); @@ -78,7 +243,7 @@ function beforeTest (testData) { } } -function afterTest (testData, _context, {passed}) { +async function afterTest (testData, _context, {error, passed}) { // If this doesn't include context data, not a screenshot test if (testData && testData.title && testData.context && testData.context.params) { const fileName = testData.context.fileName.replace(/ /g, '_') + '.png'; @@ -99,22 +264,42 @@ function afterTest (testData, _context, {passed}) { } if (!passed) { - const screenPath = path.join(screenshotRelativePath, 'actual', fileName); - const diffPath = path.join(screenshotRelativePath, 'diff', fileName); - fs.open(failedScreenshotFilename, 'a', (err, fd) => { - if (err) { - console.error('Unable to create failed test log file!'); - } else { - const title = testData.title.replace(/~\//g, '/'); - const {params, url} = testData.context; - const output = {title, diffPath, referencePath, screenPath, params, url}; - fs.appendFile(fd, `${JSON.stringify(output)},`, 'utf8', () => { - fs.close(fd); - }); - } - }); + // Track failed tests to avoid duplicate logging during retries + if (!global.loggedFailures) { + global.loggedFailures = new Set(); + } + + const testIdentifier = testData.title + '::' + fileName; + + // Only log if we haven't already logged this test failure + if (!global.loggedFailures.has(testIdentifier)) { + global.loggedFailures.add(testIdentifier); + + const screenPath = path.join(screenshotRelativePath, 'actual', fileName); + const diffPath = path.join(screenshotRelativePath, 'diff', fileName); + fs.open(failedScreenshotFilename, 'a', (err, fd) => { + if (err) { + console.error('Unable to create failed test log file!'); + } else { + const title = testData.title.replace(/~\//g, '/'); + const {params, url} = testData.context; + const output = {title, diffPath, referencePath, screenPath, params, url}; + fs.appendFile(fd, `${JSON.stringify(output)},`, 'utf8', () => { + fs.close(fd); + }); + } + }); + } + } else if (passed) { + // Test passed on retry - remove from logged failures + if (global.loggedFailures) { + const testIdentifier = testData.title + '::' + fileName; + global.loggedFailures.delete(testIdentifier); + } } } + + await cleanUpSessionHealthCheck(testData, error, passed); } function onComplete () {