CodeMirror6: add new modules, feature flag, and URL query parameter

Add a new $wgCodeMirrorV6 temporary feature flag that when enabled,
will load the 'ext.CodeMirror.v6.WikiEditor' module that is built
against CodeMirror 6. You can also pass in the ?cm6enable=1 query
parameter to force use of CodeMirror 6. This is currently only
implemented for the 2010 editor.

Due to packaging constraints with CodeMirror 6, we now use Webpack to
bundle the files, which are then used by ResourceLoader. This is similar
to what is done for Extension:Popups, MobileFrontend, among other
extensions.

A new generic class CodeMirror can be used on other areas where syntax
highlighting is desirable, but not necessarily for editing (i.e. without
WikiEditor).

This commit merely lays the foundation for CodeMirror 6 and updates
WikiEditor to use it. The actual MediaWiki syntax highlighting will come
with a future commit.

With the new Webpack build, the Gruntfile was removed and the tasks
moved to npm commands.

Bug: T317243
Change-Id: I2239d2449b2db3b638551f847eb4eff1aafa6276
This commit is contained in:
MusikAnimal 2023-09-19 13:59:29 -04:00
parent 1a19f48c70
commit 880c690a10
26 changed files with 29287 additions and 2371 deletions

3
.babelrc Normal file
View file

@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

1
.gitignore vendored
View file

@ -3,6 +3,7 @@
.*.swp
.directory
/node_modules/
/coverage/
/tests/selenium/log/
/vendor/
/composer.lock

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
16.19.1

View file

@ -2,5 +2,8 @@
"extends": "stylelint-config-wikimedia",
"rules": {
"rule-empty-line-before": null
}
},
"ignoreFiles": [
"coverage/**/*.css"
]
}

View file

@ -1,32 +0,0 @@
'use strict';
module.exports = function ( grunt ) {
const conf = grunt.file.readJSON( 'extension.json' );
grunt.loadNpmTasks( 'grunt-banana-checker' );
grunt.loadNpmTasks( 'grunt-eslint' );
grunt.loadNpmTasks( 'grunt-stylelint' );
grunt.initConfig( {
eslint: {
options: {
cache: true,
fix: grunt.option( 'fix' )
},
all: [ '.' ]
},
stylelint: {
all: [
'**/*.{css,less}',
'!resources/lib/**',
'!node_modules/**',
'!vendor/**',
'resources/lib/codemirror-fixes.less'
]
},
banana: conf.MessagesDirs
} );
grunt.registerTask( 'test', [ 'eslint', 'stylelint', 'banana' ] );
grunt.registerTask( 'default', 'test' );
};

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# mediawiki/extensions/CodeMirror
Homepage: https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:CodeMirror
## Development
As part of the [upgrade to CodeMirror 6](https://phabricator.wikimedia.org/T259059),
CodeMirror now uses an asset bundler, so during development you'll need to run a script
to assemble the frontend assets.
Use of CodeMirror 6 is controlled by the `wgCodeMirrorV6` configuration setting, or by
passing in `cm6enable=1` in the URL query string.
You can find the v6 frontend source files in `src/`, the compiled sources in
`resources/dist/`, and other frontend assets managed by ResourceLoader in
`resources/*`.
### Commands
_NOTE: Consider using [Fresh](https://gerrit.wikimedia.org/g/fresh/) to run these tasks._
* `npm install` to install dependencies.
* `npm start` to run the bundler in watch mode, reassembling the files on file change.
You'll want to keep this running in a separate terminal during development.
* `npm run build` to compile the production assets. You *must* run this step before
sending the patch or CI will fail (so that sources and built assets are in sync).
* `npm test` to run the linting tools, JavaScript unit tests, and build checks.
* `npm run test:lint` for linting of JS/LESS/CSS.
* `npm run test:lint:js` for linting of just JavaScript.
* `npm run test:lint:styles` for linting of just Less/CSS.
* `npm run test:i18n` for linting of i18n messages with banana-checker.
* `npm run test:unit` for the new Jest unit tests.
* `npm run selenium-test` for the Selenium tests.
* `npm run test:bundlesize` to test if the gzip'd entrypoint is of acceptable size.
Older QUnit tests are in `resources/mode/mediawiki/tests/qunit/`. These will
eventually be moved over to `tests/qunit` and rewritten for CodeMirror 6.

View file

@ -1,10 +1,11 @@
{
"name": "CodeMirror",
"version": "4.0.0",
"version": "5.0.0",
"author": [
"[https://www.mediawiki.org/wiki/User:Pastakhov Pavel Astakhov]",
"[https://www.mediawiki.org/wiki/User:Florianschmidtwelzow Florian Schmidt]",
"Marijn Haverbeke",
"MusikAnimal",
"[https://raw.githubusercontent.com/codemirror/CodeMirror/master/AUTHORS CodeMirror contributors]"
],
"url": "https://www.mediawiki.org/wiki/Extension:CodeMirror",
@ -19,6 +20,10 @@
"value": null,
"description": "List of namespace IDs where line numbering should be enabled, or `null` to enable for all namespaces. Set to [] to disable everywhere. Defaults to `null` for all namespaces.",
"public": true
},
"CodeMirrorV6": {
"value": false,
"description": "Temporary feature flag for the CodeMirror 6 upgrade."
}
},
"MessagesDirs": {
@ -149,6 +154,23 @@
"messages": [
"codemirror-toggle-label"
]
},
"ext.CodeMirror.v6.WikiEditor": {
"dependencies": [
"web2017-polyfills",
"mediawiki.api",
"mediawiki.user",
"user.options"
],
"packageFiles": [
"dist/main.js"
],
"styles": [
"ext.CodeMirror.v6.WikiEditor.less"
],
"messages": [
"codemirror-toggle-label"
]
}
},
"ResourceFileModulePaths": {
@ -180,7 +202,8 @@
"main": {
"class": "MediaWiki\\Extension\\CodeMirror\\Hooks",
"services": [
"UserOptionsLookup"
"UserOptionsLookup",
"MainConfig"
]
}
},

