Debug console module

* Added a debug console to the edit page, allowing unsaved modules to be
  tested.
* Removed the "preview" button from the edit page.
* Only show the "ignore code errors" checkbox on module edit pages, not
  all edit pages.
* Added Lua function mw.log() for sending messages to the debug log.

Change-Id: Ia51f439e573a1deb5b83f94ddd1a86792d5569c1
This commit is contained in:
Tim Starling 2012-07-14 14:23:42 +10:00
parent 16e9eba133
commit b5c36bad59
10 changed files with 733 additions and 10 deletions

View file

@ -22,6 +22,18 @@ $messages['en'] = array(
'scribunto-error-long' => 'Script errors:
$1',
'scribunto-console-intro' => '* The module exports are available as the variable "p", including unsaved modifications.
* Precede a line with "=" to evaluate it as an expression, or use print().
* Use mw.log() in module code to send messages to this console.',
'scribunto-console-title' => 'Debug console',
'scribunto-console-too-large' => 'This console session is too large. Please clear the console history or reduce the size of the module.',
'scribunto-console-current-src' => 'console input',
'scribunto-console-previous-src' => 'previous console input',
'scribunto-console-clear' => 'Clear',
'scribunto-console-cleared' => 'The console state was cleared because the module was updated.',
'scribunto-common-nosuchmodule' => 'Script error: No such module.',
'scribunto-common-nofunction' => 'Script error: You must specify a function to call.',
'scribunto-common-nosuchfunction' => 'Script error: The function you specified did not exist.',

View file

@ -41,6 +41,7 @@ $wgAutoloadClasses['ScribuntoModuleBase'] = $dir.'common/Base.php';
$wgAutoloadClasses['ScribuntoHooks'] = $dir.'common/Hooks.php';
$wgAutoloadClasses['ScribuntoException'] = $dir.'common/Common.php';
$wgAutoloadClasses['Scribunto'] = $dir.'common/Common.php';
$wgAutoloadClasses['ApiScribuntoConsole'] = $dir.'common/ApiScribuntoConsole.php';
$wgHooks['ParserFirstCallInit'][] = 'ScribuntoHooks::setupParserHook';
$wgHooks['ParserLimitReport'][] = 'ScribuntoHooks::reportLimits';
@ -51,6 +52,7 @@ $wgHooks['ArticleViewCustom'][] = 'ScribuntoHooks::handleScriptView';
$wgHooks['TitleIsWikitextPage'][] = 'ScribuntoHooks::isWikitextPage';
$wgHooks['CodeEditorGetPageLanguage'][] = 'ScribuntoHooks::getCodeLanguage';
$wgHooks['EditPageBeforeEditChecks'][] = 'ScribuntoHooks::beforeEditChecks';
$wgHooks['EditPageBeforeEditButtons'][] = 'ScribuntoHooks::beforeEditButtons';
$wgHooks['EditFilterMerged'][] = 'ScribuntoHooks::validateScript';
$wgHooks['UnitTestsList'][] = 'ScribuntoHooks::unitTestsList';
@ -58,15 +60,30 @@ $wgParserTestFiles[] = $dir . 'tests/engines/LuaCommon/luaParserTests.txt';
$wgParserOutputHooks['ScribuntoError'] = 'ScribuntoHooks::parserOutputHook';
$wgResourceModules['ext.scribunto'] = array(
$sbtpl = array(
'localBasePath' => dirname( __FILE__ ) . '/modules',
'remoteExtPath' => 'Scribunto/modules',
);
$wgResourceModules['ext.scribunto'] = $sbtpl + array(
'scripts' => 'ext.scribunto.js',
'dependencies' => array( 'jquery.ui.dialog' ),
'messages' => array(
'scribunto-parser-dialog-title'
),
);
$wgResourceModules['ext.scribunto.edit'] = $sbtpl + array(
'scripts' => 'ext.scribunto.edit.js',
'styles' => 'ext.scribunto.edit.css',
'dependencies' => array( 'ext.scribunto' ),
'messages' => array(
'scribunto-console-title',
'scribunto-console-intro',
'scribunto-console-clear',
'scribunto-console-cleared',
),
);
$wgAPIModules['scribunto-console'] = 'ApiScribuntoConsole';
/***** Individual engines and their configurations *****/

View file

@ -0,0 +1,153 @@
<?php
/**
* API module for serving debug console requests on the edit page
*/
class ApiScribuntoConsole extends ApiBase {
const SC_MAX_SIZE = 500000;
const SC_SESSION_EXPIRY = 3600;
public function execute() {
$params = $this->extractRequestParams();
$title = Title::newFromText( $params['title'] );
if ( !$title ) {
$this->dieUsageMsg( array( 'invalidtitle', $params['title'] ) );
}
if ( $params['session'] ) {
$sessionId = $params['session'];
} else {
$sessionId = mt_rand( 0, 0x7fffffff );
}
global $wgUser;
$sessionKey = wfMemcKey( 'scribunto-console', $wgUser->getId(), $sessionId );
$cache = ObjectCache::getInstance( CACHE_ANYTHING );
$session = null;
if ( $params['session'] ) {
$session = $cache->get( $sessionKey );
}
if ( !isset( $session['version'] ) ) {
$session = $this->newSession();
}
// Create a variable holding the session which will be stored if there
// are no errors. If there are errors, we don't want to store the current
// question to the state builder array, since that will cause subsequent
// requests to fail.
$newSession = $session;
if ( !empty( $params['clear'] ) ) {
$newSession['size'] -= strlen( implode( '', $newSession['questions'] ) );
$newSession['questions'] = array();
$session['questions'] = array();
}
if ( strlen( $params['question'] ) ) {
$newSession['size'] += strlen( $params['question'] );
$newSession['questions'][] = $params['question'];
}
if ( $params['content'] ) {
$newSession['size'] += strlen( $params['content'] ) - strlen( $newSession['content'] );
$newSession['content'] = $params['content'];
}
if ( $newSession['size'] > self::SC_MAX_SIZE ) {
$this->dieUsage( wfMsg( 'scribunto-console-too-large' ), 'scribunto-console-too-large' );
}
$result = $this->runConsole( array(
'title' => $title,
'content' => $newSession['content'],
'prevQuestions' => $session['questions'],
'question' => $params['question'] ) );
if ( $result['type'] === 'error' ) {
// Restore the questions array
$newSession['questions'] = $session['questions'];
}
$cache->set( $sessionKey, $newSession, self::SC_SESSION_EXPIRY );
$result['session'] = $sessionId;
$result['sessionSize'] = $newSession['size'];
$result['sessionMaxSize'] = self::SC_MAX_SIZE;
foreach ( $result as $key => $value ) {
$this->getResult()->addValue( null, $key, $value );
}
}
protected function runConsole( $params ) {
global $wgParser;
$options = new ParserOptions;
$options->setTemplateCallback( array( $this, 'templateCallback' ) );
$wgParser->startExternalParse( $params['title'], $options, Parser::OT_HTML, true );
$engine = Scribunto::getParserEngine( $wgParser );
try {
$result = $engine->runConsole( $params );
} catch ( ScribuntoException $e ) {
$trace = $e->getScriptTraceHtml();
$message = $e->getMessage();
$html = Html::element( 'p', array(), $message );
if ( $trace !== false ) {
$html .= Html::element( 'p', array(), wfMsgForContent( 'scribunto-common-backtrace' ) ) . $trace;
}
return array(
'type' => 'error',
'html' => $html,
'message' => $message,
'messagename' => $e->getMessageName() );
}
return array(
'type' => 'normal',
'print' => strval( $result['print'] ),
'return' => strval( $result['return'] )
);
}
protected function newSession() {
return array(
'content' => '',
'questions' => array(),
'size' => 0,
'version' => 1,
);
}
public function getAllowedParams() {
return array(
'title' => array(
ApiBase::PARAM_TYPE => 'string',
),
'content' => array(
ApiBase::PARAM_TYPE => 'string'
),
'session' => array(
ApiBase::PARAM_TYPE => 'integer',
),
'question' => array(
ApiBase::PARAM_TYPE => 'string',
ApiBase::PARAM_REQUIRED => true,
),
'clear' => array(
ApiBase::PARAM_TYPE => 'boolean',
),
);
}
public function getParamDescription() {
return array(
'title' => 'The module title to test',
'content' => 'The new content of the module',
'question' => 'The next line to evaluate as a script',
'clear' => 'Set this to true to clear the current session state',
);
}
public function getDescription() {
return 'Internal module for servicing XHR requests from the Scribunto console';
}
public function getVersion() {
return __CLASS__.': 1';
}
}

View file

@ -38,6 +38,21 @@ abstract class ScribuntoEngineBase {
*/
abstract protected function newModule( $text, $chunkName );
/**
* Run an interactive console request
*
* @param $params Associative array. Options are:
* - title: The title object for the module being debugged
* - content: The text content of the module
* - precedingQuestions: An array of previous "questions" used to establish the state
* - question: The current "question", a string script
*
* @return array containing:
* - print: The resulting print buffer
* - return: The resulting return value
*/
abstract function runConsole( $params );
/**
* Constructor.
*

View file

@ -213,6 +213,10 @@ class ScribuntoHooks {
* @param $tabindex Current tabindex
*/
public static function beforeEditChecks( &$editor, &$checkboxes, &$tabindex ) {
if ( $editor->getTitle()->getNamespace() !== NS_MODULE ) {
return true;
}
$req = RequestContext::getMain()->getRequest();
$name = 'scribunto_ignore_errors';
@ -224,6 +228,26 @@ class ScribuntoHooks {
Xml::check( $name, $req->getCheck( $name ), $attribs ) .
'&#160;' .
Xml::label( wfMsg( 'scribunto-ignore-errors' ), "mw-$name" );
// While we're here, lets set up the edit module
global $wgOut;
$wgOut->addModules( 'ext.scribunto.edit' );
$editor->editFormTextAfterTools = '<div id="mw-scribunto-console"></div>';
return true;
}
/**
* EditPageBeforeEditButtons hook
* @param $editor EditPage
* @param $buttons Button array
* @param $tabindex Current tabindex
*/
public static function beforeEditButtons( &$editor, &$buttons, &$tabindex ) {
if ( $editor->getTitle()->getNamespace() !== NS_MODULE ) {
return true;
}
unset( $buttons['preview'] );
return true;
}

View file

@ -107,6 +107,51 @@ abstract class Scribunto_LuaEngine extends ScribuntoEngineBase {
return 'lua';
}
public function runConsole( $params ) {
/**
* TODO: provide some means for giving correct line numbers for errors
* in console input, and for producing an informative error message
* if there is an error in prevQuestions.
*
* Maybe each console line could be evaluated as a different chunk,
* apparently that's what lua.c does.
*/
$code = "return function (__init)\n" .
"local p = mw.executeModule(__init)\n" .
"local print = mw.log\n";
foreach ( $params['prevQuestions'] as $q ) {
if ( substr( $q, 0, 1 ) === '=' ) {
$code .= "print(" . substr( $q, 1 ) . ")";
} else {
$code .= $q;
}
$code .= "\n";
}
$code .= "mw.clearLogBuffer()\n";
if ( substr( $params['question'], 0, 1 ) === '=' ) {
// Treat a statement starting with "=" as a return statement, like in lua.c
$code .= "return tostring(" . substr( $params['question'], 1 ) . "), mw.getLogBuffer()\n";
} else {
$code .= $params['question'] . "\n" .
"return nil, mw.getLogBuffer()\n";
}
$code .= "end\n";
$contentModule = $this->newModule(
$params['content'], $params['title']->getPrefixedDBkey() );
$contentInit = $contentModule->getInitChunk();
$consoleModule = $this->newModule( $code, wfMsg( 'scribunto-console-current-src' ) );
$consoleInit = $consoleModule->getInitChunk();
$ret = $this->getInterpreter()->callFunction( $consoleInit );
$func = $ret[0];
$ret = $this->getInterpreter()->callFunction( $func, $contentInit );
return array(
'return' => isset( $ret[0] ) ? $ret[0] : null,
'print' => isset( $ret[1] ) ? $ret[1] : '',
);
}
/**
* Workalike for luaL_checktype()
*
@ -376,7 +421,7 @@ class Scribunto_LuaModule extends ScribuntoModuleBase {
}
class Scribunto_LuaError extends ScribuntoException {
var $luaMessage;
var $luaMessage, $lineMap = array();
function __construct( $message, $options = array() ) {
$this->luaMessage = $message;
@ -394,6 +439,10 @@ class Scribunto_LuaError extends ScribuntoException {
return $this->luaMessage;
}
function setLineMap( $map ) {
$this->lineMap = $map;
}
function getScriptTraceHtml( $options = array() ) {
if ( !isset( $this->params['trace'] ) ) {
return false;
@ -406,13 +455,17 @@ class Scribunto_LuaError extends ScribuntoException {
$s = '<ol class="scribunto-trace">';
foreach ( $this->params['trace'] as $info ) {
$src = htmlspecialchars( $info['short_src'] );
if ( $info['currentline'] > 0 ) {
$src .= ':' . htmlspecialchars( $info['currentline'] );
$short_src = $srcdefined = $info['short_src'];
$currentline = $info['currentline'];
$linedefined = $info['linedefined'];
$title = Title::newFromText( $info['short_src'] );
$src = htmlspecialchars( $short_src );
if ( $currentline > 0 ) {
$src .= ':' . htmlspecialchars( $currentline );
$title = Title::newFromText( $short_src );
if ( $title && $title->getNamespace() === NS_MODULE ) {
$title->setFragment( '#mw-ce-l' . $info['currentline'] );
$title->setFragment( '#mw-ce-l' . $currentline );
$src = Html::rawElement( 'a',
array( 'href' => $title->getFullURL( 'action=edit' ) ),
$src );
@ -427,7 +480,7 @@ class Scribunto_LuaError extends ScribuntoException {
$function = '?';
} else {
$function = wfMsgExt( 'scribunto-lua-in-function-at',
$msgOptions, $info['short_src'], $info['linedefined'] );
$msgOptions, $srcdefined, $linedefined );
}
$s .= "<li>\n\t" .
wfMsgExt( 'scribunto-lua-backtrace-line', $msgOptions, "<strong>$src</strong>", $function ) .

View file

@ -5,6 +5,7 @@ local packageModuleFunc
local php
local setupDone
local allowEnvFuncs = false
local logBuffer = ''
--- Put an isolation-friendly package module into the specified environment
-- table. The package module will have an empty cache, because caching of
@ -382,4 +383,17 @@ function mw.executeFunction( chunk )
return table.concat( stringResults )
end
function mw.log( msg )
logBuffer = logBuffer .. tostring( msg ) .. '\n'
end
function mw.clearLogBuffer()
logBuffer = ''
end
function mw.getLogBuffer()
return logBuffer
end
return mw

View file

@ -0,0 +1,55 @@
.mw-scribunto-console-fieldset {
background: white;
color: black;
}
/* Preserve line breaks, but wrap too if browser supports it */
#mw-scribunto-output {
white-space: pre;
white-space: -moz-pre-wrap;
}
#mw-scribunto-input {
width: 100%;
border: none;
padding: 0;
overflow: auto;
background: #e0e0e0;
}
.mw-scribunto-input {
color: blue;
font: inherit;
font-weight: bold;
margin-top: .5em;
}
.mw-scribunto-normalOutput {
color: black;
background: white;
}
.mw-scribunto-print {
color: #630;
background: white;
}
.mw-scribunto-error {
color: red;
background: white;
}
.mw-scribunto-propList {
color: green;
background: white;
}
.mw-scribunto-message {
color: green;
background: white;
}
.mw-scribunto-tabcomplete {
color: purple;
background: white;
}
.mw-scribunto-clear {
color: red;
text-align: center;
margin-top: 1em;
border-bottom: 1px solid red;
}

View file

@ -0,0 +1,380 @@
(function ( $, mw ) {
/**
* Debug console
* Based on JavaScript Shell 1.4 by Jesse Ruderman (GPL/LGPL/MPL tri-license)
*
* TODO:
* * Refactor, more jQuery, etc.
* * Spinner?
* * A prompt in front of input lines and the textarea
* * Collapsible backtrace display
*/
var
histList = [""],
histPos = 0,
question,
_in,
_out,
lastError = null,
sessionContent = null,
sessionKey = null,
pending = false,
clearNextRequest = false;
function refocus()
{
_in.blur(); // Needed for Mozilla to scroll correctly.
_in.focus();
}
function initConsole()
{
_in = document.getElementById( "mw-scribunto-input" );
_out = document.getElementById( "mw-scribunto-output" );
recalculateInputHeight();
println( mw.msg( 'scribunto-console-intro' ), 'mw-scribunto-message' );
}
function inputKeydown( e ) {
// Use onkeydown because IE doesn't support onkeypress for arrow keys
if ( e.shiftKey && e.keyCode == 13 ) { // shift-enter
// don't do anything; allow the shift-enter to insert a line break as normal
} else if ( e.keyCode == 13 ) { // enter
// execute the input on enter
go();
} else if ( e.keyCode == 38 ) { // up
// go up in history if at top or ctrl-up
if ( e.ctrlKey || caretInFirstLine( _in ) )
hist( 'up' );
} else if ( e.keyCode == 40 ) { // down
// go down in history if at end or ctrl-down
if ( e.ctrlKey || caretInLastLine( _in ) )
hist( 'down' );
} else { }
setTimeout( recalculateInputHeight, 0 );
//return true;
}
function inputFocus( e ) {
if ( sessionContent === null ) {
// No previous state to clear
return;
}
if ( clearNextRequest ) {
// User already knows
return;
}
if ( getContent() !== sessionContent ) {
printClearBar();
clearNextRequest = true;
}
}
function caretInFirstLine( textbox )
{
// IE doesn't support selectionStart/selectionEnd
if ( textbox.selectionStart == undefined )
return true;
var firstLineBreak = textbox.value.indexOf( "\n" );
return ((firstLineBreak == -1) || (textbox.selectionStart <= firstLineBreak));
}
function caretInLastLine( textbox )
{
// IE doesn't support selectionStart/selectionEnd
if ( textbox.selectionEnd == undefined )
return true;
var lastLineBreak = textbox.value.lastIndexOf( "\n" );
return ( textbox.selectionEnd > lastLineBreak );
}
function recalculateInputHeight()
{
var rows = _in.value.split( /\n/ ).length
+ 1 // prevent scrollbar flickering in Mozilla
+ ( window.opera ? 1 : 0 ); // leave room for scrollbar in Opera
// without this check, it is impossible to select text in Opera 7.60 or Opera 8.0.
if ( _in.rows != rows )
_in.rows = rows;
}
function println( s, type )
{
if( ( s = String( s ) ) )
{
var newdiv = document.createElement( "div" );
newdiv.appendChild( document.createTextNode( s ) );
newdiv.className = type;
_out.appendChild( newdiv );
return newdiv;
}
}
function printWithRunin( h, s, type )
{
var div = println( s, type );
var head = document.createElement( "strong" );
head.appendChild( document.createTextNode( h + ": " ) );
div.insertBefore( head, div.firstChild );
}
function printClearBar()
{
$( '<div/>' )
.attr( 'class', 'mw-scribunto-clear' )
.text( mw.msg( 'scribunto-console-cleared' ) )
.appendTo( _out );
}
function hist( direction )
{
// histList[0] = first command entered, [1] = second, etc.
// type something, press up --> thing typed is now in "limbo"
// (last item in histList) and should be reachable by pressing
// down again.
var L = histList.length;
if ( L == 1 )
return;
if ( direction === 'up' )
{
if ( histPos == L-1 )
{
// Save this entry in case the user hits the down key.
histList[histPos] = _in.value;
}
if ( histPos > 0 )
{
histPos--;
// Use a timeout to prevent up from moving cursor within new text
// Set to nothing first for the same reason
setTimeout(
function() {
_in.value = '';
_in.value = histList[histPos];
var caretPos = _in.value.length;
if (_in.setSelectionRange)
_in.setSelectionRange(caretPos, caretPos);
},
0
);
}
}
else // down
{
if ( histPos < L-1 )
{
histPos++;
_in.value = histList[histPos];
}
else if ( histPos == L-1 )
{
// Already on the current entry: clear but save
if ( _in.value )
{
histList[histPos] = _in.value;
++histPos;
_in.value = "";
}
}
}
}
function printQuestion( q )
{
println( q, "mw-scribunto-input" );
}
function printError( er )
{
var lineNumberString;
lastError = er; // for debugging the shell
if ( er.name )
{
// lineNumberString should not be "", to avoid a very wacky bug in IE 6.
lineNumberString = (er.lineNumber != undefined) ? (" on line " + er.lineNumber + ": ") : ": ";
// Because IE doesn't have error.toString.
println( er.name + lineNumberString + er.message, "mw-scribunto-error" );
}
else
println( er, "mw-scribunto-error" ); // Because security errors in Moz /only/ have toString.
}
function setPending() {
pending = true;
_in.readOnly = true;
}
function clearPending() {
pending = false;
_in.readOnly = false;
}
function go()
{
if ( pending ) {
// If there is an XHR request pending, don't send another one
// We set readOnly on the textarea to give a UI indication, this is
// just for paranoia.
return;
}
question = _in.value;
if ( question == "" )
return;
histList[histList.length-1] = question;
histList[histList.length] = "";
histPos = histList.length - 1;
// Unfortunately, this has to happen *before* the script is run, so that
// print() output will go in the right place.
_in.value = '';
// can't preventDefault on input, so also clear it later
setTimeout( function() { _in.value = ""; }, 0 );
recalculateInputHeight();
printQuestion(question);
var params = {
action: 'scribunto-console',
title: mw.config.get( 'wgTitle' ),
question: question,
}
var content = getContent();
if ( !sessionKey || sessionContent !== content ) {
params.clear = true;
params.content = content;
}
if ( sessionKey ) {
params.session = sessionKey;
}
if ( clearNextRequest ) {
params.clear = true;
clearNextRequest = false;
}
var api = new mw.Api();
setPending();
api.post( params, {
ok: function( result ) {
sessionKey = result.session;
sessionContent = content;
if ( result.type === 'error' ) {
printError( result.message );
} else {
if ( result.print !== '' ) {
println( result.print, 'mw-scribunto-print' );
}
if ( result['return'] !== '' ) {
println( result['return'], "mw-scribunto-normalOutput" );
}
}
clearPending();
setTimeout( refocus, 0 );
},
err: function( code, result ) {
if ( 'error' in result && 'info' in result.error ) {
printError( result.error.info );
} else if ( 'exception' in result ) {
printError( result.exception.message );
} else {
console.log( result );
printError( 'error' );
}
clearPending();
setTimeout( refocus, 0 );
},
} );
}
function getContent() {
var $textarea = $( '#wpTextbox1' );
var context = $textarea.data( 'wikiEditor-context' );
if ( context == undefined || context.codeEditor == undefined ) {
return $textarea.val();
} else {
return $textarea.textSelection( 'getContents' );
}
}
function onClearClick( e ) {
$( '#mw-scribunto-output' ).empty();
clearNextRequest = true;
refocus();
}
mw.scribunto.edit = {
'init': function () {
var action = mw.config.get( 'wgAction' );
if ( action == 'edit' || action == 'submit' || action == 'editredlink' ) {
this.initEditPage();
}
},
'initEditPage': function() {
var console = document.getElementById( 'mw-scribunto-console' );
if ( !console ) {
return;
}
$( '<fieldset/>' )
.attr( 'class', 'mw-scribunto-console-fieldset' )
.append( $( '<legend/>' ).text( mw.msg( 'scribunto-console-title' ) ) )
.append( $( '<div id="mw-scribunto-output"></div>' ) )
.append(
$( '<div/>' ).append(
$( '<textarea/>' )
.attr({
id: 'mw-scribunto-input',
'class': 'mw-scribunto-input',
wrap: 'off',
rows: 1
})
.bind( 'keydown', inputKeydown )
.bind( 'focus', inputFocus )
)
)
.append(
$( '<div/>' ).append(
$( '<input/>' )
.attr({
type: 'button',
value: mw.msg( 'scribunto-console-clear' )
})
.bind( 'click', onClearClick )
)
)
.wrap( '<form/>' )
.appendTo( console );
initConsole();
}
};
$(document).ready( function() {
mw.scribunto.edit.init();
});
})( jQuery, mediaWiki );

View file

@ -7,7 +7,7 @@ mw.scribunto = {
this.errors = errors;
},
'init': function () {
'init': function() {
var regex = /^mw-scribunto-error-(\d+)/;
var that = this;
var dialog = $( '<div/>' );
@ -39,7 +39,7 @@ mw.scribunto = {
.dialog( 'open' );
} );
} );
}
},
};
$(document).ready( function() {