diff --git a/example/interface.js b/example/interface.js index 5ed3855b..65b126a5 100644 --- a/example/interface.js +++ b/example/interface.js @@ -8,10 +8,19 @@ // See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html // -import { Vircadia, DomainServer, AudioMixer, AvatarMixer, MessageMixer, Uuid } from "../dist/Vircadia.js"; +import { + Vircadia, + DefaultLogConfiguration, + DomainServer, AudioMixer, + AvatarMixer, + MessageMixer, + Uuid +} from "../dist/Vircadia.js"; (function () { + DefaultLogConfiguration.filter = undefined; + const DEFAULT_URL = "ws://127.0.0.1:40102"; // Shared context. diff --git a/src/Vircadia.ts b/src/Vircadia.ts index df78976a..da169045 100644 --- a/src/Vircadia.ts +++ b/src/Vircadia.ts @@ -48,3 +48,11 @@ export type { vec3 } from "./domain/shared/Vec3"; export { default as Quat } from "./domain/shared/Quat"; export type { quat } from "./domain/shared/Quat"; + +export { + DefaultLogConfiguration, + ConsoleLoggerContext, + LogFilterContext, + LoggerContextCombination, + LogReportContext +} from "./shared/Log"; diff --git a/src/domain/audio-client/AudioClient.ts b/src/domain/audio-client/AudioClient.ts index d3f432dd..e1c799e3 100644 --- a/src/domain/audio-client/AudioClient.ts +++ b/src/domain/audio-client/AudioClient.ts @@ -22,6 +22,7 @@ import PacketScribe from "../networking/packets/PacketScribe"; import PacketType, { PacketTypeValue } from "../networking/udt/PacketHeaders"; import assert from "../shared/assert"; import ContextManager from "../shared/ContextManager"; +import Log from "../../shared/Log"; import Vec3, { vec3 } from "../shared/Vec3"; @@ -434,7 +435,7 @@ class AudioClient { #processStreamStatsPacket = (message: ReceivedMessage, sendingNode: Node | null): void => { // eslint-disable-line // C++ void AudioIOStats::processStreamStatsPacket(ReceivedMessage*, Node* sendingNode) - console.warn("AudioClient: AudioStreamStats packet not processed."); + Log.one.warning("AudioClient: AudioStreamStats packet not processed."); // WEBRTC TODO: Address further C++ code. @@ -446,7 +447,7 @@ class AudioClient { #handleAudioEnvironmentDataPacket = (message: ReceivedMessage): void => { // eslint-disable-line // C++ void handleAudioEnvironmentDataPacket(ReceivedMessage* message) - console.warn("AudioClient: AudioEnvironment packet not processed."); + Log.one.warning("AudioClient: AudioEnvironment packet not processed."); // WEBRTC TODO: Address further C++ code. diff --git a/src/domain/audio/InboundAudioStream.ts b/src/domain/audio/InboundAudioStream.ts index 51f9e20b..9b1890f9 100644 --- a/src/domain/audio/InboundAudioStream.ts +++ b/src/domain/audio/InboundAudioStream.ts @@ -16,6 +16,7 @@ import { SilentAudioFrameDetails } from "../networking/packets/SilentAudioFrame" import PacketType from "../networking/udt/PacketHeaders"; import UDT from "../networking/udt/UDT"; import ContextManager from "../shared/ContextManager"; +import Log from "../../shared/Log"; /*@devdoc @@ -124,7 +125,7 @@ class InboundAudioStream { // C++ int writeDroppableSilentFrames(int silentFrames) // WEBRTC TODO: Address further C++ code. - console.warn("InboundAudioStream.#writeDroppableSilentFrames() not implemented. Frames:", silentFrames); + Log.one.warning("InboundAudioStream.#writeDroppableSilentFrames() not implemented. Frames:", silentFrames); } diff --git a/src/shared/Log.ts b/src/shared/Log.ts new file mode 100644 index 00000000..5e71c54e --- /dev/null +++ b/src/shared/Log.ts @@ -0,0 +1,529 @@ +// +// Log.ts +// +// Created by Nshan G. on 29 Oct 2021. +// Copyright 2021 Vircadia contributors. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +/*@devdoc + * The levels of the Logger class. + * @enum {number} + * @property {number} DEBUG + * @property {number} DEFAULT + * @property {number} INFO + * @property {number} WARNING + * @property {number} ERROR + */ +enum LogLevel { + DEBUG, + DEFAULT, + INFO, + WARNING, + ERROR +} + +/*@devdoc + * An array containing all values of LogLevel enum in ascending order. + * @type {Array} + */ +const AllLogLevels = [ + LogLevel.DEBUG, + LogLevel.DEFAULT, + LogLevel.INFO, + LogLevel.WARNING, + LogLevel.ERROR +] as const; + +type LoggablePrimitive = string | symbol | number | bigint | boolean | undefined | null; + +type LoggableRecord = LoggablePrimitive | Record; + +interface Loggable { + toLoggable(): LoggableRecord; +} + +type LoggableObject = LoggablePrimitive | Loggable; + +type LoggableObjectRecord = LoggableObject | Record; + +type LogMessagePredicate = (level: LogLevel, tags: Set, ...loggables: LoggableRecord[]) => boolean; + +interface LoggerContext { + log(level: LogLevel, tags: Set, ...loggables: LoggableRecord[]): void; +} + +type LoggerConfiguration = { + + context: LoggerContext; + filter?: LogMessagePredicate; + levelFilter?: (level: LogLevel) => boolean; + tagFilter?: Array; + +}; + +function loggablePrimitiveToString(loggable: LoggablePrimitive): string | undefined { + if (loggable === null) { + // NOTE: eslint disallows both + // "" + loggable + // and + // `${loggable}` + // I'm out of ideas, also for the undefined case below + return "null"; + } + + switch (typeof loggable) { + case "string": + return loggable; + + case "number": + case "boolean": + case "symbol": + return loggable.toLocaleString(); + + case "bigint": + return BigInt(loggable).toLocaleString(); + + case "undefined": + return "undefined"; + + default: + return undefined; + } +} + +function loggableRecordToString(loggable: Record): string { + let str = "\n"; + for (const [key, value] of Object.entries(loggable)) { + str += ` ${key}: ${loggablePrimitiveToString(value) as string}\n`; + } + return str; +} + +function loggableToString(loggable: LoggableRecord) { + return loggablePrimitiveToString(loggable as LoggablePrimitive) + || loggableRecordToString(loggable as Record); +} + +/*@sdkdoc + * The ConsoleLoggerContext is a logger output configuration for logging to the dev console. + * + * @class ConsoleLoggerContext + * @implements LoggerContext + */ +class ConsoleLoggerContext implements LoggerContext { + + /*@sdkdoc + * The basic types that should be possible to log. + * @typedef {string | symbol | number | bigint | boolean | undefined | null} LoggablePrimitive + */ + + /*@sdkdoc + * The message body structure that every implementation of {@linkcode LoggerContext} must support logging. + * @typedef {LoggablePrimitive | Record} LoggableRecord + */ + + /*@sdkdoc + * The LoggerContext is an interface used in {@linkcode LoggerConfiguration} + * to specify the output of the {@linkcode Logger} class. + * @interface LoggerContext + */ + + /*@sdkdoc + * Outputs the log message. + * @function + * @name LoggerContext#log + * @param {LogLevel} level - The level to log the message at. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + */ + + /* eslint-disable class-methods-use-this */ + /*@sdkdoc + * Outputs the log to the developer console. + * @param {LogLevel} level - The level to log the message at. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + */ + log(level: LogLevel, tags: Set, ...loggables: LoggableRecord[]): void { + const logFunction = [console.debug, console.log, console.info, console.warn, console.error][level]; + if (logFunction) { + if (tags.size === 0) { + logFunction(...loggables); + } else { + logFunction(`[${[...tags].join("][")}]`, ...loggables); + } + } + } + /* eslint-enable class-methods-use-this */ +} + +/*@sdkdoc + * The StringLoggerContext is a output configuration for logging to a string. + * + * @class StringLoggerContext + * @implements LoggerContext + * @property {string} buffer - The buffer to which all the log messages will be written. + */ +class StringLoggerContext implements LoggerContext { + + buffer = ""; + + /*@sdkdoc + * Outputs the log to the string buffer. + * @param {LogLevel} level - The level to log the message at. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + */ + log(level: LogLevel, tags: Set, ...loggables: LoggableRecord[]): void { + if (tags.size !== 0) { + this.buffer += `[${[...tags].join("][")}]`; + } + this.buffer += `[${LogLevel[level] as string}]`; + this.buffer += ` ${loggables.map(loggableToString).join(" ")}\n`; + } + +} + +/*@sdkdoc + * The LogFilterContext is a logger context wrapper for filtering messages. + * + * @class LogFilterContext + * @implements LoggerContext + * @param {LoggerContext} context - The buffer to forward the filtered logs to. + * @param {LogMessagePredicate} filter - The filter to apply to the messages. + */ +class LogFilterContext implements LoggerContext { + + /*@sdkdoc + * A predicate for filtering log messages. + * @callback LogMessagePredicate + * @param {LogLevel} level - The logging level of the message. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + * @return {boolean} - Whether to include/allow the message. + */ + + #_context: LoggerContext; + #_filter: LogMessagePredicate; + + constructor(context: LoggerContext, filter: LogMessagePredicate) { + this.#_context = context; + this.#_filter = filter; + } + + /*@devdoc + * Passes logs that satisfy the specified filter to the specified context. + * @param {LogLevel} level - The level to log the message at. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + */ + log(level: LogLevel, tags: Set, ...loggables: LoggableRecord[]): void { + if (this.#_filter(level, tags, ...loggables)) { + this.#_context.log(level, tags, ...loggables); + } + } +} + +/*@sdkdoc + * The LoggerContextCombination combines several logger contexts into one. + * + * @class LoggerContextCombination + * @implements LoggerContext + * @param {...LoggerContext} contexts - The contexts to combine. + */ +class LoggerContextCombination implements LoggerContext { + + #_contexts: LoggerContext[]; + + constructor(...contexts: LoggerContext[]) { + this.#_contexts = contexts; + } + + /*@sdkdoc + * Passes log message to all specified contexts. + * @param {LogLevel} level - The level to log the message at. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + */ + log(level: LogLevel, tags: Set, ...loggables: LoggableRecord[]): void { + for (const context of this.#_contexts) { + context.log(level, tags, ...loggables); + } + } +} + +/*@sdkdoc + * The LogReportContext buffers log messages and passes them to specified context + * when a message is logged at the ERROR level or above. + * + * @class LogReportContext + * @implements LoggerContext + * @param {LoggerContext} context - The contexts to pass the messages to. + * @param {number} [logCount] - The number of log messages to buffer. + * If unspecified all messages will be buffered. + */ +class LogReportContext implements LoggerContext { + + #_context: LoggerContext; + #_logCount?: number; + #_report: Array<[LogLevel, Set, LoggableRecord[]]> = []; + + constructor(context: LoggerContext, logCount?: number) { + this.#_context = context; + this.#_logCount = logCount; + } + + /*@sdkdoc + * Buffers the logs and passes them to the specified context. + * @param {LogLevel} level - The level to log the message at. + * @param {Set} tags - The tags associated with the message. + * @param {...LoggableRecord} loggables - A list of objects that comprise the message body. + */ + log(level: LogLevel, tags: Set, ...loggables: LoggableRecord[]): void { + + this.#_report.push([level, tags, loggables]); + + if (this.#_logCount && this.#_report.length > this.#_logCount) { + this.#_report.shift(); + } + + if (level >= LogLevel.ERROR) { + for (const log of this.#_report) { + this.#_context.log(log[0], log[1], ...log[2]); + } + this.#_report.length = 0; + } + } +} + +/*@devdoc + * The Logger class serves as a convenience utility and a centralized configuration point for logging. + * + * @class Logger + * @param {LoggerConfiguration} configuration - The configuration this instance will use for output and filtering. + * @property {number} DEBUG - Alias for LogLevel.DEBUG. + * @property {number} DEFAULT - Alias for LogLevel.DEFAULT. + * @property {number} INFO - Alias for LogLevel.INFO. + * @property {number} WARNING - Alias for LogLevel.WARNING. + * @property {number} ERROR - Alias for LogLevel.ERROR. + * @property {Logger} one - Equivalent to calling {@linkcode Logger#once} with true + */ +class Logger { + + /*@devdoc + * Implementing this interface will allow passing the objects of the class to the {@linkcode Logger}. + * @interface Loggable + */ + + /*@devdoc + * Converts the object to a loggable representation. + * @function + * @name Loggable#toLoggable + * @return {LoggableRecord} - a representation of the object based on loggable primitives. + */ + + /*@devdoc + * Basic type of objects that can be passed to the {@linkcode Logger} for logging. + * @typedef {LoggablePrimitive | Loggable} LoggableObject + */ + + /*@devdoc + * The message body structure that {@linkcode Logger} supports. + * + * @typedef {LoggableObject | Record} LoggableObjectRecord + */ + + /*@devdoc + * Predicate function for filtering log messages based on log level. + * @callback LogLevelPredicate + * @param {LogLevel} level - The level in question. + * @return {boolean} Whether to show messages of the given level. + */ + + /*@sdkdoc + * The LoggerConfiguration is an interface for configuration of the {@linkcode Logger} class. + * @typedef {object} LoggerConfiguration + * @property {LoggerContext} context - The context that specifies the logger output. + * @property {LogMessagePredicate} [filter] - A full message filter that is applied to log input, + * before it is passed to the context. + * @property {LogLevelPredicate} [levelFilter] - A level filter that is applied to log input, + * before the {@linkcode LoggerConfiguration#filter}. + * @property {Array} [tagFilter] - A tag specific filter that is applied to log input, + * before the {@linkcode LoggerConfiguration#filter}. + * A given message will be included if it has any of the specified tags. + * undefined value in the array specifies to include messages without any tags. + */ + + readonly DEBUG = LogLevel.DEBUG; + readonly DEFAULT = LogLevel.DEFAULT; + readonly INFO = LogLevel.INFO; + readonly WARNING = LogLevel.WARNING; + readonly ERROR = LogLevel.ERROR; + + #_configuration: LoggerConfiguration; + #_tags = new Set(); + #_tagsKey = ""; + #_once = false; + #_messageFlags = new Map(); + + constructor(configuration: LoggerConfiguration) { + this.#_configuration = configuration; + this.#_updateTagsKey(); + } + + /*@devdoc + * Copy this logger instance adding specified tags. + * @param {...string} tags - The tags to add to the new logger instance. + * @return {Logger} - A new logger instance with specified tags added. + */ + tag(...tags: string[]): Logger { + const tagged = new Logger(this.#_configuration); + tagged.#_tags = new Set([...this.#_tags, ...tags]); + tagged.#_updateTagsKey(); + tagged.#_once = this.#_once; + tagged.#_messageFlags = this.#_messageFlags; + return tagged; + } + + /*@devdoc + * Copy this logger instance specifying whether to eliminate duplicate logs or not. + * @param {boolean} enabled - true - prevent duplicates, false - allow duplicates. + * @return {Logger} - A new logger instance with specified behavior. + */ + once(enabled: boolean): Logger { + const onced = new Logger(this.#_configuration); + onced.#_tags = this.#_tags; + onced.#_tagsKey = this.#_tagsKey; + onced.#_once = enabled; + onced.#_messageFlags = this.#_messageFlags; + return onced; + } + + get one(): Logger { + return this.once(true); + } + + /*@devdoc + * Logs a message at the specified level. + * @param {LogLevel} level - The level to log at. + * @param {...LoggableObjectRecord} loggableObjects - The objects that comprise the message body. + */ + level(level: LogLevel, ...loggableObjects: LoggableObjectRecord[]): void { + const loggables = loggableObjects.map((obj) => { + const loggable = obj as Loggable; + if (loggable && typeof loggable.toLoggable === "function") { + return loggable.toLoggable(); + } + return obj as LoggableRecord; + }); + + if (this.#_once) { + const id = `${this.#_tagsKey} | ${level} | ${loggables.map(loggableToString).join(" | ")}`; + + if (!this.#_messageFlags.get(id)) { + this.#_messageFlags.set(id, true); + } else { + return; + } + } + + const levelFilter = this.#_configuration.levelFilter; + const tagFilter = this.#_configuration.tagFilter; + const filter = this.#_configuration.filter; + if ((!levelFilter || levelFilter(level)) + && (!tagFilter || tagFilter.filter((tag) => { + return tag === undefined && this.#_tags.size === 0 + || this.#_tags.has(tag as string); + }).length > 0) + && (!filter || filter(level, this.#_tags, ...loggables))) { + this.#_configuration.context.log(level, this.#_tags, ...loggables); + } + } + + /*@devdoc + * Logs a message at the default level. + * @param {...LoggableObjectRecord} loggables - The objects that comprise the message body. + */ + message(...loggables: LoggableObjectRecord[]): void { + this.level(LogLevel.DEFAULT, ...loggables); + } + + /*@devdoc + * Logs a message at the info level. + * @param {...LoggableObjectRecord} loggables - The objects that comprise the message body. + */ + info(...loggables: LoggableObjectRecord[]): void { + this.level(LogLevel.INFO, ...loggables); + } + + /*@devdoc + * Logs a message at the debug level. + * @param {...LoggableObjectRecord} loggables - The objects that comprise the message body. + */ + debug(...loggables: LoggableObjectRecord[]): void { + this.level(LogLevel.DEBUG, ...loggables); + } + + /*@devdoc + * Logs a message at the warning level. + * @param {...LoggableObjectRecord} loggables - The objects that comprise the message body. + */ + warning(...loggables: LoggableObjectRecord[]): void { + this.level(LogLevel.WARNING, ...loggables); + } + + /*@devdoc + * Logs a message at the error level. + * @param {...LoggableObjectRecord} loggables - The objects that comprise the message body. + */ + error(...loggables: LoggableObjectRecord[]): void { + this.level(LogLevel.ERROR, ...loggables); + } + + #_updateTagsKey(): void { + const tagSizes = [...this.#_tags].map((tag) => { + return tag.length; + }).join(","); + const tagsString = [...this.#_tags].join(); + this.#_tagsKey = `${tagSizes} | ${tagsString}`; + } + +} + +/*@sdkdoc + * The DefaultLogConfiguration allows the customization of the SDK logger output. + * By default the context is {@linkcode ConsoleLoggerContext}, and filter is set to allow warning and error messages only. + * @type {LoggerConfiguration} + */ +const DefaultLogConfiguration = { + context: new ConsoleLoggerContext(), + filter: (level: LogLevel) => { + return level >= LogLevel.WARNING; + } +} as LoggerConfiguration; + +/*@devdoc + * The Log is the main {@linkcode Logger} instance used in the SDK, + * initialized with {@linkcode DefaultLogConfiguration} and a "vircadia-sdk" tag. + * @type {Logger} + */ +const Log = new Logger(DefaultLogConfiguration).tag("vircadia-sdk"); + +export default Log; +export { + LogLevel, + AllLogLevels, + Loggable, + LoggerContext, + LoggerConfiguration, + ConsoleLoggerContext, + StringLoggerContext, + LogFilterContext, + LoggerContextCombination, + LogReportContext, + Logger, + DefaultLogConfiguration +}; diff --git a/tests/shared/Log.unit.test.js b/tests/shared/Log.unit.test.js new file mode 100644 index 00000000..b99ebe25 --- /dev/null +++ b/tests/shared/Log.unit.test.js @@ -0,0 +1,572 @@ +// +// Log.unit.test.js +// +// Created by Nshan G. on 30 Oct 2021. +// Copyright 2021 Vircadia contributors. +// +// Distributed under the Apache License, Version 2.0. +// See the accompanying file LICENSE or http://www.apache.org/licenses/LICENSE-2.0.html +// + +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +import Log, { + Logger, + LogLevel, + ConsoleLoggerContext, + StringLoggerContext, + LoggerContextCombination, + LogFilterContext, + LogReportContext, + DefaultLogConfiguration +} from "../../src/shared/Log"; + +describe("Logger - unit tests", () => { + + test("Console context", () => { + + const logger = new Logger({ context: new ConsoleLoggerContext() }); + + const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* no-op */ }); + const log = jest.spyOn(console, "log").mockImplementation(() => { /* no-op */ }); + const info = jest.spyOn(console, "info").mockImplementation(() => { /* no-op */ }); + const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* no-op */ }); + const error = jest.spyOn(console, "error").mockImplementation(() => { /* no-op */ }); + + logger.debug("Console debug message"); + logger.message("Console log message"); + logger.info("Console info message"); + logger.warning("Console warning message"); + logger.error("Console error message"); + + expect(debug).toHaveBeenCalledWith("Console debug message"); + expect(log).toHaveBeenCalledWith("Console log message"); + expect(info).toHaveBeenCalledWith("Console info message"); + expect(warn).toHaveBeenCalledWith("Console warning message"); + expect(error).toHaveBeenCalledWith("Console error message"); + + debug.mockClear(); + log.mockClear(); + info.mockClear(); + warn.mockClear(); + error.mockClear(); + + logger.tag("Type 1").debug("Console debug message"); + logger.tag("Type 2").message("Console log message"); + logger.tag("Type 3").info("Console info message"); + logger.tag("Type 4").warning("Console warning message"); + logger.tag("Type 5").error("Console error message"); + + expect(debug).toHaveBeenCalledWith("[Type 1]", "Console debug message"); + expect(log).toHaveBeenCalledWith("[Type 2]", "Console log message"); + expect(info).toHaveBeenCalledWith("[Type 3]", "Console info message"); + expect(warn).toHaveBeenCalledWith("[Type 4]", "Console warning message"); + expect(error).toHaveBeenCalledWith("[Type 5]", "Console error message"); + + debug.mockRestore(); + log.mockRestore(); + info.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + + }); + + test("String context", () => { + const context = new StringLoggerContext(); + const logger = new Logger({ context }); + + logger.debug("Debug message"); + logger.message("Default message"); + logger.info("Info message"); + logger.warning("Warning message"); + logger.error("Error message"); + + expect(context.buffer).toBe("" + + "[DEBUG] Debug message\n" + + "[DEFAULT] Default message\n" + + "[INFO] Info message\n" + + "[WARNING] Warning message\n" + + "[ERROR] Error message\n" + ); + context.buffer = ""; + + logger.tag("Type 1").debug("Debug message"); + logger.tag("Type 2").message("Default message"); + logger.tag("Type 3").info("Info message"); + logger.tag("Type 4").warning("Warning message"); + logger.tag("Type 5").error("Error message"); + + expect(context.buffer).toBe("" + + "[Type 1][DEBUG] Debug message\n" + + "[Type 2][DEFAULT] Default message\n" + + "[Type 3][INFO] Info message\n" + + "[Type 4][WARNING] Warning message\n" + + "[Type 5][ERROR] Error message\n" + ); + }); + + test("Filtering by level", () => { + const context = new StringLoggerContext(); + const config = { context }; + const logger = new Logger(config); + + config.levelFilter = (level) => { + return level >= LogLevel.INFO; + }; + + logger.debug("Debug message"); + logger.message("Default message"); + logger.info("Info message"); + logger.warning("Warning message"); + logger.error("Error message"); + + expect(context.buffer).toBe("" + + "[INFO] Info message\n" + + "[WARNING] Warning message\n" + + "[ERROR] Error message\n" + ); + context.buffer = ""; + + config.levelFilter = (level) => { + return level <= LogLevel.DEFAULT; + }; + + logger.debug("Debug message"); + logger.message("Default message"); + logger.info("Info message"); + logger.warning("Warning message"); + logger.error("Error message"); + + expect(context.buffer).toBe("" + + "[DEBUG] Debug message\n" + + "[DEFAULT] Default message\n" + ); + context.buffer = ""; + + config.levelFilter = (level) => { + return [LogLevel.DEBUG, LogLevel.ERROR].includes(level); + }; + + logger.debug("Debug message"); + logger.message("Default message"); + logger.info("Info message"); + logger.warning("Warning message"); + logger.error("Error message"); + + expect(context.buffer).toBe("" + + "[DEBUG] Debug message\n" + + "[ERROR] Error message\n" + ); + + }); + + test("Filtering by tags", () => { + const context = new StringLoggerContext(); + const config = { context }; + const logger = new Logger(config); + + config.tagFilter = ["Type 2", "Type 4"]; + + logger.message("message"); + logger.tag("Type 1").message("message 1"); + logger.tag("Type 2").message("message 2"); + logger.tag("Type 3").message("message 3"); + logger.tag("Type 4").message("message 4"); + logger.tag("Type 5").message("message 5"); + + expect(context.buffer).toBe("" + + "[Type 2][DEFAULT] message 2\n" + + "[Type 4][DEFAULT] message 4\n" + ); + context.buffer = ""; + + config.tagFilter = ["Type 1", "Type 3", undefined]; + + logger.message("message"); + logger.tag("Type 1").message("message 1"); + logger.tag("Type 2").message("message 2"); + logger.tag("Type 3").message("message 3"); + logger.tag("Type 4").message("message 4"); + logger.tag("Type 5").message("message 5"); + + expect(context.buffer).toBe("" + + "[DEFAULT] message\n" + + "[Type 1][DEFAULT] message 1\n" + + "[Type 3][DEFAULT] message 3\n" + ); + context.buffer = ""; + + config.tagFilter = [undefined]; + + logger.message("message"); + logger.tag("Type 1").message("message 1"); + logger.tag("Type 2").message("message 2"); + logger.tag("Type 3").message("message 3"); + logger.tag("Type 4").message("message 4"); + logger.tag("Type 5").message("message 5"); + + expect(context.buffer).toBe("" + + "[DEFAULT] message\n" + ); + context.buffer = ""; + + config.tagFilter = undefined; + + logger.message("message"); + logger.tag("Type 1").message("message 1"); + logger.tag("Type 2").message("message 2"); + logger.tag("Type 3").message("message 3"); + logger.tag("Type 4").message("message 4"); + logger.tag("Type 5").message("message 5"); + + expect(context.buffer).toBe("" + + "[DEFAULT] message\n" + + "[Type 1][DEFAULT] message 1\n" + + "[Type 2][DEFAULT] message 2\n" + + "[Type 3][DEFAULT] message 3\n" + + "[Type 4][DEFAULT] message 4\n" + + "[Type 5][DEFAULT] message 5\n" + ); + context.buffer = ""; + + }); + + test("Mixed filtering", () => { + const context = new StringLoggerContext(); + const config = { context }; + const logger = new Logger(config); + + config.levelFilter = (level) => { + return level <= LogLevel.DEFAULT; + }; + config.tagFilter = ["Type 2"]; + + logger.message("message"); + logger.tag("Type 1").debug("Debug message 1"); + logger.tag("Type 2").message("Default message 2"); + logger.tag("Type 3").info("Info message 1"); + logger.tag("Type 4").warning("message 2"); + logger.tag("Type 5").error("message 1"); + + expect(context.buffer).toBe("" + + "[Type 2][DEFAULT] Default message 2\n" + ); + context.buffer = ""; + }); + + test("Log a message only once", () => { + const context = new StringLoggerContext(); + const config = { context }; + const logger = new Logger(config); + + logger.one.debug("message"); + logger.one.debug("message"); + logger.one.debug("message"); + + logger.tag("Type").one.debug("message"); + logger.tag("Type").one.debug("message"); + logger.tag("Type").one.debug("message"); + + logger.tag("undefined").one.debug("message"); + logger.tag("undefined").one.debug("message"); + logger.tag("undefined").one.debug("message"); + + logger.one.info("message"); + logger.one.info("message"); + logger.one.info("message"); + + logger.one.debug("another message"); + logger.one.debug("another message"); + logger.one.debug("another message"); + + expect(context.buffer).toBe("" + + "[DEBUG] message\n" + + "[Type][DEBUG] message\n" + + "[undefined][DEBUG] message\n" + + "[INFO] message\n" + + "[DEBUG] another message\n" + ); + context.buffer = ""; + }); + + test("Log a record", () => { + const context = new StringLoggerContext(); + const logger = new Logger({ context }); + + const big = BigInt("9999999999999999999999999999999999999999999999"); + + logger.message("a record is:", { + str: "str", + sym: Symbol("sym"), + num: 2, + bnum: big, + bool: true, + und: undefined, + nil: null + }); + + expect(context.buffer).toBe("" + + "[DEFAULT] a record is: \n" + + " str: str\n" + + " sym: Symbol(sym)\n" + + " num: 2\n" + + ` bnum: ${big.toLocaleString()}\n` + + " bool: true\n" + + " und: undefined\n" + + " nil: null\n" + + "\n" + ); + context.buffer = ""; + }); + + test("Multiple tags", () => { + const context = new StringLoggerContext(); + const config = { context }; + const logger = new Logger(config).tag("toplevel"); + + logger.tag("one", "two").message("message"); + logger.tag("two", "three").message("message"); + logger.tag("four", "five").message("message"); + + expect(context.buffer).toBe("" + + "[toplevel][one][two][DEFAULT] message\n" + + "[toplevel][two][three][DEFAULT] message\n" + + "[toplevel][four][five][DEFAULT] message\n" + ); + context.buffer = ""; + + config.tagFilter = ["one"]; + + logger.tag("one", "two").message("message"); + logger.tag("two", "three").message("message"); + logger.tag("four", "one").message("message"); + + expect(context.buffer).toBe("" + + "[toplevel][one][two][DEFAULT] message\n" + + "[toplevel][four][one][DEFAULT] message\n" + ); + + }); + + test("Contexts composition", () => { + const context1 = new StringLoggerContext(); + const context2 = new StringLoggerContext(); + const filtered = new StringLoggerContext(); + const buglog = new StringLoggerContext(); + + + const logger = new Logger({ + context: new LoggerContextCombination( + context1, + context2, + new LogFilterContext(filtered, (level) => { + return level === LogLevel.INFO; + }), + new LogReportContext(buglog, 3) + ) + }); + + logger.message("message 1"); + logger.message("message 2"); + logger.message("message 3"); + + expect(context1.buffer).toBe("" + + "[DEFAULT] message 1\n" + + "[DEFAULT] message 2\n" + + "[DEFAULT] message 3\n" + ); + + expect(context2.buffer).toBe("" + + "[DEFAULT] message 1\n" + + "[DEFAULT] message 2\n" + + "[DEFAULT] message 3\n" + ); + + expect(filtered.buffer).toBe(""); + expect(buglog.buffer).toBe(""); + + logger.info("message 4"); + logger.warning("minor"); + + expect(context1.buffer).toBe("" + + "[DEFAULT] message 1\n" + + "[DEFAULT] message 2\n" + + "[DEFAULT] message 3\n" + + "[INFO] message 4\n" + + "[WARNING] minor\n" + ); + + expect(context2.buffer).toBe("" + + "[DEFAULT] message 1\n" + + "[DEFAULT] message 2\n" + + "[DEFAULT] message 3\n" + + "[INFO] message 4\n" + + "[WARNING] minor\n" + ); + + expect(filtered.buffer).toBe("" + + "[INFO] message 4\n" + ); + expect(buglog.buffer).toBe(""); + + logger.error("fatal"); + + expect(context1.buffer).toBe("" + + "[DEFAULT] message 1\n" + + "[DEFAULT] message 2\n" + + "[DEFAULT] message 3\n" + + "[INFO] message 4\n" + + "[WARNING] minor\n" + + "[ERROR] fatal\n" + ); + + expect(context2.buffer).toBe("" + + "[DEFAULT] message 1\n" + + "[DEFAULT] message 2\n" + + "[DEFAULT] message 3\n" + + "[INFO] message 4\n" + + "[WARNING] minor\n" + + "[ERROR] fatal\n" + ); + + expect(filtered.buffer).toBe("" + + "[INFO] message 4\n" + ); + + expect(buglog.buffer).toBe("" + + "[INFO] message 4\n" + + "[WARNING] minor\n" + + "[ERROR] fatal\n" + ); + + context1.buffer = ""; + + }); + + test("Default behavior and configuration of the main SDK Logger instance", () => { + + const debug = jest.spyOn(console, "debug").mockImplementation(() => { /* no-op */ }); + const log = jest.spyOn(console, "log").mockImplementation(() => { /* no-op */ }); + const info = jest.spyOn(console, "info").mockImplementation(() => { /* no-op */ }); + const warn = jest.spyOn(console, "warn").mockImplementation(() => { /* no-op */ }); + const error = jest.spyOn(console, "error").mockImplementation(() => { /* no-op */ }); + + Log.debug("Console debug message"); + Log.message("Console log message"); + Log.info("Console info message"); + Log.warning("Console warning message"); + Log.error("Console error message"); + + expect(warn).toHaveBeenCalledWith("[vircadia-sdk]", "Console warning message"); + expect(error).toHaveBeenCalledWith("[vircadia-sdk]", "Console error message"); + + debug.mockClear(); + log.mockClear(); + info.mockClear(); + warn.mockClear(); + error.mockClear(); + + Log.tag("Type 1").debug("Console debug message"); + Log.tag("Type 2").message("Console log message"); + Log.tag("Type 3").info("Console info message"); + Log.tag("Type 4").warning("Console warning message"); + Log.tag("Type 5").error("Console error message"); + + expect(warn).toHaveBeenCalledWith("[vircadia-sdk][Type 4]", "Console warning message"); + expect(error).toHaveBeenCalledWith("[vircadia-sdk][Type 5]", "Console error message"); + + debug.mockClear(); + log.mockClear(); + info.mockClear(); + warn.mockClear(); + error.mockClear(); + + DefaultLogConfiguration.filter = undefined; + + Log.debug("Console debug message"); + Log.message("Console log message"); + Log.info("Console info message"); + Log.warning("Console warning message"); + Log.error("Console error message"); + + expect(debug).toHaveBeenCalledWith("[vircadia-sdk]", "Console debug message"); + expect(log).toHaveBeenCalledWith("[vircadia-sdk]", "Console log message"); + expect(info).toHaveBeenCalledWith("[vircadia-sdk]", "Console info message"); + expect(warn).toHaveBeenCalledWith("[vircadia-sdk]", "Console warning message"); + expect(error).toHaveBeenCalledWith("[vircadia-sdk]", "Console error message"); + + debug.mockClear(); + log.mockClear(); + info.mockClear(); + warn.mockClear(); + error.mockClear(); + + Log.tag("Type 1").debug("Console debug message"); + Log.tag("Type 2").message("Console log message"); + Log.tag("Type 3").info("Console info message"); + Log.tag("Type 4").warning("Console warning message"); + Log.tag("Type 5").error("Console error message"); + + expect(debug).toHaveBeenCalledWith("[vircadia-sdk][Type 1]", "Console debug message"); + expect(log).toHaveBeenCalledWith("[vircadia-sdk][Type 2]", "Console log message"); + expect(info).toHaveBeenCalledWith("[vircadia-sdk][Type 3]", "Console info message"); + expect(warn).toHaveBeenCalledWith("[vircadia-sdk][Type 4]", "Console warning message"); + expect(error).toHaveBeenCalledWith("[vircadia-sdk][Type 5]", "Console error message"); + + debug.mockRestore(); + log.mockRestore(); + info.mockRestore(); + warn.mockRestore(); + error.mockRestore(); + + DefaultLogConfiguration.context = new StringLoggerContext(); + + Log.tag("Type 1").debug("String debug message"); + Log.tag("Type 2").message("String log message"); + Log.tag("Type 3").info("String info message"); + Log.tag("Type 4").warning("String warning message"); + Log.tag("Type 5").error("String error message"); + + expect(DefaultLogConfiguration.context.buffer).toBe("" + + "[vircadia-sdk][Type 1][DEBUG] String debug message\n" + + "[vircadia-sdk][Type 2][DEFAULT] String log message\n" + + "[vircadia-sdk][Type 3][INFO] String info message\n" + + "[vircadia-sdk][Type 4][WARNING] String warning message\n" + + "[vircadia-sdk][Type 5][ERROR] String error message\n" + ); + + }); + + test("Custom loggable object", () => { + + const customLoggable = new class { + // eslint-disable-next-line class-methods-use-this + toLoggable() { + return "this is a custom loggable"; + } + }(); + + const context = new StringLoggerContext(); + const logger = new Logger({ context }); + + logger.message(customLoggable); + + expect(context.buffer).toBe("" + + "[DEFAULT] this is a custom loggable\n" + ); + context.buffer = ""; + + logger.message({ + toLoggable: "but this is not" + }); + + expect(context.buffer).toBe("" + + "[DEFAULT] \n" + + " toLoggable: but this is not\n" + + "\n" + ); + context.buffer = ""; + + }); + +});