View file

@ -18,15 +18,21 @@ class Hooks implements
{
/** @var UserOptionsLookup */
private $userOptionsLookup;
private UserOptionsLookup $userOptionsLookup;
/** @var bool */
private bool $useV6;
/**
* @param UserOptionsLookup $userOptionsLookup
* @param Config $config
*/
public function __construct(
UserOptionsLookup $userOptionsLookup
UserOptionsLookup $userOptionsLookup,
Config $config
) {
$this->userOptionsLookup = $userOptionsLookup;
$this->useV6 = $config->get( 'CodeMirrorV6' );
}
/**
@ -35,7 +41,7 @@ class Hooks implements
* @param OutputPage $out
* @return bool
*/
private function isCodeMirrorOnPage( OutputPage $out ) {
private function isCodeMirrorOnPage( OutputPage $out ): bool {
// Disable CodeMirror when CodeEditor is active on this page
// Depends on ext.codeEditor being added by \MediaWiki\EditPage\EditPage::showEditForm:initial
if ( in_array( 'ext.codeEditor', $out->getModules(), true ) ) {
@ -60,7 +66,14 @@ class Hooks implements
* @return void This hook must not abort, it must return no value
*/
public function onBeforePageDisplay( $out, $skin ): void {
if ( $this->isCodeMirrorOnPage( $out ) ) {
if ( !$this->isCodeMirrorOnPage( $out ) ) {
return;
}
// TODO: remove check for cm6enable flag after migration is complete
if ( $this->useV6 || $out->getRequest()->getRawVal( 'cm6enable' ) ) {
$out->addModules( 'ext.CodeMirror.v6.WikiEditor' );
} else {
$out->addModules( 'ext.CodeMirror.WikiEditor' );
if ( $this->userOptionsLookup->getOption( $out->getUser(), 'usecodemirror' ) ) {

53
jest.config.js Normal file
View file

@ -0,0 +1,53 @@
'use strict';
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files fo
// which coverage information should be collected
collectCoverageFrom: [
'tests/jest/*.js'
],
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: [
'/node_modules/'
],
// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
branches: 31,
functions: 39,
lines: 38,
statements: 38
}
},
// An array of file extensions your modules use
moduleFileExtensions: [
'js'
],
// The paths to modules that run some code to configure or
// set up the testing environment before each test
setupFiles: [
'./tests/jest/setup.js'
],
// Simulates a real browser environment.
testEnvironment: 'jsdom',
// Ignore these directories when locating tests to run.
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/resources/'
]
};

30748
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,55 @@
{
"name": "CodeMirror",
"name": "codemirror",
"private": true,
"scripts": {
"test": "grunt test",
"start": "webpack -w --mode=development",
"build": "webpack --mode=production",
"test": "npm run test:lint && npm run test:unit && npm run check-built-assets && bundlesize",
"test:lint": "npm run test:lint:styles && npm run test:lint:js && npm run test:lint:i18n",
"test:lint:js": "eslint --cache .",
"test:lint:styles": "stylelint \"resources/**/*.less\"",
"test:lint:i18n": "banana-checker i18n/",
"test:unit": "jest",
"test:bundlesize": "bundlesize",
"check-built-assets": "{ git status src/ | grep \"nothing to commit, working tree clean\"; } && { echo 'CHECKING BUILD SOURCES ARE COMMITTED' && npm run build && git status resources/dist/ | grep \"nothing to commit, working tree clean\" || { npm run node-debug; false; }; }",
"node-debug": "node -v && npm -v && echo 'ERROR: Please ensure that production assets have been built with `npm run build` and commited, and that you are using the correct version of Node/NPM.'",
"selenium-test": "wdio tests/selenium/wdio.conf.js"
},
"engines": {
"node": "16.19.1"
},
"devDependencies": {
"@babel/core": "7.22.20",
"@babel/plugin-transform-runtime": "7.22.15",
"@babel/preset-env": "7.2.0",
"@codemirror/commands": "6.2.5",
"@codemirror/search": "6.5.4",
"@codemirror/state": "6.2.1",
"@codemirror/view": "6.18.1",
"@wdio/cli": "7.30.1",
"@wdio/junit-reporter": "7.29.1",
"@wdio/local-runner": "7.30.1",
"@wdio/mocha-framework": "7.26.0",
"@wdio/spec-reporter": "7.29.1",
"@wikimedia/mw-node-qunit": "7.2.0",
"babel-loader": "8.0.4",
"bundlesize": "0.18.1",
"clean-webpack-plugin": "3.0.0",
"dotenv": "8.2.0",
"eslint-config-wikimedia": "0.25.1",
"grunt": "1.6.1",
"grunt-banana-checker": "0.11.0",
"grunt-eslint": "24.3.0",
"grunt-stylelint": "0.19.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jquery": "3.7.1",
"stylelint-config-wikimedia": "0.16.1",
"wdio-mediawiki": "2.3.0"
}
"wdio-mediawiki": "2.3.0",
"webpack": "4.47.0",
"webpack-cli": "3.3.12"
},
"bundlesize": [
{
"path": "resources/dist/main.js",
"maxSize": "100.0kB"
}
]
}

View file

@ -22,5 +22,6 @@
"indent": [ "error", 2 ]
}
}
]
],
"ignorePatterns": [ "dist/" ]
}

