Page MenuHomePhabricator

Get PageTriage QUnit tests working on localhost
Closed, ResolvedPublic

Assigned To
Authored By
Novem_Linguae
Apr 25 2023, 6:00 AM
Referenced Files
F36975024: image.png
May 4 2023, 10:51 AM
F36972485: image.png
May 2 2023, 12:02 PM
F36972483: image.png
May 2 2023, 12:02 PM
F36972481: image.png
May 2 2023, 12:02 PM
F36963120: image.png
Apr 26 2023, 12:15 AM
F36963118: image.png
Apr 26 2023, 12:15 AM
F36963101: image.png
Apr 25 2023, 11:25 PM
F36963077: image.png
Apr 25 2023, 10:40 PM

Description

In T334642: Investigate switching Javascript unit tests from QUnit to Jest [12hrs], we decided to keep our one existing QUnit test. This means that PageTriage developers will probably want to get QUnit working on their localhost machines.

  • Get QUnit working for @Novem_Linguae's localhost.
    • No Docker
    • Using run command export MW_SERVER=http://localhost/Code/MediaWiki; export MW_SCRIPT_PATH=/core; npm run qunit --qunit-component=PageTriage.
      • Error message is ERROR [launcher]: Cannot start ChromeHeadless. [headless_shell.cc(255)] Multiple targets are not supported.
    • Using run command export MW_SERVER=http://localhost/Code/MediaWiki; export MW_SCRIPT_PATH=/core; export DISPLAY=1; export ZUUL_PROJECT=1; npm run qunit
      • No error message, but it says 0 tests completed
  • Get QUnit working for @jsn.sherman's localhost (fresh-node worked without the -env arg).
    • Docker
    • Using run command MW_SCRIPT_PATH=/w MW_SERVER=http://localhost:8080 npm run qunit -- --qunit-component=PageTriage.
    • Error message is 60 second timeout.
  • Update documentation at https://www.mediawiki.org/wiki/Manual:JavaScript_unit_testing#Run to be more helpful for these errors.

Adding test engineer @zeljkofilipin in case they have any ideas.

image.png (761×1 px, 83 KB)

Event Timeline

