From 7682eeab290823eb5cf845579b7986693d01a6aa Mon Sep 17 00:00:00 2001 From: Maribeth Moffatt Date: Fri, 29 May 2026 14:53:07 -0400 Subject: [PATCH] chore: make ci more robust, fix stack overflow and import problem (#9948) * chore: add additional logging to CI to catch circular deps and exit mocha on failure * chore: fix blockly import * chore: format * chore: increase webdriver timeout to allow longer tests * fix: stack overflow if a sound is missing * chore: dont fail for any console errors * chore: needs more timeout * chore: run mocha in a subprocess * chore: fix chromedriver cache issues * chore: remove bad error condition --- .github/workflows/build.yml | 4 + packages/blockly/core/workspace_audio.ts | 5 +- packages/blockly/core/zoom_controls.ts | 2 +- packages/blockly/eslint.config.mjs | 1 + .../blockly/scripts/gulpfiles/test_tasks.mjs | 61 ++-- packages/blockly/tests/compile/webdriver.js | 73 ++--- .../blockly/tests/generators/webdriver.js | 125 ++++----- packages/blockly/tests/mocha/index.html | 185 +++++++++++-- .../tests/mocha/shortcut_items_test.js | 2 - packages/blockly/tests/mocha/webdriver.js | 260 +++++++++++++----- .../tests/scripts/webdriver_helpers.js | 124 +++++++++ 11 files changed, 614 insertions(+), 228 deletions(-) create mode 100644 packages/blockly/tests/scripts/webdriver_helpers.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e76ba50f8..aaa309ca0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,10 @@ jobs: - name: Npm Clean Install run: npm ci + - name: Setup Chrome + if: runner.os == 'Linux' + uses: browser-actions/setup-chrome@v1 + - name: Linux Test Setup if: runner.os == 'Linux' run: source ./tests/scripts/setup_linux_env.sh diff --git a/packages/blockly/core/workspace_audio.ts b/packages/blockly/core/workspace_audio.ts index 6b1aa4c14..ecb1f84c8 100644 --- a/packages/blockly/core/workspace_audio.ts +++ b/packages/blockly/core/workspace_audio.ts @@ -106,7 +106,10 @@ export class WorkspaceAudio { source.start(); } else if (this.parentWorkspace) { // Maybe a workspace on a lower level knows about this sound. - this.parentWorkspace.getAudioManager().play(name, opt_volume); + const parentAudio = this.parentWorkspace.getAudioManager(); + if (parentAudio !== this) { + parentAudio.play(name, opt_volume); + } } } diff --git a/packages/blockly/core/zoom_controls.ts b/packages/blockly/core/zoom_controls.ts index 8cac10d14..8ff2cf3bc 100644 --- a/packages/blockly/core/zoom_controls.ts +++ b/packages/blockly/core/zoom_controls.ts @@ -14,12 +14,12 @@ // Unused import preserved for side-effects. Remove if unneeded. import './events/events_click.js'; -import {IFocusableNode} from './blockly.js'; import * as browserEvents from './browser_events.js'; import {ComponentManager} from './component_manager.js'; import * as Css from './css.js'; import {EventType} from './events/type.js'; import * as eventUtils from './events/utils.js'; +import {IFocusableNode} from './interfaces/i_focusable_node.js'; import type {IPositionable} from './interfaces/i_positionable.js'; import type {UiMetrics} from './metrics_manager.js'; import {Msg} from './msg.js'; diff --git a/packages/blockly/eslint.config.mjs b/packages/blockly/eslint.config.mjs index 74aae803a..104e1b7d0 100644 --- a/packages/blockly/eslint.config.mjs +++ b/packages/blockly/eslint.config.mjs @@ -195,6 +195,7 @@ export default [ 'tests/mocha/.mocharc.js', 'tests/migration/validate-renamings.mjs', 'tests/scripts/magic_symlink.js', + 'tests/scripts/webdriver_helpers.js', ], languageOptions: { globals: { diff --git a/packages/blockly/scripts/gulpfiles/test_tasks.mjs b/packages/blockly/scripts/gulpfiles/test_tasks.mjs index 5d2b0dc56..d32769bb8 100644 --- a/packages/blockly/scripts/gulpfiles/test_tasks.mjs +++ b/packages/blockly/scripts/gulpfiles/test_tasks.mjs @@ -14,14 +14,12 @@ import * as gulp from 'gulp'; import gzip from 'gulp-gzip'; import * as fs from 'fs'; import * as path from 'path'; -import {execSync} from 'child_process'; +import {spawnSync} from 'child_process'; import {rimraf} from 'rimraf'; import {RELEASE_DIR, TEST_TSC_OUTPUT_DIR} from './config.mjs'; import {runMochaTestsInBrowser} from '../../tests/mocha/webdriver.js'; -import {runGeneratorsInBrowser} from '../../tests/generators/webdriver.js'; -import {runCompileCheckInBrowser} from '../../tests/compile/webdriver.js'; const OUTPUT_DIR = 'build/generators'; const GOLDEN_DIR = 'tests/generators/golden'; @@ -121,8 +119,20 @@ function reportTestResult() { * @return {Promise} Asynchronous result. */ async function runTestCommand(id, command) { - return runTestTask(id, async() => { - return execSync(command, {stdio: 'inherit'}); + return runTestTask(id, async () => { + const result = spawnSync(command, { + shell: true, + stdio: 'inherit', + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `Command failed with exit code ${result.status}: ${command}`, + ); + } }); } @@ -257,24 +267,25 @@ async function metadata() { * Run Mocha tests inside a browser. * @return {Promise} Asynchronous result. */ -async function mocha(exitOnCompletion = true) { - return runTestTask('mocha', async () => { - const result = await runMochaTestsInBrowser(exitOnCompletion).catch(e => { - throw e; - }); - if (result) { - throw new Error('Mocha tests failed'); - } - console.log('Mocha tests passed'); - }); +function mocha() { + // Run in a subprocess so webdriverio is not loaded inside gulp's asyncDone + // domain (which has been observed to exit the process on CI after ~2s). + return runTestCommand('mocha', 'node tests/mocha/webdriver.js'); } /** * Run Mocha tests inside a browser and keep the browser open upon completion. * @return {Promise} Asynchronous result. */ -export async function interactiveMocha() { - return mocha(false); +export function interactiveMocha() { + return runTestTask('interactiveMocha', () => { + return runMochaTestsInBrowser(false).then((result) => { + if (result) { + throw new Error('Mocha tests failed'); + } + console.log('Mocha tests passed'); + }); + }); } /** @@ -335,7 +346,16 @@ export async function generators() { rimraf.sync(OUTPUT_DIR); fs.mkdirSync(OUTPUT_DIR); - await runGeneratorsInBrowser(OUTPUT_DIR); + const result = spawnSync('node', ['tests/generators/webdriver.js', OUTPUT_DIR], { + stdio: 'inherit', + env: process.env, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error('Generator browser tests failed.'); + } const generatorSuffixes = ['js', 'py', 'dart', 'lua', 'php']; let failed = 0; @@ -375,7 +395,10 @@ function advancedCompile() { * @return {Promise} Asynchronous result. */ function advancedCompileInBrowser() { - return runTestTask('advanced_compile_in_browser', runCompileCheckInBrowser); + return runTestCommand( + 'advanced_compile_in_browser', + 'node tests/compile/webdriver.js', + ); } /** diff --git a/packages/blockly/tests/compile/webdriver.js b/packages/blockly/tests/compile/webdriver.js index 23984216f..95d0343a7 100644 --- a/packages/blockly/tests/compile/webdriver.js +++ b/packages/blockly/tests/compile/webdriver.js @@ -9,78 +9,49 @@ * Chrome, via webdriver. */ const webdriverio = require('webdriverio'); - +const { + getWebdriverOptions, + runBrowserTestMain, +} = require('../scripts/webdriver_helpers.js'); /** - * Run the generator for a given language and save the results to a file. + * Run the health check in the browser. * @param {Thenable} browser A Thenable managing the processing of the browser * tests. */ async function runHealthCheckInBrowser(browser) { const result = await browser.execute(() => { - return healthCheck(); - }) - if (!result) throw Error('Health check failed in advanced compilation test.'); + return healthCheck(); + }); + if (!result) { + throw Error('Health check failed in advanced compilation test.'); + } console.log('Health check completed successfully.'); } /** - * Runs the generator tests in Chrome. It uses webdriverio to - * launch Chrome and load index.html. Outputs a summary of the test results - * to the console and outputs files for later validation. - * @return the Thenable managing the processing of the browser tests. + * Runs the compile health check in Chrome. + * @return {number} 0 on success. */ async function runCompileCheckInBrowser() { - const options = { - capabilities: { - browserName: 'chrome', - }, - logLevel: 'warn', - }; - // Run in headless mode on Github Actions. - if (process.env.CI) { - options.capabilities['goog:chromeOptions'] = { - args: [ - '--headless', - '--no-sandbox', - '--disable-dev-shm-usage', - '--allow-file-access-from-files', - ] - }; - } else { - // --disable-gpu is needed to prevent Chrome from hanging on Linux with - // NVIDIA drivers older than v295.20. See - // https://github.com/google/blockly/issues/5345 for details. - options.capabilities['goog:chromeOptions'] = { - args: ['--allow-file-access-from-files', '--disable-gpu'] - }; - } + const options = getWebdriverOptions(); const url = 'file://' + __dirname + '/index.html'; console.log('Starting webdriverio...'); const browser = await webdriverio.remote(options); - console.log('Loading url: ' + url); - await browser.url(url); - - await runHealthCheckInBrowser(browser); - - await browser.deleteSession(); + try { + console.log('Loading url: ' + url); + await browser.url(url); + await runHealthCheckInBrowser(browser); + } finally { + await browser.deleteSession(); + } + return 0; } if (require.main === module) { - runCompileCheckInBrowser().catch(e => { - console.error(e); - process.exit(1); - }).then(function(result) { - if (result) { - console.log('Compile test failed'); - process.exit(1); - } else { - console.log('Compile test passed'); - process.exit(0); - } - }); + runBrowserTestMain(() => runCompileCheckInBrowser()); } module.exports = {runCompileCheckInBrowser}; diff --git a/packages/blockly/tests/generators/webdriver.js b/packages/blockly/tests/generators/webdriver.js index 004b5c878..643137e48 100644 --- a/packages/blockly/tests/generators/webdriver.js +++ b/packages/blockly/tests/generators/webdriver.js @@ -7,10 +7,13 @@ /** * @fileoverview Node.js script to run generator tests in Chrome, via webdriver. */ -var webdriverio = require('webdriverio'); -var fs = require('fs'); -var path = require('path'); - +const webdriverio = require('webdriverio'); +const fs = require('fs'); +const path = require('path'); +const { + getWebdriverOptions, + runBrowserTestMain, +} = require('../scripts/webdriver_helpers.js'); /** * Run the generator for a given language and save the results to a file. @@ -22,8 +25,8 @@ var path = require('path'); */ async function runLangGeneratorInBrowser(browser, filename, codegenFn) { await browser.execute(codegenFn); - var elem = await browser.$("#importExport"); - var result = await elem.getValue(); + const elem = await browser.$('#importExport'); + const result = await elem.getValue(); fs.writeFileSync(filename, result, function(err) { if (err) { return console.log(err); @@ -36,88 +39,64 @@ async function runLangGeneratorInBrowser(browser, filename, codegenFn) { * launch Chrome and load index.html. Outputs a summary of the test results * to the console and outputs files for later validation. * @param {string} outputDir Output directory. - * @return The Thenable managing the processing of the browser tests. + * @return {number} 0 on success. */ async function runGeneratorsInBrowser(outputDir) { - var options = { - capabilities: { - browserName: 'chrome', - 'goog:chromeOptions': { - args: ['--allow-file-access-from-files'], - }, - }, - logLevel: 'warn', - }; + const options = getWebdriverOptions(); - // Run in headless mode on Github Actions. - if (process.env.CI) { - options.capabilities['goog:chromeOptions'].args.push( - '--headless', '--no-sandbox', '--disable-dev-shm-usage',); - } else { - // --disable-gpu is needed to prevent Chrome from hanging on Linux with - // NVIDIA drivers older than v295.20. See - // https://github.com/google/blockly/issues/5345 for details. - options.capabilities['goog:chromeOptions'].args.push('--disable-gpu'); - } - - var url = 'file://' + __dirname + '/index.html'; - var prefix = path.join(outputDir, 'generated'); + const url = 'file://' + __dirname + '/index.html'; + const prefix = path.join(outputDir, 'generated'); console.log('Starting webdriverio...'); const browser = await webdriverio.remote(options); - // Increase the script timeouts to 2 minutes to allow the generators to finish. - await browser.setTimeout({ 'script': 120000 }) + try { + // Increase the script timeouts to 2 minutes to allow generators to finish. + await browser.setTimeout({'script': 120000}); - console.log('Loading url: ' + url); - await browser.url(url); + console.log('Loading url: ' + url); + await browser.url(url); - await browser - .$('.blocklySvg .blocklyWorkspace > .blocklyBlockCanvas') - .waitForExist({timeout: 2000}); + await browser + .$('.blocklySvg .blocklyWorkspace > .blocklyBlockCanvas') + .waitForExist({timeout: 2000}); - await browser.execute(function() { - checkAll(); - return loadSelected(); - }); + await browser.execute(function() { + checkAll(); + return loadSelected(); + }); - await runLangGeneratorInBrowser(browser, prefix + '.js', - function() { - toJavaScript(); - }); - await runLangGeneratorInBrowser(browser, prefix + '.py', - function() { - toPython(); - }); - await runLangGeneratorInBrowser(browser, prefix + '.dart', - function() { - toDart(); - }); - await runLangGeneratorInBrowser(browser, prefix + '.lua', - function() { - toLua(); - }); - await runLangGeneratorInBrowser(browser, prefix + '.php', - function() { - toPhp(); - }); + await runLangGeneratorInBrowser(browser, prefix + '.js', + function() { + toJavaScript(); + }); + await runLangGeneratorInBrowser(browser, prefix + '.py', + function() { + toPython(); + }); + await runLangGeneratorInBrowser(browser, prefix + '.dart', + function() { + toDart(); + }); + await runLangGeneratorInBrowser(browser, prefix + '.lua', + function() { + toLua(); + }); + await runLangGeneratorInBrowser(browser, prefix + '.php', + function() { + toPhp(); + }); + } finally { + await browser.deleteSession(); + } - await browser.deleteSession(); + console.log('Generator browser tests completed.'); + return 0; } if (require.main === module) { - runGeneratorsInBrowser('tests/generators/tmp').catch(e => { - console.error(e); - process.exit(1); - }).then(function(result) { - if (result) { - console.log('Generator tests failed'); - process.exit(1); - } else { - console.log('Generator tests passed'); - process.exit(0); - } - }); + const outputDir = process.argv[2] || 'tests/generators/tmp'; + runBrowserTestMain(() => runGeneratorsInBrowser(outputDir)); } module.exports = {runGeneratorsInBrowser}; diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 9dc81465e..75b82509b 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -39,6 +39,7 @@
+
Focusable tree 1
@@ -149,6 +150,121 @@ failZero: true, }); +
diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 4c4865aba..503079c4c 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -883,8 +883,6 @@ suite('Keyboard Shortcut Items', function () { this.injectionDiv.dispatchEvent(keyEvent); // Wait for the live region to update after the event. this.clock.runAll(); - // The announcement may include an additional non-breaking space. - console.log(this.liveRegion.textContent); assert.include(this.liveRegion.textContent, expected); }; this.liveRegion = document.getElementById('blocklyAriaAnnounce'); diff --git a/packages/blockly/tests/mocha/webdriver.js b/packages/blockly/tests/mocha/webdriver.js index 6caf9140d..99d3271ca 100644 --- a/packages/blockly/tests/mocha/webdriver.js +++ b/packages/blockly/tests/mocha/webdriver.js @@ -11,6 +11,16 @@ const webdriverio = require('webdriverio'); const fs = require('fs'); const path = require('path'); const {posixPath} = require('../../scripts/helpers'); +const { + getWebdriverOptions, + runBrowserTestMain, +} = require('../scripts/webdriver_helpers.js'); + +/** @const {number} Max time to wait for the browser test page to finish. */ +const PAGE_TIMEOUT_MS = 120000; + +/** @const {number} Max time to wait for browser.deleteSession(). */ +const DELETE_SESSION_TIMEOUT_MS = 15000; /** * Ensure browser test imports that use ../../node_modules/* continue to work @@ -34,6 +44,130 @@ function ensureWorkspaceNodeModulesLinks() { } } +/** + * Run a promise with a timeout. + * @param {!Promise} promise The promise to run. + * @param {number} timeoutMs Timeout in milliseconds. + * @param {string} label Description for timeout errors. + * @return {!Promise} + */ +function withTimeout(promise, timeoutMs, label) { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(label + ' timed out after ' + timeoutMs + 'ms')); + }, timeoutMs); + }), + ]); +} + +/** + * Close the browser session, with a timeout so CI cannot hang forever. + * @param {?Object} browser The webdriverio browser instance. + */ +async function closeBrowserSession(browser) { + if (!browser) { + return; + } + try { + await withTimeout( + browser.deleteSession(), + DELETE_SESSION_TIMEOUT_MS, + 'browser.deleteSession()', + ); + } catch (e) { + console.warn('Failed to close browser session:', e.message); + } +} + +/** + * Enable focus emulation via CDP. Wrapped in a timeout because this call + * has been observed to hang intermittently on CI. + * @param {!Object} browser The webdriverio browser instance. + */ +async function enableFocusEmulation(browser) { + const timeoutMs = 10000; + try { + await withTimeout( + (async () => { + const puppeteer = await browser.getPuppeteer(); + await browser.call(async () => { + const page = (await puppeteer.pages())[0]; + const session = await page.createCDPSession(); + await session.send('Emulation.setFocusEmulationEnabled', { + enabled: true, + }); + }); + })(), + timeoutMs, + 'Focus emulation setup', + ); + } catch (e) { + console.warn('Focus emulation setup failed (continuing anyway):', + e.message); + } +} + +/** + * Print browser-side load diagnostics when tests fail to start or finish. + * @param {!Object} browser The webdriverio browser instance. + */ +async function printBrowserLoadDiagnostics(browser) { + if (!browser) { + return; + } + try { + await withTimeout((async () => { + console.log('============Blockly Mocha Load Diagnostics================'); + const loadStatus = await browser.$('#loadStatus') + .getAttribute('data-status'); + console.log('Load status:', loadStatus ?? 'unknown'); + const loadErrors = await browser.execute(() => { + return window.__blocklyTestLoadState?.errors ?? []; + }); + if (loadErrors.length) { + console.log('Captured load errors:'); + for (const error of loadErrors) { + console.log(' ' + error); + } + } + const failureMessagesEls = await browser.$$('#failureMessages p'); + for (const el of failureMessagesEls) { + const messageHtml = await el.getHTML(); + console.log(messageHtml.replace(/<\/?p>/g, '')); + } + if (loadStatus === 'pending' || loadStatus === 'imports-complete') { + console.log( + 'Blockly or test modules may not have loaded completely.'); + } + console.log('=========================================================='); + })(), 10000, 'Load diagnostics'); + } catch (diagErr) { + console.warn('Could not collect browser diagnostics:', diagErr.message); + } +} + +/** + * Wait until Mocha reports results or the page reports a load failure. + * @param {!Object} browser The webdriverio browser instance. + */ +async function waitForTestCompletion(browser) { + await browser.waitUntil(async() => { + const failureCount = await browser.$('#failureCount') + .getAttribute('tests_failed'); + if (failureCount !== 'unset') { + return true; + } + const loadStatus = await browser.$('#loadStatus') + .getAttribute('data-status'); + return loadStatus === 'failed'; + }, { + timeout: PAGE_TIMEOUT_MS, + timeoutMsg: 'Timed out waiting for Mocha tests to finish. Blockly may ' + + 'have failed to load; see load diagnostics below.', + }); +} /** * Runs the Mocha tests in this directory in Chrome. It uses webdriverio to @@ -45,67 +179,77 @@ function ensureWorkspaceNodeModulesLinks() { * @return {number} 0 on success, 1 on failure. */ async function runMochaTestsInBrowser(exitOnCompletion = true) { + // Gulp may pass a done callback as the first argument. + if (typeof exitOnCompletion === 'function') { + exitOnCompletion = true; + } + return runMochaTestsInBrowserImpl(exitOnCompletion); +} + +/** + * @param {boolean} exitOnCompletion True if the browser should quit after tests. + * @return {number} 0 on success, 1 on failure. + */ +async function runMochaTestsInBrowserImpl(exitOnCompletion) { ensureWorkspaceNodeModulesLinks(); - const options = { - capabilities: { - browserName: 'chrome', - 'goog:chromeOptions': { - args: ['--allow-file-access-from-files'], - }, - }, - logLevel: 'warn', - }; - - // Run in headless mode on Github Actions. - if (process.env.CI) { - options.capabilities['goog:chromeOptions'].args.push( - '--headless', '--no-sandbox', '--disable-dev-shm-usage',); - } else { - // --disable-gpu is needed to prevent Chrome from hanging on Linux with - // NVIDIA drivers older than v295.20. See - // https://github.com/google/blockly/issues/5345 for details. - options.capabilities['goog:chromeOptions'].args.push('--disable-gpu'); - } + const options = getWebdriverOptions(); const url = 'file://' + posixPath(__dirname) + '/index.html'; console.log('Starting webdriverio...'); - const browser = await webdriverio.remote(options); - console.log('Loading URL: ' + url); - await browser.url(url); + let browser; + let numOfFailure = '1'; + let pageLoaded = false; + let testsCompleted = false; + try { + browser = await webdriverio.remote(options); + console.log('Loading URL: ' + url); + await browser.url(url); + pageLoaded = true; - // Toggle the devtools setting to emulate focus, so that the window will - // always act as if it has focus regardless of the state of the window manager - // or operating system. This improves the reliability of FocusManager-related - // tests. - const puppeteer = await browser.getPuppeteer(); - await browser.call(async () => { - const page = (await puppeteer.pages())[0]; - const session = await page.createCDPSession(); - await session.send('Emulation.setFocusEmulationEnabled', { enabled: true }); - }); + // Focus emulation via CDP has hung on CI; skip there. + if (!process.env.CI) { + await enableFocusEmulation(browser); + } + + await waitForTestCompletion(browser); + testsCompleted = true; - await browser.waitUntil(async() => { const elem = await browser.$('#failureCount'); - const text = await elem.getAttribute('tests_failed'); - return text !== 'unset'; - }, { - timeout: 100000, - }); + numOfFailure = await elem.getAttribute('tests_failed'); - const elem = await browser.$('#failureCount'); - const numOfFailure = await elem.getAttribute('tests_failed'); + if (numOfFailure > 0) { + console.log('============Blockly Mocha Test Failures================'); + const failureMessagesEls = await browser.$$('#failureMessages p'); + if (!failureMessagesEls.length) { + console.log('There is at least one test failure, but no messages ' + + 'reported. Mocha may be failing because no tests are being run.'); + } + for (const el of failureMessagesEls) { + const messageHtml = await el.getHTML(); + console.log(messageHtml.replace(/<\/?p>/g, '')); + } + } + } catch (e) { + await printBrowserLoadDiagnostics(browser); + throw e; + } finally { + if (exitOnCompletion) { + await closeBrowserSession(browser); + } + } - if (numOfFailure > 0) { - console.log('============Blockly Mocha Test Failures================'); - const failureMessagesEls = await browser.$$('#failureMessages p'); - if (!failureMessagesEls.length) { - console.log('There is at least one test failure, but no messages reported. Mocha may be failing because no tests are being run.'); - } - for (const el of failureMessagesEls) { - const messageHtml = await el.getHTML(); - console.log(messageHtml.replace('

', '').replace('

', '')); - } + if (!pageLoaded) { + throw new Error( + 'Mocha browser tests did not start: Chrome failed to load the test ' + + 'page. Check chromedriver and Chrome setup.', + ); + } + if (!testsCompleted) { + throw new Error( + 'Mocha browser tests did not finish: the test page never reported ' + + 'results.', + ); } console.log('============Blockly Mocha Test Summary================='); @@ -114,23 +258,11 @@ async function runMochaTestsInBrowser(exitOnCompletion = true) { if (parseInt(numOfFailure) !== 0) { return 1; } - if (exitOnCompletion) await browser.deleteSession(); return 0; } if (require.main === module) { - runMochaTestsInBrowser().catch((e) => { - console.error(e); - process.exit(1); - }).then(function(result) { - if (result) { - console.log('Mocha tests failed'); - process.exit(1); - } else { - console.log('Mocha tests passed'); - process.exit(0); - } - }); + runBrowserTestMain(() => runMochaTestsInBrowser()); } module.exports = {runMochaTestsInBrowser}; diff --git a/packages/blockly/tests/scripts/webdriver_helpers.js b/packages/blockly/tests/scripts/webdriver_helpers.js new file mode 100644 index 000000000..7e5c88262 --- /dev/null +++ b/packages/blockly/tests/scripts/webdriver_helpers.js @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Shared helpers for browser-based webdriver test runners. + */ + +const {execFileSync} = require('child_process'); +const fs = require('fs'); + +/** + * Resolve the chromedriver binary for CI, preferring a system install. + * @return {string|undefined} + */ +function resolveChromedriverPath() { + if (process.env.CHROMEDRIVER_PATH) { + return process.env.CHROMEDRIVER_PATH; + } + try { + return execFileSync('which', ['chromedriver'], {encoding: 'utf8'}).trim(); + } catch { + return undefined; + } +} + +/** + * Resolve the Chrome/Chromium binary for CI. + * @return {string|undefined} + */ +function resolveChromeBinary() { + if (process.env.CHROME_BIN) { + return process.env.CHROME_BIN; + } + if (process.env.CHROME_PATH) { + return process.env.CHROME_PATH; + } + const candidates = [ + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return undefined; +} + +/** + * Build webdriverio options with CI-friendly Chrome/chromedriver settings. + * @param {!Object=} extraCapabilities Additional capability fields to merge in. + * @return {!Object} + */ +function getWebdriverOptions(extraCapabilities = {}) { + const chromeArgs = ['--allow-file-access-from-files']; + const googChromeOptions = {args: chromeArgs}; + + if (process.env.CI) { + chromeArgs.push('--headless', '--no-sandbox', '--disable-dev-shm-usage'); + } else { + // --disable-gpu is needed to prevent Chrome from hanging on Linux with + // NVIDIA drivers older than v295.20. See + // https://github.com/google/blockly/issues/5345 for details. + chromeArgs.push('--disable-gpu'); + } + + const chromeBinary = resolveChromeBinary(); + if (chromeBinary) { + googChromeOptions.binary = chromeBinary; + } + + const chromedriverPath = resolveChromedriverPath(); + const capabilities = { + browserName: 'chrome', + 'goog:chromeOptions': googChromeOptions, + ...extraCapabilities, + }; + if (chromedriverPath) { + capabilities['wdio:chromedriverOptions'] = {binary: chromedriverPath}; + } + + const options = { + capabilities, + logLevel: 'warn', + }; + + if (chromedriverPath) { + // Use the system chromedriver; do not download into /tmp. + process.env.CHROMEDRIVER_PATH = chromedriverPath; + } + + return options; +} + +/** + * Run a browser test script as the main module with reliable exit codes. + * @param {function(): Promise} runTests Function that returns 0 on + * success or a non-zero number on test failure. May throw on setup errors. + */ +function runBrowserTestMain(runTests) { + runTests() + .then((result) => { + if (result) { + process.exit(1); + } + process.exit(0); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); +} + +module.exports = { + getWebdriverOptions, + resolveChromeBinary, + resolveChromedriverPath, + runBrowserTestMain, +};