2
resources/dist/main.js vendored Normal file

File diff suppressed because one or more lines are too long

1
resources/dist/main.js.map.json vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -215,8 +215,8 @@ function init() {
// Allow textSelection() functions to work with CodeMirror editing field.
$codeMirror.textSelection( 'register', cmTextSelection );
// Also override textSelection() functions for the "real" hidden textarea to route to
// CodeMirror. We unregister this when switching to normal textarea mode.
// Also override textSelection() functions for the "real" hidden textarea to route to CodeMirror.
// We unregister this when switching to normal textarea mode.
$textbox1.textSelection( 'register', cmTextSelection );
setupSizing( $textbox1, codeMirror );

View file

@ -0,0 +1,3 @@
.cm-editor {
height: 100%;
}

18
src/.eslintrc.json Normal file
View file

@ -0,0 +1,18 @@
{
"root": true,
"extends": [
"wikimedia/client-es6",
"wikimedia/jquery",
"wikimedia/mediawiki"
],
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"commonjs": true
},
"rules": {
"es-x/no-array-prototype-includes": 0
}
}

157
src/codemirror.js Normal file
View file

@ -0,0 +1,157 @@
import { EditorState, Extension } from '@codemirror/state';
import { EditorView, lineNumbers } from '@codemirror/view';
/**
* @class CodeMirror
*/
export default class CodeMirror {
/**
* @constructor
* @param {jQuery} $textarea Textarea to add syntax highlighting to.
*/
constructor( $textarea ) {
this.$textarea = $textarea;
this.view = null;
this.state = null;
}
/**
* Extensions here should be applicable to all theoretical uses of CodeMirror in MediaWiki.
* Don't assume CodeMirror is used for editing (i.e. "View source" of a protected page).
* Subclasses are safe to override this method if needed.
*
* @return {Extension[]}
*/
get defaultExtensions() {
const extensions = [];
const namespaces = mw.config.get( 'wgCodeMirrorLineNumberingNamespaces' );
// Set to [] to disable everywhere, or null to enable everywhere
if ( !namespaces || namespaces.includes( mw.config.get( 'wgNamespaceNumber' ) ) ) {
extensions.push( lineNumbers() );
}
return extensions;
}
/**
* Setup CodeMirror and add it to the DOM. This will hide the original textarea.
*
* @param {Extension[]} extensions
*/
initialize( extensions = this.defaultExtensions ) {
// Set up the initial EditorState of CodeMirror with contents of the native textarea.
this.state = EditorState.create( {
doc: this.$textarea.textSelection( 'getContents' ),
extensions
} );
// Add CodeMirror view to the DOM.
this.view = new EditorView( {
state: this.state,
parent: this.$textarea.parent()[ 0 ]
} );
// Hide native textarea and sync CodeMirror contents upon submission.
this.$textarea.hide();
if ( this.$textarea[ 0 ].form ) {
this.$textarea[ 0 ].form.addEventListener( 'submit', () => {
this.$textarea.val( this.view.state.doc.toString() );
} );
}
// Register $.textSelection() on the .cm-editor element.
$( this.view.dom ).textSelection( 'register', this.cmTextSelection );
// Also override textSelection() functions for the "real" hidden textarea to route to
// CodeMirror. We unregister this when switching to normal textarea mode.
this.$textarea.textSelection( 'register', this.cmTextSelection );
mw.hook( 'ext.CodeMirror.switch' ).fire( true, $( this.view.dom ) );
}
/**
* Log usage of CodeMirror.
*
* @param {Object} data
*/
logUsage( data ) {
/* eslint-disable camelcase */
const event = Object.assign( {
session_token: mw.user.sessionId(),
user_id: mw.user.getId()
}, data );
const editCountBucket = mw.config.get( 'wgUserEditCountBucket' );
if ( editCountBucket !== null ) {
event.user_edit_count_bucket = editCountBucket;
}
/* eslint-enable camelcase */
mw.track( 'event.CodeMirrorUsage', event );
}
/**
* Save CodeMirror enabled preference.
*
* @param {boolean} prefValue True, if CodeMirror should be enabled by default, otherwise false.
*/
setCodeMirrorPreference( prefValue ) {
if ( !mw.user.isNamed() ) { // Skip it for unnamed users
return;
}
new mw.Api().saveOption( 'usecodemirror', prefValue ? 1 : 0 );
mw.user.options.set( 'usecodemirror', prefValue ? 1 : 0 );
}
/**
* jQuery.textSelection overrides for CodeMirror.
*
* @see https://doc.wikimedia.org/mediawiki-core/master/js/#!/api/jQuery.plugin.textSelection
* @return {Object}
*/
get cmTextSelection() {
const $cmDom = $( this.view.dom );
return {
getContents: () => this.view.state.doc.toString(),
setContents: ( content ) => {
this.view.dispatch( {
changes: {
from: 0,
to: this.view.state.doc.length,
insert: content
}
} );
return $cmDom;
},
getSelection: () => {
return this.view.state.sliceDoc(
this.view.state.selection.main.from,
this.view.state.selection.main.to
);
},
setSelection: ( options = { start: 0, end: 0 } ) => {
this.view.dispatch( {
selection: { anchor: options.start, head: ( options.end || options.start ) }
} );
this.view.focus();
return $cmDom;
},
replaceSelection: ( value ) => {
this.view.dispatch(
this.view.state.replaceSelection( value )
);
return $cmDom;
},
getCaretPosition: ( options ) => {
if ( !options.startAndEnd ) {
return this.view.state.selection.main.head;
}
return [
this.view.state.selection.main.from,
this.view.state.selection.main.to
];
},
scrollToCaretPosition: () => {
this.view.scrollIntoView( this.view.state.selection.main.head );
return $cmDom;
}
};
}
}

