1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7/**
8 * Define a 'console' API to roughly match the implementation provided by
9 * Firebug.
10 * This module helps cases where code is shared between the web and Firefox.
11 * See also Browser.jsm for an implementation of other web constants to help
12 * sharing code between the web and firefox;
13 *
14 * The API is only be a rough approximation for 3 reasons:
15 * - The Firebug console API is implemented in many places with differences in
16 *   the implementations, so there isn't a single reference to adhere to
17 * - The Firebug console is a rich display compared with dump(), so there will
18 *   be many things that we can't replicate
19 * - The primary use of this API is debugging and error logging so the perfect
20 *   implementation isn't always required (or even well defined)
21 */
22
23var EXPORTED_SYMBOLS = ["console", "ConsoleAPI"];
24
25ChromeUtils.defineModuleGetter(
26  this,
27  "Services",
28  "resource://gre/modules/Services.jsm"
29);
30
31var gTimerRegistry = new Map();
32
33/**
34 * String utility to ensure that strings are a specified length. Strings
35 * that are too long are truncated to the max length and the last char is
36 * set to "_". Strings that are too short are padded with spaces.
37 *
38 * @param {string} aStr
39 *        The string to format to the correct length
40 * @param {number} aMaxLen
41 *        The maximum allowed length of the returned string
42 * @param {number} aMinLen (optional)
43 *        The minimum allowed length of the returned string. If undefined,
44 *        then aMaxLen will be used
45 * @param {object} aOptions (optional)
46 *        An object allowing format customization. Allowed customizations:
47 *          'truncate' - can take the value "start" to truncate strings from
48 *             the start as opposed to the end or "center" to truncate
49 *             strings in the center.
50 *          'align' - takes an alignment when padding is needed for MinLen,
51 *             either "start" or "end".  Defaults to "start".
52 * @return {string}
53 *        The original string formatted to fit the specified lengths
54 */
55function fmt(aStr, aMaxLen, aMinLen, aOptions) {
56  if (aMinLen == null) {
57    aMinLen = aMaxLen;
58  }
59  if (aStr == null) {
60    aStr = "";
61  }
62  if (aStr.length > aMaxLen) {
63    if (aOptions && aOptions.truncate == "start") {
64      return "_" + aStr.substring(aStr.length - aMaxLen + 1);
65    } else if (aOptions && aOptions.truncate == "center") {
66      let start = aStr.substring(0, aMaxLen / 2);
67
68      let end = aStr.substring(aStr.length - aMaxLen / 2 + 1);
69      return start + "_" + end;
70    }
71    return aStr.substring(0, aMaxLen - 1) + "_";
72  }
73  if (aStr.length < aMinLen) {
74    let padding = Array(aMinLen - aStr.length + 1).join(" ");
75    aStr = aOptions.align === "end" ? padding + aStr : aStr + padding;
76  }
77  return aStr;
78}
79
80/**
81 * Utility to extract the constructor name of an object.
82 * Object.toString gives: "[object ?????]"; we want the "?????".
83 *
84 * @param {object} aObj
85 *        The object from which to extract the constructor name
86 * @return {string}
87 *        The constructor name
88 */
89function getCtorName(aObj) {
90  if (aObj === null) {
91    return "null";
92  }
93  if (aObj === undefined) {
94    return "undefined";
95  }
96  if (aObj.constructor && aObj.constructor.name) {
97    return aObj.constructor.name;
98  }
99  // If that fails, use Objects toString which sometimes gives something
100  // better than 'Object', and at least defaults to Object if nothing better
101  return Object.prototype.toString.call(aObj).slice(8, -1);
102}
103
104/**
105 * Indicates whether an object is a JS or `Components.Exception` error.
106 *
107 * @param {object} aThing
108          The object to check
109 * @return {boolean}
110          Is this object an error?
111 */
112function isError(aThing) {
113  return (
114    aThing &&
115    ((typeof aThing.name == "string" && aThing.name.startsWith("NS_ERROR_")) ||
116      getCtorName(aThing).endsWith("Error"))
117  );
118}
119
120/**
121 * A single line stringification of an object designed for use by humans
122 *
123 * @param {any} aThing
124 *        The object to be stringified
125 * @param {boolean} aAllowNewLines
126 * @return {string}
127 *        A single line representation of aThing, which will generally be at
128 *        most 80 chars long
129 */
130function stringify(aThing, aAllowNewLines) {
131  if (aThing === undefined) {
132    return "undefined";
133  }
134
135  if (aThing === null) {
136    return "null";
137  }
138
139  if (isError(aThing)) {
140    return "Message: " + aThing;
141  }
142
143  if (typeof aThing == "object") {
144    let type = getCtorName(aThing);
145    if (Element.isInstance(aThing)) {
146      return debugElement(aThing);
147    }
148    type = type == "Object" ? "" : type + " ";
149    let json;
150    try {
151      json = JSON.stringify(aThing);
152    } catch (ex) {
153      // Can't use a real ellipsis here, because cmd.exe isn't unicode-enabled
154      json = "{" + Object.keys(aThing).join(":..,") + ":.., }";
155    }
156    return type + json;
157  }
158
159  if (typeof aThing == "function") {
160    return aThing.toString().replace(/\s+/g, " ");
161  }
162
163  let str = aThing.toString();
164  if (!aAllowNewLines) {
165    str = str.replace(/\n/g, "|");
166  }
167  return str;
168}
169
170/**
171 * Create a simple debug representation of a given element.
172 *
173 * @param {Element} aElement
174 *        The element to debug
175 * @return {string}
176 *        A simple single line representation of aElement
177 */
178function debugElement(aElement) {
179  return (
180    "<" +
181    aElement.tagName +
182    (aElement.id ? "#" + aElement.id : "") +
183    (aElement.className && aElement.className.split
184      ? "." + aElement.className.split(" ").join(" .")
185      : "") +
186    ">"
187  );
188}
189
190/**
191 * A multi line stringification of an object, designed for use by humans
192 *
193 * @param {any} aThing
194 *        The object to be stringified
195 * @return {string}
196 *        A multi line representation of aThing
197 */
198function log(aThing) {
199  if (aThing === null) {
200    return "null\n";
201  }
202
203  if (aThing === undefined) {
204    return "undefined\n";
205  }
206
207  if (typeof aThing == "object") {
208    let reply = "";
209    let type = getCtorName(aThing);
210    if (type == "Map") {
211      reply += "Map\n";
212      for (let [key, value] of aThing) {
213        reply += logProperty(key, value);
214      }
215    } else if (type == "Set") {
216      let i = 0;
217      reply += "Set\n";
218      for (let value of aThing) {
219        reply += logProperty("" + i, value);
220        i++;
221      }
222    } else if (isError(aThing)) {
223      reply += "  Message: " + aThing + "\n";
224      if (aThing.stack) {
225        reply += "  Stack:\n";
226        var frame = aThing.stack;
227        while (frame) {
228          reply += "    " + frame + "\n";
229          frame = frame.caller;
230        }
231      }
232    } else if (Element.isInstance(aThing)) {
233      reply += "  " + debugElement(aThing) + "\n";
234    } else {
235      let keys = Object.getOwnPropertyNames(aThing);
236      if (keys.length) {
237        reply += type + "\n";
238        keys.forEach(function(aProp) {
239          reply += logProperty(aProp, aThing[aProp]);
240        });
241      } else {
242        reply += type + "\n";
243        let root = aThing;
244        let logged = [];
245        while (root != null) {
246          let properties = Object.keys(root);
247          properties.sort();
248          properties.forEach(function(property) {
249            if (!(property in logged)) {
250              logged[property] = property;
251              reply += logProperty(property, aThing[property]);
252            }
253          });
254
255          root = Object.getPrototypeOf(root);
256          if (root != null) {
257            reply += "  - prototype " + getCtorName(root) + "\n";
258          }
259        }
260      }
261    }
262
263    return reply;
264  }
265
266  return "  " + aThing.toString() + "\n";
267}
268
269/**
270 * Helper for log() which converts a property/value pair into an output
271 * string
272 *
273 * @param {string} aProp
274 *        The name of the property to include in the output string
275 * @param {object} aValue
276 *        Value assigned to aProp to be converted to a single line string
277 * @return {string}
278 *        Multi line output string describing the property/value pair
279 */
280function logProperty(aProp, aValue) {
281  let reply = "";
282  if (aProp == "stack" && typeof value == "string") {
283    let trace = parseStack(aValue);
284    reply += formatTrace(trace);
285  } else {
286    reply += "    - " + aProp + " = " + stringify(aValue) + "\n";
287  }
288  return reply;
289}
290
291const LOG_LEVELS = {
292  all: Number.MIN_VALUE,
293  debug: 2,
294  log: 3,
295  info: 3,
296  clear: 3,
297  trace: 3,
298  timeEnd: 3,
299  time: 3,
300  assert: 3,
301  group: 3,
302  groupEnd: 3,
303  profile: 3,
304  profileEnd: 3,
305  dir: 3,
306  dirxml: 3,
307  warn: 4,
308  error: 5,
309  off: Number.MAX_VALUE,
310};
311
312/**
313 * Helper to tell if a console message of `aLevel` type
314 * should be logged in stdout and sent to consoles given
315 * the current maximum log level being defined in `console.maxLogLevel`
316 *
317 * @param {string} aLevel
318 *        Console message log level
319 * @param {string} aMaxLevel {string}
320 *        String identifier (See LOG_LEVELS for possible
321 *        values) that allows to filter which messages
322 *        are logged based on their log level
323 * @return {boolean}
324 *        Should this message be logged or not?
325 */
326function shouldLog(aLevel, aMaxLevel) {
327  return LOG_LEVELS[aMaxLevel] <= LOG_LEVELS[aLevel];
328}
329
330/**
331 * Parse a stack trace, returning an array of stack frame objects, where
332 * each has filename/lineNumber/functionName members
333 *
334 * @param {string} aStack
335 *        The serialized stack trace
336 * @return {object[]}
337 *        Array of { file: "...", line: NNN, call: "..." } objects
338 */
339function parseStack(aStack) {
340  let trace = [];
341  aStack.split("\n").forEach(function(line) {
342    if (!line) {
343      return;
344    }
345    let at = line.lastIndexOf("@");
346    let posn = line.substring(at + 1);
347    trace.push({
348      filename: posn.split(":")[0],
349      lineNumber: posn.split(":")[1],
350      functionName: line.substring(0, at),
351    });
352  });
353  return trace;
354}
355
356/**
357 * Format a frame coming from Components.stack such that it can be used by the
358 * Browser Console, via console-api-log-event notifications.
359 *
360 * @param {object} aFrame
361 *        The stack frame from which to begin the walk.
362 * @param {number=0} aMaxDepth
363 *        Maximum stack trace depth. Default is 0 - no depth limit.
364 * @return {object[]}
365 *         An array of {filename, lineNumber, functionName, language} objects.
366 *         These objects follow the same format as other console-api-log-event
367 *         messages.
368 */
369function getStack(aFrame, aMaxDepth = 0) {
370  if (!aFrame) {
371    aFrame = Components.stack.caller;
372  }
373  let trace = [];
374  while (aFrame) {
375    trace.push({
376      filename: aFrame.filename,
377      lineNumber: aFrame.lineNumber,
378      functionName: aFrame.name,
379      language: aFrame.language,
380    });
381    if (aMaxDepth == trace.length) {
382      break;
383    }
384    aFrame = aFrame.caller;
385  }
386  return trace;
387}
388
389/**
390 * Take the output from parseStack() and convert it to nice readable
391 * output
392 *
393 * @param {object[]} aTrace
394 *        Array of trace objects as created by parseStack()
395 * @return {string} Multi line report of the stack trace
396 */
397function formatTrace(aTrace) {
398  let reply = "";
399  aTrace.forEach(function(frame) {
400    reply +=
401      fmt(frame.filename, 20, 20, { truncate: "start" }) +
402      " " +
403      fmt(frame.lineNumber, 5, 5) +
404      " " +
405      fmt(frame.functionName, 75, 0, { truncate: "center" }) +
406      "\n";
407  });
408  return reply;
409}
410
411/**
412 * Create a new timer by recording the current time under the specified name.
413 *
414 * @param {string} aName
415 *        The name of the timer.
416 * @param {number} [aTimestamp=Date.now()]
417 *        Optional timestamp that tells when the timer was originally started.
418 * @return {object}
419 *         The name property holds the timer name and the started property
420 *         holds the time the timer was started. In case of error, it returns
421 *         an object with the single property "error" that contains the key
422 *         for retrieving the localized error message.
423 */
424function startTimer(aName, aTimestamp) {
425  let key = aName.toString();
426  if (!gTimerRegistry.has(key)) {
427    gTimerRegistry.set(key, aTimestamp || Date.now());
428  }
429  return { name: aName, started: gTimerRegistry.get(key) };
430}
431
432/**
433 * Stop the timer with the specified name and retrieve the elapsed time.
434 *
435 * @param {string} aName
436 *        The name of the timer.
437 * @param {number} [aTimestamp=Date.now()]
438 *        Optional timestamp that tells when the timer was originally stopped.
439 * @return {object}
440 *         The name property holds the timer name and the duration property
441 *         holds the number of milliseconds since the timer was started.
442 */
443function stopTimer(aName, aTimestamp) {
444  let key = aName.toString();
445  let duration = (aTimestamp || Date.now()) - gTimerRegistry.get(key);
446  gTimerRegistry.delete(key);
447  return { name: aName, duration };
448}
449
450/**
451 * Dump a new message header to stdout by taking care of adding an eventual
452 * prefix
453 *
454 * @param {object} aConsole
455 *        ConsoleAPI instance
456 * @param {string} aLevel
457 *        The string identifier for the message log level
458 * @param {string} aMessage
459 *        The string message to print to stdout
460 */
461function dumpMessage(aConsole, aLevel, aMessage) {
462  aConsole.dump(
463    "console." +
464      aLevel +
465      ": " +
466      (aConsole.prefix ? aConsole.prefix + ": " : "") +
467      aMessage +
468      "\n"
469  );
470}
471
472/**
473 * Create a function which will output a concise level of output when used
474 * as a logging function
475 *
476 * @param {string} aLevel
477 *        A prefix to all output generated from this function detailing the
478 *        level at which output occurred
479 * @return {function}
480 *        A logging function
481 * @see createMultiLineDumper()
482 */
483function createDumper(aLevel) {
484  return function() {
485    if (!shouldLog(aLevel, this.maxLogLevel)) {
486      return;
487    }
488    let args = Array.prototype.slice.call(arguments, 0);
489    let frame = getStack(Components.stack.caller, 1)[0];
490    sendConsoleAPIMessage(this, aLevel, frame, args);
491    let data = args.map(function(arg) {
492      return stringify(arg, true);
493    });
494    dumpMessage(this, aLevel, data.join(" "));
495  };
496}
497
498/**
499 * Create a function which will output more detailed level of output when
500 * used as a logging function
501 *
502 * @param {string} aLevel
503 *        A prefix to all output generated from this function detailing the
504 *        level at which output occurred
505 * @return {function}
506 *        A logging function
507 * @see createDumper()
508 */
509function createMultiLineDumper(aLevel) {
510  return function() {
511    if (!shouldLog(aLevel, this.maxLogLevel)) {
512      return;
513    }
514    dumpMessage(this, aLevel, "");
515    let args = Array.prototype.slice.call(arguments, 0);
516    let frame = getStack(Components.stack.caller, 1)[0];
517    sendConsoleAPIMessage(this, aLevel, frame, args);
518    args.forEach(function(arg) {
519      this.dump(log(arg));
520    }, this);
521  };
522}
523
524/**
525 * Send a Console API message. This function will send a console-api-log-event
526 * notification through the nsIObserverService.
527 *
528 * @param {object} aConsole
529 *        The instance of ConsoleAPI performing the logging.
530 * @param {string} aLevel
531 *        Message severity level. This is usually the name of the console method
532 *        that was called.
533 * @param {object} aFrame
534 *        The youngest stack frame coming from Components.stack, as formatted by
535 *        getStack().
536 * @param {array} aArgs
537 *        The arguments given to the console method.
538 * @param {object} aOptions
539 *        Object properties depend on the console method that was invoked:
540 *        - timer: for time() and timeEnd(). Holds the timer information.
541 *        - groupName: for group(), groupCollapsed() and groupEnd().
542 *        - stacktrace: for trace(). Holds the array of stack frames as given by
543 *        getStack().
544 */
545function sendConsoleAPIMessage(aConsole, aLevel, aFrame, aArgs, aOptions = {}) {
546  let consoleEvent = {
547    ID: "jsm",
548    innerID: aConsole.innerID || aFrame.filename,
549    consoleID: aConsole.consoleID,
550    level: aLevel,
551    filename: aFrame.filename,
552    lineNumber: aFrame.lineNumber,
553    functionName: aFrame.functionName,
554    timeStamp: Date.now(),
555    arguments: aArgs,
556    prefix: aConsole.prefix,
557    chromeContext: true,
558  };
559
560  consoleEvent.wrappedJSObject = consoleEvent;
561
562  switch (aLevel) {
563    case "trace":
564      consoleEvent.stacktrace = aOptions.stacktrace;
565      break;
566    case "time":
567    case "timeEnd":
568      consoleEvent.timer = aOptions.timer;
569      break;
570    case "group":
571    case "groupCollapsed":
572    case "groupEnd":
573      try {
574        consoleEvent.groupName = Array.prototype.join.call(aArgs, " ");
575      } catch (ex) {
576        Cu.reportError(ex);
577        Cu.reportError(ex.stack);
578        return;
579      }
580      break;
581  }
582
583  let ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService(
584    Ci.nsIConsoleAPIStorage
585  );
586  if (ConsoleAPIStorage) {
587    ConsoleAPIStorage.recordEvent("jsm", null, consoleEvent);
588  }
589}
590
591/**
592 * This creates a console object that somewhat replicates Firebug's console
593 * object
594 *
595 * @param {object} aConsoleOptions
596 *        Optional dictionary with a set of runtime console options:
597 *        - prefix {string} : An optional prefix string to be printed before
598 *                            the actual logged message
599 *        - maxLogLevel {string} : String identifier (See LOG_LEVELS for
600 *                            possible values) that allows to filter which
601 *                            messages are logged based on their log level.
602 *                            If falsy value, all messages will be logged.
603 *                            If wrong value that doesn't match any key of
604 *                            LOG_LEVELS, no message will be logged
605 *        - maxLogLevelPref {string} : String pref name which contains the
606 *                            level to use for maxLogLevel. If the pref doesn't
607 *                            exist or gets removed, the maxLogLevel will default
608 *                            to the value passed to this constructor (or "all"
609 *                            if it wasn't specified).
610 *        - dump {function} : An optional function to intercept all strings
611 *                            written to stdout
612 *        - innerID {string}: An ID representing the source of the message.
613 *                            Normally the inner ID of a DOM window.
614 *        - consoleID {string} : String identified for the console, this will
615 *                            be passed through the console notifications
616 * @return {object}
617 *        A console API instance object
618 */
619function ConsoleAPI(aConsoleOptions = {}) {
620  // Normalize console options to set default values
621  // in order to avoid runtime checks on each console method call.
622  this.dump = aConsoleOptions.dump || dump;
623  this.prefix = aConsoleOptions.prefix || "";
624  this.maxLogLevel = aConsoleOptions.maxLogLevel;
625  this.innerID = aConsoleOptions.innerID || null;
626  this.consoleID = aConsoleOptions.consoleID || "";
627
628  // Setup maxLogLevelPref watching
629  let updateMaxLogLevel = () => {
630    if (
631      Services.prefs.getPrefType(aConsoleOptions.maxLogLevelPref) ==
632      Services.prefs.PREF_STRING
633    ) {
634      this._maxLogLevel = Services.prefs
635        .getCharPref(aConsoleOptions.maxLogLevelPref)
636        .toLowerCase();
637    } else {
638      this._maxLogLevel = this._maxExplicitLogLevel;
639    }
640  };
641
642  if (aConsoleOptions.maxLogLevelPref) {
643    updateMaxLogLevel();
644    Services.prefs.addObserver(
645      aConsoleOptions.maxLogLevelPref,
646      updateMaxLogLevel
647    );
648  }
649
650  // Bind all the functions to this object.
651  for (let prop in this) {
652    if (typeof this[prop] === "function") {
653      this[prop] = this[prop].bind(this);
654    }
655  }
656}
657
658ConsoleAPI.prototype = {
659  /**
660   * The last log level that was specified via the constructor or setter. This
661   * is used as a fallback if the pref doesn't exist or is removed.
662   */
663  _maxExplicitLogLevel: null,
664  /**
665   * The current log level via all methods of setting (pref or via the API).
666   */
667  _maxLogLevel: null,
668  debug: createMultiLineDumper("debug"),
669  assert: createDumper("assert"),
670  log: createDumper("log"),
671  info: createDumper("info"),
672  warn: createDumper("warn"),
673  error: createMultiLineDumper("error"),
674  exception: createMultiLineDumper("error"),
675
676  trace: function Console_trace() {
677    if (!shouldLog("trace", this.maxLogLevel)) {
678      return;
679    }
680    let args = Array.prototype.slice.call(arguments, 0);
681    let trace = getStack(Components.stack.caller);
682    sendConsoleAPIMessage(this, "trace", trace[0], args, { stacktrace: trace });
683    dumpMessage(this, "trace", "\n" + formatTrace(trace));
684  },
685  clear: function Console_clear() {},
686
687  dir: createMultiLineDumper("dir"),
688  dirxml: createMultiLineDumper("dirxml"),
689  group: createDumper("group"),
690  groupEnd: createDumper("groupEnd"),
691
692  time: function Console_time() {
693    if (!shouldLog("time", this.maxLogLevel)) {
694      return;
695    }
696    let args = Array.prototype.slice.call(arguments, 0);
697    let frame = getStack(Components.stack.caller, 1)[0];
698    let timer = startTimer(args[0]);
699    sendConsoleAPIMessage(this, "time", frame, args, { timer });
700    dumpMessage(this, "time", "'" + timer.name + "' @ " + new Date());
701  },
702
703  timeEnd: function Console_timeEnd() {
704    if (!shouldLog("timeEnd", this.maxLogLevel)) {
705      return;
706    }
707    let args = Array.prototype.slice.call(arguments, 0);
708    let frame = getStack(Components.stack.caller, 1)[0];
709    let timer = stopTimer(args[0]);
710    sendConsoleAPIMessage(this, "timeEnd", frame, args, { timer });
711    dumpMessage(
712      this,
713      "timeEnd",
714      "'" + timer.name + "' " + timer.duration + "ms"
715    );
716  },
717
718  profile(profileName) {
719    if (!shouldLog("profile", this.maxLogLevel)) {
720      return;
721    }
722    Services.obs.notifyObservers(
723      {
724        wrappedJSObject: {
725          action: "profile",
726          arguments: [profileName],
727          chromeContext: true,
728        },
729      },
730      "console-api-profiler"
731    );
732    dumpMessage(this, "profile", `'${profileName}'`);
733  },
734
735  profileEnd(profileName) {
736    if (!shouldLog("profileEnd", this.maxLogLevel)) {
737      return;
738    }
739    Services.obs.notifyObservers(
740      {
741        wrappedJSObject: {
742          action: "profileEnd",
743          arguments: [profileName],
744          chromeContext: true,
745        },
746      },
747      "console-api-profiler"
748    );
749    dumpMessage(this, "profileEnd", `'${profileName}'`);
750  },
751
752  get maxLogLevel() {
753    return this._maxLogLevel || "all";
754  },
755
756  set maxLogLevel(aValue) {
757    this._maxLogLevel = this._maxExplicitLogLevel = aValue;
758  },
759};
760
761var console = new ConsoleAPI();
762