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
This commit is contained in:
Maribeth Moffatt
2026-05-29 14:53:07 -04:00
committed by GitHub
parent 75de6cb905
commit 7682eeab29
11 changed files with 614 additions and 228 deletions
+4
View File
@@ -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
+4 -1
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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';
+1
View File
@@ -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: {
@@ -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',
);
}
/**
+22 -51
View File
@@ -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};
+52 -73
View File
@@ -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};
+168 -17
View File
@@ -39,6 +39,7 @@
<div id="mocha"></div>
<div id="failureCount" style="display: none" tests_failed="unset"></div>
<div id="failureMessages" style="display: none"></div>
<div id="loadStatus" data-status="pending" style="display: none"></div>
<div id="testFocusableTree1">
Focusable tree 1
<div id="testFocusableTree1.node1" style="margin-left: 1em">
@@ -149,6 +150,121 @@
failZero: true,
});
</script>
<script>
(function () {
const failureDiv = document.getElementById('failureCount');
const failureMessages = document.getElementById('failureMessages');
const loadStatus = document.getElementById('loadStatus');
/**
* Mark the test run as failed before Mocha starts.
* @param {string} message Error message to report.
*/
function reportLoadFailure(message) {
const state = window.__blocklyTestLoadState;
if (state?.finished || state?.testsStarted) {
return;
}
state.finished = true;
failureDiv.setAttribute('tests_failed', '1');
loadStatus.setAttribute('data-status', 'failed');
const msg = document.createElement('p');
msg.textContent = message;
failureMessages.appendChild(msg);
}
window.__blocklyTestLoadState = {
finished: false,
importsComplete: false,
testsStarted: false,
errors: [],
markImportsComplete() {
this.importsComplete = true;
loadStatus.setAttribute('data-status', 'imports-complete');
},
markTestsStarted() {
this.testsStarted = true;
loadStatus.setAttribute('data-status', 'running');
},
reportFailure(error) {
const message =
error instanceof Error ? error.message : String(error);
this.errors.push(message);
reportLoadFailure('Failed to load Blockly or tests: ' + message);
},
};
window.addEventListener('error', (event) => {
const state = window.__blocklyTestLoadState;
if (state.finished || state.testsStarted) {
return;
}
const message = event.message || 'Unknown script error';
const loc = event.filename
? ' at ' + event.filename + ':' + event.lineno
: '';
state.errors.push(message + loc);
if (
message.includes('initialized before') ||
message.includes('before initialization') ||
message.includes('Cannot access')
) {
reportLoadFailure(
'Blockly module load error (likely circular dependency): ' +
message +
loc,
);
} else if (!state.importsComplete) {
reportLoadFailure(
'Error loading Blockly test modules: ' + message + loc,
);
}
});
window.addEventListener('unhandledrejection', (event) => {
const state = window.__blocklyTestLoadState;
if (state.finished || state.testsStarted) {
return;
}
const reason =
event.reason instanceof Error
? event.reason.message
: String(event.reason);
state.errors.push('Unhandled rejection: ' + reason);
reportLoadFailure(
'Unhandled rejection while loading Blockly tests: ' + reason + hint,
);
});
// Fail fast if modules never finish loading or mocha never starts.
setTimeout(() => {
if (failureDiv.getAttribute('tests_failed') !== 'unset') {
return;
}
const state = window.__blocklyTestLoadState;
if (state.testsStarted) {
return;
}
const status = loadStatus.getAttribute('data-status');
let hint;
if (status === 'pending') {
hint =
'The test module script did not finish loading. This is often ' +
'caused by a circular dependency in imports (e.g. importing ' +
'from "./blockly.js" or "./utils.js" instead of a specific ' +
'file). Check the browser console for "initialized before" ' +
'errors.';
} else {
hint =
'Tests finished loading (status: ' +
status +
') but mocha.run() never started.';
}
const errors = state.errors.join('; ');
reportLoadFailure(hint + (errors ? ' Errors: ' + errors : ''));
}, 90000);
})();
</script>
<script type="module">
import {loadScript} from '../scripts/load.mjs';
@@ -261,25 +377,60 @@
import './xml_test.js';
import './zoom_controls_test.js';
// Make Blockly and generators available via global scope.
globalThis.Blockly = Blockly;
globalThis.javascriptGenerator = javascriptGenerator;
window.__blocklyTestLoadState.markImportsComplete();
// Load additional scripts.
await loadScript('../../build/msg/en.js');
await loadScript('../playgrounds/screenshot.js');
await loadScript('../../node_modules/@blockly/dev-tools/dist/index.js');
/**
* Verify Blockly loaded correctly. Partial initialization often
* indicates a circular dependency in the module graph.
* @param {!Object} blockly The Blockly namespace.
*/
function verifyBlocklyLoaded(blockly) {
// Subset of Blockly namespaces that often fail to load correctly
// when a circular dependency is present.
const required = ['inject', 'FieldDropdown', 'BlockSvg'];
const missing = required.filter((name) => !blockly[name]);
if (missing.length) {
throw new Error(
'Blockly failed to initialize; missing exports: ' +
missing.join(', ') +
'. This is often caused by a circular dependency (e.g. ' +
'importing from "./blockly.js" or "./utils.js" instead of a ' +
'specific module). Check the browser console for ' +
'"initialized before" errors.',
);
}
}
let runner = mocha.run(function (failures) {
var failureDiv = document.getElementById('failureCount');
failureDiv.setAttribute('tests_failed', failures);
});
runner.on('fail', function (test, err) {
const msg = document.createElement('p');
msg.textContent = `"${test.fullTitle()}" failed: ${err.message}`;
const div = document.getElementById('failureMessages');
div.appendChild(msg);
});
try {
verifyBlocklyLoaded(Blockly);
// Make Blockly and generators available via global scope.
globalThis.Blockly = Blockly;
globalThis.javascriptGenerator = javascriptGenerator;
// Load additional scripts.
await loadScript('../../build/msg/en.js');
await loadScript('../playgrounds/screenshot.js');
await loadScript('../../node_modules/@blockly/dev-tools/dist/index.js');
window.__blocklyTestLoadState.markTestsStarted();
let runner = mocha.run(function (failures) {
var failureDiv = document.getElementById('failureCount');
failureDiv.setAttribute('tests_failed', failures);
document
.getElementById('loadStatus')
.setAttribute('data-status', 'done');
});
runner.on('fail', function (test, err) {
const msg = document.createElement('p');
msg.textContent = `"${test.fullTitle()}" failed: ${err.message}`;
const div = document.getElementById('failureMessages');
div.appendChild(msg);
});
} catch (error) {
window.__blocklyTestLoadState.reportFailure(error);
}
</script>
<div id="blocklyDiv"></div>
@@ -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');
+196 -64
View File
@@ -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('<p>', '').replace('</p>', ''));
}
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};
@@ -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<number|void>} 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,
};