Update git submodules
[mediawiki.git] / resources / src / mediawiki.inspect.js
blob6451b9d1e7803dbf7d2a4420e28ac3c7f6118067
1 /*!
2  * The mediawiki.inspect module.
3  *
4  * @author Ori Livneh
5  * @since 1.22
6  */
8 /* eslint-disable no-console */
10 ( function () {
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.
17         /**
18          * Tools for inspecting page composition and performance.
19          *
20          * @class mw.inspect
21          * @singleton
22          */
24         var inspect = mw.inspect,
25                 byteLength = require( 'mediawiki.String' ).byteLength,
26                 hasOwn = Object.prototype.hasOwnProperty;
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         }
39         function humanSize( bytesInput ) {
40                 var i,
41                         bytes = +bytesInput,
42                         units = [ '', ' KiB', ' MiB', ' GiB', ' TiB', ' PiB' ];
44                 if ( bytes === 0 || isNaN( bytes ) ) {
45                         return bytesInput;
46                 }
48                 for ( i = 0; bytes >= 1024; bytes /= 1024 ) {
49                         i++;
50                 }
51                 // Maintain one decimal for KiB and above, but don't
52                 // add ".0" for bytes.
53                 return bytes.toFixed( i > 0 ? 1 : 0 ) + units[ i ];
54         }
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 = {};
66                 modules.forEach( function ( moduleName ) {
67                         var dependencies = mw.loader.moduleRegistry[ moduleName ].dependencies || [];
69                         if ( !hasOwn.call( graph, moduleName ) ) {
70                                 graph[ moduleName ] = { requiredBy: [] };
71                         }
72                         graph[ moduleName ].requires = dependencies;
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         };
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                 // We typically receive them from the server through batches from load.php,
92                 // or embedded as inline scripts (handled in PHP by ResourceLoader::makeModuleResponse
93                 // and ResourceLoader\ClientHtml respectively).
94                 //
95                 // The module declarator function is stored by mw.loader.implement(), allowing easy
96                 // computation of the exact size.
97                 var module = mw.loader.moduleRegistry[ moduleName ];
99                 if ( module.state !== 'ready' ) {
100                         return null;
101                 }
102                 if ( !module.declarator ) {
103                         return 0;
104                 }
105                 return byteLength( module.declarator.toString() );
106         };
108         /**
109          * Given CSS source, count both the total number of selectors it
110          * contains and the number which match some element in the current
111          * document.
112          *
113          * @param {string} css CSS source
114          * @return {Object} Selector counts
115          * @return {number} return.selectors Total number of selectors
116          * @return {number} return.matched Number of matched selectors
117          */
118         inspect.auditSelectors = function ( css ) {
119                 var selectors = { total: 0, matched: 0 },
120                         style = document.createElement( 'style' );
122                 style.textContent = css;
123                 document.body.appendChild( style );
124                 var cssRules = style.sheet.cssRules;
125                 for ( var index in cssRules ) {
126                         const rule = cssRules[ index ];
127                         selectors.total++;
128                         // document.querySelector() on prefixed pseudo-elements can throw exceptions
129                         // in Firefox and Safari. Ignore these exceptions.
130                         // https://bugs.webkit.org/show_bug.cgi?id=149160
131                         // https://bugzilla.mozilla.org/show_bug.cgi?id=1204880
132                         try {
133                                 if ( document.querySelector( rule.selectorText ) !== null ) {
134                                         selectors.matched++;
135                                 }
136                         } catch ( e ) {}
137                 }
138                 document.body.removeChild( style );
139                 return selectors;
140         };
142         /**
143          * Get a list of all loaded ResourceLoader modules.
144          *
145          * @return {Array} List of module names
146          */
147         inspect.getLoadedModules = function () {
148                 return mw.loader.getModuleNames().filter( function ( module ) {
149                         return mw.loader.getState( module ) === 'ready';
150                 } );
151         };
153         /**
154          * Print tabular data to the console using console.table.
155          *
156          * @param {Array} data Tabular data represented as an array of objects
157          *  with common properties.
158          */
159         inspect.dumpTable = console.table;
161         /**
162          * Generate and print reports.
163          *
164          * When invoked without arguments, prints all available reports.
165          *
166          * @param {...string} [reports] One or more of "size", "css", "store", or "time".
167          */
168         inspect.runReports = function () {
169                 var reports = arguments.length > 0 ?
170                         Array.prototype.slice.call( arguments ) :
171                         Object.keys( inspect.reports );
173                 reports.forEach( function ( name ) {
174                         if ( console.group ) {
175                                 console.group( 'mw.inspect ' + name + ' report' );
176                         } else {
177                                 console.log( 'mw.inspect ' + name + ' report' );
178                         }
179                         inspect.dumpTable( inspect.reports[ name ]() );
180                         if ( console.group ) {
181                                 console.groupEnd( 'mw.inspect ' + name + ' report' );
182                         }
183                 } );
184         };
186         /**
187          * Perform a string search across the JavaScript and CSS source code
188          * of all loaded modules and return an array of the names of the
189          * modules that matched.
190          *
191          * @param {string|RegExp} pattern String or regexp to match.
192          * @return {Array} Array of the names of modules that matched.
193          */
194         inspect.grep = function ( pattern ) {
195                 if ( typeof pattern.test !== 'function' ) {
196                         // eslint-disable-next-line security/detect-non-literal-regexp
197                         pattern = new RegExp( mw.util.escapeRegExp( pattern ), 'g' );
198                 }
200                 return inspect.getLoadedModules().filter( function ( moduleName ) {
201                         var module = mw.loader.moduleRegistry[ moduleName ];
203                         // Grep module's JavaScript
204                         if ( typeof module.script === 'function' && pattern.test( module.script.toString() ) ) {
205                                 return true;
206                         }
208                         // Grep module's CSS
209                         if (
210                                 $.isPlainObject( module.style ) && Array.isArray( module.style.css ) &&
211                                 pattern.test( module.style.css.join( '' ) )
212                         ) {
213                                 // Module's CSS source matches
214                                 return true;
215                         }
217                         return false;
218                 } );
219         };
221         /**
222          * @private
223          * @class mw.inspect.reports
224          * @singleton
225          */
226         inspect.reports = {
227                 /**
228                  * Generate a breakdown of all loaded modules and their size in
229                  * kibibytes. Modules are ordered from largest to smallest.
230                  *
231                  * @return {Object[]} Size reports
232                  */
233                 size: function () {
234                         // Map each module to a descriptor object.
235                         var modules = inspect.getLoadedModules().map( function ( module ) {
236                                 return {
237                                         name: module,
238                                         size: inspect.getModuleSize( module )
239                                 };
240                         } );
242                         // Sort module descriptors by size, largest first.
243                         sortByProperty( modules, 'size', true );
245                         // Convert size to human-readable string.
246                         modules.forEach( function ( module ) {
247                                 module.sizeInBytes = module.size;
248                                 module.size = humanSize( module.size );
249                         } );
251                         return modules;
252                 },
254                 /**
255                  * For each module with styles, count the number of selectors, and
256                  * count how many match against some element currently in the DOM.
257                  *
258                  * @return {Object[]} CSS reports
259                  */
260                 css: function () {
261                         var modules = [];
263                         inspect.getLoadedModules().forEach( function ( name ) {
264                                 var css, stats, module = mw.loader.moduleRegistry[ name ];
266                                 try {
267                                         css = module.style.css.join();
268                                 } catch ( e ) { return; } // skip
270                                 stats = inspect.auditSelectors( css );
271                                 modules.push( {
272                                         module: name,
273                                         allSelectors: stats.total,
274                                         matchedSelectors: stats.matched,
275                                         percentMatched: stats.total !== 0 ?
276                                                 ( stats.matched / stats.total * 100 ).toFixed( 2 ) + '%' : null
277                                 } );
278                         } );
279                         sortByProperty( modules, 'allSelectors', true );
280                         return modules;
281                 },
283                 /**
284                  * Report stats on mw.loader.store: the number of localStorage
285                  * cache hits and misses, the number of items purged from the
286                  * cache, and the total size of the module blob in localStorage.
287                  *
288                  * @return {Object[]} Store stats
289                  */
290                 store: function () {
291                         var raw, stats = { enabled: mw.loader.store.enabled };
292                         if ( stats.enabled ) {
293                                 $.extend( stats, mw.loader.store.stats );
294                                 try {
295                                         raw = localStorage.getItem( mw.loader.store.key );
296                                         stats.totalSizeInBytes = byteLength( raw );
297                                         stats.totalSize = humanSize( byteLength( raw ) );
298                                 } catch ( e ) {}
299                         }
300                         return [ stats ];
301                 },
303                 /**
304                  * Generate a breakdown of all loaded modules and their time
305                  * spent during initialisation (measured in milliseconds).
306                  *
307                  * This timing data is collected by mw.loader.profiler.
308                  *
309                  * @return {Object[]} Table rows
310                  */
311                 time: function () {
312                         var modules;
314                         if ( !mw.loader.profiler ) {
315                                 mw.log.warn( 'mw.inspect: The time report requires $wgResourceLoaderEnableJSProfiler.' );
316                                 return [];
317                         }
319                         modules = inspect.getLoadedModules()
320                                 .map( function ( moduleName ) {
321                                         return mw.loader.profiler.getProfile( moduleName );
322                                 } )
323                                 .filter( function ( perf ) {
324                                         // Exclude modules that reached "ready" state without involvement from mw.loader.
325                                         // This is primarily styles-only as loaded via <link rel="stylesheet">.
326                                         return perf !== null;
327                                 } );
329                         // Sort by total time spent, highest first.
330                         sortByProperty( modules, 'total', true );
332                         // Add human-readable strings
333                         modules.forEach( function ( module ) {
334                                 module.totalInMs = module.total;
335                                 module.total = module.totalInMs.toLocaleString() + ' ms';
336                         } );
338                         return modules;
339                 }
340         };
342         if ( mw.config.get( 'debug' ) ) {
343                 mw.log( 'mw.inspect: reports are not available in debug mode.' );
344         }
346 }() );