View file

@ -0,0 +1,8 @@
import CodeMirrorWikiEditor from './codemirror.wikieditor';
if ( mw.loader.getState( 'ext.wikiEditor' ) ) {
mw.hook( 'wikiEditor.toolbarReady' ).add( ( $textarea ) => {
const cmWE = new CodeMirrorWikiEditor( $textarea );
cmWE.addCodeMirrorToWikiEditor();
} );
}

View file

@ -0,0 +1,190 @@
import CodeMirror from './codemirror';
import { EditorSelection } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { searchKeymap } from '@codemirror/search';
/**
* @class CodeMirrorWikiEditor
*/
export default class CodeMirrorWikiEditor extends CodeMirror {
constructor( $textarea ) {
super( $textarea );
this.realtimePreviewHandler = null;
this.useCodeMirror = mw.user.options.get( 'usecodemirror' ) > 0;
this.editRecoveryHandler = null;
}
/**
* @inheritDoc
*/
setCodeMirrorPreference( prefValue ) {
this.useCodeMirror = prefValue; // Save state for function updateToolbarButton()
super.setCodeMirrorPreference( prefValue );
}
/**
* Replaces the default textarea with CodeMirror
*/
enableCodeMirror() {
// If CodeMirror is already loaded or wikEd gadget is enabled, abort. See T178348.
// FIXME: Would be good to replace the wikEd check with something more generic.
if ( this.view || mw.user.options.get( 'gadget-wikEd' ) > 0 ) {
return;
}
const selectionStart = this.$textarea.prop( 'selectionStart' ),
selectionEnd = this.$textarea.prop( 'selectionEnd' ),
scrollTop = this.$textarea.scrollTop(),
hasFocus = this.$textarea.is( ':focus' );
/*
* Default configuration, which we may conditionally add to later.
* @see https://codemirror.net/docs/ref/#state.Extension
*/
const extensions = [
...this.defaultExtensions,
history(),
// These are HTML attributes on the .cm-content element.
EditorView.contentAttributes.of( {
spellcheck: 'true',
// T259347: Use accesskey of the original textbox
accesskey: this.$textarea.attr( 'accesskey' )
} ),
EditorView.domEventHandlers( {
blur: () => this.$textarea.triggerHandler( 'blur' ),
focus: () => this.$textarea.triggerHandler( 'focus' )
} ),
EditorView.updateListener.of( ( update ) => {
if ( update.docChanged && typeof this.editRecoveryHandler === 'function' ) {
this.editRecoveryHandler();
}
} ),
EditorView.lineWrapping,
keymap.of( [
...defaultKeymap,
...searchKeymap,
...historyKeymap
] )
];
mw.hook( 'editRecovery.loadEnd' ).add( ( data ) => {
this.editRecoveryHandler = data.fieldChangeHandler;
} );
this.initialize( extensions );
// Sync scroll position, selections, and focus state.
this.view.scrollDOM.scrollTop = scrollTop;
this.view.dispatch( {
selection: EditorSelection.create( [
EditorSelection.range( selectionStart, selectionEnd )
] )
} );
if ( hasFocus ) {
this.view.focus();
}
}
/**
* Adds the CodeMirror button to WikiEditor
*/
addCodeMirrorToWikiEditor() {
const context = this.$textarea.data( 'wikiEditor-context' );
const toolbar = context && context.modules && context.modules.toolbar;
// Guard against something having removed WikiEditor (T271457)
if ( !toolbar ) {
return;
}
this.$textarea.wikiEditor(
'addToToolbar',
{
section: 'main',
groups: {
codemirror: {
tools: {
CodeMirror: {
label: mw.msg( 'codemirror-toggle-label' ),
type: 'toggle',
oouiIcon: 'highlight',
action: {
type: 'callback',
execute: () => this.switchCodeMirror()
}
}
}
}
}
}
);
const $codeMirrorButton = toolbar.$toolbar.find( '.tool[rel=CodeMirror]' );
$codeMirrorButton
.attr( 'id', 'mw-editbutton-codemirror' );
if ( this.useCodeMirror ) {
this.enableCodeMirror();
}
this.updateToolbarButton();
this.logUsage( {
editor: 'wikitext',
enabled: this.useCodeMirror,
toggled: false,
// eslint-disable-next-line no-jquery/no-global-selector,camelcase
edit_start_ts_ms: parseInt( $( 'input[name="wpStarttime"]' ).val(), 10 ) * 1000 || 0
} );
}
/**
* Updates CodeMirror button on the toolbar according to the current state (on/off)
*/
updateToolbarButton() {
// eslint-disable-next-line no-jquery/no-global-selector
const $button = $( '#mw-editbutton-codemirror' );
$button.toggleClass( 'mw-editbutton-codemirror-active', this.useCodeMirror );
// WikiEditor2010 OOUI ToggleButtonWidget
if ( $button.data( 'setActive' ) ) {
$button.data( 'setActive' )( this.useCodeMirror );
}
}
/**
* Enables or disables CodeMirror
*/
switchCodeMirror() {
if ( this.view ) {
this.setCodeMirrorPreference( false );
const scrollTop = this.view.scrollDOM.scrollTop;
const hasFocus = this.view.hasFocus;
const { from, to } = this.view.state.selection.ranges[ 0 ];
$( this.view.dom ).textSelection( 'unregister' );
this.$textarea.textSelection( 'unregister' );
this.view.destroy();
this.view = null;
this.$textarea.show();
if ( hasFocus ) {
this.$textarea.trigger( 'focus' );
}
this.$textarea.prop( 'selectionStart', Math.min( from, to ) )
.prop( 'selectionEnd', Math.max( to, from ) );
this.$textarea.scrollTop( scrollTop );
mw.hook( 'ext.CodeMirror.switch' ).fire( false, this.$textbox1 );
} else {
this.enableCodeMirror();
this.setCodeMirrorPreference( true );
}
this.updateToolbarButton();
this.logUsage( {
editor: 'wikitext',
enabled: this.useCodeMirror,
toggled: true,
// eslint-disable-next-line no-jquery/no-global-selector,camelcase
edit_start_ts_ms: parseInt( $( 'input[name="wpStarttime"]' ).val(), 10 ) * 1000 || 0
} );
}
}

