Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
71 changes: 47 additions & 24 deletions config/wdio.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: '/',
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
);
};
217 changes: 201 additions & 16 deletions screenshot/utils/confHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!');
}
Expand All @@ -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});
Expand All @@ -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';
Expand All @@ -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 () {
Expand Down