diff options
author | Evan Welsh <contact@evanwelsh.com> | 2021-08-16 01:14:13 -0700 |
---|---|---|
committer | Philip Chimento <philip.chimento@gmail.com> | 2021-08-16 20:29:02 -0700 |
commit | 924ff78052d2160878d202db98fe6eb9258f6a61 (patch) | |
tree | 5f7f1c275a6cfa1ca32dda2b268f7e10a85392b7 | |
parent | 324319bfead0e63050b817c4e3543eba45ee9709 (diff) | |
download | gjs-924ff78052d2160878d202db98fe6eb9258f6a61.tar.gz |
modules: Implement WHATWG console specification
-rw-r--r-- | .eslintrc.yml | 1 | ||||
-rw-r--r-- | installed-tests/js/.eslintrc.yml | 1 | ||||
-rw-r--r-- | installed-tests/js/matchers.js | 36 | ||||
-rw-r--r-- | installed-tests/js/meson.build | 1 | ||||
-rw-r--r-- | installed-tests/js/testConsole.js | 256 | ||||
-rw-r--r-- | js.gresource.xml | 1 | ||||
-rw-r--r-- | modules/esm/_bootstrap/default.js | 2 | ||||
-rw-r--r-- | modules/esm/console.js | 716 |
8 files changed, 1013 insertions, 1 deletions
diff --git a/.eslintrc.yml b/.eslintrc.yml index dadf40bd..26bd8c74 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -259,5 +259,6 @@ globals: window: readonly TextEncoder: readonly TextDecoder: readonly + console: readonly parserOptions: ecmaVersion: 2020 diff --git a/installed-tests/js/.eslintrc.yml b/installed-tests/js/.eslintrc.yml index cdf5cf9f..abc9c527 100644 --- a/installed-tests/js/.eslintrc.yml +++ b/installed-tests/js/.eslintrc.yml @@ -33,6 +33,7 @@ overrides: - files: - matchers.js - testCairoModule.js + - testConsole.js - testESModules.js - testEncoding.js - testGLibLogWriter.js diff --git a/installed-tests/js/matchers.js b/installed-tests/js/matchers.js index 6a2848f6..1e05828f 100644 --- a/installed-tests/js/matchers.js +++ b/installed-tests/js/matchers.js @@ -26,7 +26,41 @@ export function arrayLikeWithExactContents(elements) { * @returns {string} */ jasmineToString() { - return `${JSON.stringify(elements)}`; + return `<arrayLikeWithExactContents(${ + elements.constructor.name + }[${JSON.stringify(Array.from(elements))}]>)`; + }, + }; +} + +/** + * A jasmine asymmetric matcher which compares a given string to an + * array-like object of bytes. The compared bytes are decoded using + * TextDecoder and then compared using jasmine.stringMatching. + * + * @param {string | RegExp} text the text or regular expression to compare decoded bytes to + * @param {string} [encoding] the encoding of elements + * @returns + */ +export function decodedStringMatching(text, encoding = 'utf-8') { + const matcher = jasmine.stringMatching(text); + + return { + /** + * @param {ArrayLike<number>} compareTo an array of bytes to decode and compare to + * @returns {boolean} + */ + asymmetricMatch(compareTo) { + const decoder = new TextDecoder(encoding); + const decoded = decoder.decode(new Uint8Array(Array.from(compareTo))); + + return matcher.asymmetricMatch(decoded, []); + }, + /** + * @returns {string} + */ + jasmineToString() { + return `<decodedStringMatching(${text})>`; }, }; } diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build index f85b9586..b42f3b20 100644 --- a/installed-tests/js/meson.build +++ b/installed-tests/js/meson.build @@ -241,6 +241,7 @@ endif # minijasmine flag modules_tests = [ + 'Console', 'ESModules', 'Encoding', 'GLibLogWriter', diff --git a/installed-tests/js/testConsole.js b/installed-tests/js/testConsole.js new file mode 100644 index 00000000..95049d57 --- /dev/null +++ b/installed-tests/js/testConsole.js @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later +// SPDX-FileCopyrightText: 2021 Evan Welsh <contact@evanwelsh.com> + +// eslint-disable-next-line +/// <reference types="jasmine" /> + +import GLib from 'gi://GLib'; +import {DEFAULT_LOG_DOMAIN} from 'console'; + +import {decodedStringMatching} from './matchers.js'; + +function objectContainingLogMessage( + message, + domain = DEFAULT_LOG_DOMAIN, + fields = {} +) { + return jasmine.objectContaining({ + MESSAGE: decodedStringMatching(message), + GLIB_DOMAIN: decodedStringMatching(domain), + ...fields, + }); +} + +describe('console', function () { + /** @type {jasmine.Spy<(_level: any, _fields: any) => any>} */ + let writer_func; + + /** + * @param {RegExp | string} message _ + * @param {*} [logLevel] _ + * @param {*} [domain] _ + * @param {*} [fields] _ + */ + function expectLog( + message, + logLevel = GLib.LogLevelFlags.LEVEL_MESSAGE, + domain = DEFAULT_LOG_DOMAIN, + fields = {} + ) { + expect(writer_func).toHaveBeenCalledOnceWith( + logLevel, + objectContainingLogMessage(message, domain, fields) + ); + + // Always reset the calls, so that we can assert at the end that no + // unexpected messages were logged + writer_func.calls.reset(); + } + + beforeAll(function () { + writer_func = jasmine.createSpy( + 'Console test writer func', + function (level, _fields) { + if (level === GLib.LogLevelFlags.ERROR) + return GLib.LogWriterOutput.UNHANDLED; + + return GLib.LogWriterOutput.HANDLED; + } + ); + + writer_func.and.callThrough(); + + // @ts-expect-error The existing binding doesn't accept any parameters because + // it is a raw pointer. + GLib.log_set_writer_func(writer_func); + }); + + beforeEach(function () { + writer_func.calls.reset(); + }); + + it('has correct object tag', function () { + expect(console.toString()).toBe('[object console]'); + }); + + it('logs a message', function () { + console.log('a log'); + + expect(writer_func).toHaveBeenCalledOnceWith( + GLib.LogLevelFlags.LEVEL_MESSAGE, + objectContainingLogMessage('a log') + ); + writer_func.calls.reset(); + }); + + it('logs a warning', function () { + console.warn('a warning'); + + expect(writer_func).toHaveBeenCalledOnceWith( + GLib.LogLevelFlags.LEVEL_WARNING, + objectContainingLogMessage('a warning') + ); + writer_func.calls.reset(); + }); + + it('logs an informative message', function () { + console.info('an informative message'); + + expect(writer_func).toHaveBeenCalledOnceWith( + GLib.LogLevelFlags.LEVEL_INFO, + objectContainingLogMessage('an informative message') + ); + writer_func.calls.reset(); + }); + + describe('clear()', function () { + it('can be called', function () { + console.clear(); + }); + + it('resets indentation', function () { + console.group('a group'); + expectLog('a group'); + console.log('a log'); + expectLog(' a log'); + console.clear(); + console.log('a log'); + expectLog('a log'); + }); + }); + + describe('table()', function () { + it('logs at least something', function () { + console.table(['title', 1, 2, 3]); + expectLog(/title/); + }); + }); + + // %s - string + // %d or %i - integer + // %f - float + // %o - "optimal" object formatting + // %O - "generic" object formatting + // %c - "CSS" formatting (unimplemented by GJS) + describe('string replacement', function () { + const functions = { + log: GLib.LogLevelFlags.LEVEL_MESSAGE, + warn: GLib.LogLevelFlags.LEVEL_WARNING, + info: GLib.LogLevelFlags.LEVEL_INFO, + error: GLib.LogLevelFlags.LEVEL_CRITICAL, + }; + + Object.entries(functions).forEach(([fn, level]) => { + it(`console.${fn}() supports %s`, function () { + console[fn]('Does this %s substitute correctly?', 'modifier'); + expectLog('Does this modifier substitute correctly?', level); + }); + + it(`console.${fn}() supports %d`, function () { + console[fn]('Does this %d substitute correctly?', 10); + expectLog('Does this 10 substitute correctly?', level); + }); + + it(`console.${fn}() supports %i`, function () { + console[fn]('Does this %i substitute correctly?', 26); + expectLog('Does this 26 substitute correctly?', level); + }); + + it(`console.${fn}() supports %f`, function () { + console[fn]('Does this %f substitute correctly?', 27.56331); + expectLog('Does this 27.56331 substitute correctly?', level); + }); + + it(`console.${fn}() supports %o`, function () { + console[fn]('Does this %o substitute correctly?', new Error()); + expectLog(/Does this Error\n.*substitute correctly\?/s, level); + }); + + it(`console.${fn}() supports %O`, function () { + console[fn]('Does this %O substitute correctly?', new Error()); + expectLog('Does this {} substitute correctly?', level); + }); + + it(`console.${fn}() ignores %c`, function () { + console[fn]('Does this %c substitute correctly?', 'modifier'); + expectLog('Does this substitute correctly?', level); + }); + + it(`console.${fn}() supports mixing substitutions`, function () { + console[fn]( + 'Does this %s and the %f substitute correctly alongside %d?', + 'string', + 3.14, + 14 + ); + expectLog( + 'Does this string and the 3.14 substitute correctly alongside 14?', + level + ); + }); + + it(`console.${fn}() supports invalid numbers`, function () { + console[fn]( + 'Does this support parsing %i incorrectly?', + 'a string' + ); + expectLog('Does this support parsing NaN incorrectly?', level); + }); + + it(`console.${fn}() supports missing substitutions`, function () { + console[fn]('Does this support a missing %s substitution?'); + expectLog( + 'Does this support a missing %s substitution?', + level + ); + }); + }); + }); + + describe('time()', function () { + it('ends correctly', function (done) { + console.time('testing time'); + + // console.time logs nothing. + expect(writer_func).not.toHaveBeenCalled(); + + setTimeout(() => { + console.timeLog('testing time'); + + expectLog(/testing time: (.*)ms/); + + console.timeEnd('testing time'); + + expectLog(/testing time: (.*)ms/); + + console.timeLog('testing time'); + + expectLog( + "No time log found for label: 'testing time'.", + GLib.LogLevelFlags.LEVEL_WARNING + ); + + done(); + }, 10); + }); + + it("doesn't log initially", function (done) { + console.time('testing time'); + + // console.time logs nothing. + expect(writer_func).not.toHaveBeenCalled(); + + setTimeout(() => { + console.timeEnd('testing time'); + expectLog(/testing time: (.*)ms/); + + done(); + }, 10); + }); + + afterEach(function () { + // Ensure we only got the log lines that we expected + expect(writer_func).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/js.gresource.xml b/js.gresource.xml index 947049c2..a730f2b8 100644 --- a/js.gresource.xml +++ b/js.gresource.xml @@ -16,6 +16,7 @@ <file>modules/esm/cairo.js</file> <file>modules/esm/gettext.js</file> + <file>modules/esm/console.js</file> <file>modules/esm/gi.js</file> <file>modules/esm/system.js</file> diff --git a/modules/esm/_bootstrap/default.js b/modules/esm/_bootstrap/default.js index eb315af7..afb155b0 100644 --- a/modules/esm/_bootstrap/default.js +++ b/modules/esm/_bootstrap/default.js @@ -5,3 +5,5 @@ // Bootstrap the Encoding API import '_encoding/encoding'; +// Bootstrap the Console API +import 'console'; diff --git a/modules/esm/console.js b/modules/esm/console.js new file mode 100644 index 00000000..152df43c --- /dev/null +++ b/modules/esm/console.js @@ -0,0 +1,716 @@ +// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later +// SPDX-FileCopyrightText: 2021 Evan Welsh <contact@evanwelsh.com> + +import GLib from 'gi://GLib'; + +const sLogger = Symbol('Logger'); +const sPrinter = Symbol('Printer'); +const sFormatter = Symbol('Formatter'); +const sGroupIndentation = Symbol('GroupIndentation'); +const sTimeLabels = Symbol('Time Labels'); +const sCountLabels = Symbol('Count Labels'); +const sLogDomain = Symbol('Log Domain'); + +const DEFAULT_LOG_DOMAIN = 'Gjs-Console'; + +// A line-by-line implementation of https://console.spec.whatwg.org/. + +// 2.2.1. Formatting specifiers +// https://console.spec.whatwg.org/#formatting-specifiers +// +// %s - string +// %d or %i - integer +// %f - float +// %o - "optimal" object formatting +// %O - "generic" object formatting +// %c - "CSS" formatting (unimplemented by GJS) + +/** + * A simple regex to capture formatting specifiers + */ +const specifierTest = /%(d|i|s|f|o|O|c)/; + +/** + * @param {string} str a string to check for format specifiers like %s or %i + * @returns {boolean} + */ +function hasFormatSpecifiers(str) { + return specifierTest.test(str); +} + +/** + * @param {any} item an item to format + */ +function formatGenerically(item) { + return JSON.stringify(item, null, 4); +} + +/** + * @param {any} item an item to format + * @returns {string} + */ +function formatOptimally(item) { + // Handle optimal error formatting. + if (item instanceof Error) { + return `${item.toString()}${item.stack ? '\n' : ''}${item.stack + ?.split('\n') + // Pad each stacktrace line. + .map(line => line.padStart(2, ' ')) + .join('\n')}`; + } + + // TODO: Enhance 'optimal' formatting. + // There is a current work on a better object formatter for GJS in + // https://gitlab.gnome.org/GNOME/gjs/-/merge_requests/587 + return JSON.stringify(item, null, 4); +} + +const propertyAttributes = { + writable: true, + enumerable: false, + configurable: true, +}; + +/** + * @typedef ConsoleInternalProps + * @property {string} [sGroupIndentation] + * @property {Record<string, number>} [sCountLabels] + * @property {Record<string, number>} [sTimeLabels] + * @property {string} [sLogDomain] + */ + +/** + * Implementation of the WHATWG Console object. + * + * @implements {ConsoleInternalProps} + */ +// @ts-expect-error Console does not actually implement ConsoleInternalProps, +// once private class fields are merged we will remove the interface. +class Console { + constructor() { + // Redefine the internal functions as non-enumerable. + Object.defineProperties(this, { + [sLogger]: { + ...propertyAttributes, + value: this[sLogger].bind(this), + }, + [sFormatter]: { + ...propertyAttributes, + value: this[sFormatter].bind(this), + }, + [sPrinter]: { + ...propertyAttributes, + value: this[sPrinter].bind(this), + }, + }); + } + + get [Symbol.toStringTag]() { + return 'Console'; + } + + // 1.1 Logging functions + // https://console.spec.whatwg.org/#logging + + /** + * Logs a critical message if the condition is not truthy. + * {@see console.error()} for additional information. + * + * @param {boolean} condition a boolean condition which, if false, causes + * the log to print + * @param {...any} data formatting substitutions, if applicable + * @returns {void} + */ + assert(condition, ...data) { + if (condition) + return; + + let message = 'Assertion failed'; + + if (data.length === 0) + data.push(message); + + if (typeof data[0] !== 'string') { + data.unshift(message); + } else { + const first = data.shift(); + data.unshift(`${message}: ${first}`); + } + this[sLogger]('assert', data); + } + + /** + * Resets grouping and clears the terminal on systems supporting ANSI + * terminal control sequences. + * + * In file-based stdout or systems which do not support clearing, + * console.clear() has no visual effect. + * + * @returns {void} + */ + clear() { + this[sGroupIndentation] = ''; + } + + /** + * Logs a message with severity equal to {@see GLib.LogLevelFlags.DEBUG}. + * + * @param {...any} data formatting substitutions, if applicable + */ + debug(...data) { + this[sLogger]('debug', data); + } + + /** + * Logs a message with severity equal to {@see GLib.LogLevelFlags.CRITICAL}. + * Does not use {@see GLib.LogLevelFlags.ERROR} to avoid asserting and + * forcibly shutting down the application. + * + * @param {...any} data formatting substitutions, if applicable + */ + error(...data) { + this[sLogger]('error', data); + } + + /** + * Logs a message with severity equal to {@see GLib.LogLevelFlags.INFO}. + * + * @param {...any} data formatting substitutions, if applicable + */ + info(...data) { + this[sLogger]('info', data); + } + + /** + * Logs a message with severity equal to {@see GLib.LogLevelFlags.MESSAGE}. + * + * @param {...any} data formatting substitutions, if applicable + */ + log(...data) { + this[sLogger]('log', data); + } + + // 1.1.7 table(tabularData, properties) + table(tabularData, _properties) { + this.log(tabularData); + } + + /** + * @param {...any} data formatting substitutions, if applicable + * @returns {void} + */ + trace(...data) { + if (data.length === 0) + data = ['Trace']; + + this[sPrinter]('trace', data, { + stackTrace: + // We remove the first line to avoid logging this line as part + // of the trace. + new Error().stack?.split('\n', 2)?.[1], + }); + } + + /** + * @param {...any} data formatting substitutions, if applicable + * @returns {void} + */ + warn(...data) { + const {[sLogger]: Logger} = this; + Logger('warn', data); + } + + /** + * @param {object} item an item to format generically + * @param {never} [options] any additional options for the formatter. Unused + * in our implementation. + */ + dir(item, options) { + const object = formatGenerically(item); + + this[sPrinter]('dir', [object], options); + } + + /** + * @param {...any} data formatting substitutions, if applicable + * @returns {void} + */ + dirxml(...data) { + this.log(...data); + } + + // 1.2 Counting functions + // https://console.spec.whatwg.org/#counting + + /** + * Logs how many times console.count(label) has been called with a given + * label. + * {@see console.countReset()} for resetting a count. + * + * @param {string} label unique identifier for this action + * @returns {void} + */ + count(label) { + this[sCountLabels][label] = this[sCountLabels][label] ?? 0; + const count = ++this[sCountLabels][label]; + const concat = `${label}: ${count}`; + + this[sLogger]('count', [concat]); + } + + /** + * @param {string} label the unique label to reset the count for + * @returns {void} + */ + countReset(label) { + const {[sPrinter]: Printer} = this; + + const count = this[sCountLabels][label]; + + if (typeof count !== 'number') + Printer('reportWarning', [`No count found for label: '${label}'.`]); + else + this[sCountLabels][label] = 0; + } + + // 1.3 Grouping functions + // https://console.spec.whatwg.org/#grouping + + /** + * @param {...any} data formatting substitutions, if applicable + * @returns {void} + */ + group(...data) { + const {[sLogger]: Logger} = this; + + Logger('group', data); + + this[sGroupIndentation] += ' '; + } + + /** + * Alias for console.group() + * + * @param {...any} data formatting substitutions, if applicable + * @returns {void} + */ + groupCollapsed(...data) { + // We can't 'collapse' output in a terminal, so we alias to + // group() + this.group(...data); + } + + /** + * @returns {void} + */ + groupEnd() { + this[sGroupIndentation] = this[sGroupIndentation].slice(0, -2); + } + + // 1.4 Timing functions + // https://console.spec.whatwg.org/#timing + + /** + * @param {string} label unique identifier for this action, pass to + * console.timeEnd() to complete + * @returns {void} + */ + time(label) { + this[sTimeLabels][label] = GLib.get_monotonic_time(); + } + + /** + * Logs the time since the last call to console.time(label) where label is + * the same. + * + * @param {string} label unique identifier for this action, pass to + * console.timeEnd() to complete + * @param {...any} data string substitutions, if applicable + * @returns {void} + */ + timeLog(label, ...data) { + const {[sPrinter]: Printer} = this; + + const startTime = this[sTimeLabels][label]; + + if (typeof startTime !== 'number') { + Printer('reportWarning', [ + `No time log found for label: '${label}'.`, + ]); + } else { + const durationMs = (GLib.get_monotonic_time() - startTime) / 1000; + const concat = `${label}: ${durationMs.toFixed(3)} ms`; + data.unshift(concat); + + Printer('timeLog', data); + } + } + + /** + * Logs the time since the last call to console.time(label) and completes + * the action. + * Call console.time(label) again to re-measure. + * + * @param {string} label unique identifier for this action + * @returns {void} + */ + timeEnd(label) { + const {[sPrinter]: Printer} = this; + const startTime = this[sTimeLabels][label]; + + if (typeof startTime !== 'number') { + Printer('reportWarning', [ + `No time log found for label: '${label}'.`, + ]); + } else { + delete this[sTimeLabels][label]; + + const durationMs = (GLib.get_monotonic_time() - startTime) / 1000; + const concat = `${label}: ${durationMs.toFixed(3)} ms`; + + Printer('timeEnd', [concat]); + } + } + + // Non-standard functions which are de-facto standards. + // Similar to Node, we define these as no-ops for now. + + /** + * @deprecated Not implemented in GJS + * + * @param {string} _label unique identifier for this action, pass to + * console.profileEnd to complete + * @returns {void} + */ + profile(_label) {} + + /** + * @deprecated Not implemented in GJS + * + * @param {string} _label unique identifier for this action + * @returns {void} + */ + profileEnd(_label) {} + + /** + * @deprecated Not implemented in GJS + * + * @param {string} _label unique identifier for this action + * @returns {void} + */ + timeStamp(_label) {} + + // GJS-specific extensions for integrating with GLib structured logging + + /** + * @param {string} logDomain the GLib log domain this Console should print + * with. Defaults to 'Gjs-Console'. + * @returns {void} + */ + setLogDomain(logDomain) { + this[sLogDomain] = String(logDomain); + } + + /** + * @returns {string} + */ + get logDomain() { + return this[sLogDomain]; + } + + // 2. Supporting abstract operations + // https://console.spec.whatwg.org/#supporting-ops + + /** + * 2.1. Logger + * https://console.spec.whatwg.org/#logger + * + * Conditionally applies formatting based on the inputted arguments, + * and prints at the provided severity (logLevel) + * + * @param {string} logLevel the severity (log level) the args should be + * emitted with + * @param {unknown[]} args the arguments to pass to the printer + * @returns {void} + */ + [sLogger](logLevel, args) { + const {[sFormatter]: Formatter, [sPrinter]: Printer} = this; + + if (args.length === 0) + return; + + let [first, ...rest] = args; + + if (rest.length === 0) { + Printer(logLevel, [first]); + return undefined; + } + + // If first does not contain any format specifiers, don't call Formatter + if (typeof first !== 'string' || !hasFormatSpecifiers(first)) { + Printer(logLevel, args); + return undefined; + } + + // Otherwise, perform print the result of Formatter. + Printer(logLevel, Formatter([first, ...rest])); + + return undefined; + } + + /** + * 2.2. Formatter + * https://console.spec.whatwg.org/#formatter + * + * @param {[string, ...any[]]} args an array of format strings followed by + * their arguments + */ + [sFormatter](args) { + const {[sFormatter]: Formatter} = this; + + // The initial formatting string is the first arg + let target = args[0]; + + if (args.length === 1) + return target; + + let current = args[1]; + + // Find the index of the first format specifier. + const specifierIndex = specifierTest.exec(target).index; + const specifier = target.slice(specifierIndex, specifierIndex + 2); + let converted = null; + switch (specifier) { + case '%s': + converted = String(current); + break; + case '%d': + case '%i': + if (typeof current === 'symbol') + converted = Number.NaN; + else + converted = parseInt(current, 10); + break; + case '%f': + if (typeof current === 'symbol') + converted = Number.NaN; + else + converted = parseFloat(current); + break; + case '%o': + converted = formatOptimally(current); + break; + case '%O': + converted = formatGenerically(current); + break; + case '%c': + converted = ''; + break; + } + // If any of the previous steps set converted, replace the specifier in + // target with the converted value. + if (converted !== null) { + target = + target.slice(0, specifierIndex) + + converted + + target.slice(specifierIndex + 2); + } + + /** + * Create the next format input... + * + * @type {[string, ...any[]]} + */ + let result = [target, ...args.slice(2)]; + + if (!hasFormatSpecifiers(target)) + return result; + + if (result.length === 1) + return result; + + return Formatter(result); + } + + /** + * @typedef {object} PrinterOptions + * @param {string} [stackTrace] an error stacktrace to append + * @param {Record<string, any>} [fields] fields to include in the structured + * logging call + */ + + /** + * 2.3. Printer + * https://console.spec.whatwg.org/#printer + * + * This implementation of Printer maps WHATWG log severity to + * {@see GLib.LogLevelFlags} and outputs using GLib structured logging. + * + * @param {string} logLevel the log level (log tag) the args should be + * emitted with + * @param {unknown[]} args the arguments to print, either a format string + * with replacement args or multiple strings + * @param {PrinterOptions} [options] additional options for the + * printer + * @returns {void} + */ + [sPrinter](logLevel, args, options) { + let severity; + + switch (logLevel) { + case 'log': + case 'dir': + case 'dirxml': + case 'trace': + case 'group': + case 'groupCollapsed': + case 'timeLog': + case 'timeEnd': + severity = GLib.LogLevelFlags.LEVEL_MESSAGE; + break; + case 'debug': + severity = GLib.LogLevelFlags.LEVEL_DEBUG; + break; + case 'count': + case 'info': + severity = GLib.LogLevelFlags.LEVEL_INFO; + break; + case 'warn': + case 'countReset': + case 'reportWarning': + severity = GLib.LogLevelFlags.LEVEL_WARNING; + break; + case 'error': + case 'assert': + severity = GLib.LogLevelFlags.LEVEL_CRITICAL; + break; + default: + severity = GLib.LogLevelFlags.LEVEL_MESSAGE; + } + + let output = args + .map(a => { + if (a === null) + return 'null'; + else if (typeof a === 'object') + return formatOptimally(a); + else if (typeof a === 'function') + return a.toString(); + else if (typeof a === 'undefined') + return 'undefined'; + else if (typeof a === 'bigint') + return `${a}n`; + else + return String(a); + }) + .join(' '); + + let formattedOutput = this[sGroupIndentation] + output; + + if (logLevel === 'trace') { + formattedOutput = + `${output}\n${options?.stackTrace}` ?? 'No trace available'; + } + + GLib.log_structured(this[sLogDomain], severity, { + MESSAGE: formattedOutput, + }); + } +} + +Object.defineProperties(Console.prototype, { + [sGroupIndentation]: { + ...propertyAttributes, + value: '', + }, + [sCountLabels]: { + ...propertyAttributes, + /** @type {Record<string, number>} */ + value: {}, + }, + [sTimeLabels]: { + ...propertyAttributes, + /** @type {Record<string, number>} */ + value: {}, + }, + [sLogDomain]: { + ...propertyAttributes, + value: DEFAULT_LOG_DOMAIN, + }, +}); + +const console = new Console(); + +/** + * @param {string} domain set the GLib log domain for the global console object. + */ +function setConsoleLogDomain(domain) { + console.setLogDomain(domain); +} + +/** + * @returns {string} + */ +function getConsoleLogDomain() { + return console.logDomain; +} + +/** + * For historical web-compatibility reasons, the namespace object for + * console must have {} as its [[Prototype]]. + * + * @type {Omit<Console, 'setLogDomain' | 'logDomain'>} + */ +const globalConsole = Object.create({}); + +const propertyNames = + /** @type {['constructor', ...Array<string & keyof Console>]} */ + // eslint-disable-next-line no-extra-parens + (Object.getOwnPropertyNames(Console.prototype)); +const propertyDescriptors = Object.getOwnPropertyDescriptors(Console.prototype); +for (const key of propertyNames) { + if (key === 'constructor') + continue; + + // This non-standard function shouldn't be included. + if (key === 'setLogDomain') + continue; + + const descriptor = propertyDescriptors[key]; + if (typeof descriptor.value !== 'function') + continue; + + Object.defineProperty(globalConsole, key, { + ...descriptor, + value: descriptor.value.bind(console), + }); +} +Object.defineProperties(globalConsole, { + [Symbol.toStringTag]: { + configurable: false, + enumerable: true, + get() { + return 'console'; + }, + }, +}); +Object.freeze(globalConsole); + +Object.defineProperty(globalThis, 'console', { + configurable: false, + enumerable: true, + writable: false, + value: globalConsole, +}); + +export { + getConsoleLogDomain, + setConsoleLogDomain, + DEFAULT_LOG_DOMAIN +}; + +export default { + getConsoleLogDomain, + setConsoleLogDomain, + DEFAULT_LOG_DOMAIN, +}; |