13
tests/jest/.eslintrc.json Normal file
View file

@ -0,0 +1,13 @@
{
"extends": [
"wikimedia/jquery",
"wikimedia/mediawiki"
],
"parserOptions": {
"sourceType": "module"
},
"env": {
"browser": true,
"jest": true
}
}

View file

@ -0,0 +1,77 @@
const { EditorView } = require( '@codemirror/view' );
const CodeMirror = require( '../../src/codemirror.js' ).default;
const $textarea = $( '<textarea>' ),
cm = new CodeMirror( $textarea );
describe( 'initialize', () => {
const initializeWithForm = () => {
const form = document.createElement( 'form' );
form.append( cm.$textarea[ 0 ] );
cm.$textarea[ 0 ].form.addEventListener = jest.fn();
cm.initialize();
};
it( 'should create the EditorState with the value of the textarea', () => {
cm.$textarea.val( 'foobar' );
cm.$textarea.textSelection = jest.fn().mockReturnValue( 'foobar' );
cm.initialize();
expect( cm.view.state.doc.toString() ).toStrictEqual( 'foobar' );
} );
it( 'should instantiate an EditorView and add .cm-editor to the DOM', () => {
initializeWithForm();
expect( cm.view ).toBeInstanceOf( EditorView );
expect( cm.view.dom ).toBeInstanceOf( HTMLDivElement );
expect( cm.$textarea[ 0 ].nextSibling ).toStrictEqual( cm.view.dom );
} );
it( 'should hide the native textarea', () => {
cm.initialize();
expect( cm.$textarea[ 0 ].style.display ).toStrictEqual( 'none' );
} );
it( 'should add a listener for form submission', () => {
initializeWithForm();
expect( cm.$textarea[ 0 ].form.addEventListener ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'logUsage', () => {
it( 'should track usage of CodeMirror with the correct data', () => {
cm.logUsage( {
editor: 'wikitext',
enabled: true,
toggled: false
} );
expect( mw.track ).toBeCalledWith( 'event.CodeMirrorUsage', {
editor: 'wikitext',
enabled: true,
// eslint-disable-next-line camelcase
session_token: 'abc',
toggled: false,
// eslint-disable-next-line camelcase
user_edit_count_bucket: '1000+ edits',
// eslint-disable-next-line camelcase
user_id: 123
} );
} );
} );
describe( 'setCodeMirrorPreference', () => {
beforeEach( () => {
cm.initialize();
} );
it( 'should save using the API with the correct value', () => {
cm.setCodeMirrorPreference( true );
expect( mw.Api.prototype.saveOption ).toHaveBeenCalledWith( 'usecodemirror', 1 );
expect( mw.user.options.set ).toHaveBeenCalledWith( 'usecodemirror', 1 );
} );
it( 'should not save preferences if the user is not named', () => {
mw.user.isNamed = jest.fn().mockReturnValue( false );
cm.setCodeMirrorPreference( true );
expect( mw.Api.prototype.saveOption ).toHaveBeenCalledTimes( 0 );
expect( mw.user.options.set ).toHaveBeenCalledTimes( 0 );
} );
} );

View file

@ -0,0 +1,55 @@
mw.loader = {
getState: jest.fn().mockReturnValue( '1' )
};
const CodeMirrorWikiEditor = require( '../../src/codemirror.wikieditor.js' ).default,
$textarea = $( '<textarea>' )
.text( 'The Smashing Pumpkins' ),
cmWe = new CodeMirrorWikiEditor( $textarea );
beforeEach( () => {
// Simulate the button that enables/disables CodeMirror as WikiEditor doesn't exist here.
const btn = document.createElement( 'span' );
btn.id = 'mw-editbutton-codemirror';
btn.classList.add( 'tool' );
btn.setAttribute( 'rel', 'CodeMirror' );
document.body.appendChild( btn );
// Add WikiEditor context to the textarea.
cmWe.$textarea.data = jest.fn().mockReturnValue( {
modules: {
toolbar: {
$toolbar: $( btn )
}
}
} );
// Initialize CodeMirror.
cmWe.initialize();
} );
describe( 'addCodeMirrorToWikiEditor', () => {
cmWe.$textarea.wikiEditor = jest.fn();
it( 'should add the button to the toolbar', () => {
cmWe.addCodeMirrorToWikiEditor();
expect( cmWe.$textarea.wikiEditor ).toHaveBeenCalledWith(
'addToToolbar',
expect.objectContaining( {
groups: { codemirror: expect.any( Object ) }
} )
);
} );
} );
describe( 'updateToolbarButton', () => {
it( 'should update the toolbar button based on the current CodeMirror state', () => {
const btn = document.getElementById( 'mw-editbutton-codemirror' );
cmWe.setCodeMirrorPreference( false );
cmWe.updateToolbarButton();
expect( btn.classList.contains( 'mw-editbutton-codemirror-active' ) ).toBeFalsy();
cmWe.setCodeMirrorPreference( true );
cmWe.updateToolbarButton();
expect( btn.classList.contains( 'mw-editbutton-codemirror-active' ) ).toBeTruthy();
} );
} );

17
tests/jest/setup.js Normal file
View file

@ -0,0 +1,17 @@
global.mw = require( '@wikimedia/mw-node-qunit/src/mockMediaWiki.js' )();
mw.user = Object.assign( mw.user, {
options: {
// Only called for 'usecodemirror' option.
get: jest.fn().mockReturnValue( 1 ),
set: jest.fn()
},
sessionId: jest.fn().mockReturnValue( 'abc' ),
getId: jest.fn().mockReturnValue( 123 ),
isNamed: jest.fn().mockReturnValue( true )
} );
mw.config.get = jest.fn().mockReturnValue( '1000+ edits' );
mw.track = jest.fn();
mw.Api.prototype.saveOption = jest.fn();
global.$ = require( 'jquery' );
$.fn.textSelection = () => {};

View file

@ -3,10 +3,14 @@
namespace MediaWiki\Extension\CodeMirror\Tests;
use MediaWiki\Extension\CodeMirror\Hooks;
use MediaWiki\MediaWikiServices;
use MediaWiki\Request\WebRequest;
use MediaWiki\Title\Title;
use MediaWiki\User\UserOptionsLookup;
use MediaWikiIntegrationTestCase;
use OutputPage;
use RequestContext;
use Skin;
/**
* @group CodeMirror
@ -18,21 +22,45 @@ class HookTest extends MediaWikiIntegrationTestCase {
/**
* @covers ::isCodeMirrorOnPage
* @covers ::onBeforePageDisplay
* @param bool $useCodeMirrorV6
* @param int $expectedAddModuleCalls
* @param string $expectedFirstModule
* @dataProvider provideOnBeforePageDisplay
*/
public function testOnBeforePageDisplay() {
public function testOnBeforePageDisplay(
bool $useCodeMirrorV6, int $expectedAddModuleCalls, string $expectedFirstModule
) {
$this->overrideConfigValues( [
'CodeMirrorV6' => $useCodeMirrorV6,
] );
$userOptionsLookup = $this->createMock( UserOptionsLookup::class );
$userOptionsLookup->method( 'getOption' )->willReturn( true );
$this->setService( 'UserOptionsLookup', $userOptionsLookup );
$out = $this->createMock( \OutputPage::class );
$out = $this->createMock( OutputPage::class );
$out->method( 'getModules' )->willReturn( [] );
$out->method( 'getUser' )->willReturn( $this->createMock( \User::class ) );
$out->method( 'getActionName' )->willReturn( 'edit' );
$out->method( 'getTitle' )->willReturn( Title::makeTitle( NS_MAIN, __METHOD__ ) );
$out->expects( $this->exactly( 2 ) )->method( 'addModules' );
$request = $this->createMock( WebRequest::class );
$request->method( 'getRawVal' )->willReturn( null );
$out->method( 'getRequest' )->willReturn( $request );
$out->expects( $this->exactly( $expectedAddModuleCalls ) )
->method( 'addModules' )
->withConsecutive( [ $this->equalTo( $expectedFirstModule ) ] );
( new Hooks( $userOptionsLookup ) )
->onBeforePageDisplay( $out, $this->createMock( \Skin::class ) );
( new Hooks( $userOptionsLookup, MediaWikiServices::getInstance()->getMainConfig() ) )
->onBeforePageDisplay( $out, $this->createMock( Skin::class ) );
}
/**
* @return array[]
*/
public function provideOnBeforePageDisplay(): array {
return [
[ false, 2, 'ext.CodeMirror.WikiEditor' ],
[ true, 1, 'ext.CodeMirror.v6.WikiEditor' ]
];
}
/**

98
webpack.config.js Normal file
View file

@ -0,0 +1,98 @@
'use strict';
/* eslint-env node */
const { CleanWebpackPlugin } = require( 'clean-webpack-plugin' ),
path = require( 'path' ),
distDir = path.resolve( __dirname, 'resources/dist' ),
srcMapExt = '.map.json',
PUBLIC_PATH = '/w/extensions/CodeMirror';
module.exports = ( env, argv ) => ( {
// Apply the rule of silence: https://wikipedia.org/wiki/Unix_philosophy.
stats: {
all: false,
// Output a timestamp when a build completes. Useful when watching files.
builtAt: true,
errors: true,
warnings: true
},
// Fail on the first build error instead of tolerating it for prod builds. This seems to
// correspond to optimization.noEmitOnErrors.
bail: argv.mode === 'production',
// Specify that all paths are relative the Webpack configuration directory not the current
// working directory.
context: __dirname,
entry: './src/codemirror.wikieditor.init.js',
module: {
rules: [ {
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
// Beware of https://github.com/babel/babel-loader/issues/690. Changes to browsers require
// manual invalidation.
cacheDirectory: true
}
}
} ]
},
optimization: {
// Don't produce production output when a build error occurs.
noEmitOnErrors: argv.mode === 'production',
// Use filenames instead of unstable numerical identifiers for file references. This
// increases the gzipped bundle size some but makes the build products easier to debug and
// appear deterministic. I.e., code changes will only alter the bundle they're packed in
// instead of shifting the identifiers in other bundles.
// https://webpack.js.org/guides/caching/#deterministic-hashes (namedModules replaces NamedModulesPlugin.)
moduleIds: 'named'
},
output: {
// Specify the destination of all build products.
path: distDir,
// Store outputs per module in files named after the modules. For the JavaScript entry
// itself, append .js to each ResourceLoader module entry name. This value is tightly
// coupled to sourceMapFilename.
filename: '[name].js',
// Rename source map extensions. Per T173491 files with a .map extension cannot be served
// from prod.
sourceMapFilename: `[file]${srcMapExt}`,
devtoolModuleFilenameTemplate: `${PUBLIC_PATH}/[resource-path]`
},
// Accurate source maps at the expense of build time. The source map is intentionally exposed
// to users via sourceMapFilename for prod debugging. This goes against convention as source
// code is publicly distributed.
devtool: 'source-map',
plugins: [
// Delete the output directory on each build.
new CleanWebpackPlugin( {
cleanOnceBeforeBuildPatterns: [ '**/*', '!.eslintrc.json' ]
} )
],
performance: {
// Size violations for prod builds fail; development builds are unchecked.
hints: argv.mode === 'production' ? 'error' : false,
// Minified uncompressed size limits for chunks / assets and entrypoints. Keep these numbers
// up-to-date and rounded to the nearest 10th of a kibibyte so that code sizing costs are
// well understood. Related to bundlesize minified, gzipped compressed file size tests.
maxAssetSize: 300.0 * 1024,
maxEntrypointSize: 300.0 * 1024,
// The default filter excludes map files, but we rename ours.
assetFilter: ( filename ) => !filename.endsWith( srcMapExt )
}
} );