From 990f620578c17d1dc1a203401559d363ad88a5f4 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 2 Apr 2026 23:10:05 +0100 Subject: [PATCH 1/5] Switch to running QUnit tests via Playwright instead of Puppeteer. --- .../workflows/reusable-javascript-tests.yml | 15 ---- Gruntfile.js | 21 ++++-- tests/qunit/playwright.config.js | 18 +++++ tests/qunit/qunit.js | 74 +++++++++++++++++++ 4 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 tests/qunit/playwright.config.js create mode 100644 tests/qunit/qunit.js diff --git a/.github/workflows/reusable-javascript-tests.yml b/.github/workflows/reusable-javascript-tests.yml index 6bab6a5287665..abd543887deb6 100644 --- a/.github/workflows/reusable-javascript-tests.yml +++ b/.github/workflows/reusable-javascript-tests.yml @@ -5,12 +5,6 @@ name: JavaScript tests on: workflow_call: - inputs: - disable-apparmor: - description: 'Whether to disable AppArmor.' - required: false - type: 'boolean' - default: false # Disable permissions for all available scopes by default. # Any needed permissions should be configured at the job level. @@ -55,15 +49,6 @@ jobs: - name: Install npm Dependencies run: npm ci - # Older branches using outdated versions of Puppeteer fail on newer versions of the `ubuntu-24` image. - # This disables AppArmor in order to work around those failures. - # - # See https://issues.chromium.org/issues/373753919 - # and https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md - - name: Disable AppArmor - if: ${{ inputs.disable-apparmor }} - run: echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns - - name: Run QUnit tests run: npm run grunt qunit:compiled diff --git a/Gruntfile.js b/Gruntfile.js index 5f9109fac3cb0..0737e2fbfecf4 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -115,7 +115,6 @@ module.exports = function(grunt) { 'cssmin', 'imagemin', 'jshint', - 'qunit', 'uglify', 'watch' ], @@ -1010,12 +1009,6 @@ module.exports = function(grunt) { } } }, - qunit: { - files: [ - 'tests/qunit/**/*.html', - '!tests/qunit/editor/**' - ] - }, phpunit: { 'default': { args: ['--verbose', '-c', 'phpunit.xml.dist'] @@ -2172,6 +2165,20 @@ module.exports = function(grunt) { }, this.async()); }); + grunt.registerTask( 'qunit', 'Runs QUnit tests.', function() { + var done = this.async(); + grunt.util.spawn( { + cmd: 'node_modules/.bin/playwright', + args: [ 'test', '--config', 'tests/qunit/playwright.config.js' ], + opts: { stdio: 'inherit' } + }, function( error, result, code ) { + if ( code !== 0 ) { + grunt.fail.warn( 'QUnit tests failed.' ); + } + done(); + } ); + } ); + grunt.registerTask( 'qunit:compiled', 'Runs QUnit tests on compiled as well as uncompiled scripts.', ['build', 'copy:qunit', 'qunit'] ); diff --git a/tests/qunit/playwright.config.js b/tests/qunit/playwright.config.js new file mode 100644 index 0000000000000..377de6cecd507 --- /dev/null +++ b/tests/qunit/playwright.config.js @@ -0,0 +1,18 @@ +/** + * Playwright configuration for QUnit tests. + */ +const path = require( 'path' ); +const { defineConfig } = require( '@playwright/test' ); + +module.exports = defineConfig( { + testDir: __dirname, + outputDir: path.join( __dirname, '..', '..', 'artifacts', 'test-results' ), + testMatch: 'qunit.js', + timeout: 30_000, + workers: 1, + use: { + headless: true, + channel: 'chrome', + }, + reporter: process.env.CI ? 'github' : 'list', +} ); diff --git a/tests/qunit/qunit.js b/tests/qunit/qunit.js new file mode 100644 index 0000000000000..85b4e2c4df7f1 --- /dev/null +++ b/tests/qunit/qunit.js @@ -0,0 +1,74 @@ +const { test, expect } = require( '@playwright/test' ); +const path = require( 'path' ); +const glob = require( 'fast-glob' ); + +const qunitDir = path.resolve( __dirname ); +const htmlFiles = glob.sync( [ '**/*.html' ], { + cwd: qunitDir, + absolute: true, +} ); + +for ( const file of htmlFiles ) { + const name = path.relative( qunitDir, file ); + + test( `QUnit: ${ name }`, async ( { page } ) => { + // Inject a QUnit.done hook before any page scripts run. + await page.addInitScript( () => { + window.__qunitResults = new Promise( ( resolve ) => { + window.__qunitResolve = resolve; + } ); + + // Keep checking for QUnit to become available. + const observer = new MutationObserver( () => { + if ( typeof QUnit !== 'undefined' && ! window.__qunitHooked ) { + window.__qunitHooked = true; + observer.disconnect(); + + const failures = []; + QUnit.testDone( ( details ) => { + if ( details.failed > 0 ) { + failures.push( + `${ details.module } > ${ details.name } (${ details.failed } assertion(s))` + ); + } + } ); + + QUnit.done( ( details ) => { + window.__qunitResolve( { + passed: details.passed, + failed: details.failed, + total: details.total, + runtime: details.runtime, + failures, + } ); + } ); + } + } ); + observer.observe( document, { + childList: true, + subtree: true, + } ); + } ); + + // Navigate to the test file. + await page.goto( 'file://' + file, { waitUntil: 'domcontentloaded' } ); + + // Wait for QUnit to complete. + const results = await page.evaluate( () => window.__qunitResults ); + + // Log summary. + // eslint-disable-next-line no-console + console.log( + ` ${ results.passed }/${ results.total } passed, ${ results.failed } failed, ${ results.runtime }ms` + ); + + if ( results.failures.length > 0 ) { + // eslint-disable-next-line no-console + console.log( + results.failures.map( ( f ) => ` FAIL: ${ f }` ).join( '\n' ) + ); + } + + expect( results.failed ).toBe( 0 ); + } ); +} From dd232cb94d2be5a95afac845d00029c3802d2cd2 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 2 Apr 2026 23:10:33 +0100 Subject: [PATCH 2/5] Remove grunt-contrib-qunit. --- package-lock.json | 172 ---------------------------------------------- package.json | 1 - 2 files changed, 173 deletions(-) diff --git a/package-lock.json b/package-lock.json index a48bff6270b72..7b81e98a0d53e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "grunt-contrib-cssmin": "~5.0.0", "grunt-contrib-imagemin": "~4.0.0", "grunt-contrib-jshint": "3.2.0", - "grunt-contrib-qunit": "~10.1.1", "grunt-contrib-uglify": "~5.2.2", "grunt-contrib-watch": "~1.1.0", "grunt-file-append": "0.0.7", @@ -4561,29 +4560,6 @@ "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", "dev": true }, - "node_modules/@puppeteer/browsers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", - "integrity": "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "debug": "^4.3.5", - "extract-zip": "^2.0.1", - "progress": "^2.0.3", - "proxy-agent": "^6.4.0", - "semver": "^7.6.3", - "tar-fs": "^3.0.6", - "unbzip2-stream": "^1.4.3", - "yargs": "^17.7.2" - }, - "bin": { - "browsers": "lib/cjs/main-cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -10617,21 +10593,6 @@ "node": ">=6.0" } }, - "node_modules/chromium-bidi": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.3.tgz", - "integrity": "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "mitt": "3.0.1", - "urlpattern-polyfill": "10.0.0", - "zod": "3.23.8" - }, - "peerDependencies": { - "devtools-protocol": "*" - } - }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -17003,26 +16964,6 @@ "node": ">=8" } }, - "node_modules/grunt-contrib-qunit": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/grunt-contrib-qunit/-/grunt-contrib-qunit-10.1.1.tgz", - "integrity": "sha512-qSzY/aWl4xn8dQc2eAwKrXNB0171WHgb4aA3ZdKkN88csxS3tCD3Eh8ljfsscFAKIKZkhjierRgQypep/aV4NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter2": "^6.4.9", - "puppeteer": "^22.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/grunt-contrib-qunit/node_modules/eventemitter2": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", - "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "dev": true - }, "node_modules/grunt-contrib-uglify": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/grunt-contrib-uglify/-/grunt-contrib-uglify-5.2.2.tgz", @@ -27510,27 +27451,6 @@ "node": ">=6" } }, - "node_modules/puppeteer": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-22.15.0.tgz", - "integrity": "sha512-XjCY1SiSEi1T7iSYuxS82ft85kwDJUS7wj1Z0eGVXKdtr5g4xnVcbjwxhq5xBnpK/E7x1VZZoJDxpjAOasHT4Q==", - "deprecated": "< 24.15.0 is no longer supported", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "cosmiconfig": "^9.0.0", - "devtools-protocol": "0.0.1312386", - "puppeteer-core": "22.15.0" - }, - "bin": { - "puppeteer": "lib/esm/puppeteer/node/cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/puppeteer-core": { "version": "23.11.1", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.11.1.tgz", @@ -27611,91 +27531,6 @@ } } }, - "node_modules/puppeteer/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/puppeteer/node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/puppeteer/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/puppeteer/node_modules/puppeteer-core": { - "version": "22.15.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-22.15.0.tgz", - "integrity": "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@puppeteer/browsers": "2.3.0", - "chromium-bidi": "0.6.3", - "debug": "^4.3.6", - "devtools-protocol": "0.0.1312386", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/puppeteer/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/pure-rand": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.3.tgz", @@ -32481,13 +32316,6 @@ "node": ">= 4" } }, - "node_modules/urlpattern-polyfill": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", - "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", - "dev": true, - "license": "MIT" - }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", diff --git a/package.json b/package.json index 82bb2d4f7a8c9..cca9ff31f9d10 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "grunt-contrib-cssmin": "~5.0.0", "grunt-contrib-imagemin": "~4.0.0", "grunt-contrib-jshint": "3.2.0", - "grunt-contrib-qunit": "~10.1.1", "grunt-contrib-uglify": "~5.2.2", "grunt-contrib-watch": "~1.1.0", "grunt-file-append": "0.0.7", From 12aabc4bb08d7fbf3c866bcd95a368f7fdfce8cc Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 2 Apr 2026 23:21:03 +0100 Subject: [PATCH 3/5] This path no longer exists. --- Gruntfile.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 0737e2fbfecf4..55cab1a101b91 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -905,7 +905,6 @@ module.exports = function(grunt) { src: [ 'tests/qunit/**/*.js', '!tests/qunit/vendor/*', - '!tests/qunit/editor/**' ], options: grunt.file.readJSON( 'tests/qunit/.jshintrc' ) }, @@ -1533,8 +1532,7 @@ module.exports = function(grunt) { }, test: { files: [ - 'tests/qunit/**', - '!tests/qunit/editor/**' + 'tests/qunit/**' ], tasks: ['qunit'] } From 9c7881f39f9ba364713ed948b634a5ddcc840600 Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Thu, 2 Apr 2026 23:21:16 +0100 Subject: [PATCH 4/5] Exclude the QUnit test runner from eslint. --- Gruntfile.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gruntfile.js b/Gruntfile.js index 55cab1a101b91..a6f8bacbf755d 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -905,6 +905,8 @@ module.exports = function(grunt) { src: [ 'tests/qunit/**/*.js', '!tests/qunit/vendor/*', + '!tests/qunit/qunit.js', + '!tests/qunit/playwright.config.js' ], options: grunt.file.readJSON( 'tests/qunit/.jshintrc' ) }, From e5c2cf05e70797f477ac71869ec569020b43db1a Mon Sep 17 00:00:00 2001 From: John Blackbourn Date: Sat, 4 Apr 2026 01:24:15 +0100 Subject: [PATCH 5/5] Address PR feedback. --- Gruntfile.js | 4 ++-- tests/qunit/playwright.config.js | 3 ++- tests/qunit/qunit.js | 24 ++++++++++++++++++------ 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index a6f8bacbf755d..5667c8b8c7510 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -2168,8 +2168,8 @@ module.exports = function(grunt) { grunt.registerTask( 'qunit', 'Runs QUnit tests.', function() { var done = this.async(); grunt.util.spawn( { - cmd: 'node_modules/.bin/playwright', - args: [ 'test', '--config', 'tests/qunit/playwright.config.js' ], + cmd: 'npx', + args: [ 'playwright', 'test', '--config', 'tests/qunit/playwright.config.js' ], opts: { stdio: 'inherit' } }, function( error, result, code ) { if ( code !== 0 ) { diff --git a/tests/qunit/playwright.config.js b/tests/qunit/playwright.config.js index 377de6cecd507..fc6651e0917ac 100644 --- a/tests/qunit/playwright.config.js +++ b/tests/qunit/playwright.config.js @@ -12,7 +12,8 @@ module.exports = defineConfig( { workers: 1, use: { headless: true, - channel: 'chrome', + /* This avoids the need to run `npx playwright install` in CI. */ + channel: process.env.CI ? 'chrome' : undefined, }, reporter: process.env.CI ? 'github' : 'list', } ); diff --git a/tests/qunit/qunit.js b/tests/qunit/qunit.js index 85b4e2c4df7f1..be677d137d0cc 100644 --- a/tests/qunit/qunit.js +++ b/tests/qunit/qunit.js @@ -1,12 +1,24 @@ const { test, expect } = require( '@playwright/test' ); +const fs = require( 'fs' ); const path = require( 'path' ); -const glob = require( 'fast-glob' ); +const { pathToFileURL } = require( 'url' ); + +function getHtmlFiles( dir ) { + const entries = fs.readdirSync( dir, { withFileTypes: true } ); + + return entries.flatMap( ( entry ) => { + const entryPath = path.join( dir, entry.name ); + + if ( entry.isDirectory() ) { + return getHtmlFiles( entryPath ); + } + + return entry.isFile() && entry.name.endsWith( '.html' ) ? [ entryPath ] : []; + } ); +} const qunitDir = path.resolve( __dirname ); -const htmlFiles = glob.sync( [ '**/*.html' ], { - cwd: qunitDir, - absolute: true, -} ); +const htmlFiles = getHtmlFiles( qunitDir ); for ( const file of htmlFiles ) { const name = path.relative( qunitDir, file ); @@ -51,7 +63,7 @@ for ( const file of htmlFiles ) { } ); // Navigate to the test file. - await page.goto( 'file://' + file, { waitUntil: 'domcontentloaded' } ); + await page.goto( pathToFileURL( file ).href, { waitUntil: 'domcontentloaded' } ); // Wait for QUnit to complete. const results = await page.evaluate( () => window.__qunitResults );