diff options
Diffstat (limited to 'devtools/client/webconsole/utils/messages.js')
-rw-r--r-- | devtools/client/webconsole/utils/messages.js | 914 |
1 files changed, 914 insertions, 0 deletions
diff --git a/devtools/client/webconsole/utils/messages.js b/devtools/client/webconsole/utils/messages.js new file mode 100644 index 0000000000..aa3682bc37 --- /dev/null +++ b/devtools/client/webconsole/utils/messages.js @@ -0,0 +1,914 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const l10n = require("resource://devtools/client/webconsole/utils/l10n.js"); +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); +const { + isSupportedByConsoleTable, +} = require("resource://devtools/shared/webconsole/messages.js"); + +// URL Regex, common idioms: +// +// Lead-in (URL): +// ( Capture because we need to know if there was a lead-in +// character so we can include it as part of the text +// preceding the match. We lack look-behind matching. +// ^| The URL can start at the beginning of the string. +// [\s(,;'"`“] Or whitespace or some punctuation that does not imply +// a context which would preclude a URL. +// ) +// +// We do not need a trailing look-ahead because our regex's will terminate +// because they run out of characters they can eat. + +// What we do not attempt to have the regexp do: +// - Avoid trailing '.' and ')' characters. We let our greedy match absorb +// these, but have a separate regex for extra characters to leave off at the +// end. +// +// The Regex (apart from lead-in/lead-out): +// ( Begin capture of the URL +// (?: (potential detect beginnings) +// https?:\/\/| Start with "http" or "https" +// www\d{0,3}[.][a-z0-9.\-]{2,249}| +// Start with "www", up to 3 numbers, then "." then +// something that looks domain-namey. We differ from the +// next case in that we do not constrain the top-level +// domain as tightly and do not require a trailing path +// indicator of "/". This is IDN root compatible. +// [a-z0-9.\-]{2,250}[.][a-z]{2,4}\/ +// Detect a non-www domain, but requiring a trailing "/" +// to indicate a path. This only detects IDN domains +// with a non-IDN root. This is reasonable in cases where +// there is no explicit http/https start us out, but +// unreasonable where there is. Our real fix is the bug +// to port the Thunderbird/gecko linkification logic. +// +// Domain names can be up to 253 characters long, and are +// limited to a-zA-Z0-9 and '-'. The roots don't have +// hyphens unless they are IDN roots. Root zones can be +// found here: http://www.iana.org/domains/root/db +// ) +// [-\w.!~*'();,/?:@&=+$#%]* +// path onwards. We allow the set of characters that +// encodeURI does not escape plus the result of escaping +// (so also '%') +// ) +// eslint-disable-next-line max-len +const urlRegex = + /(^|[\s(,;'"`“])((?:https?:\/\/|www\d{0,3}[.][a-z0-9.\-]{2,249}|[a-z0-9.\-]{2,250}[.][a-z]{2,4}\/)[-\w.!~*'();,/?:@&=+$#%]*)/im; + +// Set of terminators that are likely to have been part of the context rather +// than part of the URL and so should be uneaten. This is '(', ',', ';', plus +// quotes and question end-ing punctuation and the potential permutations with +// parentheses (english-specific). +const uneatLastUrlCharsRegex = /(?:[),;.!?`'"]|[.!?]\)|\)[.!?])$/; + +const { + MESSAGE_SOURCE, + MESSAGE_TYPE, + MESSAGE_LEVEL, +} = require("resource://devtools/client/webconsole/constants.js"); +const { + ConsoleMessage, + NetworkEventMessage, +} = require("resource://devtools/client/webconsole/types.js"); + +function prepareMessage(resource, idGenerator) { + if (!resource.source) { + resource = transformResource(resource); + } + + resource.id = idGenerator.getNextId(resource); + return resource; +} + +/** + * Transforms a resource given its type. + * + * @param {Object} resource: This can be either a simple RDP packet or an object emitted + * by the Resource API. + */ +function transformResource(resource) { + switch (resource.resourceType || resource.type) { + case ResourceCommand.TYPES.CONSOLE_MESSAGE: { + return transformConsoleAPICallResource(resource); + } + + case ResourceCommand.TYPES.PLATFORM_MESSAGE: { + return transformPlatformMessageResource(resource); + } + + case ResourceCommand.TYPES.ERROR_MESSAGE: { + return transformPageErrorResource(resource); + } + + case ResourceCommand.TYPES.CSS_MESSAGE: { + return transformCSSMessageResource(resource); + } + + case ResourceCommand.TYPES.NETWORK_EVENT: { + return transformNetworkEventResource(resource); + } + + case "will-navigate": { + return transformNavigationMessagePacket(resource); + } + + case "evaluationResult": + default: { + return transformEvaluationResultPacket(resource); + } + } +} + +// eslint-disable-next-line complexity +function transformConsoleAPICallResource(consoleMessageResource) { + const { message, targetFront } = consoleMessageResource; + + let parameters = message.arguments; + let type = message.level; + let level = getLevelFromType(type); + let messageText = null; + const { timer } = message; + + // Special per-type conversion. + switch (type) { + case "clear": + // We show a message to users when calls console.clear() is called. + parameters = [l10n.getStr("consoleCleared")]; + break; + case "count": + case "countReset": + // Chrome RDP doesn't have a special type for count. + type = MESSAGE_TYPE.LOG; + const { counter } = message; + + if (!counter) { + // We don't show anything if we don't have counter data. + type = MESSAGE_TYPE.NULL_MESSAGE; + } else if (counter.error) { + messageText = l10n.getFormatStr(counter.error, [counter.label]); + level = MESSAGE_LEVEL.WARN; + parameters = null; + } else { + const label = counter.label + ? counter.label + : l10n.getStr("noCounterLabel"); + messageText = `${label}: ${counter.count}`; + parameters = null; + } + break; + case "timeStamp": + type = MESSAGE_TYPE.NULL_MESSAGE; + break; + case "time": + parameters = null; + if (timer && timer.error) { + messageText = l10n.getFormatStr(timer.error, [timer.name]); + level = MESSAGE_LEVEL.WARN; + } else { + // We don't show anything for console.time calls to match Chrome's behaviour. + type = MESSAGE_TYPE.NULL_MESSAGE; + } + break; + case "timeLog": + case "timeEnd": + if (timer && timer.error) { + parameters = null; + messageText = l10n.getFormatStr(timer.error, [timer.name]); + level = MESSAGE_LEVEL.WARN; + } else if (timer) { + // We show the duration to users when calls console.timeLog/timeEnd is called, + // if corresponding console.time() was called before. + const duration = Math.round(timer.duration * 100) / 100; + if (type === "timeEnd") { + messageText = l10n.getFormatStr("console.timeEnd", [ + timer.name, + duration, + ]); + parameters = null; + } else if (type === "timeLog") { + const [, ...rest] = parameters; + parameters = [ + l10n.getFormatStr("timeLog", [timer.name, duration]), + ...rest, + ]; + } + } else { + // If the `timer` property does not exists, we don't output anything. + type = MESSAGE_TYPE.NULL_MESSAGE; + } + break; + case "table": + if (!isSupportedByConsoleTable(parameters)) { + // If the class of the first parameter is not supported, + // we handle the call as a simple console.log + type = "log"; + } + break; + case "group": + type = MESSAGE_TYPE.START_GROUP; + if (parameters.length === 0) { + parameters = [l10n.getStr("noGroupLabel")]; + } + break; + case "groupCollapsed": + type = MESSAGE_TYPE.START_GROUP_COLLAPSED; + if (parameters.length === 0) { + parameters = [l10n.getStr("noGroupLabel")]; + } + break; + case "groupEnd": + type = MESSAGE_TYPE.END_GROUP; + parameters = null; + break; + case "dirxml": + // Handle console.dirxml calls as simple console.log + type = "log"; + break; + } + + const frame = message.filename + ? { + source: message.filename, + sourceId: message.sourceId, + line: message.lineNumber, + column: message.columnNumber, + } + : null; + + if (frame && (type === "logPointError" || type === "logPoint")) { + frame.options = { logPoint: true }; + } + + return new ConsoleMessage({ + targetFront, + source: MESSAGE_SOURCE.CONSOLE_API, + type, + level, + parameters, + messageText, + stacktrace: message.stacktrace ? message.stacktrace : null, + frame, + timeStamp: message.timeStamp, + userProvidedStyles: message.styles, + prefix: message.prefix, + private: message.private, + chromeContext: message.chromeContext, + }); +} + +function transformNavigationMessagePacket(packet) { + const { url } = packet; + return new ConsoleMessage({ + source: MESSAGE_SOURCE.CONSOLE_FRONTEND, + type: MESSAGE_TYPE.NAVIGATION_MARKER, + level: MESSAGE_LEVEL.LOG, + messageText: l10n.getFormatStr("webconsole.navigated", [url]), + timeStamp: packet.timeStamp, + allowRepeating: false, + }); +} + +function transformPlatformMessageResource(platformMessageResource) { + const { message, timeStamp, targetFront } = platformMessageResource; + return new ConsoleMessage({ + targetFront, + source: MESSAGE_SOURCE.CONSOLE_API, + type: MESSAGE_TYPE.LOG, + level: MESSAGE_LEVEL.LOG, + messageText: message, + timeStamp, + chromeContext: true, + }); +} + +function transformPageErrorResource(pageErrorResource, override = {}) { + const { pageError, targetFront } = pageErrorResource; + let level = MESSAGE_LEVEL.ERROR; + if (pageError.warning) { + level = MESSAGE_LEVEL.WARN; + } else if (pageError.info) { + level = MESSAGE_LEVEL.INFO; + } + + const frame = pageError.sourceName + ? { + source: pageError.sourceName, + sourceId: pageError.sourceId, + line: pageError.lineNumber, + column: pageError.columnNumber, + } + : null; + + return new ConsoleMessage( + Object.assign( + { + targetFront, + innerWindowID: pageError.innerWindowID, + source: MESSAGE_SOURCE.JAVASCRIPT, + type: MESSAGE_TYPE.LOG, + level, + category: pageError.category, + messageText: pageError.errorMessage, + stacktrace: pageError.stacktrace ? pageError.stacktrace : null, + frame, + errorMessageName: pageError.errorMessageName, + exceptionDocURL: pageError.exceptionDocURL, + hasException: pageError.hasException, + parameters: pageError.hasException ? [pageError.exception] : null, + timeStamp: pageError.timeStamp, + notes: pageError.notes, + private: pageError.private, + chromeContext: pageError.chromeContext, + isPromiseRejection: pageError.isPromiseRejection, + }, + override + ) + ); +} + +function transformCSSMessageResource(cssMessageResource) { + return transformPageErrorResource(cssMessageResource, { + cssSelectors: cssMessageResource.cssSelectors, + source: MESSAGE_SOURCE.CSS, + }); +} + +function transformNetworkEventResource(networkEventResource) { + return new NetworkEventMessage(networkEventResource); +} + +function transformEvaluationResultPacket(packet) { + let { + exceptionMessage, + errorMessageName, + exceptionDocURL, + exception, + exceptionStack, + hasException, + frame, + result, + helperResult, + timestamp: timeStamp, + notes, + } = packet; + + let parameter; + + if (hasException) { + // If we have an exception, we prefix it, and we reset the exception message, as we're + // not going to use it. + parameter = exception; + exceptionMessage = null; + } else if (helperResult?.object) { + parameter = helperResult.object; + } else if (helperResult?.type === "error") { + try { + exceptionMessage = l10n.getFormatStr( + helperResult.message, + helperResult.messageArgs || [] + ); + } catch (ex) { + exceptionMessage = helperResult.message; + } + } else { + parameter = result; + } + + const level = + typeof exceptionMessage !== "undefined" && packet.exceptionMessage !== null + ? MESSAGE_LEVEL.ERROR + : MESSAGE_LEVEL.LOG; + + return new ConsoleMessage({ + source: MESSAGE_SOURCE.JAVASCRIPT, + type: MESSAGE_TYPE.RESULT, + helperType: helperResult ? helperResult.type : null, + level, + messageText: exceptionMessage, + hasException, + parameters: [parameter], + errorMessageName, + exceptionDocURL, + stacktrace: exceptionStack, + frame, + timeStamp, + notes, + private: packet.private, + allowRepeating: false, + }); +} + +/** + * Return if passed messages are similar and can thus be "repeated". + * ⚠ This function is on a hot path, called for (almost) every message being sent by + * the server. This should be kept as fast as possible. + * + * @param {Message} message1 + * @param {Message} message2 + * @returns {Boolean} + */ +// eslint-disable-next-line complexity +function areMessagesSimilar(message1, message2) { + if (!message1 || !message2) { + return false; + } + + if (!areMessagesParametersSimilar(message1, message2)) { + return false; + } + + if (!areMessagesStacktracesSimilar(message1, message2)) { + return false; + } + + if ( + !message1.allowRepeating || + !message2.allowRepeating || + message1.type !== message2.type || + message1.level !== message2.level || + message1.source !== message2.source || + message1.category !== message2.category || + message1.frame?.source !== message2.frame?.source || + message1.frame?.line !== message2.frame?.line || + message1.frame?.column !== message2.frame?.column || + message1.messageText !== message2.messageText || + message1.private !== message2.private || + message1.errorMessageName !== message2.errorMessageName || + message1.hasException !== message2.hasException || + message1.isPromiseRejection !== message2.isPromiseRejection || + message1.userProvidedStyles?.length !== + message2.userProvidedStyles?.length || + `${message1.userProvidedStyles}` !== `${message2.userProvidedStyles}` + ) { + return false; + } + + return true; +} + +/** + * Return if passed messages parameters are similar + * ⚠ This function is on a hot path, called for (almost) every message being sent by + * the server. This should be kept as fast as possible. + * + * @param {Message} message1 + * @param {Message} message2 + * @returns {Boolean} + */ +function areMessagesParametersSimilar(message1, message2) { + const message1ParamsLength = message1.parameters?.length; + if (message1ParamsLength !== message2.parameters?.length) { + return false; + } + + if (!message1ParamsLength) { + return true; + } + + for (let i = 0; i < message1ParamsLength; i++) { + const message1Parameter = message1.parameters[i]; + const message2Parameter = message2.parameters[i]; + // exceptions have a grip, but we want to consider 2 messages similar as long as + // they refer to the same error. + if ( + message1.hasException && + message2.hasException && + message1Parameter._grip?.class == message2Parameter._grip?.class && + message1Parameter._grip?.preview?.message == + message2Parameter._grip?.preview?.message && + message1Parameter._grip?.preview?.stack == + message2Parameter._grip?.preview?.stack + ) { + continue; + } + + // For object references (grips), that are not exceptions, we don't want to consider + // messages to be the same as we only have a preview of what they look like, and not + // some kind of property that would give us the state of a given instance at a given + // time. + if (message1Parameter._grip || message2Parameter._grip) { + return false; + } + + if (message1Parameter.type !== message2Parameter.type) { + return false; + } + + if (message1Parameter.type) { + if (message1Parameter.text !== message2Parameter.text) { + return false; + } + } else if (message1Parameter !== message2Parameter) { + return false; + } + } + return true; +} + +/** + * Return if passed messages stacktraces are similar + * + * @param {Message} message1 + * @param {Message} message2 + * @returns {Boolean} + */ +function areMessagesStacktracesSimilar(message1, message2) { + const message1StackLength = message1.stacktrace?.length; + if (message1StackLength !== message2.stacktrace?.length) { + return false; + } + + if (!message1StackLength) { + return true; + } + + for (let i = 0; i < message1StackLength; i++) { + const message1Frame = message1.stacktrace[i]; + const message2Frame = message2.stacktrace[i]; + + if (message1Frame.filename !== message2Frame.filename) { + return false; + } + + if (message1Frame.columnNumber !== message2Frame.columnNumber) { + return false; + } + + if (message1Frame.lineNumber !== message2Frame.lineNumber) { + return false; + } + } + return true; +} + +/** + * Maps a Firefox RDP type to its corresponding level. + */ +function getLevelFromType(type) { + const levels = { + LEVEL_ERROR: "error", + LEVEL_WARNING: "warn", + LEVEL_INFO: "info", + LEVEL_LOG: "log", + LEVEL_DEBUG: "debug", + }; + + // A mapping from the console API log event levels to the Web Console levels. + const levelMap = { + error: levels.LEVEL_ERROR, + exception: levels.LEVEL_ERROR, + assert: levels.LEVEL_ERROR, + logPointError: levels.LEVEL_ERROR, + warn: levels.LEVEL_WARNING, + info: levels.LEVEL_INFO, + log: levels.LEVEL_LOG, + clear: levels.LEVEL_LOG, + trace: levels.LEVEL_LOG, + table: levels.LEVEL_LOG, + debug: levels.LEVEL_DEBUG, + dir: levels.LEVEL_LOG, + dirxml: levels.LEVEL_LOG, + group: levels.LEVEL_LOG, + groupCollapsed: levels.LEVEL_LOG, + groupEnd: levels.LEVEL_LOG, + time: levels.LEVEL_LOG, + timeEnd: levels.LEVEL_LOG, + count: levels.LEVEL_LOG, + }; + + return levelMap[type] || MESSAGE_TYPE.LOG; +} + +function isGroupType(type) { + return [ + MESSAGE_TYPE.START_GROUP, + MESSAGE_TYPE.START_GROUP_COLLAPSED, + ].includes(type); +} + +function isPacketPrivate(packet) { + return ( + packet.private === true || + (packet.message && packet.message.private === true) || + (packet.pageError && packet.pageError.private === true) || + (packet.networkEvent && packet.networkEvent.private === true) + ); +} + +function createWarningGroupMessage(id, type, firstMessage) { + return new ConsoleMessage({ + id, + allowRepeating: false, + level: MESSAGE_LEVEL.WARN, + source: MESSAGE_SOURCE.CONSOLE_FRONTEND, + type, + messageText: getWarningGroupLabel(firstMessage), + timeStamp: firstMessage.timeStamp, + innerWindowID: firstMessage.innerWindowID, + }); +} + +function createSimpleTableMessage(columns, items, timeStamp) { + return new ConsoleMessage({ + allowRepeating: false, + level: MESSAGE_LEVEL.LOG, + source: MESSAGE_SOURCE.CONSOLE_FRONTEND, + type: MESSAGE_TYPE.SIMPLE_TABLE, + columns, + items, + timeStamp, + }); +} + +/** + * Given the a regular warning message, compute the label of the warning group the message + * could be in. + * For example, if the message text is: + * The resource at “http://evil.com” was blocked because content blocking is enabled + * + * it may be turned into + * + * The resource at “<URL>” was blocked because content blocking is enabled + * + * @param {ConsoleMessage} firstMessage + * @returns {String} The computed label + */ +function getWarningGroupLabel(firstMessage) { + if ( + isContentBlockingMessage(firstMessage) || + isStorageIsolationMessage(firstMessage) || + isTrackingProtectionMessage(firstMessage) + ) { + return replaceURL(firstMessage.messageText, "<URL>"); + } + + if (isCookieSameSiteMessage(firstMessage)) { + if (Services.prefs.getBoolPref("network.cookie.sameSite.laxByDefault")) { + return l10n.getStr("webconsole.group.cookieSameSiteLaxByDefaultEnabled2"); + } + return l10n.getStr("webconsole.group.cookieSameSiteLaxByDefaultDisabled2"); + } + + if (isCSPMessage(firstMessage)) { + return l10n.getStr("webconsole.group.csp"); + } + + return ""; +} + +/** + * Replace any URL in the provided text by the provided replacement text, or an empty + * string. + * + * @param {String} text + * @param {String} replacementText + * @returns {String} + */ +function replaceURL(text, replacementText = "") { + let result = ""; + let currentIndex = 0; + let contentStart; + while (true) { + const url = urlRegex.exec(text); + // Pick the regexp with the earlier content; index will always be zero. + if (!url) { + break; + } + contentStart = url.index + url[1].length; + if (contentStart > 0) { + const nonUrlText = text.substring(0, contentStart); + result += nonUrlText; + } + + // There are some final characters for a URL that are much more likely + // to have been part of the enclosing text rather than the end of the + // URL. + let useUrl = url[2]; + const uneat = uneatLastUrlCharsRegex.exec(useUrl); + if (uneat) { + useUrl = useUrl.substring(0, uneat.index); + } + + if (useUrl) { + result += replacementText; + } + + currentIndex = currentIndex + contentStart; + + currentIndex = currentIndex + useUrl.length; + text = text.substring(url.index + url[1].length + useUrl.length); + } + + return result + text; +} + +/** + * Get the warningGroup type in which the message could be in. + * @param {ConsoleMessage} message + * @returns {String|null} null if the message can't be part of a warningGroup. + */ +function getWarningGroupType(message) { + // We got report that this can be called with `undefined` (See Bug 1801462 and Bug 1810109). + // Until we manage to reproduce and find why this happens, guard on message so at least + // we don't crash the console. + if (!message) { + return null; + } + + if ( + message.level !== MESSAGE_LEVEL.WARN && + // CookieSameSite messages are not warnings but infos + message.level !== MESSAGE_LEVEL.INFO + ) { + return null; + } + + if (isContentBlockingMessage(message)) { + return MESSAGE_TYPE.CONTENT_BLOCKING_GROUP; + } + + if (isStorageIsolationMessage(message)) { + return MESSAGE_TYPE.STORAGE_ISOLATION_GROUP; + } + + if (isTrackingProtectionMessage(message)) { + return MESSAGE_TYPE.TRACKING_PROTECTION_GROUP; + } + + if (isCookieSameSiteMessage(message)) { + return MESSAGE_TYPE.COOKIE_SAMESITE_GROUP; + } + + if (isCSPMessage(message)) { + return MESSAGE_TYPE.CSP_GROUP; + } + + return null; +} + +/** + * Returns a computed id given a message + * + * @param {ConsoleMessage} type: the message type, from MESSAGE_TYPE. + * @param {Integer} innerWindowID: the message innerWindowID. + * @returns {String} + */ +function getParentWarningGroupMessageId(message) { + const warningGroupType = getWarningGroupType(message); + if (!warningGroupType) { + return null; + } + + return `${warningGroupType}-${message.innerWindowID}`; +} + +/** + * Returns true if the message is a warningGroup message (i.e. the "Header"). + * @param {ConsoleMessage} message + * @returns {Boolean} + */ +function isWarningGroup(message) { + return ( + message.type === MESSAGE_TYPE.CONTENT_BLOCKING_GROUP || + message.type === MESSAGE_TYPE.STORAGE_ISOLATION_GROUP || + message.type === MESSAGE_TYPE.TRACKING_PROTECTION_GROUP || + message.type === MESSAGE_TYPE.COOKIE_SAMESITE_GROUP || + message.type === MESSAGE_TYPE.CORS_GROUP || + message.type === MESSAGE_TYPE.CSP_GROUP + ); +} + +/** + * Returns true if the message is a content blocking message. + * @param {ConsoleMessage} message + * @returns {Boolean} + */ +function isContentBlockingMessage(message) { + const { category } = message; + return ( + category == "cookieBlockedPermission" || + category == "cookieBlockedTracker" || + category == "cookieBlockedAll" || + category == "cookieBlockedForeign" + ); +} + +/** + * Returns true if the message is a storage isolation message. + * @param {ConsoleMessage} message + * @returns {Boolean} + */ +function isStorageIsolationMessage(message) { + const { category } = message; + return category == "cookiePartitionedForeign"; +} + +/** + * Returns true if the message is a tracking protection message. + * @param {ConsoleMessage} message + * @returns {Boolean} + */ +function isTrackingProtectionMessage(message) { + const { category } = message; + return category == "Tracking Protection"; +} + +/** + * Returns true if the message is a cookie message. + * @param {ConsoleMessage} message + * @returns {Boolean} + */ +function isCookieSameSiteMessage(message) { + const { category } = message; + return category == "cookieSameSite"; +} + +/** + * Returns true if the message is a Content Security Policy (CSP) message. + * @param {ConsoleMessage} message + * @returns {Boolean} + */ +function isCSPMessage(message) { + const { category } = message; + return typeof category == "string" && category.startsWith("CSP_"); +} + +function getDescriptorValue(descriptor) { + if (!descriptor) { + return descriptor; + } + + if (Object.prototype.hasOwnProperty.call(descriptor, "safeGetterValues")) { + return descriptor.safeGetterValues; + } + + if (Object.prototype.hasOwnProperty.call(descriptor, "getterValue")) { + return descriptor.getterValue; + } + + if (Object.prototype.hasOwnProperty.call(descriptor, "value")) { + return descriptor.value; + } + return descriptor; +} + +function getNaturalOrder(messageA, messageB) { + const aFirst = -1; + const bFirst = 1; + + // It can happen that messages are emitted in the same microsecond, making their + // timestamp similar. In such case, we rely on which message came first through + // the console API service, checking their id, except for expression result, which we'll + // always insert after because console API messages emitted from the expression need to + // be rendered before. + if (messageA.timeStamp === messageB.timeStamp) { + if (messageA.type === "result") { + return bFirst; + } + + if (messageB.type === "result") { + return aFirst; + } + + if ( + !Number.isNaN(parseInt(messageA.id, 10)) && + !Number.isNaN(parseInt(messageB.id, 10)) + ) { + return parseInt(messageA.id, 10) < parseInt(messageB.id, 10) + ? aFirst + : bFirst; + } + } + return messageA.timeStamp < messageB.timeStamp ? aFirst : bFirst; +} + +function isMessageNetworkError(message) { + return ( + message.source === MESSAGE_SOURCE.NETWORK && + message?.status && + message?.status.toString().match(/^[4,5]\d\d$/) + ); +} + +module.exports = { + areMessagesSimilar, + createWarningGroupMessage, + createSimpleTableMessage, + getDescriptorValue, + getNaturalOrder, + getParentWarningGroupMessageId, + getWarningGroupType, + isContentBlockingMessage, + isGroupType, + isMessageNetworkError, + isPacketPrivate, + isWarningGroup, + l10n, + prepareMessage, +}; |