export { SENSITIVE_DATA_REPLACER } from './utils.js' import fs from 'node:fs' import util from 'node:util' import log from 'loglevel' import type { ColorName } from 'chalk' import chalk from 'chalk' import prefix from 'loglevel-plugin-prefix' import ansiStrip from 'strip-ansi' import { mask, parseMaskingPatterns } from './utils.js' prefix.reg(log) const DEFAULT_LEVEL = process.env.WDIO_DEBUG ? 'trace' : 'info' const COLORS: Record = { error: 'red', warn: 'yellow', info: 'cyanBright', debug: 'green', trace: 'cyan', progress: 'magenta' } const matches = { COMMAND: 'COMMAND', BIDICOMMAND: 'BIDI COMMAND', DATA: 'DATA', RESULT: 'RESULT', BIDIRESULT: 'BIDI RESULT' } const SERIALIZERS = [{ /** * display error stack */ matches: (err: unknown): err is Error => err instanceof Error, serialize: (err: Error) => err.stack }, { /** * color commands blue */ matches: (log: unknown): log is string => log === matches.COMMAND || log === matches.BIDICOMMAND, serialize: (log: string) => chalk.magenta(log) }, { /** * color data yellow */ matches: (log: unknown): log is string => log === matches.DATA, serialize: (log: string) => chalk.yellow(log) }, { /** * color result cyan */ matches: (log: unknown): log is string => log === matches.RESULT || log === matches.BIDIRESULT, serialize: (log: string) => chalk.cyan(log) }] interface LoggerInterface extends log.Logger { maskingPatterns: RegExp[] | undefined progress(...msg: string[]): void; } interface Loggers { [name: string]: LoggerInterface } const loggers: Loggers = log.getLoggers() as Loggers let logLevelsConfig: Record = {} let maskingPatternsConfig: Record = {} const logCache = new Set() let logFile: fs.WriteStream | null const originalFactory = log.methodFactory const wdioLoggerMethodFactory = (wdioLogger: LoggerInterface) => function (this: log.Logger, methodName: log.LogLevelNames, logLevel: log.LogLevelNumbers, loggerName: string) { const rawMethod = originalFactory(methodName, logLevel, loggerName) return (...args: [string, string?, ...unknown[]]) => { /** * create logFile lazily */ if (!logFile && process.env.WDIO_LOG_PATH) { logFile = fs.createWriteStream(process.env.WDIO_LOG_PATH) } /** * split `prefixer: value` string to `prefixer: ` and `value` * so that SERIALIZERS can match certain string */ const match = Object.values(matches).filter(x => args[0].endsWith(`: ${x}`))[0] if (match) { const prefixStr = (args.shift() as string[]).slice(0, -match.length - 1) args.unshift(prefixStr, match) } args = args.map((arg) => { for (const s of SERIALIZERS) { if (s.matches(arg)) { return s.serialize(arg as Error & string) } } return arg }) as [string, string?, ...unknown[]] const unmaskedLogText = ansiStrip(`${util.format.apply(this, args as [format: string, ...params: string[]])}\n`) const maskedLogText = mask(unmaskedLogText, wdioLogger.maskingPatterns) if (logFile && logFile.writable) { /** * empty logging cache if stuff got logged before */ if (logCache.size) { logCache.forEach((log) => { if (logFile) { logFile.write(log) } }) logCache.clear() } if (!logsContainInitPackageError(unmaskedLogText)) { return logFile.write(maskedLogText) } // If we get Error during init of integration packages, write logs to both "outputDir" and the terminal logFile.write(maskedLogText) } logCache.add(maskedLogText) // To not break console color formatting, we use the masked log text only when needed if (maskedLogText === unmaskedLogText) { rawMethod(...args) } else { rawMethod(maskedLogText.replace(/\n$/, '')) } } } const progress = function (this: Logger & { name: string }, data: string) { if (process.stdout.isTTY && this.getLevel() <= log.levels.INFO) { const level = 'progress' const timestampFormatter = chalk.gray(new Date().toISOString()) const levelFormatter = chalk[COLORS[level]](level.toUpperCase()) const nameFormatter = chalk.whiteBright(this.name) const _data = data.length > 0 ? `${timestampFormatter} ${levelFormatter} ${nameFormatter}: ${data}` : '\r\x1b[K\x1b[?25h' process.stdout.write('\u001B[?25l') // Disable cursor in terminal process.stdout.write(`${_data}\r`) } } export default function getLogger (name: string) { /** * check if logger was already initiated */ if (loggers[name]) { return loggers[name] } let logLevel: log.LogLevelDesc = (process.env.WDIO_LOG_LEVEL || DEFAULT_LEVEL) as log.LogLevelDesc const logLevelName = getLogLevelName(name) if (logLevelsConfig[logLevelName]) { logLevel = logLevelsConfig[logLevelName] } loggers[name] = log.getLogger(name) as LoggerInterface const logger = loggers[name] logger.setLevel(logLevel) logger.maskingPatterns = maskingPatternsConfig[name] ?? parseMaskingPatterns(process.env.WDIO_LOG_MASKING_PATTERNS) logger.progress = progress logger.methodFactory = wdioLoggerMethodFactory(logger) prefix.apply(logger, { template: '%t %l %n:', timestampFormatter: (date) => chalk.gray(date.toISOString()), levelFormatter: (level: string) => chalk[COLORS[level]](level.toUpperCase()), nameFormatter: (name) => chalk.whiteBright(name) }) return logger } /** * Wait for writable stream to be flushed. * Calling this prevents part of the logs in the very env to be lost. */ getLogger.waitForBuffer = async () => new Promise(resolve => { // @ts-ignore if (logFile && Array.isArray(logFile.writableBuffer) && logFile.writableBuffer.length !== 0) { return setTimeout(async () => { await getLogger.waitForBuffer() resolve() }, 20) } resolve() }) getLogger.setLevel = (name: string, level: log.LogLevelDesc) => loggers[name].setLevel(level) getLogger.clearLogger = () => { if (logFile) { logFile.end() } logFile = null } getLogger.setLogLevelsConfig = (logLevels: Record = {}, wdioLogLevel: log.LogLevelDesc = DEFAULT_LEVEL) => { /** * set log level */ if (process.env.WDIO_LOG_LEVEL === undefined) { process.env.WDIO_LOG_LEVEL = wdioLogLevel as string } logLevelsConfig = {} /** * build logLevelsConfig object */ Object.entries(logLevels).forEach(([logName, logLevel]) => { const logLevelName = getLogLevelName(logName) logLevelsConfig[logLevelName] = logLevel }) /** * set log level for each logger */ Object.keys(loggers).forEach(logName => { const logLevelName = getLogLevelName(logName) /** * either apply log level from logLevels object or use global logLevel */ const logLevel = typeof logLevelsConfig[logLevelName] !== 'undefined' ? logLevelsConfig[logLevelName] : process.env.WDIO_LOG_LEVEL as log.LogLevelDesc loggers[logName].setLevel(logLevel) }) } type MaskPattern = string type MaskPatternForSpecificLogger = Record getLogger.setMaskingPatterns = (pattern: MaskPattern | MaskPatternForSpecificLogger) => { if ( typeof pattern === 'string') { /** * set the environment variable for masking patterns */ if (process.env.WDIO_LOG_MASKING_PATTERNS === undefined) { process.env.WDIO_LOG_MASKING_PATTERNS = pattern } } else if ( typeof pattern === 'object') { /** * build maskingPatternsConfig object */ maskingPatternsConfig = Object.entries(pattern).reduce((acc, [logName, maskingPatternsString]) => { acc[logName] = parseMaskingPatterns(maskingPatternsString) return acc }, maskingPatternsConfig) } else { throw new Error(`Invalid pattern property, expected \`string\` or \`Record\` but received \`${typeof pattern}\``) } /** * set masking patterns for each logger */ Object.keys(loggers).forEach(logName => { /** * either apply maskingPatterns or use the global one */ const maskingPatterns = maskingPatternsConfig[logName] ?? parseMaskingPatterns(process.env.WDIO_LOG_MASKING_PATTERNS) loggers[logName].maskingPatterns = maskingPatterns }) } const getLogLevelName = (logName: string) => logName.split(':').shift() as log.LogLevelDesc export type Logger = LoggerInterface function logsContainInitPackageError(logText: string) { return ERROR_LOG_VALIDATOR.every(pattern => logText.includes(pattern)) } const ERROR_LOG_VALIDATOR = [ 'Couldn\'t find plugin', 'neither as wdio scoped package', 'nor as community package', 'Please make sure you have it installed' ]