SECURITY: resources: Patch jQuery 3.3.1 for CVE-2019-11358
[lhc/web/wiklou.git] / resources / src / mediawiki.inspect.js
1 /*!
2 * The mediawiki.inspect module.
3 *
4 * @author Ori Livneh
5 * @since 1.22
6 */
7
8 /* eslint-disable no-console */
9
10 ( function () {
11
12 // mw.inspect is a singleton class with static methods
13 // that itself can also be invoked as a function (mediawiki.base/mw#inspect).
14 // In JavaScript, that is implemented by starting with a function,
15 // and subsequently setting additional properties on the function object.
16
17 /**
18 * Tools for inspecting page composition and performance.
19 *
20 * @class mw.inspect
21 * @singleton
22 */
23
24 var inspect = mw.inspect,
25 byteLength = require( 'mediawiki.String' ).byteLength,
26 hasOwn = Object.prototype.hasOwnProperty;
27
28 function sortByProperty( array, prop, descending ) {
29 var order = descending ? -1 : 1;
30 return array.sort( function ( a, b ) {
31 if ( a[ prop ] === undefined || b[ prop ] === undefined ) {
32 // Sort undefined to the end, regardless of direction
33 return a[ prop ] !== undefined ? -1 : b[ prop ] !== undefined ? 1 : 0;
34 }
35 return a[ prop ] > b[ prop ] ? order : a[ prop ] < b[ prop ] ? -order : 0;
36 } );
37 }
38
39 function humanSize( bytesInput ) {
40 var i,
41 bytes = +bytesInput,
42 units = [ '', ' KiB', ' MiB', ' GiB', ' TiB', ' PiB' ];
43
44 if ( bytes === 0 || isNaN( bytes ) ) {
45 return bytesInput;
46 }
47
48 for ( i = 0; bytes >= 1024; bytes /= 1024 ) {
49 i++;
50 }
51 // Maintain one decimal for kB and above, but don't
52 // add ".0" for bytes.
53 return bytes.toFixed( i > 0 ? 1 : 0 ) + units[ i ];
54 }
55
56 /**
57 * Return a map of all dependency relationships between loaded modules.
58 *
59 * @return {Object} Maps module names to objects. Each sub-object has
60 * two properties, 'requires' and 'requiredBy'.
61 */
62 inspect.getDependencyGraph = function () {
63 var modules = inspect.getLoadedModules(),
64 graph = {};
65
66 modules.forEach( function ( moduleName ) {
67 var dependencies = mw.loader.moduleRegistry[ moduleName ].dependencies || [];
68
69 if ( !hasOwn.call( graph, moduleName ) ) {
70 graph[ moduleName ] = { requiredBy: [] };
71 }
72 graph[ moduleName ].requires = dependencies;
73
74 dependencies.forEach( function ( depName ) {
75 if ( !hasOwn.call( graph, depName ) ) {
76 graph[ depName ] = { requiredBy: [] };
77 }
78 graph[ depName ].requiredBy.push( moduleName );
79 } );
80 } );
81 return graph;
82 };
83
84 /**
85 * Calculate the byte size of a ResourceLoader module.
86 *
87 * @param {string} moduleName The name of the module
88 * @return {number|null} Module size in bytes or null
89 */
90 inspect.getModuleSize = function ( moduleName ) {
91 var module = mw.loader.moduleRegistry[ moduleName ],
92 args, i, size;
93
94 if ( module.state !== 'ready' ) {
95 return null;
96 }
97
98 if ( !module.style && !module.script ) {
99 return 0;
100 }
101
102 function getFunctionBody( func ) {
103 return String( func )
104 // To ensure a deterministic result, replace the start of the function
105 // declaration with a fixed string. For example, in Chrome 55, it seems
106 // V8 seemingly-at-random decides to sometimes put a line break between
107 // the opening brace and first statement of the function body. T159751.
108 .replace( /^\s*function\s*\([^)]*\)\s*{\s*/, 'function(){' )
109 .replace( /\s*}\s*$/, '}' );
110 }
111
112 // Based on the load.php response for this module.
113 // For example: `mw.loader.implement("example", function(){}, {"css":[".x{color:red}"]});`
114 // @see mw.loader.store.set().
115 args = [
116 moduleName,
117 module.script,
118 module.style,
119 module.messages,
120 module.templates
121 ];
122 // Trim trailing null or empty object, as load.php would have done.
123 // @see ResourceLoader::makeLoaderImplementScript and ResourceLoader::trimArray.
124 i = args.length;
125 while ( i-- ) {
126 if ( args[ i ] === null || ( $.isPlainObject( args[ i ] ) && $.isEmptyObject( args[ i ] ) ) ) {
127 args.splice( i, 1 );
128 } else {
129 break;
130 }
131 }
132
133 size = 0;
134 for ( i = 0; i < args.length; i++ ) {
135 if ( typeof args[ i ] === 'function' ) {
136 size += byteLength( getFunctionBody( args[ i ] ) );
137 } else {
138 size += byteLength( JSON.stringify( args[ i ] ) );
139 }
140 }
141
142 return size;
143 };
144
145 /**
146 * Given CSS source, count both the total number of selectors it
147 * contains and the number which match some element in the current
148 * document.
149 *
150 * @param {string} css CSS source
151 * @return {Object} Selector counts
152 * @return {number} return.selectors Total number of selectors
153 * @return {number} return.matched Number of matched selectors
154 */
155 inspect.auditSelectors = function ( css ) {
156 var selectors = { total: 0, matched: 0 },
157 style = document.createElement( 'style' );
158
159 style.textContent = css;
160 document.body.appendChild( style );
161 // eslint-disable-next-line no-jquery/no-each-util
162 $.each( style.sheet.cssRules, function ( index, rule ) {
163 selectors.total++;
164 // document.querySelector() on prefixed pseudo-elements can throw exceptions
165 // in Firefox and Safari. Ignore these exceptions.
166 // https://bugs.webkit.org/show_bug.cgi?id=149160
167 // https://bugzilla.mozilla.org/show_bug.cgi?id=1204880
168 try {
169 if ( document.querySelector( rule.selectorText ) !== null ) {
170 selectors.matched++;
171 }
172 } catch ( e ) {}
173 } );
174 document.body.removeChild( style );
175 return selectors;
176 };
177
178 /**
179 * Get a list of all loaded ResourceLoader modules.
180 *
181 * @return {Array} List of module names
182 */
183 inspect.getLoadedModules = function () {
184 return mw.loader.getModuleNames().filter( function ( module ) {
185 return mw.loader.getState( module ) === 'ready';
186 } );
187 };
188
189 /**
190 * Print tabular data to the console, using console.table, console.log,
191 * or mw.log (in declining order of preference).
192 *
193 * @param {Array} data Tabular data represented as an array of objects
194 * with common properties.
195 */
196 inspect.dumpTable = function ( data ) {
197 try {
198 // Use Function.prototype#call to force an exception on Firefox,
199 // which doesn't define console#table but doesn't complain if you
200 // try to invoke it.
201 // eslint-disable-next-line no-useless-call
202 console.table.call( console, data );
203 return;
204 } catch ( e ) {}
205 try {
206 console.log( JSON.stringify( data, null, 2 ) );
207 } catch ( e ) {}
208 };
209
210 /**
211 * Generate and print reports.
212 *
213 * When invoked without arguments, prints all available reports.
214 *
215 * @param {...string} [reports] One or more of "size", "css", "store", or "time".
216 */
217 inspect.runReports = function () {
218 var reports = arguments.length > 0 ?
219 Array.prototype.slice.call( arguments ) :
220 Object.keys( inspect.reports );
221
222 reports.forEach( function ( name ) {
223 if ( console.group ) {
224 console.group( 'mw.inspect ' + name + ' report' );
225 } else {
226 console.log( 'mw.inspect ' + name + ' report' );
227 }
228 inspect.dumpTable( inspect.reports[ name ]() );
229 if ( console.group ) {
230 console.groupEnd( 'mw.inspect ' + name + ' report' );
231 }
232 } );
233 };
234
235 /**
236 * Perform a string search across the JavaScript and CSS source code
237 * of all loaded modules and return an array of the names of the
238 * modules that matched.
239 *
240 * @param {string|RegExp} pattern String or regexp to match.
241 * @return {Array} Array of the names of modules that matched.
242 */
243 inspect.grep = function ( pattern ) {
244 if ( typeof pattern.test !== 'function' ) {
245 pattern = new RegExp( mw.RegExp.escape( pattern ), 'g' );
246 }
247
248 return inspect.getLoadedModules().filter( function ( moduleName ) {
249 var module = mw.loader.moduleRegistry[ moduleName ];
250
251 // Grep module's JavaScript
252 if ( typeof module.script === 'function' && pattern.test( module.script.toString() ) ) {
253 return true;
254 }
255
256 // Grep module's CSS
257 if (
258 $.isPlainObject( module.style ) && Array.isArray( module.style.css ) &&
259 pattern.test( module.style.css.join( '' ) )
260 ) {
261 // Module's CSS source matches
262 return true;
263 }
264
265 return false;
266 } );
267 };
268
269 /**
270 * @private
271 * @class mw.inspect.reports
272 * @singleton
273 */
274 inspect.reports = {
275 /**
276 * Generate a breakdown of all loaded modules and their size in
277 * kilobytes. Modules are ordered from largest to smallest.
278 *
279 * @return {Object[]} Size reports
280 */
281 size: function () {
282 // Map each module to a descriptor object.
283 var modules = inspect.getLoadedModules().map( function ( module ) {
284 return {
285 name: module,
286 size: inspect.getModuleSize( module )
287 };
288 } );
289
290 // Sort module descriptors by size, largest first.
291 sortByProperty( modules, 'size', true );
292
293 // Convert size to human-readable string.
294 modules.forEach( function ( module ) {
295 module.sizeInBytes = module.size;
296 module.size = humanSize( module.size );
297 } );
298
299 return modules;
300 },
301
302 /**
303 * For each module with styles, count the number of selectors, and
304 * count how many match against some element currently in the DOM.
305 *
306 * @return {Object[]} CSS reports
307 */
308 css: function () {
309 var modules = [];
310
311 inspect.getLoadedModules().forEach( function ( name ) {
312 var css, stats, module = mw.loader.moduleRegistry[ name ];
313
314 try {
315 css = module.style.css.join();
316 } catch ( e ) { return; } // skip
317
318 stats = inspect.auditSelectors( css );
319 modules.push( {
320 module: name,
321 allSelectors: stats.total,
322 matchedSelectors: stats.matched,
323 percentMatched: stats.total !== 0 ?
324 ( stats.matched / stats.total * 100 ).toFixed( 2 ) + '%' : null
325 } );
326 } );
327 sortByProperty( modules, 'allSelectors', true );
328 return modules;
329 },
330
331 /**
332 * Report stats on mw.loader.store: the number of localStorage
333 * cache hits and misses, the number of items purged from the
334 * cache, and the total size of the module blob in localStorage.
335 *
336 * @return {Object[]} Store stats
337 */
338 store: function () {
339 var raw, stats = { enabled: mw.loader.store.enabled };
340 if ( stats.enabled ) {
341 $.extend( stats, mw.loader.store.stats );
342 try {
343 raw = localStorage.getItem( mw.loader.store.getStoreKey() );
344 stats.totalSizeInBytes = byteLength( raw );
345 stats.totalSize = humanSize( byteLength( raw ) );
346 } catch ( e ) {}
347 }
348 return [ stats ];
349 },
350
351 /**
352 * Generate a breakdown of all loaded modules and their time
353 * spent during initialisation (measured in milliseconds).
354 *
355 * This timing data is collected by mw.loader.profiler.
356 *
357 * @return {Object[]} Table rows
358 */
359 time: function () {
360 var modules;
361
362 if ( !mw.loader.profiler ) {
363 mw.log.warn( 'mw.inspect: The time report requires $wgResourceLoaderEnableJSProfiler.' );
364 return [];
365 }
366
367 modules = inspect.getLoadedModules()
368 .map( function ( moduleName ) {
369 return mw.loader.profiler.getProfile( moduleName );
370 } )
371 .filter( function ( perf ) {
372 // Exclude modules that reached "ready" state without involvement from mw.loader.
373 // This is primarily styles-only as loaded via <link rel="stylesheet">.
374 return perf !== null;
375 } );
376
377 // Sort by total time spent, highest first.
378 sortByProperty( modules, 'total', true );
379
380 // Add human-readable strings
381 modules.forEach( function ( module ) {
382 module.totalInMs = module.total;
383 module.total = module.totalInMs.toLocaleString() + ' ms';
384 } );
385
386 return modules;
387 }
388 };
389
390 if ( mw.config.get( 'debug' ) ) {
391 mw.log( 'mw.inspect: reports are not available in debug mode.' );
392 }
393
394 }() );