diff --git a/README.md b/README.md index 603bf9a96..bdf3071cf 100644 --- a/README.md +++ b/README.md @@ -183,3 +183,5 @@ documentation on [How Does Appium Work?](https://appium.io/docs/en/latest/intro/ [Apache-2.0](./LICENSE) [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Fappium%2Fappium.svg?type=large)](https://app.fossa.io/projects/git%2Bhttps%3A%2F%2Fgithub.com%2Fappium%2Fappium?ref=badge_large) + +`@appium/logger` package is under [ISC](./packages/logger/LICENSE) License. diff --git a/package-lock.json b/package-lock.json index 83665b68e..295673742 100644 --- a/package-lock.json +++ b/package-lock.json @@ -195,6 +195,10 @@ "resolved": "packages/universal-xml-plugin", "link": true }, + "node_modules/@apppium/logger": { + "resolved": "packages/logger", + "link": true + }, "node_modules/@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", @@ -22525,6 +22529,19 @@ "@img/sharp-win32-x64": "0.33.0" } }, + "packages/logger": { + "name": "@apppium/logger", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "console-control-strings": "1.1.0", + "set-blocking": "2.0.0" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=8" + } + }, "packages/opencv": { "name": "@appium/opencv", "version": "3.0.4", @@ -23967,6 +23984,13 @@ "xpath": "0.0.34" } }, + "@apppium/logger": { + "version": "file:packages/logger", + "requires": { + "console-control-strings": "1.1.0", + "set-blocking": "2.0.0" + } + }, "@babel/code-frame": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", diff --git a/packages/logger/CHANGELOG.md b/packages/logger/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/logger/LICENSE b/packages/logger/LICENSE new file mode 100644 index 000000000..845be76f6 --- /dev/null +++ b/packages/logger/LICENSE @@ -0,0 +1,18 @@ +ISC License + +Copyright npm, Inc. + +Permission to use, copy, modify, and/or distribute this +software for any purpose with or without fee is hereby +granted, provided that the above copyright notice and this +permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND NPM DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO +EVENT SHALL NPM BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE +USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 000000000..fed3f83a4 --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,31 @@ +# Appium Logger + +The logger util that Appium uses. + +# Installation + +```console +npm install @appium/logger --save +``` + +# Basic Usage + +```js +import log from '@appium/logger'; + +// additional stuff ---------------------------+ +// message ----------+ | +// prefix ----+ | | +// level -+ | | | +// v v v v + log.info('fyi', 'I have a kitty cat: %j', myKittyCat); +``` + +# History + +This module is forked from [npmlog](https://github.com/npm/npmlog) under ISC License because the original project has been archived. +Please check [the npmlog changelog](https://github.com/npm/npmlog/blob/main/CHANGELOG.md) to see the list of former module updates before it was forked. + +# License + +ISC License diff --git a/packages/logger/index.ts b/packages/logger/index.ts new file mode 100644 index 000000000..6dce9efdf --- /dev/null +++ b/packages/logger/index.ts @@ -0,0 +1,5 @@ +import log from './lib/log'; +export type * from './lib/types'; + +export {log}; +export default log; diff --git a/packages/logger/lib/log.ts b/packages/logger/lib/log.ts new file mode 100644 index 000000000..20686c151 --- /dev/null +++ b/packages/logger/lib/log.ts @@ -0,0 +1,310 @@ +import {EventEmitter} from 'node:events'; +import setBlocking from 'set-blocking'; +import consoleControl from 'console-control-strings'; +import * as util from 'node:util'; +import type {MessageObject, StyleObject, Logger, LogLevel} from './types'; +import type {Writable} from 'node:stream'; + +const DEFAULT_LOG_LEVELS: any[][] = [ + ['silly', -Infinity, {inverse: true}, 'sill'], + ['verbose', 1000, {fg: 'cyan', bg: 'black'}, 'verb'], + ['info', 2000, {fg: 'green'}], + ['timing', 2500, {fg: 'green', bg: 'black'}], + ['http', 3000, {fg: 'green', bg: 'black'}], + ['notice', 3500, {fg: 'cyan', bg: 'black'}], + ['warn', 4000, {fg: 'black', bg: 'yellow'}, 'WARN'], + ['error', 5000, {fg: 'red', bg: 'black'}, 'ERR!'], + ['silent', Infinity], +] as const; + +setBlocking(true); + +export class Log extends EventEmitter implements Logger { + level: LogLevel | string; + record: MessageObject[]; + maxRecordSize: number; + prefixStyle: StyleObject; + headingStyle: StyleObject; + heading: string; + stream: Writable; // Defaults to process.stderr + + _colorEnabled?: boolean; + _buffer: MessageObject[]; + _style: Record; + _levels: Record; + _disp: Record; + _id: number; + _paused: boolean; + + constructor() { + super(); + + this.level = 'info'; + this._buffer = []; + this.record = []; + this.maxRecordSize = 10000; + this.stream = process.stderr; + this.prefixStyle = {fg: 'magenta'}; + this.headingStyle = {fg: 'white', bg: 'black'}; + this._id = 0; + this._paused = false; + + this._style = {}; + this._levels = {}; + this._disp = {}; + this.initDefaultLevels(); + + // allow 'error' prefix + this.on('error', () => {}); + } + + private useColor(): boolean { + // by default, decide based on tty-ness. + return ( + this._colorEnabled ?? Boolean(this.stream && 'isTTY' in this.stream && this.stream.isTTY) + ); + } + + enableColor(): void { + this._colorEnabled = true; + } + + disableColor(): void { + this._colorEnabled = false; + } + + // this functionality has been deliberately disabled + enableUnicode(): void {} + disableUnicode(): void {} + enableProgress(): void {} + disableProgress(): void {} + progressEnabled(): boolean { + return false; + } + + /** + * Temporarily stop emitting, but don't drop + */ + pause(): void { + this._paused = true; + } + + resume(): void { + if (!this._paused) { + return; + } + + this._paused = false; + + const b = this._buffer; + this._buffer = []; + for (const m of b) { + this.emitLog(m); + } + } + + silly(prefix: string, message: any, ...args: any[]): void { + this.log('silly', prefix, message, ...args); + } + + verbose(prefix: string, message: any, ...args: any[]): void { + this.log('verbose', prefix, message, ...args); + } + + info(prefix: string, message: any, ...args: any[]): void { + this.log('info', prefix, message, ...args); + } + + timing(prefix: string, message: any, ...args: any[]): void { + this.log('timing', prefix, message, ...args); + } + + http(prefix: string, message: any, ...args: any[]): void { + this.log('http', prefix, message, ...args); + } + + notice(prefix: string, message: any, ...args: any[]): void { + this.log('notice', prefix, message, ...args); + } + + warn(prefix: string, message: any, ...args: any[]): void { + this.log('warn', prefix, message, ...args); + } + + error(prefix: string, message: any, ...args: any[]): void { + this.log('error', prefix, message, ...args); + } + + silent(prefix: string, message: any, ...args: any[]): void { + this.log('silent', prefix, message, ...args); + } + + addLevel(level: string, n: number, style?: StyleObject, disp?: string): void { + this._levels[level] = n; + this._style[level] = style; + if (!this[level]) { + this[level] = (prefix: string, message: any, ...args: any[]) => { + this.log(level, prefix, message, ...args); + }; + } + // If 'disp' is null or undefined, use the level as a default + this._disp[level] = disp ?? level; + } + + /** + * Creates a log message + * @param level + * @param prefix + * @param message message of the log which will be formatted using utils.format() + * @param args additional arguments appended to the log message also formatted using utils.format() + */ + log(level: LogLevel | string, prefix: string, message: any, ...args: any[]): void { + const l = this._levels[level]; + if (l === undefined) { + this.emit('error', new Error(util.format('Undefined log level: %j', level))); + return; + } + + const messageArguments: any[] = []; + let stack: string | null = null; + for (const formatArg of [message, ...args]) { + messageArguments.push(formatArg); + // resolve stack traces to a plain string. + if (typeof formatArg === 'object' && formatArg instanceof Error && formatArg.stack) { + Object.defineProperty(formatArg, 'stack', { + value: (stack = formatArg.stack + ''), + enumerable: true, + writable: true, + }); + } + } + if (stack) { + messageArguments.unshift(`${stack}\n`); + } + const formattedMessage: string = util.format(...messageArguments); + + const m: MessageObject = { + id: this._id++, + timestamp: Date.now(), + level, + prefix: String(prefix || ''), + message: formattedMessage, + }; + + this.emit('log', m); + this.emit('log.' + level, m); + if (m.prefix) { + this.emit(m.prefix, m); + } + + this.record.push(m); + const mrs = this.maxRecordSize; + if (this.record.length > mrs) { + this.record.shift(); + } + + this.emitLog(m); + } + + private emitLog(m: MessageObject): void { + if (this._paused) { + this._buffer.push(m); + return; + } + + const l = this._levels[m.level]; + if (l === undefined) { + return; + } + if (l < this._levels[this.level]) { + return; + } + if (l > 0 && !isFinite(l)) { + return; + } + + // If 'disp' is null or undefined, use the lvl as a default + // Allows: '', 0 as valid disp + const disp = this._disp[m.level]; + this.clearProgress(); + for (const line of m.message.split(/\r?\n/)) { + const heading = this.heading; + if (heading) { + this.write(heading, this.headingStyle); + this.write(' '); + } + this.write(String(disp), this._style[m.level]); + const p = m.prefix || ''; + if (p) { + this.write(' '); + } + + this.write(p, this.prefixStyle); + this.write(` ${line}\n`); + } + this.showProgress(); + } + + private _format(msg: string, style: StyleObject = {}): string | undefined { + if (!this.stream) { + return; + } + + let output = ''; + if (this.useColor()) { + const settings: string[] = []; + if (style.fg) { + settings.push(style.fg); + } + if (style.bg) { + settings.push('bg' + style.bg[0].toUpperCase() + style.bg.slice(1)); + } + if (style.bold) { + settings.push('bold'); + } + if (style.underline) { + settings.push('underline'); + } + if (style.inverse) { + settings.push('inverse'); + } + if (settings.length) { + output += consoleControl.color(settings); + } + if (style.bell) { + output += consoleControl.beep(); + } + } + output += msg; + if (this.useColor()) { + output += consoleControl.color('reset'); + } + return output; + } + + private write(msg: string, style: StyleObject = {}): void { + if (!this.stream) { + return; + } + + const formatted = this._format(msg, style); + if (formatted !== undefined) { + this.stream.write(formatted); + } + } + + private initDefaultLevels(): void { + for (const [level, index, style, disp] of DEFAULT_LOG_LEVELS) { + this._levels[level] = index; + this._style[level] = style; + this._disp[level] = disp ?? level; + } + } + + // this functionality has been deliberately disabled + private clearProgress(): void {} + private showProgress(): void {} +} + +export const GLOBAL_LOG = new Log(); +export default GLOBAL_LOG; diff --git a/packages/logger/lib/types.ts b/packages/logger/lib/types.ts new file mode 100644 index 000000000..445d02ade --- /dev/null +++ b/packages/logger/lib/types.ts @@ -0,0 +1,83 @@ +import type {EventEmitter} from 'node:events'; + +export interface Logger extends EventEmitter { + level: string; + record: MessageObject[]; + maxRecordSize: number; + prefixStyle: StyleObject; + headingStyle: StyleObject; + heading: string; + stream: any; // Defaults to process.stderr + + /** + * Creates a log message + * @param level + * @param prefix + * @param message message of the log which will be formatted using utils.format() + * @param args additional arguments appended to the log message also formatted using utils.format() + */ + log(level: LogLevel | string, prefix: string, message: any, ...args: any[]): void; + + /** + * @param prefix + * @param message message of the log which will be formatted using utils.format() + * @param args additional arguments appended to the log message also formatted using utils.format() + */ + silly(prefix: string, message: any, ...args: any[]): void; + verbose(prefix: string, message: any, ...args: any[]): void; + info(prefix: string, message: any, ...args: any[]): void; + timing(prefix: string, message: any, ...args: any[]): void; + http(prefix: string, message: any, ...args: any[]): void; + notice(prefix: string, message: any, ...args: any[]): void; + warn(prefix: string, message: any, ...args: any[]): void; + error(prefix: string, message: any, ...args: any[]): void; + silent(prefix: string, message: any, ...args: any[]): void; + + enableColor(): void; + disableColor(): void; + + enableProgress(): void; + disableProgress(): void; + progressEnabled(): boolean; + + enableUnicode(): void; + disableUnicode(): void; + + pause(): void; + resume(): void; + + addLevel(level: string, n: number, style?: StyleObject, disp?: string): void; + + // Allows for custom log levels + // log.addLevel("custom", level) + // log.custom(prefix, message) + [key: string]: any; +} + +export type LogLevel = + | 'silly' + | 'verbose' + | 'info' + | 'timing' + | 'http' + | 'notice' + | 'warn' + | 'error' + | 'silent'; + +export interface StyleObject { + fg?: string | undefined; + bg?: string | undefined; + bold?: boolean | undefined; + inverse?: boolean | undefined; + underline?: boolean | undefined; + bell?: boolean | undefined; +} + +export interface MessageObject { + id: number; + timestamp: number; + level: string; + prefix: string; + message: string; +} diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 000000000..5563f87ce --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,56 @@ +{ + "name": "@apppium/logger", + "version": "1.0.0", + "author": "https://github.com/appium", + "description": "A Universal Logger For The Appium Ecosystem", + "repository": { + "type": "git", + "url": "https://github.com/appium/appium.git", + "directory": "packages/logger" + }, + "main": "build/index.js", + "types": "index.ts", + "files": [ + "index.ts", + "lib", + "build", + "tsconfig.json", + "!build/tsconfig.tsbuildinfo", + "CHANGELOG.md" + ], + "directories": { + "lib": "./lib" + }, + "scripts": { + "test": "npm run test:unit", + "test:smoke": "node ./build/index.js", + "test:unit": "mocha --exit --timeout 1m \"./test/unit/**/*-specs.js\"" + }, + "dependencies": { + "console-control-strings": "1.1.0", + "set-blocking": "2.0.0" + }, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=8" + }, + "bugs": { + "url": "https://github.com/appium/appium/issues" + }, + "homepage": "https://appium.io", + "publishConfig": { + "access": "public" + }, + "gitHead": "8480a85ce2fa466360e0fb1a7f66628331907f02", + "keywords": [ + "automation", + "javascript", + "selenium", + "webdriver", + "ios", + "android", + "firefoxos", + "testing" + ] +} diff --git a/packages/logger/test/unit/basic-specs.js b/packages/logger/test/unit/basic-specs.js new file mode 100644 index 000000000..154e4f2e8 --- /dev/null +++ b/packages/logger/test/unit/basic-specs.js @@ -0,0 +1,425 @@ +/* eslint-disable no-console */ +import { Log } from '../../lib/log'; +import {Stream} from 'node:stream'; + +describe('basic', async function () { + const chai = await import('chai'); + chai.should(); + + let log; + + describe('logging', function () { + let s; + let result = []; + let logEvents = []; + let logInfoEvents = []; + let logPrefixEvents = []; + const resultExpect = [ + // eslint-disable-next-line max-len + '\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[7msill\u001b[0m \u001b[0m\u001b[35msilly prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[36;40mverb\u001b[0m \u001b[0m\u001b[35mverbose prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[32minfo\u001b[0m \u001b[0m\u001b[35minfo prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[32;40mtiming\u001b[0m \u001b[0m\u001b[35mtiming prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[32;40mhttp\u001b[0m \u001b[0m\u001b[35mhttp prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[36;40mnotice\u001b[0m \u001b[0m\u001b[35mnotice prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[30;43mWARN\u001b[0m \u001b[0m\u001b[35mwarn prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[31;40mERR!\u001b[0m \u001b[0m\u001b[35merror prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[32minfo\u001b[0m \u001b[0m\u001b[35minfo prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[32;40mtiming\u001b[0m \u001b[0m\u001b[35mtiming prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[32;40mhttp\u001b[0m \u001b[0m\u001b[35mhttp prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[36;40mnotice\u001b[0m \u001b[0m\u001b[35mnotice prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[30;43mWARN\u001b[0m \u001b[0m\u001b[35mwarn prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[31;40mERR!\u001b[0m \u001b[0m\u001b[35merror prefix\u001b[0m x = {"foo":{"bar":"baz"}}\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[31;40mERR!\u001b[0m \u001b[0m\u001b[35m404\u001b[0m This is a longer\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[31;40mERR!\u001b[0m \u001b[0m\u001b[35m404\u001b[0m message, with some details\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[31;40mERR!\u001b[0m \u001b[0m\u001b[35m404\u001b[0m and maybe a stack.\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u001b[31;40mERR!\u001b[0m \u001b[0m\u001b[35m404\u001b[0m \n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u0007noise\u001b[0m\u001b[35m\u001b[0m LOUD NOISES\n', + // eslint-disable-next-line max-len + '\u001b[0m\u001b[37;40mnpm\u001b[0m \u001b[0m\u0007noise\u001b[0m \u001b[0m\u001b[35merror\u001b[0m erroring\n', + '\u001b[0m', + ]; + const logPrefixEventsExpect = [ + { id: 2, + level: 'info', + prefix: 'info prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 11, + level: 'info', + prefix: 'info prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 20, + level: 'info', + prefix: 'info prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + ]; + // should be the same. + const logInfoEventsExpect = logPrefixEventsExpect; + const logEventsExpect = [ + { id: 0, + level: 'silly', + prefix: 'silly prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 1, + level: 'verbose', + prefix: 'verbose prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 2, + level: 'info', + prefix: 'info prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 3, + level: 'timing', + prefix: 'timing prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 4, + level: 'http', + prefix: 'http prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 5, + level: 'notice', + prefix: 'notice prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 6, + level: 'warn', + prefix: 'warn prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 7, + level: 'error', + prefix: 'error prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 8, + level: 'silent', + prefix: 'silent prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 9, + level: 'silly', + prefix: 'silly prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 10, + level: 'verbose', + prefix: 'verbose prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 11, + level: 'info', + prefix: 'info prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 12, + level: 'timing', + prefix: 'timing prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 13, + level: 'http', + prefix: 'http prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 14, + level: 'notice', + prefix: 'notice prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 15, + level: 'warn', + prefix: 'warn prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 16, + level: 'error', + prefix: 'error prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 17, + level: 'silent', + prefix: 'silent prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 18, + level: 'silly', + prefix: 'silly prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 19, + level: 'verbose', + prefix: 'verbose prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 20, + level: 'info', + prefix: 'info prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 21, + level: 'timing', + prefix: 'timing prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 22, + level: 'http', + prefix: 'http prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 23, + level: 'notice', + prefix: 'notice prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 24, + level: 'warn', + prefix: 'warn prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 25, + level: 'error', + prefix: 'error prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 26, + level: 'silent', + prefix: 'silent prefix', + message: 'x = {"foo":{"bar":"baz"}}', + }, + { id: 27, + level: 'error', + prefix: '404', + message: 'This is a longer\nmessage, with some details\nand maybe a stack.\n', + }, + { id: 28, + level: 'noise', + prefix: '', + message: 'LOUD NOISES', + }, + { id: 29, + level: 'noise', + prefix: 'error', + message: 'erroring', + }, + ]; + + this.beforeEach(function () { + result = []; + logEvents = []; + logInfoEvents = []; + logPrefixEvents = []; + + log = new Log(); + s = new Stream(); + s.write = (m) => result.push(m); + s.writable = true; + s.isTTY = true; + s.end = () => {}; + log.stream = s; + log.heading = 'npm'; + }); + + it('should work', function () { + log.stream.should.equal(s); + log.on('log', logEvents.push.bind(logEvents)); + log.on('log.info', logInfoEvents.push.bind(logInfoEvents)); + log.on('info prefix', logPrefixEvents.push.bind(logPrefixEvents)); + + console.error('log.level=silly'); + log.level = 'silly'; + log.silly('silly prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.verbose('verbose prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.info('info prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.timing('timing prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.http('http prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.notice('notice prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.warn('warn prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.error('error prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.silent('silent prefix', 'x = %j', { foo: { bar: 'baz' } }); + + console.error('log.level=silent'); + log.level = 'silent'; + log.silly('silly prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.verbose('verbose prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.info('info prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.timing('timing prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.http('http prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.notice('notice prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.warn('warn prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.error('error prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.silent('silent prefix', 'x = %j', { foo: { bar: 'baz' } }); + + console.error('log.level=info'); + log.level = 'info'; + log.silly('silly prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.verbose('verbose prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.info('info prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.timing('timing prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.http('http prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.notice('notice prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.warn('warn prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.error('error prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.silent('silent prefix', 'x = %j', { foo: { bar: 'baz' } }); + log.error('404', 'This is a longer\n' + + 'message, with some details\n' + + 'and maybe a stack.\n'); + log.addLevel('noise', 10000, { bell: true }); + log.noise(false, 'LOUD NOISES'); + log.noise('error', 'erroring'); + + result.join('').trim().should.equal(resultExpect.join('').trim()); + const withoutTimestamps = (x) => x.map((m) => { + Boolean(m.timestamp).should.be.true; + const copy = JSON.parse(JSON.stringify(m)); + delete copy.timestamp; + return copy; + }); + withoutTimestamps(log.record).should.eql(logEventsExpect); + withoutTimestamps(logEvents).should.eql(logEventsExpect); + withoutTimestamps(logInfoEvents).should.eql(logInfoEventsExpect); + withoutTimestamps(logPrefixEvents).should.eql(logPrefixEventsExpect); + }); + }); + + describe('utils', function () { + it('enableColor', function () { + log.enableColor(); + log.useColor().should.be.true; + }); + + it('disableColor', function () { + log.disableColor(); + log.useColor().should.be.false; + }); + + it('_buffer while paused', function () { + log.pause(); + log.log('verbose', 'test', 'test log'); + log._buffer.length.should.equal(1); + log.resume(); + log._buffer.length.should.equal(0); + }); + }); + + describe('log.log', function () { + beforeEach(function () { + log = new Log(); + }); + + it('emits error on bad loglevel', async function() { + await new Promise((resolve, reject) => { + log.once('error', (err) => { + /Undefined log level: "asdf"/.test(err).should.be.true; + resolve(); + }); + log.log('asdf', 'bad loglevel'); + setTimeout(reject, 1000); + }); + }); + + it('resolves stack traces to a plain string', async function() { + await new Promise((resolve, reject) => { + log.once('log', (m) => { + /Error: with a stack trace/.test(m.message).should.be.true; + /at Test/.test(m.message).should.be.true; + resolve(); + }); + const err = new Error('with a stack trace'); + log.log('verbose', 'oops', err); + setTimeout(reject, 1000); + }); + }); + + it('max record size', function() { + log.maxRecordSize = 3; + log.log('verbose', 'test', 'log 1'); + log.log('verbose', 'test', 'log 2'); + log.log('verbose', 'test', 'log 3'); + log.log('verbose', 'test', 'log 4'); + log.record.length.should.equal(3); + }); + }); + + describe('stream', function () { + beforeEach(function () { + log = new Log(); + }); + + it('write with no stream', function() { + log.stream = null; + log.write('message'); + }); + }); + + describe('emitLog', function () { + beforeEach(function () { + log = new Log(); + }); + + it('to nonexistant level', function() { + log.emitLog({ prefix: 'test', level: 'asdf' }); + }); + }); + + describe('format', function () { + beforeEach(function () { + log = new Log(); + }); + + it('with nonexistant stream', function() { + log.stream = null; + (log._format('message') === undefined).should.be.true; + }); + it('fg', function () { + log.enableColor(); + const o = log._format('test message', { bg: 'blue' }); + o.includes('\u001b[44mtest message\u001b[0m').should.be.true; + }); + it('bg', function () { + log.enableColor(); + const o = log._format('test message', { bg: 'white' }); + o.includes('\u001b[47mtest message\u001b[0m').should.be.true; + }); + it('bold', function () { + log.enableColor(); + const o = log._format('test message', { bold: true }); + o.includes('\u001b[1mtest message\u001b[0m').should.be.true; + }); + it('underline', function () { + log.enableColor(); + const o = log._format('test message', { underline: true }); + o.includes('\u001b[4mtest message\u001b[0m').should.be.true; + }); + it('inverse', function () { + log.enableColor(); + const o = log._format('test message', { inverse: true }); + o.includes('\u001b[7mtest message\u001b[0m').should.be.true; + }); + }); +}); diff --git a/packages/logger/test/unit/display-specs.js b/packages/logger/test/unit/display-specs.js new file mode 100644 index 000000000..3823a6931 --- /dev/null +++ b/packages/logger/test/unit/display-specs.js @@ -0,0 +1,34 @@ +import { Log } from '../../lib/log'; +import { waitForCondition } from 'asyncbox'; + +describe('display', function () { + let log; + + describe('explicitly set new log level display to empty string', function () { + let actual; + + beforeEach(function () { + actual = ''; + log = new Log(); + log.write = (msg) => { + actual += msg; + }; + }); + + it('explicitly set new log level display to empty string', async function () { + log.addLevel('explicitNoLevelDisplayed', 20000, {}, ''); + log.explicitNoLevelDisplayed('1', '2'); + await waitForCondition(() => actual.trim() === '1 2', {waitMs: 1000, intervalMs: 50}); + + actual = ''; + log.explicitNoLevelDisplayed('', '1'); + await waitForCondition(() => actual.trim() === '1', {waitMs: 1000, intervalMs: 50}); + }); + + it('explicitly set new log level display to 0', async function () { + log.addLevel('explicitNoLevelDisplayed', 20000, {}, 0); + log.explicitNoLevelDisplayed('', '1'); + await waitForCondition(() => actual.trim() === '0 1', {waitMs: 1000, intervalMs: 50}); + }); + }); +}); diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 000000000..6d870803d --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@appium/tsconfig/tsconfig.json", + "compilerOptions": { + "esModuleInterop": true, + "strict": false, + "outDir": "build", + "types": ["node"], + "checkJs": true + }, + "include": [ + "index.ts", + "lib" + ] +} diff --git a/tsconfig.json b/tsconfig.json index 2962b9aef..915238982 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -67,6 +67,9 @@ }, { "path": "packages/strongbox" - } + }, + { + "path": "packages/logger" + }, ] }