Restricted Application added a subscriber: Aklapper. · View Herald Transcript
karma: {
			options: {
				customLaunchers: {
					ChromeCustom: {
						base: 'ChromeHeadless',
						// Chrome requires --no-sandbox in Docker/CI.
						// WMF CI images expose CHROMIUM_FLAGS which sets that.
						flags: ( process.env.CHROMIUM_FLAGS || '' ).split( ' ' )
					}
				},

The issue is flags: ( process.env.CHROMIUM_FLAGS || '' ).split( ' ' ) which results in multiple arguments which is apparently no longer supported.

Deleting this line from Gruntfile.js in core gets the npm run qunit --component=PageTriage command working for me, but we need to figure out what CHROMIUM_FLAGS is used for and if there should only be a single flag, instead of support for multiple flags.

EDIT: We are indeed using CHROMIUM_FLAGS in a number of places. It looks like the change to allow a single flag is relatively recent (November 2022, patch) and is not in the version of Chromium that we use in CI (introduced in 110; CI is using version 90).

Error message is 60 second timeout.

@jsn.sherman do you get any other output?

Error message is 60 second timeout.

@jsn.sherman do you get any other output?

I basically did not follow the setup process correctly. I had some things installed in the host and some things installed in the container. Using Fresh worked fine with some argument adjustments. Interestingly, after running npm ci on the host and trying to run qunit from there, I get the same error as @Novem_Linguae's host based setup:

$ export MW_SCRIPT_PATH=/w; export MW_SERVER=http://localhost:8080; npm run qunit

> qunit
> grunt qunit

Running "assert-mw-env" task

Running "karma:main" (karma) task

START:
25 04 2023 08:54:43.929:INFO [karma-server]: Karma v6.4.1 server started at http://localhost:9876/
25 04 2023 08:54:43.930:INFO [launcher]: Launching browsers ChromeCustom with concurrency unlimited
25 04 2023 08:54:43.933:INFO [launcher]: Starting browser ChromeHeadless
25 04 2023 08:54:43.965:ERROR [launcher]: Cannot start ChromeHeadless
	[0425/085443.962372:ERROR:headless_shell.cc(255)] Multiple targets are not supported.

25 04 2023 08:54:43.965:ERROR [launcher]: ChromeHeadless stdout: 
25 04 2023 08:54:43.965:ERROR [launcher]: ChromeHeadless stderr: [0425/085443.962372:ERROR:headless_shell.cc(255)] Multiple targets are not supported.

25 04 2023 08:54:43.967:INFO [launcher]: Trying to start ChromeHeadless again (1/2).
25 04 2023 08:54:43.993:ERROR [launcher]: Cannot start ChromeHeadless
	[0425/085443.991163:ERROR:headless_shell.cc(255)] Multiple targets are not supported.

25 04 2023 08:54:43.993:ERROR [launcher]: ChromeHeadless stdout: 
25 04 2023 08:54:43.994:ERROR [launcher]: ChromeHeadless stderr: [0425/085443.991163:ERROR:headless_shell.cc(255)] Multiple targets are not supported.

25 04 2023 08:54:43.995:INFO [launcher]: Trying to start ChromeHeadless again (2/2).
25 04 2023 08:54:44.024:ERROR [launcher]: Cannot start ChromeHeadless
	[0425/085444.021350:ERROR:headless_shell.cc(255)] Multiple targets are not supported.

25 04 2023 08:54:44.024:ERROR [launcher]: ChromeHeadless stdout: 
25 04 2023 08:54:44.025:ERROR [launcher]: ChromeHeadless stderr: [0425/085444.021350:ERROR:headless_shell.cc(255)] Multiple targets are not supported.

25 04 2023 08:54:44.026:ERROR [launcher]: ChromeHeadless failed 2 times (cannot start). Giving up.

Finished in 0 secs / 0 secs @ 08:54:44 GMT-0500 (Central Daylight Time)

Warning: Task "karma:main" failed. Use --force to continue.

Aborted due to warnings.

I didn't actually say all the things above. Now that I've been playing around with qunit on mediawiki more, I wouldn't expect the above to work, but it might be a hint towards @Novem_Linguae's problem. It looks to me like the shell running qunit also needs to be able to execute mediawiki php code to set things up. Makes me wonder if the issue is there. @Novem_Linguae can you run things like maintenance scripts directly from that mingw shell that you're running qunit from?

[...]
EDIT: We are indeed using CHROMIUM_FLAGS in a number of places. It looks like the change to allow a single flag is relatively recent (November 2022, patch) and is not in the version of Chromium that we use in CI (introduced in 110; CI is using version 90).

I wonder if that's the problem! I just installed a recent release, since there wasn't a version mentioned in the docs. That would make more sense than my previous thought, since the error is in firing up chrome. I'll try installing v90.

Okay, I was in fact able to run the qunit browser tests from my host once I had a close enough chromium version available.
Downloading an old chromium versions was a bit of a pain. I followed the "old version" directions here:
https://www.chromium.org/getting-involved/download-chromium/
and ended up installing chromium 94, as that was the closest I could get that would run correctly for me.
https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html?prefix=Linux_x64/906442/

$ export CHROME_BIN=~/.local/chrome-linux/chrome export MW_SCRIPT_PATH=/w; export MW_SERVER=http://localhost:8080; npm run qunit

> qunit
> grunt qunit

Running "assert-mw-env" task

Running "karma:main" (karma) task

START:
25 04 2023 09:54:03.390:INFO [karma-server]: Karma v6.4.1 server started at http://localhost:9876/
25 04 2023 09:54:03.391:INFO [launcher]: Launching browsers ChromeCustom with concurrency unlimited
25 04 2023 09:54:03.395:INFO [launcher]: Starting browser ChromeHeadless
25 04 2023 09:54:03.588:INFO [Chrome Headless 94.0.4590.0 (Linux x86_64)]: Connected on socket QtKFfXFE97dvYvX1AAAB with id 77585171
  testrunner
    ✔ beforeEach
    ✔ afterEach
    ✔ Loader status
    ✔ assert.htmlEqual
  testrunner > testrunner-nested-hooks
    ✔ beforeEach
  testrunner-next
    ✔ afterEach
  ext.eventLogging/BackgroundQueue
    ✔ add()
  ext.eventLogging/bucketing
    ✔ getUserEditCountBucket() [0]
    ✔ getUserEditCountBucket() [3]
    ✔ getUserEditCountBucket() [99999]
    ✔ getUserEditCountBucket() [anonymous]
  ext.eventLogging/id
    ✔ pageview
    ✔ session
  ext.eventLogging/log
    ✔ logEvent()
    ✔ logEvent() via submit()
    ✔ checkUrlSize() [URL size is ok]
    ✔ checkUrlSize() [URL size is not ok]
    ✔ logEvent() - reject large event data
  ext.eventLogging/stream
    ✔ submit() - warn for event without schema
    ✔ submit() - produce an event correctly
  ext.eventLogging/utils
    ✔ eventInSample()
    ✔ sessionInSample()
    ✔ randomTokenMatch()
    ✔ makeLegacyStreamName()
  ext.eventLogging.debug
    ✔ validate()
    ✔ isInstanceOf() [boolean]
    ✔ isInstanceOf() [integer]
    ✔ isInstanceOf() [number]
    ✔ isInstanceOf() [string]
    ✔ isInstanceOf() [timestamp]
    ✔ isInstanceOf() [array]
  ext.wikimediaEvents/clientError
    ✔ processErrorLoggerObject
    ✔ processErrorInstance
    ✔ log
WARN: 'WARNING! mw.pageTriage.actionQueue has been reset.'
  ext.pageTriage.actionQueue
    ✔ Testing the queue: synchronous and asynchronous methods
WARN: 'WARNING! mw.pageTriage.actionQueue has been reset.'
25 04 2023 09:54:06.976:WARN [web-server]: 404: /dummy/?%7B%22event%22%3A%7B%22epicenter%22%3A%22Valdivia%22%2C%22magnitude%22%3A9.5%7D%2C%22schema%22%3A%22earthquake%22%2C%22webHost%22%3A%22example.test%22%2C%22wiki%22%3A%22my_wiki%22%2C%22revision%22%3A123%7D;
    ✔ Testing the queue: run() with multiple actions
WARN: 'WARNING! mw.pageTriage.actionQueue has been reset.'
    ✔ Testing the queue: run() with actions with action-specific data
  Minerva DownloadIcon
    ✔ #getOnClickHandler (print after image download)
    ✔ #getOnClickHandler (print via timeout)
    ✔ #getOnClickHandler (multiple clicks)
  isAvailable()
    ✔ isAvailable() handles properly correct namespace
    ✔ isAvailable() handles properly not supported namespace
    ✔ isAvailable() handles missing pages
    ✔ isAvailable() handles properly main page
    ✔ isAvailable() returns false for iOS
    ✔ isAvailable() uses window.chrome to filter certain chrome-like browsers
    ✔ isAvailable() handles properly browsers
    ✔ isAvailable() handles properly non-chrome browsers
    ✔ isAvailable() handles properly old devices
    ✔ isAvailable() handles properly supported browsers
  Minerva pageIssuesParser
    ✔ extractMessage
    ✔ parseSeverity
    ✔ parseType
    ✔ parseGroup
    ✔ iconName
    ✔ maxSeverity
  Minerva AB-test
    ✔ Bucketing test
  Minerva pageIssues
    ✔ insertBannersOrNotice() should add a "learn more" message
    ✔ insertBannersOrNotice() should add an icon
    ✔ clicking on the product of insertBannersOrNotice() should trigger a URL change
  Minerva UriUtil
    ✔ .isInternal()
  Minerva TitleUtil
    ✔ .newFromUri() authority [empty]
    ✔ .newFromUri() authority [metawiki]
    ✔ .newFromUri() bad input [0]
    ✔ .newFromUri() bad input [1]
    ✔ .newFromUri() bad input [2]
    ✔ .newFromUri() bad input [3]
    ✔ .newFromUri() bad input [4]
    ✔ .newFromUri() bad input [5]
    ✔ .newFromUri() bad input [6]
    ✔ .newFromUri() bad input [7]
    ✔ .newFromUri() bad input [8]
    ✔ .newFromUri() bad input [9]
    ✔ .newFromUri() misc
  Minerva Watchstar
    ✔ toggleClasses() from watched to unwatched
    ✔ toggleClasses() from unwatched to watched
    ✔ toggleClasses() from unwatched to temp watched
    ✔ toggleClasses() from temp watched to watched
  Thanks thank
    ✔ thanked cookie
    ✔ gets user gender
  Thanks mobilediff
    ✔ render button for logged in users
  ext.quicksurveys.lib
    ✔ showSurvey: Placement (infobox)
    ✔ showSurvey: Placement (image)
    ✔ showSurvey: Placement (no headings)
    ✔ surveyMatchesPlatform
    ✔ showSurvey: Placement (plain)
    ✔ showSurvey: Placement (embedded)
    ✔ isInAudience (user, minEdits, maxEdits, geo, pageIds)
  mediawiki
    ✔ Initial check
    ✔ mw.format
    ✔ mw.now
  mw.Message
    ✔ Construct
    ✔ plain()
    ✔ escaped()
    ✔ parse()
    ✔ exists()
    ✔ toString() non-existing
    ✔ jqueryMsg / Magic words
    ✔ mw.msg()
  mw.Map
    ✔ Store simple string key
    ✔ Store number-like key
    ✔ get()
    ✔ values
    ✔ set()
    ✔ exists()
    ✔ Avoid prototype pollution
  mw.loader
    ✔ .using( .., Function callback ) Promise
    ✔ Prototype method as module name
    ✔ .using() - Error: Circular dependency [Set]
WARN: 'Skipped unavailable module test.load.circleC'
    ✔ .load() - Error: Circular dependency
WARN: 'Skipped unavailable module test.load.circleDirect'
    ✔ .load() - Error: Circular dependency (direct)
    ✔ .using() - Error: Unregistered
    ✔ .load() - Error: Unregistered
WARN: 'Skipped unavailable module test.load.missingdep'
    ✔ .load() - Error: Missing dependency
    ✔ .implement( styles={ "css": [text, ..] } )
    ✔ .implement( styles={ "url": { <media>: [url, ..] } } )
    ✔ .implement( messages before script )
    ✔ .implement( styles with @import )
    ✔ .implement( dependency with styles )
    ✔ .implement( only scripts )
    ✔ .implement( only messages )
    ✔ .implement( empty )
    ✔ .implement( package files )
    ✔ .implement( name with @ )
    ✔ .addSource()
    ✔ .register() - ES6 support always true
    ✔ .batchRequest() - Module version combines for given batch
    ✔ .batchRequest() - Module version combined based on sorted order
    ✔ Broken indirect dependency
    ✔ Out-of-order implementation
    ✔ Missing dependency
    ✔ Dependency handling
    ✔ Network failure
    ✔ Skip-function handling
    ✔ .load( "//protocol-relative" ) - T32825
    ✔ .load( "/absolute-path" )
    ✔ importScript()
    ✔ importStylesheet()
    ✔ Empty string module name - T28804
    ✔ Executing race - T112232
    ✔ Stale response caching - T117587
    ✔ Stale response caching - backcompat
    ✔ No storing of group=private responses
    ✔ No storing of group=user responses
    ✔ mw.loader.store.load - Disallowed localStorage
    ✔ mw.loader.store.load - Invalid JSON
    ✔ mw.loader.store.load - Unusable JSON
    ✔ mw.loader.store.load - Expired JSON
    ✔ mw.loader.store.load - Good JSON
    ✔ require()
    ✔ require() in debug mode
    ✔ Implicit dependencies
    ✔ .getScript() - success
25 04 2023 09:54:15.265:WARN [web-server]: 404: /this-is-not-found
    ✔ .getScript() - failure
  mw.requestIdleCallback
    ✔ callback
    ✔ nested
    ✔ timeRemaining
    ✔ native
  mediawiki.jscompat
    ✔ Variable with Unicode letter in name
    ✔ Stripping of single initial newline from textarea's literal contents (T14130)
  jquery.color
    ✔ animate
  jquery.colorUtil
    ✔ getRGB [no arguments]
    ✔ getRGB [empty string]
    ✔ getRGB [array of rgb]
    ✔ getRGB [rgb string]
    ✔ getRGB [rgb spaces]
    ✔ getRGB [rgb percent]
    ✔ getRGB [rgb percent spaces]
    ✔ getRGB [hex 6 lowercase]
    ✔ getRGB [hex 6 uppercase]
    ✔ getRGB [hex 6 mixed]
    ✔ getRGB [hex 3 lowercase]
    ✔ getRGB [hex 3 uppercase]
    ✔ getRGB [hex 3 mixed]
    ✔ getRGB [rgba zeros]
    ✔ getRGB [rgba zeros nospace]
    ✔ getRGB [literal name lightGreen]
    ✔ getRGB [literal keyword transparent]
    ✔ getRGB [literal invalid]
    ✔ rgbToHsl
    ✔ hslToRgb
    ✔ getColorBrightness
  jquery.highlightText
    ✔ Check
  jquery.lengthLimit
    ✔ Plain text input
    ✔ Plain text input. Calling byteLimit with no parameters and no maxlength attribute (T38310)
    ✔ Limit using the maxlength attribute
    ✔ Limit using a custom value
    ✔ Limit using a custom value, overriding maxlength attribute
    ✔ Limit using a custom value (multibyte)
    ✔ Limit using a custom value (multibyte, outside BMP)
    ✔ Limit using a custom value (multibyte) overlapping a byte
    ✔ Pass the limit and a callback as input filter
    ✔ Limit using the maxlength attribute and pass a callback as input filter
    ✔ Pass the limit and a callback as input filter 
    ✔ Input filter that increases the length
    ✔ Input filter of which the base exceeds the limit
    ✔ Confirm properties and attributes set
    ✔ Trim from insertion when limit exceeded
    ✔ Do not cut up false matching substrings in emoji insertions
    ✔ Unpaired surrogates do not crash
  jquery.makeCollapsible
    ✔ testing hooks/triggers
    ✔ basic operation (<div>)
    ✔ basic operation (<table>)
    ✔ basic operation (<table> with caption)
    ✔ basic operation (<table> with caption and <thead>)
    ✔ basic operation (<ul>)
    ✔ basic operation (<ol>)
    ✔ basic operation when synchronous (options.instantHide)
    ✔ mw-made-collapsible data added
    ✔ mw-collapsible added when missing
    ✔ mw-collapsed added when missing
    ✔ initial collapse (mw-collapsed class)
    ✔ initial collapse (options.collapsed)
    ✔ clicks on links inside toggler pass through
    ✔ click on non-link inside toggler counts as trigger
    ✔ collapse/expand text (data-collapsetext, data-expandtext)
    ✔ collapse/expand text (options.collapseText, options.expandText)
    ✔ predefined toggle button and text (.mw-collapsible-toggle/.mw-collapsible-text)
    ✔ cloned collapsibles can be made collapsible again
    ✔ reveal hash fragment
    ✔ T168689 - nested collapsible divs should keep independent state
    ✔ placeholder element for toggle
  jquery.tablesorter
    ✔ Planets: initial sort ascending by name
    ✔ Planets: initial sort descending by radius
    ✔ Planets: ascending by name
    ✔ Planets: ascending by name (again)
    ✔ Planets: ascending by name (multiple clicks)
    ✔ Planets: descending by name
    ✔ Planets: return to initial sort
    ✔ Planets: ascending radius
    ✔ Planets: descending radius
    ✔ Sorting multiple columns by passing sort list
    ✔ Sorting multiple columns by programmatically triggering sort()
    ✔ Reset to initial sorting by triggering sort() without any parameters
    ✔ Sort via click event after having initialized the tablesorter with initial sorting
    ✔ Multi-sort via click event after having initialized the tablesorter with initial sorting
    ✔ Reset sorting making table appear unsorted
    ✔ Sorting with colspanned headers: spanned column
    ✔ Sorting with colspanned headers: sort spanned column twice
    ✔ Sorting with colspanned headers: subsequent column
    ✔ Sorting with colspanned headers: sort subsequent column twice
    ✔ Basic planet table: one unsortable column
    ✔ T30775: German-style (dmy) short numeric dates
    ✔ T30775: American-style (mdy) short numeric dates
    ✔ IPv4 address sorting (T19141)
    ✔ IPv4 address reverse sorting (T19141)
    ✔ Accented Characters with custom collation
    ✔ Accented Characters Swedish locale
    ✔ Digraphs with custom collation
    ✔ Rowspan not exploded on init
    ✔ Basic planet table: same value for multiple rows via rowspan
    ✔ Basic planet table: same value for multiple rows via rowspan (sorting initially)
    ✔ Basic planet table: Same value for multiple rows via rowspan II
    ✔ Complex date parsing I
    ✔ Currency parsing I
    ✔ Handling of .sortbottom
    ✔ Handling of .sorttop
WARN: '(sort-rowspan-error)'
    ✔ Rowspan invalid value (T265503)
    ✔ Test sort buttons not added to .sorttop row
    ✔ Test detection routine
    ✔ T34047 - caption must be before thead
    ✔ data-sort-value attribute, when available, should override sorting position
    ✔ T10115: sort numbers with commas (ascending)
    ✔ T10115: sort numbers with commas (descending)
    ✔ T34888 - Tables inside a tableheader cell
    ✔ Correct date sorting I
    ✔ Correct date sorting II
    ✔ ISO date sorting
    ✔ Sorting images using alt text
    ✔ Sorting images using alt text (complex)
    ✔ Sorting images using alt text (with format autodetection)
    ✔ T40911 - The row with the largest amount of columns should receive the sort indicators
    ✔ rowspans in table headers should prefer the last row when rows are equal in length
    ✔ holes in the table headers should not throw JS errors
    ✔ td cells in thead should not be taken into account for longest row calculation
    ✔ Rowspan exploding with row headers
    ✔ Rowspan exploding with row headers and colspans
    ✔ Rowspan exploding with colspanned cells
    ✔ Rowspan exploding with colspanned cells (2)
    ✔ Rowspan exploding with rightmost rows spanning most
    ✔ Rowspan exploding with rightmost rows spanning most (2)
    ✔ Rowspan exploding with row-and-colspanned cells
    ✔ Rowspan exploding with uneven rowspan layout
    ✔ T105731 - incomplete rows in table body
    ✔ bug T114721 - use of expand-child class
    ✔ T29745 - References ignored in sortkey
    ✔ T311145 - style tags ignored in sortkey
  jquery.tablesorter > parsers
    ✔ Textual keys
    ✔ IPv4
    ✔ MDY Dates using mdy content language
    ✔ MDY Dates using dmy content language
    ✔ Very old MDY dates
    ✔ MDY Dates
    ✔ DMY Dates
    ✔ Clobbered Dates
    ✔ MY Dates
    ✔ Y Dates
    ✔ Currency
    ✔ Currency with european separators
  jquery.textSelection
    ✔ Adding sig to end of text
    ✔ Adding bold to empty
    ✔ Adding bold to existing text
    ✔ ownline option: adding new h2
    ✔ ownline option: turn a whole line into new h2
    ✔ ownline option: turn a partial line into new h2
    ✔ splitlines option: no selection, insert new list item
    ✔ splitlines option: single partial line selection, insert new list item
    ✔ splitlines option: multiple lines
    ✔ set/getCaretPosition with forced empty selection
    ✔ set/getCaretPosition with small selection
  mediawiki.errorLogger
    ✔ installGlobalHandler
    ✔ logError
  mediawiki.base
    ✔ mw.hook - add() and fire()
    ✔ mw.hook - "hasOwnProperty" as hook name
    ✔ mw.hook - Number of arguments
    ✔ mw.hook - Variadic firing data and array data type
    ✔ mw.hook - Chainable
    ✔ mw.hook - Memory from before
    ✔ mw.hook - Multiple consumers with memory between fires
    ✔ mw.hook - Memory is not wiped when consumed.
    ✔ mw.hook - Unregistering handler.
    ✔ mw.hook - Limit impact of consumer errors T223352
    ✔ mw.hook - Variadic add and remove
    ✔ mw.log.makeDeprecated()
    ✔ mw.log.deprecate()
    ✔ RLQ.push
  mediawiki.html
    ✔ escape
    ✔ element()
    ✔ element( tagName )
    ✔ element( tagName, attrs )
    ✔ element( tagName, attrs, content )
  mediawiki.track
    ✔ track
    ✔ trackSubscribe
    ✔ trackUnsubscribe
  mediawiki.jqueryMsg
    ✔ Replace
    ✔ Plural
    ✔ Gender
    ✔ Case changing
    ✔ Grammar
    ✔ Variables
    ✔ Bi-di
    ✔ Match PHP parser
    ✔ Links
    ✔ Replacements in links
    ✔ Curly brace transformation
    ✔ Int
    ✔ Ns
    ✔ mw.Message.prototype.parser monkey-patch
    ✔ mw.Message.prototype.parser monkey-patch HTML-escape
    ✔ formatnum
    ✔ HTML
    ✔ Nowiki
    ✔ Behavior in case of invalid wikitext
    ✔ Non-string parameters to various functions
    ✔ Do not allow javascript: urls
    ✔ Do not allow arbitrary style
    ✔ Integration
    ✔ setParserDefaults
  mediawiki.messagePoster
    ✔ register
  mediawiki.String.byteLength
    ✔ Simple text
    ✔ Special text
  mediawiki.String.charAt
    ✔ Simple text
    ✔ UTF-16 text
  mediawiki.String.lcFirst
    ✔ lcFirst
  mediawiki.String.ucFirst
    ✔ ucFirst
  mediawiki.String.trimByteLength
    ✔ Limit using the maxlength attribute
    ✔ Limit using a custom value (multibyte)
    ✔ Limit using a custom value (multibyte, outside BMP)
    ✔ Limit using a custom value (multibyte) overlapping a byte
    ✔ Pass the limit and a callback as input filter
    ✔ Pass the limit and a callback as input filter 
    ✔ Input filter that increases the length
    ✔ Trim from insertion when limit exceeded
    ✔ Trim from insertion when limit exceeded 
    ✔ Do not cut up false matching substrings in emoji insertions
    ✔ Unpaired surrogates do not crash
  mediawiki.storage
    ✔ set/get(Object) with storage support
    ✔ set/get(Object) with storage methods disabled
    ✔ set/get(Object) with storage object disabled
  mediawiki.template
    ✔ add
    ✔ compile
    ✔ get
  mediawiki.template.mustache
    ✔ render
  mediawiki.inspect
    ✔ .getModuleSize() - scripts
    ✔ .getModuleSize() - scripts, styles
    ✔ .getModuleSize() - packageFiles, styles
    ✔ .getModuleSize() - scripts, messages
    ✔ .getModuleSize() - scripts, styles, messages, templates
  mediawiki.router
    ✔ instance
  mediawiki.Title
    ✔ constructor
    ✔ newFromText
    ✔ makeTitle
    ✔ Basic parsing
    ✔ Transformation
    ✔ Namespace detection and conversion
    ✔ isTalkPage/getTalkPage/getSubjectPage
    ✔ wantSignaturesNamespace
    ✔ Throw error on invalid title
    ✔ phpCharToUpper
    ✔ Case-sensivity
    ✔ toString / toText
    ✔ getExtension
    ✔ exists
    ✔ getUrl
    ✔ newFromImg
    ✔ getRelativeText
    ✔ normalizeExtension
    ✔ newFromUserInput
    ✔ newFromUserInput with invalid file name for upload
    ✔ newFromUserInput with misplaced parameter
    ✔ newFromUserInput with invalid file name, but not for upload
    ✔ newFromFileName
    ✔ makeTitle for non existent namespace
  mediawiki.toc
    ✔ Use toggle
    ✔ Initially hidden
  mediawiki.Uri
    ✔ Basic construction and properties (strict mode)
    ✔ Basic construction and properties (non-strict mode)
    ✔ Constructor( String[, Object ] )
    ✔ Constructor( Object )
    ✔ Constructor( empty[, Object ] )
    ✔ Properties
    ✔ .getQueryString()
    ✔ arrayParams
    ✔ .clone()
    ✔ .toString() after query manipulation
    ✔ Variable defaultUri
    ✔ Advanced URL
    ✔ Parse a uri with an @ symbol in the path and query
    ✔ Handle protocol-relative URLs
    ✔ T37658
  mediawiki.user
    ✔ options
    ✔ getters (anonymous)
    ✔ getters (logged-in)
    ✔ getGroups (callback)
    ✔ getGroups (Promise)
    ✔ getRights (callback)
    ✔ getRights (Promise)
    ✔ generateRandomSessionId
    ✔ generateRandomSessionId (fallback)
    ✔ getPageviewToken
    ✔ sessionId
  mediawiki.util
    ✔ rawurlencode
    ✔ escapeIdForAttribute
    ✔ escapeIdForLink
    ✔ percentDecodeFragment [0]
    ✔ percentDecodeFragment [1]
    ✔ percentDecodeFragment [2]
    ✔ percentDecodeFragment [3]
    ✔ percentDecodeFragment [4]
    ✔ percentDecodeFragment [5]
    ✔ percentDecodeFragment [6]
    ✔ percentDecodeFragment [7]
    ✔ percentDecodeFragment [8]
    ✔ percentDecodeFragment [9]
    ✔ percentDecodeFragment [10]
    ✔ percentDecodeFragment [11]
    ✔ percentDecodeFragment [12]
    ✔ wikiUrlencode [0]
    ✔ wikiUrlencode [1]
    ✔ wikiUrlencode [2]
    ✔ wikiUrlencode [3]
    ✔ wikiUrlencode [4]
    ✔ wikiUrlencode [5]
    ✔ wikiUrlencode [6]
    ✔ wikiUrlencode [7]
    ✔ wikiUrlencode [8]
    ✔ wikiUrlencode [9]
    ✔ wikiUrlencode [10]
    ✔ getUrl
    ✔ wikiScript
    ✔ addCSS
    ✔ getParamValue
    ✔ addPortletLink (Vector list)
    ✔ addPortletLink (Minerva list)
    ✔ addPortletLink (nextNode option)
    ✔ addPortletLink (accesskey option)
    ✔ addPortletLink (nested list)
    ✔ validateEmail
    ✔ isIPv6Address
    ✔ isIPv4Address
    ✔ isIPAddress
    ✔ parseImageUrl [Hashed thumb with shortened path]
    ✔ parseImageUrl [Hashed thumb with sha1-ed path]
    ✔ parseImageUrl [Normal hashed directory thumbnail]
    ✔ parseImageUrl [Normal hashed directory thumbnail with complex thumbnail parameters]
    ✔ parseImageUrl [Width-like filename component]
    ✔ parseImageUrl [Width-like filename component in non-ASCII filename]
    ✔ parseImageUrl [Commons thumbnail]
    ✔ parseImageUrl [Full image]
    ✔ parseImageUrl [thumb.php-based thumbnail]
    ✔ parseImageUrl [thumb.php-based thumbnail with px width]
    ✔ parseImageUrl [thumb.php-based BC thumbnail]
    ✔ parseImageUrl [Commons unhashed thumbnail]
    ✔ parseImageUrl [Commons unhashed thumbnail with complex thumbnail parameters]
    ✔ parseImageUrl [Unhashed local file]
    ✔ parseImageUrl [Empty string]
    ✔ parseImageUrl [String with only alphabet characters]
    ✔ parseImageUrl [Not a file path]
    ✔ parseImageUrl [Space characters]
    ✔ parseImageUrl [no dynamic thumbnail generation]
    ✔ escapeRegExp
    ✔ debounce
    ✔ debounce immediate
    ✔ debounce (old signature)
    ✔ init (.mw-body-primary)
    ✔ init (first of multiple .mw-body)
    ✔ init (#mw-content-text fallback)
    ✔ init (body fallback)
    ✔ sanitizeIP
    ✔ prettifyIP
    ✔ isTemporaryUser
  mediawiki.util: jquery.accessKeyLabel
    ✔ getAccessKeyPrefix
    ✔ updateTooltipAccessKeys - current browser
    ✔ updateTooltipAccessKeys - no access key
    ✔ updateTooltipAccessKeys - with access key
    ✔ updateTooltipAccessKeys with label element
    ✔ updateTooltipAccessKeys with label element as parent
  mediawiki.api
    ✔ get()
    ✔ post()
    ✔ API error errorformat=bc
    ✔ API error errorformat!=bc
    ✔ FormData support
    ✔ Converting arrays to pipe-separated (string)
    ✔ Converting arrays to pipe-separated (mw.Title)
    ✔ Converting arrays to pipe-separated (misc primitives)
    ✔ Omitting false booleans
    ✔ getToken() - cached
    ✔ getToken() - uncached
    ✔ getToken() - error
    ✔ getToken() - no query
WARN: 'Use of the "email" token is deprecated. Use "csrf" instead.'
    ✔ getToken() - deprecated
    ✔ badToken()
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
    ✔ badToken( legacy )
    ✔ postWithToken( tokenType, params )
    ✔ postWithToken( tokenType, params with assert )
    ✔ postWithToken( tokenType, params, ajaxOptions )
    ✔ postWithToken() - badtoken
    ✔ postWithToken() - badtoken-cached
  mediawiki.api (2)
    ✔ #abort
  mediawiki.api.category
    ✔ .getCategoriesByPrefix()
    ✔ .isCategory("")
    ✔ .isCategory("#")
    ✔ .isCategory("mw:")
    ✔ .isCategory("|")
    ✔ .getCategories("")
    ✔ .getCategories("#")
    ✔ .getCategories("mw:")
    ✔ .getCategories("|")
  mediawiki.api.edit
    ✔ edit( title, transform String )
    ✔ edit( mw.Title, transform String )
    ✔ edit( title, transform Promise )
    ✔ edit( title, transform Object )
    ✔ edit( invalid-title, transform String )
    ✔ create( title, content )
  mediawiki.api.messages
    ✔ .getMessages()
    ✔ .getMessages() with a long string
  mediawiki.api.options
    ✔ saveOption
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
    ✔ saveOptions without Unit Separator
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
WARN: 'Use of the "options" token is deprecated. Use "csrf" instead.'
    ✔ saveOptions with Unit Separator
    ✔ saveOptions (anonymous)
  mediawiki.api.parse
    ✔ .parse( string )
    ✔ .parse( Object.toString )
    ✔ .parse( mw.Title )
  mediawiki.api.upload
    ✔ Basic functionality
  mediawiki.api.watch
    ✔ .watch( string )
    ✔ .watch( Array ) - single
    ✔ .watch( Array ) - multi
  mediawiki.rest
    ✔ get()
    ✔ get() respects ajaxOptions url
    ✔ post()
    ✔ put()
    ✔ delete()
    ✔ http error
  mediawiki.rest abort
    ✔ #abort
  mediawiki.ForeignApi
    ✔ origin is included in GET requests
    ✔ origin is included in POST requests
    ✔ origin is not included in same-origin GET requests
    ✔ origin is not included in same-origin POST requests
  mediawiki.ForeignRest
    ✔ get()
    ✔ post()
    ✔ http error
  mediawiki.rcfilters - FiltersViewModel
    ✔ Setting up filters
    ✔ Default filters
    ✔ Parameter minimal state
    ✔ Parameter states
    ✔ Cleaning up parameter states
    ✔ Finding matching filters
    ✔ getParametersFromFilters
    ✔ getParametersFromFilters (custom object)
    ✔ getFiltersFromParameters
    ✔ sanitizeStringOptionGroup
    ✔ Filter interaction: subsets
    ✔ Filter interaction: full coverage
    ✔ Filter interaction: conflicts
    ✔ Filter highlights
    ✔ emptyAllFilters
    ✔ areVisibleFiltersEmpty
  mediawiki.rcfilters - FilterItem
    ✔ Initializing filter item
    ✔ Emitting events
    ✔ get/set boolean value
    ✔ get/set any value
  mediawiki.rcfilters - SavedQueryItemModel
    ✔ Initializing and getters
    ✔ Default
  mediawiki.rcfilters - SavedQueriesModel
    ✔ Initializing queries
    ✔ Adding new queries
    ✔ Manipulating queries
    ✔ Testing invert property
  mediawiki.rcfilters - UriProcessor
    ✔ getVersion
    ✔ getUpdatedUri
    ✔ updateModelBasedOnQuery
    ✔ isNewState
    ✔ doesQueryContainRecognizedParams
    ✔ _getNormalizedQueryParams
    ✔ _normalizeTargetInUri
  mediawiki.widgets.APIResultsQueue
    ✔ Query providers
    ✔ Abort providers
  mediawiki.widgets.TableWidget
    ✔ TableWidgetModel initialization
    ✔ TableWidgetModel#getRowProperties
    ✔ TableWidget#setValue
    ✔ TableWidget#insertColumn/insertRow (skipped)
    ✔ TableWidget#removeColumn (skipped)
    ✔ TableWidget#removeRow by index (skipped)
    ✔ TableWidget#removeRow by key (skipped)
    ✔ TableWidget populate text inputs
  mediawiki.language
    ✔ mw.language getData and setData
    ✔ mw.language.convertNumber
    ✔ mw.language.convertNumber - digitTransformTable
    ✔ List to text test
    ✔ mw.language.bcp47
  mediawiki.cookie
    ✔ set( key, value )
    ✔ set( key, value, expires )
    ✔ set( key, value, options )
    ✔ set with sameSiteLegacy
    ✔ get( key ) - no values
    ✔ get( key ) - with value
    ✔ get( key, prefix )
    ✔ getCrossSite( key, prefix )
  mediawiki.deflate
    ✔ deflate [foobar]
    ✔ deflate [Unicode]
    ✔ deflate [Non BMP unicode]
    ✔ deflate [5MB data]
  mediawiki.experiments
    ✔ getBucket( experiment, token )
  mediawiki.visibleTimeout
    ✔ visibleTimeoutId is always a positive integer
    ✔ basic usage when visible
    ✔ basic usage - fallback assumes visible
    ✔ can cancel timeout
    ✔ start hidden and become visible
    ✔ timeout is cumulative
  ext.echo.mobile - NotificationBadge
    ✔ .setCount()
    ✔ .setCount() Eastern Arabic numerals
    ✔ .render() [hasUnseenNotifications]
    ✔ .markAsSeen()
  ext.echo.dm - BundleNotificationItem
    ✔ Constructing the model
    ✔ Managing a list of items
  ext.echo.dm - CrossWikiNotificationItem
    ✔ Constructing the model [Default values]
    ✔ Constructing the model [Overriding model name]
    ✔ Constructing the model [Overriding model count]
    ✔ Managing notification lists
    ✔ Update seen state
    ✔ Emit discard event
  ext.echo.dm - FiltersModel
    ✔ Constructing the model [Empty config]
    ✔ Constructing the model [Readstate: unread]
    ✔ Constructing the model [Readstate: read]
    ✔ Changing filters
    ✔ .setReadState() events
  ext.echo.dm - NotificationGroupsList
    ✔ Constructing the model
    ✔ Managing lists
    ✔ Emitting discard event
  ext.echo.dm - NotificationItem
    ✔ Constructing items [Empty data]
    ✔ Constructing items [Fake data]
    ✔ Emitting update event
  ext.echo.dm - NotificationsList
    ✔ Constructing the model [Empty config]
    ✔ Constructing the model [Prefilled data]
    ✔ Handling notification items
    ✔ Intercepting events
  ext.echo.dm - PaginationModel
    ✔ Constructing the model [Empty config]
    ✔ Constructing the model [Overriding defaults]
    ✔ Emitting update event
  ext.echo.dm - SeenTimeModel
    ✔ .getTypes()
    ✔ .setSeenTime() reflected
    ✔ .setSeenTime() events
  ext.echo.dm - SourcePagesModel
    ✔ Creating source-page map
  ext.echo.dm - UnreadNotificationCounter
    ✔ .getCappedNotificationCount() [0]
    ✔ .getCappedNotificationCount() [1]
    ✔ .getCappedNotificationCount() [2]
    ✔ .estimateChange()
    ✔ .setCount()
  uw.controller.Deed
    ✔ Constructor sanity test
    ✔ load
  uw.controller.Details
    ✔ Constructor sanity test
    ✔ load
    ✔ canTransition
    ✔ transitionAll
  uw.controller.Step
    ✔ Constructor sanity test
  uw.controller.Thanks
    ✔ Constructor sanity test
    ✔ load
    ✔ Custom button configuration
    ✔ Method drops the given parameter
  uw.controller.Tutorial
    ✔ Constructor sanity test
    ✔ setSkipPreference
  uw.controller.Upload
    ✔ Constructor sanity test
    ✔ updateFileCounts
    ✔ canTransition
    ✔ transitionOne
  mw.FormDataTransport
    ✔ Constructor sanity test
    ✔ abort
    ✔ createParams
    ✔ post
    ✔ upload
    ✔ uploadChunk
WARN: 'Unable to check file's status'
    ✔ checkStatus invalid API response
    ✔ checkStatus retry
    ✔ checkStatus success
    ✔ checkStatus error API response
  uw.ConcurrentQueue
    ✔ Basic behavior
    ✔ Event emitting
    ✔ Restarting a completed queue
    ✔ Empty queue completes
    ✔ Adding new items while queue running
    ✔ Deleting items while queue running
    ✔ Deleting currently running item
    ✔ Adding a new item when almost done
  mw.UploadWizardUpload
    ✔ constructor sanity test
    ✔ getBasename
  ext.uploadWizardLicenseInput
    ✔ Smoke test
    ✔ createInputs()
    ✔ createGroupedInputs()
  ext.uploadWizard/mw.FlickrChecker.test.js
    ✔ getFilenameFromItem() simple case
    ✔ getFilenameFromItem() with empty title
    ✔ getFilenameFromItem() name conflict within instance
    ✔ getFilenameFromItem() name conflict between different instances
    ✔ setUploadDescription
  uw.TitleDetailsWidget
    ✔ .static.makeTitleInFileNS()
  mw.fileApi
    ✔ isPreviewableFile
    ✔ isPreviewableVideo

Finished in 13.838 secs / 13.587 secs @ 09:54:19 GMT-0500 (Central Daylight Time)

SUMMARY:
✔ 731 tests completed
ℹ 4 tests skipped

Done.

Okay, I was in fact able to run the qunit browser tests from my host once I had a close enough chromium version available.

Cool! Though I think we need to update the Gruntfile.js in core, because eventually CI will have the same problem, when we try to update the Chromium version.

Change 911909 had a related patch set uploaded (by Krinkle; author: Krinkle):

[mediawiki/core@master] build: Avoid extra space in Chromium command via CHROMIUM_FLAGS

https://gerrit.wikimedia.org/r/911909

@Novem_Linguae I verified that @Krinkle's patch above resolves the issue and +2ed it. You should be good to go (at least so far as this issue is concerned) after it merges. Thank you @Krinkle!

example from running with chromium 110.x installed directly on my system:

$ export MW_SCRIPT_PATH=/w; export MW_SERVER=http://localhost:8080; npm run qunit -- --qunit-component=PageTriage

> qunit
> grunt qunit --qunit-component=PageTriage

Running "assert-mw-env" task

Running "karma:main" (karma) task

START:
25 04 2023 12:39:57.983:INFO [karma-server]: Karma v6.4.1 server started at http://localhost:9876/
25 04 2023 12:39:57.984:INFO [launcher]: Launching browsers ChromeCustom with concurrency unlimited
25 04 2023 12:39:57.988:INFO [launcher]: Starting browser ChromeHeadless
25 04 2023 12:39:58.326:INFO [Chrome Headless 94.0.4590.0 (Linux x86_64)]: Connected on socket SxziIhTT6Jxxt7waAAAB with id 26902037
  testrunner
    ✔ beforeEach
    ✔ afterEach
    ✔ Loader status
    ✔ assert.htmlEqual
  testrunner > testrunner-nested-hooks
    ✔ beforeEach
  testrunner-next
    ✔ afterEach
WARN: 'WARNING! mw.pageTriage.actionQueue has been reset.'
  ext.pageTriage.actionQueue
    ✔ Testing the queue: synchronous and asynchronous methods
WARN: 'WARNING! mw.pageTriage.actionQueue has been reset.'
    ✔ Testing the queue: run() with multiple actions
WARN: 'WARNING! mw.pageTriage.actionQueue has been reset.'
    ✔ Testing the queue: run() with actions with action-specific data

Finished in 1.573 secs / 1.562 secs @ 12:40:00 GMT-0500 (Central Daylight Time)

SUMMARY:
✔ 9 tests completed

Done

Change 911909 merged by jenkins-bot:

[mediawiki/core@master] build: Avoid extra space in Chromium command via CHROMIUM_FLAGS

https://gerrit.wikimedia.org/r/911909

That patch helped. The ChromeHeadless error is gone. Thank you very much Krinkle.

I've got a new error though: Warning: Task "karma:main" failed.

image.png (402×1 px, 33 KB)

Looks like you're missing a -- from your command:
Can you try npm run qunit -- --qunit-component=PageTriage?

Looks like you're missing a -- from your command:
Can you try npm run qunit -- --qunit-component=PageTriage?

Same error.

Interestingly, the official documentation at https://www.mediawiki.org/wiki/Manual:JavaScript_unit_testing#Run doesn't have -- in it.

image.png (397×1 px, 34 KB)

I didn't actually say all the things above. Now that I've been playing around with qunit on mediawiki more, I wouldn't expect the above to work, but it might be a hint towards @Novem_Linguae's problem. It looks to me like the shell running qunit also needs to be able to execute mediawiki php code to set things up. Makes me wonder if the issue is there. @Novem_Linguae can you run things like maintenance scripts directly from that mingw shell that you're running qunit from?

Yeah, maintenance scripts run fine from my shell.

image.png (335×807 px, 28 KB)

Hmm, trying to narrow this down: do you see this error on master with no arguments?

Hmm. I think that's what would happen if Special:JavaScriptTest errored out? Not sure off the top of my head how to get output from that.

Neat! you can just navigate to the page:
http://localhost:8080/wiki/Special:JavaScriptTest
Do the tests run correctly there?

Thank you for your ideas and your help. I appreciate your time.

Special:JavaScriptTest wasn't loading at all. So I added $wgEnableJavaScriptTest = true; to my LocalSettings.php and now the page loads.

Getting some errors there. QUnit tests in CLI are still giving same error as before. (From here forward I will use npm run qunit to initiate the tests unless otherwise noted.)

image.png (1×1 px, 144 KB)

image.png (1×1 px, 151 KB)

image.png (1×1 px, 181 KB)

It's no problem! That exception looks related to mobilefrontend. I'd probably start by commenting the LocalSettings load statement for it out (assuming it's in there currently) and see if that clears it up or if it just exposes another exception.

Oh, that sounds like a great hint. I don't have Extension:MobileFrontend installed. Perhaps some QUnit test somewhere needs to be rewritten to not try to use Extension:MobileFrontend if it's not installed. Maybe worth a patch.

Suspicious file in core?

image.png (790×977 px, 60 KB)

The targets key should be fine without mobilefrontend, but this looks like some tests are running that should be skipped. Do you have any extensions installed? If so, I recommend not loading any of them and trying again. If there's an undeclared test dependency between them, this would expose the issue.

It looks to me like the shell running qunit also needs to be able to execute mediawiki php code to set things up.

For posterity: I was wrong about this; mediawiki needs to be running, but it looks to me like qunit can be run from anywhere it can access the live test page, such as when I ran qunit from my host to test mediawiki running in a container.

At some point in our tweaking, the Special:JavaScriptTests page started working fine, even with my extra extensions loaded. The MobileFrontEnd error disappeared.

Anyway, I've turned all my extensions off to be safe. Now I've just got the latest alpha of mediawiki and the latest alpha of vector, with all libraries updated with composer update and npm ci

Still getting the CLI error when doing npm run qunit from the core directory.

Is there a way to make ChromeHeadless be visible? That might provide some more hints.

image.png (397×1 px, 32 KB)

image.png (1×1 px, 152 KB)

You can have headless chrome take screenshots or dump PDFs with arguments/flags, so I imagine you could configure qunit to pass those in.

I figured out how to get my QUnit CLI to be more verbose and output debugging information. Here's the screenshots. If you have any ideas, please feel free to post.

image.png (1×1 px, 70 KB)

image.png (1×1 px, 87 KB)

image.png (919×1 px, 186 KB)

Regarding getting QUnit to work in my Windows CLI, I switched from MINGW64 to PowerShell and I'm getting different error messages now.

image.png (698×1 px, 102 KB)

I manually visited http://localhost/Code/MediaWiki/core/load.php?modules=jquery%7Cmediawiki.base&version=fsdgy in my browser and it is not 404.

Also I set $env:DISPLAY = 1 but no browser is popping up.

I'll just use Special:JavaScriptTest for now, but posting this here in case someone spots my error and has a quick fix :)

Change 918508 had a related patch set uploaded (by Jdlrobson; author: Jdlrobson):

[mediawiki/extensions/PageTriage@master] PackageFiles: ext.pageTriage.util, rewrite qunit as jest

https://gerrit.wikimedia.org/r/918508

Change 918508 merged by jenkins-bot:

[mediawiki/extensions/PageTriage@master] PackageFiles: ext.pageTriage.util, rewrite qunit as jest

https://gerrit.wikimedia.org/r/918508

Jdlrobson claimed this task.
Jdlrobson subscribed.

These now work from the command line. No need to setup QUnit.