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 = "";
+
+ });
+
+});